diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index 03ca3e7ed..e95c9b234 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -1,9 +1,14 @@ use tauri::State; +use crate::commands::codex_oauth::CodexOAuthState; use crate::commands::copilot::CopilotAuthState; -use crate::proxy::providers::copilot_auth::{GitHubAccount, GitHubDeviceCodeResponse}; +use crate::proxy::providers::codex_oauth_auth::CodexOAuthError; +use crate::proxy::providers::copilot_auth::{ + CopilotAuthError, GitHubAccount, GitHubDeviceCodeResponse, +}; const AUTH_PROVIDER_GITHUB_COPILOT: &str = "github_copilot"; +const AUTH_PROVIDER_CODEX_OAUTH: &str = "codex_oauth"; #[derive(Debug, Clone, serde::Serialize)] pub struct ManagedAuthAccount { @@ -34,9 +39,10 @@ pub struct ManagedAuthDeviceCodeResponse { pub interval: u64, } -fn ensure_auth_provider(auth_provider: &str) -> Result<&str, String> { +fn ensure_auth_provider(auth_provider: &str) -> Result<&'static str, String> { match auth_provider { AUTH_PROVIDER_GITHUB_COPILOT => Ok(AUTH_PROVIDER_GITHUB_COPILOT), + AUTH_PROVIDER_CODEX_OAUTH => Ok(AUTH_PROVIDER_CODEX_OAUTH), _ => Err(format!("Unsupported auth provider: {auth_provider}")), } } @@ -73,110 +79,220 @@ fn map_device_code_response( #[tauri::command(rename_all = "camelCase")] pub async fn auth_start_login( auth_provider: String, - state: State<'_, CopilotAuthState>, + copilot_state: State<'_, CopilotAuthState>, + codex_state: State<'_, CodexOAuthState>, ) -> Result { let auth_provider = ensure_auth_provider(&auth_provider)?; - let auth_manager = state.0.read().await; - let response = auth_manager - .start_device_flow() - .await - .map_err(|e| e.to_string())?; - Ok(map_device_code_response(auth_provider, response)) + match auth_provider { + AUTH_PROVIDER_GITHUB_COPILOT => { + let auth_manager = copilot_state.0.read().await; + let response = auth_manager + .start_device_flow() + .await + .map_err(|e| e.to_string())?; + Ok(map_device_code_response(auth_provider, response)) + } + AUTH_PROVIDER_CODEX_OAUTH => { + let auth_manager = codex_state.0.read().await; + let response = auth_manager + .start_device_flow() + .await + .map_err(|e| e.to_string())?; + Ok(map_device_code_response(auth_provider, response)) + } + _ => unreachable!(), + } } #[tauri::command(rename_all = "camelCase")] pub async fn auth_poll_for_account( auth_provider: String, device_code: String, - state: State<'_, CopilotAuthState>, + copilot_state: State<'_, CopilotAuthState>, + codex_state: State<'_, CodexOAuthState>, ) -> Result, String> { let auth_provider = ensure_auth_provider(&auth_provider)?; - let auth_manager = state.0.write().await; - match auth_manager.poll_for_token(&device_code).await { - Ok(account) => { - let default_account_id = auth_manager.get_status().await.default_account_id; - Ok(account - .map(|account| map_account(auth_provider, account, default_account_id.as_deref()))) + match auth_provider { + AUTH_PROVIDER_GITHUB_COPILOT => { + let auth_manager = copilot_state.0.write().await; + match auth_manager.poll_for_token(&device_code).await { + Ok(account) => { + let default_account_id = auth_manager.get_status().await.default_account_id; + Ok(account.map(|account| { + map_account(auth_provider, account, default_account_id.as_deref()) + })) + } + Err(CopilotAuthError::AuthorizationPending) => Ok(None), + Err(e) => Err(e.to_string()), + } } - Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => { - Ok(None) + AUTH_PROVIDER_CODEX_OAUTH => { + let auth_manager = codex_state.0.write().await; + match auth_manager.poll_for_token(&device_code).await { + Ok(account) => { + let default_account_id = auth_manager.get_status().await.default_account_id; + Ok(account.map(|account| { + map_account(auth_provider, account, default_account_id.as_deref()) + })) + } + Err(CodexOAuthError::AuthorizationPending) => Ok(None), + Err(e) => Err(e.to_string()), + } } - Err(e) => Err(e.to_string()), + _ => unreachable!(), } } #[tauri::command(rename_all = "camelCase")] pub async fn auth_list_accounts( auth_provider: String, - state: State<'_, CopilotAuthState>, + copilot_state: State<'_, CopilotAuthState>, + codex_state: State<'_, CodexOAuthState>, ) -> Result, String> { let auth_provider = ensure_auth_provider(&auth_provider)?; - let auth_manager = state.0.read().await; - let status = auth_manager.get_status().await; - let default_account_id = status.default_account_id.clone(); - Ok(status - .accounts - .into_iter() - .map(|account| map_account(auth_provider, account, default_account_id.as_deref())) - .collect()) + match auth_provider { + AUTH_PROVIDER_GITHUB_COPILOT => { + let auth_manager = copilot_state.0.read().await; + let status = auth_manager.get_status().await; + let default_account_id = status.default_account_id.clone(); + Ok(status + .accounts + .into_iter() + .map(|account| map_account(auth_provider, account, default_account_id.as_deref())) + .collect()) + } + AUTH_PROVIDER_CODEX_OAUTH => { + let auth_manager = codex_state.0.read().await; + let status = auth_manager.get_status().await; + let default_account_id = status.default_account_id.clone(); + Ok(status + .accounts + .into_iter() + .map(|account| map_account(auth_provider, account, default_account_id.as_deref())) + .collect()) + } + _ => unreachable!(), + } } #[tauri::command(rename_all = "camelCase")] pub async fn auth_get_status( auth_provider: String, - state: State<'_, CopilotAuthState>, + copilot_state: State<'_, CopilotAuthState>, + codex_state: State<'_, CodexOAuthState>, ) -> Result { let auth_provider = ensure_auth_provider(&auth_provider)?; - let auth_manager = state.0.read().await; - let status = auth_manager.get_status().await; - let default_account_id = status.default_account_id.clone(); - Ok(ManagedAuthStatus { - provider: auth_provider.to_string(), - authenticated: status.authenticated, - default_account_id: default_account_id.clone(), - migration_error: status.migration_error, - accounts: status - .accounts - .into_iter() - .map(|account| map_account(auth_provider, account, default_account_id.as_deref())) - .collect(), - }) + match auth_provider { + AUTH_PROVIDER_GITHUB_COPILOT => { + let auth_manager = copilot_state.0.read().await; + let status = auth_manager.get_status().await; + let default_account_id = status.default_account_id.clone(); + Ok(ManagedAuthStatus { + provider: auth_provider.to_string(), + authenticated: status.authenticated, + default_account_id: default_account_id.clone(), + migration_error: status.migration_error, + accounts: status + .accounts + .into_iter() + .map(|account| { + map_account(auth_provider, account, default_account_id.as_deref()) + }) + .collect(), + }) + } + AUTH_PROVIDER_CODEX_OAUTH => { + let auth_manager = codex_state.0.read().await; + let status = auth_manager.get_status().await; + let default_account_id = status.default_account_id.clone(); + Ok(ManagedAuthStatus { + provider: auth_provider.to_string(), + authenticated: status.authenticated, + default_account_id: default_account_id.clone(), + migration_error: None, + accounts: status + .accounts + .into_iter() + .map(|account| { + map_account(auth_provider, account, default_account_id.as_deref()) + }) + .collect(), + }) + } + _ => unreachable!(), + } } #[tauri::command(rename_all = "camelCase")] pub async fn auth_remove_account( auth_provider: String, account_id: String, - state: State<'_, CopilotAuthState>, + copilot_state: State<'_, CopilotAuthState>, + codex_state: State<'_, CodexOAuthState>, ) -> Result<(), String> { - ensure_auth_provider(&auth_provider)?; - let auth_manager = state.0.write().await; - auth_manager - .remove_account(&account_id) - .await - .map_err(|e| e.to_string()) + let auth_provider = ensure_auth_provider(&auth_provider)?; + match auth_provider { + AUTH_PROVIDER_GITHUB_COPILOT => { + let auth_manager = copilot_state.0.write().await; + auth_manager + .remove_account(&account_id) + .await + .map_err(|e| e.to_string()) + } + AUTH_PROVIDER_CODEX_OAUTH => { + let auth_manager = codex_state.0.write().await; + auth_manager + .remove_account(&account_id) + .await + .map_err(|e| e.to_string()) + } + _ => unreachable!(), + } } #[tauri::command(rename_all = "camelCase")] pub async fn auth_set_default_account( auth_provider: String, account_id: String, - state: State<'_, CopilotAuthState>, + copilot_state: State<'_, CopilotAuthState>, + codex_state: State<'_, CodexOAuthState>, ) -> Result<(), String> { - ensure_auth_provider(&auth_provider)?; - let auth_manager = state.0.write().await; - auth_manager - .set_default_account(&account_id) - .await - .map_err(|e| e.to_string()) + let auth_provider = ensure_auth_provider(&auth_provider)?; + match auth_provider { + AUTH_PROVIDER_GITHUB_COPILOT => { + let auth_manager = copilot_state.0.write().await; + auth_manager + .set_default_account(&account_id) + .await + .map_err(|e| e.to_string()) + } + AUTH_PROVIDER_CODEX_OAUTH => { + let auth_manager = codex_state.0.write().await; + auth_manager + .set_default_account(&account_id) + .await + .map_err(|e| e.to_string()) + } + _ => unreachable!(), + } } #[tauri::command(rename_all = "camelCase")] pub async fn auth_logout( auth_provider: String, - state: State<'_, CopilotAuthState>, + copilot_state: State<'_, CopilotAuthState>, + codex_state: State<'_, CodexOAuthState>, ) -> Result<(), String> { - ensure_auth_provider(&auth_provider)?; - let auth_manager = state.0.write().await; - auth_manager.clear_auth().await.map_err(|e| e.to_string()) + let auth_provider = ensure_auth_provider(&auth_provider)?; + match auth_provider { + AUTH_PROVIDER_GITHUB_COPILOT => { + let auth_manager = copilot_state.0.write().await; + auth_manager.clear_auth().await.map_err(|e| e.to_string()) + } + AUTH_PROVIDER_CODEX_OAUTH => { + let auth_manager = codex_state.0.write().await; + auth_manager.clear_auth().await.map_err(|e| e.to_string()) + } + _ => unreachable!(), + } } diff --git a/src-tauri/src/commands/codex_oauth.rs b/src-tauri/src/commands/codex_oauth.rs new file mode 100644 index 000000000..040de9140 --- /dev/null +++ b/src-tauri/src/commands/codex_oauth.rs @@ -0,0 +1,13 @@ +//! Codex OAuth Tauri Commands +//! +//! 提供 OpenAI ChatGPT Plus/Pro OAuth 认证相关的 Tauri 命令。 +//! +//! 注意:实际的命令通过通用 `auth_*` 命令(参见 `commands::auth`)暴露给前端, +//! 此处仅定义 State wrapper。 + +use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Codex OAuth 认证状态 +pub struct CodexOAuthState(pub Arc>); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d0be4717b..6bf8a8c73 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ mod auth; mod balance; +mod codex_oauth; mod coding_plan; mod config; mod copilot; @@ -33,6 +34,7 @@ mod workspace; pub use auth::*; pub use balance::*; +pub use codex_oauth::*; pub use coding_plan::*; pub use config::*; pub use copilot::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6e2648ff9..62444ba62 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -715,6 +715,18 @@ pub fn run() { log::info!("✓ CopilotAuthManager initialized"); } + // 初始化 CodexOAuthManager (ChatGPT Plus/Pro 反代) + { + use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager; + use commands::CodexOAuthState; + use tokio::sync::RwLock; + + let app_config_dir = crate::config::get_app_config_dir(); + let codex_oauth_manager = CodexOAuthManager::new(app_config_dir); + app.manage(CodexOAuthState(Arc::new(RwLock::new(codex_oauth_manager)))); + log::info!("✓ CodexOAuthManager initialized"); + } + // 初始化全局出站代理 HTTP 客户端 { let db = &app.state::().db; diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 13516c383..c4172ea3d 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -17,7 +17,8 @@ use super::{ types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig}, ProxyError, }; -use crate::commands::CopilotAuthState; +use crate::commands::{CodexOAuthState, CopilotAuthState}; +use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager; use crate::proxy::providers::copilot_auth::CopilotAuthManager; use crate::{app_config::AppType, provider::Provider}; use http::Extensions; @@ -917,6 +918,9 @@ impl RequestForwarder { let force_identity_encoding = needs_transform || should_force_identity_encoding(&effective_endpoint, &filtered_body, headers); + // Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充) + let mut codex_oauth_account_id: Option = None; + // 获取认证头(提前准备,用于内联替换) let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) { // GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token @@ -969,11 +973,71 @@ impl RequestForwarder { )); } } + + // Codex OAuth 特殊处理:从 CodexOAuthManager 获取真实 access_token + if auth.strategy == AuthStrategy::CodexOAuth { + if let Some(app_handle) = &self.app_handle { + let codex_state = app_handle.state::(); + let codex_auth: tokio::sync::RwLockReadGuard<'_, CodexOAuthManager> = + codex_state.0.read().await; + + // 从 provider.meta 获取关联的 ChatGPT 账号 ID + let account_id = provider + .meta + .as_ref() + .and_then(|m| m.managed_account_id_for("codex_oauth")); + + let token_result = match &account_id { + Some(id) => { + log::debug!("[CodexOAuth] 使用指定账号 {id} 获取 token"); + codex_auth.get_valid_token_for_account(id).await + } + None => { + log::debug!("[CodexOAuth] 使用默认账号获取 token"); + codex_auth.get_valid_token().await + } + }; + + match token_result { + Ok(token) => { + auth = AuthInfo::new(token, AuthStrategy::CodexOAuth); + // 解析使用的 account_id(用于注入 ChatGPT-Account-Id header) + codex_oauth_account_id = match account_id { + Some(id) => Some(id), + None => codex_auth.default_account_id().await, + }; + log::debug!( + "[CodexOAuth] 成功获取 access_token (account={})", + codex_oauth_account_id.as_deref().unwrap_or("default") + ); + } + Err(e) => { + log::error!("[CodexOAuth] 获取 access_token 失败: {e}"); + return Err(ProxyError::AuthError(format!( + "Codex OAuth 认证失败: {e}" + ))); + } + } + } else { + log::error!("[CodexOAuth] AppHandle 不可用"); + return Err(ProxyError::AuthError( + "Codex OAuth 认证不可用(无 AppHandle)".to_string(), + )); + } + } + adapter.get_auth_headers(&auth) } else { Vec::new() }; + // 注入 Codex OAuth 的 ChatGPT-Account-Id header(如果有 account_id) + if let Some(ref account_id) = codex_oauth_account_id { + if let Ok(hv) = http::HeaderValue::from_str(account_id) { + auth_headers.push((http::HeaderName::from_static("chatgpt-account-id"), hv)); + } + } + // --- Copilot 优化器:动态 header 注入 --- if let Some((ref classification, ref det_request_id)) = copilot_optimization { for (name, value) in auth_headers.iter_mut() { diff --git a/src-tauri/src/proxy/providers/auth.rs b/src-tauri/src/proxy/providers/auth.rs index 3ec6b8bdb..6bd9a02a5 100644 --- a/src-tauri/src/proxy/providers/auth.rs +++ b/src-tauri/src/proxy/providers/auth.rs @@ -119,6 +119,15 @@ pub enum AuthStrategy { /// /// 使用动态获取的 Copilot Token(通过 GitHub OAuth 设备码流程获取) GitHubCopilot, + + /// Codex OAuth 认证方式(ChatGPT Plus/Pro) + /// + /// - Header: `Authorization: Bearer ` + /// - Header: `ChatGPT-Account-Id: ` (来自 forwarder 注入) + /// - Header: `originator: cc-switch` + /// + /// 使用动态获取的 OpenAI access_token(通过 Device Code 流程获取) + CodexOAuth, } #[cfg(test)] @@ -234,6 +243,7 @@ mod tests { AuthStrategy::Google, AuthStrategy::GoogleOAuth, AuthStrategy::GitHubCopilot, + AuthStrategy::CodexOAuth, ]; for (i, s1) in strategies.iter().enumerate() { diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 2cd7c0172..52689d3b5 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -22,6 +22,13 @@ use crate::proxy::error::ProxyError; /// 供 handler/forwarder 外部使用的公开函数。 /// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic" pub fn get_claude_api_format(provider: &Provider) -> &'static str { + // 0) Codex OAuth 强制使用 openai_responses(不可被覆盖) + if let Some(meta) = provider.meta.as_ref() { + if meta.provider_type.as_deref() == Some("codex_oauth") { + return "openai_responses"; + } + } + // 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config) if let Some(meta) = provider.meta.as_ref() { if let Some(api_format) = meta.api_format.as_deref() { @@ -82,7 +89,18 @@ pub fn transform_claude_request_for_api_format( match api_format { "openai_responses" => { - super::transform_responses::anthropic_to_responses(body, Some(cache_key)) + // Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false + // + include: ["reasoning.encrypted_content"],由 transform 层统一处理。 + let is_codex_oauth = provider + .meta + .as_ref() + .and_then(|m| m.provider_type.as_deref()) + == Some("codex_oauth"); + super::transform_responses::anthropic_to_responses( + body, + Some(cache_key), + is_codex_oauth, + ) } "openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)), _ => Ok(body), @@ -101,10 +119,16 @@ impl ClaudeAdapter { /// /// 根据 base_url 和 auth_mode 检测具体的供应商类型: /// - GitHubCopilot: meta.provider_type 为 github_copilot 或 base_url 包含 githubcopilot.com + /// - CodexOAuth: meta.provider_type 为 codex_oauth /// - OpenRouter: base_url 包含 openrouter.ai /// - ClaudeAuth: auth_mode 为 bearer_only /// - Claude: 默认 Anthropic 官方 pub fn provider_type(&self, provider: &Provider) -> ProviderType { + // 检测 Codex OAuth (ChatGPT Plus/Pro) + if self.is_codex_oauth(provider) { + return ProviderType::CodexOAuth; + } + // 检测 GitHub Copilot if self.is_github_copilot(provider) { return ProviderType::GitHubCopilot; @@ -123,6 +147,16 @@ impl ClaudeAdapter { ProviderType::Claude } + /// 检测是否为 Codex OAuth 供应商(ChatGPT Plus/Pro 反代) + fn is_codex_oauth(&self, provider: &Provider) -> bool { + if let Some(meta) = provider.meta.as_ref() { + if meta.provider_type.as_deref() == Some("codex_oauth") { + return true; + } + } + false + } + /// 检测是否为 GitHub Copilot 供应商 fn is_github_copilot(&self, provider: &Provider) -> bool { // 方式1: 检查 meta.provider_type @@ -254,6 +288,11 @@ impl ProviderAdapter for ClaudeAdapter { } fn extract_base_url(&self, provider: &Provider) -> Result { + // Codex OAuth: 强制使用 ChatGPT 后端 API 端点(忽略用户配置的 base_url) + if self.is_codex_oauth(provider) { + return Ok("https://chatgpt.com/backend-api/codex".to_string()); + } + // 1. 从 env 中获取 if let Some(env) = provider.settings_config.get("env") { if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) { @@ -304,6 +343,15 @@ impl ProviderAdapter for ClaudeAdapter { )); } + // Codex OAuth (ChatGPT Plus/Pro) 同样使用占位符 + // 实际的 access_token 由 CodexOAuthManager 动态提供 + if provider_type == ProviderType::CodexOAuth { + return Some(AuthInfo::new( + "codex_oauth_placeholder".to_string(), + AuthStrategy::CodexOAuth, + )); + } + let strategy = match provider_type { ProviderType::OpenRouter => AuthStrategy::Bearer, ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth, @@ -315,6 +363,12 @@ impl ProviderAdapter for ClaudeAdapter { } fn build_url(&self, base_url: &str, endpoint: &str) -> String { + // Codex OAuth: 所有请求统一走 /responses 端点 + if base_url == "https://chatgpt.com/backend-api/codex" { + let _ = endpoint; // 忽略原始 endpoint + return "https://chatgpt.com/backend-api/codex/responses".to_string(); + } + // NOTE: // 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages` // 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。 @@ -347,6 +401,20 @@ impl ProviderAdapter for ClaudeAdapter { HeaderValue::from_str(&bearer).unwrap(), )] } + AuthStrategy::CodexOAuth => { + // 注意:bearer token 由 forwarder 动态注入到 auth.api_key + // ChatGPT-Account-Id 由 forwarder 注入额外 header + vec![ + ( + HeaderName::from_static("authorization"), + HeaderValue::from_str(&bearer).unwrap(), + ), + ( + HeaderName::from_static("originator"), + HeaderValue::from_static("cc-switch"), + ), + ] + } AuthStrategy::GitHubCopilot => { // 生成请求追踪 ID let request_id = uuid::Uuid::new_v4().to_string(); @@ -412,6 +480,11 @@ impl ProviderAdapter for ClaudeAdapter { return true; } + // Codex OAuth 总是需要格式转换 (Anthropic → OpenAI Responses API) + if self.is_codex_oauth(provider) { + return true; + } + // 根据 api_format 配置决定是否需要格式转换 // - "anthropic" (默认): 直接透传,无需转换 // - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换 diff --git a/src-tauri/src/proxy/providers/codex_oauth_auth.rs b/src-tauri/src/proxy/providers/codex_oauth_auth.rs new file mode 100644 index 000000000..508e7cd4a --- /dev/null +++ b/src-tauri/src/proxy/providers/codex_oauth_auth.rs @@ -0,0 +1,1132 @@ +//! Codex OAuth Authentication Module +//! +//! 实现 OpenAI ChatGPT Plus/Pro 订阅的 OAuth Device Code 流程。 +//! 支持多账号管理,每个 Provider 可关联不同的 ChatGPT 账号。 +//! +//! ## 认证流程 +//! 1. 启动 Device Code 流程,获取 device_auth_id 和 user_code +//! 2. 用户在浏览器中完成 ChatGPT 授权 +//! 3. 轮询获取 authorization_code 和 code_verifier(注意:verifier 由服务端返回) +//! 4. 使用 code + verifier 换取 access_token + refresh_token + id_token +//! 5. 自动刷新 access_token(到期前 60 秒) +//! +//! ## 多账号支持 +//! - 每个 ChatGPT 账号独立存储 refresh_token +//! - Provider 通过 meta.authBinding 关联账号(auth_provider = "codex_oauth") +//! - 通过 JWT id_token 提取 chatgpt_account_id 作为账号唯一标识 + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; + +use super::copilot_auth::{GitHubAccount, GitHubDeviceCodeResponse}; + +/// OpenAI OAuth 客户端 ID(OpenCode 使用,与官方 Codex CLI 相同) +const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; + +/// Device Code 启动 URL +const DEVICE_AUTH_USERCODE_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/usercode"; + +/// Device Code 轮询 URL +const DEVICE_AUTH_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/token"; + +/// OAuth Token URL(用于 code 换 token 和 refresh token) +const OAUTH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; + +/// Device Code 验证 URL(向用户展示) +const DEVICE_VERIFICATION_URL: &str = "https://auth.openai.com/codex/device"; + +/// Device Code 流程的 redirect_uri(OpenAI 服务端约定) +const DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback"; + +/// Token 刷新提前量(毫秒) +const TOKEN_REFRESH_BUFFER_MS: i64 = 60_000; + +/// Device Code 默认有效时长(秒),OpenAI 文档约定 15 分钟 +const DEVICE_CODE_DEFAULT_EXPIRES_IN: u64 = 900; + +/// 轮询间隔安全余量(秒) +const POLLING_SAFETY_MARGIN_SECS: u64 = 3; + +/// User-Agent +const CODEX_USER_AGENT: &str = "cc-switch-codex-oauth"; + +/// Codex OAuth 错误 +#[derive(Debug, thiserror::Error)] +pub enum CodexOAuthError { + #[error("等待用户授权中")] + AuthorizationPending, + + #[error("用户拒绝授权")] + AccessDenied, + + #[error("Device Code 已过期")] + ExpiredToken, + + #[error("OAuth Token 获取失败: {0}")] + TokenFetchFailed(String), + + #[error("Refresh Token 失效或已过期")] + RefreshTokenInvalid, + + #[error("网络错误: {0}")] + NetworkError(String), + + #[error("解析错误: {0}")] + ParseError(String), + + #[error("IO 错误: {0}")] + IoError(String), + + #[error("账号不存在: {0}")] + AccountNotFound(String), +} + +impl From for CodexOAuthError { + fn from(err: reqwest::Error) -> Self { + CodexOAuthError::NetworkError(err.to_string()) + } +} + +impl From for CodexOAuthError { + fn from(err: std::io::Error) -> Self { + CodexOAuthError::IoError(err.to_string()) + } +} + +/// OpenAI Device Code 响应 +#[derive(Debug, Clone, Deserialize)] +struct DeviceCodeResponse { + device_auth_id: String, + user_code: String, + #[serde(default)] + interval: Option, + #[serde(default)] + expires_in: Option, +} + +/// OpenAI Device Code 轮询响应(成功) +#[derive(Debug, Clone, Deserialize)] +struct DevicePollSuccess { + authorization_code: String, + code_verifier: String, +} + +/// OAuth Token 响应 +#[derive(Debug, Clone, Deserialize)] +struct OAuthTokenResponse { + access_token: String, + refresh_token: Option, + #[serde(default)] + id_token: Option, + #[serde(default)] + expires_in: Option, +} + +/// 解析后的 JWT claims(仅关心 chatgpt_account_id 等字段) +#[derive(Debug, Clone, Default, Deserialize)] +struct IdTokenClaims { + #[serde(default)] + chatgpt_account_id: Option, + #[serde(default)] + email: Option, + #[serde(default)] + organizations: Vec, + #[serde(default, rename = "https://api.openai.com/auth")] + openai_auth: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct OrgClaim { + #[serde(default)] + id: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct OpenAiAuthClaim { + #[serde(default)] + chatgpt_account_id: Option, +} + +/// 缓存的 access_token(含过期时间) +#[derive(Debug, Clone)] +struct CachedAccessToken { + token: String, + /// 过期时间戳(毫秒) + expires_at_ms: i64, +} + +impl CachedAccessToken { + fn is_expiring_soon(&self) -> bool { + let now = chrono::Utc::now().timestamp_millis(); + self.expires_at_ms - now < TOKEN_REFRESH_BUFFER_MS + } +} + +/// 进行中的 Device Code 条目,带过期时间以便清理放弃的登录流程 +#[derive(Debug, Clone)] +struct PendingDeviceCode { + user_code: String, + /// Unix 毫秒时间戳,超时后可清理 + expires_at_ms: i64, +} + +/// 持久化的账号数据 +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CodexAccountData { + /// chatgpt_account_id(同时作为 HashMap 的 key) + pub account_id: String, + /// 账号邮箱(如果可获取) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, + /// Refresh Token(持久化) + pub refresh_token: String, + /// 认证时间戳(秒) + pub authenticated_at: i64, +} + +/// 公开的账号信息(返回给前端,复用 GitHubAccount 结构) +impl From<&CodexAccountData> for GitHubAccount { + fn from(data: &CodexAccountData) -> Self { + GitHubAccount { + id: data.account_id.clone(), + // 用 email 作为显示名(若无则用 account_id) + login: data + .email + .clone() + .unwrap_or_else(|| format!("ChatGPT ({})", &data.account_id)), + avatar_url: None, + authenticated_at: data.authenticated_at, + } + } +} + +/// 持久化存储结构(v1) +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct CodexOAuthStore { + #[serde(default)] + version: u32, + #[serde(default)] + accounts: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + default_account_id: Option, +} + +/// Codex OAuth 认证管理器(多账号) +pub struct CodexOAuthManager { + accounts: Arc>>, + default_account_id: Arc>>, + /// 内存缓存的 access_token(不持久化) + access_tokens: Arc>>, + /// 每个账号的刷新锁 + refresh_locks: Arc>>>>, + /// 进行中的 Device Code 流程:device_auth_id -> {user_code, expires_at_ms} + /// 过期条目会在 start_device_flow 时被清理,防止放弃的登录流程导致无界增长 + pending_device_codes: Arc>>, + http_client: Client, + storage_path: PathBuf, +} + +impl CodexOAuthManager { + pub fn new(data_dir: PathBuf) -> Self { + let storage_path = data_dir.join("codex_oauth_auth.json"); + + let manager = Self { + accounts: Arc::new(RwLock::new(HashMap::new())), + default_account_id: Arc::new(RwLock::new(None)), + access_tokens: Arc::new(RwLock::new(HashMap::new())), + refresh_locks: Arc::new(RwLock::new(HashMap::new())), + pending_device_codes: Arc::new(RwLock::new(HashMap::new())), + http_client: Client::new(), + storage_path, + }; + + if let Err(e) = manager.load_from_disk_sync() { + log::warn!("[CodexOAuth] 加载存储失败: {e}"); + } + + manager + } + + // ==================== 设备码流程 ==================== + + /// 启动 Device Code 流程 + /// + /// 返回 GitHubDeviceCodeResponse 复用现有前端结构,但字段含义对应 OpenAI 的字段: + /// - device_code = device_auth_id + /// - user_code = user_code + /// - verification_uri = https://auth.openai.com/codex/device + pub async fn start_device_flow(&self) -> Result { + log::info!("[CodexOAuth] 启动 Device Code 流程"); + + let response = self + .http_client + .post(DEVICE_AUTH_USERCODE_URL) + .header("Content-Type", "application/json") + .header("User-Agent", CODEX_USER_AGENT) + .json(&serde_json::json!({ "client_id": CODEX_CLIENT_ID })) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(CodexOAuthError::NetworkError(format!( + "Device Code 请求失败: {status} - {text}" + ))); + } + + let device: DeviceCodeResponse = response + .json() + .await + .map_err(|e| CodexOAuthError::ParseError(e.to_string()))?; + + let interval = parse_interval(device.interval.as_ref()); + let expires_in = device.expires_in.unwrap_or(DEVICE_CODE_DEFAULT_EXPIRES_IN); + let expires_at_ms = chrono::Utc::now().timestamp_millis() + (expires_in as i64) * 1000; + + // 记录 device_auth_id -> 用户码映射;同时清理所有已过期的条目, + // 避免用户放弃登录流程导致 HashMap 无界增长 + { + let mut pending = self.pending_device_codes.write().await; + let now_ms = chrono::Utc::now().timestamp_millis(); + pending.retain(|_, entry| entry.expires_at_ms > now_ms); + pending.insert( + device.device_auth_id.clone(), + PendingDeviceCode { + user_code: device.user_code.clone(), + expires_at_ms, + }, + ); + } + + log::info!( + "[CodexOAuth] 获取 Device Code 成功,user_code: {}", + device.user_code + ); + + Ok(GitHubDeviceCodeResponse { + device_code: device.device_auth_id, + user_code: device.user_code, + verification_uri: DEVICE_VERIFICATION_URL.to_string(), + expires_in, + interval, + }) + } + + /// 轮询 Device Code 状态 + /// + /// 接收 device_code(即 device_auth_id),返回 Some(account) 表示授权成功 + pub async fn poll_for_token( + &self, + device_code: &str, + ) -> Result, CodexOAuthError> { + let entry = { + let pending = self.pending_device_codes.read().await; + pending.get(device_code).cloned() + }; + + let entry = entry.ok_or_else(|| { + CodexOAuthError::TokenFetchFailed( + "未找到对应的 user_code,请重新启动登录流程".to_string(), + ) + })?; + + if entry.expires_at_ms <= chrono::Utc::now().timestamp_millis() { + let mut pending = self.pending_device_codes.write().await; + pending.remove(device_code); + return Err(CodexOAuthError::ExpiredToken); + } + + let user_code = entry.user_code; + + log::debug!("[CodexOAuth] 轮询 Device Code"); + + let poll_response = self + .http_client + .post(DEVICE_AUTH_TOKEN_URL) + .header("Content-Type", "application/json") + .header("User-Agent", CODEX_USER_AGENT) + .json(&serde_json::json!({ + "device_auth_id": device_code, + "user_code": user_code, + })) + .send() + .await?; + + let status = poll_response.status(); + + // 403/404 表示用户未完成授权,继续轮询 + if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::NOT_FOUND { + return Err(CodexOAuthError::AuthorizationPending); + } + + if status == reqwest::StatusCode::GONE { + return Err(CodexOAuthError::ExpiredToken); + } + + if !status.is_success() { + let text = poll_response.text().await.unwrap_or_default(); + return Err(CodexOAuthError::TokenFetchFailed(format!( + "{status} - {text}" + ))); + } + + let success: DevicePollSuccess = poll_response + .json() + .await + .map_err(|e| CodexOAuthError::ParseError(e.to_string()))?; + + log::info!("[CodexOAuth] 用户已授权,正在换取 OAuth Token"); + + // 用 authorization_code + code_verifier 换 token + let tokens = self + .exchange_code_for_tokens(&success.authorization_code, &success.code_verifier) + .await?; + + // 清理 pending device code + { + let mut pending = self.pending_device_codes.write().await; + pending.remove(device_code); + } + + let refresh_token = tokens.refresh_token.clone().ok_or_else(|| { + CodexOAuthError::TokenFetchFailed("响应缺少 refresh_token".to_string()) + })?; + + let (account_id, email) = extract_identity_from_tokens(&tokens); + let account_id = account_id.ok_or_else(|| { + CodexOAuthError::ParseError("无法从 token 中提取 account_id".to_string()) + })?; + + // 缓存 access_token + { + let mut tokens_cache = self.access_tokens.write().await; + tokens_cache.insert( + account_id.clone(), + CachedAccessToken { + token: tokens.access_token.clone(), + expires_at_ms: compute_expires_at_ms(tokens.expires_in), + }, + ); + } + + let account = self + .add_account_internal(account_id, refresh_token, email) + .await?; + + Ok(Some(account)) + } + + /// 用 authorization_code + code_verifier 换取 tokens + async fn exchange_code_for_tokens( + &self, + code: &str, + code_verifier: &str, + ) -> Result { + let response = self + .http_client + .post(OAUTH_TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("User-Agent", CODEX_USER_AGENT) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", DEVICE_REDIRECT_URI), + ("client_id", CODEX_CLIENT_ID), + ("code_verifier", code_verifier), + ]) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(CodexOAuthError::TokenFetchFailed(format!( + "Token 交换失败: {status} - {text}" + ))); + } + + response + .json() + .await + .map_err(|e| CodexOAuthError::ParseError(e.to_string())) + } + + /// 用 refresh_token 刷新 access_token + async fn refresh_with_token( + &self, + refresh_token: &str, + ) -> Result { + let response = self + .http_client + .post(OAUTH_TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("User-Agent", CODEX_USER_AGENT) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", CODEX_CLIENT_ID), + ("scope", "openid profile email"), + ]) + .send() + .await?; + + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(CodexOAuthError::RefreshTokenInvalid); + } + + if !status.is_success() { + let text = response.text().await.unwrap_or_default(); + return Err(CodexOAuthError::TokenFetchFailed(format!( + "Refresh 失败: {status} - {text}" + ))); + } + + response + .json() + .await + .map_err(|e| CodexOAuthError::ParseError(e.to_string())) + } + + // ==================== Token 获取(含自动刷新) ==================== + + /// 获取指定账号的有效 access_token(必要时自动刷新) + pub async fn get_valid_token_for_account( + &self, + account_id: &str, + ) -> Result { + // 先检查缓存 + { + let tokens = self.access_tokens.read().await; + if let Some(cached) = tokens.get(account_id) { + if !cached.is_expiring_soon() { + return Ok(cached.token.clone()); + } + } + } + + log::info!("[CodexOAuth] 账号 {account_id} 的 access_token 需要刷新"); + + let refresh_lock = self.get_refresh_lock(account_id).await; + let _guard = refresh_lock.lock().await; + + // double-check + { + let tokens = self.access_tokens.read().await; + if let Some(cached) = tokens.get(account_id) { + if !cached.is_expiring_soon() { + return Ok(cached.token.clone()); + } + } + } + + let refresh_token = { + let accounts = self.accounts.read().await; + accounts + .get(account_id) + .map(|a| a.refresh_token.clone()) + .ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))? + }; + + let new_tokens = self.refresh_with_token(&refresh_token).await?; + + // 如果服务端返回了新的 refresh_token,更新存储 + if let Some(new_refresh) = new_tokens.refresh_token.clone() { + if new_refresh != refresh_token { + let mut accounts = self.accounts.write().await; + if let Some(account) = accounts.get_mut(account_id) { + account.refresh_token = new_refresh; + } + drop(accounts); + self.save_to_disk().await?; + } + } + + let access_token = new_tokens.access_token.clone(); + let expires_at_ms = compute_expires_at_ms(new_tokens.expires_in); + + { + let mut tokens = self.access_tokens.write().await; + tokens.insert( + account_id.to_string(), + CachedAccessToken { + token: access_token.clone(), + expires_at_ms, + }, + ); + } + + Ok(access_token) + } + + /// 获取默认账号的有效 token + pub async fn get_valid_token(&self) -> Result { + match self.resolve_default_account_id().await { + Some(id) => self.get_valid_token_for_account(&id).await, + None => Err(CodexOAuthError::AccountNotFound( + "无可用的 ChatGPT 账号".to_string(), + )), + } + } + + /// 获取默认账号 ID(热路径使用,避免克隆整个账号 HashMap) + pub async fn default_account_id(&self) -> Option { + self.resolve_default_account_id().await + } + + // ==================== 多账号管理 ==================== + + pub async fn list_accounts(&self) -> Vec { + let accounts = self.accounts.read().await.clone(); + let default_id = self.resolve_default_account_id().await; + Self::sorted_accounts(&accounts, default_id.as_deref()) + } + + pub async fn remove_account(&self, account_id: &str) -> Result<(), CodexOAuthError> { + log::info!("[CodexOAuth] 移除账号: {account_id}"); + + { + let mut accounts = self.accounts.write().await; + if accounts.remove(account_id).is_none() { + return Err(CodexOAuthError::AccountNotFound(account_id.to_string())); + } + } + + { + let mut tokens = self.access_tokens.write().await; + tokens.remove(account_id); + } + { + let mut locks = self.refresh_locks.write().await; + locks.remove(account_id); + } + + { + let accounts = self.accounts.read().await; + let mut default = self.default_account_id.write().await; + if default.as_deref() == Some(account_id) { + *default = Self::fallback_default_account_id(&accounts); + } + } + + self.save_to_disk().await?; + Ok(()) + } + + pub async fn set_default_account(&self, account_id: &str) -> Result<(), CodexOAuthError> { + { + let accounts = self.accounts.read().await; + if !accounts.contains_key(account_id) { + return Err(CodexOAuthError::AccountNotFound(account_id.to_string())); + } + } + + { + let mut default = self.default_account_id.write().await; + *default = Some(account_id.to_string()); + } + + self.save_to_disk().await?; + Ok(()) + } + + pub async fn clear_auth(&self) -> Result<(), CodexOAuthError> { + log::info!("[CodexOAuth] 清除所有认证"); + + { + let mut accounts = self.accounts.write().await; + accounts.clear(); + } + { + let mut default = self.default_account_id.write().await; + *default = None; + } + { + let mut tokens = self.access_tokens.write().await; + tokens.clear(); + } + { + let mut locks = self.refresh_locks.write().await; + locks.clear(); + } + { + let mut pending = self.pending_device_codes.write().await; + pending.clear(); + } + + if self.storage_path.exists() { + std::fs::remove_file(&self.storage_path)?; + } + + Ok(()) + } + + pub async fn is_authenticated(&self) -> bool { + let accounts = self.accounts.read().await; + !accounts.is_empty() + } + + /// 获取认证状态摘要(与 Copilot 的格式保持一致,便于复用前端) + pub async fn get_status(&self) -> CodexOAuthStatus { + let accounts_map = self.accounts.read().await.clone(); + let default_id = self.resolve_default_account_id().await; + let account_list = Self::sorted_accounts(&accounts_map, default_id.as_deref()); + let authenticated = !account_list.is_empty(); + let username = default_id + .as_ref() + .and_then(|id| accounts_map.get(id)) + .and_then(|a| a.email.clone()) + .or_else(|| account_list.first().map(|a| a.login.clone())); + + CodexOAuthStatus { + accounts: account_list, + default_account_id: default_id, + authenticated, + username, + } + } + + // ==================== 内部方法 ==================== + + async fn add_account_internal( + &self, + account_id: String, + refresh_token: String, + email: Option, + ) -> Result { + let now = chrono::Utc::now().timestamp(); + + let data = CodexAccountData { + account_id: account_id.clone(), + email, + refresh_token, + authenticated_at: now, + }; + + let account = GitHubAccount::from(&data); + + { + let mut accounts = self.accounts.write().await; + accounts.insert(account_id.clone(), data); + } + + { + let mut default = self.default_account_id.write().await; + if default.is_none() { + *default = Some(account_id); + } + } + + self.save_to_disk().await?; + Ok(account) + } + + fn fallback_default_account_id(accounts: &HashMap) -> Option { + accounts + .iter() + .max_by(|(id_a, a), (id_b, b)| { + a.authenticated_at + .cmp(&b.authenticated_at) + .then_with(|| id_b.cmp(id_a)) + }) + .map(|(id, _)| id.clone()) + } + + fn sorted_accounts( + accounts: &HashMap, + default_account_id: Option<&str>, + ) -> Vec { + let mut list: Vec = accounts.values().map(GitHubAccount::from).collect(); + list.sort_by(|a, b| { + let a_default = default_account_id == Some(a.id.as_str()); + let b_default = default_account_id == Some(b.id.as_str()); + b_default + .cmp(&a_default) + .then_with(|| b.authenticated_at.cmp(&a.authenticated_at)) + .then_with(|| a.login.cmp(&b.login)) + }); + list + } + + async fn resolve_default_account_id(&self) -> Option { + let stored = self.default_account_id.read().await.clone(); + let accounts = self.accounts.read().await; + + if let Some(id) = stored { + if accounts.contains_key(&id) { + return Some(id); + } + } + + Self::fallback_default_account_id(&accounts) + } + + async fn get_refresh_lock(&self, account_id: &str) -> Arc> { + { + let locks = self.refresh_locks.read().await; + if let Some(lock) = locks.get(account_id) { + return Arc::clone(lock); + } + } + + let mut locks = self.refresh_locks.write().await; + Arc::clone( + locks + .entry(account_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))), + ) + } + + fn write_store_atomic(&self, content: &str) -> Result<(), CodexOAuthError> { + if let Some(parent) = self.storage_path.parent() { + fs::create_dir_all(parent)?; + } + + let parent = self + .storage_path + .parent() + .ok_or_else(|| CodexOAuthError::IoError("无效的存储路径".to_string()))?; + let file_name = self + .storage_path + .file_name() + .ok_or_else(|| CodexOAuthError::IoError("无效的存储文件名".to_string()))? + .to_string_lossy() + .to_string(); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let tmp_path = parent.join(format!("{file_name}.tmp.{ts}")); + + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&tmp_path)?; + file.write_all(content.as_bytes())?; + file.flush()?; + + fs::rename(&tmp_path, &self.storage_path)?; + fs::set_permissions(&self.storage_path, fs::Permissions::from_mode(0o600))?; + } + + #[cfg(windows)] + { + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&tmp_path)?; + file.write_all(content.as_bytes())?; + file.flush()?; + + if self.storage_path.exists() { + let _ = fs::remove_file(&self.storage_path); + } + fs::rename(&tmp_path, &self.storage_path)?; + } + + Ok(()) + } + + fn load_from_disk_sync(&self) -> Result<(), CodexOAuthError> { + if !self.storage_path.exists() { + return Ok(()); + } + + let content = std::fs::read_to_string(&self.storage_path)?; + let store: CodexOAuthStore = serde_json::from_str(&content) + .map_err(|e| CodexOAuthError::ParseError(e.to_string()))?; + + if let Ok(mut accounts) = self.accounts.try_write() { + *accounts = store.accounts; + log::info!("[CodexOAuth] 从磁盘加载 {} 个账号", accounts.len()); + } + if let Ok(mut default) = self.default_account_id.try_write() { + *default = store.default_account_id; + if default.is_none() { + if let Ok(accounts) = self.accounts.try_read() { + *default = Self::fallback_default_account_id(&accounts); + } + } + } + + Ok(()) + } + + async fn save_to_disk(&self) -> Result<(), CodexOAuthError> { + let accounts = self.accounts.read().await.clone(); + let default = self.resolve_default_account_id().await; + + let store = CodexOAuthStore { + version: 1, + accounts, + default_account_id: default, + }; + + let content = serde_json::to_string_pretty(&store) + .map_err(|e| CodexOAuthError::ParseError(e.to_string()))?; + + self.write_store_atomic(&content)?; + + log::info!( + "[CodexOAuth] 保存到磁盘成功({} 个账号)", + store.accounts.len() + ); + + Ok(()) + } +} + +/// Codex OAuth 状态摘要 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexOAuthStatus { + pub accounts: Vec, + pub default_account_id: Option, + pub authenticated: bool, + pub username: Option, +} + +// ==================== 工具函数 ==================== + +/// 解析 OpenAI Device Code 响应中的 interval 字段 +/// +/// 服务端可能返回字符串或数字,需要兼容 +fn parse_interval(value: Option<&serde_json::Value>) -> u64 { + let raw = match value { + Some(serde_json::Value::Number(n)) => n.as_u64().unwrap_or(5), + Some(serde_json::Value::String(s)) => s.parse::().unwrap_or(5), + _ => 5, + }; + raw.max(1) + POLLING_SAFETY_MARGIN_SECS +} + +/// 从 expires_in(秒)计算过期时间戳(毫秒) +fn compute_expires_at_ms(expires_in: Option) -> i64 { + let now_ms = chrono::Utc::now().timestamp_millis(); + let secs = expires_in.unwrap_or(3600); + now_ms + secs * 1000 +} + +/// 解析 JWT 中的 claims +fn parse_jwt_claims(token: &str) -> Option { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return None; + } + let decoded = URL_SAFE_NO_PAD.decode(parts[1]).ok()?; + serde_json::from_slice(&decoded).ok() +} + +/// 从 token 响应中提取 (account_id, email) +fn extract_identity_from_tokens(tokens: &OAuthTokenResponse) -> (Option, Option) { + let mut account_id: Option = None; + let mut email: Option = None; + + if let Some(id_token) = tokens.id_token.as_deref() { + if let Some(claims) = parse_jwt_claims(id_token) { + account_id = claims + .chatgpt_account_id + .clone() + .or_else(|| { + claims + .openai_auth + .as_ref() + .and_then(|a| a.chatgpt_account_id.clone()) + }) + .or_else(|| claims.organizations.first().and_then(|o| o.id.clone())); + email = claims.email.clone(); + } + } + + if account_id.is_none() { + if let Some(claims) = parse_jwt_claims(&tokens.access_token) { + account_id = claims + .chatgpt_account_id + .clone() + .or_else(|| { + claims + .openai_auth + .as_ref() + .and_then(|a| a.chatgpt_account_id.clone()) + }) + .or_else(|| claims.organizations.first().and_then(|o| o.id.clone())); + if email.is_none() { + email = claims.email.clone(); + } + } + } + + (account_id, email) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_interval_number() { + let v = serde_json::Value::Number(serde_json::Number::from(5)); + assert_eq!(parse_interval(Some(&v)), 5 + POLLING_SAFETY_MARGIN_SECS); + } + + #[test] + fn test_parse_interval_string() { + let v = serde_json::Value::String("10".to_string()); + assert_eq!(parse_interval(Some(&v)), 10 + POLLING_SAFETY_MARGIN_SECS); + } + + #[test] + fn test_parse_interval_default() { + assert_eq!(parse_interval(None), 5 + POLLING_SAFETY_MARGIN_SECS); + } + + #[test] + fn test_parse_interval_min() { + let v = serde_json::Value::Number(serde_json::Number::from(0)); + // 0 应被提升到 1 + assert_eq!(parse_interval(Some(&v)), 1 + POLLING_SAFETY_MARGIN_SECS); + } + + #[test] + fn test_compute_expires_at_ms() { + let result = compute_expires_at_ms(Some(3600)); + let now = chrono::Utc::now().timestamp_millis(); + // 应在未来约 3600 秒处(允许少量误差) + assert!(result > now + 3500 * 1000); + assert!(result < now + 3700 * 1000); + } + + #[test] + fn test_compute_expires_at_ms_default() { + let result = compute_expires_at_ms(None); + let now = chrono::Utc::now().timestamp_millis(); + assert!(result > now); + } + + #[test] + fn test_cached_token_expiring_soon() { + let now = chrono::Utc::now().timestamp_millis(); + // 30 秒后过期 - 在缓冲期内 + let expiring = CachedAccessToken { + token: "t".to_string(), + expires_at_ms: now + 30_000, + }; + assert!(expiring.is_expiring_soon()); + + // 1 小时后过期 - 不在缓冲期内 + let valid = CachedAccessToken { + token: "t".to_string(), + expires_at_ms: now + 3_600_000, + }; + assert!(!valid.is_expiring_soon()); + } + + #[test] + fn test_parse_jwt_claims_invalid() { + assert!(parse_jwt_claims("not-a-jwt").is_none()); + assert!(parse_jwt_claims("only.two").is_none()); + } + + #[test] + fn test_parse_jwt_claims_valid() { + // Header: {"alg":"none"} + // Payload: {"chatgpt_account_id":"acc-123","email":"test@example.com"} + // Signature: empty + let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}"); + let payload = URL_SAFE_NO_PAD + .encode(b"{\"chatgpt_account_id\":\"acc-123\",\"email\":\"test@example.com\"}"); + let jwt = format!("{header}.{payload}."); + let claims = parse_jwt_claims(&jwt).unwrap(); + assert_eq!(claims.chatgpt_account_id.as_deref(), Some("acc-123")); + assert_eq!(claims.email.as_deref(), Some("test@example.com")); + } + + #[test] + fn test_parse_jwt_claims_organizations_fallback() { + let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}"); + let payload = URL_SAFE_NO_PAD.encode(b"{\"organizations\":[{\"id\":\"org-456\"}]}"); + let jwt = format!("{header}.{payload}."); + let claims = parse_jwt_claims(&jwt).unwrap(); + assert_eq!( + claims + .organizations + .first() + .and_then(|o| o.id.clone()) + .as_deref(), + Some("org-456") + ); + } + + #[tokio::test] + async fn test_manager_initial_state() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + assert!(!manager.is_authenticated().await); + assert!(manager.list_accounts().await.is_empty()); + } + + #[tokio::test] + async fn test_manager_save_and_load() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().to_path_buf(); + + // Manually inject an account through internal methods + { + let manager = CodexOAuthManager::new(path.clone()); + manager + .add_account_internal( + "acc-123".to_string(), + "rt-secret".to_string(), + Some("user@example.com".to_string()), + ) + .await + .unwrap(); + } + + // New manager should load from disk + let manager2 = CodexOAuthManager::new(path); + let accounts = manager2.list_accounts().await; + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].id, "acc-123"); + } + + #[tokio::test] + async fn test_remove_account() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + + manager + .add_account_internal( + "acc-123".to_string(), + "rt".to_string(), + Some("a@example.com".to_string()), + ) + .await + .unwrap(); + manager + .add_account_internal( + "acc-456".to_string(), + "rt2".to_string(), + Some("b@example.com".to_string()), + ) + .await + .unwrap(); + + manager.remove_account("acc-123").await.unwrap(); + let accounts = manager.list_accounts().await; + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].id, "acc-456"); + } +} diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 646bec414..b6b8bb643 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -15,6 +15,7 @@ mod adapter; mod auth; mod claude; mod codex; +pub mod codex_oauth_auth; pub mod copilot_auth; mod gemini; pub mod models; @@ -58,6 +59,8 @@ pub enum ProviderType { OpenRouter, /// GitHub Copilot (OAuth + Copilot Token,需要 Anthropic ↔ OpenAI 转换) GitHubCopilot, + /// OpenAI Codex (ChatGPT Plus/Pro OAuth,需要 Anthropic ↔ Responses API 转换) + CodexOAuth, } impl ProviderType { @@ -70,6 +73,7 @@ impl ProviderType { pub fn needs_transform(&self) -> bool { match self { ProviderType::GitHubCopilot => true, + ProviderType::CodexOAuth => true, ProviderType::OpenRouter => false, _ => false, } @@ -86,6 +90,7 @@ impl ProviderType { } ProviderType::OpenRouter => "https://openrouter.ai/api", ProviderType::GitHubCopilot => "https://api.githubcopilot.com", + ProviderType::CodexOAuth => "https://chatgpt.com/backend-api/codex", } } @@ -101,6 +106,9 @@ impl ProviderType { if meta.provider_type.as_deref() == Some("github_copilot") { return ProviderType::GitHubCopilot; } + if meta.provider_type.as_deref() == Some("codex_oauth") { + return ProviderType::CodexOAuth; + } } // 检测 base_url 是否为 GitHub Copilot @@ -175,6 +183,7 @@ impl ProviderType { ProviderType::GeminiCli => "gemini_cli", ProviderType::OpenRouter => "openrouter", ProviderType::GitHubCopilot => "github_copilot", + ProviderType::CodexOAuth => "codex_oauth", } } } @@ -199,6 +208,7 @@ impl std::str::FromStr for ProviderType { "github_copilot" | "github-copilot" | "githubcopilot" => { Ok(ProviderType::GitHubCopilot) } + "codex_oauth" | "codex-oauth" | "codexoauth" => Ok(ProviderType::CodexOAuth), _ => Err(format!("Invalid provider type: {s}")), } } @@ -228,7 +238,8 @@ pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box Box::new(ClaudeAdapter::new()), + | ProviderType::GitHubCopilot + | ProviderType::CodexOAuth => Box::new(ClaudeAdapter::new()), ProviderType::Codex => Box::new(CodexAdapter::new()), ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()), } diff --git a/src-tauri/src/proxy/providers/transform_responses.rs b/src-tauri/src/proxy/providers/transform_responses.rs index 1015c32d4..6af18eac1 100644 --- a/src-tauri/src/proxy/providers/transform_responses.rs +++ b/src-tauri/src/proxy/providers/transform_responses.rs @@ -14,7 +14,14 @@ use serde_json::{json, Value}; /// Anthropic 请求 → OpenAI Responses 请求 /// /// `cache_key`: optional prompt_cache_key to inject for improved cache routing -pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result { +/// `is_codex_oauth`: 当目标后端是 ChatGPT Plus/Pro 反代 (`chatgpt.com/backend-api/codex`) 时为 true。 +/// 该后端强制要求 `store: false`,并要求 `include` 包含 `reasoning.encrypted_content` +/// 以便在无服务端状态下保持多轮 reasoning 上下文。 +pub fn anthropic_to_responses( + body: Value, + cache_key: Option<&str>, + is_codex_oauth: bool, +) -> Result { let mut result = json!({}); // NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。 @@ -103,6 +110,59 @@ pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result = body + .get("include") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + if !includes + .iter() + .any(|v| v.as_str() == Some(REASONING_MARKER)) + { + includes.push(json!(REASONING_MARKER)); + } + result["include"] = json!(includes); + + if let Some(obj) = result.as_object_mut() { + // —— 删除 ChatGPT 反代不接受的字段 —— + obj.remove("max_output_tokens"); + obj.remove("temperature"); + obj.remove("top_p"); + + // —— 兜底必填字段(or_insert:客户端送了什么就保留,否则注入默认值)—— + obj.entry("instructions".to_string()).or_insert(json!("")); + obj.entry("tools".to_string()).or_insert(json!([])); + obj.entry("parallel_tool_calls".to_string()) + .or_insert(json!(false)); + + // —— 强制覆盖 stream = true —— + // 即便客户端误传 stream:false 也要覆盖,因为 codex-rs 永远 true, + // 且 cc-switch SSE 解析层只支持流式响应。 + obj.insert("stream".to_string(), json!(true)); + } + } + Ok(result) } @@ -468,7 +528,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert_eq!(result["model"], "gpt-4o"); assert_eq!(result["max_output_tokens"], 1024); assert_eq!(result["input"][0]["role"], "user"); @@ -487,7 +547,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert_eq!(result["instructions"], "You are a helpful assistant."); // system should not appear in input assert_eq!(result["input"].as_array().unwrap().len(), 1); @@ -505,7 +565,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert_eq!(result["instructions"], "Part 1\n\nPart 2"); } @@ -522,7 +582,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert_eq!(result["tools"][0]["type"], "function"); assert_eq!(result["tools"][0]["name"], "get_weather"); assert!(result["tools"][0].get("parameters").is_some()); @@ -539,7 +599,7 @@ mod tests { "tool_choice": {"type": "any"} }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert_eq!(result["tool_choice"], "required"); } @@ -552,7 +612,7 @@ mod tests { "tool_choice": {"type": "tool", "name": "get_weather"} }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert_eq!(result["tool_choice"]["type"], "function"); assert_eq!(result["tool_choice"]["name"], "get_weather"); } @@ -571,7 +631,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); let input_arr = result["input"].as_array().unwrap(); // Should produce: assistant message (text) + function_call item @@ -601,7 +661,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); let input_arr = result["input"].as_array().unwrap(); // Should produce: function_call_output item (lifted) @@ -625,7 +685,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); let input_arr = result["input"].as_array().unwrap(); // thinking should be discarded, only text remains @@ -648,7 +708,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); let content = result["input"][0]["content"].as_array().unwrap(); assert_eq!(content[0]["type"], "input_text"); @@ -806,7 +866,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert_eq!(result["model"], "o3-mini"); } @@ -818,7 +878,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, Some("my-provider-id")).unwrap(); + let result = anthropic_to_responses(input, Some("my-provider-id"), false).unwrap(); assert_eq!(result["prompt_cache_key"], "my-provider-id"); } @@ -836,7 +896,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert!(result["tools"][0].get("cache_control").is_none()); } @@ -853,7 +913,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert!(result["input"][0]["content"][0] .get("cache_control") .is_none()); @@ -915,7 +975,7 @@ mod tests { "max_tokens": 4096, "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert_eq!(result["max_output_tokens"], 4096); assert!(result.get("max_completion_tokens").is_none()); } @@ -929,7 +989,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert_eq!(result["reasoning"]["effort"], "xhigh"); } @@ -943,7 +1003,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert_eq!(result["reasoning"]["effort"], "low"); } @@ -956,7 +1016,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert_eq!(result["reasoning"]["effort"], "low"); } @@ -969,7 +1029,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert_eq!(result["reasoning"]["effort"], "medium"); } @@ -982,7 +1042,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert_eq!(result["reasoning"]["effort"], "high"); } @@ -995,7 +1055,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert_eq!(result["reasoning"]["effort"], "high"); } @@ -1008,7 +1068,271 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None).unwrap(); + let result = anthropic_to_responses(input, None, false).unwrap(); assert!(result.get("reasoning").is_none()); } + + // ==================== Codex OAuth (ChatGPT 反代) 协议约束 ==================== + + #[test] + fn test_anthropic_to_responses_codex_oauth_sets_store_and_include() { + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, true).unwrap(); + + // store 必须显式为 false(ChatGPT 后端拒绝 true) + assert_eq!(result["store"], json!(false)); + + // include 必须包含 reasoning.encrypted_content(无服务端状态下保持多轮 reasoning) + assert_eq!(result["include"], json!(["reasoning.encrypted_content"])); + } + + #[test] + fn test_anthropic_to_responses_non_codex_omits_store_and_include() { + // 回归护栏:is_codex_oauth=false 时,行为必须与今日字节级一致 + // —— 不写 store、不写 include,OpenRouter / Azure / OpenAI 付费 API 路径不受影响 + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, false).unwrap(); + + assert!(result.get("store").is_none()); + assert!(result.get("include").is_none()); + } + + #[test] + fn test_anthropic_to_responses_codex_oauth_preserves_existing_include() { + // 客户端预置了 include:union 保留原有项 + 添加 marker,不重复 + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}], + "include": ["something.else", "reasoning.encrypted_content"] + }); + + let result = anthropic_to_responses(input, None, true).unwrap(); + let includes = result["include"] + .as_array() + .expect("include should be array"); + + // 原有项必须保留 + assert!(includes + .iter() + .any(|v| v.as_str() == Some("something.else"))); + // marker 必须存在 + assert!(includes + .iter() + .any(|v| v.as_str() == Some("reasoning.encrypted_content"))); + // 不重复:marker 只出现一次 + let marker_count = includes + .iter() + .filter(|v| v.as_str() == Some("reasoning.encrypted_content")) + .count(); + assert_eq!(marker_count, 1, "marker 不应被重复添加(idempotent 失败)"); + } + + #[test] + fn test_anthropic_to_responses_codex_oauth_strips_max_output_tokens() { + // ChatGPT Plus/Pro 反代不接受 max_output_tokens(OpenAI 官方 codex-rs 的 + // ResponsesApiRequest 结构体里也没有这个字段),必须删除,否则服务端 400: + // "Unsupported parameter: max_output_tokens" + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, true).unwrap(); + + assert!( + result.get("max_output_tokens").is_none(), + "Codex OAuth 路径必须删除 max_output_tokens" + ); + } + + #[test] + fn test_anthropic_to_responses_non_codex_keeps_max_output_tokens() { + // 回归护栏:非 Codex OAuth 路径必须保留 max_output_tokens + // —— OpenAI 付费 Responses API / Azure 等仍然依赖这个字段 + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, false).unwrap(); + + assert_eq!(result["max_output_tokens"], json!(1024)); + } + + // ==================== 第二轮:P0 + P1 字段对齐 ==================== + + #[test] + fn test_codex_oauth_strips_temperature() { + // P0: ChatGPT 反代不接受 temperature + // 依据:OpenAI 官方 codex-rs 的 ResponsesApiRequest 结构体根本没有这个字段 + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "temperature": 0.7, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, true).unwrap(); + + assert!( + result.get("temperature").is_none(), + "Codex OAuth 路径必须删除 temperature" + ); + } + + #[test] + fn test_codex_oauth_strips_top_p() { + // P0: ChatGPT 反代不接受 top_p + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "top_p": 0.9, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, true).unwrap(); + + assert!( + result.get("top_p").is_none(), + "Codex OAuth 路径必须删除 top_p" + ); + } + + #[test] + fn test_codex_oauth_defaults_required_fields_when_absent() { + // P1: 极简输入(无 system / 无 tools / 无 stream),断言四个必填字段都被注入默认值 + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, true).unwrap(); + + assert_eq!( + result["instructions"], + json!(""), + "instructions 缺失时应兜底为空字符串" + ); + assert_eq!(result["tools"], json!([]), "tools 缺失时应兜底为空数组"); + assert_eq!( + result["parallel_tool_calls"], + json!(false), + "parallel_tool_calls 应兜底为 false" + ); + assert_eq!(result["stream"], json!(true), "stream 应被强制设为 true"); + } + + #[test] + fn test_codex_oauth_preserves_existing_instructions_and_tools() { + // P1: 客户端送了 system 和 tools,应保留原值,不被默认值覆盖 + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "system": "You are a helpful assistant", + "tools": [{ + "name": "get_weather", + "description": "Get weather", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}} + } + }], + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, true).unwrap(); + + assert_eq!( + result["instructions"], + json!("You are a helpful assistant"), + "client 已送的 instructions 必须保留" + ); + + let tools = result["tools"].as_array().expect("tools 应为数组"); + assert_eq!(tools.len(), 1, "client 已送的 tools 必须保留"); + assert_eq!(tools[0]["name"], json!("get_weather")); + } + + #[test] + fn test_codex_oauth_forces_stream_true_even_when_client_sends_false() { + // 即使客户端误传 stream:false,也要强制覆盖为 true + // 依据:cc-switch SSE 解析层只支持流式响应 + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "stream": false, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, true).unwrap(); + + assert_eq!( + result["stream"], + json!(true), + "Codex OAuth 路径下 stream 必须强制为 true" + ); + } + + #[test] + fn test_non_codex_keeps_temperature_and_top_p() { + // 回归护栏:非 Codex OAuth 路径必须保留 temperature/top_p + // —— 防止 P0 删除逻辑误扩散到 OpenRouter / Azure / 付费 OpenAI 路径 + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "temperature": 0.7, + "top_p": 0.9, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, false).unwrap(); + + assert_eq!(result["temperature"], json!(0.7)); + assert_eq!(result["top_p"], json!(0.9)); + } + + #[test] + fn test_non_codex_does_not_inject_default_required_fields() { + // 回归护栏:非 Codex OAuth 路径不应被 P1 默认值污染 + // —— OpenRouter / Azure / 付费 OpenAI 等保持原有"条件写入"语义 + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, false).unwrap(); + + assert!( + result.get("parallel_tool_calls").is_none(), + "非 Codex OAuth 路径不应注入 parallel_tool_calls" + ); + assert!( + result.get("stream").is_none(), + "非 Codex OAuth 路径不应注入 stream" + ); + // instructions 和 tools 因为客户端没送,所以不应出现 + assert!( + result.get("instructions").is_none(), + "非 Codex OAuth 路径下 instructions 在客户端未送时不应被注入" + ); + assert!( + result.get("tools").is_none(), + "非 Codex OAuth 路径下 tools 在客户端未送时不应被注入" + ); + } } diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 79f1021ed..e24e09cfa 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -357,8 +357,16 @@ impl StreamCheckService { "messages": [{ "role": "user", "content": test_prompt }], "stream": true }); + // Codex OAuth (ChatGPT Plus/Pro 反代) 需要 store:false + include 标记, + // 否则 Stream Check 会和生产路径一样被服务端 400 拒绝。 + let is_codex_oauth = provider + .meta + .as_ref() + .and_then(|m| m.provider_type.as_deref()) + == Some("codex_oauth"); + let body = if is_openai_responses { - anthropic_to_responses(anthropic_body, Some(&provider.id)) + anthropic_to_responses(anthropic_body, Some(&provider.id), is_codex_oauth) .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? } else if is_openai_chat { anthropic_to_openai(anthropic_body, Some(&provider.id)) diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 033a0874c..0c9482a53 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -28,6 +28,7 @@ import { ChevronDown, ChevronRight, Download, Loader2 } from "lucide-react"; import EndpointSpeedTest from "./EndpointSpeedTest"; import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared"; import { CopilotAuthSection } from "./CopilotAuthSection"; +import { CodexOAuthSection } from "./CodexOAuthSection"; import { copilotGetModels, copilotGetModelsForAccount, @@ -70,6 +71,12 @@ interface ClaudeFormFieldsProps { /** GitHub 账号选择回调(多账号支持) */ onGitHubAccountSelect?: (accountId: string | null) => void; + // Codex OAuth (ChatGPT Plus/Pro) + isCodexOauthPreset?: boolean; + isCodexOauthAuthenticated?: boolean; + selectedCodexAccountId?: string | null; + onCodexAccountSelect?: (accountId: string | null) => void; + // Template Values templateValueEntries: Array<[string, TemplateValueConfig]>; templateValues: Record; @@ -134,6 +141,9 @@ export function ClaudeFormFields({ isCopilotAuthenticated, selectedGitHubAccountId, onGitHubAccountSelect, + isCodexOauthPreset, + selectedCodexAccountId, + onCodexAccountSelect, templateValueEntries, templateValues, templatePresetName, @@ -357,6 +367,14 @@ export function ClaudeFormFields({ /> )} + {/* Codex OAuth 认证 (ChatGPT Plus/Pro) */} + {isCodexOauthPreset && ( + + )} + {/* API Key 输入框(非 OAuth 预设时显示) */} {shouldShowApiKey && !usesOAuth && ( void; +} + +/** + * Codex OAuth 认证区块 + * + * 通过 OpenAI Device Code 流程登录 ChatGPT Plus/Pro 账号, + * 用于将 Claude Code 请求反代到 Codex 后端 API。 + */ +export const CodexOAuthSection: React.FC = ({ + className, + selectedAccountId, + onAccountSelect, +}) => { + const { t } = useTranslation(); + const [copied, setCopied] = React.useState(false); + + const { + accounts, + defaultAccountId, + hasAnyAccount, + pollingState, + deviceCode, + error, + isPolling, + isAddingAccount, + isRemovingAccount, + isSettingDefaultAccount, + addAccount, + removeAccount, + setDefaultAccount, + cancelAuth, + logout, + } = useCodexOauth(); + + const copyUserCode = async () => { + if (deviceCode?.user_code) { + await copyText(deviceCode.user_code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleAccountSelect = (value: string) => { + onAccountSelect?.(value === "none" ? null : value); + }; + + const handleRemoveAccount = (accountId: string, e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + removeAccount(accountId); + if (selectedAccountId === accountId) { + onAccountSelect?.(null); + } + }; + + return ( +
+ {/* 认证状态标题 */} +
+ + + {hasAnyAccount + ? t("codexOauth.accountCount", { + count: accounts.length, + defaultValue: `${accounts.length} 个账号`, + }) + : t("codexOauth.notAuthenticated", "未认证")} + +
+ + {/* 账号选择器 */} + {hasAnyAccount && onAccountSelect && ( +
+ + +
+ )} + + {/* 已登录账号列表 */} + {hasAnyAccount && ( +
+ +
+ {accounts.map((account) => ( +
+
+ + {account.login} + {defaultAccountId === account.id && ( + + {t("codexOauth.defaultAccount", "默认")} + + )} + {selectedAccountId === account.id && ( + + {t("codexOauth.selected", "已选中")} + + )} +
+
+ {defaultAccountId !== account.id && ( + + )} + +
+
+ ))} +
+
+ )} + + {/* 未认证 - 登录按钮 */} + {!hasAnyAccount && pollingState === "idle" && ( + + )} + + {/* 已有账号 - 添加更多按钮 */} + {hasAnyAccount && pollingState === "idle" && ( + + )} + + {/* 轮询中状态 */} + {isPolling && deviceCode && ( +
+
+ + {t("codexOauth.waitingForAuth", "等待授权中...")} +
+ +
+

+ {t("codexOauth.enterCode", "在浏览器中输入以下代码:")} +

+
+ + {deviceCode.user_code} + + +
+
+ + + +
+ +
+
+ )} + + {/* 错误状态 */} + {pollingState === "error" && error && ( +
+

{error}

+
+ + +
+
+ )} + + {/* 注销所有账号 */} + {hasAnyAccount && accounts.length > 1 && ( + + )} +
+ ); +}; + +export default CodexOAuthSection; diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index eac36a651..f5f5f6dbb 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -82,6 +82,7 @@ import { useOmoDraftState, useOpenclawFormState, useCopilotAuth, + useCodexOauth, } from "./hooks"; import { CLAUDE_DEFAULT_CONFIG, @@ -346,11 +347,19 @@ export function ProviderForm({ // Copilot OAuth 认证状态(仅 Claude 应用需要) const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth(); + // Codex OAuth 认证状态(ChatGPT Plus/Pro 反代) + const { isAuthenticated: isCodexOauthAuthenticated } = useCodexOauth(); + // 选中的 GitHub 账号 ID(多账号支持) const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState< string | null >(() => resolveManagedAccountId(initialData?.meta, "github_copilot")); + // 选中的 ChatGPT 账号 ID(Codex OAuth 多账号支持) + const [selectedCodexAccountId, setSelectedCodexAccountId] = useState< + string | null + >(() => resolveManagedAccountId(initialData?.meta, "codex_oauth")); + const { codexAuth, codexConfig, @@ -782,6 +791,9 @@ export function ProviderForm({ templatePreset?.providerType === "github_copilot" || initialData?.meta?.providerType === "github_copilot" || baseUrl.includes("githubcopilot.com"); + const isCodexOauthProvider = + templatePreset?.providerType === "codex_oauth" || + initialData?.meta?.providerType === "codex_oauth"; // GitHub Copilot 必须先登录才能添加 if (isCopilotProvider && !isCopilotAuthenticated) { toast.error( @@ -791,10 +803,19 @@ export function ProviderForm({ ); return; } + // Codex OAuth 必须先登录才能添加 + if (isCodexOauthProvider && !isCodexOauthAuthenticated) { + toast.error( + t("codexOauth.loginRequired", { + defaultValue: "请先登录 ChatGPT 账号", + }), + ); + return; + } if (category !== "official" && category !== "cloud_provider") { if (appId === "claude") { - if (!baseUrl.trim()) { + if (!isCodexOauthProvider && !baseUrl.trim()) { toast.error( t("providerForm.endpointRequired", { defaultValue: "非官方供应商请填写 API 端点", @@ -802,7 +823,7 @@ export function ProviderForm({ ); return; } - if (!isCopilotProvider && !apiKey.trim()) { + if (!isCopilotProvider && !isCodexOauthProvider && !apiKey.trim()) { toast.error( t("providerForm.apiKeyRequired", { defaultValue: "非官方供应商请填写 API Key", @@ -1015,7 +1036,7 @@ export function ProviderForm({ ? useGeminiCommonConfigFlag : undefined, endpointAutoSelect, - // 保存 providerType(用于识别 Copilot 等特殊供应商) + // 保存 providerType(用于识别 Copilot / Codex OAuth 等特殊供应商) providerType, authBinding: isCopilotProvider ? { @@ -1023,7 +1044,13 @@ export function ProviderForm({ authProvider: "github_copilot", accountId: selectedGitHubAccountId ?? undefined, } - : undefined, + : isCodexOauthProvider + ? { + source: "managed_account", + authProvider: "codex_oauth", + accountId: selectedCodexAccountId ?? undefined, + } + : undefined, // GitHub Copilot 多账号:保存关联的账号 ID githubAccountId: isCopilotProvider && selectedGitHubAccountId @@ -1493,15 +1520,24 @@ export function ProviderForm({ initialData?.meta?.providerType === "github_copilot" || baseUrl.includes("githubcopilot.com") } + isCodexOauthPreset={ + templatePreset?.providerType === "codex_oauth" || + initialData?.meta?.providerType === "codex_oauth" + } usesOAuth={ templatePreset?.requiresOAuth === true || templatePreset?.providerType === "github_copilot" || initialData?.meta?.providerType === "github_copilot" || - baseUrl.includes("githubcopilot.com") + baseUrl.includes("githubcopilot.com") || + templatePreset?.providerType === "codex_oauth" || + initialData?.meta?.providerType === "codex_oauth" } isCopilotAuthenticated={isCopilotAuthenticated} selectedGitHubAccountId={selectedGitHubAccountId} onGitHubAccountSelect={setSelectedGitHubAccountId} + isCodexOauthAuthenticated={isCodexOauthAuthenticated} + selectedCodexAccountId={selectedCodexAccountId} + onCodexAccountSelect={setSelectedCodexAccountId} templateValueEntries={templateValueEntries} templateValues={templateValues} templatePresetName={templatePreset?.name || ""} diff --git a/src/components/providers/forms/hooks/index.ts b/src/components/providers/forms/hooks/index.ts index f39983aeb..ddee534c7 100644 --- a/src/components/providers/forms/hooks/index.ts +++ b/src/components/providers/forms/hooks/index.ts @@ -18,3 +18,4 @@ export { useOpencodeFormState } from "./useOpencodeFormState"; export { useOmoDraftState } from "./useOmoDraftState"; export { useOpenclawFormState } from "./useOpenclawFormState"; export { useCopilotAuth } from "./useCopilotAuth"; +export { useCodexOauth } from "./useCodexOauth"; diff --git a/src/components/providers/forms/hooks/useCodexOauth.ts b/src/components/providers/forms/hooks/useCodexOauth.ts new file mode 100644 index 000000000..84530edf6 --- /dev/null +++ b/src/components/providers/forms/hooks/useCodexOauth.ts @@ -0,0 +1,10 @@ +import { useManagedAuth } from "./useManagedAuth"; + +/** + * Codex OAuth (ChatGPT Plus/Pro) 认证 hook + * + * 复用通用 useManagedAuth,仅指定 provider 为 "codex_oauth" + */ +export function useCodexOauth() { + return useManagedAuth("codex_oauth"); +} diff --git a/src/components/settings/AuthCenterPanel.tsx b/src/components/settings/AuthCenterPanel.tsx index ee06b0bc7..e98bed41a 100644 --- a/src/components/settings/AuthCenterPanel.tsx +++ b/src/components/settings/AuthCenterPanel.tsx @@ -1,7 +1,8 @@ -import { Github, ShieldCheck } from "lucide-react"; +import { Github, ShieldCheck, Sparkles } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; import { CopilotAuthSection } from "@/components/providers/forms/CopilotAuthSection"; +import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection"; export function AuthCenterPanel() { const { t } = useTranslation(); @@ -50,6 +51,25 @@ export function AuthCenterPanel() { + +
+
+
+ +
+
+

ChatGPT (Codex OAuth)

+

+ {t("settings.authCenter.codexOauthDescription", { + defaultValue: + "管理 ChatGPT Plus/Pro 账号,用于将 Claude Code 请求反代到 Codex 后端。仅限个人开发使用。", + })} +

+
+
+ + +
); } diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 2ed5dcd88..a29d00635 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -53,7 +53,8 @@ export interface ProviderPreset { // 供应商类型标识(用于特殊供应商检测) // - "github_copilot": GitHub Copilot 供应商(需要 OAuth 认证) - providerType?: "github_copilot"; + // - "codex_oauth": OpenAI Codex via ChatGPT Plus/Pro 反代(需要 OAuth 认证) + providerType?: "github_copilot" | "codex_oauth"; // 是否需要 OAuth 认证(而非 API Key) requiresOAuth?: boolean; @@ -708,6 +709,27 @@ export const providerPresets: ProviderPreset[] = [ icon: "github", iconColor: "#000000", }, + { + name: "Codex (ChatGPT Plus/Pro)", + websiteUrl: "https://openai.com/chatgpt/pricing", + settingsConfig: { + env: { + // base_url 由代理后端强制重写为 chatgpt.com/backend-api/codex + // 用户无需配置 + ANTHROPIC_BASE_URL: "https://chatgpt.com/backend-api/codex", + ANTHROPIC_MODEL: "gpt-5.1-codex", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "gpt-5.1-codex-mini", + ANTHROPIC_DEFAULT_SONNET_MODEL: "gpt-5.1-codex", + ANTHROPIC_DEFAULT_OPUS_MODEL: "gpt-5.1-codex-max", + }, + }, + category: "third_party", + apiFormat: "openai_responses", + providerType: "codex_oauth", + requiresOAuth: true, + icon: "openai", + iconColor: "#000000", + }, { name: "Nvidia", websiteUrl: "https://build.nvidia.com", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dc6eb02c5..6d9f1e5a9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -845,6 +845,27 @@ "migrationFailed": "Legacy auth migration failed: {{error}}", "loadModelsFailed": "Failed to load Copilot models" }, + "codexOauth": { + "authStatus": "ChatGPT Plus/Pro Auth", + "notAuthenticated": "Not authenticated", + "loginWithChatGPT": "Sign in with ChatGPT", + "loginRequired": "Please sign in to ChatGPT first", + "waitingForAuth": "Waiting for authorization...", + "enterCode": "Enter the code in your browser:", + "accountCount": "{{count}} account(s)", + "selectAccount": "Select account", + "selectAccountPlaceholder": "Choose a ChatGPT account", + "useDefaultAccount": "Use default account", + "loggedInAccounts": "Logged in accounts", + "defaultAccount": "Default", + "selected": "Selected", + "removeAccount": "Remove account", + "setAsDefault": "Set as default", + "addAnotherAccount": "Add another account", + "logoutAll": "Logout all accounts", + "retry": "Retry", + "copyCode": "Copy code" + }, "endpointTest": { "title": "API Endpoint Management", "endpoints": "endpoints", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 47b08db43..853b466d0 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -845,6 +845,27 @@ "migrationFailed": "旧認証データの移行に失敗しました: {{error}}", "loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました" }, + "codexOauth": { + "authStatus": "ChatGPT Plus/Pro 認証", + "notAuthenticated": "未認証", + "loginWithChatGPT": "ChatGPT でログイン", + "loginRequired": "先に ChatGPT にログインしてください", + "waitingForAuth": "認証を待機中...", + "enterCode": "ブラウザで以下のコードを入力してください:", + "accountCount": "{{count}} アカウント", + "selectAccount": "アカウントを選択", + "selectAccountPlaceholder": "ChatGPT アカウントを選択", + "useDefaultAccount": "デフォルトアカウントを使用", + "loggedInAccounts": "ログイン済みアカウント", + "defaultAccount": "デフォルト", + "selected": "選択中", + "removeAccount": "アカウントを削除", + "setAsDefault": "デフォルトに設定", + "addAnotherAccount": "別のアカウントを追加", + "logoutAll": "すべてのアカウントをログアウト", + "retry": "再試行", + "copyCode": "コードをコピー" + }, "endpointTest": { "title": "API エンドポイント管理", "endpoints": "エンドポイント", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index aaa944ab9..7e02c19d6 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -845,6 +845,27 @@ "migrationFailed": "旧认证数据迁移失败:{{error}}", "loadModelsFailed": "加载 Copilot 模型列表失败" }, + "codexOauth": { + "authStatus": "ChatGPT Plus/Pro 认证", + "notAuthenticated": "未认证", + "loginWithChatGPT": "使用 ChatGPT 登录", + "loginRequired": "请先登录 ChatGPT 账号", + "waitingForAuth": "等待授权中...", + "enterCode": "请在浏览器中输入以下验证码:", + "accountCount": "{{count}} 个账号", + "selectAccount": "选择账号", + "selectAccountPlaceholder": "选择一个 ChatGPT 账号", + "useDefaultAccount": "使用默认账号", + "loggedInAccounts": "已登录账号", + "defaultAccount": "默认", + "selected": "已选中", + "removeAccount": "移除账号", + "setAsDefault": "设为默认", + "addAnotherAccount": "添加其他账号", + "logoutAll": "注销所有账号", + "retry": "重试", + "copyCode": "复制代码" + }, "endpointTest": { "title": "请求地址管理", "endpoints": "个端点", diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts index 5e6ecde00..a4d840ffa 100644 --- a/src/lib/api/auth.ts +++ b/src/lib/api/auth.ts @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; -export type ManagedAuthProvider = "github_copilot"; +export type ManagedAuthProvider = "github_copilot" | "codex_oauth"; export interface ManagedAuthAccount { id: string;