mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-06 22:01:44 +08:00
修复 Codex 切换供应商后历史记录变化 (#2349)
* 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 <farion1231@gmail.com>
This commit is contained in:
@@ -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<String, AppError> {
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn active_codex_model_provider_id(doc: &DocumentMut) -> Option<String> {
|
||||
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<String> {
|
||||
let doc = config_text.parse::<DocumentMut>().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<Option<String>, AppError> {
|
||||
if config_text.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.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<Item = &'a str>,
|
||||
) -> Result<String, AppError> {
|
||||
if config_text.trim().is_empty() {
|
||||
return Ok(config_text.to_string());
|
||||
}
|
||||
|
||||
let mut doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.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<String> = 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<String, AppError> {
|
||||
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::<DocumentMut>()
|
||||
.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"
|
||||
|
||||
@@ -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 进行
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::<Value>(&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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user