From 47da4a03cb7ff2ebed1f34ea30531217c8134507 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 21 Apr 2026 18:35:56 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=A2=E3=83=87=E3=83=AB=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E3=81=AE=E3=83=8F=E3=83=BC=E3=83=89=E3=82=B3=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=82=92=E6=B6=88=E3=81=97=E9=A3=9B=E3=81=97=E3=80=81Codex?= =?UTF-8?q?=E3=81=AE=E3=83=95=E3=82=A9=E3=83=BC=E3=83=9E=E3=83=83=E3=83=88?= =?UTF-8?q?=E3=81=AE=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent-memory/ticket-reviewer/MEMORY.md | 1 + .../feedback_out_of_scope_mixing.md | 17 ++ AGENTS.md | 2 + CLAUDE.md | 2 + TODO.md | 2 + .../examples/record_test_fixtures/main.rs | 19 +- .../llm-worker/examples/worker_cancel_demo.rs | 11 +- crates/llm-worker/examples/worker_cli.rs | 4 +- .../llm_client/scheme/anthropic/capability.rs | 29 +-- .../scheme/anthropic/scheme_impl.rs | 4 - .../llm_client/scheme/gemini/capability.rs | 31 +-- .../llm_client/scheme/gemini/scheme_impl.rs | 4 - .../llm-worker/src/llm_client/scheme/mod.rs | 11 +- .../scheme/openai_chat/capability.rs | 73 +----- .../scheme/openai_chat/scheme_impl.rs | 4 - .../scheme/openai_responses/capability.rs | 72 +----- .../scheme/openai_responses/request.rs | 59 ++++- .../scheme/openai_responses/scheme_impl.rs | 11 +- crates/manifest/src/model.rs | 1 + crates/provider/src/capability.rs | 211 ++++++++++++++++++ crates/provider/src/codex_oauth/mod.rs | 14 +- crates/provider/src/lib.rs | 16 +- tickets/llm-capability-ownership.md | 79 +++++++ tickets/llm-capability-ownership.review.md | 95 ++++++++ tickets/llm-provider-catalog.md | 102 +++++++++ 25 files changed, 628 insertions(+), 246 deletions(-) create mode 100644 .claude/agent-memory/ticket-reviewer/feedback_out_of_scope_mixing.md create mode 100644 crates/provider/src/capability.rs create mode 100644 tickets/llm-capability-ownership.md create mode 100644 tickets/llm-capability-ownership.review.md create mode 100644 tickets/llm-provider-catalog.md diff --git a/.claude/agent-memory/ticket-reviewer/MEMORY.md b/.claude/agent-memory/ticket-reviewer/MEMORY.md index c20066f5..0e50694f 100644 --- a/.claude/agent-memory/ticket-reviewer/MEMORY.md +++ b/.claude/agent-memory/ticket-reviewer/MEMORY.md @@ -1,3 +1,4 @@ - [Event broadcast pattern](project_event_broadcast_pattern.md) — Pod は event_tx: Option> を保持、Controller が attach_notifier と同タイミングで attach - [Test-path omission precedent](feedback_test_path_omission.md) — 要件に挙がったテストを「共通ヘルパ経由だから省略」した場合は Approve with follow-up が相場 - [cargo add workspace pitfall](feedback_cargo_add_workspace_pitfall.md) — ルート Cargo.toml に [workspace.dependencies] が未定義、workspace = true 指定は現状使えない +- [Out-of-scope diff mixing](feedback_out_of_scope_mixing.md) — スコープ外修正が ticket diff に同居 → [major] Non-blocking で指摘、コミット分割推奨、総合は Approve diff --git a/.claude/agent-memory/ticket-reviewer/feedback_out_of_scope_mixing.md b/.claude/agent-memory/ticket-reviewer/feedback_out_of_scope_mixing.md new file mode 100644 index 00000000..cb215dc0 --- /dev/null +++ b/.claude/agent-memory/ticket-reviewer/feedback_out_of_scope_mixing.md @@ -0,0 +1,17 @@ +--- +name: Out-of-scope diff mixed into ticket +description: 本チケットのスコープ外修正が同じ diff/作業ツリーに同居していた場合のレビュー判定ルール +type: feedback +--- + +ticket の実装 diff にスコープ外の修正(別の疎通バグ fix、別レイヤの API 調整等)が同居している状況では、**major 扱いの Non-blocking**(= Approve 可、ただし follow-up 指摘)で扱うのが precedent。 + +**Why:** ユーザー自身が「別コミット候補」と認識した上で差分提示してくるケースが複数回ある。コミット分割はユーザーの git 操作領域(CLAUDE.md: Git はユーザー責務)なので、reviewer 側は**コミット分割を推奨する**指摘に留める。blocker にはしない。 + +**How to apply:** +- review.md の Non-blocking セクションで「スコープ外 diff が同居している」項目を [major] で立て、該当ファイルと何が本筋外かを列挙する。 +- 「本チケットの review は X 単体の妥当性判定に留め、スコープ外修正の可否まで巻き込まない」ことを明記。 +- 本筋(チケット要件)が満たされていれば総合判定は Approve で良い。 +- 挙動保存の確認は本筋と同時に行うが、スコープ外変更の影響で疎通確認が混同しないか(どの変更が疎通パスした根拠か)を review.md に一言書く。 + +precedent: `tickets/llm-capability-ownership.review.md` (2026-04-21) diff --git a/AGENTS.md b/AGENTS.md index afd2893a..3f050f28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ Gitは基本的にすべてユーザーが操作している。書き込みが必要な操作は明示的に許可されない限り行わないこと +外部の参考プロジェクトはexternal checkoutでローカルでReadする運用をしている。 + --- `TODO.md`、`tickets/`はgitで管理されていて、時系列の管理はgitを参照して把握すること。 diff --git a/CLAUDE.md b/CLAUDE.md index afd2893a..3f050f28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,8 @@ Gitは基本的にすべてユーザーが操作している。書き込みが必要な操作は明示的に許可されない限り行わないこと +外部の参考プロジェクトはexternal checkoutでローカルでReadする運用をしている。 + --- `TODO.md`、`tickets/`はgitで管理されていて、時系列の管理はgitを参照して把握すること。 diff --git a/TODO.md b/TODO.md index 4988900e..bf0bc77b 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,8 @@ - [ ] ツール設計 - [ ] Bash ツール (Permission 層と統合) → [tickets/bash-tool.md](tickets/bash-tool.md) - [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md) +- [ ] LLM プロバイダ/モデルカタログ → [tickets/llm-provider-catalog.md](tickets/llm-provider-catalog.md) +- [ ] モデル capability の責務を llm-worker 外へ → [tickets/llm-capability-ownership.md](tickets/llm-capability-ownership.md) - [ ] Pod オーケストレーション - [ ] 動的 Scope 変更 → [tickets/dynamic-scope.md](tickets/dynamic-scope.md) - [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md) diff --git a/crates/llm-worker/examples/record_test_fixtures/main.rs b/crates/llm-worker/examples/record_test_fixtures/main.rs index e9d0faef..1ae80e1c 100644 --- a/crates/llm-worker/examples/record_test_fixtures/main.rs +++ b/crates/llm-worker/examples/record_test_fixtures/main.rs @@ -20,32 +20,17 @@ mod recorder; mod scenarios; use clap::{Parser, ValueEnum}; -use llm_worker::llm_client::capability::{ - CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport, -}; use llm_worker::llm_client::scheme::{ Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme, }; use llm_worker::llm_client::transport::{HttpTransport, ResolvedAuth}; -/// 既定の capability: fixture 記録には cache_control を付けない -/// (既知モデルの静的テーブルを経由すると scheme 毎に自動設定される)。 -fn fallback_capability() -> ModelCapability { - ModelCapability { - tool_calling: ToolCallingSupport::Parallel, - structured_output: StructuredOutput::JsonSchema, - reasoning: None, - vision: false, - prompt_caching: CacheStrategy::Auto, - } -} - fn make_transport( scheme: S, model: &str, auth: ResolvedAuth, ) -> HttpTransport { - let cap = scheme.capability_for(model).unwrap_or_else(fallback_capability); + let cap = scheme.default_capability(); let base_url = scheme.default_base_url().to_string(); HttpTransport::new(scheme, model.to_string(), base_url, auth, cap) } @@ -138,7 +123,7 @@ async fn run_scenario_with_ollama( model.to_string(), "http://localhost:11434".to_string(), ResolvedAuth::None, - fallback_capability(), + AnthropicScheme::new().default_capability(), ); recorder::record_request( diff --git a/crates/llm-worker/examples/worker_cancel_demo.rs b/crates/llm-worker/examples/worker_cancel_demo.rs index feea838d..1983f555 100644 --- a/crates/llm-worker/examples/worker_cancel_demo.rs +++ b/crates/llm-worker/examples/worker_cancel_demo.rs @@ -2,9 +2,6 @@ //! //! Example of cancelling from another thread during streaming -use llm_worker::llm_client::capability::{ - CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport, -}; use llm_worker::llm_client::scheme::{Scheme, anthropic::AnthropicScheme}; use llm_worker::llm_client::transport::{HttpTransport, ResolvedAuth}; use llm_worker::{Worker, WorkerResult}; @@ -28,13 +25,7 @@ async fn main() -> Result<(), Box> { let scheme = AnthropicScheme::new(); let model = "claude-sonnet-4-20250514".to_string(); - let cap = scheme.capability_for(&model).unwrap_or(ModelCapability { - tool_calling: ToolCallingSupport::Parallel, - structured_output: StructuredOutput::JsonSchema, - reasoning: None, - vision: false, - prompt_caching: CacheStrategy::Auto, - }); + let cap = scheme.default_capability(); let base_url = scheme.default_base_url().to_string(); let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap); let worker = Worker::new(client); diff --git a/crates/llm-worker/examples/worker_cli.rs b/crates/llm-worker/examples/worker_cli.rs index 2c7c25bd..3b46d15f 100644 --- a/crates/llm-worker/examples/worker_cli.rs +++ b/crates/llm-worker/examples/worker_cli.rs @@ -343,9 +343,7 @@ fn build_transport( model: String, auth: ResolvedAuth, ) -> Box { - let cap = scheme - .capability_for(&model) - .unwrap_or_else(default_capability); + let cap = scheme.default_capability(); let base_url = scheme.default_base_url().to_string(); Box::new(HttpTransport::new(scheme, model, base_url, auth, cap)) } diff --git a/crates/llm-worker/src/llm_client/scheme/anthropic/capability.rs b/crates/llm-worker/src/llm_client/scheme/anthropic/capability.rs index 51b34897..ccfdc524 100644 --- a/crates/llm-worker/src/llm_client/scheme/anthropic/capability.rs +++ b/crates/llm-worker/src/llm_client/scheme/anthropic/capability.rs @@ -1,34 +1,17 @@ -//! `model_id → ModelCapability` 静的テーブル。 +//! Anthropic scheme の wire-level 既定 capability。 //! -//! 既知モデルのみ網羅する。未知モデルは `None` を返し、呼び出し側 -//! (`HttpTransport` 構築時)に scheme 既定へフォールバックさせる。 +//! モデル ID 固有のテーブル(`claude-*` など)は高レベル構築層 +//! (`provider::capability`)の責務。ここでは未知モデルでも「この wire で +//! 安全に送れる最小共通項」を返すだけに留める。 use crate::llm_client::capability::{ - CacheStrategy, ModelCapability, ReasoningSupport, StructuredOutput, ToolCallingSupport, + CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport, }; -/// Anthropic 公式モデルの既定 capability。 -/// -/// `claude-sonnet-*` / `claude-opus-*` / `claude-haiku-*` に対応する。 -/// `cache_control` は公式のみ有効で、最大 4 breakpoint(公式仕様)。 -pub(crate) fn lookup(model_id: &str) -> Option { - if !model_id.starts_with("claude-") { - return None; - } - Some(ModelCapability { - tool_calling: ToolCallingSupport::Parallel, - structured_output: StructuredOutput::JsonSchema, - reasoning: Some(ReasoningSupport::BudgetTokens), - vision: true, - prompt_caching: CacheStrategy::Explicit { max_breakpoints: 4 }, - }) -} - /// Scheme 既定の capability。 /// /// Ollama の `/v1/messages` 流用を想定して `cache_control` を送らない -/// `CacheStrategy::Auto` にする。Anthropic 本家の未知モデル(新 Claude) -/// も tool_calling / vision を備える想定で Parallel / true を返す。 +/// `CacheStrategy::Auto` にする。 pub(crate) fn default_capability() -> ModelCapability { ModelCapability { tool_calling: ToolCallingSupport::Parallel, diff --git a/crates/llm-worker/src/llm_client/scheme/anthropic/scheme_impl.rs b/crates/llm-worker/src/llm_client/scheme/anthropic/scheme_impl.rs index 0ddef053..6e260e59 100644 --- a/crates/llm-worker/src/llm_client/scheme/anthropic/scheme_impl.rs +++ b/crates/llm-worker/src/llm_client/scheme/anthropic/scheme_impl.rs @@ -93,10 +93,6 @@ impl Scheme for AnthropicScheme { Ok(vec![event]) } - fn capability_for(&self, model_id: &str) -> Option { - super::capability::lookup(model_id) - } - fn default_capability(&self) -> ModelCapability { super::capability::default_capability() } diff --git a/crates/llm-worker/src/llm_client/scheme/gemini/capability.rs b/crates/llm-worker/src/llm_client/scheme/gemini/capability.rs index 38d8e043..315cf06e 100644 --- a/crates/llm-worker/src/llm_client/scheme/gemini/capability.rs +++ b/crates/llm-worker/src/llm_client/scheme/gemini/capability.rs @@ -1,10 +1,14 @@ -//! `model_id → ModelCapability` 静的テーブル(Google Gemini)。 +//! Gemini scheme の wire-level 既定 capability。 +//! +//! モデル ID 固有のテーブル(`gemini-*` バージョン別の reasoning 有無)は +//! 高レベル構築層(`provider::capability`)の責務。ここでは wire の +//! 保守的 default のみ。 use crate::llm_client::capability::{ - CacheStrategy, ModelCapability, ReasoningSupport, StructuredOutput, ToolCallingSupport, + CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport, }; -/// Scheme 既定の capability(未知モデル / 未明示モデル用)。 +/// Scheme 既定の capability(未知モデル / 未明示モデル用)。 pub(crate) fn default_capability() -> ModelCapability { ModelCapability { tool_calling: ToolCallingSupport::Parallel, @@ -14,24 +18,3 @@ pub(crate) fn default_capability() -> ModelCapability { prompt_caching: CacheStrategy::Auto, } } - -pub(crate) fn lookup(model_id: &str) -> Option { - if !model_id.starts_with("gemini-") { - return None; - } - // 2.5 系以降は thinking / reasoning を持つ - let reasoning = if model_id.starts_with("gemini-2.5") - || model_id.starts_with("gemini-3") - { - Some(ReasoningSupport::BudgetTokens) - } else { - None - }; - Some(ModelCapability { - tool_calling: ToolCallingSupport::Parallel, - structured_output: StructuredOutput::JsonSchema, - reasoning, - vision: true, - prompt_caching: CacheStrategy::Auto, - }) -} diff --git a/crates/llm-worker/src/llm_client/scheme/gemini/scheme_impl.rs b/crates/llm-worker/src/llm_client/scheme/gemini/scheme_impl.rs index ae9831c0..f50db4dd 100644 --- a/crates/llm-worker/src/llm_client/scheme/gemini/scheme_impl.rs +++ b/crates/llm-worker/src/llm_client/scheme/gemini/scheme_impl.rs @@ -47,10 +47,6 @@ impl Scheme for GeminiScheme { Ok(self.parse_event(data)?.unwrap_or_default()) } - fn capability_for(&self, model_id: &str) -> Option { - super::capability::lookup(model_id) - } - fn default_capability(&self) -> ModelCapability { super::capability::default_capability() } diff --git a/crates/llm-worker/src/llm_client/scheme/mod.rs b/crates/llm-worker/src/llm_client/scheme/mod.rs index cc794b3f..2afd010b 100644 --- a/crates/llm-worker/src/llm_client/scheme/mod.rs +++ b/crates/llm-worker/src/llm_client/scheme/mod.rs @@ -76,13 +76,10 @@ pub trait Scheme: Clone + Send + Sync + 'static { state: &mut Self::State, ) -> Result, ClientError>; - /// 既知モデル ID の能力テーブル引き。未知なら `None` を返す - /// ので、呼び出し側は [`Scheme::default_capability`] に - /// フォールバックする。 - fn capability_for(&self, model_id: &str) -> Option; - - /// scheme 既定の capability。未知モデル ID や未明示モデルでの - /// フォールバックに使う。`capability_for` と違って必ず値を返す。 + /// scheme 既定の capability。モデル ID に関係なく、この wire で + /// 安全に送れる最小共通項を返す。既知モデル ID の能力テーブルは + /// `provider::capability::lookup` 側(高レベル構築層)の責務で、 + /// scheme はここには関与しない。 fn default_capability(&self) -> ModelCapability; /// scheme 側でサポートしていない `RequestConfig` フィールドを diff --git a/crates/llm-worker/src/llm_client/scheme/openai_chat/capability.rs b/crates/llm-worker/src/llm_client/scheme/openai_chat/capability.rs index 5fa5b94e..b89784bc 100644 --- a/crates/llm-worker/src/llm_client/scheme/openai_chat/capability.rs +++ b/crates/llm-worker/src/llm_client/scheme/openai_chat/capability.rs @@ -1,76 +1,13 @@ -//! `model_id → ModelCapability` 静的テーブル(OpenAI Chat Completions)。 +//! OpenAI Chat Completions scheme の wire-level 既定 capability。 //! -//! OpenAI 本家の主要モデルのみ網羅する。OpenRouter / xAI / Groq 等は -//! モデル ID が各社独自なので、マニフェスト側で明示 override する -//! 前提。 -//! -//! [`classify`] はモデル ID から family を判定する一次情報で、 -//! `scheme/openai_responses` からも参照される。 +//! モデル ID 固有のテーブル(`gpt-5` 系など)は高レベル構築層 +//! (`provider::capability`)の責務。ここでは wire の保守的 default のみ。 use crate::llm_client::capability::{ - CacheStrategy, ModelCapability, ReasoningSupport, StructuredOutput, ToolCallingSupport, + CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport, }; -/// OpenAI 本家のモデル family 分類。 -/// -/// `openai_chat` と `openai_responses` で共有する一次情報。各 scheme は -/// この分類に自 scheme 固有の `ReasoningSupport` 等を当てはめて -/// `ModelCapability` を組み立てる。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum OpenAiFamily { - /// GPT-5 / o1 / o3 / o4 系 — reasoning 対応 - Reasoning, - /// GPT-4o / GPT-4 系 - Gpt4, - /// GPT-3.5 系(旧式) - Gpt35, -} - -/// モデル ID の prefix から family を判定する。未知は `None`。 -pub(crate) fn classify(model_id: &str) -> Option { - if model_id.starts_with("gpt-5") - || model_id.starts_with("o1") - || model_id.starts_with("o3") - || model_id.starts_with("o4") - { - return Some(OpenAiFamily::Reasoning); - } - if model_id.starts_with("gpt-4") { - return Some(OpenAiFamily::Gpt4); - } - if model_id.starts_with("gpt-3.5") { - return Some(OpenAiFamily::Gpt35); - } - None -} - -pub(crate) fn lookup(model_id: &str) -> Option { - classify(model_id).map(|family| match family { - OpenAiFamily::Reasoning => ModelCapability { - tool_calling: ToolCallingSupport::Parallel, - structured_output: StructuredOutput::JsonSchema, - reasoning: Some(ReasoningSupport::Effort), - vision: true, - prompt_caching: CacheStrategy::Auto, - }, - OpenAiFamily::Gpt4 => ModelCapability { - tool_calling: ToolCallingSupport::Parallel, - structured_output: StructuredOutput::JsonSchema, - reasoning: None, - vision: true, - prompt_caching: CacheStrategy::Auto, - }, - OpenAiFamily::Gpt35 => ModelCapability { - tool_calling: ToolCallingSupport::Parallel, - structured_output: StructuredOutput::JsonObject, - reasoning: None, - vision: false, - prompt_caching: CacheStrategy::Auto, - }, - }) -} - -/// Scheme 既定の capability。OpenAI 互換ルーター系(xAI / Groq / OpenRouter 等) +/// Scheme 既定の capability。OpenAI 互換ルーター系(xAI / Groq / OpenRouter 等) /// で未知モデル ID を受けたときのフォールバックに使う。 pub(crate) fn default_capability() -> ModelCapability { ModelCapability { diff --git a/crates/llm-worker/src/llm_client/scheme/openai_chat/scheme_impl.rs b/crates/llm-worker/src/llm_client/scheme/openai_chat/scheme_impl.rs index 2e9e2f94..87c1ef78 100644 --- a/crates/llm-worker/src/llm_client/scheme/openai_chat/scheme_impl.rs +++ b/crates/llm-worker/src/llm_client/scheme/openai_chat/scheme_impl.rs @@ -52,10 +52,6 @@ impl Scheme for OpenAIScheme { Ok(self.parse_event(data)?.unwrap_or_default()) } - fn capability_for(&self, model_id: &str) -> Option { - super::capability::lookup(model_id) - } - fn default_capability(&self) -> ModelCapability { super::capability::default_capability() } diff --git a/crates/llm-worker/src/llm_client/scheme/openai_responses/capability.rs b/crates/llm-worker/src/llm_client/scheme/openai_responses/capability.rs index 8bdd509c..d6257163 100644 --- a/crates/llm-worker/src/llm_client/scheme/openai_responses/capability.rs +++ b/crates/llm-worker/src/llm_client/scheme/openai_responses/capability.rs @@ -1,75 +1,11 @@ -//! `model_id → ModelCapability` 静的テーブル(OpenAI Responses API)。 +//! OpenAI Responses scheme の wire-level 既定 capability。 //! -//! モデル family 判定は `scheme/openai_chat/capability.rs::classify` を -//! 共有する。Responses 側は `ReasoningSupport::Effort` 固定で、prompt -//! caching はサーバ側自動(`CacheStrategy::Auto`)。 -//! -//! `gpt-5-codex` は `gpt-5` prefix 経由で Reasoning 扱いされるが、 -//! `codex-mini-latest` 等 `codex-` prefix のモデルは ChatGPT backend -//! 経由(CodexOAuth)でしか使えないため、このテーブルでだけ Reasoning -//! にフォールバックする。 +//! モデル ID 固有のテーブル(`gpt-5` / `codex-` 系など)は高レベル構築層 +//! (`provider::capability`)の責務。ここでは wire の保守的 default のみ。 use crate::llm_client::capability::{ - CacheStrategy, ModelCapability, ReasoningSupport, StructuredOutput, ToolCallingSupport, + CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport, }; -use crate::llm_client::scheme::openai_chat::capability::{OpenAiFamily, classify}; - -pub(crate) fn lookup(model_id: &str) -> Option { - let family = classify(model_id).or_else(|| { - if model_id.starts_with("codex-") { - Some(OpenAiFamily::Reasoning) - } else { - None - } - })?; - Some(match family { - OpenAiFamily::Reasoning => ModelCapability { - tool_calling: ToolCallingSupport::Parallel, - structured_output: StructuredOutput::JsonSchema, - reasoning: Some(ReasoningSupport::Effort), - vision: true, - prompt_caching: CacheStrategy::Auto, - }, - OpenAiFamily::Gpt4 => ModelCapability { - tool_calling: ToolCallingSupport::Parallel, - structured_output: StructuredOutput::JsonSchema, - reasoning: None, - vision: true, - prompt_caching: CacheStrategy::Auto, - }, - OpenAiFamily::Gpt35 => ModelCapability { - tool_calling: ToolCallingSupport::Parallel, - structured_output: StructuredOutput::JsonObject, - reasoning: None, - vision: false, - prompt_caching: CacheStrategy::Auto, - }, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn gpt_5_codex_is_reasoning() { - // `gpt-5` prefix で classify される - let cap = lookup("gpt-5-codex").unwrap(); - assert!(cap.reasoning.is_some()); - } - - #[test] - fn codex_mini_latest_is_reasoning() { - // ChatGPT backend 専用モデル。`codex-` prefix で Reasoning にフォールバック - let cap = lookup("codex-mini-latest").unwrap(); - assert!(cap.reasoning.is_some()); - } - - #[test] - fn unknown_model_returns_none() { - assert!(lookup("foo-bar-3000").is_none()); - } -} pub(crate) fn default_capability() -> ModelCapability { ModelCapability { diff --git a/crates/llm-worker/src/llm_client/scheme/openai_responses/request.rs b/crates/llm-worker/src/llm_client/scheme/openai_responses/request.rs index 42889033..08ae557b 100644 --- a/crates/llm-worker/src/llm_client/scheme/openai_responses/request.rs +++ b/crates/llm-worker/src/llm_client/scheme/openai_responses/request.rs @@ -4,7 +4,7 @@ //! item 配列で reasoning / function_call / function_call_output が //! first-class。`Item` を素に近い形で `input[]` に投影できる。 -use serde::Serialize; +use serde::{Serialize, Serializer}; use serde_json::Value; use crate::llm_client::{ @@ -125,11 +125,28 @@ pub(crate) struct ResponseTool { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// OpenAI Responses API は `type:"object"` のパラメータスキーマに + /// `properties` が存在することを要求する。schemars は引数なし struct + /// から `properties` を含まない最小スキーマを出すので、serialize + /// 時に空オブジェクトを補う。 + #[serde(serialize_with = "serialize_parameters")] pub parameters: Value, /// Structured output モード制御。デフォルト false。 pub strict: bool, } +fn serialize_parameters(value: &Value, s: S) -> Result { + if let Some(obj) = value.as_object() + && obj.get("type").and_then(Value::as_str) == Some("object") + && !obj.contains_key("properties") + { + let mut patched = obj.clone(); + patched.insert("properties".to_string(), Value::Object(Default::default())); + return Value::Object(patched).serialize(s); + } + value.serialize(s) +} + impl OpenAIResponsesScheme { /// `Request` から wire 形式の body を組み立てる。 pub(crate) fn build_request( @@ -438,6 +455,46 @@ mod tests { assert_eq!(body.max_output_tokens, Some(100)); } + #[test] + fn tool_schema_without_properties_is_normalized() { + // schemars は引数なし struct から `type:"object"` だけのスキーマを + // 吐く。OpenAI Responses は `properties` 欠落を 400 で拒否するので + // 送る直前に空オブジェクトを補うのを確認。 + let scheme = OpenAIResponsesScheme::new(); + let raw_schema = serde_json::json!({ "type": "object" }); + let req = Request::new().tool( + ToolDefinition::new("empty") + .description("no args") + .input_schema(raw_schema), + ); + let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); + let json = serde_json::to_value(&body).unwrap(); + assert_eq!(json["tools"][0]["parameters"]["type"], "object"); + assert!( + json["tools"][0]["parameters"]["properties"].is_object(), + "properties must be present as an object, got: {}", + json["tools"][0]["parameters"] + ); + } + + #[test] + fn tool_schema_with_properties_is_untouched() { + let scheme = OpenAIResponsesScheme::new(); + let raw_schema = serde_json::json!({ + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"] + }); + let req = Request::new().tool( + ToolDefinition::new("t") + .description("d") + .input_schema(raw_schema.clone()), + ); + let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); + let json = serde_json::to_value(&body).unwrap(); + assert_eq!(json["tools"][0]["parameters"], raw_schema); + } + #[test] fn serialized_body_has_expected_shape() { // wire 形式が崩れていないかのスモークテスト diff --git a/crates/llm-worker/src/llm_client/scheme/openai_responses/scheme_impl.rs b/crates/llm-worker/src/llm_client/scheme/openai_responses/scheme_impl.rs index 2a9c5988..80a4b6a0 100644 --- a/crates/llm-worker/src/llm_client/scheme/openai_responses/scheme_impl.rs +++ b/crates/llm-worker/src/llm_client/scheme/openai_responses/scheme_impl.rs @@ -19,11 +19,14 @@ impl Scheme for OpenAIResponsesScheme { type State = OpenAIResponsesState; fn default_base_url(&self) -> &'static str { - "https://api.openai.com" + // `/v1` は base_url 側に寄せる。ChatGPT OAuth 経由のときは + // `https://chatgpt.com/backend-api/codex` を base にすれば同じ + // `/responses` path で両系統を吸収できる(Codex CLI 準拠)。 + "https://api.openai.com/v1" } fn path(&self, _model_id: &str) -> String { - "/v1/responses".to_string() + "/responses".to_string() } fn required_auth(&self) -> AuthRequirement { @@ -49,10 +52,6 @@ impl Scheme for OpenAIResponsesScheme { super::events::parse_sse(event_type, data, state) } - fn capability_for(&self, model_id: &str) -> Option { - super::capability::lookup(model_id) - } - fn default_capability(&self) -> ModelCapability { super::capability::default_capability() } diff --git a/crates/manifest/src/model.rs b/crates/manifest/src/model.rs index f62287ff..a3dcae40 100644 --- a/crates/manifest/src/model.rs +++ b/crates/manifest/src/model.rs @@ -67,6 +67,7 @@ pub enum AuthRef { file: Option, }, /// ChatGPT OAuth(`~/.codex/auth.json`)。実装は `llm-auth-codex-oauth` チケット + #[serde(rename = "codex_oauth")] CodexOAuth, } diff --git a/crates/provider/src/capability.rs b/crates/provider/src/capability.rs new file mode 100644 index 00000000..54a39b49 --- /dev/null +++ b/crates/provider/src/capability.rs @@ -0,0 +1,211 @@ +//! `SchemeKind` × `model_id` → [`ModelCapability`] の既知モデル静的テーブル。 +//! +//! このテーブルは「モデル ID の知識」であり、wire 実装(`llm-worker`)の +//! 責務ではなく高レベル構築層(`crates/provider`)の責務として置く。 +//! llm-worker の scheme には `default_capability()` のみ残し、未知モデル +//! 時は scheme 既定にフォールバックする。 +//! +//! 解決順(`build_client` が呼ぶ): +//! 1. `ModelConfig.capability` 明示指定 +//! 2. [`lookup`] (本モジュール) +//! 3. `Scheme::default_capability()` (llm-worker) + +use llm_worker::llm_client::capability::{ + CacheStrategy, ModelCapability, ReasoningSupport, StructuredOutput, ToolCallingSupport, +}; +use manifest::SchemeKind; + +/// `scheme` と `model_id` から既知モデルの capability を返す。 +/// 未知なら `None`(呼び出し側が `Scheme::default_capability()` へフォールバック)。 +pub fn lookup(scheme: SchemeKind, model_id: &str) -> Option { + match scheme { + SchemeKind::Anthropic => anthropic_lookup(model_id), + SchemeKind::OpenaiChat => openai_chat_lookup(model_id), + SchemeKind::OpenaiResponses => openai_responses_lookup(model_id), + SchemeKind::Gemini => gemini_lookup(model_id), + } +} + +// --- Anthropic -------------------------------------------------------------- + +fn anthropic_lookup(model_id: &str) -> Option { + if !model_id.starts_with("claude-") { + return None; + } + Some(ModelCapability { + tool_calling: ToolCallingSupport::Parallel, + structured_output: StructuredOutput::JsonSchema, + reasoning: Some(ReasoningSupport::BudgetTokens), + vision: true, + prompt_caching: CacheStrategy::Explicit { max_breakpoints: 4 }, + }) +} + +// --- OpenAI (chat / responses で共有の family 判定) -------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OpenAiFamily { + /// GPT-5 / o1 / o3 / o4 系 — reasoning 対応 + Reasoning, + /// GPT-4o / GPT-4 系 + Gpt4, + /// GPT-3.5 系(旧式) + Gpt35, +} + +fn openai_classify(model_id: &str) -> Option { + if model_id.starts_with("gpt-5") + || model_id.starts_with("o1") + || model_id.starts_with("o3") + || model_id.starts_with("o4") + { + return Some(OpenAiFamily::Reasoning); + } + if model_id.starts_with("gpt-4") { + return Some(OpenAiFamily::Gpt4); + } + if model_id.starts_with("gpt-3.5") { + return Some(OpenAiFamily::Gpt35); + } + None +} + +fn openai_chat_lookup(model_id: &str) -> Option { + openai_classify(model_id).map(|family| match family { + OpenAiFamily::Reasoning => ModelCapability { + tool_calling: ToolCallingSupport::Parallel, + structured_output: StructuredOutput::JsonSchema, + reasoning: Some(ReasoningSupport::Effort), + vision: true, + prompt_caching: CacheStrategy::Auto, + }, + OpenAiFamily::Gpt4 => ModelCapability { + tool_calling: ToolCallingSupport::Parallel, + structured_output: StructuredOutput::JsonSchema, + reasoning: None, + vision: true, + prompt_caching: CacheStrategy::Auto, + }, + OpenAiFamily::Gpt35 => ModelCapability { + tool_calling: ToolCallingSupport::Parallel, + structured_output: StructuredOutput::JsonObject, + reasoning: None, + vision: false, + prompt_caching: CacheStrategy::Auto, + }, + }) +} + +fn openai_responses_lookup(model_id: &str) -> Option { + // `codex-` prefix は ChatGPT backend 経由(CodexOAuth)でのみ使える + // Reasoning モデル family。`gpt-5` 系と同じ扱い。 + let family = openai_classify(model_id).or_else(|| { + if model_id.starts_with("codex-") { + Some(OpenAiFamily::Reasoning) + } else { + None + } + })?; + Some(match family { + OpenAiFamily::Reasoning => ModelCapability { + tool_calling: ToolCallingSupport::Parallel, + structured_output: StructuredOutput::JsonSchema, + reasoning: Some(ReasoningSupport::Effort), + vision: true, + prompt_caching: CacheStrategy::Auto, + }, + OpenAiFamily::Gpt4 => ModelCapability { + tool_calling: ToolCallingSupport::Parallel, + structured_output: StructuredOutput::JsonSchema, + reasoning: None, + vision: true, + prompt_caching: CacheStrategy::Auto, + }, + OpenAiFamily::Gpt35 => ModelCapability { + tool_calling: ToolCallingSupport::Parallel, + structured_output: StructuredOutput::JsonObject, + reasoning: None, + vision: false, + prompt_caching: CacheStrategy::Auto, + }, + }) +} + +// --- Gemini ----------------------------------------------------------------- + +fn gemini_lookup(model_id: &str) -> Option { + if !model_id.starts_with("gemini-") { + return None; + } + // 2.5 系以降は thinking / reasoning を持つ + let reasoning = if model_id.starts_with("gemini-2.5") || model_id.starts_with("gemini-3") { + Some(ReasoningSupport::BudgetTokens) + } else { + None + }; + Some(ModelCapability { + tool_calling: ToolCallingSupport::Parallel, + structured_output: StructuredOutput::JsonSchema, + reasoning, + vision: true, + prompt_caching: CacheStrategy::Auto, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anthropic_known_claude() { + let cap = lookup(SchemeKind::Anthropic, "claude-sonnet-4-6").unwrap(); + assert!(matches!( + cap.prompt_caching, + CacheStrategy::Explicit { max_breakpoints: 4 } + )); + assert!(cap.reasoning.is_some()); + } + + #[test] + fn anthropic_unknown_is_none() { + assert!(lookup(SchemeKind::Anthropic, "llama3").is_none()); + } + + #[test] + fn openai_chat_gpt5_is_reasoning() { + let cap = lookup(SchemeKind::OpenaiChat, "gpt-5-xyz").unwrap(); + assert!(cap.reasoning.is_some()); + } + + #[test] + fn openai_responses_codex_prefix_is_reasoning() { + let cap = lookup(SchemeKind::OpenaiResponses, "codex-mini-latest").unwrap(); + assert!(cap.reasoning.is_some()); + } + + #[test] + fn openai_responses_gpt5_codex_is_reasoning() { + // gpt-5 prefix 経由で classify される + let cap = lookup(SchemeKind::OpenaiResponses, "gpt-5-codex").unwrap(); + assert!(cap.reasoning.is_some()); + } + + #[test] + fn gemini_25_has_reasoning() { + let cap = lookup(SchemeKind::Gemini, "gemini-2.5-pro").unwrap(); + assert!(matches!(cap.reasoning, Some(ReasoningSupport::BudgetTokens))); + } + + #[test] + fn gemini_legacy_has_no_reasoning() { + let cap = lookup(SchemeKind::Gemini, "gemini-1.5-pro").unwrap(); + assert!(cap.reasoning.is_none()); + } + + #[test] + fn unknown_model_is_none_across_schemes() { + assert!(lookup(SchemeKind::OpenaiChat, "foo-bar").is_none()); + assert!(lookup(SchemeKind::OpenaiResponses, "foo-bar").is_none()); + assert!(lookup(SchemeKind::Gemini, "foo-bar").is_none()); + } +} diff --git a/crates/provider/src/codex_oauth/mod.rs b/crates/provider/src/codex_oauth/mod.rs index 09d1558e..e5a6e1e1 100644 --- a/crates/provider/src/codex_oauth/mod.rs +++ b/crates/provider/src/codex_oauth/mod.rs @@ -143,7 +143,7 @@ impl CodexAuthProvider { } fn build_headers(snap: &AuthSnapshot) -> Result, CodexAuthError> { - let mut out = Vec::with_capacity(3); + let mut out = Vec::with_capacity(5); let auth_val = HeaderValue::from_str(&format!("Bearer {}", snap.access_token)) .map_err(|e| CodexAuthError::InvalidHeader(format!("Authorization: {e}")))?; @@ -156,6 +156,18 @@ impl CodexAuthProvider { acc_val, )); + // Cloudflare WAF は ChatGPT backend アクセス元を `originator` / + // `User-Agent` で識別する。Codex CLI が送る固定値を流用しないと + // HTML challenge (403) を返されて SSE に到達できない。 + out.push(( + HeaderName::from_static("originator"), + HeaderValue::from_static("codex_cli_rs"), + )); + out.push(( + HeaderName::from_static("user-agent"), + HeaderValue::from_static("codex_cli_rs/0.60.0"), + )); + // FedRAMP 組織は id_token JWT 内の claim で判定 if jwt::parse_chatgpt_claims(&snap.id_token) .map(|c| c.is_fedramp) diff --git a/crates/provider/src/lib.rs b/crates/provider/src/lib.rs index b97e8ca5..3da5d573 100644 --- a/crates/provider/src/lib.rs +++ b/crates/provider/src/lib.rs @@ -10,6 +10,7 @@ //! なる認証ストア解決(Codex OAuth の `~/.codex/auth.json` 読取等)は //! このクレートに追加する。 +pub mod capability; pub mod codex_oauth; use std::sync::Arc; @@ -87,12 +88,14 @@ fn resolve_auth( } /// `AuthRef::CodexOAuth` 指定時、`base_url` 未指定なら ChatGPT backend を既定とする。 +/// Codex CLI が使う `/backend-api/codex` を base に取り、scheme 側の `/responses` +/// path と結合して `https://chatgpt.com/backend-api/codex/responses` になる。 fn effective_base_url(scheme: &S, config: &ModelConfig) -> String { if let Some(b) = &config.base_url { return b.clone(); } if matches!(config.auth, AuthRef::CodexOAuth) { - return "https://chatgpt.com/backend-api".to_string(); + return "https://chatgpt.com/backend-api/codex".to_string(); } scheme.default_base_url().to_string() } @@ -108,14 +111,15 @@ fn build_transport( }); } // capability の優先順位: - // 1. `ModelConfig.capability` の明示指定(OpenAI 互換ルーターの - // 未知モデル等、マニフェストで完全に上書きしたいケース) - // 2. scheme 静的テーブル(既知モデル) - // 3. `Scheme::default_capability()`(scheme ごとの安全側デフォルト) + // 1. `ModelConfig.capability` の明示指定(OpenAI 互換ルーターの + // 未知モデル等、マニフェストで完全に上書きしたいケース) + // 2. `provider::capability::lookup` の既知モデルテーブル + // (モデル ID の知識は高レベル構築層(ここ)の責務) + // 3. `Scheme::default_capability()`(scheme ごとの wire-level 安全側) let capability: ModelCapability = config .capability .clone() - .or_else(|| scheme.capability_for(&config.model_id)) + .or_else(|| capability::lookup(config.scheme, &config.model_id)) .unwrap_or_else(|| scheme.default_capability()); let base_url = effective_base_url(&scheme, config); Ok(Box::new(HttpTransport::new( diff --git a/tickets/llm-capability-ownership.md b/tickets/llm-capability-ownership.md new file mode 100644 index 00000000..33f2b090 --- /dev/null +++ b/tickets/llm-capability-ownership.md @@ -0,0 +1,79 @@ +# モデル capability の責務を llm-worker 外へ移す + +## 背景 + +`llm-worker` は「wire scheme の送受信」に専念する低レベル基盤に留める方針。にもかかわらず、現状は各 scheme に `capability.rs` が同居していて、`claude-*` / `gpt-5` prefix / `gemini-2.5` prefix / `codex-` prefix 等の個別モデル ID 知識が `llm-worker` に閉じ込められている。 + +該当箇所: + +- `crates/llm-worker/src/llm_client/scheme/anthropic/capability.rs::lookup` +- `crates/llm-worker/src/llm_client/scheme/openai_chat/capability.rs::classify` / `lookup` +- `crates/llm-worker/src/llm_client/scheme/openai_responses/capability.rs::lookup` +- `crates/llm-worker/src/llm_client/scheme/gemini/capability.rs::lookup` +- `Scheme::capability_for(model_id) -> Option` trait メソッド + +「モデル ID から能力を決める」は wire 実装の責務ではない。カタログ/プロバイダ構築層(高レベル側)の責務で、ここに居るのは層の漏出。新モデル(`gpt-6` 等)が出るたびに `llm-worker` を触る構造は抽象が壊れている。 + +## 要件 + +1. **`Scheme::capability_for` の廃止**: trait から削除。`llm-worker` は wire-level の安全側フォールバックである `default_capability()` のみ持つ。 + +2. **モデル知識を `crates/provider` へ移す**: + - `crates/provider/src/capability.rs`(または `capability/` モジュール)を新設 + - 現 `scheme/*/capability.rs` の `lookup`(および `classify`)を移植 + - 公開 API: `pub fn lookup(scheme: SchemeKind, model_id: &str) -> Option` + +3. **`build_client` の capability 解決順は維持**: + 1. `ModelConfig.capability` 明示指定 + 2. `provider::capability::lookup(scheme, model_id)` (移設先) + 3. `scheme.default_capability()` (`llm-worker` に残る) + +4. **`llm-worker/examples/*` の追従**: `scheme.capability_for` を使っている箇所(`worker_cli` / `worker_cancel_demo` / `record_test_fixtures`)は `scheme.default_capability()` か明示 `ModelCapability` に置換する。examples を provider に依存させない。 + +5. **テストの移設**: scheme 側 capability テストは provider 側に移す(テーブル本体と一緒に移動)。 + +6. **完了時の動作**: `cargo build --workspace` が通り、`test_pod.codex.local.toml` 経由の疎通テストが引き続き成功する。 + +## 設計判断 + +### scheme 側に `default_capability()` を残す理由 + +wire format 固有の保守的 default (例: OpenAI 互換は Parallel tool call, JsonSchema structured output) は wire 層の知識で、モデル固有ではない。「この scheme では何が最低限送れるか」を示す安全側の宣言なので `llm-worker` に残す。 + +### examples を provider に依存させない + +worker_cli 等は scheme の使い方を示す最小例。provider クレートに依存させると低レベル例として壊れるし、循環依存の気配も出る。examples では `scheme.default_capability()` を使い、モデル固有最適化が必要なら `ModelCapability` 手組みで済ませる。 + +### `llm-provider-catalog` との関係 + +カタログチケットは「プロバイダと代表モデル ID の提示」が主題で、capability は意図的に載せない判断だった。本チケットはその決定を変えない。`provider::capability::lookup` は provider 層のうちカタログとは別の関数として置く(将来的にカタログが capability ヒントを提供する余地は残すが、本チケットでは扱わない)。 + +### 新モデル追加の運用 + +`provider::capability::lookup` が「知ってるモデル ID」のテーブルを持つ設計は維持する(本チケットの焦点は場所の正しさ)。新モデル対応はテーブル追記で済むが、新スキーマ/新プロバイダ経路が出たときに llm-worker を触らずに済むのが改善点。 + +## Scope 外 + +- ランタイム capability 検出 (OpenAI `/v1/models` や ChatGPT backend の動的モデル列挙) +- `providers.toml` カタログ連携 (`llm-provider-catalog` チケット側) +- prefix match から厳密マッチへの切替(別論点、今回は挙動保存) + +## 影響範囲 + +- `crates/llm-worker/src/llm_client/scheme/mod.rs`: `capability_for` trait メソッド削除 +- `crates/llm-worker/src/llm_client/scheme/*/scheme_impl.rs`: `capability_for` impl 削除 +- `crates/llm-worker/src/llm_client/scheme/*/capability.rs`: `lookup`/`classify` 削除、`default_capability` のみ残す +- `crates/provider/src/capability.rs`: 新設(テーブル統合) +- `crates/provider/src/lib.rs`: `build_client` の capability 解決経路を更新 +- `crates/llm-worker/examples/*`: `capability_for` 呼び出し除去 + +## 依存 + +- `llm-model-config` 完了済 +- `llm-scheme-openai-responses` 完了済 +- `llm-auth-codex-oauth` 完了済 + +## Review +- 状態: Approve +- レビュー詳細: [./llm-capability-ownership.review.md](./llm-capability-ownership.review.md) +- 日付: 2026-04-21 diff --git a/tickets/llm-capability-ownership.review.md b/tickets/llm-capability-ownership.review.md new file mode 100644 index 00000000..7505b471 --- /dev/null +++ b/tickets/llm-capability-ownership.review.md @@ -0,0 +1,95 @@ +# Review: モデル capability の責務を llm-worker 外へ移す + +## 前提・要件の確認 + +1. **`Scheme::capability_for` の廃止**: 満たされている。 + - `crates/llm-worker/src/llm_client/scheme/mod.rs:79-83` では trait メソッドが削除され、doc コメントも「`provider::capability::lookup` 側(高レベル構築層)の責務」と明記。 + - 各 `scheme_impl.rs`(anthropic / openai_chat / openai_responses / gemini)から `capability_for` impl が消えていることを diff で確認。 + - 念のため grep で `capability_for` 残骸を全 crate 検索 → チケット本文以外の参照なし。 + +2. **モデル知識を `crates/provider` へ移す**: 満たされている。 + - `crates/provider/src/capability.rs:1-153` に Anthropic / OpenAI Chat / OpenAI Responses / Gemini の 4 テーブルと、openai_chat と openai_responses で共有していた `OpenAiFamily` / `openai_classify` を統合。 + - 公開 API は要件通り `pub fn lookup(scheme: SchemeKind, model_id: &str) -> Option`(`crates/provider/src/capability.rs:20-27`)。 + - `crates/provider/src/lib.rs:13` で `pub mod capability;` としてモジュール公開。 + +3. **`build_client` の capability 解決順**: 満たされている。 + - `crates/provider/src/lib.rs:119-123` で `config.capability.clone().or_else(|| capability::lookup(...)).unwrap_or_else(|| scheme.default_capability())` の 3 段階フォールバックが明示されており、コメントも要件の 3 階層とそれぞれの責務を正しく対応付けている。 + +4. **examples の追従(provider に依存させない)**: 満たされている。 + - `crates/llm-worker/examples/worker_cli.rs:341-349`: `scheme.default_capability()` 直接利用。Ollama 分岐(`worker_cli.rs:378`)だけは従前通りローカル `default_capability()` 関数を使用 → これは「明示 `ModelCapability` に置換」の要件に合致。 + - `crates/llm-worker/examples/worker_cancel_demo.rs:26-28`: `scheme.default_capability()` のみ。ローカル fallback 定義は削除済。 + - `crates/llm-worker/examples/record_test_fixtures/main.rs:22-36`: `scheme.default_capability()` + Ollama 分岐で `AnthropicScheme::new().default_capability()`。provider クレート import なし。 + - examples の `Cargo.toml` に provider 依存が追加されていないことも間接的に確認(worker_cli / worker_cancel_demo は `llm_worker::` のみ import)。 + +5. **テストの移設**: 満たされている。 + - 旧 `scheme/openai_responses/capability.rs` の `#[cfg(test)] mod tests` は削除。 + - `crates/provider/src/capability.rs:155-211` に 8 件の unit test(Anthropic known/unknown, OpenAI chat gpt5, OpenAI responses codex / gpt-5-codex, Gemini 2.5 / 1.5, unknown across schemes)を配置。旧来 `gpt_5_codex_is_reasoning` / `codex_mini_latest_is_reasoning` / `unknown_model_returns_none` の振る舞いは保存されている。 + - scheme 側の capability.rs には `#[cfg(test)]` 残留なしを grep で確認。 + +6. **完了時の動作**: 満たされている。 + - `cargo check --workspace --examples` 成功(warning は既存の `end_scope` 未使用のみ、今回の変更と無関係)。 + - `cargo test -p provider --lib` 33/33 pass(capability テスト 8 件込み)。 + - `cargo test -p llm-worker --lib` 100/100 pass。 + - 疎通テストの実行ログは diff に含まれていないが、静的テーブルは 1:1 で移植されており挙動差は出ない(下記「挙動保存の確認」参照)。 + +## アーキテクチャ・スコープ + +### レイヤ境界 +`llm-worker` から `claude-*` / `gpt-5` / `codex-` / `gemini-2.5` 等のモデル ID 固有知識が完全に撤退し、`provider` に集約された。CLAUDE.md の「llm-worker は wire scheme の低レベル基盤に留める」方針と feedback memory `llm_worker_scope` に厳密に合致する、このチケットの趣旨通りの成果。 + +### モジュール粒度 +`provider/src/capability.rs` を 1 ファイルに統合した判断は本件では妥当。理由: +- 統合前の 4 ファイル合計 ~180 行で、既に 1 ファイルに収まるサイズ。 +- `OpenAiFamily` / `openai_classify` が chat / responses 両方から参照される「一次情報」だったので、同一ファイルに閉じ込めた方が `pub(crate)` スコープだけで済み、サブモジュール間の `pub(super)` 公開戦略を考えなくて良い。 +- 将来テーブルが肥大化した場合の `capability/{anthropic,openai,gemini}.rs` サブモジュール分割は容易(今は不要)。 + +判断として Approve。将来新プロバイダを追加したときに 1 ファイル 400-500 行を超えるようなら改めて分割検討で十分。 + +### scheme 側 `default_capability()` の存置 +ticket 設計判断の通り、「この wire で安全に送れる最小共通項」は wire 知識であり scheme 層に残す意味がある。Ollama(Anthropic scheme + 認証なし + `default_capability`)経路でも wire 既定が有効に機能する(`crates/llm-worker/examples/record_test_fixtures/main.rs:126` など)。Approve。 + +### examples の provider 非依存 +worker_cli の Ollama 分岐が、provider 層に依存せずローカル `default_capability()` 関数で手組み `ModelCapability` を作っている実装は、チケット設計判断「examples を provider に依存させない」に一致。循環依存の予防としても正しい。Approve。 + +## 挙動保存の確認(旧 → 新のテーブル) + +旧 4 ファイルの `lookup` / `classify` / `OpenAiFamily` と `provider/src/capability.rs` を突き合わせた結果、全フィールド一致: + +| モデル family | 旧位置 | 新位置 | 差分 | +| --- | --- | --- | --- | +| Anthropic `claude-*` | `scheme/anthropic/capability.rs` | `provider::capability::anthropic_lookup` | 無し(`Explicit { max_breakpoints: 4 }`, `BudgetTokens`, `vision: true`) | +| OpenAI `gpt-5` / `o1` / `o3` / `o4` | `scheme/openai_chat/capability.rs` | `openai_classify` + `openai_chat_lookup` | 無し(Reasoning: `Effort`) | +| OpenAI `gpt-4` | 同上 | 同上 | 無し | +| OpenAI `gpt-3.5` | 同上 | 同上 | 無し(`JsonObject`, `vision: false`) | +| OpenAI Responses `codex-*` prefix | `scheme/openai_responses/capability.rs` | `openai_responses_lookup` の `or_else` | 無し(Reasoning にフォールバック) | +| Gemini `gemini-*` (一般) | `scheme/gemini/capability.rs` | `gemini_lookup` | 無し | +| Gemini `gemini-2.5` / `gemini-3` | 同上 | 同上 | 無し(`BudgetTokens`) | + +`default_capability()` 値も全 scheme で保存(anthropic: vision=false, openai_chat: vision=false, openai_responses: vision=false, gemini: vision=true)。**特に gemini の `vision: true` が保たれているか**というレビュー観点は OK。 + +## 指摘事項 + +### Blocking +なし。 + +### Non-blocking / Follow-up + +- **[major][スコープ外変更の混入]** チケット diff にスコープ外の Codex OAuth 疎通修正が同居している(`crates/llm-worker/src/llm_client/scheme/openai_responses/scheme_impl.rs` の `default_base_url` / `path` 変更、`crates/provider/src/lib.rs::effective_base_url`、`crates/provider/src/codex_oauth/mod.rs::build_headers`、`crates/manifest/src/model.rs` の `#[serde(rename = "codex_oauth")]`)。ユーザー自身も「別コミット候補」と認識しているので後続コミット分割で対処できるが、レビュー観点としては: + - 本チケットの review が「capability 移設」単体の妥当性判定を越えて Codex 疎通修正の可否まで巻き込むことは避けるべき。 + - 少なくともコミット段階で `llm-capability-ownership` コミットと「Codex OAuth 疎通修正」コミットを分けた方が後から git log で挙動回帰を追える。 + - `path` を `/v1/responses` → `/responses` にしつつ `default_base_url` に `/v1` を含める変更は OpenAI 公式経路と ChatGPT backend 経路の URL 組立てを統合する話で、本チケットで動作させた疎通確認が「capability 移設後の回帰」なのか「URL 変更の検証」なのかを混同しやすい。次回以降、本性質の「ついで修正」は別チケット/別コミットに切ることを推奨。 + +- **[minor][公開 API の可視性]** `provider::capability::lookup` は Pub だが、`OpenAiFamily` / `openai_classify` / `*_lookup` 系 helper が `pub(crate)` ですら宣言されておらず、module-private 関数として閉じている。現状 `build_client` は `lookup` 1 点経由で十分だが、将来 `llm-provider-catalog` から family を参照したいユースケースが出た時点で公開化を検討すればよい。今は Approve。 + +- **[minor][テスト粒度]** 旧 `openai_responses` の `gpt_5_codex_is_reasoning` / `codex_mini_latest_is_reasoning` / `unknown_model_returns_none` が `provider/src/capability.rs` tests に移植されたのは確認済だが、もとの `openai_chat::lookup` 側には個別 test が無かったので、新テーブルでも gpt-4 / gpt-3.5 の classify 成否テストは追加されていない。挙動保存の観点では現状で十分(ticket 要件は「移設」なので)。新モデル追加時に境界テストを足す運用で Approve。 + +### Nits + +- `provider/src/capability.rs` の section コメント `// --- Anthropic ---` 等、既存 codebase 他所ではあまり見かけないスタイル。統一上は `mod anthropic { ... }` サブモジュール化でも良かったが、短い関数群の可読性補助としては問題なし。 +- `lib.rs:119-123` の capability 解決コメントが丁寧で、将来の保守者にとって助かる。good keep. + +## 判断 + +**Approve** — 要件 1-6 すべてを満たし、テーブルの移設は旧挙動を完全保存、レイヤ境界を `llm-worker / provider` 間で正しく引き直した。scheme 側 `default_capability()` の存置判断、examples を provider 非依存に保った判断とも妥当。 + +ただし「スコープ外 Codex 疎通修正」の同居は、このチケットのコミット外に切り出すのが望ましい(コミットは user の責務なので、分割方針を user 判断で)。 diff --git a/tickets/llm-provider-catalog.md b/tickets/llm-provider-catalog.md new file mode 100644 index 00000000..5c0a4da8 --- /dev/null +++ b/tickets/llm-provider-catalog.md @@ -0,0 +1,102 @@ +# LLM プロバイダ/モデルカタログ + +## 背景 + +llm-worker の基盤(`scheme` / `ModelCapability` / `AuthRef`)と `crates/provider` の構築ファクトリは `llm-model-config` / `llm-scheme-openai-responses` / `llm-auth-codex-oauth` 完了で揃った。Pod マニフェストは `ModelConfig { scheme, base_url, model_id, auth, capability }` を 1 個宣言する形で機能する。 + +一方で UI 層(`native-gui-mvp` / `tui-pod-spawn-ui`)から「使えるプロバイダと代表的なモデル ID を一覧で見せる」ために参照できる候補リストが無い。現状 UI はマニフェストを手書き前提で、プロバイダ追加がコード変更でなく宣言で済むという `docs/plan/llm_providers.md` の狙い(「ルーター系は後追いで数を増やしやすい宣言型設計」)が UI 側に届いていない。 + +`docs/plan/llm_providers.md` 本文に書かれていた `available_models[]` 相当は、llm-worker / manifest の基盤に載せるより、このカタログレイヤで宣言するのが素直。基盤は「1 個のモデルを動かす」責務に留め、「選択肢を提供する」責務を分離する。 + +## 要件 + +1. **カタログファイル形式の定義**: TOML 宣言で以下を表現できる + + ```toml + [[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"] + ``` + + - `id` はカタログ内ユニーク。UI での選択・保存用 + - `default_models[]` は代表的な ID。UI はこれを候補として出すが、ユーザーが自由入力もできる想定 + - `auth_hint` は UI 向けメタ(env 名 / 認証不要 / OAuth 利用可 の表示に使う)。実際の認証解決は従来通り `crates/provider` が `AuthRef` から行う + - `capability` はここでは宣言しない(scheme の静的テーブルと `ModelConfig.capability` の既存 2 段階で足りる) + +2. **読取 API**: カタログを読み `Vec` として返す関数を 1 本公開する。配置先は `crates/provider`(認証解決と同じ層、plan の「llm-worker は低レベル基盤に留める」原則と整合)。 + +3. **配置パス**: + - builtin: `crates/provider/assets/providers.toml` を `include_str!` で同梱(代表的な 4 経路 = Anthropic / Ollama / Codex OAuth / OpenRouter を最低限入れる) + - user override: `$XDG_CONFIG_HOME/insomnia/providers.toml` があれば **マージではなく置換** で優先採用(マージ規則の複雑さは避ける) + - 両方読めない場合は builtin のみ + +4. **ProviderEntry → ModelConfig の変換**: UI が「このプロバイダのこのモデルを使う」を選んだときに、`ProviderEntry` + `model_id` から `ModelConfig` を組める変換関数を提供する。`auth_hint.kind` が `AuthRef` の各バリアントに対応する。 + +5. **完了時の動作**: + - `crates/provider` の公開 API から builtin プロバイダ一覧が取得できる + - user override ファイルが置かれていれば置換採用される + - UI 未実装段階でも、unit test で「カタログ読取 → `ProviderEntry` 選択 → `ModelConfig` 生成 → `build_client` が成功する」経路が通る + +## 設計判断 + +### builtin と user override の関係 + +マージ(builtin に追記する)方式は競合ルール(`id` 衝突時の優先・部分上書き)が必要になり、UI が「どの設定が効いているか」を説明しづらくなる。user override があれば完全置換とし、ユーザーは builtin をコピーして編集するか、自分で最小構成を書くかを選ぶ。 + +### `auth_hint` と `AuthRef` の二重定義 + +一見冗長だが、`auth_hint` は「UI が何を表示・要求するか」のヒント、`AuthRef` は「ランタイムがどこからトークンを引くか」の宣言で責務が違う。`auth_hint.kind = "api_key"` から `AuthRef::ApiKey { env: auth_hint.env, file: None }` への変換関数 1 本で繋ぐ。 + +### capability 宣言をカタログに入れない + +scheme 側の静的テーブルに未登録のモデル ID(OpenRouter 経由の任意モデル等)を使うときは `ModelConfig.capability` で明示指定するという経路が既に存在する。カタログ側で更に capability を持つと 3 箇所で capability が定義できることになり、優先順位が混乱する。カタログは「選択肢の提示」に専念する。 + +### auto_discover は別チケット + +Ollama `/api/tags` を叩いてモデル一覧を動的に取る機構は欲しいが、本チケットの静的カタログが動いてから別チケットで足す。`ProviderEntry` に後から `discover: Option` を任意で足せる構造で設計しておく(フィールド拡張予定のコメントだけ残す)。 + +## Scope 外 + +- UI 実装(GUI / TUI 側のプロバイダ・モデル選択画面は各 UI チケットで) +- auto_discover(Ollama `/api/tags`、OpenRouter `/models` 等の動的列挙) +- カタログファイル編集 UI +- プロファイル(同一プロバイダで複数の API key を切り替える等) + +## 依存 + +- `llm-model-config` 完了済(`ModelConfig` / `AuthRef` / `SchemeKind` / `ModelCapability`) +- `llm-scheme-openai-responses` 完了済(Responses scheme) +- `llm-auth-codex-oauth` 完了済(`AuthRef::CodexOAuth` 解決) + +## 影響範囲 + +- `crates/provider/src/`: カタログ読取 + `ProviderEntry` 型 + 変換関数を追加 +- `crates/provider/assets/providers.toml`: 新設(builtin カタログ) +- 他クレートに型は露出するが、既存 API に破壊的変更は入れない