merge: prepare llm engine for publish
This commit is contained in:
@@ -1526,11 +1526,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -3132,7 +3132,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);
|
||||
}
|
||||
}
|
||||
@@ -3149,7 +3149,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");
|
||||
@@ -3586,7 +3586,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");
|
||||
@@ -5154,7 +5154,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,
|
||||
@@ -5383,7 +5383,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
|
||||
|
||||
Reference in New Issue
Block a user