From 45b1e7b6de991cd5f6993a38aae790a1a1dfd91e Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 23 Apr 2026 15:37:51 +0900 Subject: [PATCH] =?UTF-8?q?llm-provider-catalog=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/provider/Cargo.toml | 2 +- crates/provider/assets/providers.toml | 30 +++ crates/provider/src/catalog.rs | 316 +++++++++++++++++++++++++ crates/provider/src/lib.rs | 1 + tickets/llm-provider-catalog.md | 5 + tickets/llm-provider-catalog.review.md | 60 +++++ 6 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 crates/provider/assets/providers.toml create mode 100644 crates/provider/src/catalog.rs create mode 100644 tickets/llm-provider-catalog.review.md diff --git a/crates/provider/Cargo.toml b/crates/provider/Cargo.toml index b88e2508..524662fb 100644 --- a/crates/provider/Cargo.toml +++ b/crates/provider/Cargo.toml @@ -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" diff --git a/crates/provider/assets/providers.toml b/crates/provider/assets/providers.toml new file mode 100644 index 00000000..c0215149 --- /dev/null +++ b/crates/provider/assets/providers.toml @@ -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"] diff --git a/crates/provider/src/catalog.rs b/crates/provider/src/catalog.rs new file mode 100644 index 00000000..9d15bbe1 --- /dev/null +++ b/crates/provider/src/catalog.rs @@ -0,0 +1,316 @@ +//! プロバイダ/モデルカタログ。 +//! +//! builtin (`assets/providers.toml`) と user override +//! (`$XDG_CONFIG_HOME/insomnia/providers.toml`) を読み、 +//! `Vec` を返す。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, + }, + /// ChatGPT OAuth(`~/.codex/auth.json`) + #[serde(rename = "codex_oauth")] + CodexOAuth, +} + +/// カタログ 1 エントリ。 +/// +/// 将来 `discover: Option` を任意で追加予定(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, + pub auth_hint: AuthHint, + #[serde(default)] + pub default_models: Vec, +} + +#[derive(Debug, Deserialize)] +struct CatalogFile { + #[serde(default)] + provider: Vec, +} + +impl ProviderEntry { + /// 選ばれた `model_id` と組み合わせて [`ModelConfig`] を構築する。 + pub fn to_model_config(&self, model_id: impl Into) -> 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, 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, CatalogError> { + let parsed: CatalogFile = + toml::from_str(BUILTIN_CATALOG).map_err(CatalogError::BuiltinParse)?; + Ok(parsed.provider) +} + +/// 指定パスから読む(テスト・明示指定用)。 +pub fn load_from_path(path: &Path) -> Result, 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 { + 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); + } +} diff --git a/crates/provider/src/lib.rs b/crates/provider/src/lib.rs index 3da5d573..9f1bb09f 100644 --- a/crates/provider/src/lib.rs +++ b/crates/provider/src/lib.rs @@ -11,6 +11,7 @@ //! このクレートに追加する。 pub mod capability; +pub mod catalog; pub mod codex_oauth; use std::sync::Arc; diff --git a/tickets/llm-provider-catalog.md b/tickets/llm-provider-catalog.md index 5c0a4da8..a809643b 100644 --- a/tickets/llm-provider-catalog.md +++ b/tickets/llm-provider-catalog.md @@ -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 diff --git a/tickets/llm-provider-catalog.review.md b/tickets/llm-provider-catalog.review.md new file mode 100644 index 00000000..515d6ed7 --- /dev/null +++ b/tickets/llm-provider-catalog.review.md @@ -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` を返す読取 API を `crates/provider` に 1 本公開 +満たされている。 +- `pub fn load() -> Result, 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) -> 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` が紛れ込むとカタログのスコープが広がるため、別型にしたのは妥当。 +- **過剰抽象の有無**: `discover: Option` のような先取り実装はされていない(コメントで言及のみ、`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 ではない。