diff --git a/src-tauri/src/providers/codex.rs b/src-tauri/src/providers/codex.rs index 9873d0bfb..2ed901199 100644 --- a/src-tauri/src/providers/codex.rs +++ b/src-tauri/src/providers/codex.rs @@ -17,6 +17,7 @@ const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; const OPENAI_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const DEFAULT_CALLBACK_PORT: u16 = 1455; const CODEX_API_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; +const DEFAULT_API_BASE_URL: &str = "https://api.openai.com"; /// Codex OAuth credentials storage /// @@ -26,6 +27,10 @@ const CODEX_API_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; /// Supports multiple field name formats: /// - snake_case: `refresh_token`, `access_token`, `id_token`, `account_id`, `last_refresh` /// - camelCase: `refreshToken`, `accessToken`, `idToken`, `accountId`, `lastRefresh` +/// +/// 同时兼容 Codex CLI 的 API Key 登录格式: +/// - `api_key` / `apiKey` +/// - `api_base_url` / `apiBaseUrl` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CodexCredentials { /// JWT ID token containing user claims @@ -45,6 +50,12 @@ pub struct CodexCredentials { alias = "refreshToken" )] pub refresh_token: Option, + /// API Key(Codex CLI 支持通过 API Key 登录) + #[serde(default, skip_serializing_if = "Option::is_none", alias = "apiKey")] + pub api_key: Option, + /// API Base URL(可选) + #[serde(default, skip_serializing_if = "Option::is_none", alias = "apiBaseUrl")] + pub api_base_url: Option, /// OpenAI account identifier #[serde(default, skip_serializing_if = "Option::is_none", alias = "accountId")] pub account_id: Option, @@ -83,6 +94,8 @@ impl Default for CodexCredentials { id_token: None, access_token: None, refresh_token: None, + api_key: None, + api_base_url: None, account_id: None, last_refresh: None, email: None, @@ -428,6 +441,24 @@ impl CodexProvider { CODEX_API_BASE_URL } + /// 获取已配置的 API Key(trim 后的非空值) + fn get_api_key(&self) -> Option<&str> { + self.credentials + .api_key + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + } + + fn build_responses_url(base_url: &str) -> String { + let base = base_url.trim_end_matches('/'); + if base.ends_with("/v1") { + format!("{}/responses", base) + } else { + format!("{}/v1/responses", base) + } + } + /// Load credentials from the default path pub async fn load_credentials(&mut self) -> Result<(), Box> { let path = Self::default_creds_path(); @@ -457,9 +488,14 @@ impl CodexProvider { })?; // 检查关键字段 - if creds.refresh_token.is_none() { + let has_api_key = creds + .api_key + .as_deref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + if creds.refresh_token.is_none() && !has_api_key { tracing::warn!( - "[CODEX] 凭证文件缺少 refresh_token 字段。支持的字段名: refresh_token, refreshToken" + "[CODEX] 凭证文件缺少 refresh_token/api_key 字段。支持的字段名: refresh_token, refreshToken, api_key, apiKey" ); // 打印文件中的顶级字段名,帮助调试 if let Ok(json_value) = serde_json::from_str::(&content) { @@ -471,9 +507,10 @@ impl CodexProvider { } tracing::info!( - "[CODEX] 凭证加载成功: has_access={}, has_refresh={}, email={:?}, path={:?}", + "[CODEX] 凭证加载成功: has_access={}, has_refresh={}, has_api_key={}, email={:?}, path={:?}", creds.access_token.is_some(), creds.refresh_token.is_some(), + has_api_key, creds.email, path ); @@ -505,6 +542,11 @@ impl CodexProvider { /// Check if the access token is expired pub fn is_token_expired(&self) -> bool { + // API Key 模式:不涉及过期概念 + if self.get_api_key().is_some() { + return false; + } + if let Some(expires_str) = &self.credentials.expires_at { if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(expires_str) { let now = chrono::Utc::now(); @@ -518,6 +560,9 @@ impl CodexProvider { /// Check if credentials are valid (has access token and not expired) pub fn is_valid(&self) -> bool { + if self.get_api_key().is_some() { + return true; + } self.credentials.access_token.is_some() && !self.is_token_expired() } @@ -608,6 +653,8 @@ impl CodexProvider { id_token, access_token: Some(access_token), refresh_token, + api_key: None, + api_base_url: None, account_id, last_refresh: Some(chrono::Utc::now().to_rfc3339()), email, @@ -627,10 +674,15 @@ impl CodexProvider { /// Refresh the access token using the refresh token pub async fn refresh_token(&mut self) -> Result> { + // API Key 模式无需刷新 + if let Some(api_key) = self.get_api_key() { + return Ok(api_key.to_string()); + } + let refresh_token = self.credentials.refresh_token.as_ref().ok_or_else(|| { create_config_error( - "没有可用的 refresh_token。请确保凭证文件包含 refresh_token 或 refreshToken 字段,\ - 或使用 OAuth 登录功能重新获取凭证", + "没有可用的 refresh_token 或 api_key。请确保凭证文件包含 refresh_token/refreshToken 或 api_key/apiKey 字段,\ + 或使用 OAuth 登录功能重新获取凭证(需要 refresh_token)", ) })?; @@ -768,6 +820,11 @@ impl CodexProvider { /// Check if token needs refresh (expiring within the specified duration) pub fn needs_refresh(&self, lead_time: chrono::Duration) -> bool { + // API Key 模式无需刷新 + if self.get_api_key().is_some() { + return false; + } + if self.credentials.access_token.is_none() { return true; } @@ -788,6 +845,11 @@ impl CodexProvider { /// This is the recommended method to call before making API requests. /// It will automatically refresh the token if it's expired or about to expire. pub async fn ensure_valid_token(&mut self) -> Result> { + // 兼容 Codex CLI 的 API Key 登录:auth.json 只有 api_key,没有 refresh_token + if let Some(api_key) = self.get_api_key() { + return Ok(api_key.to_string()); + } + // Refresh if token expires within 5 minutes let lead_time = chrono::Duration::minutes(5); @@ -811,6 +873,11 @@ impl CodexProvider { /// Get the access token, refreshing if necessary pub async fn get_access_token(&mut self) -> Result> { + // API Key 模式直接返回 + if let Some(api_key) = self.get_api_key() { + return Ok(api_key.to_string()); + } + if self.is_token_expired() { self.refresh_token().await?; } @@ -919,42 +986,68 @@ impl CodexProvider { &self, request: &serde_json::Value, ) -> Result> { - let token = self - .credentials - .access_token - .as_ref() - .ok_or("No access token available")?; + enum AuthMode { + ApiKey, + OAuth, + } + + let (token, mode) = match self.get_api_key() { + Some(api_key) => (api_key, AuthMode::ApiKey), + None => ( + self.credentials + .access_token + .as_deref() + .ok_or("No access token or api_key available")?, + AuthMode::OAuth, + ), + }; // Build the Codex API URL - let url = format!("{}/responses", CODEX_API_BASE_URL); + let url = match mode { + AuthMode::ApiKey => { + let base_url = self + .credentials + .api_base_url + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .unwrap_or(DEFAULT_API_BASE_URL); + Self::build_responses_url(base_url) + } + AuthMode::OAuth => format!("{}/responses", CODEX_API_BASE_URL), + }; // Transform OpenAI chat completion request to Codex format let codex_request = transform_to_codex_format(request)?; tracing::debug!("[CODEX] Calling API: {}", url); - let resp = self + let mut req = self .client .post(&url) .header("Authorization", format!("Bearer {}", token)) .header("Content-Type", "application/json") .header("Accept", "text/event-stream") - .header("Version", "0.21.0") .header("Openai-Beta", "responses=experimental") - .header( - "User-Agent", - "codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464", - ) - .header("Originator", "codex_cli_rs") - .header("Session_id", uuid::Uuid::new_v4().to_string()) - // Add account ID header if available - .header( - "Chatgpt-Account-Id", - self.credentials.account_id.as_deref().unwrap_or(""), - ) - .json(&codex_request) - .send() - .await?; + .json(&codex_request); + + if matches!(mode, AuthMode::OAuth) { + req = req + .header("Version", "0.21.0") + .header( + "User-Agent", + "codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464", + ) + .header("Originator", "codex_cli_rs") + .header("Session_id", uuid::Uuid::new_v4().to_string()) + // Add account ID header if available + .header( + "Chatgpt-Account-Id", + self.credentials.account_id.as_deref().unwrap_or(""), + ); + } + + let resp = req.send().await?; Ok(resp) } @@ -1163,6 +1256,7 @@ mod tests { let creds = CodexCredentials::default(); assert!(creds.access_token.is_none()); assert!(creds.refresh_token.is_none()); + assert!(creds.api_key.is_none()); assert_eq!(creds.r#type, "codex"); } @@ -1232,6 +1326,32 @@ mod tests { assert_eq!(creds.expires_at, Some("2024-12-31T23:59:59Z".to_string())); } + #[test] + fn test_codex_credentials_api_key_fields() { + let json = r#"{ + "api_key": "sk-test", + "api_base_url": "https://api.openai.com/v1" + }"#; + + let creds: CodexCredentials = serde_json::from_str(json).unwrap(); + assert_eq!(creds.api_key, Some("sk-test".to_string())); + assert_eq!( + creds.api_base_url, + Some("https://api.openai.com/v1".to_string()) + ); + + let json2 = r#"{ + "apiKey": "sk-test-2", + "apiBaseUrl": "https://example.com/v1" + }"#; + let creds2: CodexCredentials = serde_json::from_str(json2).unwrap(); + assert_eq!(creds2.api_key, Some("sk-test-2".to_string())); + assert_eq!( + creds2.api_base_url, + Some("https://example.com/v1".to_string()) + ); + } + #[test] fn test_codex_credentials_expires_at_alias() { // 测试 expires_at 字段的多种别名 @@ -1264,6 +1384,31 @@ mod tests { assert!(provider.credentials.access_token.is_none()); } + #[test] + fn test_build_responses_url() { + assert_eq!( + CodexProvider::build_responses_url("https://api.openai.com"), + "https://api.openai.com/v1/responses" + ); + assert_eq!( + CodexProvider::build_responses_url("https://api.openai.com/v1"), + "https://api.openai.com/v1/responses" + ); + assert_eq!( + CodexProvider::build_responses_url("https://example.com/v1/"), + "https://example.com/v1/responses" + ); + } + + #[tokio::test] + async fn test_ensure_valid_token_prefers_api_key() { + let mut provider = CodexProvider::new(); + provider.credentials.api_key = Some("sk-test".to_string()); + + let token = provider.ensure_valid_token().await.unwrap(); + assert_eq!(token, "sk-test"); + } + #[test] fn test_generate_auth_url() { let provider = CodexProvider::new(); @@ -1342,7 +1487,12 @@ mod tests { // No expiry - should be considered expired assert!(provider.is_token_expired()); + // API Key 模式 - 不应视为过期 + provider.credentials.api_key = Some("sk-test".to_string()); + assert!(!provider.is_token_expired()); + // Expired token + provider.credentials.api_key = None; provider.credentials.expires_at = Some("2020-01-01T00:00:00Z".to_string()); assert!(provider.is_token_expired()); diff --git a/src-tauri/src/services/provider_pool_service.rs b/src-tauri/src/services/provider_pool_service.rs index 745a004df..b184ab321 100644 --- a/src-tauri/src/services/provider_pool_service.rs +++ b/src-tauri/src/services/provider_pool_service.rs @@ -852,28 +852,15 @@ impl ProviderPoolService { .map_err(|e| format!("获取 Codex Token 失败: {}", e))?; // 使用 OpenAI 兼容 API 进行健康检查 - let url = "https://api.openai.com/v1/chat/completions"; - let request_body = serde_json::json!({ - "model": model, - "messages": [{"role": "user", "content": "Say OK"}], - "max_tokens": 10 - }); + // 兼容 Codex CLI API Key 模式:如果 auth.json 提供 api_base_url,则优先使用 + let base_url = provider + .credentials + .api_base_url + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()); - let response = self - .client - .post(url) - .header("Authorization", format!("Bearer {}", token)) - .json(&request_body) - .timeout(self.health_check_timeout) - .send() - .await - .map_err(|e| format!("请求失败: {}", e))?; - - if response.status().is_success() { - Ok(()) - } else { - Err(format!("HTTP {}", response.status())) - } + self.check_openai_health(&token, base_url, model).await } // Claude OAuth 健康检查 @@ -1078,12 +1065,20 @@ impl ProviderPoolService { let creds: serde_json::Value = serde_json::from_str(&content).map_err(|e| format!("解析凭证文件失败: {}", e))?; - let has_access_token = creds + let has_api_key = creds + .get("apiKey") + .or_else(|| creds.get("api_key")) + .map(|v| v.as_str().is_some()) + .unwrap_or(false); + + let has_oauth_access_token = creds .get("accessToken") .or_else(|| creds.get("access_token")) .map(|v| v.as_str().is_some()) .unwrap_or(false); + let has_access_token = has_oauth_access_token || has_api_key; + let has_refresh_token = creds .get("refreshToken") .or_else(|| creds.get("refresh_token")) @@ -1114,6 +1109,20 @@ impl ProviderPoolService { (has_access_token, None) } } + "codex" => { + // Codex: 兼容 OAuth token 或 Codex CLI 的 API Key 登录 + if has_api_key { + (true, None) + } else { + let expires_at = creds + .get("expiresAt") + .or_else(|| creds.get("expires_at")) + .or_else(|| creds.get("expired")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + (has_oauth_access_token, expires_at) + } + } _ => (has_access_token, None), };