From 51d6c458ff2ccac7f283dad6f15732ed956c71ac Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 12 Jul 2026 17:51:56 +0800 Subject: [PATCH] feat(codex): route native ChatGPT sessions through proxy takeover Allow the built-in Codex official provider to participate in takeover mode while preserving Codex's native OAuth or API-key credentials instead of persisting them into provider records. Project official routing into a dedicated TOML provider, normalize inline tables, clean stale managed placeholders, and fail closed when the live configuration cannot be transformed safely. Validate forwarded authorization, make official 401/403 responses non-retryable, avoid circuit-breaker pollution, and share the first-party ChatGPT endpoint across the Codex and Claude adapters. --- src-tauri/src/codex_config.rs | 226 ++++++- src-tauri/src/commands/proxy.rs | 10 +- src-tauri/src/database/dao/providers_seed.rs | 3 +- src-tauri/src/database/mod.rs | 4 +- src-tauri/src/proxy/forwarder.rs | 98 ++- src-tauri/src/proxy/providers/claude.rs | 6 +- src-tauri/src/proxy/providers/codex.rs | 39 ++ src-tauri/src/proxy/providers/mod.rs | 10 +- src-tauri/src/services/provider/mod.rs | 13 +- src-tauri/src/services/proxy.rs | 597 ++++++++++++++----- src-tauri/src/tray.rs | 8 +- 11 files changed, 835 insertions(+), 179 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index b6b899a54..4a4ed8785 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -12,7 +12,13 @@ use std::process::Command; use toml_edit::DocumentMut; pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom"; +/// Temporary model-provider id used while the built-in `codex-official` +/// provider is routed through CC Switch. A dedicated id is an ownership +/// marker: unlike a generic localhost `base_url`, it can be detected and +/// cleaned up without mistaking a user's own local provider for takeover. +pub const CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID: &str = "cc-switch-official"; pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json"; +const CODEX_PROXY_AUTH_PLACEHOLDER: &str = "PROXY_MANAGED"; /// Top-level `config.toml` key that controls Codex's built-in web-search tool. pub(crate) const CODEX_WEB_SEARCH_FIELD: &str = "web_search"; @@ -1338,15 +1344,139 @@ pub fn read_codex_live_settings() -> Result { /// backend), `name = "OpenAI"` keeps Codex's `is_openai()` feature gates /// (web search, remote compaction), and `supports_websockets` restores the /// built-in default that custom entries otherwise lose. -fn codex_unified_official_provider_table() -> toml_edit::Table { +fn codex_official_provider_table( + base_url: Option<&str>, + supports_websockets: bool, +) -> toml_edit::Table { let mut table = toml_edit::Table::new(); table["name"] = toml_edit::value("OpenAI"); table["requires_openai_auth"] = toml_edit::value(true); - table["supports_websockets"] = toml_edit::value(true); + table["supports_websockets"] = toml_edit::value(supports_websockets); table["wire_api"] = toml_edit::value("responses"); + if let Some(base_url) = base_url { + table["base_url"] = toml_edit::value(base_url.trim_end_matches('/')); + } table } +fn codex_unified_official_provider_table() -> toml_edit::Table { + codex_official_provider_table(None, true) +} + +fn remove_codex_proxy_placeholders_from_providers(providers: &mut toml_edit::Table) { + for (_, item) in providers.iter_mut() { + if let Some(table) = item.as_table_mut() { + let should_remove = table + .get("experimental_bearer_token") + .and_then(|item| item.as_str()) + == Some(CODEX_PROXY_AUTH_PLACEHOLDER); + if should_remove { + table.remove("experimental_bearer_token"); + } + } else if let Some(table) = item.as_inline_table_mut() { + let should_remove = table + .get("experimental_bearer_token") + .and_then(|value| value.as_str()) + == Some(CODEX_PROXY_AUTH_PLACEHOLDER); + if should_remove { + table.remove("experimental_bearer_token"); + } + } + } +} + +/// Project the built-in Codex official provider through the local proxy while +/// keeping authentication owned by Codex itself. +/// +/// The resulting custom provider explicitly opts into OpenAI authentication, +/// so Codex forwards its existing ChatGPT login to the local `/responses` +/// endpoint. No API key or bearer placeholder is written to `auth.json`. +pub fn apply_codex_official_proxy_route( + config_text: &str, + proxy_base_url: &str, +) -> Result { + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + + // A third-party takeover may have left the proxy placeholder in config.toml. + // The official route must use Codex's native OpenAI login instead. + doc.as_table_mut().remove("experimental_bearer_token"); + doc["model_provider"] = toml_edit::value(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID); + + let mut providers = match doc.as_table_mut().remove("model_providers") { + Some(item) => item.into_table().map_err(|_| { + AppError::Message( + "Invalid Codex config.toml: model_providers must be a table".to_string(), + ) + })?, + None => { + let mut table = toml_edit::Table::new(); + table.set_implicit(true); + table + } + }; + + // Clean only CC Switch's placeholder from every stale provider table. Real + // user bearer tokens are preserved, as are all unrelated provider fields. + remove_codex_proxy_placeholders_from_providers(&mut providers); + + // The local proxy currently exposes HTTP/SSE, not Codex websocket routes. + let table = codex_official_provider_table(Some(proxy_base_url), false); + + providers.insert( + CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID, + toml_edit::Item::Table(table), + ); + doc["model_providers"] = toml_edit::Item::Table(providers); + Ok(doc.to_string()) +} + +/// Whether a live Codex config is the official route projected by CC Switch. +pub fn codex_config_has_official_proxy_route(config_text: &str) -> bool { + if !config_text.contains(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID) { + return false; + } + config_text + .parse::() + .ok() + .and_then(|doc| { + doc.get("model_provider") + .and_then(|item| item.as_str()) + .map(str::to_string) + }) + .as_deref() + == Some(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID) +} + +/// Remove only the official takeover route owned by CC Switch. This is a +/// last-resort crash cleanup when no live backup or provider SSOT is usable. +pub fn remove_codex_official_proxy_route(config_text: &str) -> Result { + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + if doc.get("model_provider").and_then(|item| item.as_str()) + != Some(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID) + { + return Ok(config_text.to_string()); + } + + doc.as_table_mut().remove("model_provider"); + if let Some(item) = doc.as_table_mut().remove("model_providers") { + let mut providers = item.into_table().map_err(|_| { + AppError::Message( + "Invalid Codex config.toml: model_providers must be a table".to_string(), + ) + })?; + providers.remove(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID); + remove_codex_proxy_placeholders_from_providers(&mut providers); + if !providers.is_empty() { + doc["model_providers"] = toml_edit::Item::Table(providers); + } + } + Ok(doc.to_string()) +} + fn table_matches_codex_unified_official_provider(table: &toml_edit::Table) -> bool { table.len() == 4 && table.get("name").and_then(|item| item.as_str()) == Some("OpenAI") @@ -1812,6 +1942,98 @@ mod tests { ); } + #[test] + fn official_proxy_route_uses_native_auth_and_local_responses_provider() { + let input = r#"model = "gpt-5.4" +experimental_bearer_token = "PROXY_MANAGED" + +[mcp_servers.example] +command = "example" +"#; + let output = apply_codex_official_proxy_route(input, "http://127.0.0.1:15721/v1") + .expect("apply official proxy route"); + let doc: toml::Value = toml::from_str(&output).expect("parse output"); + + assert_eq!( + doc.get("model_provider").and_then(toml::Value::as_str), + Some(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID) + ); + assert!(doc.get("experimental_bearer_token").is_none()); + assert!( + doc.get("mcp_servers").is_some(), + "unrelated config survives" + ); + + let provider = &doc["model_providers"][CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID]; + assert_eq!( + provider.get("base_url").and_then(toml::Value::as_str), + Some("http://127.0.0.1:15721/v1") + ); + assert_eq!( + provider + .get("requires_openai_auth") + .and_then(toml::Value::as_bool), + Some(true) + ); + assert_eq!( + provider + .get("supports_websockets") + .and_then(toml::Value::as_bool), + Some(false) + ); + assert!(codex_config_has_official_proxy_route(&output)); + } + + #[test] + fn official_proxy_route_cleanup_only_removes_owned_provider() { + let projected = + apply_codex_official_proxy_route("model = \"gpt-5.4\"\n", "http://127.0.0.1:15721/v1") + .expect("project"); + let cleaned = remove_codex_official_proxy_route(&projected).expect("clean"); + let doc: toml::Value = toml::from_str(&cleaned).expect("parse cleaned"); + assert!(doc.get("model_provider").is_none()); + assert!(doc.get("model_providers").is_none()); + assert_eq!( + doc.get("model").and_then(toml::Value::as_str), + Some("gpt-5.4") + ); + } + + #[test] + fn official_proxy_route_rejects_non_table_model_providers_without_panicking() { + for input in [ + "model_providers = 3\n", + "[[model_providers]]\nname = \"broken\"\n", + ] { + let result = apply_codex_official_proxy_route(input, "http://127.0.0.1:15721/v1"); + assert!(result.is_err()); + } + } + + #[test] + fn official_proxy_route_normalizes_inline_tables_and_cleans_stale_placeholder() { + let input = r#"model_provider = "rightcode" +model_providers = { rightcode = { name = "RightCode", experimental_bearer_token = "PROXY_MANAGED" } } +"#; + let projected = apply_codex_official_proxy_route(input, "http://127.0.0.1:15721/v1") + .expect("project inline provider table"); + let projected_doc: toml::Value = toml::from_str(&projected).expect("parse projected"); + assert!(projected_doc["model_providers"]["rightcode"] + .get("experimental_bearer_token") + .is_none()); + assert!(projected_doc["model_providers"] + .get(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID) + .is_some()); + + let cleaned = remove_codex_official_proxy_route(&projected).expect("clean projected"); + let cleaned_doc: toml::Value = toml::from_str(&cleaned).expect("parse cleaned"); + assert!(cleaned_doc.get("model_provider").is_none()); + assert!(cleaned_doc["model_providers"].get("rightcode").is_some()); + assert!(cleaned_doc["model_providers"] + .get(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID) + .is_none()); + } + #[test] fn unified_session_bucket_preserves_other_keys_and_explicit_routing() { let with_catalog = "model_catalog_json = \"cc-switch-model-catalog.json\"\n"; diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index 93f62b403..e171d1e4b 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -6,6 +6,7 @@ use crate::error::AppError; use crate::proxy::types::*; use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats}; use crate::store::AppState; +use std::str::FromStr; /// 启动代理服务器(仅启动服务,不接管 Live 配置) #[tauri::command] @@ -279,13 +280,18 @@ pub async fn switch_proxy_provider( app_type: String, provider_id: String, ) -> Result<(), String> { - // Block official providers during proxy takeover + // Codex's built-in official provider can use the client's native OpenAI + // login through takeover. Other official providers remain blocked. let provider = state .db .get_provider_by_id(&provider_id, &app_type) .map_err(|e| format!("读取供应商失败: {e}"))? .ok_or_else(|| format!("供应商不存在: {provider_id}"))?; - if provider.category.as_deref() == Some("official") { + let app = crate::app_config::AppType::from_str(&app_type) + .map_err(|e| format!("无效的应用类型: {e}"))?; + if provider.category.as_deref() == Some("official") + && !crate::services::provider::official_provider_supports_proxy_takeover(&app, &provider) + { return Err( "代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)" .to_string(), diff --git a/src-tauri/src/database/dao/providers_seed.rs b/src-tauri/src/database/dao/providers_seed.rs index 2babe36e2..8091a1031 100644 --- a/src-tauri/src/database/dao/providers_seed.rs +++ b/src-tauri/src/database/dao/providers_seed.rs @@ -11,6 +11,7 @@ use crate::app_config::AppType; pub(crate) const CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID: &str = "claude-desktop-official"; +pub(crate) const CODEX_OFFICIAL_PROVIDER_ID: &str = "codex-official"; /// 单条官方供应商种子定义。 pub(crate) struct OfficialProviderSeed { @@ -49,7 +50,7 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[ settings_config_json: r#"{"env":{}}"#, }, OfficialProviderSeed { - id: "codex-official", + id: CODEX_OFFICIAL_PROVIDER_ID, app_type: AppType::Codex, name: "OpenAI Official", website_url: "https://chatgpt.com/codex", diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index dda8da4a5..fa0652312 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -32,7 +32,9 @@ mod schema; mod tests; // DAO 类型导出供外部使用 -pub(crate) use dao::providers_seed::{is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID}; +pub(crate) use dao::providers_seed::{ + is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID, +}; pub(crate) use dao::proxy::{ validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE, diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index c5accf283..7759b61cb 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -39,6 +39,22 @@ use tokio::sync::RwLock; const PROXY_AUTH_PLACEHOLDER: &str = "PROXY_MANAGED"; +fn validate_codex_official_authorization(headers: &http::HeaderMap) -> Result<(), ProxyError> { + let authorization = headers + .get(http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::trim); + match authorization { + None | Some("") => Err(ProxyError::AuthError( + "Codex 官方登录不可用,请先在 Codex 中完成 ChatGPT 登录".to_string(), + )), + Some(value) if value.contains(PROXY_AUTH_PLACEHOLDER) => Err(ProxyError::AuthError( + "已切换到 OpenAI 官方供应商,请重启 Codex 或新建会话以加载官方登录配置".to_string(), + )), + Some(_) => Ok(()), + } +} + pub struct ForwardResult { pub response: ProxyResponse, pub provider: Provider, @@ -986,7 +1002,7 @@ impl RequestForwarder { // 先分类错误,决定是否计入 provider 健康度 // —— NonRetryable / ClientAbort 是客户端层错误,无论换哪家 provider 都会被拒绝, // 不应污染熔断器和数据库健康度(与 release_permit_neutral 同语义)。 - let category = self.categorize_proxy_error(&e); + let category = self.categorize_proxy_error(&e, provider); match category { ErrorCategory::Retryable => { @@ -1130,6 +1146,12 @@ impl RequestForwarder { && super::providers::should_convert_codex_responses_to_chat(provider, endpoint); let codex_responses_to_anthropic = matches!(app_type, AppType::Codex) && super::providers::should_convert_codex_responses_to_anthropic(provider, endpoint); + let codex_official_auth_passthrough = matches!(app_type, AppType::Codex) + && super::providers::is_codex_official_provider(provider); + + if codex_official_auth_passthrough { + validate_codex_official_authorization(headers)?; + } // 应用模型映射(独立于格式转换) // Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route @@ -1840,6 +1862,17 @@ impl RequestForwarder { || key_str.eq_ignore_ascii_case("x-api-key") || key_str.eq_ignore_ascii_case("x-goog-api-key") { + // The built-in Codex official provider deliberately has no + // credential in CC Switch. `requires_openai_auth = true` makes + // Codex send its native ChatGPT authorization, which must reach + // the fixed official upstream unchanged. Other credential + // headers are still discarded. + if codex_official_auth_passthrough && key_str.eq_ignore_ascii_case("authorization") + { + saw_auth = true; + ordered_headers.append(key.clone(), value.clone()); + continue; + } if !saw_auth { saw_auth = true; for (ah_name, ah_value) in &auth_headers { @@ -2494,7 +2527,23 @@ impl RequestForwarder { } } - fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory { + fn categorize_proxy_error(&self, error: &ProxyError, provider: &Provider) -> ErrorCategory { + // Authentication belongs to the Codex client for the built-in official + // route. Retrying another provider would silently move the conversation + // away from the selected official account and poison its health state. + if super::providers::is_codex_official_provider(provider) + && (matches!(error, ProxyError::AuthError(_)) + || matches!( + error, + ProxyError::UpstreamError { + status: 401 | 403, + .. + } + )) + { + return ErrorCategory::NonRetryable; + } + match error { // 网络和上游错误:都应该尝试下一个供应商 ProxyError::Timeout(_) => ErrorCategory::Retryable, @@ -4197,14 +4246,53 @@ mod tests { #[test] fn invalid_client_history_is_not_retryable() { let forwarder = test_forwarder(Duration::ZERO, Duration::ZERO); + let provider = test_provider_with_type(None); assert_eq!( - forwarder.categorize_proxy_error(&ProxyError::InvalidRequest( - "invalid historical tool arguments".to_string() - )), + forwarder.categorize_proxy_error( + &ProxyError::InvalidRequest("invalid historical tool arguments".to_string()), + &provider, + ), ErrorCategory::NonRetryable ); } + #[test] + fn official_codex_auth_failures_are_not_retryable() { + let forwarder = test_forwarder(Duration::ZERO, Duration::ZERO); + let mut provider = test_provider_with_type(None); + provider.id = "codex-official".to_string(); + provider.category = Some("official".to_string()); + + for error in [ + ProxyError::AuthError("restart Codex".to_string()), + ProxyError::UpstreamError { + status: 401, + body: None, + }, + ProxyError::UpstreamError { + status: 403, + body: None, + }, + ] { + assert_eq!( + forwarder.categorize_proxy_error(&error, &provider), + ErrorCategory::NonRetryable + ); + } + } + + #[test] + fn official_codex_rejects_stale_proxy_placeholder_with_restart_hint() { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer PROXY_MANAGED"), + ); + let error = validate_codex_official_authorization(&headers) + .expect_err("stale placeholder must be rejected"); + assert!(matches!(error, ProxyError::AuthError(message) if message.contains("重启 Codex"))); + } + #[test] fn rewrite_codex_responses_compact_endpoint_to_chat_preserves_query() { let (endpoint, passthrough_query) = diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index c8db6b919..dbaffbb2e 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -666,7 +666,7 @@ 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()); + return Ok(super::CHATGPT_CODEX_BASE_URL.to_string()); } // 1. 从 env 中获取 @@ -778,9 +778,9 @@ 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" { + if base_url == super::CHATGPT_CODEX_BASE_URL { let _ = endpoint; // 忽略原始 endpoint - return "https://chatgpt.com/backend-api/codex/responses".to_string(); + return format!("{}/responses", super::CHATGPT_CODEX_BASE_URL); } // NOTE: diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index 281d2968d..3fc42eb20 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -206,6 +206,14 @@ pub fn should_convert_codex_responses_to_anthropic(provider: &Provider, endpoint ) && codex_provider_uses_anthropic(provider) } +/// The single built-in official Codex provider. Unlike managed Codex OAuth +/// providers used by Claude, this route receives authentication from the +/// calling Codex client (`requires_openai_auth = true`). +pub fn is_codex_official_provider(provider: &Provider) -> bool { + provider.id == crate::database::CODEX_OFFICIAL_PROVIDER_ID + && provider.category.as_deref() == Some("official") +} + /// Resolve the model-catalog tool profile for a Codex provider using the SAME /// Anthropic detection as the proxy router ([`codex_provider_uses_anthropic`]), so the /// generated catalog never disagrees with the routed transform. A provider whose @@ -217,6 +225,9 @@ pub fn resolve_codex_catalog_tool_profile( provider: &Provider, ) -> crate::codex_config::CodexCatalogToolProfile { use crate::codex_config::CodexCatalogToolProfile; + if is_codex_official_provider(provider) { + return CodexCatalogToolProfile::NativeResponses; + } if codex_provider_uses_anthropic(provider) { return CodexCatalogToolProfile::Anthropic; } @@ -634,6 +645,10 @@ impl ProviderAdapter for CodexAdapter { } fn extract_base_url(&self, provider: &Provider) -> Result { + if is_codex_official_provider(provider) { + return Ok(super::CHATGPT_CODEX_BASE_URL.to_string()); + } + // 1. 尝试直接获取 base_url 字段 if let Some(url) = provider .settings_config @@ -781,6 +796,30 @@ mod tests { } } + #[test] + fn official_provider_uses_fixed_chatgpt_backend_without_stored_key() { + let mut provider = create_provider(json!({ "auth": {}, "config": "" })); + provider.id = "codex-official".to_string(); + provider.category = Some("official".to_string()); + let adapter = CodexAdapter::new(); + + assert!(is_codex_official_provider(&provider)); + assert_eq!( + adapter + .extract_base_url(&provider) + .expect("official base url"), + "https://chatgpt.com/backend-api/codex" + ); + assert!(adapter.extract_auth(&provider).is_none()); + assert_eq!( + adapter.build_url( + "https://chatgpt.com/backend-api/codex", + "/responses/compact" + ), + "https://chatgpt.com/backend-api/codex/responses/compact" + ); + } + #[test] fn prompt_cache_routing_auto_enables_known_upstreams_only() { let kimi = create_provider(json!({ diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index cbd2eac2b..c70d36764 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -41,6 +41,8 @@ use crate::app_config::AppType; use crate::provider::Provider; use serde::{Deserialize, Serialize}; +pub const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; + // 公开导出 pub use adapter::ProviderAdapter; pub use auth::{AuthInfo, AuthStrategy}; @@ -52,9 +54,9 @@ pub use claude::{ pub use codex::CodexAdapter; pub use codex::{ apply_codex_chat_upstream_model, apply_codex_upstream_model, codex_provider_upstream_model, - inject_codex_chat_prompt_cache_key, resolve_codex_catalog_tool_profile, - resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_anthropic, - should_convert_codex_responses_to_chat, + inject_codex_chat_prompt_cache_key, is_codex_official_provider, + resolve_codex_catalog_tool_profile, resolve_codex_chat_reasoning_config, + should_convert_codex_responses_to_anthropic, should_convert_codex_responses_to_chat, }; pub use gemini::GeminiAdapter; @@ -110,7 +112,7 @@ impl ProviderType { } ProviderType::OpenRouter => "https://openrouter.ai/api", ProviderType::GitHubCopilot => "https://api.githubcopilot.com", - ProviderType::CodexOAuth => "https://chatgpt.com/backend-api/codex", + ProviderType::CodexOAuth => CHATGPT_CODEX_BASE_URL, } } diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 507686655..9c78ad058 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -43,6 +43,14 @@ use live::{ }; use usage::validate_usage_script; +/// The built-in Codex official provider is safe to select during takeover: +/// Codex keeps ownership of its ChatGPT login and the proxy only forwards the +/// authenticated request. Other official providers retain the existing block. +pub fn official_provider_supports_proxy_takeover(app_type: &AppType, provider: &Provider) -> bool { + matches!(app_type, AppType::Codex) + && crate::proxy::providers::is_codex_official_provider(provider) +} + /// 统一会话开关变更后,立即按新开关状态重写当前官方 Codex 供应商的 /// live 配置,使开关即时生效(无需等下一次切换)。 /// 当前供应商非官方(或不存在)时为 no-op:注入只作用于官方配置, @@ -2394,7 +2402,10 @@ impl ProviderService { // Block switching to official providers when proxy takeover is active. // Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans. - if should_hot_switch && _provider.category.as_deref() == Some("official") { + if should_hot_switch + && _provider.category.as_deref() == Some("official") + && !official_provider_supports_proxy_takeover(&app_type, _provider) + { return Err(AppError::localized( "switch.official_blocked_by_proxy", "代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。", diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 8f420c540..263809609 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -374,29 +374,11 @@ impl ProxyService { } let (_, proxy_codex_base_url) = self.build_proxy_urls().await?; - if let Some(auth) = effective_settings - .get_mut("auth") - .and_then(|v| v.as_object_mut()) - { - auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); - } else if let Some(root) = effective_settings.as_object_mut() { - root.insert( - "auth".to_string(), - json!({ "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER }), - ); - } - - let config_str = effective_settings - .get("config") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let updated_config = Self::apply_codex_proxy_toml_config_for_provider( - config_str, + Self::apply_codex_takeover_fields_for_provider( + &mut effective_settings, &proxy_codex_base_url, - Some(provider), - ); - effective_settings["config"] = json!(updated_config); - Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider)); + provider, + )?; self.write_codex_takeover_live_for_provider(&effective_settings, Some(provider))?; Ok(()) @@ -738,7 +720,11 @@ impl ProxyService { crate::settings::get_effective_current_provider(&self.db, &app) { if let Ok(Some(provider)) = self.db.get_provider_by_id(¤t_id, app_type_str) { - if provider.category.as_deref() == Some("official") { + if provider.category.as_deref() == Some("official") + && !crate::services::provider::official_provider_supports_proxy_takeover( + &app, &provider, + ) + { if let Some(handle) = self.app_handle.read().await.as_ref() { let _ = handle.emit( "proxy-official-warning", @@ -984,6 +970,12 @@ impl ProxyService { if let Ok(Some(mut provider)) = self.db.get_provider_by_id(&provider_id, "codex") { + // The built-in official row is a routing capability, not + // a credential store. Its auth must remain empty even + // when the live Codex login uses OPENAI_API_KEY mode. + if crate::proxy::providers::is_codex_official_provider(&provider) { + return Ok(()); + } if let Some(token) = live_config .get("auth") .and_then(|v| v.get("OPENAI_API_KEY")) @@ -1361,34 +1353,16 @@ impl ProxyService { log::info!("Claude Live 配置已接管,代理地址: {proxy_url}"); } - // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token) + // Codex: project the selected provider through the local Responses endpoint. if let Ok(mut live_config) = self.read_codex_live() { - // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符) - if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) { - auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); - } - - // 2. 修改 config.toml 中的 base_url - let config_str = live_config - .get("config") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let codex_provider = self - .get_current_provider_for_app(&AppType::Codex) - .ok() - .flatten(); - let updated_config = Self::apply_codex_proxy_toml_config_for_provider( - config_str, - &proxy_codex_base_url, - codex_provider.as_ref(), - ); - live_config["config"] = json!(updated_config); - Self::attach_codex_model_catalog_from_provider( + let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?; + Self::apply_codex_takeover_fields_for_provider( &mut live_config, - codex_provider.as_ref(), - ); + &proxy_codex_base_url, + &codex_provider, + )?; - self.write_codex_takeover_live_for_provider(&live_config, codex_provider.as_ref())?; + self.write_codex_takeover_live_for_provider(&live_config, Some(&codex_provider))?; log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}"); } @@ -1431,26 +1405,12 @@ impl ProxyService { } AppType::Codex => { let mut live_config = self.read_codex_live()?; - - if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) { - auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); - } - - let config_str = live_config - .get("config") - .and_then(|v| v.as_str()) - .unwrap_or(""); let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?; - let updated_config = Self::apply_codex_proxy_toml_config_for_provider( - config_str, - &proxy_codex_base_url, - Some(&codex_provider), - ); - live_config["config"] = json!(updated_config); - Self::attach_codex_model_catalog_from_provider( + Self::apply_codex_takeover_fields_for_provider( &mut live_config, - Some(&codex_provider), - ); + &proxy_codex_base_url, + &codex_provider, + )?; self.write_codex_takeover_live_for_provider(&live_config, Some(&codex_provider))?; log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}"); @@ -1507,34 +1467,17 @@ impl ProxyService { } AppType::Codex => { if let Ok(mut live_config) = self.read_codex_live() { - if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) - { - auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); - } - - let config_str = live_config - .get("config") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let codex_provider = self - .get_current_provider_for_app(&AppType::Codex) - .ok() - .flatten(); - let updated_config = Self::apply_codex_proxy_toml_config_for_provider( - config_str, - &proxy_codex_base_url, - codex_provider.as_ref(), - ); - live_config["config"] = json!(updated_config); - Self::attach_codex_model_catalog_from_provider( + let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?; + Self::apply_codex_takeover_fields_for_provider( &mut live_config, - codex_provider.as_ref(), - ); + &proxy_codex_base_url, + &codex_provider, + )?; - let _ = self.write_codex_takeover_live_for_provider( + self.write_codex_takeover_live_for_provider( &live_config, - codex_provider.as_ref(), - ); + Some(&codex_provider), + )?; } } AppType::Gemini => { @@ -1831,7 +1774,7 @@ impl ProxyService { Self::proxy_urls_match(url, &proxy_codex_base_url) }) }); - Ok(Self::codex_live_has_proxy_placeholder(&config) && base_url_matches) + Ok(Self::is_codex_live_taken_over(&config) && base_url_matches) } AppType::Gemini => { let config = self.read_gemini_live()?; @@ -1894,6 +1837,8 @@ impl ProxyService { token == PROXY_TOKEN_PLACEHOLDER }) .map_err(|e| format!("清理 Codex 接管占位符失败: {e}"))?; + let updated = crate::codex_config::remove_codex_official_proxy_route(&updated) + .map_err(|e| format!("清理 Codex 官方接管路由失败: {e}"))?; config["config"] = json!(updated); } @@ -2027,6 +1972,10 @@ impl ProxyService { fn is_codex_live_taken_over(config: &Value) -> bool { Self::codex_live_has_proxy_placeholder(config) + || config + .get("config") + .and_then(|v| v.as_str()) + .is_some_and(crate::codex_config::codex_config_has_official_proxy_route) } fn is_gemini_live_taken_over(config: &Value) -> bool { @@ -2046,7 +1995,7 @@ impl ProxyService { fn live_has_proxy_placeholder_for_app(app_type: &AppType, config: &Value) -> bool { match app_type { AppType::Claude => Self::is_claude_live_taken_over(config), - AppType::Codex => Self::codex_live_has_proxy_placeholder(config), + AppType::Codex => Self::is_codex_live_taken_over(config), AppType::Gemini => Self::is_gemini_live_taken_over(config), _ => false, } @@ -2089,13 +2038,24 @@ impl ProxyService { .map_err(|e| format!("解析 {app_type} 现有备份失败: {e}")) }) .transpose()?; + // A stale takeover marker can survive without its DB backup (for + // example after a partial cleanup). In that abnormal state the live + // auth file is still the only copy of the user's login, so use it as + // the preservation source instead of replacing it with the empty + // official seed snapshot. + let existing_backup_value = + existing_backup_value.or_else(|| self.read_codex_live().ok()); if let Some(existing_value) = existing_backup_value.as_ref() { Self::preserve_codex_mcp_servers_from_existing_config( &mut effective_settings, existing_value, )?; - Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?; + Self::preserve_codex_auth_in_backup( + &mut effective_settings, + existing_value, + crate::proxy::providers::is_codex_official_provider(provider), + )?; } // 统一会话开关:备份是接管释放时恢复 live 的来源,官方配置的 @@ -2156,8 +2116,14 @@ impl ProxyService { .map_err(|e| format!("读取供应商失败: {e}"))? .ok_or_else(|| format!("供应商不存在: {provider_id}"))?; - // Defense-in-depth: block official providers during proxy takeover - if provider.category.as_deref() == Some("official") { + // Defense-in-depth: only the built-in Codex official provider supports + // native OpenAI auth passthrough during takeover. + if provider.category.as_deref() == Some("official") + && !crate::services::provider::official_provider_supports_proxy_takeover( + &app_type_enum, + &provider, + ) + { return Err( "代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)" .to_string(), @@ -2298,17 +2264,19 @@ impl ProxyService { Ok(()) } - fn preserve_codex_oauth_auth_in_backup( + fn preserve_codex_auth_in_backup( target_settings: &mut Value, existing_backup: &Value, + preserve_api_key: bool, ) -> Result<(), String> { - if !crate::settings::preserve_codex_official_auth_on_switch() { - return Ok(()); - } - let Some(existing_auth) = existing_backup .get("auth") - .filter(|auth| crate::codex_config::codex_auth_has_oauth_login_material(auth)) + .filter(|auth| { + !Self::codex_auth_has_proxy_placeholder(auth) + && (crate::codex_config::codex_auth_has_oauth_login_material(auth) + || (preserve_api_key + && crate::codex_config::codex_auth_has_login_material(auth))) + }) .cloned() else { return Ok(()); @@ -2350,33 +2318,69 @@ impl ProxyService { // ==================== Live 配置读写辅助方法 ==================== - /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现) - fn update_toml_base_url(toml_str: &str, new_url: &str) -> String { - crate::codex_config::update_codex_toml_field(toml_str, "base_url", new_url) - .unwrap_or_else(|_| toml_str.to_string()) - } - /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。 /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。 fn apply_codex_proxy_toml_config_for_provider( toml_str: &str, proxy_url: &str, provider: Option<&Provider>, - ) -> String { - let updated = Self::update_toml_base_url(toml_str, proxy_url); + ) -> Result { + if provider.is_some_and(crate::proxy::providers::is_codex_official_provider) { + return crate::codex_config::apply_codex_official_proxy_route(toml_str, proxy_url) + .map_err(|e| format!("生成 Codex 官方接管配置失败: {e}")); + } + + let updated = crate::codex_config::update_codex_toml_field(toml_str, "base_url", proxy_url) + .map_err(|e| format!("更新 Codex 代理地址失败: {e}"))?; let mut updated = crate::codex_config::update_codex_toml_field(&updated, "wire_api", "responses") - .unwrap_or(updated); + .map_err(|e| format!("更新 Codex wire_api 失败: {e}"))?; if let Some(upstream_model) = provider.and_then(crate::proxy::providers::codex_provider_upstream_model) { updated = crate::codex_config::update_codex_toml_field(&updated, "model", &upstream_model) - .unwrap_or(updated); + .map_err(|e| format!("更新 Codex 上游模型失败: {e}"))?; } - updated + Ok(updated) + } + + fn apply_codex_takeover_auth_placeholder(settings: &mut Value, provider: Option<&Provider>) { + if provider.is_some_and(crate::proxy::providers::is_codex_official_provider) { + return; + } + + if let Some(auth) = settings.get_mut("auth").and_then(|v| v.as_object_mut()) { + auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); + } else if let Some(root) = settings.as_object_mut() { + root.insert( + "auth".to_string(), + json!({ "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER }), + ); + } + } + + fn apply_codex_takeover_fields_for_provider( + settings: &mut Value, + proxy_base_url: &str, + provider: &Provider, + ) -> Result<(), String> { + Self::apply_codex_takeover_auth_placeholder(settings, Some(provider)); + let config_text = settings + .get("config") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(); + let projected = Self::apply_codex_proxy_toml_config_for_provider( + &config_text, + proxy_base_url, + Some(provider), + )?; + settings["config"] = json!(projected); + Self::attach_codex_model_catalog_from_provider(settings, Some(provider)); + Ok(()) } fn attach_codex_model_catalog_from_provider( @@ -2497,27 +2501,38 @@ impl ProxyService { config: &Value, provider: Option<&Provider>, ) -> Result<(), String> { - if crate::settings::preserve_codex_official_auth_on_switch() { - if let Some(auth) = config - .get("auth") - .filter(|auth| Self::codex_auth_has_proxy_placeholder(auth)) - { - let config_str = config.get("config").and_then(|v| v.as_str()).unwrap_or(""); - let profile = provider - .map(crate::proxy::providers::resolve_codex_catalog_tool_profile) - .unwrap_or(crate::codex_config::CodexCatalogToolProfile::ProxyChat); - let prepared_config = - crate::codex_config::prepare_codex_live_config_text_with_optional_catalog( - config, config_str, profile, - ) - .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; - let live_config = - crate::codex_config::prepare_codex_provider_live_config(auth, &prepared_config) - .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; - crate::codex_config::write_codex_live_config_atomic(Some(&live_config)) - .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; - return Ok(()); - } + let official_passthrough = + provider.is_some_and(crate::proxy::providers::is_codex_official_provider); + let placeholder_auth = config + .get("auth") + .is_some_and(Self::codex_auth_has_proxy_placeholder); + + // Takeover must never overwrite Codex's long-lived ChatGPT login. For + // third-party providers the placeholder is moved into config.toml; for + // codex-official no placeholder is needed because requires_openai_auth + // makes Codex supply its native authorization. + if official_passthrough || placeholder_auth { + let config_str = config.get("config").and_then(|v| v.as_str()).unwrap_or(""); + let profile = provider + .map(crate::proxy::providers::resolve_codex_catalog_tool_profile) + .unwrap_or(crate::codex_config::CodexCatalogToolProfile::ProxyChat); + let prepared_config = + crate::codex_config::prepare_codex_live_config_text_with_optional_catalog( + config, config_str, profile, + ) + .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + let live_config = if official_passthrough { + prepared_config + } else { + crate::codex_config::prepare_codex_provider_live_config( + config.get("auth").unwrap_or(&Value::Null), + &prepared_config, + ) + .map_err(|e| format!("写入 Codex 配置失败: {e}"))? + }; + crate::codex_config::write_codex_live_config_atomic(Some(&live_config)) + .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + return Ok(()); } self.write_codex_live_for_provider(config, provider) @@ -2561,9 +2576,11 @@ impl ProxyService { match (auth, prepared_cfg.as_deref()) { (Some(auth), Some(cfg)) => { - let auth_path = get_codex_auth_path(); if auth.as_object().is_some_and(|obj| obj.is_empty()) { - let _ = crate::config::delete_file(&auth_path); + // An empty provider snapshot must not destroy an existing + // Codex login. This is especially important when takeover + // switches from an API-key-backed official session to the + // built-in empty `codex-official` seed. let config_path = get_codex_config_path(); crate::config::write_text_file(&config_path, cfg) .map_err(|e| format!("写入 Codex config 失败: {e}"))?; @@ -3586,6 +3603,230 @@ wire_api = "responses" .expect("reset settings"); } + #[tokio::test] + #[serial] + async fn codex_takeover_hot_switches_between_builtin_official_and_third_party() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + // Exercise the default setting: takeover itself must now preserve native + // auth regardless of the legacy compatibility toggle. + crate::settings::update_settings(crate::settings::AppSettings::default()) + .expect("reset settings"); + + let db = Arc::new(Database::memory().expect("init db")); + use_ephemeral_proxy_port(&db).await; + let service = ProxyService::new(db.clone()); + let oauth_auth = json!({ + "auth_mode": "chatgpt", + "tokens": { + "id_token": "oauth-id", + "access_token": "oauth-access" + } + }); + crate::codex_config::write_codex_live_atomic(&oauth_auth, Some("model = \"gpt-5.4\"\n")) + .expect("seed official live config"); + + let mut official = Provider::with_id( + "codex-official".to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": "model = \"gpt-5.4\"\n" }), + None, + ); + official.category = Some("official".to_string()); + db.save_provider("codex", &official) + .expect("save official provider"); + + let mut third_party = Provider::with_id( + "rightcode".to_string(), + "RightCode".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "rightcode-key" }, + "config": r#"model_provider = "rightcode" + +[model_providers.rightcode] +name = "RightCode" +base_url = "https://rightcode.example/v1" +wire_api = "responses" +"# + }), + None, + ); + third_party.category = Some("custom".to_string()); + db.save_provider("codex", &third_party) + .expect("save third-party provider"); + db.set_current_provider("codex", "codex-official") + .expect("set current provider"); + crate::settings::set_current_provider(&AppType::Codex, Some("codex-official")) + .expect("set local current provider"); + + service + .set_takeover_for_app("codex", true) + .await + .expect("enable official takeover"); + + let read_auth = || -> Value { + crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) + .expect("read live auth") + }; + assert_eq!(read_auth(), oauth_auth); + let official_live = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read official takeover config"); + assert!(crate::codex_config::codex_config_has_official_proxy_route( + &official_live + )); + assert!(official_live.contains("requires_openai_auth = true")); + assert!(!official_live.contains(PROXY_TOKEN_PLACEHOLDER)); + + service + .hot_switch_provider("codex", "rightcode") + .await + .expect("switch to third-party provider"); + assert_eq!( + read_auth(), + oauth_auth, + "third-party route must preserve OAuth" + ); + let third_party_live = + std::fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read third-party takeover config"); + assert!(third_party_live.contains(PROXY_TOKEN_PLACEHOLDER)); + assert!(!crate::codex_config::codex_config_has_official_proxy_route( + &third_party_live + )); + + service + .hot_switch_provider("codex", "codex-official") + .await + .expect("switch back to official provider"); + assert_eq!( + read_auth(), + oauth_auth, + "official switch must reuse native OAuth" + ); + let official_live = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read restored official takeover config"); + assert!(crate::codex_config::codex_config_has_official_proxy_route( + &official_live + )); + assert!(!official_live.contains(PROXY_TOKEN_PLACEHOLDER)); + + service + .set_takeover_for_app("codex", false) + .await + .expect("disable takeover"); + assert_eq!(read_auth(), oauth_auth); + } + + #[test] + fn codex_takeover_backup_preserves_api_key_login_material() { + let mut target = json!({ "auth": {}, "config": "" }); + let existing = json!({ + "auth": { "OPENAI_API_KEY": "sk-real" }, + "config": "model = \"gpt-5.4\"\n" + }); + + ProxyService::preserve_codex_auth_in_backup(&mut target, &existing, true) + .expect("preserve API-key auth"); + assert_eq!(target["auth"]["OPENAI_API_KEY"], "sk-real"); + } + + #[tokio::test] + #[serial] + async fn codex_backup_rebuild_uses_live_auth_when_backup_is_missing() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + crate::codex_config::write_codex_live_atomic( + &json!({ "OPENAI_API_KEY": "sk-real" }), + Some("model = \"gpt-5.4\"\n"), + ) + .expect("seed live auth"); + let mut official = Provider::with_id( + crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": "" }), + None, + ); + official.category = Some("official".to_string()); + + service + .update_live_backup_from_provider_inner("codex", &official) + .await + .expect("rebuild backup"); + let backup = db + .get_live_backup("codex") + .await + .expect("read backup") + .expect("backup exists"); + let value: Value = serde_json::from_str(&backup.original_config).expect("parse backup"); + assert_eq!(value["auth"]["OPENAI_API_KEY"], "sk-real"); + } + + #[test] + #[serial] + fn codex_empty_restore_snapshot_does_not_delete_existing_auth_json() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db); + let auth = json!({ "OPENAI_API_KEY": "sk-real" }); + crate::codex_config::write_codex_live_atomic(&auth, Some("model = \"gpt-5.4\"\n")) + .expect("seed auth"); + + service + .write_codex_live_verbatim(&json!({ + "auth": {}, + "config": "model = \"gpt-5.4-mini\"\n" + })) + .expect("restore empty snapshot"); + + let live_auth: Value = + crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) + .expect("read auth"); + assert_eq!(live_auth, auth); + } + + #[tokio::test] + #[serial] + async fn codex_sync_live_does_not_store_credentials_in_builtin_official_row() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + let mut official = Provider::with_id( + crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": "" }), + None, + ); + official.category = Some("official".to_string()); + db.save_provider("codex", &official).expect("save official"); + db.set_current_provider("codex", crate::database::CODEX_OFFICIAL_PROVIDER_ID) + .expect("set current"); + crate::settings::set_current_provider( + &AppType::Codex, + Some(crate::database::CODEX_OFFICIAL_PROVIDER_ID), + ) + .expect("set local current"); + crate::codex_config::write_codex_live_atomic( + &json!({ "OPENAI_API_KEY": "sk-real" }), + Some("model = \"gpt-5.4\"\n"), + ) + .expect("seed live auth"); + + service + .sync_live_to_provider(&AppType::Codex) + .await + .expect("sync live"); + + let stored = db + .get_provider_by_id(crate::database::CODEX_OFFICIAL_PROVIDER_ID, "codex") + .expect("read official") + .expect("official exists"); + assert_eq!(stored.settings_config["auth"], json!({})); + } + #[tokio::test] #[serial] async fn codex_set_takeover_for_app_preserves_oauth_auth_json_when_preserve_enabled() { @@ -4036,7 +4277,7 @@ wire_api = "responses" #[tokio::test] #[serial] - async fn codex_takeover_preserve_disabled_uses_legacy_auth_write_path() { + async fn codex_takeover_preserves_native_auth_even_when_legacy_toggle_is_disabled() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); crate::settings::update_settings(crate::settings::AppSettings { @@ -4100,26 +4341,15 @@ wire_api = "responses" crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) .expect("read live auth"); assert_eq!( - live_auth - .get("OPENAI_API_KEY") - .and_then(|value| value.as_str()), - Some(PROXY_TOKEN_PLACEHOLDER), - "disabled preservation should keep the legacy auth.json takeover placeholder" - ); - assert_eq!( - live_auth - .get("tokens") - .and_then(|tokens| tokens.get("access_token")) - .and_then(|value| value.as_str()), - Some("oauth-access"), - "the new config-only takeover branch must not run when preservation is disabled" + live_auth, oauth_auth, + "takeover must preserve native OAuth independently of the legacy toggle" ); let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read live config"); assert!( - !live_config.contains(PROXY_TOKEN_PLACEHOLDER), - "disabled preservation should not move the takeover placeholder into config.toml" + live_config.contains(PROXY_TOKEN_PLACEHOLDER), + "third-party takeover should carry its local placeholder in config.toml" ); crate::settings::update_settings(crate::settings::AppSettings::default()) @@ -4291,7 +4521,8 @@ requires_openai_auth = true "#; let new_url = "http://127.0.0.1:5000/v1"; - let output = ProxyService::update_toml_base_url(input, new_url); + let output = crate::codex_config::update_codex_toml_field(input, "base_url", new_url) + .expect("update base_url"); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); @@ -4332,7 +4563,8 @@ wire_api = "chat" let proxy_url = "http://127.0.0.1:5000/v1"; let output = - ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None); + ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None) + .expect("apply proxy config"); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); @@ -4351,6 +4583,51 @@ wire_api = "chat" ); } + #[test] + fn apply_codex_proxy_toml_config_routes_builtin_official_with_native_auth() { + let mut provider = Provider::with_id( + "codex-official".to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": "" }), + None, + ); + provider.category = Some("official".to_string()); + let proxy_url = "http://127.0.0.1:5000/v1"; + + let output = ProxyService::apply_codex_proxy_toml_config_for_provider( + "experimental_bearer_token = \"PROXY_MANAGED\"\n", + proxy_url, + Some(&provider), + ) + .expect("apply official proxy config"); + let parsed: toml::Value = toml::from_str(&output).expect("valid official route"); + let route_id = crate::codex_config::CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID; + let route = &parsed["model_providers"][route_id]; + + assert_eq!(parsed["model_provider"].as_str(), Some(route_id)); + assert_eq!(route["base_url"].as_str(), Some(proxy_url)); + assert_eq!(route["requires_openai_auth"].as_bool(), Some(true)); + assert!(parsed.get("experimental_bearer_token").is_none()); + } + + #[test] + fn apply_codex_proxy_toml_config_fails_closed_for_invalid_official_config() { + let mut provider = Provider::with_id( + crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": "" }), + None, + ); + provider.category = Some("official".to_string()); + + let result = ProxyService::apply_codex_proxy_toml_config_for_provider( + "model_providers = 3\n", + "http://127.0.0.1:5000/v1", + Some(&provider), + ); + assert!(result.is_err()); + } + #[test] fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() { let input = r#" @@ -4380,7 +4657,8 @@ wire_api = "responses" input, proxy_url, Some(&provider), - ); + ) + .expect("apply chat proxy config"); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); @@ -4426,7 +4704,8 @@ wire_api = "responses" input, "http://127.0.0.1:5000/v1", Some(&provider), - ); + ) + .expect("apply responses proxy config"); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); @@ -4471,7 +4750,8 @@ wire_api = "responses" input, "http://127.0.0.1:5000/v1", Some(&provider), - ); + ) + .expect("restore responses model"); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); @@ -4488,7 +4768,8 @@ model = "gpt-5.1-codex" "#; let new_url = "http://127.0.0.1:5000/v1"; - let output = ProxyService::update_toml_base_url(input, new_url); + let output = crate::codex_config::update_codex_toml_field(input, "base_url", new_url) + .expect("update top-level base_url"); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index da11fa289..95efbb87f 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -673,8 +673,12 @@ pub fn create_tray_menu( for (id, provider) in sort_providers(&providers) { let is_current = current_id == *id; - let is_official_blocked = - is_app_taken_over && provider.category.as_deref() == Some("official"); + let is_official_blocked = is_app_taken_over + && provider.category.as_deref() == Some("official") + && !crate::services::provider::official_provider_supports_proxy_takeover( + §ion.app_type, + provider, + ); let label = if is_official_blocked { format!("{} \u{26D4}", &provider.name) // ⛔ emoji } else {