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.
This commit is contained in:
Jason
2026-07-12 17:51:56 +08:00
parent 13e7c1fcc4
commit 51d6c458ff
11 changed files with 835 additions and 179 deletions
+224 -2
View File
@@ -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<Value, AppError> {
/// 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<String, AppError> {
let mut doc = config_text
.parse::<DocumentMut>()
.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::<DocumentMut>()
.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<String, AppError> {
let mut doc = config_text
.parse::<DocumentMut>()
.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";
+8 -2
View File
@@ -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(),
+2 -1
View File
@@ -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",
+3 -1
View File
@@ -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,
+93 -5
View File
@@ -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) =
+3 -3
View File
@@ -666,7 +666,7 @@ impl ProviderAdapter for ClaudeAdapter {
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
// 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:
+39
View File
@@ -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<String, ProxyError> {
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!({
+6 -4
View File
@@ -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,
}
}
+12 -1
View File
@@ -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 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。",
+439 -158
View File
@@ -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(&current_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_urlauth.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<String, String> {
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");
+6 -2
View File
@@ -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(
&section.app_type,
provider,
);
let label = if is_official_blocked {
format!("{} \u{26D4}", &provider.name) // ⛔ emoji
} else {