From 30f9abacb8a78504a31e14fa77b0c30d05a3a9d9 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 24 Apr 2026 10:45:03 +0900 Subject: [PATCH] =?UTF-8?q?models=E3=81=A8providers=E3=82=92=E3=82=AB?= =?UTF-8?q?=E3=82=BF=E3=83=AD=E3=82=B0=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/manifest/src/config.rs | 133 ++--- crates/manifest/src/lib.rs | 25 +- crates/manifest/src/model.rs | 76 ++- crates/pod/src/controller.rs | 31 +- crates/pod/src/factory.rs | 4 +- crates/pod/src/lib.rs | 2 +- crates/pod/src/spawn_pod.rs | 48 +- crates/pod/tests/spawn_pod_test.rs | 15 +- crates/provider/README.md | 15 +- crates/provider/src/capability.rs | 211 -------- crates/provider/src/catalog.rs | 503 ++++++++++++++---- crates/provider/src/lib.rs | 114 ++-- docs/plan/memory-prompts.md | 26 +- docs/plan/memory.md | 84 ++- resources/models/builtin.toml | 43 ++ .../providers/builtin.toml | 8 +- 16 files changed, 777 insertions(+), 561 deletions(-) delete mode 100644 crates/provider/src/capability.rs create mode 100644 resources/models/builtin.toml rename crates/provider/assets/providers.toml => resources/providers/builtin.toml (51%) diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 514ab52b..bcf11da5 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::defaults; -use crate::model::{AuthRef, ModelConfig, SchemeKind}; +use crate::model::{AuthRef, ModelManifest}; use crate::{CompactionConfig, PodManifest, PodMeta, ScopeConfig, ToolOutputLimits, WorkerManifest}; /// Partial-form Pod manifest. Every field is optional; one or more @@ -23,8 +23,12 @@ use crate::{CompactionConfig, PodManifest, PodMeta, ScopeConfig, ToolOutputLimit pub struct PodManifestConfig { #[serde(default)] pub pod: PodMetaConfig, + /// `[model]` セクションは partial でも完成形でも同じ + /// [`ModelManifest`] を使う。ref / inline の両形を受け入れるための + /// 全 Optional 構造なので、カスケード層と最終マニフェストで型を + /// 分ける必要がない。 #[serde(default)] - pub model: ModelConfigPartial, + pub model: ModelManifest, #[serde(default)] pub worker: WorkerManifestConfig, #[serde(default)] @@ -44,21 +48,6 @@ pub struct PodMetaConfig { pub prompt_pack: Option, } -/// Partial-form of [`ModelConfig`]. カスケード層で個別に与えられる。 -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ModelConfigPartial { - #[serde(default)] - pub scheme: Option, - #[serde(default)] - pub base_url: Option, - #[serde(default)] - pub model_id: Option, - #[serde(default)] - pub auth: Option, - #[serde(default)] - pub capability: Option, -} - #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct WorkerManifestConfig { #[serde(default)] @@ -98,7 +87,7 @@ pub struct CompactionConfigPartial { #[serde(default)] pub compact_worker_max_input_tokens: Option, #[serde(default)] - pub model: Option, + pub model: Option, } /// Errors raised when converting a [`PodManifestConfig`] to a validated @@ -209,18 +198,6 @@ impl PodMetaConfig { } } -impl ModelConfigPartial { - fn merge(self, upper: Self) -> Self { - Self { - scheme: upper.scheme.or(self.scheme), - base_url: upper.base_url.or(self.base_url), - model_id: upper.model_id.or(self.model_id), - auth: upper.auth.or(self.auth), - capability: upper.capability.or(self.capability), - } - } -} - impl WorkerManifestConfig { fn merge(self, upper: Self) -> Self { Self { @@ -262,7 +239,7 @@ impl CompactionConfigPartial { compact_worker_max_input_tokens: upper .compact_worker_max_input_tokens .or(self.compact_worker_max_input_tokens), - model: merge_option(self.model, upper.model, ModelConfigPartial::merge), + model: merge_option(self.model, upper.model, ModelManifest::merge), } } } @@ -303,29 +280,6 @@ fn ensure_absolute(field: &'static str, path: &Path) -> Result<(), ResolveError> } } -fn resolve_model( - cfg: ModelConfigPartial, - scheme_field: &'static str, - model_id_field: &'static str, - auth_file_field: &'static str, -) -> Result { - let scheme = cfg.scheme.ok_or(ResolveError::MissingField(scheme_field))?; - let model_id = cfg - .model_id - .ok_or(ResolveError::MissingField(model_id_field))?; - let auth = cfg.auth.unwrap_or_default(); - if let AuthRef::ApiKey { file: Some(p), .. } = &auth { - ensure_absolute(auth_file_field, p)?; - } - Ok(ModelConfig { - scheme, - base_url: cfg.base_url, - model_id, - auth, - capability: cfg.capability, - }) -} - /// `AuthRef::ApiKey { file, .. }` が相対パスのとき `base` を前置する。 fn resolve_auth_file(auth: &mut Option, base: &Path) { if let Some(AuthRef::ApiKey { file: Some(p), .. }) = auth.as_mut() { @@ -333,6 +287,16 @@ fn resolve_auth_file(auth: &mut Option, base: &Path) { } } +/// モデル宣言に含まれる `auth.file` が絶対パスであることを検証する。 +/// ref / scheme / model_id 等の論理的な有効性(ref があるか、inline が +/// 揃っているか)の検証はカタログを知る `crates/provider` 側で行う。 +fn validate_model_paths(model: &ModelManifest, field: &'static str) -> Result<(), ResolveError> { + if let Some(AuthRef::ApiKey { file: Some(p), .. }) = &model.auth { + ensure_absolute(field, p)?; + } + Ok(()) +} + impl TryFrom for PodManifest { type Error = ResolveError; @@ -346,12 +310,7 @@ impl TryFrom for PodManifest { ensure_absolute("pod.prompt_pack", p)?; } - let model = resolve_model( - cfg.model, - "model.scheme", - "model.model_id", - "model.auth.file", - )?; + validate_model_paths(&cfg.model, "model.auth.file")?; let worker = WorkerManifest { instruction: cfg @@ -384,17 +343,9 @@ impl TryFrom for PodManifest { let compaction = cfg .compaction .map(|c| -> Result { - let comp_model = c - .model - .map(|p| { - resolve_model( - p, - "compaction.model.scheme", - "compaction.model.model_id", - "compaction.model.auth.file", - ) - }) - .transpose()?; + if let Some(ref cm) = c.model { + validate_model_paths(cm, "compaction.model.auth.file")?; + } Ok(CompactionConfig { prune_protected_turns: c .prune_protected_turns @@ -413,14 +364,14 @@ impl TryFrom for PodManifest { compact_worker_max_input_tokens: c .compact_worker_max_input_tokens .unwrap_or(defaults::COMPACT_WORKER_MAX_INPUT_TOKENS), - model: comp_model, + model: c.model, }) }) .transpose()?; Ok(PodManifest { pod: PodMeta { name, prompt_pack }, - model, + model: cfg.model, worker, scope: cfg.scope, compaction, @@ -431,6 +382,7 @@ impl TryFrom for PodManifest { #[cfg(test)] mod tests { use super::*; + use crate::model::SchemeKind; use crate::{Permission, ScopeRule}; fn abs(path: &str) -> PathBuf { @@ -450,7 +402,7 @@ mod tests { name: Some("test".into()), prompt_pack: None, }, - model: ModelConfigPartial { + model: ModelManifest { scheme: Some(SchemeKind::Anthropic), model_id: Some("claude-sonnet-4-20250514".into()), ..Default::default() @@ -472,7 +424,7 @@ mod tests { fn resolve_minimal_succeeds() { let manifest: PodManifest = minimal_valid().try_into().unwrap(); assert_eq!(manifest.pod.name, "test"); - assert_eq!(manifest.model.scheme, SchemeKind::Anthropic); + assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic)); } #[test] @@ -570,7 +522,7 @@ mod tests { name: Some("lower".into()), prompt_pack: None, }, - model: ModelConfigPartial { + model: ModelManifest { model_id: Some("lower-model".into()), ..Default::default() }, @@ -743,7 +695,7 @@ permission = "write" name: Some("x".into()), prompt_pack: None, }, - model: ModelConfigPartial { + model: ModelManifest { scheme: Some(SchemeKind::Anthropic), model_id: Some("m".into()), ..Default::default() @@ -796,7 +748,32 @@ name = "dbg" let merged = builtin.merge(user).merge(project).merge(overlay); let manifest: PodManifest = merged.try_into().unwrap(); assert_eq!(manifest.pod.name, "dbg"); - assert_eq!(manifest.model.scheme, SchemeKind::Anthropic); + assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic)); assert_eq!(manifest.scope.allow.len(), 1); } + + #[test] + fn merge_preserves_ref() { + let lower = PodManifestConfig { + model: ModelManifest { + ref_: Some("anthropic/claude-sonnet-4-6".into()), + ..Default::default() + }, + ..Default::default() + }; + let upper = PodManifestConfig { + model: ModelManifest { + // only override auth + auth: Some(AuthRef::None), + ..Default::default() + }, + ..Default::default() + }; + let merged = lower.merge(upper); + assert_eq!( + merged.model.ref_.as_deref(), + Some("anthropic/claude-sonnet-4-6") + ); + assert_eq!(merged.model.auth, Some(AuthRef::None)); + } } diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index bec5ecea..9ccacb33 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -4,10 +4,10 @@ mod model; mod scope; pub use config::{ - CompactionConfigPartial, ModelConfigPartial, PodManifestConfig, PodMetaConfig, ResolveError, + CompactionConfigPartial, PodManifestConfig, PodMetaConfig, ResolveError, ToolOutputLimitsPartial, WorkerManifestConfig, }; -pub use model::{AuthRef, ModelConfig, SchemeKind}; +pub use model::{AuthRef, ModelCapability, ModelManifest, SchemeKind}; pub use protocol::{Permission, ScopeRule}; pub use scope::{Scope, ScopeError}; @@ -26,7 +26,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PodManifest { pub pod: PodMeta, - pub model: ModelConfig, + pub model: ModelManifest, pub worker: WorkerManifest, pub scope: ScopeConfig, #[serde(default)] @@ -191,7 +191,7 @@ pub struct CompactionConfig { /// Optional model for the compactor (summary) LLM. /// If omitted, the main model is cloned via `clone_boxed()`. #[serde(default)] - pub model: Option, + pub model: Option, } fn default_prune_protected_turns() -> usize { @@ -255,9 +255,12 @@ permission = "write" fn parse_minimal_manifest() { let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap(); assert_eq!(manifest.pod.name, "test-agent"); - assert_eq!(manifest.model.scheme, SchemeKind::Anthropic); - assert_eq!(manifest.model.model_id, "claude-sonnet-4-20250514"); - assert_eq!(manifest.model.auth, AuthRef::None); + assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic)); + assert_eq!( + manifest.model.model_id.as_deref(), + Some("claude-sonnet-4-20250514") + ); + assert!(manifest.model.auth.is_none()); assert_eq!(manifest.scope.allow.len(), 1); assert!(manifest.scope.deny.is_empty()); assert_eq!(manifest.worker.instruction, defaults::DEFAULT_INSTRUCTION); @@ -294,8 +297,8 @@ permission = "write" "#; let manifest = PodManifest::from_toml(toml).unwrap(); assert_eq!(manifest.pod.name, "code-reviewer"); - let file = match &manifest.model.auth { - AuthRef::ApiKey { file, .. } => file.as_deref(), + let file = match manifest.model.auth.as_ref() { + Some(AuthRef::ApiKey { file, .. }) => file.as_deref(), _ => panic!("expected ApiKey"), }; assert_eq!(file, Some(std::path::Path::new("/abs/keys/anthropic"))); @@ -398,8 +401,8 @@ model_id = "claude-sonnet-4-20250514" let manifest = PodManifest::from_toml(&toml).unwrap(); let c = manifest.compaction.unwrap(); let p = c.model.unwrap(); - assert_eq!(p.scheme, SchemeKind::Gemini); - assert_eq!(p.model_id, "gemini-2.0-flash"); + assert_eq!(p.scheme, Some(SchemeKind::Gemini)); + assert_eq!(p.model_id.as_deref(), Some("gemini-2.0-flash")); } #[test] diff --git a/crates/manifest/src/model.rs b/crates/manifest/src/model.rs index a3dcae40..c37f01f3 100644 --- a/crates/manifest/src/model.rs +++ b/crates/manifest/src/model.rs @@ -1,8 +1,14 @@ //! LLM モデル宣言型 //! -//! Pod マニフェストの `[model]` セクションで記述する型。`scheme` と -//! `auth` を直交軸として表現し、1 つの汎用アダプタ(`crates/provider`) -//! で任意の wire / 認証組合せを受け止める。 +//! Pod マニフェストの `[model]` セクションで記述する型。`ref`(プロバイダ +//! とモデルを両方指し示す短縮形)と inline 指定(`scheme` / `model_id` +//! 直書き)の両方を受け入れるため、すべてのフィールドを `Option` として +//! 持つ 1 つの型 [`ModelManifest`] に統合している。実解決(ref をプロバイダ +//! カタログ / モデルカタログから引いて `scheme` や `model_id` を埋める) +//! は `crates/provider` の責務で、本モジュールはデータ表現のみを提供する。 +//! +//! 同じ型を partial(カスケード層)と完成形(最終マニフェスト)の両方で +//! 使うことで、merge と最終変換の重複を避ける。 use std::path::PathBuf; @@ -12,27 +18,57 @@ use serde::{Deserialize, Serialize}; // マニフェストで任意に override できるよう型だけ再エクスポートする。 pub use llm_worker::llm_client::capability::ModelCapability; -/// Pod が使う LLM モデルの宣言。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ModelConfig { - /// wire format - pub scheme: SchemeKind, - /// API のベース URL。未指定なら scheme の既定値にフォールバック - #[serde(default)] +/// Pod マニフェストの `[model]` セクション。 +/// +/// - ref だけ書く: `[model] ref = "anthropic/claude-sonnet-4-6"` +/// - ref + 一部 override: ref で基底を引き、`auth` 等だけ書き換え +/// - 完全 inline: `ref` を省略して `scheme` / `model_id` / `auth` を直書き +/// +/// どの形が有効かの判定は `provider::resolve_model_manifest` が担う。 +/// 本クレートは「どこから取るか」を表現するだけで、未設定かどうかを +/// 理由にした hard error は出さない。 +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct ModelManifest { + /// `/` 形式のカタログ参照。`/` の + /// 最初の 1 文字目で split し provider カタログを引く。 + /// OpenRouter の `anthropic/claude-sonnet-4` のように `/` を含む + /// model_id は `openrouter/anthropic/claude-sonnet-4` と書く + /// (provider 側で最初の `/` のみ split するため)。 + #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")] + pub ref_: Option, + /// wire format の明示指定。ref 未指定時は必須、ref 指定時は override。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheme: Option, + /// API のベース URL。scheme の既定値を override する。 + #[serde(default, skip_serializing_if = "Option::is_none")] pub base_url: Option, - /// プロバイダが受け付けるモデル ID - pub model_id: String, - /// 認証方式 - #[serde(default)] - pub auth: AuthRef, - /// モデル能力の明示指定。`None` のときは `crates/provider` が - /// scheme 静的テーブル → scheme 既定値の順でフォールバックする。 - /// OpenAI 互換ルーター(OpenRouter / xAI / Groq 等)で scheme テーブル - /// に載っていないモデル ID を使うときに指定する。 - #[serde(default)] + /// プロバイダが受け付けるモデル ID。ref 未指定時は必須。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, + /// 認証方式。ref 未指定時は必須、ref 指定時は override。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + /// モデル能力の明示指定。未指定時はモデルカタログ → provider + /// `default_capability` → scheme 既定の順で解決される。 + #[serde(default, skip_serializing_if = "Option::is_none")] pub capability: Option, } +impl ModelManifest { + /// `upper` を `self` に上書きマージする。マニフェスト cascade 向け + /// (builtin → user → project → overlay の優先順位で呼ばれる)。 + pub fn merge(self, upper: Self) -> Self { + Self { + ref_: upper.ref_.or(self.ref_), + scheme: upper.scheme.or(self.scheme), + base_url: upper.base_url.or(self.base_url), + model_id: upper.model_id.or(self.model_id), + auth: upper.auth.or(self.auth), + capability: upper.capability.or(self.capability), + } + } +} + /// サポートする wire scheme の種類。 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index 929a690f..5b07e91c 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -683,11 +683,28 @@ where St: Store, { let manifest = pod.manifest(); - let provider = match manifest.model.scheme { - manifest::SchemeKind::Anthropic => "anthropic", - manifest::SchemeKind::OpenaiChat => "openai_chat", - manifest::SchemeKind::OpenaiResponses => "openai_responses", - manifest::SchemeKind::Gemini => "gemini", + // `build_client` がここに到達する前に同じマニフェストで成功している + // ため、カタログ解決も必ず通る。念のため失敗時は "unknown" に落とす。 + let resolved = provider::catalog::resolve_model_manifest(&manifest.model).ok(); + let (provider_name, model_id) = match resolved { + Some(cfg) => { + let name = match cfg.scheme { + manifest::SchemeKind::Anthropic => "anthropic", + manifest::SchemeKind::OpenaiChat => "openai_chat", + manifest::SchemeKind::OpenaiResponses => "openai_responses", + manifest::SchemeKind::Gemini => "gemini", + }; + (name.to_string(), cfg.model_id) + } + None => ( + "unknown".to_string(), + manifest + .model + .ref_ + .clone() + .or_else(|| manifest.model.model_id.clone()) + .unwrap_or_default(), + ), }; // The tool list mirrors what `spawn()` registers on the Worker: // builtin filesystem tools plus the pod-orchestration tools. @@ -708,8 +725,8 @@ where protocol::Greeting { pod_name: manifest.pod.name.clone(), cwd: pod.pwd().display().to_string(), - provider: provider.into(), - model: manifest.model.model_id.clone(), + provider: provider_name, + model: model_id, scope_summary: pod.scope().summary(), tools: tool_names, } diff --git a/crates/pod/src/factory.rs b/crates/pod/src/factory.rs index f2ffcf1e..302f2d69 100644 --- a/crates/pod/src/factory.rs +++ b/crates/pod/src/factory.rs @@ -411,7 +411,7 @@ name = "overlay-name" // overlay layer so later calls win. This also exercises the // scope union across layers (two allow rules). assert_eq!(manifest.pod.name, "overlay-name"); - assert_eq!(manifest.model.model_id, "project-model"); + assert_eq!(manifest.model.model_id.as_deref(), Some("project-model")); assert_eq!(manifest.scope.allow.len(), 2); } @@ -461,7 +461,7 @@ model_id = "project-model" .unwrap(); // project layer overrides user layer on model.model_id - assert_eq!(manifest.model.model_id, "project-model"); + assert_eq!(manifest.model.model_id.as_deref(), Some("project-model")); // user layer provides the rest assert_eq!(manifest.pod.name, "from-user"); } diff --git a/crates/pod/src/lib.rs b/crates/pod/src/lib.rs index 924df603..2274d6b5 100644 --- a/crates/pod/src/lib.rs +++ b/crates/pod/src/lib.rs @@ -32,7 +32,7 @@ pub use factory::{FactoryError, PodFactory}; pub use notifier::Notifier; pub use hook::{Hook, HookEventKind, HookRegistryBuilder}; pub use manifest::{ - AuthRef, ModelConfig, PodManifest, PodManifestConfig, PodMetaConfig, Scope, SchemeKind, + AuthRef, ModelManifest, PodManifest, PodManifestConfig, PodMetaConfig, Scope, SchemeKind, }; pub use pod::{Pod, PodError, PodRunResult, apply_worker_manifest}; pub use prompt_loader::PromptLoader; diff --git a/crates/pod/src/spawn_pod.rs b/crates/pod/src/spawn_pod.rs index 38b17944..1c2bfe7b 100644 --- a/crates/pod/src/spawn_pod.rs +++ b/crates/pod/src/spawn_pod.rs @@ -14,8 +14,8 @@ use std::time::Duration; use async_trait::async_trait; use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use manifest::{ - ModelConfig, ModelConfigPartial, Permission, PodManifestConfig, PodMetaConfig, ScopeConfig, - ScopeRule, WorkerManifestConfig, + ModelManifest, Permission, PodManifestConfig, PodMetaConfig, ScopeConfig, ScopeRule, + WorkerManifestConfig, }; use protocol::Method; use protocol::stream::JsonLineWriter; @@ -118,7 +118,7 @@ pub struct SpawnPodTool { /// Pod's overlay TOML so the child does not need its own provider /// configuration in the manifest cascade. Per-spawn override is /// out of scope here (see `tickets/spawn-inherit-provider.md`). - spawner_model: ModelConfig, + spawner_model: ModelManifest, } impl SpawnPodTool { @@ -129,7 +129,7 @@ impl SpawnPodTool { spawner_pwd: PathBuf, registry: Arc, parent_socket: Option, - spawner_model: ModelConfig, + spawner_model: ModelManifest, ) -> Self { Self { spawner_name, @@ -350,20 +350,14 @@ fn build_overlay_toml( name: &str, instruction: &str, scope_allow: &[ScopeRule], - model: &ModelConfig, + model: &ModelManifest, ) -> Result { let overlay = PodManifestConfig { pod: PodMetaConfig { name: Some(name.to_string()), prompt_pack: None, }, - model: ModelConfigPartial { - scheme: Some(model.scheme), - base_url: model.base_url.clone(), - model_id: Some(model.model_id.clone()), - auth: Some(model.auth.clone()), - capability: model.capability.clone(), - }, + model: model.clone(), worker: WorkerManifestConfig { instruction: Some(instruction.to_string()), ..Default::default() @@ -460,7 +454,7 @@ pub fn spawn_pod_tool( spawner_pwd: PathBuf, registry: Arc, parent_socket: Option, - spawner_model: ModelConfig, + spawner_model: ModelManifest, ) -> ToolDefinition { Arc::new(move || { let schema = schemars::schema_for!(SpawnPodInput); @@ -487,16 +481,16 @@ mod tests { use manifest::{AuthRef, SchemeKind}; #[test] - fn overlay_inherits_spawner_model() { - let model = ModelConfig { - scheme: SchemeKind::Anthropic, + fn overlay_inherits_inline_spawner_model() { + let model = ModelManifest { + scheme: Some(SchemeKind::Anthropic), base_url: Some("https://example.test".into()), - model_id: "claude-sonnet-4".into(), - auth: AuthRef::ApiKey { + model_id: Some("claude-sonnet-4".into()), + auth: Some(AuthRef::ApiKey { env: None, file: Some(PathBuf::from("/etc/keys/anthropic")), - }, - capability: None, + }), + ..Default::default() }; let toml_str = build_overlay_toml("child", "$insomnia/default", &[], &model).unwrap(); @@ -511,4 +505,18 @@ mod tests { }; assert_eq!(file.as_deref(), Some(Path::new("/etc/keys/anthropic"))); } + + #[test] + fn overlay_inherits_ref_spawner_model() { + let model = ModelManifest { + ref_: Some("anthropic/claude-sonnet-4-6".into()), + ..Default::default() + }; + let toml_str = build_overlay_toml("child", "$insomnia/default", &[], &model).unwrap(); + let parsed = PodManifestConfig::from_toml(&toml_str).unwrap(); + assert_eq!( + parsed.model.ref_.as_deref(), + Some("anthropic/claude-sonnet-4-6") + ); + } } diff --git a/crates/pod/tests/spawn_pod_test.rs b/crates/pod/tests/spawn_pod_test.rs index 73847665..d741a07b 100644 --- a/crates/pod/tests/spawn_pod_test.rs +++ b/crates/pod/tests/spawn_pod_test.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use std::sync::{LazyLock, Mutex}; use llm_worker::tool::{ToolError, ToolOutput}; -use manifest::{AuthRef, ModelConfig, Permission, SchemeKind, ScopeRule}; +use manifest::{AuthRef, ModelManifest, Permission, SchemeKind, ScopeRule}; use pod::runtime_dir::{RuntimeDir, SpawnedPodRecord}; use pod::scope_lock::{self, LockFileGuard}; use pod::spawn_pod::spawn_pod_tool; @@ -134,14 +134,15 @@ fn which_true() -> String { /// Tests don't exercise the model — they intercept the spawned /// child via a mock socket — but `spawn_pod_tool` needs a value to -/// embed in the overlay TOML. Any well-formed `ModelConfig` works. -fn dummy_model() -> ModelConfig { - ModelConfig { - scheme: SchemeKind::Anthropic, +/// embed in the overlay TOML. Any well-formed `ModelManifest` works. +fn dummy_model() -> ModelManifest { + ModelManifest { + scheme: Some(SchemeKind::Anthropic), base_url: None, - model_id: "claude-test".into(), - auth: AuthRef::None, + model_id: Some("claude-test".into()), + auth: Some(AuthRef::None), capability: None, + ..Default::default() } } diff --git a/crates/provider/README.md b/crates/provider/README.md index ce6eda33..a805b0fb 100644 --- a/crates/provider/README.md +++ b/crates/provider/README.md @@ -1,15 +1,20 @@ # provider -マニフェストの `ModelConfig` から適切な `LlmClient`(`HttpTransport`)を構築するファクトリクレート。APIキーの環境変数 / ファイル解決と scheme ↔ auth の整合検証を担う。 +マニフェストの `ModelManifest` から適切な `LlmClient`(`HttpTransport`)を構築するファクトリクレート。プロバイダ / モデルカタログの解決、API キーの環境変数 / ファイル解決、scheme ↔ auth の整合検証を担う。 ## 公開型 -- `build_client(config: &ModelConfig) -> Result, ProviderError>` — `SchemeKind` と `AuthRef` から `HttpTransport` を構築 -- `ProviderError` — クライアント構築エラー +- `build_client(manifest: &ModelManifest) -> Result, ProviderError>` — ref / inline を受け取り、カタログ解決 → `HttpTransport` 構築までを行う +- `build_client_from_config(config: &ModelConfig) -> Result, ProviderError>` — 解決済み `ModelConfig` から構築 +- `catalog::resolve_model_manifest(&ModelManifest) -> Result` — ref / inline を `ModelConfig` へ解決(build せずに参照のみ欲しいケース向け) +- `catalog::{load_providers, load_models}` — builtin + user override を解決したカタログ +- `ProviderError` / `CatalogError` / `catalog::ResolveError` — エラー種別 ## 責務 +- プロバイダ / モデルカタログの builtin (`resources/{providers,models}/builtin.toml`) と user override (`$XDG_CONFIG_HOME/insomnia/{providers,models}.toml`) の解決 +- `ModelManifest` の ref 形を `(provider, model_id)` に split し、`ModelConfig` へ展開 - `AuthRef::ApiKey` を `ResolvedAuth::ApiKey` に解決(env → file の優先順位) -- `AuthRef::None` を `ResolvedAuth::None` に変換 +- `AuthRef::None` / `AuthRef::CodexOAuth` の解決 - `Scheme::required_auth()` と `ResolvedAuth` の妥当性検証(非対応組合せは構築エラー) -- 既知モデルは scheme の静的テーブル、未知モデルは scheme 既定の `ModelCapability` を採用 +- capability は manifest 明示 > model catalog > provider.default_capability > `Scheme::default_capability()` の順で解決 diff --git a/crates/provider/src/capability.rs b/crates/provider/src/capability.rs deleted file mode 100644 index 54a39b49..00000000 --- a/crates/provider/src/capability.rs +++ /dev/null @@ -1,211 +0,0 @@ -//! `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/catalog.rs b/crates/provider/src/catalog.rs index 9d15bbe1..1c92a50c 100644 --- a/crates/provider/src/catalog.rs +++ b/crates/provider/src/catalog.rs @@ -1,21 +1,25 @@ -//! プロバイダ/モデルカタログ。 +//! プロバイダ / モデルカタログ。 //! -//! builtin (`assets/providers.toml`) と user override -//! (`$XDG_CONFIG_HOME/insomnia/providers.toml`) を読み、 -//! `Vec` を返す。user override がある場合は builtin を -//! 置き換える(マージしない)。 +//! - builtin プロバイダ: `resources/providers/builtin.toml` +//! - builtin モデル: `resources/models/builtin.toml` +//! - user override: `$XDG_CONFIG_HOME/insomnia/{providers,models}.toml` //! -//! `ProviderEntry` から [`ModelConfig`] への変換は -//! [`ProviderEntry::to_model_config`] で行う。`auth_hint` はここでは -//! UI 表示用のヒントで、実際の認証解決は従来通り [`crate::build_client`] -//! が `AuthRef` から行う。 +//! どちらの override も「あれば builtin を置換、無ければ builtin」と +//! いう一方向の差し替え(マージしない)。providers / models は独立に +//! 読み、片方だけ user override も可。 +//! +//! [`resolve_model_manifest`] が `manifest::ModelManifest`(ref / inline +//! 両形)を最終的な [`ModelConfig`] に解決する単一の入口で、wire 層 +//! に渡す前のバリデーションもここで行う。 use std::path::{Path, PathBuf}; -use manifest::{AuthRef, ModelConfig, SchemeKind}; +use llm_worker::llm_client::capability::ModelCapability; +use manifest::{AuthRef, ModelManifest, SchemeKind}; use serde::{Deserialize, Serialize}; -const BUILTIN_CATALOG: &str = include_str!("../assets/providers.toml"); +const BUILTIN_PROVIDERS: &str = include_str!("../../../resources/providers/builtin.toml"); +const BUILTIN_MODELS: &str = include_str!("../../../resources/models/builtin.toml"); #[derive(Debug, thiserror::Error)] pub enum CatalogError { @@ -35,11 +39,30 @@ pub enum CatalogError { BuiltinParse(#[source] toml::de::Error), } +/// マニフェスト解決時のエラー。`ModelManifest` がカタログ参照を満たせ +/// ない、あるいは inline フォームでも必須フィールドが揃わない場合に +/// 返す。 +#[derive(Debug, thiserror::Error)] +pub enum ResolveError { + #[error("failed to load provider catalog: {0}")] + LoadProviders(#[source] CatalogError), + #[error("failed to load model catalog: {0}")] + LoadModels(#[source] CatalogError), + #[error("invalid model.ref `{0}`: must be `/`")] + MalformedRef(String), + #[error("model.ref points to unknown provider `{0}`")] + UnknownProvider(String), + #[error( + "model.ref omitted; manifest must specify scheme, model_id, and auth (missing: {0})" + )] + InlineMissing(&'static str), +} + /// UI 向けの認証ヒント。 /// /// 「何を表示・要求するか」のメタ情報で、ランタイムの [`AuthRef`] -/// とは責務が別。1:1 の対応関係にあり、 -/// [`ProviderEntry::to_model_config`] で相互変換される。 +/// とは責務が別。1:1 の対応関係にあり、[`auth_hint_to_ref`] / [`AuthHint`] +/// 解決経路で相互変換される。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum AuthHint { @@ -55,10 +78,7 @@ pub enum AuthHint { CodexOAuth, } -/// カタログ 1 エントリ。 -/// -/// 将来 `discover: Option` を任意で追加予定(Ollama -/// `/api/tags` 等の動的モデル列挙)。別チケットで実装する。 +/// プロバイダカタログの 1 エントリ。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ProviderEntry { pub id: String, @@ -67,78 +87,248 @@ pub struct ProviderEntry { #[serde(default)] pub base_url: Option, pub auth_hint: AuthHint, + /// モデルカタログ未登録モデルでこの provider が使われたとき + /// (ref で provider はあるが model 行は無い等)のフォールバック。 + /// 省略時は `Scheme::default_capability()` を最終フォールバックに + /// 使う。 #[serde(default)] - pub default_models: Vec, + pub default_capability: Option, +} + +/// モデルカタログの 1 エントリ。 +/// +/// `id` は **provider 内ユニーク**。同じ `gpt-5` が異なる provider に +/// 存在するのは OK で、ref が必ず `/` を含むため +/// 曖昧性が出ない。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ModelEntry { + pub id: String, + pub provider: String, + /// モデル単位の capability override。省略時は + /// `ProviderEntry::default_capability` にフォールバックする。 + #[serde(default)] + pub capability: Option, +} + +/// 解決済みモデル設定。`build_client` が消費する完成形。 +#[derive(Debug, Clone, PartialEq)] +pub struct ModelConfig { + pub scheme: SchemeKind, + pub base_url: Option, + pub model_id: String, + pub auth: AuthRef, + pub capability: Option, } #[derive(Debug, Deserialize)] -struct CatalogFile { +struct ProviderCatalogFile { #[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, - } +#[derive(Debug, Deserialize)] +struct ModelCatalogFile { + #[serde(default)] + model: Vec, +} + +/// `auth_hint` に対応する [`AuthRef`] のひな型を返す。env / file は +/// マニフェスト側で override 可能なので、ここでは hint そのままを +/// 反映した最小形だけを返す(`AuthRef::ApiKey { env: hint_env, file: None }`)。 +fn auth_hint_to_ref(hint: &AuthHint) -> AuthRef { + match hint { + AuthHint::None => AuthRef::None, + AuthHint::ApiKey { env } => AuthRef::ApiKey { + env: env.clone(), + file: None, + }, + AuthHint::CodexOAuth => AuthRef::CodexOAuth, } } -/// builtin + user override を解決してカタログを返す。 +// --- providers --------------------------------------------------------------- + +/// builtin + user override を解決して provider カタログを返す。 /// /// 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() +pub fn load_providers() -> Result, CatalogError> { + if let Some(path) = user_override_path("providers.toml") && path.is_file() { - return load_from_path(&path); + return load_providers_from(&path); } - load_builtin() + load_builtin_providers() } -/// builtin カタログ (`assets/providers.toml`) のみを返す。 -pub fn load_builtin() -> Result, CatalogError> { - let parsed: CatalogFile = - toml::from_str(BUILTIN_CATALOG).map_err(CatalogError::BuiltinParse)?; +/// builtin provider カタログのみを返す。 +pub fn load_builtin_providers() -> Result, CatalogError> { + let parsed: ProviderCatalogFile = + toml::from_str(BUILTIN_PROVIDERS).map_err(CatalogError::BuiltinParse)?; Ok(parsed.provider) } -/// 指定パスから読む(テスト・明示指定用)。 -pub fn load_from_path(path: &Path) -> Result, CatalogError> { +/// 指定パスから provider カタログを読む。 +pub fn load_providers_from(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, - })?; + let parsed: ProviderCatalogFile = + toml::from_str(&text).map_err(|source| CatalogError::Parse { + path: path.to_path_buf(), + source, + })?; Ok(parsed.provider) } -fn user_override_path() -> Option { +// --- models ------------------------------------------------------------------ + +/// builtin + user override を解決してモデルカタログを返す。 +pub fn load_models() -> Result, CatalogError> { + if let Some(path) = user_override_path("models.toml") + && path.is_file() + { + return load_models_from(&path); + } + load_builtin_models() +} + +/// builtin model カタログのみを返す。 +pub fn load_builtin_models() -> Result, CatalogError> { + let parsed: ModelCatalogFile = + toml::from_str(BUILTIN_MODELS).map_err(CatalogError::BuiltinParse)?; + Ok(parsed.model) +} + +/// 指定パスからモデルカタログを読む。 +pub fn load_models_from(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: ModelCatalogFile = toml::from_str(&text).map_err(|source| CatalogError::Parse { + path: path.to_path_buf(), + source, + })?; + Ok(parsed.model) +} + +// --- ref 解決 / マニフェスト → ModelConfig --------------------------------- + +/// `/` の最初の `/` で 1 回だけ split する。 +/// OpenRouter の `openrouter/anthropic/claude-sonnet-4` のように +/// model_id に `/` を含むケースは、provider=`openrouter`、 +/// model_id=`anthropic/claude-sonnet-4` として通る。 +fn split_ref(s: &str) -> Option<(&str, &str)> { + let (provider, rest) = s.split_once('/')?; + if provider.is_empty() || rest.is_empty() { + return None; + } + Some((provider, rest)) +} + +/// `ModelManifest` をカタログ込みで解決し、最終 [`ModelConfig`] を返す。 +/// +/// - **`ref` あり** → provider カタログを引き、未登録なら hard error。 +/// model カタログは未登録でも warn ログだけに留め、`provider.default_capability` +/// にフォールバック(タイポで動く可能性は API 側 `model not found` で +/// 結果的に検出されるため)。 +/// - **`ref` なし** → `scheme` / `model_id` / `auth` の 3 つが揃って +/// いることを検証し、そのまま `ModelConfig` を組む。 +/// +/// 各フィールドの解決順は ticket の表に準拠: +/// scheme/base_url は manifest 明示 > provider、model_id は manifest 明示 > ref、 +/// auth は manifest 明示 > provider.auth_hint 由来、capability は +/// manifest 明示 > model catalog > provider.default_capability > +/// (`build_client` 側で)`Scheme::default_capability()`。 +pub fn resolve_model_manifest(manifest: &ModelManifest) -> Result { + let providers = load_providers().map_err(ResolveError::LoadProviders)?; + let models = load_models().map_err(ResolveError::LoadModels)?; + resolve_with_catalogs(manifest, &providers, &models) +} + +/// テスト等で in-memory カタログを差し込む解決経路。 +pub fn resolve_with_catalogs( + manifest: &ModelManifest, + providers: &[ProviderEntry], + models: &[ModelEntry], +) -> Result { + if let Some(ref_str) = &manifest.ref_ { + let (provider_id, ref_model_id) = split_ref(ref_str) + .ok_or_else(|| ResolveError::MalformedRef(ref_str.clone()))?; + let provider = providers + .iter() + .find(|p| p.id == provider_id) + .ok_or_else(|| ResolveError::UnknownProvider(provider_id.to_string()))?; + + // model 行は無くても続行可(warn ログ + provider.default_capability)。 + let model_entry = models + .iter() + .find(|m| m.provider == provider_id && m.id == ref_model_id); + if model_entry.is_none() { + tracing::warn!( + provider = provider_id, + model = ref_model_id, + "model.ref not found in model catalog; falling back to provider.default_capability" + ); + } + + let scheme = manifest.scheme.unwrap_or(provider.scheme); + let base_url = manifest + .base_url + .clone() + .or_else(|| provider.base_url.clone()); + let model_id = manifest + .model_id + .clone() + .unwrap_or_else(|| ref_model_id.to_string()); + let auth = manifest + .auth + .clone() + .unwrap_or_else(|| auth_hint_to_ref(&provider.auth_hint)); + let capability = manifest.capability.clone().or_else(|| { + model_entry + .and_then(|m| m.capability.clone()) + .or_else(|| provider.default_capability.clone()) + }); + Ok(ModelConfig { + scheme, + base_url, + model_id, + auth, + capability, + }) + } else { + let scheme = manifest + .scheme + .ok_or(ResolveError::InlineMissing("scheme"))?; + let model_id = manifest + .model_id + .clone() + .ok_or(ResolveError::InlineMissing("model_id"))?; + let auth = manifest + .auth + .clone() + .ok_or(ResolveError::InlineMissing("auth"))?; + Ok(ModelConfig { + scheme, + base_url: manifest.base_url.clone(), + model_id, + auth, + capability: manifest.capability.clone(), + }) + } +} + +fn user_override_path(file_name: &str) -> Option { if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") && !dir.is_empty() { - return Some(PathBuf::from(dir).join("insomnia").join("providers.toml")); + return Some(PathBuf::from(dir).join("insomnia").join(file_name)); } if let Ok(home) = std::env::var("HOME") && !home.is_empty() @@ -147,7 +337,7 @@ fn user_override_path() -> Option { PathBuf::from(home) .join(".config") .join("insomnia") - .join("providers.toml"), + .join(file_name), ); } None @@ -156,12 +346,11 @@ fn user_override_path() -> Option { #[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(); + fn builtin_has_four_providers() { + let entries = load_builtin_providers().unwrap(); let ids: Vec<&str> = entries.iter().map(|e| e.id.as_str()).collect(); assert_eq!( ids, @@ -170,76 +359,157 @@ mod tests { } #[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()); + fn builtin_provider_default_capability_present() { + let entries = load_builtin_providers().unwrap(); + let anthropic = entries.iter().find(|e| e.id == "anthropic").unwrap(); + assert!(anthropic.default_capability.is_some()); } #[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"), + fn builtin_models_cover_each_provider() { + let entries = load_builtin_models().unwrap(); + let providers: std::collections::BTreeSet<&str> = + entries.iter().map(|m| m.provider.as_str()).collect(); + for p in ["anthropic", "ollama-local", "codex-oauth", "openrouter"] { + assert!( + providers.contains(p), + "model catalog should cover provider `{p}`" + ); } } #[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"); + fn resolve_ref_pulls_provider_defaults() { + let providers = load_builtin_providers().unwrap(); + let models = load_builtin_models().unwrap(); + let manifest = ModelManifest { + ref_: Some("anthropic/claude-sonnet-4-6".into()), + ..Default::default() + }; + let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap(); + assert_eq!(cfg.scheme, SchemeKind::Anthropic); + assert_eq!(cfg.model_id, "claude-sonnet-4-6"); + assert_eq!( + cfg.base_url.as_deref(), + Some("https://api.anthropic.com") + ); match cfg.auth { AuthRef::ApiKey { env, file } => { - assert_eq!(env.as_deref(), Some("INSOMNIA_API_KEY_OPENROUTER")); + assert_eq!(env.as_deref(), Some("INSOMNIA_API_KEY_ANTHROPIC")); assert!(file.is_none()); } - _ => panic!("expected ApiKey"), + _ => panic!("expected ApiKey auth from provider hint"), } - - 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); + assert!(cfg.capability.is_some(), "should fall back to provider.default_capability"); } #[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()); + fn resolve_ref_with_inline_overrides() { + let providers = load_builtin_providers().unwrap(); + let models = load_builtin_models().unwrap(); + let manifest = ModelManifest { + ref_: Some("anthropic/claude-sonnet-4-6".into()), + auth: Some(AuthRef::ApiKey { + env: None, + file: Some(PathBuf::from("/tmp/sk-ant")), + }), + ..Default::default() + }; + let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap(); + match cfg.auth { + AuthRef::ApiKey { env, file } => { + assert!(env.is_none()); + assert_eq!(file.as_deref(), Some(Path::new("/tmp/sk-ant"))); + } + _ => panic!("override auth should win"), + } } #[test] - fn load_from_path_reads_override() { + fn resolve_ref_with_nested_model_id() { + // OpenRouter: `//` 形式の model_id を持つ + let providers = load_builtin_providers().unwrap(); + let models = load_builtin_models().unwrap(); + let manifest = ModelManifest { + ref_: Some("openrouter/anthropic/claude-sonnet-4".into()), + ..Default::default() + }; + let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap(); + assert_eq!(cfg.scheme, SchemeKind::OpenaiChat); + assert_eq!(cfg.model_id, "anthropic/claude-sonnet-4"); + } + + #[test] + fn resolve_ref_unknown_provider_is_hard_error() { + let providers = load_builtin_providers().unwrap(); + let models = load_builtin_models().unwrap(); + let manifest = ModelManifest { + ref_: Some("nope/some-model".into()), + ..Default::default() + }; + let err = resolve_with_catalogs(&manifest, &providers, &models).unwrap_err(); + assert!(matches!(err, ResolveError::UnknownProvider(_))); + } + + #[test] + fn resolve_ref_unknown_model_is_warn_not_error() { + let providers = load_builtin_providers().unwrap(); + let models = load_builtin_models().unwrap(); + let manifest = ModelManifest { + ref_: Some("anthropic/some-future-claude".into()), + ..Default::default() + }; + let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap(); + assert_eq!(cfg.model_id, "some-future-claude"); + assert!(cfg.capability.is_some(), "should use provider default"); + } + + #[test] + fn resolve_inline_full_form() { + let providers = load_builtin_providers().unwrap(); + let models = load_builtin_models().unwrap(); + let manifest = ModelManifest { + scheme: Some(SchemeKind::Anthropic), + model_id: Some("claude-sonnet-4-6".into()), + auth: Some(AuthRef::ApiKey { + env: None, + file: Some(PathBuf::from("/tmp/sk")), + }), + ..Default::default() + }; + let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap(); + assert_eq!(cfg.scheme, SchemeKind::Anthropic); + assert_eq!(cfg.model_id, "claude-sonnet-4-6"); + assert!(cfg.capability.is_none(), "no catalog hit for inline-only"); + } + + #[test] + fn resolve_inline_missing_auth_errors() { + let providers = load_builtin_providers().unwrap(); + let models = load_builtin_models().unwrap(); + let manifest = ModelManifest { + scheme: Some(SchemeKind::Anthropic), + model_id: Some("claude".into()), + ..Default::default() + }; + let err = resolve_with_catalogs(&manifest, &providers, &models).unwrap_err(); + assert!(matches!(err, ResolveError::InlineMissing("auth"))); + } + + #[test] + fn malformed_ref_errors() { + let providers = load_builtin_providers().unwrap(); + let models = load_builtin_models().unwrap(); + let manifest = ModelManifest { + ref_: Some("noslash".into()), + ..Default::default() + }; + let err = resolve_with_catalogs(&manifest, &providers, &models).unwrap_err(); + assert!(matches!(err, ResolveError::MalformedRef(_))); + } + + #[test] + fn load_providers_from_path() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("providers.toml"); std::fs::write( @@ -251,27 +521,26 @@ 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(); + let entries = load_providers_from(&path).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].id, "custom"); } #[test] - fn malformed_override_returns_parse_error() { + fn malformed_provider_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(); + let err = load_providers_from(&path).unwrap_err(); assert!(matches!(err, CatalogError::Parse { .. })); } #[test] #[serial] - fn load_prefers_override_over_builtin() { + fn load_prefers_user_override() { let dir = tempfile::tempdir().unwrap(); let insomnia_dir = dir.path().join("insomnia"); std::fs::create_dir_all(&insomnia_dir).unwrap(); @@ -289,7 +558,7 @@ auth_hint = { kind = "none" } let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok(); unsafe { std::env::set_var("XDG_CONFIG_HOME", dir.path()) }; - let entries = load().unwrap(); + let entries = load_providers().unwrap(); match prev_xdg { Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) }, None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") }, @@ -306,7 +575,7 @@ auth_hint = { kind = "none" } // 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(); + let entries = load_providers().unwrap(); match prev_xdg { Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) }, None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") }, diff --git a/crates/provider/src/lib.rs b/crates/provider/src/lib.rs index 9f1bb09f..1a41d6ae 100644 --- a/crates/provider/src/lib.rs +++ b/crates/provider/src/lib.rs @@ -1,16 +1,19 @@ -//! Pod マニフェストの [`ModelConfig`] を [`Box`] +//! Pod マニフェストの [`ModelManifest`] を [`Box`] //! に落とすファクトリ。 //! -//! * `SchemeKind` を各 `Scheme` 実装にマップ -//! * `AuthRef` を環境変数 / ファイルから解決して [`ResolvedAuth`] に -//! * `scheme.required_auth()` と解決値を照合(非対応組合せは構築エラー) -//! * `ModelCapability` は明示指定 → scheme 静的テーブル → 未知時はデフォルト +//! 段階: +//! 1. `ModelManifest` を [`catalog::resolve_model_manifest`] で +//! カタログ込み [`ModelConfig`] に解決(ref → 展開 / inline → 検証) +//! 2. `AuthRef` を環境変数 / ファイルから解決して [`ResolvedAuth`] に +//! 3. `scheme.required_auth()` と解決値を照合(非対応組合せは構築エラー) +//! 4. `ModelCapability` は manifest 明示 > model catalog > provider +//! default_capability > scheme 既定 の順でフォールバック(上位 3 段は +//! `catalog::resolve_model_manifest` が [`ModelConfig`] に詰め込む) //! //! llm-worker は低レベル基盤に留める方針なので、高レベル側で必要に //! なる認証ストア解決(Codex OAuth の `~/.codex/auth.json` 読取等)は //! このクレートに追加する。 -pub mod capability; pub mod catalog; pub mod codex_oauth; @@ -26,7 +29,9 @@ use llm_worker::llm_client::{ transport::{HttpTransport, ResolvedAuth}, }; -use manifest::{AuthRef, ModelConfig, SchemeKind}; +use manifest::{AuthRef, ModelManifest, SchemeKind}; + +pub use catalog::{ModelConfig, ResolveError as CatalogResolveError}; /// プロバイダ構築時のエラー。 #[derive(Debug, thiserror::Error)] @@ -42,6 +47,9 @@ pub enum ProviderError { #[error("scheme {scheme:?} is not implemented yet")] SchemeNotImplemented { scheme: SchemeKind }, + + #[error("failed to resolve model manifest: {0}")] + ManifestResolve(#[from] catalog::ResolveError), } /// `AuthRef` をランタイムで使える [`ResolvedAuth`] に解決する。 @@ -111,16 +119,14 @@ fn build_transport( scheme: config.scheme, }); } - // capability の優先順位: - // 1. `ModelConfig.capability` の明示指定(OpenAI 互換ルーターの - // 未知モデル等、マニフェストで完全に上書きしたいケース) - // 2. `provider::capability::lookup` の既知モデルテーブル - // (モデル ID の知識は高レベル構築層(ここ)の責務) - // 3. `Scheme::default_capability()`(scheme ごとの wire-level 安全側) + // capability の優先順位 (上位 3 段は `ModelConfig` に既に反映済み): + // 1. manifest 明示 + // 2. model catalog + // 3. provider.default_capability + // 4. `Scheme::default_capability()`(scheme ごとの wire-level 安全側) let capability: ModelCapability = config .capability .clone() - .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( @@ -132,8 +138,7 @@ fn build_transport( ))) } -/// [`ModelConfig`] から [`LlmClient`] を構築する。 -pub fn build_client(config: &ModelConfig) -> Result, ProviderError> { +fn build_from_config(config: &ModelConfig) -> Result, ProviderError> { let resolved = resolve_auth(config.scheme, &config.auth)?; match config.scheme { SchemeKind::Anthropic => build_transport(AnthropicScheme::new(), config, resolved), @@ -145,6 +150,23 @@ pub fn build_client(config: &ModelConfig) -> Result, Provider } } +/// [`ModelManifest`] から [`LlmClient`] を構築する。ref / inline の +/// いずれも受け取り、カタログ解決は内部で行う。 +pub fn build_client(manifest: &ModelManifest) -> Result, ProviderError> { + let config = catalog::resolve_model_manifest(manifest)?; + build_from_config(&config) +} + +/// 既に解決済みの [`ModelConfig`] から [`LlmClient`] を構築する。 +/// `ModelManifest` から既に `catalog::resolve_model_manifest` を通した +/// ケース(factory / spawn 経路でカタログ引きを 1 回だけにしたい等)で +/// 使う。 +pub fn build_client_from_config( + config: &ModelConfig, +) -> Result, ProviderError> { + build_from_config(config) +} + #[cfg(test)] mod tests { use super::*; @@ -243,39 +265,45 @@ mod tests { fn missing_key_returns_api_key_missing() { let env_name = SchemeKind::Anthropic.default_env_var(); unsafe { std::env::remove_var(env_name) }; - let result = build_client(&anthropic_config()); + let result = build_client_from_config(&anthropic_config()); assert!(matches!(result, Err(ProviderError::ApiKeyMissing { .. }))); } #[test] - fn model_config_capability_overrides_scheme_default() { - // 未知モデル ID でも `ModelConfig.capability` が指定されていれば - // scheme の静的テーブル / デフォルトではなくその値が採用される。 - use llm_worker::llm_client::capability::{ - CacheStrategy, ModelCapability, ReasoningEffort, ReasoningSupport, StructuredOutput, - ToolCallingSupport, + fn ref_manifest_builds_client() { + // Ollama は AuthRef::None で構築できる end-to-end path。 + let manifest = ModelManifest { + ref_: Some("ollama-local/llama3.1".into()), + ..Default::default() }; - - let explicit = ModelCapability { - tool_calling: ToolCallingSupport::Parallel, - structured_output: StructuredOutput::JsonSchema, - reasoning: Some(ReasoningSupport::Effort), - vision: true, - prompt_caching: CacheStrategy::Auto, - }; - - // TOML 経由の往復(`[model.capability]` が正しくパースできる) - let toml_str = toml::to_string(&explicit).unwrap(); - let round_trip: ModelCapability = toml::from_str(&toml_str).unwrap(); - assert_eq!(round_trip, explicit); - - // `_ = ReasoningEffort` は serde derive が欠けていると失敗する - // ほぼ確実なコンパイル時ガード。 - let _ = ReasoningEffort::Medium; + let client = build_client(&manifest); + assert!( + client.is_ok(), + "ollama ref should build without credentials: {:?}", + client.err() + ); } #[test] - fn ollama_succeeds_without_key() { + fn inline_manifest_builds_client() { + // Form C: 完全直書き。Ollama 相当を AuthRef::None で構築。 + let manifest = ModelManifest { + scheme: Some(SchemeKind::Anthropic), + base_url: Some("http://localhost:11434".into()), + model_id: Some("llama3".into()), + auth: Some(AuthRef::None), + ..Default::default() + }; + let client = build_client(&manifest); + assert!( + client.is_ok(), + "inline ollama config should build: {:?}", + client.err() + ); + } + + #[test] + fn ollama_config_succeeds_without_key() { // Ollama = Anthropic scheme + base_url 差し替え + AuthRef::None let config = ModelConfig { scheme: SchemeKind::Anthropic, @@ -284,8 +312,6 @@ mod tests { auth: AuthRef::None, capability: None, }; - // scheme.required_auth() が XApiKey でも ResolvedAuth::None は許容する - // (None は全 scheme で受け入れるため) - assert!(build_client(&config).is_ok()); + assert!(build_client_from_config(&config).is_ok()); } } diff --git a/docs/plan/memory-prompts.md b/docs/plan/memory-prompts.md index 13e82a21..02274130 100644 --- a/docs/plan/memory-prompts.md +++ b/docs/plan/memory-prompts.md @@ -24,16 +24,17 @@ memory 関連 prompt は種別を問わず、最低限以下を共有する: Phase 1 は「派生物を作る」段階ではなく、「起きたことを抽出する」段階として縛る: - 対象は `decisions`、`discussions`、`attempts`、`requests` の候補に限る -- Knowledge 化、summary rewrite、slug 命名、`auto_invoke` 判断は行わない +- Knowledge 化、summary rewrite、slug 命名、`model_invokation` 判断は行わない - 一回限りの雑談、浅い質問、長期参照価値の薄い進行ログは返さなくてよい - 出力は schema 準拠の構造化データのみ。自由文の補足説明で schema 外情報を足さない - 対象が無ければ空配列を返す ### Phase 2: 統合 prompt -Phase 2 は既存 `memory/*` と staging を見て、追加・更新・統合を agentic に判断する: +Phase 2 は既存 `memory/*`、`knowledge/*`、staging を見て、追加・更新・統合を agentic に判断する: -- 入力には staging の活動ログ、既存 `memory/*`、Knowledge 化候補レポートを含める +- 入力には staging の活動ログ、既存 `memory/*`(summary / decisions / requests)の全文、Knowledge 化候補レポートを含める +- 既存 `knowledge/*` は prompt に埋めず、Knowledge 検索ツール経由で agent が必要分を引く。まず候補レポートの source や staging の話題に近い slug を検索し、ヒットした slug / description / kind / `model_invokation` を見て適合先を探す - 新規作成より update を優先し、既存 slug に自然に統合できる場合は新規 file を増やさない - Decisions / Requests は staging の `source` をそのまま使い、LLM が `sources` を組み立てない - summary は必要なときだけ rewrite し、常に 1-5k tokens 目安に圧縮する @@ -47,26 +48,20 @@ Knowledge の新規作成 / 更新では、Phase 2 全体の原則に加えて - 採択ラインは「このプロジェクト / ユーザーに対して再度参照価値のある事実・ルール・ノウハウ」に限る - 一回限りの判断や議論は Decisions に留め、繰り返し参照される抽象化だけを Knowledge に上げる - 新規作成は Knowledge 化候補レポートに載った source から派生する場合に限る -- 既存 Knowledge の slug / description 一覧を見て、適合先があるなら必ず update を優先する +- 既存 Knowledge は Knowledge 検索ツールで検索し、ヒットした slug / description / kind / `model_invokation` を見て、適合先があるなら必ず update を優先する - 新規 slug は「既存に適合先が無い」と説明できるときだけ作る +- Knowledge は `kind` を frontmatter に持ち、少なくとも「用語 / 運用方針 / ルール / 事実 / ノウハウ」のどこに寄るかを明示する - `last_sources` は入力で与えられた source を使い、推論で補完しない - `description` は「何の知識か / いつ使うか」が短く分かる文にする -- `auto_invoke` ON/OFF は頻度・常駐コストの判断材料を踏まえて慎重に扱い、初期値は OFF とする +- description だけで対象範囲が分からない粒度や、複数主題を抱えた file は避ける +- `model_invokation` ON/OFF は頻度・常駐コストの判断材料を踏まえて慎重に扱い、初期値は OFF とする - `#` 参照を書く場合は、実在 record への参照だけを使う - `AGENTS.md` や `docs/` に既に固定化されたルールの写しは作らない - 保存価値が無ければ Knowledge を何も追加しない ### 監査 LLM prompt -監査 LLM は「よく書けているか」ではなく、「壊していないか」を見る: - -- 入力には write 前後 diff、対象 record の直前内容、今回の source / staging 抜粋、採択基準を渡す -- rewrite / 圧縮で主張、根拠、参照が失われていないかを確認する -- 新規追加が source や活動ログに裏打ちされているかを確認する -- session 固有の進行状態や一時的事情が混入していないかを確認する -- `description` が本文のスコープと一致しているかを確認する -- `auto_invoke` の設定が本文の重要度や再利用性と不整合でないかを確認する -- 問題がある場合は `pass | fail` に加えて違反カテゴリと具体箇所を返し、Hook が差し戻せる形にする +初期範囲では専用の監査 LLM は持たない(`memory.md` §書き込み経路と Linter / §将来検討 参照)。意味破壊の抑制は Phase 2 prompt 側の情報損失最小化指示と git diff レビューに寄せる。後から 2 層目として挟む際の入力・check 項目・pass-fail 返却形式はそのときに詰める。 ### GC prompt @@ -74,7 +69,8 @@ GC は Phase 2 より攻撃的に整理してよいが、可逆性と説明可 - 入力には GC 対象 record 群に加えて、Linter Warn、使用頻度メトリクス、`replaced` chain、sources 過多情報を含める - 明示 invoke 保護閾値を超える record は drop / 大幅圧縮の対象外とする -- `similar-slug`、`sources-overflow`、`replaced` 滞留、stale record を優先的に処理する +- 各 record を `outdated`、`superseded`、`unused`、`noisy` の観点で評価し、なぜ GC 対象なのかを分類する +- `similar-slug`、`sources-overflow`、`replaced` 滞留は主に `superseded` または `noisy` の材料として扱う - merge / split / trim / drop の理由を diff から読める形で残す - 直接削除してよいが、git で可逆である前提に甘えすぎず、誤判定しやすいものは merge / trim を優先する diff --git a/docs/plan/memory.md b/docs/plan/memory.md index 734ba220..8488830f 100644 --- a/docs/plan/memory.md +++ b/docs/plan/memory.md @@ -19,7 +19,7 @@ Workflow(`/` で呼び出される制約付き作業フロー)は別 p | Always-on サマリ | `memory/summary.md` | 1-5k tokens 目安 | | Decisions | `memory/decisions/.md` | `status: open \| resolved \| replaced` で未決議論も保持、置き換え時は `replaced_by: ` | | Requests | `memory/requests/.md` | ユーザー submit の構造化要約 | -| Knowledge | `memory/knowledge/.md` | `#slug` で注入。ノウハウ / 用語 / 運用方針 / ルール / 事実など型を設けず Markdown 自由記述 | +| Knowledge | `knowledge/.md` | `#slug` で注入。`kind` で大まかな型だけ持ち、本文は Markdown 自由記述 | - `` は kebab-case(内容を要約した短い識別子)。**ファイル名そのものが ID**、frontmatter に別途 `id` field は持たない - **1 件 1 ファイル**。append-only な複数エントリログファイルは作らない @@ -35,23 +35,54 @@ agentskills.io の `SKILL.md` 形式は採用しない。Knowledge は `#` | フラグ | 意味 | デフォルト | | ---------------- | ------------------------------------------------------- | ---------- | -| `auto_invoke` | description が LLM context に載り、LLM が自発的に呼べる | **OFF** | +| `model_invokation` | description が model context に載り、モデルが自発的に参照判断できる | **OFF** | | `user_invocable` | ユーザーが `#` で明示的に呼べる | **ON** | Knowledge は Phase 2 が自律的に新規作成 / 更新 / フラグ切替を行う前提。毎回の人間承認 gate は設けない(実効性が低い)。保護は 3 段で担保: - **採択 gate**: Knowledge 新規作成は使用頻度メトリクスの Knowledge 化候補レポート(後述)に載った source から派生する場合に限る。閾値未満のうちは decisions / requests に留める -- **Linter + 監査 LLM**: 構造違反と意味破壊を watch(詳細は後述) +- **Linter**: 構造違反を watch(詳細は後述)。意味破壊の自動検出は初期は持たず、挙動を見てから監査 LLM 層を追加する(将来検討) - **OS ファイル権限**: 人間が書き換えさせたくない record は `-r--` にしてロック。Phase 2 / GC の write は OS レベルで弾かれる Workflow も同じフラグ仕様(`workflow.md` 参照)。per-record 保護フラグを提供する拡張は将来検討、初期は OS 権限で足りる。 +### retrieval 経路 + +Knowledge / memory を LLM に渡す経路は以下で固定。採択基準(次節)と表裏で、引ける前提がないと採択しても無意味になる。 + +- **Knowledge 検索ツール**: frontmatter 含めた全文検索。通常 Pod と Phase 2 Pod の両方に渡す + - Input: `query`(自由文字列)。オプションで `slug`(完全一致 1 件返し、`#` 解決に使う)、`kind` filter + - Output: `{ slug, kind, description, model_invokation, excerpt }` の配列。`excerpt` はマッチ箇所の前後数行 + - 対象は `knowledge/*.md`。派生 index ファイルは持たず実ファイルを都度スキャン + - ソートは初期 grep の出現順、FTS / vector 導入時に関連度へ切り替え(将来検討) + - ヒット件数上限と excerpt 行数は設定で tune +- **memory 検索ツール**: `memory/{summary,decisions,requests}/*.md` 対象。spec は Knowledge 検索ツールと同型。§使用頻度メトリクスの観測経路と同一視する +- **更新は既定の汎用 CRUD + Linter**: Knowledge / memory とも §書き込み経路と Linter の汎用 CRUD tool + post-write Linter Hook で済ませる。専用の create/update ツールは作らない +- **常駐注入**: メモリを消費する主体は通常 Pod。`model_invokation: ON` な record の description を通常 Pod の system prompt に常駐注入する。Phase 2 prompt には入れない + - 予算はシステムプロンプト全体の予算に含める(`memory_summary.md` の 5k 枠とは別管理にしない) + - 超過時の件数キャップ / 優先順位ルールは、description 1024 chars 上限で通常は収まる前提。ON record 数が増えたら追加する +- **Phase 2 の Knowledge アクセス**: 全 Knowledge 本文を prompt に埋めず、Knowledge 検索ツール + 汎用 CRUD を agent に渡して自律探索させる(詳細は §Phase 2) +- **`#` 補完 / 自動呼び出し(大枠のみ、実装は段階的)**: + - `#` は検索ツールの slug 完全一致経路で本文が展開される + - 補完 UI(slug サジェスト)は TUI 側。`user_invocable: false` は候補除外 + - 自動呼び出しは、常駐注入された description をモデルが見て必要と判断すれば検索ツールを呼ぶ形で成立する。専用の auto-invoke 経路は別途用意しない + +### Knowledge の採択基準 + +Knowledge は「保存する価値があるか」だけでなく、「あとで見つけて再利用できるか」で評価する。最低限の基準は以下: + +- **slug は入口**。短く、何の知識か推測でき、`#` や検索で指名しやすいものを優先する +- **description は discovery 面そのもの**。本文の要約ではなく、「何の知識で、どんな時に読むべきか」を短く示す +- **1 file = 1 主題**。description だけで対象範囲が分かる粒度に寄せる。細分化しすぎる slug 乱立も避ける +- **update 優先**。新情報は既存 slug に畳み込み、自然な適合先がない時だけ新規 slug を作る +- **昇格ライン**は「このプロジェクト / ユーザーで再度参照する価値のある事実・ルール・ノウハウ」。一回限りの判断や議論は decisions / requests に留める +- **`model_invokation` ON は別判断**。重要度だけでなく、description だけで「どんな時に読むべきか」が伝わるものに限る + ### 書き込み経路と Linter -人間も consolidation sub-Worker も**同じ CRUD tool(file read / write / edit)**で `memory/*` を触る。書き込み時の制約は 2 層で検証し、違反時は post-write Hook が turn を戻して sub-Worker に自己修正させる(N 回失敗で abort): +人間も consolidation sub-Worker も**同じ CRUD tool(file read / write / edit)**で `memory/*` を触る。書き込み時の制約は静的 Linter で検証し、違反時は post-write Hook が turn を戻して sub-Worker に自己修正させる(N 回失敗で abort)。Linter は frontmatter / slug / 参照整合などの機械的ルールを見る。 -1. **Linter(静的)**: frontmatter / slug / 参照整合などの機械的ルール -2. **監査 LLM(意味的)**: rewrite が元の情報を壊していないかを別 prompt で check。特に Knowledge の意味損壊を watch する主経路 +意味破壊(rewrite で既存の主張・根拠が落ちる、Knowledge の記述主題がズレる等)の自動検出は初期範囲に含めない。Phase 2 prompt 側の情報損失最小化指示と git diff レビューで運用し、実使用で顕在化したら監査 LLM 層を後から挟む(将来検討)。 Linter ルールは 2 系統: @@ -59,14 +90,14 @@ Linter ルールは 2 系統: - frontmatter 必須 field - Decisions / Requests: `created_at`, `updated_at`, `sources` - - Knowledge: `description`, `auto_invoke`, `user_invocable`, `last_sources`, `created_at`, `updated_at` + - Knowledge: `kind`, `description`, `model_invokation`, `user_invocable`, `last_sources`, `created_at`, `updated_at` - Summary: `updated_at`(optional: `last_rewritten_from_range`) - `memory/workflow/` への書き込み禁止(sub-Worker context のみ、人間編集は除外) - 同 slug での新規作成禁止(既存があれば update に切り替えるサイン) - `#` 参照が実在ファイルを指す - `replaced_by: ` が実在 record を指す - Decisions の `status` は enum `open | resolved | replaced` -- `auto_invoke: true` の record は description 文字数上限(agentskills 準拠 1024 chars) +- `model_invokation: true` の record は description 文字数上限(agentskills 準拠 1024 chars) - 種別ごとの char 硬上限(具体値は運用で調整、設定ファイルで tune) **膨張抑制 Warn**(error ではなく改善ヒント、sub-Worker は task 余力があれば対応): @@ -100,12 +131,12 @@ Workflow 保護は専用 tool schema のトリックではなく Linter ルー - **Trigger**: staging の累積ファイル数 or bytes が閾値超過、または compact 発火時(必ず flush) - **実行主体**: Phase 1 を終えた pod が consolidation Worker を spawn。並走防止は staging 配下の進行状況ファイル(後述)で担保 -- **入力**: 起動時スナップショットで確定した consumed ID list 分の staging エントリ(活動ログ + `source`)+ 既存 `memory/*`(summary / decisions / requests / knowledge)+ **Knowledge 化候補レポート**(後述の使用頻度メトリクスから機械集計、閾値超過の source 一覧) -- **処理**: sub-Worker に**汎用 CRUD tool(file read / write / edit)+ post-write Linter Hook** を渡し、agentic に以下を自律判断: +- **入力**: 起動時スナップショットで確定した consumed ID list 分の staging エントリ(活動ログ + `source`)+ 既存 `memory/*`(summary / decisions / requests)の全文 + **Knowledge 化候補レポート**(後述の使用頻度メトリクスから機械集計、閾値超過の source 一覧)。既存 `knowledge/*` は全文を prompt に埋めず、Knowledge 検索ツール経由で agent が必要分を引く +- **処理**: sub-Worker に**汎用 CRUD tool(file read / write / edit)+ Knowledge 検索ツール + memory 検索ツール + post-write Linter Hook** を渡し、agentic に以下を自律判断: - 新規 decisions / requests を 1 件 1 ファイルで追加。`sources` は staging の `source` をコピー(LLM 推論ではない) - - 活動ログから派生する Knowledge(用語定義 / 運用方針 / ルール / 事実 / ノウハウ)を新規作成 or 既存 patch。**新規作成は候補レポート掲載の source から派生する場合に限る**。`last_sources` を更新 + - 活動ログから派生する Knowledge(用語定義 / 運用方針 / ルール / 事実 / ノウハウ)を新規作成 or 既存 patch。**新規作成は候補レポート掲載の source から派生する場合に限る**。`kind` を frontmatter に持ち、`last_sources` を更新 - summary を必要に応じて rewrite -- **書き込み先**: `memory/*` 配下。Workflow 禁止は Linter で担保(`workflow.md` 参照) +- **書き込み先**: `memory/*` と `knowledge/*`。Workflow 禁止は Linter で担保(`workflow.md` 参照) - **完了処理**: consumed ID list の staging のみ cleanup(実行中に Phase 1 が追加した分は残す)。Phase 2 完了時に staging に新着があれば次を発火(Coalesce) - **モデル**: `memory.consolidation_model`。reasoning 系 @@ -126,13 +157,14 @@ Workflow 保護は専用 tool schema のトリックではなく Linter ルー - **rewrite は許可**。既存内容と新規情報を統合・再構成して情報密度を上げることを優先。単純 append(追記で増やすだけ)は避ける - rewrite 時は**情報損失を最小化**する: 既存の主張・根拠・sources を保持。表現を整理・短縮しても、含まれている要素は落とさない - 削除は置き換え記録(`status: replaced` + `replaced_by: `)で表現、直接削除しない +- Knowledge は既存 record 群の slug / description / kind / `model_invokation` を入口に適合先を探し、自然に統合できるなら新規 slug を増やさない - 人間編集は git diff で顕在化する前提。整合しない rewrite は避け、衝突時は git で解決 #### Offer 経路 Memory record の書き込みは Phase 2 が自律判断し、Offer は設けない(Knowledge 含む)。人間承認経路が必要なのは以下: -- Workflow 関連の offer(新規作成 / 改善 / `auto_invoke` ON 化)は `workflow.md` 参照 +- Workflow 関連の offer(新規作成 / 改善 / `model_invokation` ON 化)は `workflow.md` 参照 #### Compact との関係 @@ -146,7 +178,7 @@ Phase 2 とは別経路で memory を再評価する定期ジョブ。Phase 2 - 類似 slug が乱立する(Linter Warn で検出したものをまとめて処理) - `replaced` が溜まり続けて grep / 注入時のノイズになる - sources 累積 -- 長期的に陳腐化した記録の drop +- 現状と不整合になった record、実質的に置き換え済みの record、使われていない record、形がノイズ化した record の整理 他プロジェクトの GC 設計の横断比較は `docs/ref/memory-systems.md` §8。 @@ -159,16 +191,27 @@ GC Agent は **drop / merge / split を自律実行**(削除まで含む)。 Phase 2 と同じ CRUD tool + Linter Hook を使うので、operation 粒度は自然にサポートされる(専用 API は用意しない)。 +#### GC の評価カテゴリ + +GC は record を一律に「stale」とみなさず、少なくとも次の 4 カテゴリで評価する: + +- `outdated`: 以前は妥当だったが、現在の実装・方針・運用と不整合になっている +- `superseded`: 別 record が実質的な正本になっており、元の record は置き換え済みに近い +- `unused`: 誤りではないが、明示 invoke や検索でほとんど参照されずノイズ化している +- `noisy`: 内容自体は有効でも、粒度・重複・冗長さ・sources 過多などで discovery / retrieval を悪化させている + +これらは **保護条件ではなく GC 理由の分類**。保護条件は別に持ち、その上で `drop / merge / split / trim / rewrite` のどれを選ぶかをこのカテゴリで説明可能にする。 + #### 使用頻度メトリクス 時間単位は実時間を使わない(LLM スループット向上で陳腐化の意味が変わるため)、累積 input token で正規化する。 -**観測経路**: `memory/*` への読み取りは専用の memory 検索ツール(既存 built-in の grep / read とは別に用意)経由に揃える。invoke 計測はツール内でフックし、`#` / `/` / 明示検索呼び出しを同一経路に集約する。 +**観測経路**: `memory/*` / `knowledge/*` への読み取りは §retrieval 経路 で定義した memory 検索ツール / Knowledge 検索ツール(既存 built-in の grep / read とは別に用意)経由に揃える。invoke 計測はツール内でフックし、`#` / `/` / 明示検索呼び出しを同一経路に集約する。 **カウント対象**: - **明示 invoke**: 検索ツール経由の読み取り / `#` / `/` を n回/Mtoken でスコア化 -- **auto_invoke 注入**: 注入は context 常駐コストで、「載っているだけ」か「使われた」かを統計上区別不能。明示 invoke の分子には含めず、**コスト側(注入した record に対する消費 input tokens)として別途記録**する。使われ率 ratio や ON/OFF 判断の材料として後段で使う +- **`model_invokation` 注入**: 注入は context 常駐コストで、「載っているだけ」か「使われた」かを統計上区別不能。明示 invoke の分子には含めず、**コスト側(注入した record に対する消費 input tokens)として別途記録**する。使われ率 ratio や ON/OFF 判断の材料として後段で使う - ファイル token 数 **記録先**: staging とは独立。invoke event を UUID + Stats 形式で workspace 側に記録し、session データが失われても統計が残るようにする。具体 schema・フォーマットは未定。 @@ -179,7 +222,8 @@ Phase 2 と同じ CRUD tool + Linter Hook を使うので、operation 粒度は #### 判断ルール -- 保護閾値: **明示 invoke** の `frequency >= 1.0 invokes/Mtoken` の record は drop / 大幅圧縮の対象外(初期値 1.0、workspace 設定でカスタマイズ可)。auto_invoke 注入による常駐は計数対象外(別指標として後段で参照) +- 保護閾値: **明示 invoke** の `frequency >= 1.0 invokes/Mtoken` の record は drop / 大幅圧縮の対象外(初期値 1.0、workspace 設定でカスタマイズ可)。`model_invokation` 注入による常駐は計数対象外(別指標として後段で参照) +- GC の評価カテゴリは `outdated | superseded | unused | noisy` を使う。単一 record が複数カテゴリに該当してもよい ### ファイル形式 @@ -189,7 +233,8 @@ Phase 2 と同じ CRUD tool + Linter Hook を使うので、operation 粒度は - Decisions / Requests: `sources: [{session_id, range: [start, end]}, ...]` 永続化(update 時は追記累積) - Knowledge: `last_sources: [{session_id, range}, ...]`(最新更新時のみ、過去履歴は git log で追う) - Summary: optional `last_rewritten_from_range`(なしでも可) -- Knowledge 固有: `description`, `auto_invoke`, `user_invocable` +- Knowledge 固有: `kind`, `description`, `model_invokation`, `user_invocable` +- Knowledge の保存先は `knowledge/.md`。`memory/` とは兄弟ディレクトリに分ける - Decisions 固有: `status: open | resolved | replaced`、置き換え時は `replaced_by: ` - Phase 1 staging: `memory/_staging/.json`(JSON、1 件 1 ファイル、Phase 2 完了で削除。短命なので UUIDv7 可)。pod 側ラッパーが `source` を機械付与して LLM 出力と wrap - Workflow の frontmatter は `workflow.md` 参照 @@ -200,8 +245,9 @@ Phase 2 と同じ CRUD tool + Linter Hook を使うので、operation 粒度は ### 将来検討(運用で必要性が見えたら追加) +- 監査 LLM 層(意味破壊検出)の導入 — 初期は静的 Linter のみで運用し、Phase 2 の rewrite で情報損失・主題ズレが実運用で顕在化したら post-write Hook の 2 層目として追加。入力 / check 項目 / pass-fail 返却形式は導入時に詰める - Vector index / FTS5 等の検索索引 — 初期は grep で足りる想定。ファイル数増加で検索が重くなったら検討 -- `auto_invoke` offer の自動判定ロジック — 初期は人間が手動で切り替え +- `model_invokation` offer の自動判定ロジック — 初期は人間が手動で切り替え - 過去 session を cross-session で検索する UI - Phase 2 を担う常駐 daemon 化 — オンデマンド + lock 方式で始める。必要性が出たら upgrade path として daemon 化 - Deterministic promotion(OpenClaw 型 scoring + ゲート)— 初期は Phase 2 agent の LLM 判断に委ねる。運用実績で出力を評価してから、成熟カテゴリから scoring 導入 diff --git a/resources/models/builtin.toml b/resources/models/builtin.toml new file mode 100644 index 00000000..51271ca5 --- /dev/null +++ b/resources/models/builtin.toml @@ -0,0 +1,43 @@ +# Anthropic direct +[[model]] +id = "claude-sonnet-4-6" +provider = "anthropic" + +[[model]] +id = "claude-sonnet-4-5" +provider = "anthropic" + +[[model]] +id = "claude-opus-4-1" +provider = "anthropic" + +# Ollama local (capability is router-ish / ollama handles its own models) +[[model]] +id = "llama3.1" +provider = "ollama-local" + +[[model]] +id = "qwen2.5-coder" +provider = "ollama-local" + +# Codex OAuth (ChatGPT backend via Responses API) +[[model]] +id = "gpt-5-codex" +provider = "codex-oauth" +capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "effort", vision = true, prompt_caching = { kind = "auto" } } + +[[model]] +id = "gpt-5" +provider = "codex-oauth" +capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "effort", vision = true, prompt_caching = { kind = "auto" } } + +# OpenRouter +[[model]] +id = "anthropic/claude-sonnet-4" +provider = "openrouter" +capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "budget_tokens", vision = true, prompt_caching = { kind = "auto" } } + +[[model]] +id = "openai/gpt-5" +provider = "openrouter" +capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "effort", vision = true, prompt_caching = { kind = "auto" } } diff --git a/crates/provider/assets/providers.toml b/resources/providers/builtin.toml similarity index 51% rename from crates/provider/assets/providers.toml rename to resources/providers/builtin.toml index c0215149..faa6d353 100644 --- a/crates/provider/assets/providers.toml +++ b/resources/providers/builtin.toml @@ -4,7 +4,7 @@ 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"] +default_capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "budget_tokens", vision = true, prompt_caching = { kind = "explicit", max_breakpoints = 4 } } [[provider]] id = "ollama-local" @@ -12,14 +12,14 @@ display_name = "Ollama (local)" scheme = "anthropic" base_url = "http://localhost:11434" auth_hint = { kind = "none" } -default_models = ["llama3.1", "qwen2.5-coder"] +default_capability = { tool_calling = "parallel", structured_output = "json_schema", vision = false, prompt_caching = { kind = "auto" } } [[provider]] id = "codex-oauth" display_name = "ChatGPT (Codex OAuth)" scheme = "openai_responses" auth_hint = { kind = "codex_oauth" } -default_models = ["gpt-5-codex", "gpt-5"] +default_capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "effort", vision = true, prompt_caching = { kind = "auto" } } [[provider]] id = "openrouter" @@ -27,4 +27,4 @@ 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"] +default_capability = { tool_calling = "parallel", structured_output = "json_schema", vision = true, prompt_caching = { kind = "auto" } }