llm-provider-catalog実装

This commit is contained in:
2026-04-23 15:37:51 +09:00
parent e21d2041ef
commit ccf1f2b6bf
6 changed files with 413 additions and 1 deletions
+1 -1
View File
@@ -15,10 +15,10 @@ serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
thiserror = "2.0"
tokio = { version = "1.52.1", features = ["sync", "fs", "rt"] }
toml = "1.1.2"
tracing = "0.1.44"
[dev-dependencies]
serial_test = "3.4.0"
tempfile = "3.27.0"
toml = "1.1.2"
wiremock = "0.6.5"
+30
View File
@@ -0,0 +1,30 @@
[[provider]]
id = "anthropic"
display_name = "Anthropic"
scheme = "anthropic"
base_url = "https://api.anthropic.com"
auth_hint = { kind = "api_key", env = "INSOMNIA_API_KEY_ANTHROPIC" }
default_models = ["claude-sonnet-4-5", "claude-opus-4-1"]
[[provider]]
id = "ollama-local"
display_name = "Ollama (local)"
scheme = "anthropic"
base_url = "http://localhost:11434"
auth_hint = { kind = "none" }
default_models = ["llama3.1", "qwen2.5-coder"]
[[provider]]
id = "codex-oauth"
display_name = "ChatGPT (Codex OAuth)"
scheme = "openai_responses"
auth_hint = { kind = "codex_oauth" }
default_models = ["gpt-5-codex", "gpt-5"]
[[provider]]
id = "openrouter"
display_name = "OpenRouter"
scheme = "openai_chat"
base_url = "https://openrouter.ai/api/v1"
auth_hint = { kind = "api_key", env = "INSOMNIA_API_KEY_OPENROUTER" }
default_models = ["anthropic/claude-sonnet-4", "openai/gpt-5"]
+316
View File
@@ -0,0 +1,316 @@
//! プロバイダ/モデルカタログ。
//!
//! builtin (`assets/providers.toml`) と user override
//! (`$XDG_CONFIG_HOME/insomnia/providers.toml`) を読み、
//! `Vec<ProviderEntry>` を返す。user override がある場合は builtin を
//! 置き換える(マージしない)。
//!
//! `ProviderEntry` から [`ModelConfig`] への変換は
//! [`ProviderEntry::to_model_config`] で行う。`auth_hint` はここでは
//! UI 表示用のヒントで、実際の認証解決は従来通り [`crate::build_client`]
//! が `AuthRef` から行う。
use std::path::{Path, PathBuf};
use manifest::{AuthRef, ModelConfig, SchemeKind};
use serde::{Deserialize, Serialize};
const BUILTIN_CATALOG: &str = include_str!("../assets/providers.toml");
#[derive(Debug, thiserror::Error)]
pub enum CatalogError {
#[error("failed to read catalog at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse catalog at {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("failed to parse builtin catalog: {0}")]
BuiltinParse(#[source] toml::de::Error),
}
/// UI 向けの認証ヒント。
///
/// 「何を表示・要求するか」のメタ情報で、ランタイムの [`AuthRef`]
/// とは責務が別。1:1 の対応関係にあり、
/// [`ProviderEntry::to_model_config`] で相互変換される。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AuthHint {
/// 認証不要(ローカル Ollama 等)
None,
/// API key。`env` が指定されていれば UI はその env 名を提示する
ApiKey {
#[serde(default)]
env: Option<String>,
},
/// ChatGPT OAuth`~/.codex/auth.json`
#[serde(rename = "codex_oauth")]
CodexOAuth,
}
/// カタログ 1 エントリ。
///
/// 将来 `discover: Option<DiscoverMode>` を任意で追加予定(Ollama
/// `/api/tags` 等の動的モデル列挙)。別チケットで実装する。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProviderEntry {
pub id: String,
pub display_name: String,
pub scheme: SchemeKind,
#[serde(default)]
pub base_url: Option<String>,
pub auth_hint: AuthHint,
#[serde(default)]
pub default_models: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct CatalogFile {
#[serde(default)]
provider: Vec<ProviderEntry>,
}
impl ProviderEntry {
/// 選ばれた `model_id` と組み合わせて [`ModelConfig`] を構築する。
pub fn to_model_config(&self, model_id: impl Into<String>) -> ModelConfig {
let auth = match &self.auth_hint {
AuthHint::None => AuthRef::None,
AuthHint::ApiKey { env } => AuthRef::ApiKey {
env: env.clone(),
file: None,
},
AuthHint::CodexOAuth => AuthRef::CodexOAuth,
};
ModelConfig {
scheme: self.scheme,
base_url: self.base_url.clone(),
model_id: model_id.into(),
auth,
capability: None,
}
}
}
/// builtin + user override を解決してカタログを返す。
///
/// user override (`$XDG_CONFIG_HOME/insomnia/providers.toml`) が
/// 存在すれば builtin を置き換える。存在しなければ builtin のみ。
/// user override が存在するが壊れている場合はエラーを返す(silent
/// fallback はしない — ユーザーが書いた設定が silent に無視されて
/// builtin に戻る挙動は気付きにくいため)。
pub fn load() -> Result<Vec<ProviderEntry>, CatalogError> {
if let Some(path) = user_override_path()
&& path.is_file()
{
return load_from_path(&path);
}
load_builtin()
}
/// builtin カタログ (`assets/providers.toml`) のみを返す。
pub fn load_builtin() -> Result<Vec<ProviderEntry>, CatalogError> {
let parsed: CatalogFile =
toml::from_str(BUILTIN_CATALOG).map_err(CatalogError::BuiltinParse)?;
Ok(parsed.provider)
}
/// 指定パスから読む(テスト・明示指定用)。
pub fn load_from_path(path: &Path) -> Result<Vec<ProviderEntry>, CatalogError> {
let text = std::fs::read_to_string(path).map_err(|source| CatalogError::Io {
path: path.to_path_buf(),
source,
})?;
let parsed: CatalogFile = toml::from_str(&text).map_err(|source| CatalogError::Parse {
path: path.to_path_buf(),
source,
})?;
Ok(parsed.provider)
}
fn user_override_path() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("XDG_CONFIG_HOME")
&& !dir.is_empty()
{
return Some(PathBuf::from(dir).join("insomnia").join("providers.toml"));
}
if let Ok(home) = std::env::var("HOME")
&& !home.is_empty()
{
return Some(
PathBuf::from(home)
.join(".config")
.join("insomnia")
.join("providers.toml"),
);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::build_client;
use serial_test::serial;
#[test]
fn builtin_has_four_entries() {
let entries = load_builtin().unwrap();
let ids: Vec<&str> = entries.iter().map(|e| e.id.as_str()).collect();
assert_eq!(
ids,
vec!["anthropic", "ollama-local", "codex-oauth", "openrouter"]
);
}
#[test]
fn builtin_ollama_shape() {
let entries = load_builtin().unwrap();
let ollama = entries.iter().find(|e| e.id == "ollama-local").unwrap();
assert_eq!(ollama.scheme, SchemeKind::Anthropic);
assert_eq!(
ollama.base_url.as_deref(),
Some("http://localhost:11434")
);
assert_eq!(ollama.auth_hint, AuthHint::None);
assert!(!ollama.default_models.is_empty());
}
#[test]
fn builtin_codex_oauth_shape() {
let entries = load_builtin().unwrap();
let codex = entries.iter().find(|e| e.id == "codex-oauth").unwrap();
assert_eq!(codex.scheme, SchemeKind::OpenaiResponses);
assert_eq!(codex.auth_hint, AuthHint::CodexOAuth);
// base_url 未指定 → Codex OAuth のデフォルト backend に解決される
assert!(codex.base_url.is_none());
}
#[test]
fn builtin_openrouter_uses_explicit_env() {
let entries = load_builtin().unwrap();
let router = entries.iter().find(|e| e.id == "openrouter").unwrap();
match &router.auth_hint {
AuthHint::ApiKey { env } => {
assert_eq!(env.as_deref(), Some("INSOMNIA_API_KEY_OPENROUTER"));
}
_ => panic!("openrouter should use ApiKey hint"),
}
}
#[test]
fn to_model_config_maps_auth_hint() {
let entries = load_builtin().unwrap();
let ollama = entries.iter().find(|e| e.id == "ollama-local").unwrap();
let cfg = ollama.to_model_config("llama3");
assert_eq!(cfg.auth, AuthRef::None);
assert_eq!(cfg.model_id, "llama3");
let router = entries.iter().find(|e| e.id == "openrouter").unwrap();
let cfg = router.to_model_config("openai/gpt-5");
match cfg.auth {
AuthRef::ApiKey { env, file } => {
assert_eq!(env.as_deref(), Some("INSOMNIA_API_KEY_OPENROUTER"));
assert!(file.is_none());
}
_ => panic!("expected ApiKey"),
}
let codex = entries.iter().find(|e| e.id == "codex-oauth").unwrap();
let cfg = codex.to_model_config("gpt-5");
assert_eq!(cfg.auth, AuthRef::CodexOAuth);
}
#[test]
fn ollama_entry_builds_client() {
// カタログ読取 → ProviderEntry 選択 → ModelConfig 生成 →
// build_client が成功する end-to-end path。
let entries = load_builtin().unwrap();
let ollama = entries.iter().find(|e| e.id == "ollama-local").unwrap();
let cfg = ollama.to_model_config("llama3");
assert!(build_client(&cfg).is_ok());
}
#[test]
fn load_from_path_reads_override() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("providers.toml");
std::fs::write(
&path,
r#"
[[provider]]
id = "custom"
display_name = "Custom"
scheme = "anthropic"
base_url = "http://example.com"
auth_hint = { kind = "none" }
default_models = ["model-x"]
"#,
)
.unwrap();
let entries = load_from_path(&path).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "custom");
}
#[test]
fn malformed_override_returns_parse_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("providers.toml");
std::fs::write(&path, "this is not valid ][ toml").unwrap();
let err = load_from_path(&path).unwrap_err();
assert!(matches!(err, CatalogError::Parse { .. }));
}
#[test]
#[serial]
fn load_prefers_override_over_builtin() {
let dir = tempfile::tempdir().unwrap();
let insomnia_dir = dir.path().join("insomnia");
std::fs::create_dir_all(&insomnia_dir).unwrap();
std::fs::write(
insomnia_dir.join("providers.toml"),
r#"
[[provider]]
id = "only-one"
display_name = "Only"
scheme = "anthropic"
auth_hint = { kind = "none" }
"#,
)
.unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe { std::env::set_var("XDG_CONFIG_HOME", dir.path()) };
let entries = load().unwrap();
match prev_xdg {
Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) },
None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
}
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "only-one");
}
#[test]
#[serial]
fn load_falls_back_to_builtin_when_override_absent() {
let dir = tempfile::tempdir().unwrap();
// override ファイルは作らない
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe { std::env::set_var("XDG_CONFIG_HOME", dir.path()) };
let entries = load().unwrap();
match prev_xdg {
Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) },
None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
}
assert_eq!(entries.len(), 4);
}
}
+1
View File
@@ -11,6 +11,7 @@
//! このクレートに追加する。
pub mod capability;
pub mod catalog;
pub mod codex_oauth;
use std::sync::Arc;