refactor(codex): unify custom model_provider routing key to "custom"

- 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).
This commit is contained in:
Jason
2026-05-28 17:30:44 +08:00
Unverified
parent 3d6fb894b5
commit fc0433f2f4
8 changed files with 1075 additions and 99 deletions
-11
View File
@@ -172,17 +172,6 @@ pub(crate) fn is_custom_codex_model_provider_id(id: &str) -> bool {
.any(|reserved| reserved.eq_ignore_ascii_case(id))
}
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)?;
if is_custom_codex_model_provider_id(&provider_id) {
Some(provider_id)
} else {
None
}
}
/// Write only Codex `config.toml` for provider switching.
///
/// Codex login state lives in `auth.json`; provider routing, endpoint, model,
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -34,6 +34,10 @@ fn merge_settings_for_save(
.codex_third_party_history_provider_bucket_v1
.clone();
}
if incoming_migrations.codex_provider_template_v1.is_none() {
incoming_migrations.codex_provider_template_v1 =
existing_migrations.codex_provider_template_v1.clone();
}
}
incoming
}
@@ -98,8 +102,8 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
mod tests {
use super::merge_settings_for_save;
use crate::settings::{
AppSettings, CodexThirdPartyHistoryProviderBucketMigration, LocalMigrations,
WebDavSyncSettings,
AppSettings, CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration,
LocalMigrations, WebDavSyncSettings,
};
#[test]
@@ -236,6 +240,10 @@ mod tests {
scanned_history_files: true,
},
),
codex_provider_template_v1: Some(CodexProviderTemplateMigration {
completed_at: "2026-05-20T00:01:00Z".to_string(),
migrated_provider_ids: vec!["legacy".to_string()],
}),
}),
..AppSettings::default()
};
@@ -255,6 +263,16 @@ mod tests {
assert_eq!(migration.target_provider_id, "custom");
assert_eq!(migration.migrated_jsonl_files, 2);
assert_eq!(migration.migrated_state_rows, 3);
let template_migration = merged
.local_migrations
.as_ref()
.and_then(|migrations| migrations.codex_provider_template_v1.as_ref())
.expect("template migration marker should be preserved");
assert_eq!(
template_migration.migrated_provider_ids,
vec!["legacy".to_string()]
);
}
}
+63 -36
View File
@@ -285,37 +285,19 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
/// Build Codex settings configuration
fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
// Generate a safe provider name identifier
let clean_provider_name = {
let raw: String = request
.name
.clone()
.unwrap_or_else(|| "custom".to_string())
.chars()
.filter(|c| !c.is_control())
.collect();
let lower = raw.to_lowercase();
let mut key: String = lower
.chars()
.map(|c| match c {
'a'..='z' | '0'..='9' | '_' => c,
_ => '_',
})
.collect();
// Remove leading/trailing underscores
while key.starts_with('_') {
key.remove(0);
}
while key.ends_with('_') {
key.pop();
}
if key.is_empty() {
"custom".to_string()
} else {
key
}
let provider_display_name = request
.name
.as_deref()
.unwrap_or("custom")
.chars()
.filter(|c| !c.is_control())
.collect::<String>()
.trim()
.to_string();
let provider_display_name = if provider_display_name.is_empty() {
"custom".to_string()
} else {
provider_display_name
};
// Model name: use deeplink model or default
@@ -331,16 +313,20 @@ fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
.trim_end_matches('/')
.to_string();
let provider_display_name = toml_edit::Value::from(provider_display_name.as_str()).to_string();
let model_name = toml_edit::Value::from(model_name.as_str()).to_string();
let endpoint = toml_edit::Value::from(endpoint.as_str()).to_string();
// Build config.toml content
let config_toml = format!(
r#"model_provider = "{clean_provider_name}"
model = "{model_name}"
r#"model_provider = "custom"
model = {model_name}
model_reasoning_effort = "high"
disable_response_storage = true
[model_providers.{clean_provider_name}]
name = "{clean_provider_name}"
base_url = "{endpoint}"
[model_providers.custom]
name = {provider_display_name}
base_url = {endpoint}
wire_api = "responses"
requires_openai_auth = true
"#
@@ -822,6 +808,47 @@ mod tests {
assert_eq!(obj.get("api_mode").unwrap(), "chat_completions");
}
#[test]
fn build_codex_settings_uses_custom_key_and_preserves_display_name() {
let request = DeepLinkImportRequest {
resource: "provider".to_string(),
app: Some("codex".to_string()),
name: Some("My \"Relay\"".to_string()),
endpoint: Some("https://api.example.com/v1/".to_string()),
api_key: Some("sk-test".to_string()),
model: Some("gpt-5-codex".to_string()),
..Default::default()
};
let settings = build_codex_settings(&request);
let config_text = settings
.get("config")
.and_then(|value| value.as_str())
.expect("config text");
let parsed: toml::Value = toml::from_str(config_text).expect("valid Codex config");
assert_eq!(
parsed
.get("model_provider")
.and_then(|value| value.as_str()),
Some("custom")
);
let custom_provider = parsed
.get("model_providers")
.and_then(|value| value.get("custom"))
.expect("custom model provider");
assert_eq!(
custom_provider.get("name").and_then(|value| value.as_str()),
Some("My \"Relay\"")
);
assert_eq!(
custom_provider
.get("base_url")
.and_then(|value| value.as_str()),
Some("https://api.example.com/v1")
);
}
#[test]
fn openclaw_still_uses_camel_case() {
// OpenClaw's live config natively uses camelCase; guard against a
+18
View File
@@ -558,6 +558,24 @@ pub fn run() {
log::warn!("✗ Codex history provider bucket migration failed: {e}");
}
}
match crate::codex_history_migration::maybe_migrate_codex_provider_template_bucket(
&db_for_codex_history_migration,
) {
Ok(outcome) => {
if let Some(reason) = outcome.skipped_reason {
log::debug!("○ Codex provider template bucket migration skipped: {reason}");
} else if !outcome.migrated_provider_ids.is_empty() {
log::info!(
"✓ Codex provider template bucket migration completed: providers={}",
outcome.migrated_provider_ids.len()
);
}
}
Err(e) => {
log::warn!("✗ Codex provider template bucket migration failed: {e}");
}
}
});
}
+2 -2
View File
@@ -628,12 +628,12 @@ impl UniversalProvider {
// 生成 Codex 的 config.toml 内容
let config_toml = format!(
r#"model_provider = "newapi"
r#"model_provider = "custom"
model = "{model}"
model_reasoning_effort = "{reasoning_effort}"
disable_response_storage = true
[model_providers.newapi]
[model_providers.custom]
name = "NewAPI"
base_url = "{codex_base_url}"
wire_api = "responses"
+30 -1
View File
@@ -178,13 +178,15 @@ 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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_provider_template_v1: Option<CodexProviderTemplateMigration>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -202,6 +204,14 @@ pub struct CodexThirdPartyHistoryProviderBucketMigration {
pub scanned_history_files: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexProviderTemplateMigration {
pub completed_at: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub migrated_provider_ids: Vec<String>,
}
/// 应用设置结构
///
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
@@ -610,6 +620,25 @@ pub fn mark_codex_third_party_history_provider_bucket_migrated(
})
}
pub fn is_codex_provider_template_migrated() -> bool {
get_settings()
.local_migrations
.as_ref()
.and_then(|migrations| migrations.codex_provider_template_v1.as_ref())
.is_some()
}
pub fn mark_codex_provider_template_migrated(
migration: CodexProviderTemplateMigration,
) -> Result<(), AppError> {
mutate_settings(|settings| {
let migrations = settings
.local_migrations
.get_or_insert_with(Default::default);
migrations.codex_provider_template_v1 = Some(migration);
})
}
/// 从文件重新加载设置到内存缓存
/// 用于导入配置等场景,确保内存缓存与文件同步
pub fn reload_settings() -> Result<(), AppError> {
@@ -152,12 +152,12 @@ export function UniversalProviderFormModal({
const codexBaseUrl = baseUrl.endsWith("/v1")
? baseUrl
: `${baseUrl.replace(/\/+$/, "")}/v1`;
const configToml = `model_provider = "newapi"
const configToml = `model_provider = "custom"
model = "${model}"
model_reasoning_effort = "${reasoningEffort}"
disable_response_storage = true
[model_providers.newapi]
[model_providers.custom]
name = "NewAPI"
base_url = "${codexBaseUrl}"
wire_api = "responses"