refactor: fold provider crate into engine

This commit is contained in:
2026-07-15 00:58:29 +09:00
parent 1505c5e8a3
commit 839fc7b40c
33 changed files with 263 additions and 279 deletions
+3 -2
View File
@@ -11,12 +11,12 @@ default = []
[dependencies]
async-trait = { workspace = true }
clap = { version = "4.6.0", features = ["derive"] }
llm-engine = { workspace = true }
llm-engine = { workspace = true, features = ["codex"] }
session-store = { workspace = true }
secrets = { workspace = true }
manifest = { workspace = true }
mcp = { workspace = true }
protocol = { workspace = true }
provider = { workspace = true }
client = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
@@ -46,6 +46,7 @@ futures-util = { version = "0.3", features = ["sink"] }
[dev-dependencies]
dotenv = "0.15.0"
futures = { workspace = true }
serial_test = "3.4.0"
tempfile = { workspace = true }
wat = "1.241.2"
yoi-plugin-pdk = { workspace = true }
+2 -2
View File
@@ -1449,11 +1449,11 @@ where
let manifest = worker.manifest();
// `build_client` がここに到達する前に同じマニフェストで成功している
// ため、カタログ解決も必ず通る。念のため失敗時は "unknown" に落とす。
let resolved = provider::catalog::resolve_model_manifest(&manifest.model).ok();
let resolved = manifest::model_catalog::resolve_model_manifest(&manifest.model).ok();
let context_window = resolved
.as_ref()
.map(|cfg| cfg.context_window)
.unwrap_or(provider::catalog::DEFAULT_CONTEXT_WINDOW);
.unwrap_or(manifest::model_catalog::DEFAULT_CONTEXT_WINDOW);
let (provider_name, model_id) = match resolved {
Some(cfg) => {
let name = match cfg.scheme {
+2 -1
View File
@@ -8,6 +8,7 @@ pub mod fs_view;
pub mod hook;
pub(crate) mod in_flight;
pub mod ipc;
pub mod model_client;
pub mod prompt;
pub mod runtime;
pub mod segment_log_sink;
@@ -30,11 +31,11 @@ pub use manifest::{
AuthRef, ModelManifest, SchemeKind, Scope, WorkerManifest, WorkerManifestConfig,
WorkerMetaConfig,
};
pub use model_client::{ProviderError, build_client};
pub use prompt::catalog::{CatalogError, PromptCatalog, WorkerPrompt};
pub use prompt::loader::PromptLoader;
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
pub use provider::{ProviderError, build_client};
pub use runtime::dir::RuntimeDir;
pub use segment_log_sink::SegmentLogSink;
pub use shared_state::WorkerSharedState;
+412
View File
@@ -0,0 +1,412 @@
//! [`ModelManifest`] を [`Box<dyn LlmClient>`] に落とす worker-side factory。
//!
//! 段階:
//! 1. `ModelManifest` を [`catalog::resolve_model_manifest`] で
//! カタログ込み [`ModelConfig`] に解決(ref → 展開 / inline → 検証)
//! 2. `AuthRef` を local secret store / ファイルから解決して [`ResolvedAuth`] に
//! 3. `scheme.required_auth()` と解決値を照合(非対応組合せは構築エラー)
//! 4. `ModelCapability` は manifest 明示 > model catalog > provider
//! default_capability > scheme 既定 の順でフォールバック(上位 3 段は
//! `catalog::resolve_model_manifest` が [`ModelConfig`] に詰め込む)
//!
//! llm-engine は低レベル基盤に留める方針なので、高レベル側で必要に
//! なる認証ストア解決と secret store 解決は worker 側で行う。
use std::sync::Arc;
use llm_engine::llm_client::{
LlmClient,
capability::ModelCapability,
scheme::{
Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme,
openai_responses::OpenAIResponsesScheme,
},
transport::{HttpTransport, ResolvedAuth, TransportPolicy},
};
use llm_engine::providers::codex::CodexAuthProvider;
use manifest::{AuthRef, ModelManifest, SchemeKind, model_catalog as catalog};
use secrets::{SecretStore, SecretValue};
pub use manifest::model_catalog::{ModelConfig, ResolveError as CatalogResolveError};
/// プロバイダ構築時のエラー。
#[derive(Debug, thiserror::Error)]
pub enum ProviderError {
#[error("model configuration error: {0}")]
Config(String),
#[error("API key not provided for scheme {scheme:?}")]
ApiKeyMissing { scheme: SchemeKind },
#[error("failed to resolve secret `{id}`: {source}")]
SecretStore {
id: String,
#[source]
source: secrets::Error,
},
#[error("scheme {scheme:?} does not support this auth")]
AuthMismatch { scheme: SchemeKind },
#[error("scheme {scheme:?} is not implemented yet")]
SchemeNotImplemented { scheme: SchemeKind },
#[error("failed to resolve model manifest: {0}")]
ManifestResolve(#[from] catalog::ResolveError),
}
/// `AuthRef` をランタイムで使える [`ResolvedAuth`] に解決する。
fn resolve_auth(scheme: SchemeKind, auth: &AuthRef) -> Result<ResolvedAuth, ProviderError> {
let resolver = DefaultSecretResolver;
resolve_auth_with_resolver(scheme, auth, &resolver)
}
trait SecretResolver {
fn get_secret(&self, id: &str) -> Result<SecretValue, secrets::Error>;
}
struct DefaultSecretResolver;
impl SecretResolver for DefaultSecretResolver {
fn get_secret(&self, id: &str) -> Result<SecretValue, secrets::Error> {
let data_dir = manifest::paths::data_dir().ok_or_else(|| secrets::Error::Read {
path: std::path::PathBuf::from("<data_dir>"),
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not determine yoi data directory",
),
})?;
SecretStore::new(data_dir).get(id)
}
}
fn resolve_auth_with_resolver(
scheme: SchemeKind,
auth: &AuthRef,
resolver: &dyn SecretResolver,
) -> Result<ResolvedAuth, ProviderError> {
match auth {
AuthRef::None => Ok(ResolvedAuth::None),
AuthRef::ApiKey { file } => {
if let Some(path) = file {
if !path.is_absolute() {
return Err(ProviderError::Config(format!(
"auth.file must be absolute: {}",
path.display()
)));
}
let contents = std::fs::read_to_string(path).map_err(|e| {
ProviderError::Config(format!(
"failed to read auth.file {}: {e}",
path.display()
))
})?;
return Ok(ResolvedAuth::ApiKey(contents.trim().to_owned()));
}
Err(ProviderError::ApiKeyMissing { scheme })
}
AuthRef::CodexOAuth => {
let provider = CodexAuthProvider::from_default_home()
.map_err(|e| ProviderError::Config(e.to_string()))?;
Ok(ResolvedAuth::Custom(Arc::new(provider)))
}
AuthRef::SecretRef { ref_ } => {
let value = resolver
.get_secret(ref_)
.map_err(|source| ProviderError::SecretStore {
id: ref_.clone(),
source,
})?;
Ok(ResolvedAuth::ApiKey(value.into_string()))
}
}
}
/// `AuthRef::CodexOAuth` 指定時、`base_url` 未指定なら ChatGPT backend を既定とする。
/// Codex CLI が使う `/backend-api/codex` を base に取り、scheme 側の `/responses`
/// path と結合して `https://chatgpt.com/backend-api/codex/responses` になる。
fn effective_base_url<S: Scheme>(scheme: &S, config: &ModelConfig) -> String {
if let Some(b) = &config.base_url {
return b.clone();
}
if matches!(config.auth, AuthRef::CodexOAuth) {
return "https://chatgpt.com/backend-api/codex".to_string();
}
scheme.default_base_url().to_string()
}
fn build_transport<S: Scheme>(
scheme: S,
config: &ModelConfig,
resolved: ResolvedAuth,
policy: TransportPolicy,
) -> Result<Box<dyn LlmClient>, ProviderError> {
if !resolved.matches(scheme.required_auth()) {
return Err(ProviderError::AuthMismatch {
scheme: config.scheme,
});
}
// 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()
.unwrap_or_else(|| scheme.default_capability());
let base_url = effective_base_url(&scheme, config);
Ok(Box::new(
HttpTransport::new(
scheme,
config.model_id.clone(),
base_url,
resolved,
capability,
)
.with_transport_policy(policy),
))
}
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,
TransportPolicy::standard(),
),
SchemeKind::OpenaiChat => build_transport(
OpenAIScheme::new(),
config,
resolved,
TransportPolicy::standard(),
),
SchemeKind::Gemini => build_transport(
GeminiScheme::new(),
config,
resolved,
TransportPolicy::standard(),
),
SchemeKind::OpenaiResponses => {
// ChatGPT backend (codex-oauth) は `max_output_tokens` /
// `temperature` / `top_p` を 400 で弾くため、その経路では
// 送出を止める。
let send_to_official = !matches!(config.auth, AuthRef::CodexOAuth);
let scheme = OpenAIResponsesScheme::new()
.with_send_max_output_tokens(send_to_official)
.with_send_sampling_params(send_to_official);
let policy = if matches!(config.auth, AuthRef::CodexOAuth) {
TransportPolicy::openai_compatible_zstd()
} else {
TransportPolicy::standard()
};
build_transport(scheme, config, resolved, policy)
}
}
}
/// [`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::*;
use serial_test::serial;
use std::io::Write;
use std::path::{Path, PathBuf};
struct TestSecrets(std::collections::BTreeMap<String, String>);
struct ConfigDirGuard {
prev: Option<String>,
}
impl ConfigDirGuard {
fn new(path: &Path) -> Self {
let prev = std::env::var("YOI_CONFIG_DIR").ok();
// SAFETY: tests using this guard are marked `#[serial]`.
unsafe { std::env::set_var("YOI_CONFIG_DIR", path) };
Self { prev }
}
}
impl Drop for ConfigDirGuard {
fn drop(&mut self) {
unsafe {
match &self.prev {
Some(v) => std::env::set_var("YOI_CONFIG_DIR", v),
None => std::env::remove_var("YOI_CONFIG_DIR"),
}
}
}
}
impl SecretResolver for TestSecrets {
fn get_secret(&self, id: &str) -> Result<SecretValue, secrets::Error> {
self.0
.get(id)
.cloned()
.map(SecretValue::new)
.ok_or_else(|| secrets::Error::NotFound { id: id.to_string() })
}
}
fn anthropic_config() -> ModelConfig {
ModelConfig {
scheme: SchemeKind::Anthropic,
base_url: None,
model_id: "claude-sonnet-4-20250514".into(),
auth: AuthRef::ApiKey { file: None },
capability: None,
context_window: 200_000,
max_context_window: None,
}
}
#[test]
fn resolve_from_secret_ref() {
let resolver = TestSecrets(std::collections::BTreeMap::from([(
"providers/anthropic/default".to_string(),
"sk-from-store".to_string(),
)]));
let auth = resolve_auth_with_resolver(
SchemeKind::Anthropic,
&AuthRef::SecretRef {
ref_: "providers/anthropic/default".into(),
},
&resolver,
)
.unwrap();
match auth {
ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-store"),
_ => panic!("expected ApiKey"),
}
}
#[test]
fn missing_secret_names_only_id() {
let resolver = TestSecrets(Default::default());
let err = resolve_auth_with_resolver(
SchemeKind::Anthropic,
&AuthRef::SecretRef {
ref_: "providers/anthropic/missing".into(),
},
&resolver,
)
.unwrap_err();
let message = err.to_string();
assert!(message.contains("providers/anthropic/missing"));
assert!(!message.contains("sk-"));
}
#[test]
fn resolve_from_file() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("key.txt");
{
let mut f = std::fs::File::create(&key_path).unwrap();
writeln!(f, " sk-from-file").unwrap();
}
let config = ModelConfig {
auth: AuthRef::ApiKey {
file: Some(key_path),
},
..anthropic_config()
};
let auth = resolve_auth(config.scheme, &config.auth).unwrap();
match auth {
ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-file"),
_ => panic!("expected ApiKey"),
}
}
#[test]
fn relative_auth_file_is_rejected() {
let config = ModelConfig {
auth: AuthRef::ApiKey {
file: Some(PathBuf::from("keys/anthropic")),
},
..anthropic_config()
};
let err = resolve_auth(config.scheme, &config.auth).unwrap_err();
assert!(matches!(err, ProviderError::Config(_)));
}
#[test]
#[serial]
fn missing_key_returns_api_key_missing() {
let result = build_client_from_config(&anthropic_config());
assert!(matches!(result, Err(ProviderError::ApiKeyMissing { .. })));
}
#[test]
#[serial]
fn ref_manifest_builds_client() {
let dir = tempfile::tempdir().unwrap();
let _guard = ConfigDirGuard::new(dir.path());
// Ollama は AuthRef::None で構築できる end-to-end path。
let manifest = ModelManifest {
ref_: Some("ollama-local/llama3.1".into()),
..Default::default()
};
let client = build_client(&manifest);
assert!(
client.is_ok(),
"ollama ref should build without credentials: {:?}",
client.err()
);
}
#[test]
#[serial]
fn inline_manifest_builds_client() {
let dir = tempfile::tempdir().unwrap();
let _guard = ConfigDirGuard::new(dir.path());
// 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,
base_url: Some("http://localhost:11434".into()),
model_id: "llama3".into(),
auth: AuthRef::None,
capability: None,
context_window: 200_000,
max_context_window: None,
};
assert!(build_client_from_config(&config).is_ok());
}
}
+5 -5
View File
@@ -3048,7 +3048,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
fn build_compactor_client(&self) -> Result<Box<dyn LlmClient>, WorkerError> {
if let Some(ref compaction) = self.manifest.compaction {
if let Some(ref model_config) = compaction.model {
let client = provider::build_client(model_config)?;
let client = crate::model_client::build_client(model_config)?;
return Ok(client);
}
}
@@ -3065,7 +3065,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
memory_cfg: &manifest::MemoryConfig,
) -> Result<Box<dyn LlmClient>, WorkerError> {
if let Some(ref m) = memory_cfg.extract_model {
let client = provider::build_client(m)?;
let client = crate::model_client::build_client(m)?;
return Ok(client);
}
let worker = self.engine.as_ref().expect("worker taken during run");
@@ -3502,7 +3502,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
memory_cfg: &manifest::MemoryConfig,
) -> Result<Box<dyn LlmClient>, WorkerError> {
if let Some(ref m) = memory_cfg.consolidation_model {
let client = provider::build_client(m)?;
let client = crate::model_client::build_client(m)?;
return Ok(client);
}
let worker = self.engine.as_ref().expect("worker taken during run");
@@ -5070,7 +5070,7 @@ pub enum WorkerError {
ManifestResolve(#[source] ResolveError),
#[error(transparent)]
Provider(#[from] provider::ProviderError),
Provider(#[from] crate::model_client::ProviderError),
#[error("compaction thrash: context still exceeds threshold immediately after compact")]
CompactThrash,
@@ -5299,7 +5299,7 @@ fn prepare_worker_common_from_scope(
let delegation_scope =
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
let client = provider::build_client(&manifest.model)?;
let client = crate::model_client::build_client(&manifest.model)?;
let prompts = PromptCatalog::load(loader, manifest.worker.prompt_pack.as_deref())?;
let memory_layout = manifest.memory.as_ref().and_then(|mem| {
filesystem_authority