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
+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");