modelsとprovidersをカタログ化
This commit is contained in:
parent
7ecb1e6fc1
commit
30f9abacb8
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()` の順で解決
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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") },
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 とする
|
||||
- `#<slug>` 参照を書く場合は、実在 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 を優先する
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ Workflow(`/<slug>` で呼び出される制約付き作業フロー)は別 p
|
|||
| Always-on サマリ | `memory/summary.md` | 1-5k tokens 目安 |
|
||||
| Decisions | `memory/decisions/<slug>.md` | `status: open \| resolved \| replaced` で未決議論も保持、置き換え時は `replaced_by: <slug>` |
|
||||
| Requests | `memory/requests/<slug>.md` | ユーザー submit の構造化要約 |
|
||||
| Knowledge | `memory/knowledge/<slug>.md` | `#slug` で注入。ノウハウ / 用語 / 運用方針 / ルール / 事実など型を設けず Markdown 自由記述 |
|
||||
| Knowledge | `knowledge/<slug>.md` | `#slug` で注入。`kind` で大まかな型だけ持ち、本文は Markdown 自由記述 |
|
||||
|
||||
- `<slug>` は kebab-case(内容を要約した短い識別子)。**ファイル名そのものが ID**、frontmatter に別途 `id` field は持たない
|
||||
- **1 件 1 ファイル**。append-only な複数エントリログファイルは作らない
|
||||
|
|
@ -35,23 +35,54 @@ agentskills.io の `SKILL.md` 形式は採用しない。Knowledge は `#<slug>`
|
|||
|
||||
| フラグ | 意味 | デフォルト |
|
||||
| ---------------- | ------------------------------------------------------- | ---------- |
|
||||
| `auto_invoke` | description が LLM context に載り、LLM が自発的に呼べる | **OFF** |
|
||||
| `model_invokation` | description が model context に載り、モデルが自発的に参照判断できる | **OFF** |
|
||||
| `user_invocable` | ユーザーが `#<slug>` で明示的に呼べる | **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 件返し、`#<slug>` 解決に使う)、`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>` 補完 / 自動呼び出し(大枠のみ、実装は段階的)**:
|
||||
- `#<slug>` は検索ツールの slug 完全一致経路で本文が展開される
|
||||
- 補完 UI(slug サジェスト)は TUI 側。`user_invocable: false` は候補除外
|
||||
- 自動呼び出しは、常駐注入された description をモデルが見て必要と判断すれば検索ツールを呼ぶ形で成立する。専用の auto-invoke 経路は別途用意しない
|
||||
|
||||
### Knowledge の採択基準
|
||||
|
||||
Knowledge は「保存する価値があるか」だけでなく、「あとで見つけて再利用できるか」で評価する。最低限の基準は以下:
|
||||
|
||||
- **slug は入口**。短く、何の知識か推測でき、`#<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 に切り替えるサイン)
|
||||
- `#<slug>` 参照が実在ファイルを指す
|
||||
- `replaced_by: <slug>` が実在 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: <slug>`)で表現、直接削除しない
|
||||
- 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 計測はツール内でフックし、`#<slug>` / `/<slug>` / 明示検索呼び出しを同一経路に集約する。
|
||||
**観測経路**: `memory/*` / `knowledge/*` への読み取りは §retrieval 経路 で定義した memory 検索ツール / Knowledge 検索ツール(既存 built-in の grep / read とは別に用意)経由に揃える。invoke 計測はツール内でフックし、`#<slug>` / `/<slug>` / 明示検索呼び出しを同一経路に集約する。
|
||||
|
||||
**カウント対象**:
|
||||
|
||||
- **明示 invoke**: 検索ツール経由の読み取り / `#<slug>` / `/<slug>` を 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/<slug>.md`。`memory/` とは兄弟ディレクトリに分ける
|
||||
- Decisions 固有: `status: open | resolved | replaced`、置き換え時は `replaced_by: <slug>`
|
||||
- Phase 1 staging: `memory/_staging/<id>.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 導入
|
||||
|
|
|
|||
43
resources/models/builtin.toml
Normal file
43
resources/models/builtin.toml
Normal file
|
|
@ -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" } }
|
||||
|
|
@ -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" } }
|
||||
Loading…
Reference in New Issue
Block a user