mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
fc0433f2f4
- Always emit `model_provider = "custom"` from deep link, UniversalProvider, and the universal form modal so future writes share one stable routing key. - Add `codex_provider_template_v1` local migration that rewrites legacy keys (aihubmix/ccswitch/...) under `[model_providers.custom]`, updates profile refs, and backs up the original settings_config under `~/.cc-switch/backups/<timestamp>/providers/`. - Tighten history migration source detection to a whitelist plus `[model_providers.<id>]` existence check so user-authored keys are never rewritten in jsonl/state DB. - Encode deep link name/model/endpoint through `toml_edit::Value` so display names containing quotes or backslashes no longer break the generated config.toml. - Stabilize provider settings backup filename hash with Sha256 (was process-random SipHash).
1737 lines
58 KiB
Rust
1737 lines
58 KiB
Rust
//! Codex 第三方历史会话归桶迁移。
|
||
//!
|
||
//! 只迁移本机 `~/.codex` 历史数据;完成标记写入设备级 `settings.json`,
|
||
//! 失败时不写标记,下一次启动自动重试。
|
||
|
||
use crate::codex_config::{
|
||
get_codex_config_dir, read_codex_config_text, 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::{
|
||
CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration,
|
||
};
|
||
use chrono::{Local, Utc};
|
||
use rusqlite::{backup::Backup, params_from_iter, Connection};
|
||
use serde_json::Value;
|
||
use sha2::{Digest, Sha256};
|
||
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";
|
||
const LEGACY_CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
|
||
// If a Codex preset ever used a temporary routing key, keep that old key here
|
||
// so local history can be bucketed under the current custom provider id.
|
||
const CC_SWITCH_LEGACY_CODEX_MODEL_PROVIDER_IDS: &[&str] = &[
|
||
LEGACY_CC_SWITCH_CODEX_MODEL_PROVIDER_ID,
|
||
"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 {
|
||
pub source_provider_ids: Vec<String>,
|
||
pub migrated_jsonl_files: usize,
|
||
pub migrated_state_rows: usize,
|
||
pub skipped_reason: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct CodexProviderTemplateBucketMigrationOutcome {
|
||
pub migrated_provider_ids: Vec<String>,
|
||
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,
|
||
scanned_history_files: true,
|
||
},
|
||
)?;
|
||
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,
|
||
scanned_history_files: true,
|
||
},
|
||
)?;
|
||
|
||
Ok(CodexHistoryProviderBucketMigrationOutcome {
|
||
source_provider_ids: source_provider_ids_vec,
|
||
migrated_jsonl_files,
|
||
migrated_state_rows,
|
||
skipped_reason: None,
|
||
})
|
||
}
|
||
|
||
pub fn maybe_migrate_codex_provider_template_bucket(
|
||
db: &Database,
|
||
) -> Result<CodexProviderTemplateBucketMigrationOutcome, AppError> {
|
||
if crate::settings::is_codex_provider_template_migrated() {
|
||
return Ok(CodexProviderTemplateBucketMigrationOutcome {
|
||
skipped_reason: Some("already_migrated".to_string()),
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
let backup_root = migration_backup_root();
|
||
let outcome = migrate_codex_provider_templates_to_custom(db, &backup_root)?;
|
||
crate::settings::mark_codex_provider_template_migrated(CodexProviderTemplateMigration {
|
||
completed_at: Utc::now().to_rfc3339(),
|
||
migrated_provider_ids: outcome.migrated_provider_ids.clone(),
|
||
})?;
|
||
|
||
Ok(outcome)
|
||
}
|
||
|
||
fn migrate_codex_provider_templates_to_custom(
|
||
db: &Database,
|
||
backup_root: &Path,
|
||
) -> Result<CodexProviderTemplateBucketMigrationOutcome, AppError> {
|
||
let providers = db.get_all_providers("codex")?;
|
||
let mut migrated_provider_ids = Vec::new();
|
||
|
||
for (_, provider) in providers {
|
||
if provider.category.as_deref() == Some("official")
|
||
|| is_official_seed_id(&provider.id)
|
||
|| provider.is_codex_oauth()
|
||
{
|
||
continue;
|
||
}
|
||
|
||
let Some(config_text) = provider
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|value| value.as_str())
|
||
else {
|
||
continue;
|
||
};
|
||
|
||
let Some(migrated_config_text) = migrate_provider_config_template_to_custom(config_text)?
|
||
else {
|
||
continue;
|
||
};
|
||
|
||
let mut settings = provider.settings_config.clone();
|
||
let Some(obj) = settings.as_object_mut() else {
|
||
log::warn!(
|
||
"Skipping Codex provider template migration for {}: settings_config is not an object",
|
||
provider.id
|
||
);
|
||
continue;
|
||
};
|
||
backup_provider_settings_config(&provider.id, &provider.settings_config, backup_root)?;
|
||
obj.insert("config".to_string(), Value::String(migrated_config_text));
|
||
db.update_provider_settings_config("codex", &provider.id, &settings)?;
|
||
migrated_provider_ids.push(provider.id);
|
||
}
|
||
|
||
Ok(CodexProviderTemplateBucketMigrationOutcome {
|
||
migrated_provider_ids,
|
||
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;
|
||
}
|
||
|
||
insert_known_cc_switch_legacy_source_id(&mut ids, &provider.id);
|
||
|
||
let Some(config_text) = provider
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|value| value.as_str())
|
||
else {
|
||
continue;
|
||
};
|
||
|
||
for provider_id in trusted_legacy_codex_model_provider_ids_from_config(config_text) {
|
||
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_known_cc_switch_legacy_source_id(&mut ids, &provider_id);
|
||
}
|
||
}
|
||
|
||
Ok(ids)
|
||
}
|
||
|
||
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) {
|
||
ids.insert(trimmed.to_string());
|
||
}
|
||
}
|
||
|
||
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 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
|
||
&& provider_id != LEGACY_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(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 trusted_legacy_codex_model_provider_ids_from_config(config_text: &str) -> BTreeSet<String> {
|
||
let Ok(doc) = config_text.parse::<DocumentMut>() else {
|
||
return BTreeSet::new();
|
||
};
|
||
|
||
trusted_legacy_codex_model_provider_ids_from_doc(&doc)
|
||
}
|
||
|
||
fn trusted_legacy_codex_model_provider_ids_from_doc(doc: &DocumentMut) -> BTreeSet<String> {
|
||
let mut ids = BTreeSet::new();
|
||
insert_trusted_legacy_config_model_provider_id(&mut ids, doc, doc.get("model_provider"));
|
||
|
||
if let Some(profiles) = doc.get("profiles").and_then(|item| item.as_table_like()) {
|
||
for (_, profile_item) in profiles.iter() {
|
||
if let Some(profile_table) = profile_item.as_table_like() {
|
||
insert_trusted_legacy_config_model_provider_id(
|
||
&mut ids,
|
||
doc,
|
||
profile_table.get("model_provider"),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
ids
|
||
}
|
||
|
||
fn insert_trusted_legacy_config_model_provider_id(
|
||
ids: &mut BTreeSet<String>,
|
||
doc: &DocumentMut,
|
||
item: Option<&toml_edit::Item>,
|
||
) {
|
||
let Some(provider_id) = item.and_then(|item| item.as_str()).map(str::trim) else {
|
||
return;
|
||
};
|
||
if provider_id.is_empty()
|
||
|| !is_known_cc_switch_legacy_codex_model_provider_id(provider_id)
|
||
|| !config_defines_model_provider(doc, provider_id)
|
||
{
|
||
return;
|
||
}
|
||
ids.insert(provider_id.to_string());
|
||
}
|
||
|
||
fn config_defines_model_provider(doc: &DocumentMut, provider_id: &str) -> bool {
|
||
doc.get("model_providers")
|
||
.and_then(|item| item.as_table())
|
||
.and_then(|table| table.get(provider_id))
|
||
.and_then(|item| item.as_table())
|
||
.is_some()
|
||
}
|
||
|
||
fn migrate_provider_config_template_to_custom(
|
||
config_text: &str,
|
||
) -> Result<Option<String>, AppError> {
|
||
if config_text.trim().is_empty() {
|
||
return Ok(None);
|
||
}
|
||
|
||
let mut doc = config_text
|
||
.parse::<DocumentMut>()
|
||
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
|
||
|
||
let source_provider_ids = trusted_legacy_codex_model_provider_ids_from_doc(&doc);
|
||
if source_provider_ids.is_empty() {
|
||
return Ok(None);
|
||
}
|
||
|
||
let active_provider_id = doc
|
||
.get("model_provider")
|
||
.and_then(|item| item.as_str())
|
||
.map(str::trim)
|
||
.filter(|provider_id| !provider_id.is_empty())
|
||
.map(str::to_string);
|
||
|
||
let custom_table_exists =
|
||
config_defines_model_provider(&doc, CC_SWITCH_CODEX_MODEL_PROVIDER_ID);
|
||
let source_provider_id_to_move = active_provider_id
|
||
.as_deref()
|
||
.filter(|provider_id| source_provider_ids.contains(*provider_id))
|
||
.map(str::to_string)
|
||
.or_else(|| {
|
||
if custom_table_exists {
|
||
None
|
||
} else {
|
||
source_provider_ids.iter().next().cloned()
|
||
}
|
||
});
|
||
|
||
let mut changed = false;
|
||
|
||
if let Some(source_provider_id) = source_provider_id_to_move {
|
||
let Some(model_providers) = doc
|
||
.get_mut("model_providers")
|
||
.and_then(|item| item.as_table_mut())
|
||
else {
|
||
return Ok(None);
|
||
};
|
||
|
||
let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
|
||
return Ok(None);
|
||
};
|
||
model_providers[CC_SWITCH_CODEX_MODEL_PROVIDER_ID] = provider_table;
|
||
changed = true;
|
||
}
|
||
|
||
if active_provider_id
|
||
.as_deref()
|
||
.is_some_and(|provider_id| source_provider_ids.contains(provider_id))
|
||
{
|
||
doc["model_provider"] = toml_edit::value(CC_SWITCH_CODEX_MODEL_PROVIDER_ID);
|
||
changed = true;
|
||
}
|
||
|
||
for source_provider_id in source_provider_ids {
|
||
if rewrite_legacy_provider_profile_refs(&mut doc, source_provider_id.as_str()) {
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
if changed {
|
||
Ok(Some(doc.to_string()))
|
||
} else {
|
||
Ok(None)
|
||
}
|
||
}
|
||
|
||
fn rewrite_legacy_provider_profile_refs(doc: &mut DocumentMut, source_provider_id: &str) -> bool {
|
||
let Some(profiles) = doc
|
||
.get_mut("profiles")
|
||
.and_then(|item| item.as_table_like_mut())
|
||
else {
|
||
return false;
|
||
};
|
||
|
||
let mut changed = false;
|
||
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_legacy = profile_table
|
||
.get("model_provider")
|
||
.and_then(|item| item.as_str())
|
||
== Some(source_provider_id);
|
||
if references_legacy {
|
||
profile_table.insert(
|
||
"model_provider",
|
||
toml_edit::value(CC_SWITCH_CODEX_MODEL_PROVIDER_ID),
|
||
);
|
||
changed = true;
|
||
}
|
||
}
|
||
changed
|
||
}
|
||
|
||
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 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}")))?;
|
||
|
||
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 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)
|
||
}
|
||
|
||
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 backup_provider_settings_config(
|
||
provider_id: &str,
|
||
settings_config: &Value,
|
||
backup_root: &Path,
|
||
) -> Result<(), AppError> {
|
||
let backup_path = backup_root
|
||
.join("providers")
|
||
.join(provider_settings_backup_filename(provider_id));
|
||
if let Some(parent) = backup_path.parent() {
|
||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||
}
|
||
|
||
let payload = serde_json::json!({
|
||
"providerId": provider_id,
|
||
"settingsConfig": settings_config,
|
||
});
|
||
let bytes =
|
||
serde_json::to_vec_pretty(&payload).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||
atomic_write(&backup_path, &bytes)
|
||
}
|
||
|
||
fn provider_settings_backup_filename(provider_id: &str) -> String {
|
||
let safe_id: String = provider_id
|
||
.chars()
|
||
.map(|ch| {
|
||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
|
||
ch
|
||
} else {
|
||
'_'
|
||
}
|
||
})
|
||
.collect();
|
||
let safe_id = if safe_id.is_empty() {
|
||
"provider".to_string()
|
||
} else {
|
||
safe_id
|
||
};
|
||
// Keep the hash stable across processes while avoiding collisions after sanitization.
|
||
let digest = Sha256::digest(provider_id.as_bytes());
|
||
let hash = digest[..8]
|
||
.iter()
|
||
.map(|byte| format!("{byte:02x}"))
|
||
.collect::<String>();
|
||
format!("{hash}-{safe_id}.settings_config.json")
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
fn migrate_provider_templates_for_test(
|
||
db: &Database,
|
||
) -> (
|
||
CodexProviderTemplateBucketMigrationOutcome,
|
||
tempfile::TempDir,
|
||
) {
|
||
let backup_dir = tempdir().expect("backup dir");
|
||
let outcome = migrate_codex_provider_templates_to_custom(db, backup_dir.path())
|
||
.expect("migrate template");
|
||
(outcome, backup_dir)
|
||
}
|
||
|
||
#[test]
|
||
fn simulates_local_codex_provider_bucket_migration_end_to_end() {
|
||
let dir = tempdir().expect("tempdir");
|
||
let codex_dir = dir.path().join(".codex");
|
||
let backup_root = dir.path().join("backup");
|
||
fs::create_dir_all(&codex_dir).expect("create codex dir");
|
||
|
||
let db = Database::memory().expect("memory db");
|
||
let providers = [
|
||
Provider::with_id(
|
||
"rightcode".to_string(),
|
||
"RightCode".to_string(),
|
||
serde_json::json!({
|
||
"auth": {},
|
||
"config": r#"model_provider = "aihubmix"
|
||
|
||
[model_providers.aihubmix]
|
||
name = "AIHubMix"
|
||
base_url = "https://aihubmix.example/v1"
|
||
"#
|
||
}),
|
||
None,
|
||
),
|
||
Provider::with_id(
|
||
"legacy-ccswitch".to_string(),
|
||
"Legacy CC Switch".to_string(),
|
||
serde_json::json!({
|
||
"auth": {},
|
||
"config": r#"model_provider = "ccswitch"
|
||
|
||
[model_providers.ccswitch]
|
||
name = "AIHubMix"
|
||
base_url = "https://aihubmix.example/v1"
|
||
"#
|
||
}),
|
||
None,
|
||
),
|
||
Provider::with_id(
|
||
"normalized-aihubmix".to_string(),
|
||
"Already Normalized".to_string(),
|
||
serde_json::json!({
|
||
"auth": {},
|
||
"config": r#"model_provider = "custom"
|
||
|
||
[model_providers.custom]
|
||
name = "AIHubMix"
|
||
base_url = "https://aihubmix.example/v1"
|
||
"#
|
||
}),
|
||
None,
|
||
),
|
||
Provider::with_id(
|
||
"manual-relay".to_string(),
|
||
"Manual Relay".to_string(),
|
||
serde_json::json!({
|
||
"auth": {},
|
||
"config": r#"model_provider = "my-private-relay"
|
||
|
||
[model_providers.my-private-relay]
|
||
name = "Manual Relay"
|
||
base_url = "http://localhost:8080/v1"
|
||
"#
|
||
}),
|
||
None,
|
||
),
|
||
Provider::with_id(
|
||
"custom-openai".to_string(),
|
||
"Custom OpenAI".to_string(),
|
||
serde_json::json!({
|
||
"auth": {},
|
||
"config": r#"model_provider = "openai"
|
||
|
||
[model_providers.openai]
|
||
name = "Custom OpenAI"
|
||
base_url = "https://proxy.example/v1"
|
||
"#
|
||
}),
|
||
None,
|
||
),
|
||
];
|
||
for provider in providers {
|
||
db.save_provider("codex", &provider).expect("save provider");
|
||
}
|
||
|
||
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", &official).expect("save official");
|
||
|
||
let source_provider_ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||
assert_eq!(
|
||
source_provider_ids,
|
||
source_ids(&["aihubmix", "ccswitch", "rightcode"])
|
||
);
|
||
|
||
let session_dir = codex_dir.join("sessions/2026/05/28");
|
||
fs::create_dir_all(&session_dir).expect("create session dir");
|
||
let session_path = session_dir.join("local-sim.jsonl");
|
||
fs::write(
|
||
&session_path,
|
||
concat!(
|
||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"rightcode\"}}\n",
|
||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s2\",\"model_provider\":\"aihubmix\"}}\n",
|
||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s3\",\"model_provider\":\"ccswitch\"}}\n",
|
||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s4\",\"model_provider\":\"my-private-relay\"}}\n",
|
||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s5\",\"model_provider\":\"openai\"}}\n",
|
||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s6\",\"model_provider\":\"custom\"}}\n",
|
||
),
|
||
)
|
||
.expect("write session");
|
||
|
||
let migrated_jsonl =
|
||
migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)
|
||
.expect("migrate jsonl");
|
||
assert_eq!(migrated_jsonl, 1);
|
||
let session_text = fs::read_to_string(&session_path).expect("read session");
|
||
assert_eq!(
|
||
session_text
|
||
.matches("\"model_provider\":\"custom\"")
|
||
.count(),
|
||
4
|
||
);
|
||
assert!(session_text.contains("\"model_provider\":\"my-private-relay\""));
|
||
assert!(session_text.contains("\"model_provider\":\"openai\""));
|
||
assert!(backup_root
|
||
.join("jsonl/sessions/2026/05/28/local-sim.jsonl")
|
||
.exists());
|
||
|
||
let state_db_path = codex_dir.join(CODEX_STATE_DB_FILENAME);
|
||
let conn = Connection::open(&state_db_path).expect("open state db");
|
||
conn.execute_batch(
|
||
"CREATE TABLE threads (
|
||
id TEXT PRIMARY KEY,
|
||
model_provider TEXT NOT NULL
|
||
);
|
||
INSERT INTO threads (id, model_provider) VALUES
|
||
('rightcode-thread', 'rightcode'),
|
||
('aihubmix-thread', 'aihubmix'),
|
||
('ccswitch-thread', 'ccswitch'),
|
||
('manual-thread', 'my-private-relay'),
|
||
('openai-thread', 'openai'),
|
||
('custom-thread', 'custom');",
|
||
)
|
||
.expect("seed state db");
|
||
drop(conn);
|
||
|
||
let migrated_state_rows = migrate_codex_state_db_provider_bucket(
|
||
&state_db_path,
|
||
&codex_dir,
|
||
&source_provider_ids,
|
||
&backup_root,
|
||
)
|
||
.expect("migrate state db");
|
||
assert_eq!(migrated_state_rows, 3);
|
||
|
||
let conn = Connection::open(&state_db_path).expect("reopen state db");
|
||
let count_provider = |provider_id: &str| -> i64 {
|
||
conn.query_row(
|
||
"SELECT COUNT(*) FROM threads WHERE model_provider = ?1",
|
||
[provider_id],
|
||
|row| row.get(0),
|
||
)
|
||
.expect("count provider")
|
||
};
|
||
assert_eq!(count_provider("custom"), 4);
|
||
assert_eq!(count_provider("my-private-relay"), 1);
|
||
assert_eq!(count_provider("openai"), 1);
|
||
assert!(backup_root
|
||
.join("state")
|
||
.join(CODEX_STATE_DB_FILENAME)
|
||
.exists());
|
||
drop(conn);
|
||
|
||
let template_outcome = migrate_codex_provider_templates_to_custom(&db, &backup_root)
|
||
.expect("migrate provider templates");
|
||
assert!(!template_outcome
|
||
.migrated_provider_ids
|
||
.iter()
|
||
.any(|id| id == "normalized-aihubmix"));
|
||
assert_eq!(
|
||
source_ids(
|
||
&template_outcome
|
||
.migrated_provider_ids
|
||
.iter()
|
||
.map(String::as_str)
|
||
.collect::<Vec<_>>()
|
||
),
|
||
source_ids(&["legacy-ccswitch", "rightcode"])
|
||
);
|
||
|
||
let config_provider_id = |provider_id: &str| -> String {
|
||
db.get_provider_by_id(provider_id, "codex")
|
||
.expect("get provider")
|
||
.expect("provider exists")
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(Value::as_str)
|
||
.expect("config text")
|
||
.to_string()
|
||
};
|
||
|
||
let rightcode_config: toml::Value =
|
||
toml::from_str(&config_provider_id("rightcode")).expect("parse rightcode config");
|
||
assert_eq!(
|
||
rightcode_config
|
||
.get("model_provider")
|
||
.and_then(|value| value.as_str()),
|
||
Some("custom")
|
||
);
|
||
assert!(rightcode_config
|
||
.get("model_providers")
|
||
.and_then(|value| value.get("aihubmix"))
|
||
.is_none());
|
||
|
||
let ccswitch_config: toml::Value =
|
||
toml::from_str(&config_provider_id("legacy-ccswitch")).expect("parse ccswitch config");
|
||
assert_eq!(
|
||
ccswitch_config
|
||
.get("model_provider")
|
||
.and_then(|value| value.as_str()),
|
||
Some("custom")
|
||
);
|
||
assert!(ccswitch_config
|
||
.get("model_providers")
|
||
.and_then(|value| value.get("ccswitch"))
|
||
.is_none());
|
||
|
||
let manual_config: toml::Value =
|
||
toml::from_str(&config_provider_id("manual-relay")).expect("parse manual config");
|
||
assert_eq!(
|
||
manual_config
|
||
.get("model_provider")
|
||
.and_then(|value| value.as_str()),
|
||
Some("my-private-relay")
|
||
);
|
||
|
||
let openai_config: toml::Value =
|
||
toml::from_str(&config_provider_id("custom-openai")).expect("parse openai config");
|
||
assert_eq!(
|
||
openai_config
|
||
.get("model_provider")
|
||
.and_then(|value| value.as_str()),
|
||
Some("openai")
|
||
);
|
||
|
||
let normalized_config: toml::Value =
|
||
toml::from_str(&config_provider_id("normalized-aihubmix"))
|
||
.expect("parse normalized config");
|
||
assert_eq!(
|
||
normalized_config
|
||
.get("model_provider")
|
||
.and_then(|value| value.as_str()),
|
||
Some("custom")
|
||
);
|
||
}
|
||
|
||
#[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 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");
|
||
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"));
|
||
}
|
||
|
||
#[test]
|
||
fn skips_unknown_provider_model_provider_id_from_existing_config() {
|
||
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.contains("my-private-relay"));
|
||
}
|
||
|
||
#[test]
|
||
fn skips_undefined_provider_model_provider_id_from_existing_config() {
|
||
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"
|
||
}),
|
||
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("my-private-relay"));
|
||
}
|
||
|
||
#[test]
|
||
fn skips_unknown_profile_model_provider_id_from_existing_config() {
|
||
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": r#"profile = "work"
|
||
|
||
[model_providers.my-private-relay]
|
||
name = "Manual Relay"
|
||
base_url = "http://localhost:8080/v1"
|
||
|
||
[profiles.work]
|
||
model_provider = "my-private-relay"
|
||
"#
|
||
}),
|
||
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("my-private-relay"));
|
||
}
|
||
|
||
#[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_legacy_ccswitch_provider_id_from_stored_config() {
|
||
let db = Database::memory().expect("memory db");
|
||
let mut provider = Provider::with_id(
|
||
"generated-uuid".to_string(),
|
||
"Legacy Stable".to_string(),
|
||
serde_json::json!({
|
||
"auth": {},
|
||
"config": "model_provider = \"ccswitch\"\n\n[model_providers.ccswitch]\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("ccswitch"));
|
||
assert!(ids.contains("aihubmix"));
|
||
assert!(!ids.contains("generated-uuid"));
|
||
}
|
||
|
||
#[test]
|
||
fn migrates_stored_provider_template_to_custom() {
|
||
let db = Database::memory().expect("memory db");
|
||
let provider = Provider::with_id(
|
||
"legacy".to_string(),
|
||
"Legacy Stable".to_string(),
|
||
serde_json::json!({
|
||
"auth": {},
|
||
"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"
|
||
|
||
[profiles.work]
|
||
model_provider = "aihubmix"
|
||
model = "gpt-5.4"
|
||
"#
|
||
}),
|
||
None,
|
||
);
|
||
db.save_provider("codex", &provider).expect("save provider");
|
||
|
||
let (outcome, backup_dir) = migrate_provider_templates_for_test(&db);
|
||
assert_eq!(outcome.migrated_provider_ids, vec!["legacy".to_string()]);
|
||
|
||
let saved = db
|
||
.get_provider_by_id("legacy", "codex")
|
||
.expect("get provider")
|
||
.expect("provider exists");
|
||
let config_text = saved
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(Value::as_str)
|
||
.expect("config text");
|
||
let parsed: toml::Value = toml::from_str(config_text).expect("parse config");
|
||
|
||
assert_eq!(
|
||
parsed
|
||
.get("model_provider")
|
||
.and_then(|value| value.as_str()),
|
||
Some("custom")
|
||
);
|
||
assert!(parsed
|
||
.get("model_providers")
|
||
.and_then(|value| value.get("aihubmix"))
|
||
.is_none());
|
||
assert_eq!(
|
||
parsed
|
||
.get("model_providers")
|
||
.and_then(|value| value.get("custom"))
|
||
.and_then(|value| value.get("base_url"))
|
||
.and_then(|value| value.as_str()),
|
||
Some("https://aihubmix.example/v1")
|
||
);
|
||
assert_eq!(
|
||
parsed
|
||
.get("profiles")
|
||
.and_then(|value| value.get("work"))
|
||
.and_then(|value| value.get("model_provider"))
|
||
.and_then(|value| value.as_str()),
|
||
Some("custom")
|
||
);
|
||
|
||
let backups: Vec<_> = fs::read_dir(backup_dir.path().join("providers"))
|
||
.expect("provider backups")
|
||
.flatten()
|
||
.collect();
|
||
assert_eq!(backups.len(), 1);
|
||
let backup_text = fs::read_to_string(backups[0].path()).expect("read provider backup");
|
||
assert!(backup_text.contains(r#""providerId": "legacy""#));
|
||
assert!(backup_text.contains(r#"model_provider = \"aihubmix\""#));
|
||
|
||
let (second, _second_backup_dir) = migrate_provider_templates_for_test(&db);
|
||
assert!(second.migrated_provider_ids.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn migrates_legacy_ccswitch_provider_template_to_custom() {
|
||
let db = Database::memory().expect("memory db");
|
||
let provider = Provider::with_id(
|
||
"legacy-ccswitch".to_string(),
|
||
"Legacy CC Switch".to_string(),
|
||
serde_json::json!({
|
||
"auth": {},
|
||
"config": r#"model_provider = "ccswitch"
|
||
|
||
[model_providers.ccswitch]
|
||
name = "AIHubMix"
|
||
base_url = "https://aihubmix.example/v1"
|
||
"#
|
||
}),
|
||
None,
|
||
);
|
||
db.save_provider("codex", &provider).expect("save provider");
|
||
|
||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||
assert_eq!(
|
||
outcome.migrated_provider_ids,
|
||
vec!["legacy-ccswitch".to_string()]
|
||
);
|
||
|
||
let saved = db
|
||
.get_provider_by_id("legacy-ccswitch", "codex")
|
||
.expect("get provider")
|
||
.expect("provider exists");
|
||
let config_text = saved
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(Value::as_str)
|
||
.expect("config text");
|
||
let parsed: toml::Value = toml::from_str(config_text).expect("parse config");
|
||
|
||
assert_eq!(
|
||
parsed
|
||
.get("model_provider")
|
||
.and_then(|value| value.as_str()),
|
||
Some("custom")
|
||
);
|
||
assert!(parsed
|
||
.get("model_providers")
|
||
.and_then(|value| value.get("ccswitch"))
|
||
.is_none());
|
||
assert_eq!(
|
||
parsed
|
||
.get("model_providers")
|
||
.and_then(|value| value.get("custom"))
|
||
.and_then(|value| value.get("base_url"))
|
||
.and_then(|value| value.as_str()),
|
||
Some("https://aihubmix.example/v1")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn skips_unknown_stored_provider_template() {
|
||
let db = Database::memory().expect("memory db");
|
||
let provider = Provider::with_id(
|
||
"manual".to_string(),
|
||
"Manual Relay".to_string(),
|
||
serde_json::json!({
|
||
"auth": {},
|
||
"config": r#"model_provider = "my-private-relay"
|
||
|
||
[model_providers.my-private-relay]
|
||
name = "Manual Relay"
|
||
base_url = "http://localhost:8080/v1"
|
||
"#
|
||
}),
|
||
None,
|
||
);
|
||
db.save_provider("codex", &provider).expect("save provider");
|
||
|
||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||
assert!(outcome.migrated_provider_ids.is_empty());
|
||
|
||
let saved = db
|
||
.get_provider_by_id("manual", "codex")
|
||
.expect("get provider")
|
||
.expect("provider exists");
|
||
let config_text = saved
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(Value::as_str)
|
||
.expect("config text");
|
||
let parsed: toml::Value = toml::from_str(config_text).expect("parse config");
|
||
|
||
assert_eq!(
|
||
parsed
|
||
.get("model_provider")
|
||
.and_then(|value| value.as_str()),
|
||
Some("my-private-relay")
|
||
);
|
||
assert_eq!(
|
||
parsed
|
||
.get("model_providers")
|
||
.and_then(|value| value.get("my-private-relay"))
|
||
.and_then(|value| value.get("base_url"))
|
||
.and_then(|value| value.as_str()),
|
||
Some("http://localhost:8080/v1")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn skips_reserved_key_in_non_official_stored_provider_template() {
|
||
let db = Database::memory().expect("memory db");
|
||
let provider = Provider::with_id(
|
||
"custom-openai".to_string(),
|
||
"Custom OpenAI".to_string(),
|
||
serde_json::json!({
|
||
"auth": {},
|
||
"config": r#"model_provider = "openai"
|
||
|
||
[model_providers.openai]
|
||
name = "Custom OpenAI"
|
||
base_url = "https://proxy.example/v1"
|
||
"#
|
||
}),
|
||
None,
|
||
);
|
||
db.save_provider("codex", &provider).expect("save provider");
|
||
|
||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||
assert!(outcome.migrated_provider_ids.is_empty());
|
||
|
||
let saved = db
|
||
.get_provider_by_id("custom-openai", "codex")
|
||
.expect("get provider")
|
||
.expect("provider exists");
|
||
let config_text = saved
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(Value::as_str)
|
||
.expect("config text");
|
||
let parsed: toml::Value = toml::from_str(config_text).expect("parse config");
|
||
|
||
assert_eq!(
|
||
parsed
|
||
.get("model_provider")
|
||
.and_then(|value| value.as_str()),
|
||
Some("openai")
|
||
);
|
||
assert_eq!(
|
||
parsed
|
||
.get("model_providers")
|
||
.and_then(|value| value.get("openai"))
|
||
.and_then(|value| value.get("base_url"))
|
||
.and_then(|value| value.as_str()),
|
||
Some("https://proxy.example/v1")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn migrates_profile_model_provider_refs_to_custom_when_top_level_is_already_custom() {
|
||
let db = Database::memory().expect("memory db");
|
||
let provider = Provider::with_id(
|
||
"profiled".to_string(),
|
||
"Profiled Relay".to_string(),
|
||
serde_json::json!({
|
||
"auth": {},
|
||
"config": r#"model_provider = "custom"
|
||
profile = "work"
|
||
|
||
[model_providers.custom]
|
||
name = "Current"
|
||
base_url = "https://current.example/v1"
|
||
|
||
[model_providers.aihubmix]
|
||
name = "AIHubMix"
|
||
base_url = "https://aihubmix.example/v1"
|
||
|
||
[profiles.work]
|
||
model_provider = "aihubmix"
|
||
"#
|
||
}),
|
||
None,
|
||
);
|
||
db.save_provider("codex", &provider).expect("save provider");
|
||
|
||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||
assert_eq!(outcome.migrated_provider_ids, vec!["profiled".to_string()]);
|
||
|
||
let saved = db
|
||
.get_provider_by_id("profiled", "codex")
|
||
.expect("get provider")
|
||
.expect("provider exists");
|
||
let config_text = saved
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(Value::as_str)
|
||
.expect("config text");
|
||
let parsed: toml::Value = toml::from_str(config_text).expect("parse config");
|
||
|
||
assert_eq!(
|
||
parsed
|
||
.get("profiles")
|
||
.and_then(|value| value.get("work"))
|
||
.and_then(|value| value.get("model_provider"))
|
||
.and_then(|value| value.as_str()),
|
||
Some("custom")
|
||
);
|
||
assert_eq!(
|
||
parsed
|
||
.get("model_providers")
|
||
.and_then(|value| value.get("custom"))
|
||
.and_then(|value| value.get("base_url"))
|
||
.and_then(|value| value.as_str()),
|
||
Some("https://current.example/v1")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn skips_custom_category_unknown_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"));
|
||
assert!(!ids.contains("generated-uuid"));
|
||
}
|
||
|
||
#[test]
|
||
fn skips_custom_category_unknown_provider_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.contains("my-local-relay"));
|
||
}
|
||
}
|