// unused imports removed use std::path::PathBuf; use crate::config::{ atomic_write, delete_file, get_home_dir, sanitize_provider_name, write_json_file, write_text_file, }; use crate::error::AppError; use serde_json::{json, Value}; use std::fs; use std::path::Path; use std::process::Command; use toml_edit::DocumentMut; pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom"; pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json"; const CODEX_MODEL_CATALOG_TEMPLATE_SLUG: &str = "gpt-5.5"; /// Reserved built-in provider IDs from OpenAI Codex's config/model-provider /// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy /// removed provider aliases. const CODEX_RESERVED_MODEL_PROVIDER_IDS: &[&str] = &[ "amazon-bedrock", "openai", "ollama", "lmstudio", "oss", "ollama-chat", ]; /// 获取 Codex 配置目录路径 pub fn get_codex_config_dir() -> PathBuf { if let Some(custom) = crate::settings::get_codex_override_dir() { return custom; } get_home_dir().join(".codex") } /// 获取 Codex auth.json 路径 pub fn get_codex_auth_path() -> PathBuf { get_codex_config_dir().join("auth.json") } /// 获取 Codex config.toml 路径 pub fn get_codex_config_path() -> PathBuf { get_codex_config_dir().join("config.toml") } pub fn get_codex_model_catalog_path() -> PathBuf { get_codex_config_dir().join(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME) } /// 获取 Codex 供应商配置文件路径 #[allow(dead_code)] pub fn get_codex_provider_paths( provider_id: &str, provider_name: Option<&str>, ) -> (PathBuf, PathBuf) { let base_name = provider_name .map(sanitize_provider_name) .unwrap_or_else(|| sanitize_provider_name(provider_id)); let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json")); let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml")); (auth_path, config_path) } /// 删除 Codex 供应商配置文件 #[allow(dead_code)] pub fn delete_codex_provider_config( provider_id: &str, provider_name: &str, ) -> Result<(), AppError> { let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name)); delete_file(&auth_path).ok(); delete_file(&config_path).ok(); Ok(()) } /// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步 pub fn write_codex_live_atomic( auth: &Value, config_text_opt: Option<&str>, ) -> Result<(), AppError> { let auth_path = get_codex_auth_path(); let config_path = get_codex_config_path(); if let Some(parent) = auth_path.parent() { std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } // 读取旧内容用于回滚 let old_auth = if auth_path.exists() { Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?) } else { None }; let _old_config = if config_path.exists() { Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?) } else { None }; // 准备写入内容 let cfg_text = match config_text_opt { Some(s) => s.to_string(), None => String::new(), }; if !cfg_text.trim().is_empty() { toml::from_str::(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?; } // 第一步:写 auth.json write_json_file(&auth_path, auth)?; // 第二步:写 config.toml(失败则回滚 auth.json) if let Err(e) = write_text_file(&config_path, &cfg_text) { // 回滚 auth.json if let Some(bytes) = old_auth { let _ = atomic_write(&auth_path, &bytes); } else { let _ = delete_file(&auth_path); } return Err(e); } Ok(()) } /// 读取 `~/.codex/config.toml`,若不存在返回空字符串 pub fn read_codex_config_text() -> Result { let path = get_codex_config_path(); if path.exists() { std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e)) } else { Ok(String::new()) } } /// 对非空的 TOML 文本进行语法校验 pub fn validate_config_toml(text: &str) -> Result<(), AppError> { if text.trim().is_empty() { return Ok(()); } toml::from_str::(text) .map(|_| ()) .map_err(|e| AppError::toml(Path::new("config.toml"), e)) } /// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空) pub fn read_and_validate_codex_config_text() -> Result { let s = read_codex_config_text()?; validate_config_toml(&s)?; Ok(s) } fn active_codex_model_provider_id(doc: &DocumentMut) -> Option { doc.get("model_provider") .and_then(|item| item.as_str()) .map(str::trim) .filter(|id| !id.is_empty()) .map(str::to_string) } pub(crate) fn is_custom_codex_model_provider_id(id: &str) -> bool { let id = id.trim(); !id.is_empty() && !CODEX_RESERVED_MODEL_PROVIDER_IDS .iter() .any(|reserved| reserved.eq_ignore_ascii_case(id)) } pub(crate) fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option { let doc = config_text.parse::().ok()?; let provider_id = active_codex_model_provider_id(&doc)?; if is_custom_codex_model_provider_id(&provider_id) { Some(provider_id) } else { None } } fn codex_model_provider_id_with_table_from_config( config_text: &str, ) -> Result, AppError> { if config_text.trim().is_empty() { return Ok(None); } let doc = config_text .parse::() .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; let Some(provider_id) = active_codex_model_provider_id(&doc) else { return Ok(None); }; let has_provider_table = doc .get("model_providers") .and_then(|item| item.as_table()) .and_then(|table| table.get(provider_id.as_str())) .is_some(); Ok(has_provider_table.then_some(provider_id)) } fn normalize_codex_live_config_model_provider(config_text: &str) -> Result { if config_text.trim().is_empty() { return Ok(config_text.to_string()); } let mut doc = config_text .parse::() .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; let Some(source_provider_id) = active_codex_model_provider_id(&doc) else { return Ok(config_text.to_string()); }; let has_source_provider_table = doc .get("model_providers") .and_then(|item| item.as_table()) .and_then(|table| table.get(source_provider_id.as_str())) .is_some(); if !has_source_provider_table { return Ok(config_text.to_string()); } if !is_custom_codex_model_provider_id(&source_provider_id) { return Ok(config_text.to_string()); } let stable_provider_id = CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string(); if stable_provider_id == source_provider_id { return Ok(config_text.to_string()); } if let Some(model_providers) = doc .get_mut("model_providers") .and_then(|item| item.as_table_mut()) { let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else { return Ok(config_text.to_string()); }; model_providers[stable_provider_id.as_str()] = provider_table; } rewrite_codex_profile_model_provider_refs(&mut doc, &source_provider_id, &stable_provider_id); doc["model_provider"] = toml_edit::value(stable_provider_id.as_str()); Ok(doc.to_string()) } fn rewrite_codex_profile_model_provider_refs( doc: &mut DocumentMut, source_provider_id: &str, stable_provider_id: &str, ) { let Some(profiles) = doc .get_mut("profiles") .and_then(|item| item.as_table_like_mut()) else { return; }; let profile_keys: Vec = 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_source = profile_table .get("model_provider") .and_then(|item| item.as_str()) == Some(source_provider_id); if references_source { profile_table.insert("model_provider", toml_edit::value(stable_provider_id)); } } } /// Keep Codex's active `model_provider` stable across CC Switch provider changes. /// /// Codex stores and filters resume history by `model_provider`, so switching between /// provider-specific ids like `rightcode` and `aihubmix` makes history appear to move. /// CC Switch-managed third-party providers share one stable bucket while official /// built-in providers such as `openai` keep their original identity. pub fn normalize_codex_settings_config_model_provider( settings: &mut Value, ) -> Result<(), AppError> { let Some(config_text) = settings .get("config") .and_then(|value| value.as_str()) .map(str::to_string) else { return Ok(()); }; let normalized = normalize_codex_live_config_model_provider(&config_text)?; if let Some(obj) = settings.as_object_mut() { obj.insert("config".to_string(), Value::String(normalized)); } Ok(()) } fn restore_codex_backfill_model_provider_id( config_text: &str, template_config_text: &str, ) -> Result { let Some(template_provider_id) = codex_model_provider_id_with_table_from_config(template_config_text)? else { return Ok(config_text.to_string()); }; if config_text.trim().is_empty() { return Ok(config_text.to_string()); } let mut doc = config_text .parse::() .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; let Some(live_provider_id) = active_codex_model_provider_id(&doc) else { return Ok(config_text.to_string()); }; if live_provider_id == template_provider_id { return Ok(config_text.to_string()); } if let Some(model_providers) = doc .get_mut("model_providers") .and_then(|item| item.as_table_mut()) { let Some(provider_table) = model_providers.remove(live_provider_id.as_str()) else { return Ok(config_text.to_string()); }; model_providers[template_provider_id.as_str()] = provider_table; } else { return Ok(config_text.to_string()); } rewrite_codex_profile_model_provider_refs(&mut doc, &live_provider_id, &template_provider_id); doc["model_provider"] = toml_edit::value(template_provider_id.as_str()); Ok(doc.to_string()) } /// Convert a Codex live config that was normalized for history stability back /// to the provider-specific id used by the stored provider template. pub fn restore_codex_settings_config_model_provider_for_backfill( settings: &mut Value, template_settings: &Value, ) -> Result<(), AppError> { let Some(config_text) = settings .get("config") .and_then(|value| value.as_str()) .map(str::to_string) else { return Ok(()); }; let Some(template_config_text) = template_settings .get("config") .and_then(|value| value.as_str()) else { return Ok(()); }; let restored = restore_codex_backfill_model_provider_id(&config_text, template_config_text)?; if let Some(obj) = settings.as_object_mut() { obj.insert("config".to_string(), Value::String(restored)); } Ok(()) } /// Atomically write Codex live config after normalizing provider-specific ids. /// /// Use this for provider-driven live writes. Keep `write_codex_live_atomic` available /// for exact restore/backup paths that must preserve the config text byte-for-byte. pub fn write_codex_live_atomic_with_stable_provider( auth: &Value, config_text_opt: Option<&str>, ) -> Result<(), AppError> { match config_text_opt { Some(config_text) => { let mut settings = serde_json::Map::new(); settings.insert("config".to_string(), Value::String(config_text.to_string())); let mut settings = Value::Object(settings); normalize_codex_settings_config_model_provider(&mut settings)?; let config_text = settings .get("config") .and_then(|value| value.as_str()) .unwrap_or(config_text); write_codex_live_atomic(auth, Some(config_text)) } None => write_codex_live_atomic(auth, None), } } fn parse_codex_positive_u64(value: Option<&Value>) -> Option { match value { Some(Value::Number(n)) => n.as_u64().filter(|v| *v > 0), Some(Value::String(s)) => s.trim().parse::().ok().filter(|v| *v > 0), _ => None, } } fn extract_codex_top_level_u64(config_text: &str, field: &str) -> Option { let doc = config_text.parse::().ok()?; doc.get(field) .and_then(|value| value.as_integer()) .and_then(|value| u64::try_from(value).ok()) .filter(|value| *value > 0) } fn codex_catalog_model_entry( template: &Value, model: &str, display_name: &str, context_window: u64, priority: usize, ) -> Value { let mut entry = template.clone(); let Some(entry_obj) = entry.as_object_mut() else { return json!({}); }; entry_obj.insert("slug".to_string(), json!(model)); entry_obj.insert("display_name".to_string(), json!(display_name)); entry_obj.insert("description".to_string(), json!(display_name)); entry_obj.insert("context_window".to_string(), json!(context_window)); entry_obj.insert("max_context_window".to_string(), json!(context_window)); entry_obj.insert("priority".to_string(), json!(1000 + priority)); entry_obj.insert("additional_speed_tiers".to_string(), json!([])); entry_obj.insert("service_tiers".to_string(), json!([])); entry_obj.insert("availability_nux".to_string(), Value::Null); entry_obj.insert("upgrade".to_string(), Value::Null); entry } #[derive(Debug, Clone, PartialEq, Eq)] struct CodexCatalogModelSpec { model: String, display_name: String, context_window: u64, } fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec { let Some(models) = settings .get("modelCatalog") .and_then(|catalog| catalog.get("models")) .and_then(|models| models.as_array()) else { return Vec::new(); }; let default_context_window = extract_codex_top_level_u64(config_text, "model_context_window").unwrap_or(128_000); let mut seen = std::collections::HashSet::new(); let mut specs = Vec::new(); for model_config in models { let Some(model) = model_config .get("model") .and_then(|value| value.as_str()) .map(str::trim) .filter(|model| !model.is_empty()) else { continue; }; if !seen.insert(model.to_string()) { continue; } let display_name = model_config .get("displayName") .or_else(|| model_config.get("display_name")) .and_then(|value| value.as_str()) .map(str::trim) .filter(|name| !name.is_empty()) .unwrap_or(model); let context_window = parse_codex_positive_u64( model_config .get("contextWindow") .or_else(|| model_config.get("context_window")), ) .unwrap_or(default_context_window); specs.push(CodexCatalogModelSpec { model: model.to_string(), display_name: display_name.to_string(), context_window, }); } specs } fn find_codex_model_template(catalog: &Value) -> Option { catalog .get("models") .and_then(|models| models.as_array()) .and_then(|models| { models.iter().find(|model| { model.get("slug").and_then(|slug| slug.as_str()) == Some(CODEX_MODEL_CATALOG_TEMPLATE_SLUG) }) }) .cloned() } fn load_codex_model_template_from_cache() -> Result, AppError> { let path = get_codex_config_dir().join("models_cache.json"); if !path.exists() { return Ok(None); } let text = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; let catalog: Value = serde_json::from_str(&text).map_err(|e| AppError::json(&path, e))?; Ok(find_codex_model_template(&catalog)) } fn load_codex_model_template_from_bundled() -> Result, AppError> { let output = match Command::new("codex") .args(["debug", "models", "--bundled"]) .output() { Ok(output) => output, Err(err) => { log::debug!("failed to run `codex debug models --bundled`: {err}"); return Ok(None); } }; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); log::debug!("`codex debug models --bundled` failed: {stderr}"); return Ok(None); } let catalog: Value = serde_json::from_slice(&output.stdout).map_err(|e| { AppError::Message(format!( "Failed to parse `codex debug models --bundled` output: {e}" )) })?; Ok(find_codex_model_template(&catalog)) } fn load_codex_model_catalog_template() -> Result { if let Some(template) = load_codex_model_template_from_cache()? { return Ok(template); } if let Some(template) = load_codex_model_template_from_bundled()? { return Ok(template); } Err(AppError::Message(format!( "Codex model catalog template `{CODEX_MODEL_CATALOG_TEMPLATE_SLUG}` not found. Please start Codex once so models_cache.json is available, or ensure the `codex` CLI is on PATH." ))) } fn codex_model_catalog_from_specs(specs: &[CodexCatalogModelSpec], template: &Value) -> Value { let entries: Vec = specs .iter() .enumerate() .map(|(index, spec)| { codex_catalog_model_entry( template, &spec.model, &spec.display_name, spec.context_window, index, ) }) .collect(); json!({ "models": entries }) } fn codex_model_catalog_from_settings( settings: &Value, config_text: &str, ) -> Result, AppError> { let specs = codex_catalog_model_specs(settings, config_text); if specs.is_empty() { return Ok(None); } let template = load_codex_model_catalog_template()?; Ok(Some(codex_model_catalog_from_specs(&specs, &template))) } fn set_codex_model_catalog_json_field( config_text: &str, catalog_path: Option<&Path>, ) -> Result { let mut doc = config_text .parse::() .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; let generated_path = get_codex_model_catalog_path(); match catalog_path { Some(path) => { doc["model_catalog_json"] = toml_edit::value(path.to_string_lossy().as_ref()); } None => { let should_remove = doc .get("model_catalog_json") .and_then(|item| item.as_str()) .map(|path| { path == generated_path.to_string_lossy().as_ref() || Path::new(path).file_name().and_then(|name| name.to_str()) == Some(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME) }) .unwrap_or(false); if should_remove { doc.as_table_mut().remove("model_catalog_json"); } } } Ok(doc.to_string()) } /// Generate Codex `model_catalog_json` from provider settings and inject/remove /// the top-level TOML field that points Codex to the generated file. pub fn prepare_codex_config_text_with_model_catalog( settings: &Value, config_text: &str, ) -> Result { let catalog_path = get_codex_model_catalog_path(); if let Some(catalog) = codex_model_catalog_from_settings(settings, config_text)? { let config_text = set_codex_model_catalog_json_field(config_text, Some(&catalog_path))?; write_json_file(&catalog_path, &catalog)?; Ok(config_text) } else { set_codex_model_catalog_json_field(config_text, None) } } /// Unified helper: write Codex live config with model catalog preparation. /// Replaces scattered `prepare_codex_config_text_with_model_catalog` calls. pub fn write_codex_live_with_catalog( settings: &Value, auth: &Value, config_text: Option<&str>, ) -> Result<(), AppError> { let prepared_config = config_text .map(|text| prepare_codex_config_text_with_model_catalog(settings, text)) .transpose()?; write_codex_live_atomic_with_stable_provider(auth, prepared_config.as_deref()) } /// Update a field in Codex config.toml using toml_edit (syntax-preserving). /// /// Supported fields: /// - `"base_url"`: writes to `[model_providers.].base_url` if `model_provider` exists, /// otherwise falls back to top-level `base_url`. /// - `"wire_api"`: writes to `[model_providers.].wire_api` if `model_provider` exists, /// otherwise falls back to top-level `wire_api`. /// - `"model"` / `"model_catalog_json"`: writes to top-level field. /// /// Empty value removes the field. pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Result { let mut doc = toml_str .parse::() .map_err(|e| format!("TOML parse error: {e}"))?; let trimmed = value.trim(); match field { "base_url" | "wire_api" => { let model_provider = doc .get("model_provider") .and_then(|item| item.as_str()) .map(str::to_string); if let Some(provider_key) = model_provider { // Ensure [model_providers] table exists if doc.get("model_providers").is_none() { doc["model_providers"] = toml_edit::table(); } if let Some(model_providers) = doc["model_providers"].as_table_mut() { // Ensure [model_providers.] table exists if !model_providers.contains_key(&provider_key) { model_providers[&provider_key] = toml_edit::table(); } if let Some(provider_table) = model_providers[&provider_key].as_table_mut() { if trimmed.is_empty() { provider_table.remove(field); } else { provider_table[field] = toml_edit::value(trimmed); } return Ok(doc.to_string()); } } } // Fallback: no model_provider or structure mismatch → top-level field if trimmed.is_empty() { doc.as_table_mut().remove(field); } else { doc[field] = toml_edit::value(trimmed); } } "model" | "model_catalog_json" => { if trimmed.is_empty() { doc.as_table_mut().remove(field); } else { doc[field] = toml_edit::value(trimmed); } } _ => return Err(format!("unsupported field: {field}")), } Ok(doc.to_string()) } /// Remove `base_url` from the active model_provider section only if it matches `predicate`. /// Also removes top-level `base_url` if it matches. /// Used by proxy cleanup to strip local proxy URLs without touching user-configured URLs. pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) -> bool) -> String { let mut doc = match toml_str.parse::() { Ok(doc) => doc, Err(_) => return toml_str.to_string(), }; let model_provider = doc .get("model_provider") .and_then(|item| item.as_str()) .map(str::to_string); if let Some(provider_key) = model_provider { if let Some(model_providers) = doc .get_mut("model_providers") .and_then(|v| v.as_table_mut()) { if let Some(provider_table) = model_providers .get_mut(provider_key.as_str()) .and_then(|v| v.as_table_mut()) { let should_remove = provider_table .get("base_url") .and_then(|item| item.as_str()) .map(&predicate) .unwrap_or(false); if should_remove { provider_table.remove("base_url"); } } } } // Fallback: also clean up top-level base_url if it matches let should_remove_root = doc .get("base_url") .and_then(|item| item.as_str()) .map(&predicate) .unwrap_or(false); if should_remove_root { doc.as_table_mut().remove("base_url"); } doc.to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn normalize_live_config_uses_custom_for_third_party_model_provider_id() { let target = r#"model_provider = "aihubmix" model = "gpt-5.4" [model_providers.aihubmix] name = "AiHubMix" base_url = "https://aihubmix.example/v1" wire_api = "responses" requires_openai_auth = true [mcp_servers.context7] command = "npx" "#; let result = normalize_codex_live_config_model_provider(target).unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); assert_eq!( parsed.get("model_provider").and_then(|v| v.as_str()), Some("custom") ); let model_providers = parsed .get("model_providers") .and_then(|v| v.as_table()) .expect("model_providers should exist"); assert!( model_providers.get("aihubmix").is_none(), "source provider id should not remain in live config" ); let stable_provider = model_providers .get("custom") .expect("stable provider table should exist"); assert_eq!( stable_provider.get("base_url").and_then(|v| v.as_str()), Some("https://aihubmix.example/v1") ); assert!( parsed.get("mcp_servers").is_some(), "unrelated config should be preserved" ); } #[test] fn normalize_live_config_uses_custom_for_custom_provider_even_without_anchor() { let target = r#"model_provider = "aihubmix" [model_providers.aihubmix] name = "AiHubMix" base_url = "https://aihubmix.example/v1" wire_api = "responses" "#; let result = normalize_codex_live_config_model_provider(target).unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); assert_eq!( parsed.get("model_provider").and_then(|v| v.as_str()), Some("custom") ); assert!( parsed .get("model_providers") .and_then(|v| v.get("custom")) .is_some(), "third-party provider id should be normalized to custom" ); } #[test] fn normalize_live_config_leaves_official_empty_config_unchanged() { let result = normalize_codex_live_config_model_provider("").unwrap(); assert_eq!(result, ""); } #[test] fn normalize_live_config_rewrites_matching_profile_model_provider_refs() { let target = r#"model_provider = "vendor_alpha" model = "gpt-5.4" profile = "work" [model_providers.vendor_alpha] name = "Vendor Alpha" base_url = "https://alpha.example/v1" wire_api = "responses" [profiles.work] model_provider = "vendor_alpha" model = "gpt-5.4" "#; let result = normalize_codex_live_config_model_provider(target).unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); assert_eq!( parsed.get("model_provider").and_then(|v| v.as_str()), Some("custom") ); assert_eq!( parsed .get("profiles") .and_then(|v| v.get("work")) .and_then(|v| v.get("model_provider")) .and_then(|v| v.as_str()), Some("custom"), "profile override matching the rewritten provider should stay valid" ); } #[test] fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() { let target = r#"model_provider = "vendor_alpha" model = "gpt-5.4" [model_providers.vendor_alpha] name = "Vendor Alpha" base_url = "https://alpha.example/v1" wire_api = "responses" [model_providers.local_profile] name = "Local Profile" base_url = "http://localhost:11434/v1" wire_api = "responses" [profiles.local] model_provider = "local_profile" model = "local-model" "#; let result = normalize_codex_live_config_model_provider(target).unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); assert_eq!( parsed .get("profiles") .and_then(|v| v.get("local")) .and_then(|v| v.get("model_provider")) .and_then(|v| v.as_str()), Some("local_profile"), "unrelated profile provider references should be preserved" ); assert!( parsed .get("model_providers") .and_then(|v| v.get("local_profile")) .is_some(), "unrelated provider tables should also remain available" ); } #[test] fn normalize_live_config_keeps_custom_across_repeated_switches() { let first_target = r#"model_provider = "vendor_alpha" [model_providers.vendor_alpha] name = "Vendor Alpha" base_url = "https://alpha.example/v1" wire_api = "responses" "#; let second_target = r#"model_provider = "vendor_beta" [model_providers.vendor_beta] name = "Vendor Beta" base_url = "https://beta.example/v1" wire_api = "responses" "#; let first = normalize_codex_live_config_model_provider(first_target).unwrap(); let second = normalize_codex_live_config_model_provider(second_target).unwrap(); let first_parsed: toml::Value = toml::from_str(&first).unwrap(); let parsed: toml::Value = toml::from_str(&second).unwrap(); assert_eq!( first_parsed.get("model_provider").and_then(|v| v.as_str()), Some("custom") ); assert_eq!( parsed.get("model_provider").and_then(|v| v.as_str()), Some("custom"), "stable provider id should not drift across repeated switches" ); assert_eq!( parsed .get("model_providers") .and_then(|v| v.get("custom")) .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()), Some("https://beta.example/v1") ); } #[test] fn base_url_writes_into_correct_model_provider_section() { let input = r#"model_provider = "any" model = "gpt-5.1-codex" [model_providers.any] name = "any" wire_api = "responses" "#; let result = update_codex_toml_field(input, "base_url", "https://example.com/v1").unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); let base_url = parsed .get("model_providers") .and_then(|v| v.get("any")) .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()) .expect("base_url should be in model_providers.any"); assert_eq!(base_url, "https://example.com/v1"); // Should NOT have top-level base_url assert!(parsed.get("base_url").is_none()); // wire_api preserved let wire_api = parsed .get("model_providers") .and_then(|v| v.get("any")) .and_then(|v| v.get("wire_api")) .and_then(|v| v.as_str()); assert_eq!(wire_api, Some("responses")); } #[test] fn wire_api_writes_into_correct_model_provider_section() { let input = r#"model_provider = "chat_only" model = "gpt-5.1-codex" [model_providers.chat_only] name = "Chat Only" base_url = "https://example.com/v1" wire_api = "chat" "#; let result = update_codex_toml_field(input, "wire_api", "responses").unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); let provider = parsed .get("model_providers") .and_then(|v| v.get("chat_only")) .expect("model_providers.chat_only should exist"); assert_eq!( provider.get("wire_api").and_then(|v| v.as_str()), Some("responses") ); assert_eq!( provider.get("base_url").and_then(|v| v.as_str()), Some("https://example.com/v1") ); assert!(parsed.get("wire_api").is_none()); } #[test] fn base_url_creates_section_when_missing() { let input = r#"model_provider = "custom" model = "gpt-4" "#; let result = update_codex_toml_field(input, "base_url", "https://custom.api/v1").unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); let base_url = parsed .get("model_providers") .and_then(|v| v.get("custom")) .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()) .expect("should create section and set base_url"); assert_eq!(base_url, "https://custom.api/v1"); } #[test] fn base_url_falls_back_to_top_level_without_model_provider() { let input = r#"model = "gpt-4" "#; let result = update_codex_toml_field(input, "base_url", "https://fallback.api/v1").unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); let base_url = parsed .get("base_url") .and_then(|v| v.as_str()) .expect("should set top-level base_url"); assert_eq!(base_url, "https://fallback.api/v1"); } #[test] fn clearing_base_url_removes_only_from_correct_section() { let input = r#"model_provider = "any" [model_providers.any] name = "any" base_url = "https://old.api/v1" wire_api = "responses" [mcp_servers.context7] command = "npx" "#; let result = update_codex_toml_field(input, "base_url", "").unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); // base_url removed from model_providers.any let any_section = parsed .get("model_providers") .and_then(|v| v.get("any")) .expect("model_providers.any should exist"); assert!(any_section.get("base_url").is_none()); // wire_api preserved assert_eq!( any_section.get("wire_api").and_then(|v| v.as_str()), Some("responses") ); // mcp_servers untouched assert!(parsed.get("mcp_servers").is_some()); } #[test] fn model_field_operates_on_top_level() { let input = r#"model_provider = "any" model = "gpt-4" [model_providers.any] name = "any" "#; let result = update_codex_toml_field(input, "model", "gpt-5").unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); assert_eq!(parsed.get("model").and_then(|v| v.as_str()), Some("gpt-5")); // Clear model let result2 = update_codex_toml_field(&result, "model", "").unwrap(); let parsed2: toml::Value = toml::from_str(&result2).unwrap(); assert!(parsed2.get("model").is_none()); } #[test] fn preserves_comments_and_whitespace() { let input = r#"# My Codex config model_provider = "any" model = "gpt-4" # Provider section [model_providers.any] name = "any" base_url = "https://old.api/v1" "#; let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap(); // Comments should be preserved assert!(result.contains("# My Codex config")); assert!(result.contains("# Provider section")); } #[test] fn does_not_misplace_when_profiles_section_follows() { let input = r#"model_provider = "any" [model_providers.any] name = "any" base_url = "https://old.api/v1" [profiles.default] model = "gpt-4" "#; let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); // base_url in correct section let base_url = parsed .get("model_providers") .and_then(|v| v.get("any")) .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()); assert_eq!(base_url, Some("https://new.api/v1")); // profiles section untouched let profile_model = parsed .get("profiles") .and_then(|v| v.get("default")) .and_then(|v| v.get("model")) .and_then(|v| v.as_str()); assert_eq!(profile_model, Some("gpt-4")); } #[test] fn remove_base_url_if_predicate() { let input = r#"model_provider = "any" [model_providers.any] name = "any" base_url = "http://127.0.0.1:5000/v1" wire_api = "responses" "#; let result = remove_codex_toml_base_url_if(input, |url| url.starts_with("http://127.0.0.1")); let parsed: toml::Value = toml::from_str(&result).unwrap(); let any_section = parsed .get("model_providers") .and_then(|v| v.get("any")) .unwrap(); assert!(any_section.get("base_url").is_none()); assert_eq!( any_section.get("wire_api").and_then(|v| v.as_str()), Some("responses") ); } #[test] fn remove_base_url_if_keeps_non_matching() { let input = r#"model_provider = "any" [model_providers.any] base_url = "https://production.api/v1" "#; let result = remove_codex_toml_base_url_if(input, |url| url.starts_with("http://127.0.0.1")); let parsed: toml::Value = toml::from_str(&result).unwrap(); let base_url = parsed .get("model_providers") .and_then(|v| v.get("any")) .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()); assert_eq!(base_url, Some("https://production.api/v1")); } #[test] fn codex_model_catalog_uses_provider_models_and_context() { let template = json!({ "slug": "gpt-5.5", "display_name": "GPT-5.5", "description": "Frontier model", "base_instructions": "gpt-5.5 base instructions", "model_messages": { "instructions_template": "gpt-5.5 instructions template", "instructions_variables": { "personality_default": "", "personality_friendly": "", "personality_pragmatic": "" } }, "additional_speed_tiers": ["fast"], "service_tiers": [ { "id": "priority", "name": "Fast", "description": "1.5x speed, increased usage" } ], "availability_nux": { "message": "GPT-5.5 is now available." }, "upgrade": { "target": "gpt-5.5" }, "context_window": 272000, "max_context_window": 272000 }); let settings = json!({ "modelCatalog": { "models": [ { "model": "deepseek-v4-flash", "displayName": "DeepSeek V4 Flash", "contextWindow": "64000" }, { "model": "kimi-k2", "display_name": "Kimi K2" } ] } }); let specs = codex_catalog_model_specs(&settings, r#"model_context_window = 128000"#); let catalog = codex_model_catalog_from_specs(&specs, &template); let models = catalog .get("models") .and_then(|value| value.as_array()) .expect("models should be an array"); assert_eq!(models.len(), 2); assert_eq!( models[0].get("slug").and_then(|value| value.as_str()), Some("deepseek-v4-flash") ); assert_eq!( models[0] .get("context_window") .and_then(|value| value.as_u64()), Some(64_000) ); assert_eq!( models[1] .get("context_window") .and_then(|value| value.as_u64()), Some(128_000) ); assert!( models[0].get("model_messages").is_some(), "Codex requires model_messages in custom catalogs" ); assert_eq!( models[0] .get("base_instructions") .and_then(|value| value.as_str()), Some("gpt-5.5 base instructions") ); assert_eq!( models[0].get("model_messages"), template.get("model_messages"), "custom catalog entries should keep the gpt-5.5 agent template" ); assert_eq!( models[0].get("additional_speed_tiers"), Some(&json!([])), "generated third-party entries should not inherit OpenAI speed tiers" ); assert!( models[0] .get("availability_nux") .is_some_and(|value| value.is_null()), "generated third-party entries should not inherit GPT-5.5 launch messaging" ); } #[test] fn model_catalog_json_field_operates_on_top_level() { let input = r#"model_provider = "any" [model_providers.any] name = "any" "#; let catalog_path = Path::new("/tmp/cc-switch-model-catalog.json"); let result = set_codex_model_catalog_json_field(input, Some(catalog_path)).unwrap(); let parsed: toml::Value = toml::from_str(&result).unwrap(); assert_eq!( parsed .get("model_catalog_json") .and_then(|value| value.as_str()), Some("/tmp/cc-switch-model-catalog.json") ); assert!( parsed .get("model_providers") .and_then(|value| value.get("any")) .and_then(|value| value.get("model_catalog_json")) .is_none(), "model_catalog_json should stay top-level" ); } }