//! Pod マニフェストの [`ModelConfig`] を [`Box`] //! に落とすファクトリ。 //! //! * `SchemeKind` を各 `Scheme` 実装にマップ //! * `AuthRef` を環境変数 / ファイルから解決して [`ResolvedAuth`] に //! * `scheme.required_auth()` と解決値を照合(非対応組合せは構築エラー) //! * `ModelCapability` は明示指定 → scheme 静的テーブル → 未知時はデフォルト //! //! llm-worker は低レベル基盤に留める方針なので、高レベル側で必要に //! なる認証ストア解決(Codex OAuth の `~/.codex/auth.json` 読取等)は //! このクレートに追加する。 pub mod capability; pub mod codex_oauth; use std::sync::Arc; use llm_worker::llm_client::{ LlmClient, capability::ModelCapability, scheme::{ Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme, openai_responses::OpenAIResponsesScheme, }, transport::{HttpTransport, ResolvedAuth}, }; use manifest::{AuthRef, ModelConfig, SchemeKind}; /// プロバイダ構築時のエラー。 #[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("scheme {scheme:?} does not support this auth")] AuthMismatch { scheme: SchemeKind }, #[error("scheme {scheme:?} is not implemented yet")] SchemeNotImplemented { scheme: SchemeKind }, } /// `AuthRef` をランタイムで使える [`ResolvedAuth`] に解決する。 /// /// 解決順: /// 1. `AuthRef::ApiKey { env, .. }` で env が指定されていればその変数を参照 /// 2. そうでなければ scheme 既定の環境変数 (`SchemeKind::default_env_var`) /// 3. それでも無ければ `file` を読む(絶対パスのみ) fn resolve_auth( scheme: SchemeKind, auth: &AuthRef, ) -> Result { match auth { AuthRef::None => Ok(ResolvedAuth::None), AuthRef::ApiKey { env, file } => { let env_name = env.as_deref().unwrap_or(scheme.default_env_var()); if let Ok(val) = std::env::var(env_name) && !val.is_empty() { return Ok(ResolvedAuth::ApiKey(val)); } 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 = codex_oauth::CodexAuthProvider::from_default_home() .map_err(|e| ProviderError::Config(e.to_string()))?; Ok(ResolvedAuth::Custom(Arc::new(provider))) } } } /// `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(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( scheme: S, config: &ModelConfig, resolved: ResolvedAuth, ) -> Result, ProviderError> { if !resolved.matches(scheme.required_auth()) { return Err(ProviderError::AuthMismatch { scheme: config.scheme, }); } // capability の優先順位: // 1. `ModelConfig.capability` の明示指定(OpenAI 互換ルーターの // 未知モデル等、マニフェストで完全に上書きしたいケース) // 2. `provider::capability::lookup` の既知モデルテーブル // (モデル ID の知識は高レベル構築層(ここ)の責務) // 3. `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( scheme, config.model_id.clone(), base_url, resolved, capability, ))) } /// [`ModelConfig`] から [`LlmClient`] を構築する。 pub fn build_client(config: &ModelConfig) -> Result, ProviderError> { let resolved = resolve_auth(config.scheme, &config.auth)?; match config.scheme { SchemeKind::Anthropic => build_transport(AnthropicScheme::new(), config, resolved), SchemeKind::OpenaiChat => build_transport(OpenAIScheme::new(), config, resolved), SchemeKind::Gemini => build_transport(GeminiScheme::new(), config, resolved), SchemeKind::OpenaiResponses => { build_transport(OpenAIResponsesScheme::new(), config, resolved) } } } #[cfg(test)] mod tests { use super::*; use serial_test::serial; use std::io::Write; use std::path::PathBuf; fn anthropic_config() -> ModelConfig { ModelConfig { scheme: SchemeKind::Anthropic, base_url: None, model_id: "claude-sonnet-4-20250514".into(), auth: AuthRef::ApiKey { env: None, file: None, }, capability: None, } } #[test] #[serial] fn resolve_from_env() { let env_name = SchemeKind::Anthropic.default_env_var(); unsafe { std::env::set_var(env_name, "sk-from-env") }; let auth = resolve_auth(SchemeKind::Anthropic, &anthropic_config().auth).unwrap(); unsafe { std::env::remove_var(env_name) }; match auth { ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-env"), _ => panic!("expected ApiKey"), } } #[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 { env: Some("INSOMNIA_API_KEY_NONEXISTENT".into()), 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] #[serial] fn env_takes_precedence_over_file() { let dir = tempfile::tempdir().unwrap(); let key_path = dir.path().join("key.txt"); std::fs::write(&key_path, "sk-from-file").unwrap(); let env_name = SchemeKind::Anthropic.default_env_var(); unsafe { std::env::set_var(env_name, "sk-from-env") }; let config = ModelConfig { auth: AuthRef::ApiKey { env: None, file: Some(key_path), }, ..anthropic_config() }; let auth = resolve_auth(config.scheme, &config.auth).unwrap(); unsafe { std::env::remove_var(env_name) }; match auth { ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-env"), _ => panic!("expected ApiKey"), } } #[test] fn relative_auth_file_is_rejected() { let config = ModelConfig { auth: AuthRef::ApiKey { env: Some("INSOMNIA_API_KEY_NONEXISTENT".into()), 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 env_name = SchemeKind::Anthropic.default_env_var(); unsafe { std::env::remove_var(env_name) }; let result = build_client(&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, }; 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; } #[test] fn ollama_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, }; // scheme.required_auth() が XApiKey でも ResolvedAuth::None は許容する // (None は全 scheme で受け入れるため) assert!(build_client(&config).is_ok()); } }