modelsとprovidersをカタログ化

This commit is contained in:
2026-04-24 10:45:03 +09:00
parent 7ecb1e6fc1
commit 30f9abacb8
16 changed files with 777 additions and 561 deletions
+55 -78
View File
@@ -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<PathBuf>,
}
/// Partial-form of [`ModelConfig`]. カスケード層で個別に与えられる。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ModelConfigPartial {
#[serde(default)]
pub scheme: Option<SchemeKind>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub model_id: Option<String>,
#[serde(default)]
pub auth: Option<AuthRef>,
#[serde(default)]
pub capability: Option<crate::model::ModelCapability>,
}
#[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<u64>,
#[serde(default)]
pub model: Option<ModelConfigPartial>,
pub model: Option<ModelManifest>,
}
/// 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<ModelConfig, ResolveError> {
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<AuthRef>, base: &Path) {
if let Some(AuthRef::ApiKey { file: Some(p), .. }) = auth.as_mut() {
@@ -333,6 +287,16 @@ fn resolve_auth_file(auth: &mut Option<AuthRef>, 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<PodManifestConfig> for PodManifest {
type Error = ResolveError;
@@ -346,12 +310,7 @@ impl TryFrom<PodManifestConfig> 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<PodManifestConfig> for PodManifest {
let compaction = cfg
.compaction
.map(|c| -> Result<CompactionConfig, ResolveError> {
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<PodManifestConfig> 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<PodManifestConfig> 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));
}
}
+14 -11
View File
@@ -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<ModelConfig>,
pub model: Option<ModelManifest>,
}
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]
+56 -20
View File
@@ -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 {
/// `<provider_id>/<model_id_in_ref>` 形式のカタログ参照。`/` の
/// 最初の 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<String>,
/// wire format の明示指定。ref 未指定時は必須、ref 指定時は override。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scheme: Option<SchemeKind>,
/// API のベース URL。scheme の既定値を override する。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
/// プロバイダが受け付けるモデル 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<String>,
/// 認証方式。ref 未指定時は必須、ref 指定時は override。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<AuthRef>,
/// モデル能力の明示指定。未指定時はモデルカタログ → provider
/// `default_capability` → scheme 既定の順で解決される。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability: Option<ModelCapability>,
}
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")]
+24 -7
View File
@@ -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,
}
+2 -2
View File
@@ -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");
}
+1 -1
View File
@@ -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;
+28 -20
View File
@@ -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<SpawnedPodRegistry>,
parent_socket: Option<PathBuf>,
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<String, toml::ser::Error> {
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<SpawnedPodRegistry>,
parent_socket: Option<PathBuf>,
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")
);
}
}
+8 -7
View File
@@ -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()
}
}
+10 -5
View File
@@ -1,15 +1,20 @@
# provider
マニフェストの `ModelConfig` から適切な `LlmClient``HttpTransport<S>`)を構築するファクトリクレート。APIキーの環境変数 / ファイル解決scheme ↔ auth の整合検証を担う。
マニフェストの `ModelManifest` から適切な `LlmClient``HttpTransport<S>`)を構築するファクトリクレート。プロバイダ / モデルカタログの解決、API キーの環境変数 / ファイル解決scheme ↔ auth の整合検証を担う。
## 公開型
- `build_client(config: &ModelConfig) -> Result<Box<dyn LlmClient>, ProviderError>``SchemeKind``AuthRef` から `HttpTransport<S>` 構築
- `ProviderError`クライアント構築エラー
- `build_client(manifest: &ModelManifest) -> Result<Box<dyn LlmClient>, ProviderError>`ref / inline を受け取り、カタログ解決 → `HttpTransport<S>` 構築までを行う
- `build_client_from_config(config: &ModelConfig) -> Result<Box<dyn LlmClient>, ProviderError>`解決済み `ModelConfig` から構築
- `catalog::resolve_model_manifest(&ModelManifest) -> Result<ModelConfig, ResolveError>` — 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()` の順で解決
-30
View File
@@ -1,30 +0,0 @@
[[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"]
-211
View File
@@ -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<ModelCapability> {
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<ModelCapability> {
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<OpenAiFamily> {
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<ModelCapability> {
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<ModelCapability> {
// `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<ModelCapability> {
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());
}
}
+386 -117
View File
@@ -1,21 +1,25 @@
//! プロバイダ/モデルカタログ。
//! プロバイダ / モデルカタログ。
//!
//! builtin (`assets/providers.toml`) と user override
//! (`$XDG_CONFIG_HOME/insomnia/providers.toml`) を読み、
//! `Vec<ProviderEntry>` を返す。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 `<provider>/<model_id>`")]
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<DiscoverMode>` を任意で追加予定(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<String>,
pub auth_hint: AuthHint,
/// モデルカタログ未登録モデルでこの provider が使われたとき
/// ref で provider はあるが model 行は無い等)のフォールバック。
/// 省略時は `Scheme::default_capability()` を最終フォールバックに
/// 使う。
#[serde(default)]
pub default_models: Vec<String>,
pub default_capability: Option<ModelCapability>,
}
/// モデルカタログの 1 エントリ。
///
/// `id` は **provider 内ユニーク**。同じ `gpt-5` が異なる provider に
/// 存在するのは OK で、ref が必ず `<provider>/<model_id>` を含むため
/// 曖昧性が出ない。
#[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<ModelCapability>,
}
/// 解決済みモデル設定。`build_client` が消費する完成形。
#[derive(Debug, Clone, PartialEq)]
pub struct ModelConfig {
pub scheme: SchemeKind,
pub base_url: Option<String>,
pub model_id: String,
pub auth: AuthRef,
pub capability: Option<ModelCapability>,
}
#[derive(Debug, Deserialize)]
struct CatalogFile {
struct ProviderCatalogFile {
#[serde(default)]
provider: Vec<ProviderEntry>,
}
impl ProviderEntry {
/// 選ばれた `model_id` と組み合わせて [`ModelConfig`] を構築する。
pub fn to_model_config(&self, model_id: impl Into<String>) -> ModelConfig {
let auth = match &self.auth_hint {
AuthHint::None => AuthRef::None,
AuthHint::ApiKey { env } => AuthRef::ApiKey {
env: env.clone(),
file: None,
},
AuthHint::CodexOAuth => AuthRef::CodexOAuth,
};
ModelConfig {
scheme: self.scheme,
base_url: self.base_url.clone(),
model_id: model_id.into(),
auth,
capability: None,
}
#[derive(Debug, Deserialize)]
struct ModelCatalogFile {
#[serde(default)]
model: Vec<ModelEntry>,
}
/// `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<Vec<ProviderEntry>, CatalogError> {
if let Some(path) = user_override_path()
pub fn load_providers() -> Result<Vec<ProviderEntry>, 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<Vec<ProviderEntry>, CatalogError> {
let parsed: CatalogFile =
toml::from_str(BUILTIN_CATALOG).map_err(CatalogError::BuiltinParse)?;
/// builtin provider カタログのみを返す。
pub fn load_builtin_providers() -> Result<Vec<ProviderEntry>, CatalogError> {
let parsed: ProviderCatalogFile =
toml::from_str(BUILTIN_PROVIDERS).map_err(CatalogError::BuiltinParse)?;
Ok(parsed.provider)
}
/// 指定パスから読む(テスト・明示指定用)
pub fn load_from_path(path: &Path) -> Result<Vec<ProviderEntry>, CatalogError> {
/// 指定パスから provider カタログを読む
pub fn load_providers_from(path: &Path) -> Result<Vec<ProviderEntry>, CatalogError> {
let text = std::fs::read_to_string(path).map_err(|source| CatalogError::Io {
path: path.to_path_buf(),
source,
})?;
let parsed: CatalogFile = toml::from_str(&text).map_err(|source| CatalogError::Parse {
path: path.to_path_buf(),
source,
})?;
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<PathBuf> {
// --- models ------------------------------------------------------------------
/// builtin + user override を解決してモデルカタログを返す。
pub fn load_models() -> Result<Vec<ModelEntry>, 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<Vec<ModelEntry>, CatalogError> {
let parsed: ModelCatalogFile =
toml::from_str(BUILTIN_MODELS).map_err(CatalogError::BuiltinParse)?;
Ok(parsed.model)
}
/// 指定パスからモデルカタログを読む。
pub fn load_models_from(path: &Path) -> Result<Vec<ModelEntry>, 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 ---------------------------------
/// `<provider_id>/<model_id>` の最初の `/` で 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<ModelConfig, ResolveError> {
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<ModelConfig, ResolveError> {
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<PathBuf> {
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> {
PathBuf::from(home)
.join(".config")
.join("insomnia")
.join("providers.toml"),
.join(file_name),
);
}
None
@@ -156,12 +346,11 @@ fn user_override_path() -> Option<PathBuf> {
#[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: `<router>/<provider>/<model>` 形式の 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") },
+70 -44
View File
@@ -1,16 +1,19 @@
//! Pod マニフェストの [`ModelConfig`] を [`Box<dyn LlmClient>`]
//! Pod マニフェストの [`ModelManifest`] を [`Box<dyn LlmClient>`]
//! に落とすファクトリ。
//!
//! * `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<S: Scheme>(
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<S: Scheme>(
)))
}
/// [`ModelConfig`] から [`LlmClient`] を構築する。
pub fn build_client(config: &ModelConfig) -> Result<Box<dyn LlmClient>, ProviderError> {
fn build_from_config(config: &ModelConfig) -> Result<Box<dyn LlmClient>, 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<Box<dyn LlmClient>, Provider
}
}
/// [`ModelManifest`] から [`LlmClient`] を構築する。ref / inline の
/// いずれも受け取り、カタログ解決は内部で行う。
pub fn build_client(manifest: &ModelManifest) -> Result<Box<dyn LlmClient>, 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<Box<dyn LlmClient>, 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());
}
}