From a1e6c3b65de4ccc2a91f54c5ca8564f331ccaf44 Mon Sep 17 00:00:00 2001 From: SaladDay <92240037+SaladDay@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:54:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Codex=20=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E5=90=8E=E5=8E=86=E5=8F=B2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=8F=98=E5=8C=96=20(#2349)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Keep Codex history stable across provider switches * Restore template Codex provider id when backfilling live config Backfill writes the current Codex live config back to the previous provider's stored template after a switch. Because the live file now carries a normalized stable model_provider id, the previous provider's template would lose its own provider-specific id (and any matching [profiles.*] references) on every subsequent switch. Reverse the normalization at backfill time by rewriting model_provider, the active model_providers section, and matching profile references back to the template's original id. --------- Co-authored-by: Jason --- src-tauri/src/codex_config.rs | 511 ++++++++++++++++++++++++ src-tauri/src/services/config.rs | 2 +- src-tauri/src/services/provider/live.rs | 69 +++- src-tauri/src/services/proxy.rs | 168 +++++++- src-tauri/tests/import_export_sync.rs | 87 ++++ src-tauri/tests/provider_service.rs | 235 +++++++++++ 6 files changed, 1043 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 98f6d06a..af02c1b3 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -11,6 +11,20 @@ use std::fs; use std::path::Path; use toml_edit::DocumentMut; +pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch"; + +/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider +/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy +/// removed provider aliases. +const CODEX_RESERVED_MODEL_PROVIDER_IDS: &[&str] = &[ + "amazon-bedrock", + "openai", + "ollama", + "lmstudio", + "oss", + "ollama-chat", +]; + /// 获取 Codex 配置目录路径 pub fn get_codex_config_dir() -> PathBuf { if let Some(custom) = crate::settings::get_codex_override_dir() { @@ -137,6 +151,268 @@ pub fn read_and_validate_codex_config_text() -> Result { Ok(s) } +fn active_codex_model_provider_id(doc: &DocumentMut) -> Option { + doc.get("model_provider") + .and_then(|item| item.as_str()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) +} + +fn is_custom_codex_model_provider_id(id: &str) -> bool { + let id = id.trim(); + !id.is_empty() + && !CODEX_RESERVED_MODEL_PROVIDER_IDS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(id)) +} + +fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option { + let doc = config_text.parse::().ok()?; + let provider_id = active_codex_model_provider_id(&doc)?; + + if is_custom_codex_model_provider_id(&provider_id) { + Some(provider_id) + } else { + None + } +} + +fn codex_model_provider_id_with_table_from_config( + config_text: &str, +) -> Result, AppError> { + if config_text.trim().is_empty() { + return Ok(None); + } + + let doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + let Some(provider_id) = active_codex_model_provider_id(&doc) else { + return Ok(None); + }; + + let has_provider_table = doc + .get("model_providers") + .and_then(|item| item.as_table()) + .and_then(|table| table.get(provider_id.as_str())) + .is_some(); + + Ok(has_provider_table.then_some(provider_id)) +} + +fn normalize_codex_live_config_model_provider_with_anchors<'a>( + config_text: &str, + anchor_config_texts: impl IntoIterator, +) -> Result { + if config_text.trim().is_empty() { + return Ok(config_text.to_string()); + } + + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + + let Some(source_provider_id) = active_codex_model_provider_id(&doc) else { + return Ok(config_text.to_string()); + }; + + let has_source_provider_table = doc + .get("model_providers") + .and_then(|item| item.as_table()) + .and_then(|table| table.get(source_provider_id.as_str())) + .is_some(); + if !has_source_provider_table { + return Ok(config_text.to_string()); + } + + let stable_provider_id = anchor_config_texts + .into_iter() + .find_map(stable_codex_model_provider_id_from_config) + .or_else(|| { + is_custom_codex_model_provider_id(&source_provider_id) + .then(|| source_provider_id.clone()) + }) + .unwrap_or_else(|| CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string()); + + if stable_provider_id == source_provider_id { + return Ok(config_text.to_string()); + } + + if let Some(model_providers) = doc + .get_mut("model_providers") + .and_then(|item| item.as_table_mut()) + { + let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else { + return Ok(config_text.to_string()); + }; + model_providers[stable_provider_id.as_str()] = provider_table; + } + + rewrite_codex_profile_model_provider_refs(&mut doc, &source_provider_id, &stable_provider_id); + doc["model_provider"] = toml_edit::value(stable_provider_id.as_str()); + + Ok(doc.to_string()) +} + +fn rewrite_codex_profile_model_provider_refs( + doc: &mut DocumentMut, + source_provider_id: &str, + stable_provider_id: &str, +) { + let Some(profiles) = doc + .get_mut("profiles") + .and_then(|item| item.as_table_like_mut()) + else { + return; + }; + + let profile_keys: Vec = profiles.iter().map(|(key, _)| key.to_string()).collect(); + for profile_key in profile_keys { + let Some(profile_table) = profiles + .get_mut(&profile_key) + .and_then(|item| item.as_table_like_mut()) + else { + continue; + }; + + let references_source = profile_table + .get("model_provider") + .and_then(|item| item.as_str()) + == Some(source_provider_id); + if references_source { + profile_table.insert("model_provider", toml_edit::value(stable_provider_id)); + } + } +} + +/// Keep Codex's active `model_provider` stable across CC Switch provider changes. +/// +/// Codex stores and filters resume history by `model_provider`, so switching between +/// provider-specific ids like `rightcode` and `aihubmix` makes history appear to move. +/// We preserve an existing custom provider id when possible and only rewrite the +/// live config text that Codex sees at provider-driven write boundaries. +pub fn normalize_codex_settings_config_model_provider( + settings: &mut Value, + anchor_config_text: Option<&str>, +) -> Result<(), AppError> { + let Some(config_text) = settings + .get("config") + .and_then(|value| value.as_str()) + .map(str::to_string) + else { + return Ok(()); + }; + + let current_config_text = read_codex_config_text().ok(); + let anchors = anchor_config_text + .into_iter() + .chain(current_config_text.as_deref()); + let normalized = + normalize_codex_live_config_model_provider_with_anchors(&config_text, anchors)?; + + if let Some(obj) = settings.as_object_mut() { + obj.insert("config".to_string(), Value::String(normalized)); + } + + Ok(()) +} + +fn restore_codex_backfill_model_provider_id( + config_text: &str, + template_config_text: &str, +) -> Result { + let Some(template_provider_id) = + codex_model_provider_id_with_table_from_config(template_config_text)? + else { + return Ok(config_text.to_string()); + }; + + if config_text.trim().is_empty() { + return Ok(config_text.to_string()); + } + + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + let Some(live_provider_id) = active_codex_model_provider_id(&doc) else { + return Ok(config_text.to_string()); + }; + + if live_provider_id == template_provider_id { + return Ok(config_text.to_string()); + } + + if let Some(model_providers) = doc + .get_mut("model_providers") + .and_then(|item| item.as_table_mut()) + { + let Some(provider_table) = model_providers.remove(live_provider_id.as_str()) else { + return Ok(config_text.to_string()); + }; + model_providers[template_provider_id.as_str()] = provider_table; + } else { + return Ok(config_text.to_string()); + } + + rewrite_codex_profile_model_provider_refs(&mut doc, &live_provider_id, &template_provider_id); + doc["model_provider"] = toml_edit::value(template_provider_id.as_str()); + + Ok(doc.to_string()) +} + +/// Convert a Codex live config that was normalized for history stability back +/// to the provider-specific id used by the stored provider template. +pub fn restore_codex_settings_config_model_provider_for_backfill( + settings: &mut Value, + template_settings: &Value, +) -> Result<(), AppError> { + let Some(config_text) = settings + .get("config") + .and_then(|value| value.as_str()) + .map(str::to_string) + else { + return Ok(()); + }; + let Some(template_config_text) = template_settings + .get("config") + .and_then(|value| value.as_str()) + else { + return Ok(()); + }; + + let restored = restore_codex_backfill_model_provider_id(&config_text, template_config_text)?; + if let Some(obj) = settings.as_object_mut() { + obj.insert("config".to_string(), Value::String(restored)); + } + + Ok(()) +} + +/// Atomically write Codex live config after normalizing provider-specific ids. +/// +/// Use this for provider-driven live writes. Keep `write_codex_live_atomic` available +/// for exact restore/backup paths that must preserve the config text byte-for-byte. +pub fn write_codex_live_atomic_with_stable_provider( + auth: &Value, + config_text_opt: Option<&str>, +) -> Result<(), AppError> { + match config_text_opt { + Some(config_text) => { + let mut settings = serde_json::Map::new(); + settings.insert("config".to_string(), Value::String(config_text.to_string())); + let mut settings = Value::Object(settings); + normalize_codex_settings_config_model_provider(&mut settings, None)?; + let config_text = settings + .get("config") + .and_then(|value| value.as_str()) + .unwrap_or(config_text); + write_codex_live_atomic(auth, Some(config_text)) + } + None => write_codex_live_atomic(auth, None), + } +} + /// Update a field in Codex config.toml using toml_edit (syntax-preserving). /// /// Supported fields: @@ -254,6 +530,241 @@ pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) -> mod tests { use super::*; + #[test] + fn normalize_live_config_preserves_current_custom_model_provider_id() { + let current = r#"model_provider = "rightcode" + +[model_providers.rightcode] +name = "RightCode" +base_url = "https://rightcode.example/v1" +wire_api = "responses" +"#; + let target = r#"model_provider = "aihubmix" +model = "gpt-5.4" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +requires_openai_auth = true + +[mcp_servers.context7] +command = "npx" +"#; + + let result = + normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap(); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + + assert_eq!( + parsed.get("model_provider").and_then(|v| v.as_str()), + Some("rightcode") + ); + + let model_providers = parsed + .get("model_providers") + .and_then(|v| v.as_table()) + .expect("model_providers should exist"); + assert!( + model_providers.get("aihubmix").is_none(), + "source provider id should not remain in live config" + ); + + let stable_provider = model_providers + .get("rightcode") + .expect("stable provider table should exist"); + assert_eq!( + stable_provider.get("base_url").and_then(|v| v.as_str()), + Some("https://aihubmix.example/v1") + ); + assert!( + parsed.get("mcp_servers").is_some(), + "unrelated config should be preserved" + ); + } + + #[test] + fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() { + let current = r#"model_provider = "openai""#; + let target = r#"model_provider = "aihubmix" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +"#; + + let result = + normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap(); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + + assert_eq!( + parsed.get("model_provider").and_then(|v| v.as_str()), + Some("aihubmix") + ); + assert!( + parsed + .get("model_providers") + .and_then(|v| v.get("aihubmix")) + .is_some(), + "target provider id should be kept when there is no reusable live custom id" + ); + } + + #[test] + fn normalize_live_config_leaves_official_empty_config_unchanged() { + let current = r#"model_provider = "rightcode" + +[model_providers.rightcode] +base_url = "https://rightcode.example/v1" +"#; + + let result = + normalize_codex_live_config_model_provider_with_anchors("", Some(current)).unwrap(); + + assert_eq!(result, ""); + } + + #[test] + fn normalize_live_config_rewrites_matching_profile_model_provider_refs() { + let current = r#"model_provider = "session_anchor" + +[model_providers.session_anchor] +name = "Session Anchor" +base_url = "https://anchor.example/v1" +wire_api = "responses" +"#; + let target = r#"model_provider = "vendor_alpha" +model = "gpt-5.4" +profile = "work" + +[model_providers.vendor_alpha] +name = "Vendor Alpha" +base_url = "https://alpha.example/v1" +wire_api = "responses" + +[profiles.work] +model_provider = "vendor_alpha" +model = "gpt-5.4" +"#; + + let result = + normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap(); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + + assert_eq!( + parsed.get("model_provider").and_then(|v| v.as_str()), + Some("session_anchor") + ); + assert_eq!( + parsed + .get("profiles") + .and_then(|v| v.get("work")) + .and_then(|v| v.get("model_provider")) + .and_then(|v| v.as_str()), + Some("session_anchor"), + "profile override matching the rewritten provider should stay valid" + ); + } + + #[test] + fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() { + let current = r#"model_provider = "session_anchor" + +[model_providers.session_anchor] +name = "Session Anchor" +base_url = "https://anchor.example/v1" +wire_api = "responses" +"#; + let target = r#"model_provider = "vendor_alpha" +model = "gpt-5.4" + +[model_providers.vendor_alpha] +name = "Vendor Alpha" +base_url = "https://alpha.example/v1" +wire_api = "responses" + +[model_providers.local_profile] +name = "Local Profile" +base_url = "http://localhost:11434/v1" +wire_api = "responses" + +[profiles.local] +model_provider = "local_profile" +model = "local-model" +"#; + + let result = + normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap(); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + + assert_eq!( + parsed + .get("profiles") + .and_then(|v| v.get("local")) + .and_then(|v| v.get("model_provider")) + .and_then(|v| v.as_str()), + Some("local_profile"), + "unrelated profile provider references should be preserved" + ); + assert!( + parsed + .get("model_providers") + .and_then(|v| v.get("local_profile")) + .is_some(), + "unrelated provider tables should also remain available" + ); + } + + #[test] + fn normalize_live_config_keeps_stable_provider_across_repeated_switches() { + let anchor = r#"model_provider = "session_anchor" + +[model_providers.session_anchor] +name = "Session Anchor" +base_url = "https://anchor.example/v1" +wire_api = "responses" +"#; + let first_target = r#"model_provider = "vendor_alpha" + +[model_providers.vendor_alpha] +name = "Vendor Alpha" +base_url = "https://alpha.example/v1" +wire_api = "responses" +"#; + let second_target = r#"model_provider = "vendor_beta" + +[model_providers.vendor_beta] +name = "Vendor Beta" +base_url = "https://beta.example/v1" +wire_api = "responses" +"#; + + let first = + normalize_codex_live_config_model_provider_with_anchors(first_target, Some(anchor)) + .unwrap(); + let second = normalize_codex_live_config_model_provider_with_anchors( + second_target, + Some(first.as_str()), + ) + .unwrap(); + let parsed: toml::Value = toml::from_str(&second).unwrap(); + + assert_eq!( + parsed.get("model_provider").and_then(|v| v.as_str()), + Some("session_anchor"), + "stable provider id should not drift across repeated switches" + ); + assert_eq!( + parsed + .get("model_providers") + .and_then(|v| v.get("session_anchor")) + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()), + Some("https://beta.example/v1") + ); + } + #[test] fn base_url_writes_into_correct_model_provider_section() { let input = r#"model_provider = "any" diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 7c116c31..ec282f37 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -156,7 +156,7 @@ impl ConfigService { } let cfg_text = settings.get("config").and_then(Value::as_str); - crate::codex_config::write_codex_live_atomic(auth, cfg_text)?; + crate::codex_config::write_codex_live_atomic_with_stable_provider(auth, cfg_text)?; // 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用 // sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空 // MCP 的启用/禁用应通过 McpService::toggle_app 进行 diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 2127b51d..488e88fb 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -8,7 +8,9 @@ use serde_json::{json, Value}; use toml_edit::{DocumentMut, Item, TableLike}; use crate::app_config::AppType; -use crate::codex_config::{get_codex_auth_path, get_codex_config_path}; +use crate::codex_config::{ + get_codex_auth_path, get_codex_config_path, write_codex_live_atomic_with_stable_provider, +}; use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file}; use crate::database::Database; use crate::error::AppError; @@ -528,29 +530,55 @@ pub(crate) fn strip_common_config_from_live_settings( app_type.as_str(), provider.id ); - return live_settings; + return restore_live_settings_for_provider_backfill(app_type, provider, live_settings); } }; - if !provider_uses_common_config(app_type, provider, snippet.as_deref()) { - return live_settings; - } - - let Some(snippet_text) = snippet.as_deref() else { - return live_settings; + let backfill_settings = if provider_uses_common_config(app_type, provider, snippet.as_deref()) { + match snippet.as_deref() { + Some(snippet_text) => { + match remove_common_config_from_settings(app_type, &live_settings, snippet_text) { + Ok(settings) => settings, + Err(err) => { + log::warn!( + "Failed to strip common config for {} provider '{}': {err}", + app_type.as_str(), + provider.id + ); + live_settings + } + } + } + None => live_settings, + } + } else { + live_settings }; - match remove_common_config_from_settings(app_type, &live_settings, snippet_text) { - Ok(settings) => settings, - Err(err) => { - log::warn!( - "Failed to strip common config for {} provider '{}': {err}", - app_type.as_str(), - provider.id - ); - live_settings - } + restore_live_settings_for_provider_backfill(app_type, provider, backfill_settings) +} + +fn restore_live_settings_for_provider_backfill( + app_type: &AppType, + provider: &Provider, + live_settings: Value, +) -> Value { + if !matches!(app_type, AppType::Codex) { + return live_settings; } + + let mut settings = live_settings; + if let Err(err) = crate::codex_config::restore_codex_settings_config_model_provider_for_backfill( + &mut settings, + &provider.settings_config, + ) { + log::warn!( + "Failed to restore Codex provider id while backfilling '{}': {err}", + provider.id + ); + } + + settings } pub(crate) fn normalize_provider_common_config_for_storage( @@ -683,10 +711,7 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string()) })?; - let auth_path = get_codex_auth_path(); - write_json_file(&auth_path, auth)?; - let config_path = get_codex_config_path(); - std::fs::write(&config_path, config_str).map_err(|e| AppError::io(&config_path, e))?; + write_codex_live_atomic_with_stable_provider(auth, Some(config_str))?; } AppType::Gemini => { // Delegate to write_gemini_live which handles env file writing correctly diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 0683153b..2b7d2c41 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1476,20 +1476,33 @@ impl ProxyService { .map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?; if matches!(app_type_enum, AppType::Codex) { - let existing_backup = self + let existing_backup_value = self .db .get_live_backup(app_type) .await - .map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?; + .map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))? + .map(|backup| { + serde_json::from_str::(&backup.original_config) + .map_err(|e| format!("解析 {app_type} 现有备份失败: {e}")) + }) + .transpose()?; - if let Some(existing_backup) = existing_backup { - let existing_value: Value = serde_json::from_str(&existing_backup.original_config) - .map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))?; + if let Some(existing_value) = existing_backup_value.as_ref() { Self::preserve_codex_mcp_servers_in_backup( &mut effective_settings, - &existing_value, + existing_value, )?; } + + let anchor_config_text = existing_backup_value + .as_ref() + .and_then(|value| value.get("config")) + .and_then(|value| value.as_str()); + crate::codex_config::normalize_codex_settings_config_model_provider( + &mut effective_settings, + anchor_config_text, + ) + .map_err(|e| format!("归一化 Codex restore backup 失败: {e}"))?; } let backup_json = match app_type_enum { @@ -1749,6 +1762,8 @@ impl ProxyService { 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)) => write_codex_live_atomic(auth, Some(cfg)) .map_err(|e| format!("写入 Codex 配置失败: {e}"))?, @@ -2693,6 +2708,147 @@ base_url = "https://new.example/v1" ); } + #[tokio::test] + #[serial] + async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() { + 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 provider_a = Provider::with_id( + "a".to_string(), + "RightCode".to_string(), + json!({ + "auth": { + "OPENAI_API_KEY": "rightcode-key" + }, + "config": r#"model_provider = "rightcode" +model = "gpt-5.4" + +[model_providers.rightcode] +name = "RightCode" +base_url = "https://rightcode.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + }), + None, + ); + let provider_b = Provider::with_id( + "b".to_string(), + "AiHubMix".to_string(), + json!({ + "auth": { + "OPENAI_API_KEY": "aihubmix-key" + }, + "config": r#"model_provider = "aihubmix" +model = "gpt-5.4" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + }), + None, + ); + + db.save_provider("codex", &provider_a) + .expect("save provider a"); + db.save_provider("codex", &provider_b) + .expect("save provider b"); + db.set_current_provider("codex", "a") + .expect("set current provider"); + crate::settings::set_current_provider(&AppType::Codex, Some("a")) + .expect("set local current provider"); + db.save_live_backup( + "codex", + &serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"), + ) + .await + .expect("seed live backup"); + service + .write_codex_live(&json!({ + "auth": { + "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER + }, + "config": r#"model_provider = "rightcode" +model = "gpt-5.4" + +[model_providers.rightcode] +name = "RightCode" +base_url = "http://127.0.0.1:15721/v1" +wire_api = "responses" +requires_openai_auth = true +"# + })) + .expect("seed taken-over Codex live config"); + + service + .hot_switch_provider("codex", "b") + .await + .expect("hot switch Codex provider"); + + let backup = db + .get_live_backup("codex") + .await + .expect("get live backup") + .expect("backup exists"); + let stored: Value = + serde_json::from_str(&backup.original_config).expect("parse backup json"); + let backup_config = stored + .get("config") + .and_then(|v| v.as_str()) + .expect("backup config string"); + let parsed_backup: toml::Value = + toml::from_str(backup_config).expect("parse backup config"); + assert_eq!( + parsed_backup.get("model_provider").and_then(|v| v.as_str()), + Some("rightcode"), + "provider-derived restore backup should retain stable Codex model_provider" + ); + let backup_model_providers = parsed_backup + .get("model_providers") + .and_then(|v| v.as_table()) + .expect("backup model_providers"); + assert!(backup_model_providers.get("aihubmix").is_none()); + assert_eq!( + backup_model_providers + .get("rightcode") + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()), + Some("https://aihubmix.example/v1"), + "stable provider id should point at the hot-switched provider endpoint" + ); + + service + .restore_live_config_for_app_with_fallback(&AppType::Codex) + .await + .expect("restore Codex live config"); + + let live = service.read_codex_live().expect("read Codex live config"); + let live_config = live + .get("config") + .and_then(|v| v.as_str()) + .expect("live config string"); + let parsed_live: toml::Value = toml::from_str(live_config).expect("parse live config"); + assert_eq!( + parsed_live.get("model_provider").and_then(|v| v.as_str()), + Some("rightcode"), + "restored Codex live config should not switch history buckets" + ); + assert_eq!( + live.get("auth") + .and_then(|auth| auth.get("OPENAI_API_KEY")) + .and_then(|v| v.as_str()), + Some("aihubmix-key"), + "restore should still use the hot-switched provider auth" + ); + } + #[tokio::test] #[serial] async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() { diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index 2be08faf..7fb9e5aa 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -139,6 +139,93 @@ fn sync_codex_provider_writes_auth_and_config() { assert_eq!(synced_cfg, toml_text); } +#[test] +fn sync_codex_provider_preserves_live_model_provider_id_for_history() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" }); + let legacy_config = r#"model_provider = "rightcode" +model = "gpt-5.4" + +[model_providers.rightcode] +name = "RightCode" +base_url = "https://rightcode.example/v1" +wire_api = "responses" +requires_openai_auth = true +"#; + cc_switch_lib::write_codex_live_atomic(&legacy_auth, Some(legacy_config)) + .expect("seed existing Codex live config"); + + let mut config = MultiAppConfig::default(); + let provider_config = json!({ + "auth": { + "OPENAI_API_KEY": "fresh-key" + }, + "config": r#"model_provider = "aihubmix" +model = "gpt-5.4" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + }); + + let provider = Provider::with_id( + "codex-1".to_string(), + "Codex Test".to_string(), + provider_config, + None, + ); + + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.providers.insert("codex-1".to_string(), provider); + manager.current = "codex-1".to_string(); + + ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live"); + + let toml_text = + fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml"); + let parsed: toml::Value = toml::from_str(&toml_text).expect("parse config.toml"); + + assert_eq!( + parsed.get("model_provider").and_then(|v| v.as_str()), + Some("rightcode"), + "legacy ConfigService sync should use the stable live provider id" + ); + + let model_providers = parsed + .get("model_providers") + .and_then(|v| v.as_table()) + .expect("model_providers should exist"); + assert!( + model_providers.get("aihubmix").is_none(), + "provider-specific target id should not be written to live config" + ); + assert_eq!( + model_providers + .get("rightcode") + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()), + Some("https://aihubmix.example/v1") + ); + + let synced_cfg = config + .get_manager(&AppType::Codex) + .and_then(|manager| manager.providers.get("codex-1")) + .and_then(|provider| provider.settings_config.get("config")) + .and_then(|v| v.as_str()) + .expect("synced config string"); + assert!( + synced_cfg.contains("[model_providers.rightcode]"), + "ConfigService keeps its existing behavior of syncing provider config from live" + ); +} + #[test] fn sync_enabled_to_codex_writes_enabled_servers() { let _guard = test_mutex().lock().expect("acquire test mutex"); diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index d9679f92..bb963be6 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -239,6 +239,241 @@ command = "say" ); } +#[test] +fn provider_service_switch_codex_preserves_live_model_provider_id_for_history() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" }); + let legacy_config = r#"model_provider = "rightcode" +model = "gpt-5.4" + +[model_providers.rightcode] +name = "RightCode" +base_url = "https://rightcode.example/v1" +wire_api = "responses" +requires_openai_auth = true +"#; + write_codex_live_atomic(&legacy_auth, Some(legacy_config)) + .expect("seed existing codex live config"); + + let mut initial_config = MultiAppConfig::default(); + { + let manager = initial_config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "old-provider".to_string(); + manager.providers.insert( + "old-provider".to_string(), + Provider::with_id( + "old-provider".to_string(), + "RightCode".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "stale"}, + "config": legacy_config + }), + None, + ), + ); + manager.providers.insert( + "new-provider".to_string(), + Provider::with_id( + "new-provider".to_string(), + "AiHubMix".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "fresh-key"}, + "config": r#"model_provider = "aihubmix" +model = "gpt-5.4" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + }), + None, + ), + ); + } + + let state = create_test_state_with_config(&initial_config).expect("create test state"); + + ProviderService::switch(&state, AppType::Codex, "new-provider") + .expect("switch provider should succeed"); + + let config_text = + std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml"); + let parsed: toml::Value = toml::from_str(&config_text).expect("parse config.toml"); + + assert_eq!( + parsed.get("model_provider").and_then(|v| v.as_str()), + Some("rightcode"), + "live Codex model_provider should stay stable so resume history remains visible" + ); + + let model_providers = parsed + .get("model_providers") + .and_then(|v| v.as_table()) + .expect("model_providers table exists"); + assert!( + model_providers.get("aihubmix").is_none(), + "target provider-specific id should be rewritten in live config" + ); + assert_eq!( + model_providers + .get("rightcode") + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()), + Some("https://aihubmix.example/v1"), + "stable provider id should point at the newly selected supplier endpoint" + ); + + let providers = state + .db + .get_all_providers(AppType::Codex.as_str()) + .expect("read providers after switch"); + let new_config_text = providers + .get("new-provider") + .expect("new provider exists") + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert!( + new_config_text.contains("[model_providers.aihubmix]"), + "stored provider template should remain provider-specific" + ); +} + +#[test] +fn provider_service_switch_codex_backfill_keeps_provider_specific_model_provider_id() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" }); + let provider_a_config = r#"model_provider = "rightcode" +model = "gpt-5.4" + +[model_providers.rightcode] +name = "RightCode" +base_url = "https://rightcode.example/v1" +wire_api = "responses" +requires_openai_auth = true +"#; + write_codex_live_atomic(&legacy_auth, Some(provider_a_config)) + .expect("seed existing codex live config"); + + let mut initial_config = MultiAppConfig::default(); + { + let manager = initial_config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "provider-a".to_string(); + manager.providers.insert( + "provider-a".to_string(), + Provider::with_id( + "provider-a".to_string(), + "RightCode".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "rightcode-key"}, + "config": provider_a_config + }), + None, + ), + ); + manager.providers.insert( + "provider-b".to_string(), + Provider::with_id( + "provider-b".to_string(), + "AiHubMix".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "aihubmix-key"}, + "config": r#"model_provider = "aihubmix" +model = "gpt-5.4" +profile = "work" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +requires_openai_auth = true + +[profiles.work] +model_provider = "aihubmix" +model = "gpt-5.4" +"# + }), + None, + ), + ); + manager.providers.insert( + "provider-c".to_string(), + Provider::with_id( + "provider-c".to_string(), + "Vendor C".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "vendor-c-key"}, + "config": r#"model_provider = "vendor_c" +model = "gpt-5.4" + +[model_providers.vendor_c] +name = "Vendor C" +base_url = "https://vendor-c.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + }), + None, + ), + ); + } + + let state = create_test_state_with_config(&initial_config).expect("create test state"); + + ProviderService::switch(&state, AppType::Codex, "provider-b") + .expect("switch to provider b should succeed"); + ProviderService::switch(&state, AppType::Codex, "provider-c") + .expect("switch to provider c should succeed"); + + let providers = state + .db + .get_all_providers(AppType::Codex.as_str()) + .expect("read providers after switches"); + let provider_b_config = providers + .get("provider-b") + .expect("provider b exists") + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .expect("provider b config"); + let parsed: toml::Value = toml::from_str(provider_b_config).expect("parse provider b config"); + + assert_eq!( + parsed.get("model_provider").and_then(|v| v.as_str()), + Some("aihubmix"), + "backfill should restore provider b's storage-specific model_provider id" + ); + assert!( + parsed + .get("model_providers") + .and_then(|v| v.get("aihubmix")) + .is_some(), + "provider b should keep its own model_providers table after backfill" + ); + assert_eq!( + parsed + .get("profiles") + .and_then(|v| v.get("work")) + .and_then(|v| v.get("model_provider")) + .and_then(|v| v.as_str()), + Some("aihubmix"), + "profile overrides should be restored to provider b's storage-specific id" + ); +} + #[test] fn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup() { let _guard = test_mutex().lock().expect("acquire test mutex");