llm-model-configの実装
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
# manifest
|
||||
|
||||
Pod の宣言的設定を TOML マニフェストとして定義・パースするクレート。プロバイダ設定、ワーカー設定、ディレクトリスコープ制約を記述できる。
|
||||
Pod の宣言的設定を TOML マニフェストとして定義・パースするクレート。モデル設定、ワーカー設定、ディレクトリスコープ制約を記述できる。
|
||||
|
||||
## 公開型
|
||||
|
||||
- `PodManifest` — Pod 設定全体(`from_toml()` でパース)
|
||||
- `PodMeta` — Pod メタデータ(名前、pwd)
|
||||
- `ProviderConfig` — LLM プロバイダ設定(種別、モデル、APIキー環境変数、ベースURL)
|
||||
- `ProviderKind` — プロバイダ種別(`Anthropic`, `Openai`, `Gemini`, `Ollama`)
|
||||
- `ModelConfig` — LLM モデル設定(scheme、base_url、model_id、auth)
|
||||
- `SchemeKind` — wire scheme 種別(`Anthropic`, `OpenaiChat`, `OpenaiResponses`, `Gemini`)
|
||||
- `AuthRef` — 認証参照(`None`, `ApiKey { env, file }`, `CodexOAuth`)
|
||||
- `WorkerManifest` — ワーカー設定(システムプロンプト、max_tokens、temperature)
|
||||
- `ScopeConfig` / `ScopeRule` / `Permission` — allow / deny の宣言的スコープ設定
|
||||
- `Scope` — 実行時スコープ。`from_config(&ScopeConfig, pwd)` で構築し、`is_readable` / `is_writable` / `permission_at` で問い合わせる
|
||||
|
||||
@@ -13,10 +13,8 @@ use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::defaults;
|
||||
use crate::{
|
||||
CompactionConfig, PodManifest, PodMeta, ProviderConfig, ProviderKind, ScopeConfig,
|
||||
ToolOutputLimits, WorkerManifest,
|
||||
};
|
||||
use crate::model::{AuthRef, ModelConfig, SchemeKind};
|
||||
use crate::{CompactionConfig, PodManifest, PodMeta, ScopeConfig, ToolOutputLimits, WorkerManifest};
|
||||
|
||||
/// Partial-form Pod manifest. Every field is optional; one or more
|
||||
/// instances merge via [`PodManifestConfig::merge`] before being
|
||||
@@ -26,7 +24,7 @@ pub struct PodManifestConfig {
|
||||
#[serde(default)]
|
||||
pub pod: PodMetaConfig,
|
||||
#[serde(default)]
|
||||
pub provider: ProviderConfigPartial,
|
||||
pub model: ModelConfigPartial,
|
||||
#[serde(default)]
|
||||
pub worker: WorkerManifestConfig,
|
||||
#[serde(default)]
|
||||
@@ -41,16 +39,17 @@ pub struct PodMetaConfig {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// Partial-form of [`ModelConfig`]. カスケード層で個別に与えられる。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ProviderConfigPartial {
|
||||
pub struct ModelConfigPartial {
|
||||
#[serde(default)]
|
||||
pub kind: Option<ProviderKind>,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub api_key_file: Option<PathBuf>,
|
||||
pub scheme: Option<SchemeKind>,
|
||||
#[serde(default)]
|
||||
pub base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub auth: Option<AuthRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -92,7 +91,7 @@ pub struct CompactionConfigPartial {
|
||||
#[serde(default)]
|
||||
pub compact_worker_max_input_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub provider: Option<ProviderConfigPartial>,
|
||||
pub model: Option<ModelConfigPartial>,
|
||||
}
|
||||
|
||||
/// Errors raised when converting a [`PodManifestConfig`] to a validated
|
||||
@@ -148,18 +147,16 @@ impl PodManifestConfig {
|
||||
/// rules from different layers do not accidentally inherit another
|
||||
/// layer's base.
|
||||
///
|
||||
/// Affected fields: `provider.api_key_file`,
|
||||
/// Affected fields: `model.auth.file`,
|
||||
/// `scope.allow[].target`, `scope.deny[].target`,
|
||||
/// `compaction.provider.api_key_file`.
|
||||
/// `compaction.model.auth.file`.
|
||||
pub fn resolve_paths(mut self, base: &Path) -> Self {
|
||||
debug_assert!(
|
||||
base.is_absolute(),
|
||||
"resolve_paths base must be absolute: {}",
|
||||
base.display()
|
||||
);
|
||||
if let Some(ref mut p) = self.provider.api_key_file {
|
||||
*p = join_if_relative(base, p);
|
||||
}
|
||||
resolve_auth_file(&mut self.model.auth, base);
|
||||
for rule in &mut self.scope.allow {
|
||||
rule.target = join_if_relative(base, &rule.target);
|
||||
}
|
||||
@@ -167,10 +164,9 @@ impl PodManifestConfig {
|
||||
rule.target = join_if_relative(base, &rule.target);
|
||||
}
|
||||
if let Some(ref mut compaction) = self.compaction
|
||||
&& let Some(ref mut cp) = compaction.provider
|
||||
&& let Some(ref mut p) = cp.api_key_file
|
||||
&& let Some(ref mut cp) = compaction.model
|
||||
{
|
||||
*p = join_if_relative(base, p);
|
||||
resolve_auth_file(&mut cp.auth, base);
|
||||
}
|
||||
self
|
||||
}
|
||||
@@ -182,7 +178,7 @@ impl PodManifestConfig {
|
||||
pub fn merge(self, upper: PodManifestConfig) -> Self {
|
||||
Self {
|
||||
pod: self.pod.merge(upper.pod),
|
||||
provider: self.provider.merge(upper.provider),
|
||||
model: self.model.merge(upper.model),
|
||||
worker: self.worker.merge(upper.worker),
|
||||
scope: merge_scope(self.scope, upper.scope),
|
||||
compaction: merge_option(
|
||||
@@ -202,13 +198,13 @@ impl PodMetaConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderConfigPartial {
|
||||
impl ModelConfigPartial {
|
||||
fn merge(self, upper: Self) -> Self {
|
||||
Self {
|
||||
kind: upper.kind.or(self.kind),
|
||||
model: upper.model.or(self.model),
|
||||
api_key_file: upper.api_key_file.or(self.api_key_file),
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,7 +250,7 @@ impl CompactionConfigPartial {
|
||||
compact_worker_max_input_tokens: upper
|
||||
.compact_worker_max_input_tokens
|
||||
.or(self.compact_worker_max_input_tokens),
|
||||
provider: merge_option(self.provider, upper.provider, ProviderConfigPartial::merge),
|
||||
model: merge_option(self.model, upper.model, ModelConfigPartial::merge),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,25 +291,35 @@ fn ensure_absolute(field: &'static str, path: &Path) -> Result<(), ResolveError>
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_provider(
|
||||
cfg: ProviderConfigPartial,
|
||||
kind_field: &'static str,
|
||||
model_field: &'static str,
|
||||
api_key_field: &'static str,
|
||||
) -> Result<ProviderConfig, ResolveError> {
|
||||
let kind = cfg.kind.ok_or(ResolveError::MissingField(kind_field))?;
|
||||
let model = cfg.model.ok_or(ResolveError::MissingField(model_field))?;
|
||||
if let Some(ref p) = cfg.api_key_file {
|
||||
ensure_absolute(api_key_field, p)?;
|
||||
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(ProviderConfig {
|
||||
kind,
|
||||
model,
|
||||
api_key_file: cfg.api_key_file,
|
||||
Ok(ModelConfig {
|
||||
scheme,
|
||||
base_url: cfg.base_url,
|
||||
model_id,
|
||||
auth,
|
||||
})
|
||||
}
|
||||
|
||||
/// `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() {
|
||||
*p = join_if_relative(base, p);
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<PodManifestConfig> for PodManifest {
|
||||
type Error = ResolveError;
|
||||
|
||||
@@ -323,11 +329,11 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||
.name
|
||||
.ok_or(ResolveError::MissingField("pod.name"))?;
|
||||
|
||||
let provider = resolve_provider(
|
||||
cfg.provider,
|
||||
"provider.kind",
|
||||
"provider.model",
|
||||
"provider.api_key_file",
|
||||
let model = resolve_model(
|
||||
cfg.model,
|
||||
"model.scheme",
|
||||
"model.model_id",
|
||||
"model.auth.file",
|
||||
)?;
|
||||
|
||||
let worker = WorkerManifest {
|
||||
@@ -361,14 +367,14 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||
let compaction = cfg
|
||||
.compaction
|
||||
.map(|c| -> Result<CompactionConfig, ResolveError> {
|
||||
let comp_provider = c
|
||||
.provider
|
||||
let comp_model = c
|
||||
.model
|
||||
.map(|p| {
|
||||
resolve_provider(
|
||||
resolve_model(
|
||||
p,
|
||||
"compaction.provider.kind",
|
||||
"compaction.provider.model",
|
||||
"compaction.provider.api_key_file",
|
||||
"compaction.model.scheme",
|
||||
"compaction.model.model_id",
|
||||
"compaction.model.auth.file",
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
@@ -390,14 +396,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),
|
||||
provider: comp_provider,
|
||||
model: comp_model,
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
Ok(PodManifest {
|
||||
pod: PodMeta { name },
|
||||
provider,
|
||||
model,
|
||||
worker,
|
||||
scope: cfg.scope,
|
||||
compaction,
|
||||
@@ -414,14 +420,21 @@ mod tests {
|
||||
PathBuf::from(format!("/tmp/insomnia-test{path}"))
|
||||
}
|
||||
|
||||
fn api_key_file_auth(path: PathBuf) -> AuthRef {
|
||||
AuthRef::ApiKey {
|
||||
env: None,
|
||||
file: Some(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn minimal_valid() -> PodManifestConfig {
|
||||
PodManifestConfig {
|
||||
pod: PodMetaConfig {
|
||||
name: Some("test".into()),
|
||||
},
|
||||
provider: ProviderConfigPartial {
|
||||
kind: Some(ProviderKind::Anthropic),
|
||||
model: Some("claude-sonnet-4-20250514".into()),
|
||||
model: ModelConfigPartial {
|
||||
scheme: Some(SchemeKind::Anthropic),
|
||||
model_id: Some("claude-sonnet-4-20250514".into()),
|
||||
..Default::default()
|
||||
},
|
||||
worker: WorkerManifestConfig::default(),
|
||||
@@ -441,16 +454,20 @@ mod tests {
|
||||
fn resolve_minimal_succeeds() {
|
||||
let manifest: PodManifest = minimal_valid().try_into().unwrap();
|
||||
assert_eq!(manifest.pod.name, "test");
|
||||
assert_eq!(manifest.provider.kind, ProviderKind::Anthropic);
|
||||
assert_eq!(manifest.model.scheme, SchemeKind::Anthropic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_paths_joins_relative_api_key_file() {
|
||||
fn resolve_paths_joins_relative_auth_file() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.provider.api_key_file = Some(PathBuf::from("keys/anthropic"));
|
||||
cfg.model.auth = Some(api_key_file_auth(PathBuf::from("keys/anthropic")));
|
||||
let resolved = cfg.resolve_paths(Path::new("/home/user/.config/insomnia"));
|
||||
let file = match resolved.model.auth {
|
||||
Some(AuthRef::ApiKey { file, .. }) => file,
|
||||
_ => panic!("expected ApiKey"),
|
||||
};
|
||||
assert_eq!(
|
||||
resolved.provider.api_key_file.as_deref(),
|
||||
file.as_deref(),
|
||||
Some(Path::new("/home/user/.config/insomnia/keys/anthropic"))
|
||||
);
|
||||
}
|
||||
@@ -458,12 +475,13 @@ mod tests {
|
||||
#[test]
|
||||
fn resolve_paths_leaves_absolute_paths_untouched() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.provider.api_key_file = Some(PathBuf::from("/etc/already/abs"));
|
||||
cfg.model.auth = Some(api_key_file_auth(PathBuf::from("/etc/already/abs")));
|
||||
let resolved = cfg.resolve_paths(Path::new("/home/user"));
|
||||
assert_eq!(
|
||||
resolved.provider.api_key_file.as_deref(),
|
||||
Some(Path::new("/etc/already/abs"))
|
||||
);
|
||||
let file = match resolved.model.auth {
|
||||
Some(AuthRef::ApiKey { file, .. }) => file,
|
||||
_ => panic!("expected ApiKey"),
|
||||
};
|
||||
assert_eq!(file.as_deref(), Some(Path::new("/etc/already/abs")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -484,16 +502,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_from_invariant_rejects_lingering_relative_api_key_file() {
|
||||
fn try_from_invariant_rejects_lingering_relative_auth_file() {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.provider.api_key_file = Some(PathBuf::from("keys/relative"));
|
||||
// Skipping resolve_paths on purpose: TryFrom must catch the
|
||||
// invariant violation.
|
||||
cfg.model.auth = Some(api_key_file_auth(PathBuf::from("keys/relative")));
|
||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ResolveError::RelativePath {
|
||||
field: "provider.api_key_file",
|
||||
field: "model.auth.file",
|
||||
..
|
||||
}
|
||||
));
|
||||
@@ -535,8 +551,8 @@ mod tests {
|
||||
pod: PodMetaConfig {
|
||||
name: Some("lower".into()),
|
||||
},
|
||||
provider: ProviderConfigPartial {
|
||||
model: Some("lower-model".into()),
|
||||
model: ModelConfigPartial {
|
||||
model_id: Some("lower-model".into()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
@@ -549,8 +565,8 @@ mod tests {
|
||||
};
|
||||
let merged = lower.merge(upper);
|
||||
assert_eq!(merged.pod.name.as_deref(), Some("upper"));
|
||||
// model not present in upper — retain lower
|
||||
assert_eq!(merged.provider.model.as_deref(), Some("lower-model"));
|
||||
// model_id not present in upper — retain lower
|
||||
assert_eq!(merged.model.model_id.as_deref(), Some("lower-model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -706,9 +722,9 @@ permission = "write"
|
||||
pod: PodMetaConfig {
|
||||
name: Some("x".into()),
|
||||
},
|
||||
provider: ProviderConfigPartial {
|
||||
kind: Some(ProviderKind::Anthropic),
|
||||
model: Some("m".into()),
|
||||
model: ModelConfigPartial {
|
||||
scheme: Some(SchemeKind::Anthropic),
|
||||
model_id: Some("m".into()),
|
||||
..Default::default()
|
||||
},
|
||||
scope: ScopeConfig {
|
||||
@@ -734,9 +750,9 @@ permission = "write"
|
||||
let builtin = PodManifestConfig::default();
|
||||
let user = PodManifestConfig::from_toml(
|
||||
r#"
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "claude-sonnet-4-20250514"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -759,7 +775,7 @@ 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.provider.kind, ProviderKind::Anthropic);
|
||||
assert_eq!(manifest.model.scheme, SchemeKind::Anthropic);
|
||||
assert_eq!(manifest.scope.allow.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
+40
-75
@@ -1,30 +1,31 @@
|
||||
mod config;
|
||||
pub mod defaults;
|
||||
mod model;
|
||||
mod scope;
|
||||
|
||||
pub use config::{
|
||||
CompactionConfigPartial, PodManifestConfig, PodMetaConfig, ProviderConfigPartial, ResolveError,
|
||||
CompactionConfigPartial, ModelConfigPartial, PodManifestConfig, PodMetaConfig, ResolveError,
|
||||
ToolOutputLimitsPartial, WorkerManifestConfig,
|
||||
};
|
||||
pub use model::{AuthRef, ModelConfig, SchemeKind};
|
||||
pub use protocol::{Permission, ScopeRule};
|
||||
pub use scope::{Scope, ScopeError};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::num::NonZeroU32;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Declarative configuration for a Pod.
|
||||
///
|
||||
/// Parsed from a TOML manifest file. Describes the provider, model,
|
||||
/// system prompt, and directory scope (required). The Pod's working
|
||||
/// directory is **not** part of the manifest — it is the process's
|
||||
/// `std::env::current_dir()` at construction time.
|
||||
/// Parsed from a TOML manifest file. Describes the model, system prompt,
|
||||
/// and directory scope (required). The Pod's working directory is **not**
|
||||
/// part of the manifest — it is the process's `std::env::current_dir()`
|
||||
/// at construction time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PodManifest {
|
||||
pub pod: PodMeta,
|
||||
pub provider: ProviderConfig,
|
||||
pub model: ModelConfig,
|
||||
pub worker: WorkerManifest,
|
||||
pub scope: ScopeConfig,
|
||||
#[serde(default)]
|
||||
@@ -37,44 +38,6 @@ pub struct PodMeta {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// LLM provider configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub kind: ProviderKind,
|
||||
pub model: String,
|
||||
/// Path to a file containing the API key (read and trimmed at startup).
|
||||
#[serde(default)]
|
||||
pub api_key_file: Option<PathBuf>,
|
||||
/// Custom base URL for the provider API.
|
||||
#[serde(default)]
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Supported LLM providers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProviderKind {
|
||||
Anthropic,
|
||||
Openai,
|
||||
Gemini,
|
||||
Ollama,
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
/// Conventional environment variable name for the API key.
|
||||
///
|
||||
/// Returns `INSOMNIA_API_KEY_{KIND}` (e.g. `INSOMNIA_API_KEY_ANTHROPIC`).
|
||||
pub fn env_var_name(self) -> String {
|
||||
let kind = match self {
|
||||
Self::Anthropic => "ANTHROPIC",
|
||||
Self::Openai => "OPENAI",
|
||||
Self::Gemini => "GEMINI",
|
||||
Self::Ollama => "OLLAMA",
|
||||
};
|
||||
format!("INSOMNIA_API_KEY_{kind}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker-level configuration embedded in the manifest.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkerManifest {
|
||||
@@ -211,10 +174,10 @@ pub struct CompactionConfig {
|
||||
#[serde(default = "default_compact_worker_max_input_tokens")]
|
||||
pub compact_worker_max_input_tokens: u64,
|
||||
|
||||
/// Optional provider for the compactor (summary) LLM.
|
||||
/// If omitted, the main provider is cloned via `clone_boxed()`.
|
||||
/// Optional model for the compactor (summary) LLM.
|
||||
/// If omitted, the main model is cloned via `clone_boxed()`.
|
||||
#[serde(default)]
|
||||
pub provider: Option<ProviderConfig>,
|
||||
pub model: Option<ModelConfig>,
|
||||
}
|
||||
|
||||
fn default_prune_protected_turns() -> usize {
|
||||
@@ -243,7 +206,7 @@ impl Default for CompactionConfig {
|
||||
compact_retained_tokens: default_compact_retained_tokens(),
|
||||
compact_auto_read_budget: default_compact_auto_read_budget(),
|
||||
compact_worker_max_input_tokens: default_compact_worker_max_input_tokens(),
|
||||
provider: None,
|
||||
model: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,9 +226,9 @@ mod tests {
|
||||
[pod]
|
||||
name = "test-agent"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "claude-sonnet-4-20250514"
|
||||
|
||||
[worker]
|
||||
|
||||
@@ -278,9 +241,9 @@ permission = "write"
|
||||
fn parse_minimal_manifest() {
|
||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
assert_eq!(manifest.pod.name, "test-agent");
|
||||
assert_eq!(manifest.provider.kind, ProviderKind::Anthropic);
|
||||
assert_eq!(manifest.provider.model, "claude-sonnet-4-20250514");
|
||||
assert!(manifest.provider.api_key_file.is_none());
|
||||
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.scope.allow.len(), 1);
|
||||
assert!(manifest.scope.deny.is_empty());
|
||||
assert_eq!(manifest.worker.instruction, defaults::DEFAULT_INSTRUCTION);
|
||||
@@ -292,10 +255,10 @@ permission = "write"
|
||||
[pod]
|
||||
name = "code-reviewer"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
api_key_file = "/abs/keys/anthropic"
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "claude-sonnet-4-20250514"
|
||||
auth = { kind = "api_key", file = "/abs/keys/anthropic" }
|
||||
|
||||
[worker]
|
||||
instruction = "$user/reviewer"
|
||||
@@ -317,10 +280,11 @@ permission = "write"
|
||||
"#;
|
||||
let manifest = PodManifest::from_toml(toml).unwrap();
|
||||
assert_eq!(manifest.pod.name, "code-reviewer");
|
||||
assert_eq!(
|
||||
manifest.provider.api_key_file.as_deref(),
|
||||
Some(std::path::Path::new("/abs/keys/anthropic"))
|
||||
);
|
||||
let file = match &manifest.model.auth {
|
||||
AuthRef::ApiKey { file, .. } => file.as_deref(),
|
||||
_ => panic!("expected ApiKey"),
|
||||
};
|
||||
assert_eq!(file, Some(std::path::Path::new("/abs/keys/anthropic")));
|
||||
assert_eq!(manifest.worker.instruction, "$user/reviewer");
|
||||
assert_eq!(manifest.worker.max_tokens, Some(4096));
|
||||
assert_eq!(manifest.worker.temperature, Some(0.3));
|
||||
@@ -340,9 +304,9 @@ permission = "write"
|
||||
[pod]
|
||||
name = "missing-scope"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "claude-sonnet-4-20250514"
|
||||
|
||||
[worker]
|
||||
"#;
|
||||
@@ -408,20 +372,20 @@ model = "claude-sonnet-4-20250514"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_compaction_with_provider() {
|
||||
fn parse_compaction_with_model() {
|
||||
let toml = format!(
|
||||
"{MINIMAL_REQUIRED}\n\
|
||||
[compaction]\n\
|
||||
compact_threshold = 80000\n\n\
|
||||
[compaction.provider]\n\
|
||||
kind = \"gemini\"\n\
|
||||
model = \"gemini-2.0-flash\"\n"
|
||||
[compaction.model]\n\
|
||||
scheme = \"gemini\"\n\
|
||||
model_id = \"gemini-2.0-flash\"\n"
|
||||
);
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let c = manifest.compaction.unwrap();
|
||||
let p = c.provider.unwrap();
|
||||
assert_eq!(p.kind, ProviderKind::Gemini);
|
||||
assert_eq!(p.model, "gemini-2.0-flash");
|
||||
let p = c.model.unwrap();
|
||||
assert_eq!(p.scheme, SchemeKind::Gemini);
|
||||
assert_eq!(p.model_id, "gemini-2.0-flash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -431,8 +395,9 @@ model = "claude-sonnet-4-20250514"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_unknown_provider() {
|
||||
let toml = MINIMAL_REQUIRED.replace("kind = \"anthropic\"", "kind = \"unknown_provider\"");
|
||||
fn reject_unknown_scheme() {
|
||||
let toml =
|
||||
MINIMAL_REQUIRED.replace("scheme = \"anthropic\"", "scheme = \"unknown_scheme\"");
|
||||
assert!(PodManifest::from_toml(&toml).is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! LLM モデル宣言型
|
||||
//!
|
||||
//! Pod マニフェストの `[model]` セクションで記述する型。`scheme` と
|
||||
//! `auth` を直交軸として表現し、1 つの汎用アダプタ(`crates/provider`)
|
||||
//! で任意の wire / 認証組合せを受け止める。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Pod が使う LLM モデルの宣言。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ModelConfig {
|
||||
/// wire format
|
||||
pub scheme: SchemeKind,
|
||||
/// API のベース URL。未指定なら scheme の既定値にフォールバック
|
||||
#[serde(default)]
|
||||
pub base_url: Option<String>,
|
||||
/// プロバイダが受け付けるモデル ID
|
||||
pub model_id: String,
|
||||
/// 認証方式
|
||||
#[serde(default)]
|
||||
pub auth: AuthRef,
|
||||
}
|
||||
|
||||
/// サポートする wire scheme の種類。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SchemeKind {
|
||||
/// Anthropic Messages API (`/v1/messages`)。Ollama `/v1/messages` もこれで扱う
|
||||
Anthropic,
|
||||
/// OpenAI Chat Completions (`/v1/chat/completions`)。OpenAI 互換ルーター共通枠
|
||||
OpenaiChat,
|
||||
/// OpenAI Responses API (`/v1/responses`)。別チケットで scheme 新設予定
|
||||
OpenaiResponses,
|
||||
/// Google Gemini (`/v1beta/models/...:streamGenerateContent`)
|
||||
Gemini,
|
||||
}
|
||||
|
||||
/// 認証の参照。
|
||||
///
|
||||
/// 実際のトークン値の解決(env / file 読取、OAuth refresh 等)は
|
||||
/// `crates/provider` で行う。ここはあくまで「どこから取るか」の宣言。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AuthRef {
|
||||
/// 認証不要(ローカル Ollama 等)
|
||||
#[default]
|
||||
None,
|
||||
/// API key。env / file のいずれか(両方指定された場合は env が優先)
|
||||
ApiKey {
|
||||
/// 環境変数名。未指定のときは scheme ごとの既定(`INSOMNIA_API_KEY_*`)
|
||||
#[serde(default)]
|
||||
env: Option<String>,
|
||||
/// key を書き込んだファイル(絶対パス)
|
||||
#[serde(default)]
|
||||
file: Option<PathBuf>,
|
||||
},
|
||||
/// ChatGPT OAuth(`~/.codex/auth.json`)。実装は `llm-auth-codex-oauth` チケット
|
||||
CodexOAuth,
|
||||
}
|
||||
|
||||
impl SchemeKind {
|
||||
/// 既定の環境変数名(`INSOMNIA_API_KEY_*`)。
|
||||
///
|
||||
/// `AuthRef::ApiKey { env: None, .. }` の env 未指定時に使う。
|
||||
pub fn default_env_var(self) -> &'static str {
|
||||
match self {
|
||||
Self::Anthropic => "INSOMNIA_API_KEY_ANTHROPIC",
|
||||
Self::OpenaiChat | Self::OpenaiResponses => "INSOMNIA_API_KEY_OPENAI",
|
||||
Self::Gemini => "INSOMNIA_API_KEY_GEMINI",
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user