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
parent 3d6fb894b5
commit fc0433f2f4
8 changed files with 1075 additions and 99 deletions
+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