mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
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:
@@ -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
@@ -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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user