mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 10:25:05 +08:00
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:
@@ -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
@@ -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<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");
|
||||
|
||||
Reference in New Issue
Block a user