feat(codex): preserve OAuth login state during third-party provider switching

Codex provider switches now only write config.toml for third-party providers,
injecting the API key as experimental_bearer_token. The user's auth.json
(ChatGPT OAuth tokens) is preserved. Official providers with login material
still write auth.json normally. Backfill restores bearer tokens into stored
provider auth.OPENAI_API_KEY to maintain canonical shape.
This commit is contained in:
Jason
2026-05-25 17:46:23 +08:00
parent 88ba908bc4
commit 95f2dd4126
17 changed files with 1664 additions and 154 deletions
+26 -5
View File
@@ -159,8 +159,9 @@ impl ConfigService {
}
let cfg_text = settings.get("config").and_then(Value::as_str);
crate::codex_config::write_codex_live_with_catalog(
crate::codex_config::write_codex_provider_live_with_catalog(
&provider.settings_config,
provider.category.as_deref(),
auth,
cfg_text,
)?;
@@ -172,10 +173,30 @@ impl ConfigService {
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
if let Some(target) = manager.providers.get_mut(provider_id) {
if let Some(obj) = target.settings_config.as_object_mut() {
obj.insert(
"config".to_string(),
serde_json::Value::String(cfg_text_after),
);
let mut restored = serde_json::json!({
"auth": auth.clone(),
"config": cfg_text_after,
});
let restore_provider_token =
crate::codex_config::should_restore_codex_provider_token_for_backfill(
provider.category.as_deref(),
&provider.settings_config,
);
crate::codex_config::restore_codex_settings_for_backfill(
&mut restored,
&provider.settings_config,
restore_provider_token,
)?;
// 必须同时写回 auth 和 config: backfill 会恢复 stored provider id
// 并把 live 的 experimental_bearer_token 移到 restored.auth.OPENAI_API_KEY。
if let Some(restored_obj) = restored.as_object() {
if let Some(auth_value) = restored_obj.get("auth") {
obj.insert("auth".to_string(), auth_value.clone());
}
if let Some(config_value) = restored_obj.get("config") {
obj.insert("config".to_string(), config_value.clone());
}
}
}
}
}
+38 -28
View File
@@ -580,12 +580,18 @@ fn restore_live_settings_for_provider_backfill(
}
let mut settings = live_settings;
if let Err(err) = crate::codex_config::restore_codex_settings_config_model_provider_for_backfill(
let restore_provider_token =
crate::codex_config::should_restore_codex_provider_token_for_backfill(
provider.category.as_deref(),
&provider.settings_config,
);
if let Err(err) = crate::codex_config::restore_codex_settings_for_backfill(
&mut settings,
&provider.settings_config,
restore_provider_token,
) {
log::warn!(
"Failed to restore Codex provider id while backfilling '{}': {err}",
"Failed to restore Codex settings while backfilling '{}': {err}",
provider.id
);
}
@@ -728,8 +734,9 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
let config_str = obj.get("config").and_then(|v| v.as_str());
crate::codex_config::write_codex_live_with_catalog(
crate::codex_config::write_codex_provider_live_with_catalog(
&provider.settings_config,
provider.category.as_deref(),
auth,
config_str,
)?;
@@ -951,17 +958,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
match app_type {
AppType::Codex => {
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return Err(AppError::localized(
"codex.auth.missing",
"Codex 配置文件不存在:缺少 auth.json",
"Codex configuration missing: auth.json not found",
));
}
let auth: Value = read_json_file(&auth_path)?;
let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?;
let mut result = json!({ "auth": auth, "config": cfg_text });
let mut result = crate::codex_config::read_codex_live_settings()?;
// `modelCatalog` is a cc-switch private field that lives only in
// the DB SSOT plus the `cc-switch-model-catalog.json` projection
// file — it is never inlined into `auth.json` or `config.toml`.
@@ -1091,19 +1088,7 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
}
let settings_config = match app_type {
AppType::Codex => {
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return Err(AppError::localized(
"codex.live.missing",
"Codex 配置文件不存在",
"Codex configuration file is missing",
));
}
let auth: Value = read_json_file(&auth_path)?;
let config_str = crate::codex_config::read_and_validate_codex_config_text()?;
json!({ "auth": auth, "config": config_str })
}
AppType::Codex => crate::codex_config::read_codex_live_settings()?,
AppType::Claude => {
let settings_path = get_claude_settings_path();
if !settings_path.exists() {
@@ -1169,7 +1154,32 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
settings_config,
None,
);
provider.category = Some("custom".to_string());
provider.category = Some(
if matches!(app_type, AppType::Codex) {
let config_text = provider
.settings_config
.get("config")
.and_then(Value::as_str);
let has_provider_key = crate::codex_config::extract_codex_api_key(
provider.settings_config.get("auth"),
config_text,
)
.is_some();
let has_login_material = provider
.settings_config
.get("auth")
.is_some_and(crate::codex_config::codex_auth_has_login_material);
if has_login_material && !has_provider_key {
"official"
} else {
"custom"
}
} else {
"custom"
}
.to_string(),
);
state.db.save_provider(app_type.as_str(), &provider)?;
state
+13 -13
View File
@@ -2244,7 +2244,7 @@ impl ProviderService {
Ok((credentials.api_key, credentials.base_url))
}
AppType::Codex => {
let auth = provider
let _auth = provider
.settings_config
.get("auth")
.and_then(|v| v.as_object())
@@ -2256,24 +2256,24 @@ impl ProviderService {
)
})?;
let api_key = auth
.get("OPENAI_API_KEY")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AppError::localized(
"provider.codex.api_key.missing",
"缺少 API Key",
"API key is missing",
)
})?
.to_string();
let config_toml = provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.unwrap_or("");
let api_key = crate::codex_config::extract_codex_api_key(
provider.settings_config.get("auth"),
Some(config_toml),
)
.ok_or_else(|| {
AppError::localized(
"provider.codex.api_key.missing",
"缺少 API Key",
"API key is missing",
)
})?;
let base_url = if config_toml.contains("base_url") {
let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| {
AppError::localized(
+126 -31
View File
@@ -357,7 +357,7 @@ impl ProxyService {
effective_settings["config"] = json!(updated_config);
Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));
self.write_codex_live(&effective_settings)?;
self.write_codex_live_for_provider(&effective_settings, Some(provider))?;
Ok(())
}
@@ -1190,7 +1190,7 @@ impl ProxyService {
codex_provider.as_ref(),
);
self.write_codex_live(&live_config)?;
self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;
log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}");
}
@@ -1257,7 +1257,7 @@ impl ProxyService {
codex_provider.as_ref(),
);
self.write_codex_live(&live_config)?;
self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;
log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}");
}
AppType::Gemini => {
@@ -1336,7 +1336,8 @@ impl ProxyService {
codex_provider.as_ref(),
);
let _ = self.write_codex_live(&live_config);
let _ =
self.write_codex_live_for_provider(&live_config, codex_provider.as_ref());
}
}
AppType::Gemini => {
@@ -2045,43 +2046,55 @@ impl ProxyService {
}
fn read_codex_live(&self) -> Result<Value, String> {
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return Err("Codex auth.json 不存在".to_string());
}
let auth: Value =
read_json_file(&auth_path).map_err(|e| format!("读取 Codex auth 失败: {e}"))?;
let config_path = get_codex_config_path();
let config_str = if config_path.exists() {
std::fs::read_to_string(&config_path)
.map_err(|e| format!("读取 Codex config 失败: {e}"))?
} else {
String::new()
};
Ok(json!({
"auth": auth,
"config": config_str
}))
crate::codex_config::read_codex_live_settings()
.map_err(|e| format!("读取 Codex Live 配置失败: {e}"))
}
fn write_codex_live(&self, config: &Value) -> Result<(), String> {
self.write_codex_live_verbatim(config)
}
fn write_codex_live_for_provider(
&self,
config: &Value,
provider: Option<&Provider>,
) -> Result<(), String> {
let Some(provider) = provider else {
return self.write_codex_live_verbatim(config);
};
let auth = config
.get("auth")
.ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?;
let config_str = config.get("config").and_then(|v| v.as_str());
crate::codex_config::write_codex_provider_live_with_catalog(
config,
provider.category.as_deref(),
auth,
config_str,
)
.map_err(|e| format!("写入 Codex 配置失败: {e}"))
}
fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> {
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
let auth = config.get("auth");
let config_str = config.get("config").and_then(|v| v.as_str());
// Proxy restore writes saved live backups verbatim. Provider-driven writes go
// through write_live_with_common_config(), which normalizes Codex provider ids.
match (auth, config_str) {
(Some(auth), Some(cfg)) => {
// Use unified helper to prepare catalog if present
crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg))
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
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);
let config_path = get_codex_config_path();
crate::config::write_text_file(&config_path, cfg)
.map_err(|e| format!("写入 Codex config 失败: {e}"))?;
} else {
crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg))
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
}
}
(Some(auth), None) => {
let auth_path = get_codex_auth_path();
@@ -2547,6 +2560,88 @@ mod tests {
);
}
#[test]
#[serial]
fn codex_custom_provider_live_write_preserves_oauth_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 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(
r#"model_provider = "openai"
model = "gpt-5-codex"
"#,
),
)
.expect("seed live OAuth auth");
let mut provider = Provider::with_id(
"rightcode".to_string(),
"RightCode".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "rightcode-key"
},
"config": r#"model_provider = "rightcode"
model = "gpt-5-codex"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
"#
}),
None,
);
provider.category = Some("custom".to_string());
let takeover_settings = json!({
"auth": {
"OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER
},
"config": r#"model_provider = "rightcode"
model = "gpt-5-codex"
[model_providers.rightcode]
name = "RightCode"
base_url = "http://127.0.0.1:15721/v1"
wire_api = "responses"
"#
});
service
.write_codex_live_for_provider(&takeover_settings, Some(&provider))
.expect("write provider-driven Codex live config");
let live_auth: Value =
crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())
.expect("read live auth");
assert_eq!(
live_auth, oauth_auth,
"third-party Codex proxy writes must not overwrite ChatGPT OAuth login state"
);
let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())
.expect("read live config");
assert!(
live_config.contains("experimental_bearer_token"),
"proxy placeholder should move into config.toml instead of auth.json"
);
assert!(
live_config.contains(PROXY_TOKEN_PLACEHOLDER),
"live config should carry the proxy placeholder token"
);
}
#[test]
fn update_toml_base_url_updates_active_model_provider_base_url() {
let input = r#"