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