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")
+559
View File
@@ -0,0 +1,559 @@
//! Codex 第三方历史会话归桶迁移。
//!
//! 只迁移本机 `~/.codex` 历史数据;完成标记写入设备级 `settings.json`
//! 失败时不写标记,下一次启动自动重试。
use crate::codex_config::{
get_codex_config_dir, is_custom_codex_model_provider_id, read_codex_config_text,
stable_codex_model_provider_id_from_config, CC_SWITCH_CODEX_MODEL_PROVIDER_ID,
};
use crate::config::{atomic_write, copy_file, get_app_config_dir};
use crate::database::{is_official_seed_id, Database};
use crate::error::AppError;
use crate::settings::CodexThirdPartyHistoryProviderBucketMigration;
use chrono::{Local, Utc};
use rusqlite::{backup::Backup, params_from_iter, Connection};
use serde_json::Value;
use std::collections::{BTreeSet, HashSet};
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use toml_edit::DocumentMut;
const MIGRATION_NAME: &str = "codex-history-provider-migration-v1";
const CODEX_STATE_DB_FILENAME: &str = "state_5.sqlite";
#[derive(Debug, Clone, Default)]
pub struct CodexHistoryProviderBucketMigrationOutcome {
pub source_provider_ids: Vec<String>,
pub migrated_jsonl_files: usize,
pub migrated_state_rows: usize,
pub skipped_reason: Option<String>,
}
pub fn maybe_migrate_codex_third_party_history_provider_bucket(
db: &Database,
) -> Result<CodexHistoryProviderBucketMigrationOutcome, AppError> {
if crate::settings::is_codex_third_party_history_provider_bucket_migrated() {
return Ok(CodexHistoryProviderBucketMigrationOutcome {
skipped_reason: Some("already_migrated".to_string()),
..Default::default()
});
}
let source_provider_ids = collect_source_model_provider_ids(db)?;
if source_provider_ids.is_empty() {
crate::settings::mark_codex_third_party_history_provider_bucket_migrated(
CodexThirdPartyHistoryProviderBucketMigration {
completed_at: Utc::now().to_rfc3339(),
target_provider_id: CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string(),
source_provider_ids: Vec::new(),
migrated_jsonl_files: 0,
migrated_state_rows: 0,
},
)?;
return Ok(CodexHistoryProviderBucketMigrationOutcome {
skipped_reason: Some("no_third_party_provider_ids".to_string()),
..Default::default()
});
}
let backup_root = migration_backup_root();
let codex_dir = get_codex_config_dir();
let migrated_jsonl_files =
migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)?;
let migrated_state_rows =
migrate_codex_state_dbs(&codex_dir, &source_provider_ids, &backup_root)?;
let source_provider_ids_vec: Vec<String> = source_provider_ids.iter().cloned().collect();
crate::settings::mark_codex_third_party_history_provider_bucket_migrated(
CodexThirdPartyHistoryProviderBucketMigration {
completed_at: Utc::now().to_rfc3339(),
target_provider_id: CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string(),
source_provider_ids: source_provider_ids_vec.clone(),
migrated_jsonl_files,
migrated_state_rows,
},
)?;
Ok(CodexHistoryProviderBucketMigrationOutcome {
source_provider_ids: source_provider_ids_vec,
migrated_jsonl_files,
migrated_state_rows,
skipped_reason: None,
})
}
fn collect_source_model_provider_ids(db: &Database) -> Result<BTreeSet<String>, AppError> {
let providers = db.get_all_providers("codex")?;
let mut ids = BTreeSet::new();
for provider in providers.values() {
if provider.category.as_deref() == Some("official")
|| is_official_seed_id(&provider.id)
|| provider.is_codex_oauth()
{
continue;
}
if is_migratable_source_id(&provider.id) {
ids.insert(provider.id.clone());
}
let Some(config_text) = provider
.settings_config
.get("config")
.and_then(|value| value.as_str())
else {
continue;
};
if let Some(provider_id) = stable_codex_model_provider_id_from_config(config_text) {
if is_migratable_source_id(&provider_id) {
ids.insert(provider_id);
}
}
}
Ok(ids)
}
fn is_migratable_source_id(provider_id: &str) -> bool {
let trimmed = provider_id.trim();
!trimmed.is_empty()
&& trimmed != CC_SWITCH_CODEX_MODEL_PROVIDER_ID
&& is_custom_codex_model_provider_id(trimmed)
}
fn migration_backup_root() -> PathBuf {
get_app_config_dir()
.join("backups")
.join(MIGRATION_NAME)
.join(Local::now().format("%Y%m%d_%H%M%S").to_string())
}
fn migrate_codex_jsonl_files(
codex_dir: &Path,
source_provider_ids: &BTreeSet<String>,
backup_root: &Path,
) -> Result<usize, AppError> {
let mut files = Vec::new();
collect_jsonl_files(&codex_dir.join("sessions"), &mut files, 0, 8);
collect_jsonl_files(&codex_dir.join("archived_sessions"), &mut files, 0, 4);
let source_provider_ids: HashSet<String> = source_provider_ids.iter().cloned().collect();
let mut migrated = 0;
for file_path in files {
if rewrite_codex_session_file_for_provider_bucket(
&file_path,
codex_dir,
&source_provider_ids,
backup_root,
)? {
migrated += 1;
}
}
Ok(migrated)
}
fn collect_jsonl_files(dir: &Path, files: &mut Vec<PathBuf>, depth: u8, max_depth: u8) {
if depth > max_depth || !dir.is_dir() {
return;
}
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) => {
log::debug!(
"Failed to read Codex session directory {}: {err}",
dir.display()
);
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_jsonl_files(&path, files, depth + 1, max_depth);
} else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
files.push(path);
}
}
}
fn rewrite_codex_session_file_for_provider_bucket(
path: &Path,
codex_dir: &Path,
source_provider_ids: &HashSet<String>,
backup_root: &Path,
) -> Result<bool, AppError> {
let metadata_before = fs::metadata(path).map_err(|e| AppError::io(path, e))?;
let modified_before = metadata_before.modified().ok();
let len_before = metadata_before.len();
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
let mut rewritten = String::with_capacity(content.len());
let mut changed = false;
for segment in content.split_inclusive('\n') {
let (line, newline) = segment
.strip_suffix('\n')
.map(|line| (line, "\n"))
.unwrap_or((segment, ""));
if let Some(next_line) = rewrite_codex_session_meta_line(line, source_provider_ids) {
rewritten.push_str(&next_line);
changed = true;
} else {
rewritten.push_str(line);
}
rewritten.push_str(newline);
}
if !changed {
return Ok(false);
}
ensure_codex_session_file_unchanged(path, modified_before, len_before)?;
backup_codex_jsonl_file(path, codex_dir, backup_root)?;
ensure_codex_session_file_unchanged(path, modified_before, len_before)?;
atomic_write(path, rewritten.as_bytes())?;
Ok(true)
}
fn ensure_codex_session_file_unchanged(
path: &Path,
modified_before: Option<SystemTime>,
len_before: u64,
) -> Result<(), AppError> {
let metadata_after = fs::metadata(path).map_err(|e| AppError::io(path, e))?;
if metadata_after.modified().ok() != modified_before || metadata_after.len() != len_before {
return Err(AppError::Message(format!(
"Codex session file changed during migration: {}",
path.display()
)));
}
Ok(())
}
fn rewrite_codex_session_meta_line(
line: &str,
source_provider_ids: &HashSet<String>,
) -> Option<String> {
if !line.contains("\"session_meta\"") || !line.contains("\"model_provider\"") {
return None;
}
let mut value: Value = serde_json::from_str(line).ok()?;
if value.get("type").and_then(Value::as_str) != Some("session_meta") {
return None;
}
let payload = value.get_mut("payload")?.as_object_mut()?;
let current_provider = payload.get("model_provider")?.as_str()?;
if !source_provider_ids.contains(current_provider) {
return None;
}
payload.insert(
"model_provider".to_string(),
Value::String(CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string()),
);
serde_json::to_string(&value).ok()
}
fn migrate_codex_state_dbs(
codex_dir: &Path,
source_provider_ids: &BTreeSet<String>,
backup_root: &Path,
) -> Result<usize, AppError> {
let config_text = read_codex_config_text().unwrap_or_default();
let mut migrated = 0;
for db_path in codex_state_db_paths(codex_dir, &config_text) {
migrated += migrate_codex_state_db_provider_bucket(
&db_path,
codex_dir,
source_provider_ids,
backup_root,
)?;
}
Ok(migrated)
}
fn codex_state_db_paths(codex_dir: &Path, config_text: &str) -> Vec<PathBuf> {
let mut paths = vec![codex_dir.join(CODEX_STATE_DB_FILENAME)];
if let Some(sqlite_home) = sqlite_home_from_codex_config(config_text) {
let db_path = sqlite_home.join(CODEX_STATE_DB_FILENAME);
if !paths.contains(&db_path) {
paths.push(db_path);
}
}
paths
}
fn sqlite_home_from_codex_config(config_text: &str) -> Option<PathBuf> {
let doc = config_text.parse::<DocumentMut>().ok()?;
let raw = doc.get("sqlite_home")?.as_str()?.trim();
if raw.is_empty() {
return None;
}
Some(resolve_user_path(raw))
}
fn resolve_user_path(raw: &str) -> PathBuf {
if raw == "~" {
return crate::config::get_home_dir();
}
if let Some(rest) = raw.strip_prefix("~/") {
return crate::config::get_home_dir().join(rest);
}
if let Some(rest) = raw.strip_prefix("~\\") {
return crate::config::get_home_dir().join(rest);
}
PathBuf::from(raw)
}
fn migrate_codex_state_db_provider_bucket(
db_path: &Path,
codex_dir: &Path,
source_provider_ids: &BTreeSet<String>,
backup_root: &Path,
) -> Result<usize, AppError> {
if !db_path.exists() || source_provider_ids.is_empty() {
return Ok(0);
}
let conn = Connection::open(db_path)
.map_err(|e| AppError::Database(format!("打开 Codex state DB 失败: {e}")))?;
conn.busy_timeout(Duration::from_secs(5))
.map_err(|e| AppError::Database(format!("设置 Codex state DB busy_timeout 失败: {e}")))?;
if !Database::table_exists(&conn, "threads")?
|| !Database::has_column(&conn, "threads", "model_provider")?
{
return Ok(0);
}
let placeholders = placeholders(source_provider_ids.len());
let count_sql =
format!("SELECT COUNT(*) FROM threads WHERE model_provider IN ({placeholders})");
let matching_rows: i64 = conn
.query_row(
&count_sql,
params_from_iter(source_provider_ids.iter()),
|row| row.get(0),
)
.map_err(|e| AppError::Database(format!("统计 Codex state DB 待迁移行失败: {e}")))?;
if matching_rows == 0 {
return Ok(0);
}
backup_codex_state_db(db_path, codex_dir, backup_root, &conn)?;
let update_sql =
format!("UPDATE threads SET model_provider = ? WHERE model_provider IN ({placeholders})");
let mut values = Vec::with_capacity(source_provider_ids.len() + 1);
values.push(CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
values.extend(source_provider_ids.iter().cloned());
let changed = conn
.execute(&update_sql, params_from_iter(values.iter()))
.map_err(|e| AppError::Database(format!("迁移 Codex state DB provider 失败: {e}")))?;
Ok(changed)
}
fn placeholders(count: usize) -> String {
std::iter::repeat_n("?", count)
.collect::<Vec<_>>()
.join(", ")
}
fn backup_codex_jsonl_file(
path: &Path,
codex_dir: &Path,
backup_root: &Path,
) -> Result<(), AppError> {
let backup_path = backup_root
.join("jsonl")
.join(relative_backup_path(path, codex_dir));
copy_existing_file(path, &backup_path)
}
fn backup_codex_state_db(
db_path: &Path,
codex_dir: &Path,
backup_root: &Path,
source_conn: &Connection,
) -> Result<(), AppError> {
let backup_path = backup_root
.join("state")
.join(relative_backup_path(db_path, codex_dir));
if let Some(parent) = backup_path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let mut backup_conn = Connection::open(&backup_path)
.map_err(|e| AppError::Database(format!("创建 Codex state DB 备份失败: {e}")))?;
let backup = Backup::new(source_conn, &mut backup_conn)
.map_err(|e| AppError::Database(format!("初始化 Codex state DB 备份失败: {e}")))?;
backup
.run_to_completion(5, Duration::from_millis(25), None)
.map_err(|e| AppError::Database(format!("写入 Codex state DB 备份失败: {e}")))?;
Ok(())
}
fn copy_existing_file(source: &Path, target: &Path) -> Result<(), AppError> {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
copy_file(source, target)
}
fn relative_backup_path(path: &Path, root: &Path) -> PathBuf {
if let Ok(relative) = path.strip_prefix(root) {
return relative.to_path_buf();
}
let mut hasher = std::collections::hash_map::DefaultHasher::new();
path.hash(&mut hasher);
let hash = hasher.finish();
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "file".to_string());
PathBuf::from("external").join(format!("{hash:016x}-{file_name}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::Provider;
use tempfile::tempdir;
fn source_ids(values: &[&str]) -> BTreeSet<String> {
values.iter().map(|value| value.to_string()).collect()
}
#[test]
fn rewrites_only_codex_session_meta_provider_ids() {
let dir = tempdir().expect("tempdir");
let codex_dir = dir.path().join(".codex");
let backup_root = dir.path().join("backup");
let session_dir = codex_dir.join("sessions/2026/05/20");
fs::create_dir_all(&session_dir).expect("create session dir");
let path = session_dir.join("rollout-test.jsonl");
fs::write(
&path,
concat!(
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"rightcode\"}}\n",
"{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"hi\"}}\n"
),
)
.expect("write session");
let changed = rewrite_codex_session_file_for_provider_bucket(
&path,
&codex_dir,
&HashSet::from(["rightcode".to_string()]),
&backup_root,
)
.expect("rewrite");
assert!(changed);
let next = fs::read_to_string(&path).expect("read rewritten");
assert!(next.contains("\"model_provider\":\"custom\""));
assert!(backup_root
.join("jsonl/sessions/2026/05/20/rollout-test.jsonl")
.exists());
}
#[test]
fn updates_codex_state_db_thread_provider_ids() {
let dir = tempdir().expect("tempdir");
let codex_dir = dir.path().join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
let db_path = codex_dir.join(CODEX_STATE_DB_FILENAME);
let conn = Connection::open(&db_path).expect("open db");
conn.execute_batch(
"CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT NOT NULL
);
INSERT INTO threads (id, model_provider) VALUES
('a', 'rightcode'),
('b', 'openai'),
('c', 'aihubmix');",
)
.expect("seed db");
drop(conn);
let backup_root = dir.path().join("backup");
let changed = migrate_codex_state_db_provider_bucket(
&db_path,
&codex_dir,
&source_ids(&["rightcode", "aihubmix"]),
&backup_root,
)
.expect("migrate state db");
assert_eq!(changed, 2);
let conn = Connection::open(&db_path).expect("reopen db");
let custom_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM threads WHERE model_provider = 'custom'",
[],
|row| row.get(0),
)
.expect("count custom");
let openai_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM threads WHERE model_provider = 'openai'",
[],
|row| row.get(0),
)
.expect("count openai");
assert_eq!(custom_count, 2);
assert_eq!(openai_count, 1);
let backup_path = backup_root.join("state").join(CODEX_STATE_DB_FILENAME);
let backup_conn = Connection::open(&backup_path).expect("open backup db");
let backed_up_source_count: i64 = backup_conn
.query_row(
"SELECT COUNT(*) FROM threads WHERE model_provider IN ('rightcode', 'aihubmix')",
[],
|row| row.get(0),
)
.expect("count backed up source providers");
assert_eq!(backed_up_source_count, 2);
}
#[test]
fn collects_third_party_provider_ids_from_codex_providers() {
let db = Database::memory().expect("memory db");
let third_party = Provider::with_id(
"rightcode".to_string(),
"RightCode".to_string(),
serde_json::json!({
"auth": {},
"config": "model_provider = \"aihubmix\"\n\n[model_providers.aihubmix]\nname = \"AIHubMix\"\nbase_url = \"https://example.com/v1\""
}),
None,
);
let mut official = Provider::with_id(
"codex-official".to_string(),
"OpenAI Official".to_string(),
serde_json::json!({"auth": {}, "config": "model_provider = \"openai\""}),
None,
);
official.category = Some("official".to_string());
db.save_provider("codex", &third_party)
.expect("save third-party");
db.save_provider("codex", &official).expect("save official");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(ids.contains("rightcode"));
assert!(ids.contains("aihubmix"));
assert!(!ids.contains("openai"));
assert!(!ids.contains("codex-official"));
}
}
+52 -1
View File
@@ -21,6 +21,20 @@ fn merge_settings_for_save(
}
_ => {}
}
if incoming.local_migrations.is_none() {
incoming.local_migrations = existing.local_migrations.clone();
} else if let (Some(incoming_migrations), Some(existing_migrations)) =
(&mut incoming.local_migrations, &existing.local_migrations)
{
if incoming_migrations
.codex_third_party_history_provider_bucket_v1
.is_none()
{
incoming_migrations.codex_third_party_history_provider_bucket_v1 = existing_migrations
.codex_third_party_history_provider_bucket_v1
.clone();
}
}
incoming
}
@@ -83,7 +97,10 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
#[cfg(test)]
mod tests {
use super::merge_settings_for_save;
use crate::settings::{AppSettings, WebDavSyncSettings};
use crate::settings::{
AppSettings, CodexThirdPartyHistoryProviderBucketMigration, LocalMigrations,
WebDavSyncSettings,
};
#[test]
fn save_settings_should_preserve_existing_webdav_when_payload_omits_it() {
@@ -204,6 +221,40 @@ mod tests {
Some("")
);
}
#[test]
fn save_settings_should_preserve_local_migrations_when_payload_omits_it() {
let existing = AppSettings {
local_migrations: Some(LocalMigrations {
codex_third_party_history_provider_bucket_v1: Some(
CodexThirdPartyHistoryProviderBucketMigration {
completed_at: "2026-05-20T00:00:00Z".to_string(),
target_provider_id: "custom".to_string(),
source_provider_ids: vec!["rightcode".to_string()],
migrated_jsonl_files: 2,
migrated_state_rows: 3,
},
),
}),
..AppSettings::default()
};
let incoming = AppSettings::default();
let merged = merge_settings_for_save(incoming, &existing);
let migration = merged
.local_migrations
.as_ref()
.and_then(|migrations| {
migrations
.codex_third_party_history_provider_bucket_v1
.as_ref()
})
.expect("local migration marker should be preserved");
assert_eq!(migration.target_provider_id, "custom");
assert_eq!(migration.migrated_jsonl_files, 2);
assert_eq!(migration.migrated_state_rows, 3);
}
}
/// 获取开机自启状态
+1 -1
View File
@@ -32,7 +32,7 @@ mod schema;
mod tests;
// DAO 类型导出供外部使用
pub(crate) use dao::providers_seed::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID;
pub(crate) use dao::providers_seed::{is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID};
pub(crate) use dao::proxy::{
validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST,
PRICING_SOURCE_RESPONSE,
+26
View File
@@ -5,6 +5,7 @@ mod claude_desktop_config;
mod claude_mcp;
mod claude_plugin;
mod codex_config;
mod codex_history_migration;
mod commands;
mod config;
mod database;
@@ -535,6 +536,31 @@ pub fn run() {
Err(e) => log::warn!("✗ Failed to seed official providers: {e}"),
}
{
let db_for_codex_history_migration = app_state.db.clone();
tauri::async_runtime::spawn_blocking(move || {
match crate::codex_history_migration::maybe_migrate_codex_third_party_history_provider_bucket(
&db_for_codex_history_migration,
) {
Ok(outcome) => {
if let Some(reason) = outcome.skipped_reason {
log::debug!("○ Codex history provider bucket migration skipped: {reason}");
} else {
log::info!(
"✓ Codex history provider bucket migration completed: sources={}, jsonl_files={}, state_rows={}",
outcome.source_provider_ids.len(),
outcome.migrated_jsonl_files,
outcome.migrated_state_rows
);
}
}
Err(e) => {
log::warn!("✗ Codex history provider bucket migration failed: {e}");
}
}
});
}
// 老用户 / 已确认的路径由 `fresh_install_at_startup` 自行拦截,这里不做写入。
// 字段只由前端在用户点击"我知道了"时 save_settings 回写,语义是"用户显式确认过"。
if !first_run_already_confirmed && fresh_install_at_startup {
+4 -9
View File
@@ -1735,13 +1735,8 @@ impl ProxyService {
)?;
}
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}"))?;
}
@@ -3388,7 +3383,7 @@ requires_openai_auth = true
toml::from_str(backup_config).expect("parse backup config");
assert_eq!(
parsed_backup.get("model_provider").and_then(|v| v.as_str()),
Some("rightcode"),
Some("custom"),
"provider-derived restore backup should retain stable Codex model_provider"
);
let backup_model_providers = parsed_backup
@@ -3398,7 +3393,7 @@ requires_openai_auth = true
assert!(backup_model_providers.get("aihubmix").is_none());
assert_eq!(
backup_model_providers
.get("rightcode")
.get("custom")
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str()),
Some("https://aihubmix.example/v1"),
@@ -3418,7 +3413,7 @@ requires_openai_auth = true
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"),
Some("custom"),
"restored Codex live config should not switch history buckets"
);
assert_eq!(
@@ -3527,7 +3522,7 @@ requires_openai_auth = true
assert_eq!(
parsed_live.get("model_provider").and_then(|v| v.as_str()),
Some("stable")
Some("custom")
);
assert_eq!(
parsed_live.get("model").and_then(|v| v.as_str()),
+52
View File
@@ -176,6 +176,30 @@ impl WebDavSyncSettings {
}
}
/// 本机自动迁移状态。
///
/// 这里记录的是设备级操作(例如修改本机 `~/.codex` 文件),不随数据库同步。
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct LocalMigrations {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_third_party_history_provider_bucket_v1:
Option<CodexThirdPartyHistoryProviderBucketMigration>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexThirdPartyHistoryProviderBucketMigration {
pub completed_at: String,
pub target_provider_id: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source_provider_ids: Vec<String>,
#[serde(default)]
pub migrated_jsonl_files: usize,
#[serde(default)]
pub migrated_state_rows: usize,
}
/// 应用设置结构
///
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
@@ -301,6 +325,10 @@ pub struct AppSettings {
/// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preferred_terminal: Option<String>,
// ===== 本机自动迁移状态 =====
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_migrations: Option<LocalMigrations>,
}
fn default_show_in_tray() -> bool {
@@ -351,6 +379,7 @@ impl Default for AppSettings {
backup_interval_hours: None,
backup_retain_count: None,
preferred_terminal: None,
local_migrations: None,
}
}
}
@@ -556,6 +585,29 @@ where
Ok(())
}
pub fn is_codex_third_party_history_provider_bucket_migrated() -> bool {
get_settings()
.local_migrations
.as_ref()
.and_then(|migrations| {
migrations
.codex_third_party_history_provider_bucket_v1
.as_ref()
})
.is_some()
}
pub fn mark_codex_third_party_history_provider_bucket_migrated(
migration: CodexThirdPartyHistoryProviderBucketMigration,
) -> Result<(), AppError> {
mutate_settings(|settings| {
let migrations = settings
.local_migrations
.get_or_insert_with(Default::default);
migrations.codex_third_party_history_provider_bucket_v1 = Some(migration);
})
}
/// 从文件重新加载设置到内存缓存
/// 用于导入配置等场景,确保内存缓存与文件同步
pub fn reload_settings() -> Result<(), AppError> {
+3 -3
View File
@@ -309,8 +309,8 @@ requires_openai_auth = true
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"
Some("custom"),
"live Codex third-party model_provider should use the CC Switch history bucket"
);
let model_providers = parsed
@@ -323,7 +323,7 @@ requires_openai_auth = true
);
assert_eq!(
model_providers
.get("rightcode")
.get("custom")
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str()),
Some("https://aihubmix.example/v1"),