feat(codex): migrate third-party history provider bucket to unified "custom" ID

Codex CLI filters conversations by model_provider — after normalizing
all third-party providers to "custom", old sessions with their original
provider IDs become invisible. This migration rewrites JSONL session
files and state_5.sqlite threads to use the unified ID.

Uses a three-tier trust model for source ID collection:
- Known legacy preset whitelist (46 IDs, case-insensitive)
- cc-switch-created custom providers (identified by created_at)
- User-maintained custom providers are explicitly skipped

Adds scanned_history_files flag to prevent stale v1 markers from
blocking re-runs with the improved source ID collection logic.
This commit is contained in:
Jason
2026-05-25 19:08:18 +08:00
parent 177eef6682
commit b15d9dfa11
3 changed files with 292 additions and 7 deletions
+288 -6
View File
@@ -23,6 +23,54 @@ use toml_edit::DocumentMut;
const MIGRATION_NAME: &str = "codex-history-provider-migration-v1";
const CODEX_STATE_DB_FILENAME: &str = "state_5.sqlite";
const CC_SWITCH_LEGACY_CODEX_MODEL_PROVIDER_IDS: &[&str] = &[
"aicodemirror",
"aicoding",
"aigocode",
"aihubmix",
"ark_agentplan",
"bailian",
"bailing",
"byteplus",
"claudecn",
"compshare",
"compshare_coding",
"crazyrouter",
"ctok",
"cubence",
"deepseek",
"dmxapi",
"doubaoseed",
"eflowcode",
"kimi",
"lemondata",
"longcat",
"micu",
"minimax",
"minimax_en",
"modelscope",
"novita",
"nvidia",
"openrouter",
"packycode",
"patewayai",
"pipellm",
"qianfan_coding",
"relaxycode",
"rightcode",
"runapi",
"shengsuanyun",
"siliconflow",
"siliconflow_en",
"sssaicode",
"stepfun",
"stepfun_en",
"therouter",
"xiaomi_mimo",
"xiaomi_mimo_token_plan",
"zhipu_glm",
"zhipu_glm_en",
];
#[derive(Debug, Clone, Default)]
pub struct CodexHistoryProviderBucketMigrationOutcome {
@@ -51,6 +99,7 @@ pub fn maybe_migrate_codex_third_party_history_provider_bucket(
source_provider_ids: Vec::new(),
migrated_jsonl_files: 0,
migrated_state_rows: 0,
scanned_history_files: true,
},
)?;
return Ok(CodexHistoryProviderBucketMigrationOutcome {
@@ -74,6 +123,7 @@ pub fn maybe_migrate_codex_third_party_history_provider_bucket(
source_provider_ids: source_provider_ids_vec.clone(),
migrated_jsonl_files,
migrated_state_rows,
scanned_history_files: true,
},
)?;
@@ -97,10 +147,13 @@ fn collect_source_model_provider_ids(db: &Database) -> Result<BTreeSet<String>,
continue;
}
if is_migratable_source_id(&provider.id) {
ids.insert(provider.id.clone());
let is_cc_switch_custom_provider = is_cc_switch_created_custom_provider(provider);
if provider.category.as_deref() == Some("custom") && !is_cc_switch_custom_provider {
continue;
}
insert_known_cc_switch_legacy_source_id(&mut ids, &provider.id);
let Some(config_text) = provider
.settings_config
.get("config")
@@ -110,15 +163,35 @@ fn collect_source_model_provider_ids(db: &Database) -> Result<BTreeSet<String>,
};
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);
if is_cc_switch_custom_provider {
insert_migratable_source_id(&mut ids, &provider_id);
} else {
insert_known_cc_switch_legacy_source_id(&mut ids, &provider_id);
}
}
if let Some(provider_id) =
legacy_codex_model_provider_id_from_normalized_config(config_text)
{
insert_migratable_source_id(&mut ids, &provider_id);
}
}
Ok(ids)
}
fn is_cc_switch_created_custom_provider(provider: &crate::provider::Provider) -> bool {
provider.category.as_deref() == Some("custom")
&& provider.created_at.is_some()
&& provider.id != "default"
}
fn insert_migratable_source_id(ids: &mut BTreeSet<String>, provider_id: &str) {
let trimmed = provider_id.trim();
if is_migratable_source_id(trimmed) {
ids.insert(trimmed.to_string());
}
}
fn is_migratable_source_id(provider_id: &str) -> bool {
let trimmed = provider_id.trim();
!trimmed.is_empty()
@@ -133,6 +206,56 @@ fn migration_backup_root() -> PathBuf {
.join(Local::now().format("%Y%m%d_%H%M%S").to_string())
}
fn insert_known_cc_switch_legacy_source_id(ids: &mut BTreeSet<String>, provider_id: &str) {
let trimmed = provider_id.trim();
if is_known_cc_switch_legacy_codex_model_provider_id(trimmed) {
insert_migratable_source_id(ids, trimmed);
}
}
fn is_known_cc_switch_legacy_codex_model_provider_id(provider_id: &str) -> bool {
CC_SWITCH_LEGACY_CODEX_MODEL_PROVIDER_IDS
.iter()
.any(|known| known.eq_ignore_ascii_case(provider_id))
}
fn legacy_codex_model_provider_id_from_normalized_config(config_text: &str) -> Option<String> {
let doc = config_text.parse::<DocumentMut>().ok()?;
let provider_id = doc
.get("model_provider")
.and_then(|item| item.as_str())
.map(str::trim)?;
if provider_id != CC_SWITCH_CODEX_MODEL_PROVIDER_ID {
return None;
}
let name = doc
.get("model_providers")
.and_then(|item| item.as_table())
.and_then(|table| table.get(CC_SWITCH_CODEX_MODEL_PROVIDER_ID))
.and_then(|item| item.as_table())
.and_then(|table| table.get("name"))
.and_then(|item| item.as_str())?
.trim();
normalized_legacy_codex_provider_name(name).map(str::to_string)
}
fn normalized_legacy_codex_provider_name(name: &str) -> Option<&'static str> {
if is_known_cc_switch_legacy_codex_model_provider_id(name) {
return CC_SWITCH_LEGACY_CODEX_MODEL_PROVIDER_IDS
.iter()
.copied()
.find(|known| known.eq_ignore_ascii_case(name));
}
match name {
"E-FlowCode" => Some("eflowcode"),
"PIPELLM" => Some("pipellm"),
_ => None,
}
}
fn migrate_codex_jsonl_files(
codex_dir: &Path,
source_provider_ids: &BTreeSet<String>,
@@ -323,7 +446,7 @@ fn migrate_codex_state_db_provider_bucket(
return Ok(0);
}
let conn = Connection::open(db_path)
let mut 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}")))?;
@@ -355,9 +478,14 @@ fn migrate_codex_state_db_provider_bucket(
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
let tx = conn
.transaction()
.map_err(|e| AppError::Database(format!("开启 Codex state DB 迁移事务失败: {e}")))?;
let changed = tx
.execute(&update_sql, params_from_iter(values.iter()))
.map_err(|e| AppError::Database(format!("迁移 Codex state DB provider 失败: {e}")))?;
tx.commit()
.map_err(|e| AppError::Database(format!("提交 Codex state DB 迁移事务失败: {e}")))?;
Ok(changed)
}
@@ -466,6 +594,78 @@ mod tests {
.exists());
}
#[test]
fn does_not_rewrite_unknown_jsonl_history_without_trusted_source_id() {
let dir = tempdir().expect("tempdir");
let codex_dir = dir.path().join(".codex");
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-rightcode.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 backup_root = dir.path().join("backup");
let changed = migrate_codex_jsonl_files(
&codex_dir,
&source_ids(&["some-trusted-provider"]),
&backup_root,
)
.expect("migrate jsonl");
assert_eq!(changed, 0);
let next = fs::read_to_string(&path).expect("read session");
assert!(next.contains("\"model_provider\":\"rightcode\""));
assert!(!backup_root.exists());
}
#[test]
fn does_not_update_unknown_state_db_history_without_trusted_source_id() {
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', 'aihubmix'),
('b', 'openai'),
('c', 'custom');",
)
.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"]),
&backup_root,
)
.expect("migrate state db");
assert_eq!(changed, 0);
let conn = Connection::open(&db_path).expect("reopen db");
let aihubmix_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM threads WHERE model_provider = 'aihubmix'",
[],
|row| row.get(0),
)
.expect("count aihubmix");
assert_eq!(aihubmix_count, 1);
assert!(!backup_root.exists());
}
#[test]
fn updates_codex_state_db_thread_provider_ids() {
let dir = tempdir().expect("tempdir");
@@ -556,4 +756,86 @@ mod tests {
assert!(!ids.contains("openai"));
assert!(!ids.contains("codex-official"));
}
#[test]
fn skips_unknown_non_custom_provider_model_provider_id() {
let db = Database::memory().expect("memory db");
let mut provider = Provider::with_id(
"manual-aggregator".to_string(),
"Manual Aggregator".to_string(),
serde_json::json!({
"auth": {},
"config": "model_provider = \"my-private-relay\"\n\n[model_providers.my-private-relay]\nname = \"Manual Relay\"\nbase_url = \"http://localhost:8080/v1\""
}),
None,
);
provider.category = Some("aggregator".to_string());
db.save_provider("codex", &provider).expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(ids.is_empty());
}
#[test]
fn collects_known_legacy_provider_id_from_normalized_preset_config() {
let db = Database::memory().expect("memory db");
let mut provider = Provider::with_id(
"generated-uuid".to_string(),
"AIHubMix".to_string(),
serde_json::json!({
"auth": {},
"config": "model_provider = \"custom\"\n\n[model_providers.custom]\nname = \"AIHubMix\"\nbase_url = \"https://aihubmix.example/v1\""
}),
None,
);
provider.category = Some("aggregator".to_string());
db.save_provider("codex", &provider).expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(ids.contains("aihubmix"));
assert!(!ids.contains("generated-uuid"));
}
#[test]
fn collects_custom_category_provider_when_created_by_cc_switch() {
let db = Database::memory().expect("memory db");
let mut provider = Provider::with_id(
"generated-uuid".to_string(),
"Manual Relay".to_string(),
serde_json::json!({
"auth": {},
"config": "model_provider = \"my-private-relay\"\n\n[model_providers.my-private-relay]\nname = \"Manual Relay\"\nbase_url = \"http://localhost:8080/v1\""
}),
None,
);
provider.category = Some("custom".to_string());
provider.created_at = Some(1);
db.save_provider("codex", &provider).expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(ids.contains("my-private-relay"));
}
#[test]
fn skips_custom_category_provider_even_when_config_has_custom_model_provider_id() {
let db = Database::memory().expect("memory db");
let mut provider = Provider::with_id(
"manual".to_string(),
"Manual Relay".to_string(),
serde_json::json!({
"auth": {},
"config": "model_provider = \"my-local-relay\"\n\n[model_providers.my-local-relay]\nname = \"Manual Relay\"\nbase_url = \"http://localhost:8080/v1\""
}),
None,
);
provider.category = Some("custom".to_string());
db.save_provider("codex", &provider).expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(ids.is_empty());
}
}
+1
View File
@@ -233,6 +233,7 @@ mod tests {
source_provider_ids: vec!["rightcode".to_string()],
migrated_jsonl_files: 2,
migrated_state_rows: 3,
scanned_history_files: true,
},
),
}),
+3 -1
View File
@@ -198,6 +198,8 @@ pub struct CodexThirdPartyHistoryProviderBucketMigration {
pub migrated_jsonl_files: usize,
#[serde(default)]
pub migrated_state_rows: usize,
#[serde(default)]
pub scanned_history_files: bool,
}
/// 应用设置结构
@@ -594,7 +596,7 @@ pub fn is_codex_third_party_history_provider_bucket_migrated() -> bool {
.codex_third_party_history_provider_bucket_v1
.as_ref()
})
.is_some()
.is_some_and(|m| m.scanned_history_files)
}
pub fn mark_codex_third_party_history_provider_bucket_migrated(