Files
CC-Switch/src-tauri/src/codex_config.rs
T
Jason b44f83f7c5 feat: unify Codex third-party providers into stable "custom" history bucket
Codex filters resume history by `model_provider`, so switching between
provider-specific ids like `rightcode` and `aihubmix` made past sessions
appear to vanish. Collapse all third-party providers into a single
stable bucket so cross-switch history stays visible.

- Normalize live `model_provider` to "custom" on every Codex write
  (reserved built-in ids like openai/ollama are preserved).
- Add device-level one-shot migration that rewrites historical JSONL
  session files and the `state_5.sqlite` threads table from legacy
  provider ids into the "custom" bucket. Backs up originals under
  `~/.cc-switch/backups/codex-history-provider-migration-v1/` and uses
  the SQLite Backup API for the state DB.
- Record completion in `settings.json` under `localMigrations` so the
  migration is strictly idempotent across launches.
- Update Codex provider preset templates to emit `model_provider = "custom"`
  out of the box.
2026-05-25 22:20:33 +08:00

1351 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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::<toml::Table>(&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<String, AppError> {
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::<toml::Table>(text)
.map(|_| ())
.map_err(|e| AppError::toml(Path::new("config.toml"), e))
}
/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)
pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
let s = read_codex_config_text()?;
validate_config_toml(&s)?;
Ok(s)
}
fn active_codex_model_provider_id(doc: &DocumentMut) -> Option<String> {
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<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
}
}
fn codex_model_provider_id_with_table_from_config(
config_text: &str,
) -> Result<Option<String>, AppError> {
if config_text.trim().is_empty() {
return Ok(None);
}
let doc = config_text
.parse::<DocumentMut>()
.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<String, AppError> {
if config_text.trim().is_empty() {
return Ok(config_text.to_string());
}
let mut doc = config_text
.parse::<DocumentMut>()
.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<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_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<String, AppError> {
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::<DocumentMut>()
.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<u64> {
match value {
Some(Value::Number(n)) => n.as_u64().filter(|v| *v > 0),
Some(Value::String(s)) => s.trim().parse::<u64>().ok().filter(|v| *v > 0),
_ => None,
}
}
fn extract_codex_top_level_u64(config_text: &str, field: &str) -> Option<u64> {
let doc = config_text.parse::<toml::Value>().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<CodexCatalogModelSpec> {
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<Value> {
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<Option<Value>, 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<Option<Value>, 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<Value, AppError> {
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<Value> = 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<Option<Value>, 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<String, AppError> {
let mut doc = config_text
.parse::<DocumentMut>()
.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<String, AppError> {
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.<current>].base_url` if `model_provider` exists,
/// otherwise falls back to top-level `base_url`.
/// - `"wire_api"`: writes to `[model_providers.<current>].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<String, String> {
let mut doc = toml_str
.parse::<DocumentMut>()
.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.<provider_key>] 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::<DocumentMut>() {
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"
);
}
}