llm-provider-catalog実装

This commit is contained in:
Keisuke Hirata 2026-04-23 15:37:51 +09:00
parent e21d2041ef
commit ccf1f2b6bf
6 changed files with 413 additions and 1 deletions

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"

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"]

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);
}
}

View File

@ -11,6 +11,7 @@
//! このクレートに追加する。
pub mod capability;
pub mod catalog;
pub mod codex_oauth;
use std::sync::Arc;

View File

@ -100,3 +100,8 @@ Ollama `/api/tags` を叩いてモデル一覧を動的に取る機構は欲し
- `crates/provider/src/`: カタログ読取 + `ProviderEntry` 型 + 変換関数を追加
- `crates/provider/assets/providers.toml`: 新設builtin カタログ)
- 他クレートに型は露出するが、既存 API に破壊的変更は入れない
## Review
- 状態: Approve
- レビュー詳細: [./llm-provider-catalog.review.md](./llm-provider-catalog.review.md)
- 日付: 2026-04-23

View File

@ -0,0 +1,60 @@
# Review: LLM プロバイダ/モデルカタログ
## 前提・要件の確認
### 要件 1: TOML 形式の定義(`[[provider]]` 配列、`auth_hint = { kind, env? }`
満たされている。
- `crates/provider/assets/providers.toml` の 4 エントリがチケット例(`tickets/llm-provider-catalog.md:15-46`と一字一句合致しているid / display_name / scheme / base_url / auth_hint.kind / env 名 / default_models の順序と値)。
- `AuthHint` 側は `#[serde(tag = "kind", rename_all = "snake_case")]` でインラインテーブル `{ kind = "api_key", env = "..." }` を受け付ける(`crates/provider/src/catalog.rs:44-56`)。`kind = "codex_oauth"` はデフォルトの `snake_case` 変換が `codex_o_auth` になってしまう問題を `#[serde(rename = "codex_oauth")]` で明示的に回避しており、既存 `AuthRef::CodexOAuth``crates/manifest/src/model.rs:70`)と同じ書き方になっている。整合している。
### 要件 2: `Vec<ProviderEntry>` を返す読取 API を `crates/provider` に 1 本公開
満たされている。
- `pub fn load() -> Result<Vec<ProviderEntry>, CatalogError>` が主 API`catalog.rs:108`)。補助として `load_builtin` / `load_from_path` も公開されており、後者はテストと将来の明示パス指定に合理的。
- 配置は `crates/provider` 直下(`lib.rs:14` で `pub mod catalog;`)。`llm-worker` に下りていない。Memory の「llm-worker は低レベル基盤に留める」方針に整合。
### 要件 3: 配置パスbuiltin の `include_str!` / user override 置換 / 両方読めなければ builtin
満たされている。
- `BUILTIN_CATALOG: &str = include_str!("../assets/providers.toml")``catalog.rs:18`)。
- user override は `$XDG_CONFIG_HOME/insomnia/providers.toml`、未設定時は `$HOME/.config/insomnia/providers.toml``catalog.rs:137-154`)。チケットでは `$HOME` フォールバックは明示されていないが、XDG Base Directory spec の既定挙動として妥当で、過剰とも言えない。
- マージではなく置換(`catalog.rs:108-115`)。`load_prefers_override_over_builtin` / `load_falls_back_to_builtin_when_override_absent` テストで両経路をカバー。
### 要件 4: `ProviderEntry` + `model_id``ModelConfig` の変換
満たされている。
- `ProviderEntry::to_model_config(&self, model_id: impl Into<String>) -> ModelConfig``catalog.rs:82-99`)が `AuthHint` の 3 バリアントを `AuthRef` に 1:1 で写す。`capability` は常に `None` で返し、`build_client` 側の 3 段階 fallback`ModelConfig.capability` → `capability::lookup``scheme default`、`lib.rs:120-124`)に委ねるのはチケット設計判断「`capability` 宣言をカタログに入れない」(`llm-provider-catalog.md:77-79`)と整合。
- `to_model_config_maps_auth_hint` テスト(`catalog.rs:208-229`)で 3 バリアント全部を確認。
### 要件 5: 完了時の動作builtin 取得 / override 置換 / UI 未実装段階で unit test e2e
満たされている。
- `ollama_entry_builds_client``catalog.rs:231-239`)が「カタログ読取 → `ProviderEntry` 選択 → `ModelConfig` 生成 → `build_client` が成功」の経路を端から端まで通している。Ollama は `AuthRef::None` なので環境変数依存がなく、常に成功する選択として適切。
- `cargo test -p provider` が 43 件全部 pass、ワークスペース `cargo build` もクリーン。
## アーキテクチャ・スコープ
- **レイヤ分離**: `crates/provider` 内に閉じ、`llm-worker` には漏れていない。`build_client` / `resolve_auth` の既存シグネチャは一切変更していない(`lib.rs:53-146` は今回の差分外)。非破壊性 OK。
- **責務分離**: `AuthHint`UI メタ)と `AuthRef`(ランタイム解決)を別型として定義し、`to_model_config` で 1:1 変換する設計は、チケット「設計判断: `auth_hint``AuthRef` の二重定義」(`llm-provider-catalog.md:73-75`の意図を正確に反映している。型を流用する代替案もあり得たが、UI ヒントに `file: Option<PathBuf>` が紛れ込むとカタログのスコープが広がるため、別型にしたのは妥当。
- **過剰抽象の有無**: `discover: Option<DiscoverMode>` のような先取り実装はされていない(コメントで言及のみ、`catalog.rs:60-61`。チケットの「auto_discover は別チケット」方針に従っている。
- **依存追加**: `toml` を dev-dependencies から regular dependencies に「移動」する形で `cargo add` 相当の編集がされている。手動編集ではあるが、既存行を move しただけなので Memory の `Use cargo add` 方針からの逸脱は軽微。
- **モジュール分割**: `pod.rs` 等の既存 primary ファイルに詰め込まず、`catalog.rs` として独立モジュール化している。Memory の「feature モジュール分割」方針に合致。
- **Scope 外の侵食**: UI / auto_discover / 編集 UI / プロファイルのどれにも触れていない。境界遵守。
## 指摘事項
### Non-blocking / Follow-up
- `$HOME` フォールバック(`catalog.rs:143-152`がチケット本文に明示されていない。妥当な挙動ではあるが、チケットの「配置パス」記述と実装の解像度に乖離があり、ユーザーが後から読んだとき驚きがある。ticket 側に一行追記するか、コード側のドキュメント(モジュール冒頭 docに「XDG 既定挙動として `$HOME/.config/...` にフォールバック」を明記しておくと親切。
### Nits
- `catalog.rs:106` の doc コメントに "sigh" というタイポがある("silent" の意図と思われる)。該当箇所:
```
/// 存在すれば builtin を置き換える。存在しなければ builtin のみ。
/// user override が存在するが壊れている場合はエラーを返すsilent
/// fallback はしない — ユーザーが書いた設定が sigh なく無視されて
/// builtin に戻る挙動は気付きにくいため)。
```
`sigh``silent` に直すか、日本語化で「気付かれず」等に差し替え。
- `builtin_ollama_shape``catalog.rs:173-183`)の assert が `scheme == Anthropic` を確認しているが、コメントの「Ollama = Anthropic scheme」の意図は `lib.rs:278-290``ollama_succeeds_without_key` と重複する情報。コメントで相互参照しておくと将来の読み手に親切。
## 判断
**Approve完了可** — チケット要件 1〜5 は全て明確に満たされており、`AuthHint`/`AuthRef` の責務分離・レイヤ境界・既存 API 非破壊といったアーキテクチャ観点も適切。指摘は `sigh` タイポと `$HOME` フォールバックのドキュメント明示の 2 点のみで、どちらも blocking ではない。