feat: unify Codex third-party providers into stable "custom" history bucket

Codex filters resume history by `model_provider`, so switching between
provider-specific ids like `rightcode` and `aihubmix` made past sessions
appear to vanish. Collapse all third-party providers into a single
stable bucket so cross-switch history stays visible.

- Normalize live `model_provider` to "custom" on every Codex write
  (reserved built-in ids like openai/ollama are preserved).
- Add device-level one-shot migration that rewrites historical JSONL
  session files and the `state_5.sqlite` threads table from legacy
  provider ids into the "custom" bucket. Backs up originals under
  `~/.cc-switch/backups/codex-history-provider-migration-v1/` and uses
  the SQLite Backup API for the state DB.
- Record completion in `settings.json` under `localMigrations` so the
  migration is strictly idempotent across launches.
- Update Codex provider preset templates to emit `model_provider = "custom"`
  out of the box.
This commit is contained in:
Jason
2026-05-20 17:10:38 +08:00
parent 2a4651a21e
commit b44f83f7c5
11 changed files with 771 additions and 121 deletions
+36 -90
View File
@@ -12,7 +12,7 @@ use std::path::Path;
use std::process::Command;
use toml_edit::DocumentMut;
pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom";
pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json";
const CODEX_MODEL_CATALOG_TEMPLATE_SLUG: &str = "gpt-5.5";
@@ -166,7 +166,7 @@ fn active_codex_model_provider_id(doc: &DocumentMut) -> Option<String> {
.map(str::to_string)
}
fn is_custom_codex_model_provider_id(id: &str) -> bool {
pub(crate) fn is_custom_codex_model_provider_id(id: &str) -> bool {
let id = id.trim();
!id.is_empty()
&& !CODEX_RESERVED_MODEL_PROVIDER_IDS
@@ -174,7 +174,7 @@ fn is_custom_codex_model_provider_id(id: &str) -> bool {
.any(|reserved| reserved.eq_ignore_ascii_case(id))
}
fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option<String> {
pub(crate) 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)?;
@@ -208,10 +208,7 @@ fn codex_model_provider_id_with_table_from_config(
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> {
fn normalize_codex_live_config_model_provider(config_text: &str) -> Result<String, AppError> {
if config_text.trim().is_empty() {
return Ok(config_text.to_string());
}
@@ -232,15 +229,11 @@ fn normalize_codex_live_config_model_provider_with_anchors<'a>(
if !has_source_provider_table {
return Ok(config_text.to_string());
}
if !is_custom_codex_model_provider_id(&source_provider_id) {
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());
let stable_provider_id = CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string();
if stable_provider_id == source_provider_id {
return Ok(config_text.to_string());
@@ -297,11 +290,10 @@ fn rewrite_codex_profile_model_provider_refs(
///
/// 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.
/// CC Switch-managed third-party providers share one stable bucket while official
/// built-in providers such as `openai` keep their original identity.
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")
@@ -311,12 +303,7 @@ pub fn normalize_codex_settings_config_model_provider(
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)?;
let normalized = normalize_codex_live_config_model_provider(&config_text)?;
if let Some(obj) = settings.as_object_mut() {
obj.insert("config".to_string(), Value::String(normalized));
@@ -409,7 +396,7 @@ pub fn write_codex_live_atomic_with_stable_provider(
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)?;
normalize_codex_settings_config_model_provider(&mut settings)?;
let config_text = settings
.get("config")
.and_then(|value| value.as_str())
@@ -798,14 +785,7 @@ 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"
"#;
fn normalize_live_config_uses_custom_for_third_party_model_provider_id() {
let target = r#"model_provider = "aihubmix"
model = "gpt-5.4"
@@ -819,13 +799,12 @@ requires_openai_auth = true
command = "npx"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let result = normalize_codex_live_config_model_provider(target).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("rightcode")
Some("custom")
);
let model_providers = parsed
@@ -838,7 +817,7 @@ command = "npx"
);
let stable_provider = model_providers
.get("rightcode")
.get("custom")
.expect("stable provider table should exist");
assert_eq!(
stable_provider.get("base_url").and_then(|v| v.as_str()),
@@ -851,8 +830,7 @@ command = "npx"
}
#[test]
fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() {
let current = r#"model_provider = "openai""#;
fn normalize_live_config_uses_custom_for_custom_provider_even_without_anchor() {
let target = r#"model_provider = "aihubmix"
[model_providers.aihubmix]
@@ -861,46 +839,31 @@ base_url = "https://aihubmix.example/v1"
wire_api = "responses"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let result = normalize_codex_live_config_model_provider(target).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("aihubmix")
Some("custom")
);
assert!(
parsed
.get("model_providers")
.and_then(|v| v.get("aihubmix"))
.and_then(|v| v.get("custom"))
.is_some(),
"target provider id should be kept when there is no reusable live custom id"
"third-party provider id should be normalized to custom"
);
}
#[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();
let result = normalize_codex_live_config_model_provider("").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"
@@ -915,13 +878,12 @@ model_provider = "vendor_alpha"
model = "gpt-5.4"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let result = normalize_codex_live_config_model_provider(target).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")
Some("custom")
);
assert_eq!(
parsed
@@ -929,20 +891,13 @@ model = "gpt-5.4"
.and_then(|v| v.get("work"))
.and_then(|v| v.get("model_provider"))
.and_then(|v| v.as_str()),
Some("session_anchor"),
Some("custom"),
"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"
@@ -961,8 +916,7 @@ model_provider = "local_profile"
model = "local-model"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let result = normalize_codex_live_config_model_provider(target).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
@@ -984,14 +938,7 @@ model = "local-model"
}
#[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"
"#;
fn normalize_live_config_keeps_custom_across_repeated_switches() {
let first_target = r#"model_provider = "vendor_alpha"
[model_providers.vendor_alpha]
@@ -1007,25 +954,24 @@ 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 first = normalize_codex_live_config_model_provider(first_target).unwrap();
let second = normalize_codex_live_config_model_provider(second_target).unwrap();
let first_parsed: toml::Value = toml::from_str(&first).unwrap();
let parsed: toml::Value = toml::from_str(&second).unwrap();
assert_eq!(
first_parsed.get("model_provider").and_then(|v| v.as_str()),
Some("custom")
);
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("session_anchor"),
Some("custom"),
"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("custom"))
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str()),
Some("https://beta.example/v1")