mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 02:14:43 +08:00
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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user