diff --git a/src-tauri/src/commands/provider_pool_cmd.rs b/src-tauri/src/commands/provider_pool_cmd.rs index 5b3770400..a6f144f11 100644 --- a/src-tauri/src/commands/provider_pool_cmd.rs +++ b/src-tauri/src/commands/provider_pool_cmd.rs @@ -742,6 +742,7 @@ pub fn add_codex_oauth_credential( db: State<'_, DbConnection>, pool_service: State<'_, ProviderPoolServiceState>, creds_file_path: String, + api_base_url: Option, name: Option, ) -> Result { // 复制并重命名文件到应用存储目录 @@ -752,6 +753,7 @@ pub fn add_codex_oauth_credential( "codex", CredentialData::CodexOAuth { creds_file_path: stored_file_path, + api_base_url, }, name, Some(true), @@ -1236,6 +1238,7 @@ pub async fn get_codex_auth_url_and_wait( "codex", CredentialData::CodexOAuth { creds_file_path: result.creds_file_path, + api_base_url: None, }, name, Some(true), @@ -1276,6 +1279,7 @@ pub async fn start_codex_oauth_login( "codex", CredentialData::CodexOAuth { creds_file_path: result.creds_file_path, + api_base_url: None, }, name, Some(true), diff --git a/src-tauri/src/models/provider_pool_model.rs b/src-tauri/src/models/provider_pool_model.rs index 0e15aca6d..187167ad7 100644 --- a/src-tauri/src/models/provider_pool_model.rs +++ b/src-tauri/src/models/provider_pool_model.rs @@ -72,7 +72,12 @@ pub enum CredentialData { excluded_models: Vec, }, /// Codex OAuth 凭证(OpenAI Codex) - CodexOAuth { creds_file_path: String }, + CodexOAuth { + creds_file_path: String, + /// API Base URL(可选,默认使用凭证文件中的配置) + #[serde(default)] + api_base_url: Option, + }, /// Claude OAuth 凭证(Anthropic OAuth) ClaudeOAuth { creds_file_path: String }, /// iFlow OAuth 凭证 @@ -113,7 +118,9 @@ impl CredentialData { CredentialData::GeminiApiKey { api_key, .. } => { format!("Gemini API Key: {}", mask_key(api_key)) } - CredentialData::CodexOAuth { creds_file_path } => { + CredentialData::CodexOAuth { + creds_file_path, .. + } => { format!("Codex OAuth: {}", mask_path(creds_file_path)) } CredentialData::ClaudeOAuth { creds_file_path } => { @@ -550,7 +557,9 @@ pub fn get_oauth_creds_path(cred: &CredentialData) -> Option { CredentialData::AntigravityOAuth { creds_file_path, .. } => Some(creds_file_path.clone()), - CredentialData::CodexOAuth { creds_file_path } => Some(creds_file_path.clone()), + CredentialData::CodexOAuth { + creds_file_path, .. + } => Some(creds_file_path.clone()), CredentialData::ClaudeOAuth { creds_file_path } => Some(creds_file_path.clone()), CredentialData::IFlowOAuth { creds_file_path } => Some(creds_file_path.clone()), CredentialData::IFlowCookie { creds_file_path } => Some(creds_file_path.clone()), diff --git a/src-tauri/src/providers/codex.rs b/src-tauri/src/providers/codex.rs index 4d5b9f647..f09eed36b 100644 --- a/src-tauri/src/providers/codex.rs +++ b/src-tauri/src/providers/codex.rs @@ -455,13 +455,27 @@ impl CodexProvider { .filter(|s| !s.is_empty()) } - fn build_responses_url(base_url: &str) -> String { + pub(crate) fn build_responses_url(base_url: &str) -> String { let base = base_url.trim_end_matches('/'); + + // 规则说明: + // - 如果 base_url 以 /v1 结尾:直接拼 /responses + // - 如果 base_url 只有域名(path 为空或 /):拼 /v1/responses(OpenAI 标准) + // - 如果 base_url 已包含路径前缀(如 https://yunyi.cfd/codex):认为前缀已包含路由信息,拼 /responses if base.ends_with("/v1") { - format!("{}/responses", base) - } else { - format!("{}/v1/responses", base) + return format!("{}/responses", base); } + + if let Ok(parsed) = url::Url::parse(base) { + let path = parsed.path().trim_end_matches('/'); + if path.is_empty() || path == "/" { + return format!("{}/v1/responses", base); + } + return format!("{}/responses", base); + } + + // 兜底:保持旧行为 + format!("{}/v1/responses", base) } /// Load credentials from the default path @@ -1092,7 +1106,18 @@ impl CodexProvider { .header("Openai-Beta", "responses=experimental") .json(&codex_request); - if matches!(mode, AuthMode::OAuth) { + // 部分三方 Codex 代理(如 Yunyi)会依赖 Codex CLI 的特征 headers; + // 仅在 OAuth 模式或显式配置了自定义 base_url 时附加,避免影响 OpenAI 官方 Key 模式。 + let should_add_codex_cli_headers = matches!(mode, AuthMode::OAuth) + || (matches!(mode, AuthMode::ApiKey) + && self + .credentials + .api_base_url + .as_deref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false)); + + if should_add_codex_cli_headers { req = req .header("Version", "0.21.0") .header( @@ -1101,6 +1126,7 @@ impl CodexProvider { ) .header("Originator", "codex_cli_rs") .header("Session_id", uuid::Uuid::new_v4().to_string()) + .header("Conversation_id", uuid::Uuid::new_v4().to_string()) // Add account ID header if available .header( "Chatgpt-Account-Id", @@ -1459,6 +1485,10 @@ mod tests { CodexProvider::build_responses_url("https://example.com/v1/"), "https://example.com/v1/responses" ); + assert_eq!( + CodexProvider::build_responses_url("https://yunyi.cfd/codex"), + "https://yunyi.cfd/codex/responses" + ); } #[tokio::test] diff --git a/src-tauri/src/server/handlers/management.rs b/src-tauri/src/server/handlers/management.rs index 2ca09fb80..4e751e249 100644 --- a/src-tauri/src/server/handlers/management.rs +++ b/src-tauri/src/server/handlers/management.rs @@ -383,6 +383,7 @@ pub async fn management_add_credential( if let Some(token_file) = request.token_file { CredentialData::CodexOAuth { creds_file_path: token_file, + api_base_url: request.base_url.clone(), } } else { return ( diff --git a/src-tauri/src/services/provider_pool_service.rs b/src-tauri/src/services/provider_pool_service.rs index 40c64950f..ab669f31e 100644 --- a/src-tauri/src/services/provider_pool_service.rs +++ b/src-tauri/src/services/provider_pool_service.rs @@ -487,8 +487,12 @@ impl ProviderPoolService { self.check_gemini_api_key_health(api_key, base_url.as_deref(), model) .await } - CredentialData::CodexOAuth { creds_file_path } => { - self.check_codex_health(creds_file_path, model).await + CredentialData::CodexOAuth { + creds_file_path, + api_base_url, + } => { + self.check_codex_health(creds_file_path, api_base_url.as_deref(), model) + .await } CredentialData::ClaudeOAuth { creds_file_path } => { self.check_claude_oauth_health(creds_file_path, model).await @@ -925,7 +929,12 @@ impl ProviderPoolService { // Codex 健康检查 // 支持 Yunyi 等代理使用 responses API 格式 - async fn check_codex_health(&self, creds_path: &str, model: &str) -> Result<(), String> { + async fn check_codex_health( + &self, + creds_path: &str, + override_base_url: Option<&str>, + model: &str, + ) -> Result<(), String> { use crate::providers::codex::CodexProvider; let mut provider = CodexProvider::new(); @@ -941,29 +950,34 @@ impl ProviderPoolService { ) })?; - // 获取 base_url,如果设置了则使用 responses API,否则使用 OpenAI chat/completions - let base_url = provider - .credentials - .api_base_url - .as_deref() + // 优先使用 override_base_url(来自 CredentialData),其次使用凭证文件中的配置 + let base_url = override_base_url .map(|s| s.trim()) - .filter(|s| !s.is_empty()); + .filter(|s| !s.is_empty()) + .or_else(|| { + provider + .credentials + .api_base_url + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + }); match base_url { Some(base) => { - // 使用自定义 base_url (如 Yunyi),使用 responses API 格式 - let base = base.trim_end_matches('/'); - let url = if base.ends_with("/v1") { - format!("{}/responses", base) - } else { - format!("{}/v1/responses", base) - }; + // 使用自定义 base_url (如 Yunyi),与 CodexProvider 的 URL/headers 行为保持一致 + let url = CodexProvider::build_responses_url(base); - // Codex/Yunyi 使用 responses API 格式 + // Codex/Yunyi 使用 responses API 格式;云驿等代理要求 stream 必须为 true let request_body = serde_json::json!({ "model": model, - "input": "Say OK", - "max_output_tokens": 10 + "input": [{ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Say OK"}] + }], + "max_output_tokens": 10, + "stream": true }); tracing::debug!( @@ -979,6 +993,13 @@ impl ProviderPoolService { .header("Content-Type", "application/json") .header("Accept", "text/event-stream") .header("Openai-Beta", "responses=experimental") + .header("Originator", "codex_cli_rs") + .header("Session_id", uuid::Uuid::new_v4().to_string()) + .header("Conversation_id", uuid::Uuid::new_v4().to_string()) + .header( + "User-Agent", + "codex_cli_rs/0.77.0 (ProxyCast health check; Mac OS; arm64)", + ) .json(&request_body) .timeout(self.health_check_timeout) .send() diff --git a/src-tauri/src/services/token_cache_service.rs b/src-tauri/src/services/token_cache_service.rs index ccab11b5d..927e551fa 100644 --- a/src-tauri/src/services/token_cache_service.rs +++ b/src-tauri/src/services/token_cache_service.rs @@ -282,9 +282,9 @@ impl TokenCacheService { last_refresh_error: None, }) } - CredentialData::CodexOAuth { creds_file_path } => { - self.refresh_codex(creds_file_path).await - } + CredentialData::CodexOAuth { + creds_file_path, .. + } => self.refresh_codex(creds_file_path).await, CredentialData::ClaudeOAuth { creds_file_path } => { self.refresh_claude_oauth(creds_file_path).await } @@ -764,7 +764,9 @@ impl TokenCacheService { refresh_error_count: 0, last_refresh_error: None, }), - CredentialData::CodexOAuth { creds_file_path } => { + CredentialData::CodexOAuth { + creds_file_path, .. + } => { let content = tokio::fs::read_to_string(creds_file_path) .await .map_err(|e| format!("读取 Codex 凭证文件失败: {}", e))?;