//! Codex (OpenAI) Provider Adapter //! //! 仅透传模式,支持直连 OpenAI API //! //! ## 客户端检测 //! 支持检测官方 Codex 客户端 (codex_vscode, codex_cli_rs) use super::{AuthInfo, AuthStrategy, ProviderAdapter}; use crate::provider::Provider; use crate::proxy::error::ProxyError; use regex::Regex; use serde_json::Value as JsonValue; use std::sync::LazyLock; use toml::Value as TomlValue; /// 官方 Codex 客户端 User-Agent 正则 #[allow(dead_code)] static CODEX_CLIENT_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^(codex_vscode|codex_cli_rs)/[\d.]+").unwrap()); /// Codex 适配器 pub struct CodexAdapter; /// Local model written into Codex config.toml for Chat-only upstreams. /// /// Codex itself must see a model it can load metadata for; the real provider /// model is restored inside the proxy immediately before Chat Completions /// conversion. pub const CODEX_CHAT_CLIENT_MODEL: &str = "gpt-5.4"; /// Whether this Codex provider's real upstream should be called through /// OpenAI Chat Completions, even if the local Codex client is talking to CC /// Switch through the Responses API. pub fn codex_provider_uses_chat_completions(provider: &Provider) -> bool { if let Some(api_format) = provider .meta .as_ref() .and_then(|meta| meta.api_format.as_deref()) .or_else(|| { provider .settings_config .get("api_format") .and_then(|v| v.as_str()) }) .or_else(|| { provider .settings_config .get("apiFormat") .and_then(|v| v.as_str()) }) { return is_chat_wire_api(api_format); } if let Some(wire_api) = provider .settings_config .get("config") .and_then(|v| v.as_str()) .and_then(extract_codex_wire_api_from_toml) { return is_chat_wire_api(&wire_api); } if let Some(base_url) = provider .settings_config .get("base_url") .or_else(|| provider.settings_config.get("baseURL")) .and_then(|v| v.as_str()) { return is_chat_completions_url(base_url); } provider .settings_config .get("config") .and_then(|v| v.as_str()) .and_then(extract_codex_base_url_from_toml) .map(|url| is_chat_completions_url(&url)) .unwrap_or(false) } pub fn should_convert_codex_responses_to_chat(provider: &Provider, endpoint: &str) -> bool { let path = endpoint .split_once('?') .map_or(endpoint, |(path, _query)| path); matches!( path, "/responses" | "/v1/responses" | "/responses/compact" | "/v1/responses/compact" ) && codex_provider_uses_chat_completions(provider) } /// Extract the real upstream model configured for a Codex provider. pub fn codex_provider_upstream_model(provider: &Provider) -> Option { provider .settings_config .get("model") .and_then(|v| v.as_str()) .map(str::trim) .filter(|model| !model.is_empty()) .map(ToString::to_string) .or_else(|| { provider .settings_config .get("config") .and_then(|v| v.as_str()) .and_then(extract_codex_model_from_toml) }) } /// For Codex Chat providers, replace the local Codex-safe model with the real /// upstream model before converting the request to Chat Completions. pub fn apply_codex_chat_upstream_model( provider: &Provider, body: &mut JsonValue, ) -> Option { if !codex_provider_uses_chat_completions(provider) { return None; } let upstream_model = codex_provider_upstream_model(provider)?; body["model"] = JsonValue::String(upstream_model.clone()); Some(upstream_model) } fn is_chat_wire_api(value: &str) -> bool { matches!( value.trim().to_ascii_lowercase().as_str(), "chat" | "chat_completions" | "chat-completions" | "openai_chat" | "openai-chat" | "openai_chat_completions" ) } fn is_chat_completions_url(value: &str) -> bool { value .trim_end_matches('/') .to_ascii_lowercase() .ends_with("/chat/completions") } fn extract_codex_wire_api_from_toml(config_text: &str) -> Option { let doc = config_text.parse::().ok()?; if let Some(active_provider) = doc.get("model_provider").and_then(|v| v.as_str()) { if let Some(wire_api) = doc .get("model_providers") .and_then(|providers| providers.get(active_provider)) .and_then(|provider| provider.get("wire_api")) .and_then(|v| v.as_str()) { return Some(wire_api.to_string()); } } doc.get("wire_api") .and_then(|v| v.as_str()) .map(ToString::to_string) } fn extract_codex_model_from_toml(config_text: &str) -> Option { let doc = config_text.parse::().ok()?; doc.get("model") .and_then(|v| v.as_str()) .map(str::trim) .filter(|model| !model.is_empty()) .map(ToString::to_string) } fn extract_codex_base_url_from_toml(config_text: &str) -> Option { let doc = config_text.parse::().ok()?; if let Some(active_provider) = doc.get("model_provider").and_then(|v| v.as_str()) { if let Some(base_url) = doc .get("model_providers") .and_then(|providers| providers.get(active_provider)) .and_then(|provider| provider.get("base_url")) .and_then(|v| v.as_str()) { return Some(base_url.to_string()); } } doc.get("base_url") .and_then(|v| v.as_str()) .map(ToString::to_string) } impl CodexAdapter { pub fn new() -> Self { Self } /// 检测是否为官方 Codex 客户端 /// /// 匹配 User-Agent 模式: `^(codex_vscode|codex_cli_rs)/[\d.]+` #[allow(dead_code)] pub fn is_official_client(user_agent: &str) -> bool { CODEX_CLIENT_REGEX.is_match(user_agent) } /// 从 Provider 配置中提取 API Key fn extract_key(&self, provider: &Provider) -> Option { // 1. 尝试从 env 中获取 if let Some(env) = provider.settings_config.get("env") { if let Some(key) = env.get("OPENAI_API_KEY").and_then(|v| v.as_str()) { return Some(key.to_string()); } } // 2. 尝试从 auth 中获取 (Codex CLI 格式) if let Some(auth) = provider.settings_config.get("auth") { if let Some(key) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) { return Some(key.to_string()); } } // 3. 尝试直接获取 if let Some(key) = provider .settings_config .get("apiKey") .or_else(|| provider.settings_config.get("api_key")) .and_then(|v| v.as_str()) { return Some(key.to_string()); } // 4. 尝试从 config 对象中获取 if let Some(config) = provider.settings_config.get("config") { if let Some(key) = config .get("api_key") .or_else(|| config.get("apiKey")) .and_then(|v| v.as_str()) { return Some(key.to_string()); } } None } } impl Default for CodexAdapter { fn default() -> Self { Self::new() } } impl ProviderAdapter for CodexAdapter { fn name(&self) -> &'static str { "Codex" } fn extract_base_url(&self, provider: &Provider) -> Result { // 1. 尝试直接获取 base_url 字段 if let Some(url) = provider .settings_config .get("base_url") .and_then(|v| v.as_str()) { return Ok(url.trim_end_matches('/').to_string()); } // 2. 尝试 baseURL if let Some(url) = provider .settings_config .get("baseURL") .and_then(|v| v.as_str()) { return Ok(url.trim_end_matches('/').to_string()); } // 3. 尝试从 config 对象中获取 if let Some(config) = provider.settings_config.get("config") { if let Some(url) = config.get("base_url").and_then(|v| v.as_str()) { return Ok(url.trim_end_matches('/').to_string()); } // 尝试解析 TOML 字符串格式 if let Some(config_str) = config.as_str() { if let Some(start) = config_str.find("base_url = \"") { let rest = &config_str[start + 12..]; if let Some(end) = rest.find('"') { return Ok(rest[..end].trim_end_matches('/').to_string()); } } if let Some(start) = config_str.find("base_url = '") { let rest = &config_str[start + 12..]; if let Some(end) = rest.find('\'') { return Ok(rest[..end].trim_end_matches('/').to_string()); } } } } Err(ProxyError::ConfigError( "Codex Provider 缺少 base_url 配置".to_string(), )) } fn extract_auth(&self, provider: &Provider) -> Option { self.extract_key(provider) .map(|key| AuthInfo::new(key, AuthStrategy::Bearer)) } fn build_url(&self, base_url: &str, endpoint: &str) -> String { let base_trimmed = base_url.trim_end_matches('/'); let endpoint_trimmed = endpoint.trim_start_matches('/'); // OpenAI/Codex 的 base_url 可能是: // - 纯 origin: https://api.openai.com (需要自动补 /v1) // - 已含 /v1: https://api.openai.com/v1 (直接拼接) // - 自定义前缀: https://xxx/openai (不添加 /v1,直接拼接) // 检查 base_url 是否已经包含 /v1 let already_has_v1 = base_trimmed.ends_with("/v1"); // 检查是否是纯 origin(没有路径部分) let origin_only = match base_trimmed.split_once("://") { Some((_scheme, rest)) => !rest.contains('/'), None => !base_trimmed.contains('/'), }; let mut url = if already_has_v1 { // 已经有 /v1,直接拼接 format!("{base_trimmed}/{endpoint_trimmed}") } else if origin_only { // 纯 origin,添加 /v1 format!("{base_trimmed}/v1/{endpoint_trimmed}") } else { // 自定义前缀,不添加 /v1,直接拼接 format!("{base_trimmed}/{endpoint_trimmed}") }; // 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致) while url.contains("/v1/v1") { url = url.replace("/v1/v1", "/v1"); } url } fn get_auth_headers( &self, auth: &AuthInfo, ) -> Result, ProxyError> { use super::adapter::auth_header_value; let bearer = format!("Bearer {}", auth.api_key); Ok(vec![( http::HeaderName::from_static("authorization"), auth_header_value(&bearer)?, )]) } } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn create_provider(config: serde_json::Value) -> Provider { Provider { id: "test".to_string(), name: "Test Codex".to_string(), settings_config: config, website_url: None, category: Some("codex".to_string()), created_at: None, sort_index: None, notes: None, meta: None, icon: None, icon_color: None, in_failover_queue: false, } } #[test] fn test_extract_base_url_direct() { let adapter = CodexAdapter::new(); let provider = create_provider(json!({ "base_url": "https://api.openai.com/v1" })); let url = adapter.extract_base_url(&provider).unwrap(); assert_eq!(url, "https://api.openai.com/v1"); } #[test] fn test_extract_auth_from_auth_field() { let adapter = CodexAdapter::new(); let provider = create_provider(json!({ "auth": { "OPENAI_API_KEY": "sk-test-key-12345678" } })); let auth = adapter.extract_auth(&provider).unwrap(); assert_eq!(auth.api_key, "sk-test-key-12345678"); assert_eq!(auth.strategy, AuthStrategy::Bearer); } #[test] fn test_extract_auth_from_env() { let adapter = CodexAdapter::new(); let provider = create_provider(json!({ "env": { "OPENAI_API_KEY": "sk-env-key-12345678" } })); let auth = adapter.extract_auth(&provider).unwrap(); assert_eq!(auth.api_key, "sk-env-key-12345678"); } #[test] fn test_build_url() { let adapter = CodexAdapter::new(); let url = adapter.build_url("https://api.openai.com/v1", "/responses"); assert_eq!(url, "https://api.openai.com/v1/responses"); } #[test] fn test_build_url_origin_adds_v1() { let adapter = CodexAdapter::new(); let url = adapter.build_url("https://api.openai.com", "/responses"); assert_eq!(url, "https://api.openai.com/v1/responses"); } #[test] fn test_build_url_custom_prefix_no_v1() { let adapter = CodexAdapter::new(); let url = adapter.build_url("https://example.com/openai", "/responses"); assert_eq!(url, "https://example.com/openai/responses"); } #[test] fn test_build_url_dedup_v1() { let adapter = CodexAdapter::new(); // base_url 已包含 /v1,endpoint 也包含 /v1 let url = adapter.build_url("https://www.packyapi.com/v1", "/v1/responses"); assert_eq!(url, "https://www.packyapi.com/v1/responses"); } // 官方客户端检测测试 #[test] fn test_is_official_client_vscode() { assert!(CodexAdapter::is_official_client("codex_vscode/1.0.0")); assert!(CodexAdapter::is_official_client("codex_vscode/2.3.4")); assert!(CodexAdapter::is_official_client("codex_vscode/0.1")); } #[test] fn test_is_official_client_cli() { assert!(CodexAdapter::is_official_client("codex_cli_rs/1.0.0")); assert!(CodexAdapter::is_official_client("codex_cli_rs/0.5.2")); } #[test] fn test_is_not_official_client() { assert!(!CodexAdapter::is_official_client("Mozilla/5.0")); assert!(!CodexAdapter::is_official_client("curl/7.68.0")); assert!(!CodexAdapter::is_official_client("python-requests/2.25.1")); assert!(!CodexAdapter::is_official_client("codex_other/1.0.0")); assert!(!CodexAdapter::is_official_client("")); } #[test] fn test_is_official_client_partial_match() { // 必须从开头匹配 assert!(!CodexAdapter::is_official_client("some codex_vscode/1.0.0")); assert!(!CodexAdapter::is_official_client( "prefix_codex_cli_rs/1.0.0" )); } #[test] fn test_codex_provider_uses_chat_completions_from_active_wire_api() { let provider = create_provider(json!({ "config": r#" model_provider = "chat_only" model = "gpt-5" [model_providers.chat_only] name = "Chat Only" base_url = "https://example.com/v1" wire_api = "chat" "# })); assert!(codex_provider_uses_chat_completions(&provider)); assert!(should_convert_codex_responses_to_chat( &provider, "/responses?stream=true" )); assert!(!should_convert_codex_responses_to_chat( &provider, "/chat/completions" )); } #[test] fn test_codex_provider_uses_chat_completions_from_full_chat_url() { let provider = create_provider(json!({ "base_url": "https://example.com/v1/chat/completions" })); assert!(codex_provider_uses_chat_completions(&provider)); assert!(should_convert_codex_responses_to_chat( &provider, "/v1/responses/compact" )); } #[test] fn test_codex_provider_uses_chat_completions_from_meta_api_format_for_compact() { let mut provider = create_provider(json!({ "base_url": "https://example.com/v1" })); provider.meta = Some(crate::provider::ProviderMeta { api_format: Some("openai_chat".to_string()), ..Default::default() }); assert!(codex_provider_uses_chat_completions(&provider)); assert!(should_convert_codex_responses_to_chat( &provider, "/responses/compact?stream=true" )); } #[test] fn test_codex_provider_uses_chat_completions_from_meta_api_format_for_responses() { let mut provider = create_provider(json!({ "base_url": "https://api.deepseek.com/v1" })); provider.meta = Some(crate::provider::ProviderMeta { api_format: Some("openai_chat".to_string()), ..Default::default() }); assert!(should_convert_codex_responses_to_chat( &provider, "/v1/responses" )); } #[test] fn test_apply_codex_chat_upstream_model_uses_provider_config_model() { let mut provider = create_provider(json!({ "config": r#" model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" "# })); provider.meta = Some(crate::provider::ProviderMeta { api_format: Some("openai_chat".to_string()), ..Default::default() }); let mut body = json!({ "model": CODEX_CHAT_CLIENT_MODEL, "input": "ping" }); let upstream_model = apply_codex_chat_upstream_model(&provider, &mut body); assert_eq!(upstream_model.as_deref(), Some("deepseek-v4-flash")); assert_eq!( body.get("model").and_then(|v| v.as_str()), Some("deepseek-v4-flash") ); } }