fix: preserve Codex model catalog on live read of active provider

Editing the currently active Codex provider triggered `read_live_settings`,
which returned only { auth, config }, omitting `modelCatalog`. The form's
mapping table then initialized to empty, and a subsequent save wiped the
DB's `modelCatalog` field — silently destroying user-configured model
mappings after every CC Switch restart.

The mappings already live on disk as a projection in
`~/.codex/cc-switch-model-catalog.json` (pointed at by `config.toml`'s
`model_catalog_json`). Reverse-parse that file so live reads return the
same shape the save path writes.

- Add `read_codex_model_catalog_simplified_from_live` to recover
  `{ model, displayName?, contextWindow? }` entries from the catalog file.
- Skip user-managed external catalogs (filename != cc-switch-model-catalog.json)
  so we don't downgrade their richer structure to the simplified table.
- Squash display_name == slug and context_window == default (config's
  `model_context_window`, or 128_000 fallback) so blank inputs round-trip
  back to blank instead of materializing fallback values in the UI.
- Collapse all failure modes (missing file, parse error, no matching
  field) to Ok(None) so the editor stays openable when the projection
  file is absent or corrupt.
- Wire the new function into `read_live_settings`'s Codex branch.
- Cover the new pure helpers with 7 unit tests in codex_config::tests.
This commit is contained in:
Jason
2026-05-20 17:36:55 +08:00
Unverified
parent b44f83f7c5
commit ad8bdf16ae
2 changed files with 255 additions and 1 deletions
+241
View File
@@ -651,6 +651,128 @@ pub fn prepare_codex_config_text_with_model_catalog(
}
}
/// Reverse of `prepare_codex_config_text_with_model_catalog`: read the
/// cc-switchmaintained catalog file referenced by `~/.codex/config.toml` and
/// convert it back into the simplified shape the frontend table uses:
/// `{ "models": [{ "model", "displayName"?, "contextWindow"? }, ...] }`.
///
/// We only reverse-parse catalogs whose `model_catalog_json` path is the
/// cc-switchgenerated file (identified by filename
/// `cc-switch-model-catalog.json`). A user-managed external catalog file is
/// left alone — surfacing its richer structure as the simplified table would
/// be a downgrade we can't safely round-trip.
///
/// `displayName` and `contextWindow` are omitted from the returned entry when
/// the on-disk value matches the fallback that
/// `codex_model_catalog_from_settings` injects for unset inputs (slug for
/// display_name, `model_context_window` or 128_000 for context_window). This
/// preserves the "user left it blank" intent across round-trip; an unavoidable
/// edge case is that a user-typed value that happens to equal the fallback
/// will also collapse to blank, but the next save writes the same fallback so
/// behavior stays consistent.
///
/// All failure modes (missing file, parse error, no `model_catalog_json`,
/// entries without `slug`) collapse to `Ok(None)` so callers can treat this
/// as best-effort enrichment without making `read_live_settings` brittle.
pub fn read_codex_model_catalog_simplified_from_live() -> Result<Option<Value>, AppError> {
let config_text = read_codex_config_text()?;
let generated_path = get_codex_model_catalog_path();
let Some(catalog_path) = resolve_cc_switch_catalog_path(&config_text, &generated_path) else {
return Ok(None);
};
if !catalog_path.exists() {
return Ok(None);
}
let Ok(catalog_text) = fs::read_to_string(&catalog_path) else {
return Ok(None);
};
Ok(build_simplified_catalog_from_texts(
&config_text,
&catalog_text,
))
}
/// Given `config.toml` text, resolve the on-disk path of the cc-switchowned
/// catalog file (returns `None` if `model_catalog_json` is absent or points at
/// a file we don't own). Relative paths fall back to `generated_path`.
fn resolve_cc_switch_catalog_path(config_text: &str, generated_path: &Path) -> Option<PathBuf> {
if config_text.trim().is_empty() {
return None;
}
let doc = config_text.parse::<DocumentMut>().ok()?;
let catalog_path_str = doc
.get("model_catalog_json")
.and_then(|item| item.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())?;
let referenced_path = Path::new(catalog_path_str);
let is_cc_switch_owned = catalog_path_str == generated_path.to_string_lossy().as_ref()
|| referenced_path.file_name().and_then(|name| name.to_str())
== Some(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME);
if !is_cc_switch_owned {
return None;
}
if referenced_path.is_absolute() {
Some(referenced_path.to_path_buf())
} else {
Some(generated_path.to_path_buf())
}
}
/// Pure reverse-parsing core: convert Codex catalog JSON text back into the
/// frontend's simplified `{ models: [{ model, displayName?, contextWindow? }] }`
/// shape. Returns `None` when the catalog is unparseable, has no `models`
/// array, or yields zero valid entries.
fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) -> Option<Value> {
let catalog: Value = serde_json::from_str(catalog_text).ok()?;
let models = catalog.get("models").and_then(|m| m.as_array())?;
let default_context_window =
extract_codex_top_level_u64(config_text, "model_context_window").unwrap_or(128_000);
let mut entries = Vec::with_capacity(models.len());
for entry in models {
let Some(model) = entry
.get("slug")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
else {
continue;
};
let mut obj = serde_json::Map::new();
obj.insert("model".to_string(), json!(model));
if let Some(display_name) = entry
.get("display_name")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty() && *s != model)
{
obj.insert("displayName".to_string(), json!(display_name));
}
if let Some(context_window) = entry
.get("context_window")
.and_then(|v| v.as_u64())
.filter(|v| *v > 0 && *v != default_context_window)
{
obj.insert("contextWindow".to_string(), json!(context_window));
}
entries.push(Value::Object(obj));
}
if entries.is_empty() {
return None;
}
Some(json!({ "models": entries }))
}
/// 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(
@@ -1347,4 +1469,123 @@ name = "any"
"model_catalog_json should stay top-level"
);
}
#[test]
fn resolve_catalog_path_returns_none_when_config_missing_field() {
let generated = PathBuf::from("/tmp/.codex/cc-switch-model-catalog.json");
assert!(resolve_cc_switch_catalog_path("", &generated).is_none());
assert!(
resolve_cc_switch_catalog_path("model = \"gpt-5\"", &generated).is_none(),
"no model_catalog_json field should yield None"
);
}
#[test]
fn resolve_catalog_path_accepts_cc_switch_owned_file() {
let generated = PathBuf::from("/tmp/.codex/cc-switch-model-catalog.json");
let config = r#"model_catalog_json = "/tmp/.codex/cc-switch-model-catalog.json"
"#;
let resolved = resolve_cc_switch_catalog_path(config, &generated).expect("path resolves");
assert_eq!(resolved, generated);
}
#[test]
fn resolve_catalog_path_rejects_user_owned_external_file() {
let generated = PathBuf::from("/tmp/.codex/cc-switch-model-catalog.json");
let config = r#"model_catalog_json = "/Users/me/.codex/my-handwritten-catalog.json"
"#;
assert!(
resolve_cc_switch_catalog_path(config, &generated).is_none(),
"external catalog files should be left alone"
);
}
#[test]
fn build_simplified_catalog_round_trips_user_input() {
let config = "";
let catalog = r#"{
"models": [
{ "slug": "deepseek-v4-pro", "display_name": "deepseek-v4-pro", "context_window": 1000000 },
{ "slug": "deepseek-v4-flash", "display_name": "DeepSeek Flash", "context_window": 1000000 }
]
}"#;
let result = build_simplified_catalog_from_texts(config, catalog).expect("entries found");
let models = result
.get("models")
.and_then(|m| m.as_array())
.expect("models array");
assert_eq!(models.len(), 2);
// First entry: display_name == slug → displayName squashed; explicit
// context_window != default 128_000 → preserved.
assert_eq!(
models[0].get("model").and_then(|v| v.as_str()),
Some("deepseek-v4-pro")
);
assert!(models[0].get("displayName").is_none());
assert_eq!(
models[0].get("contextWindow").and_then(|v| v.as_u64()),
Some(1_000_000)
);
// Second entry: display_name distinct from slug → preserved.
assert_eq!(
models[1].get("displayName").and_then(|v| v.as_str()),
Some("DeepSeek Flash")
);
}
#[test]
fn build_simplified_catalog_squashes_default_context_window() {
// Default fallback is 128_000 when config.toml has no model_context_window.
let catalog = r#"{
"models": [{ "slug": "kimi", "display_name": "kimi", "context_window": 128000 }]
}"#;
let result = build_simplified_catalog_from_texts("", catalog).expect("entry");
let entry = &result.get("models").unwrap().as_array().unwrap()[0];
assert!(
entry.get("contextWindow").is_none(),
"default 128_000 should be squashed so the form shows blank, matching the user's blank input"
);
}
#[test]
fn build_simplified_catalog_respects_explicit_model_context_window() {
// When config.toml sets model_context_window, that becomes the default fallback.
let config = r#"model_context_window = 200000
"#;
let catalog = r#"{
"models": [
{ "slug": "a", "display_name": "a", "context_window": 200000 },
{ "slug": "b", "display_name": "b", "context_window": 500000 }
]
}"#;
let result = build_simplified_catalog_from_texts(config, catalog).expect("entries");
let models = result.get("models").unwrap().as_array().unwrap();
// Matches default → squashed.
assert!(models[0].get("contextWindow").is_none());
// Different from default → preserved.
assert_eq!(
models[1].get("contextWindow").and_then(|v| v.as_u64()),
Some(500_000)
);
}
#[test]
fn build_simplified_catalog_returns_none_when_unparseable() {
assert!(build_simplified_catalog_from_texts("", "not json").is_none());
assert!(build_simplified_catalog_from_texts("", "{}").is_none());
assert!(
build_simplified_catalog_from_texts("", r#"{"models": []}"#).is_none(),
"empty models array should yield None so the field is not inserted at all"
);
assert!(
build_simplified_catalog_from_texts(
"",
r#"{"models": [{"display_name": "no slug"}]}"#,
)
.is_none(),
"entries lacking slug are skipped; a fully-skipped catalog yields None"
);
}
}
+14 -1
View File
@@ -961,7 +961,20 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
}
let auth: Value = read_json_file(&auth_path)?;
let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?;
Ok(json!({ "auth": auth, "config": cfg_text }))
let mut result = json!({ "auth": auth, "config": cfg_text });
// `modelCatalog` is a cc-switch private field that lives only in
// the DB SSOT plus the `cc-switch-model-catalog.json` projection
// file — it is never inlined into `auth.json` or `config.toml`.
// Reverse-parse the projection so the edit form for the active
// Codex provider doesn't see an empty mapping table.
if let Ok(Some(model_catalog)) =
crate::codex_config::read_codex_model_catalog_simplified_from_live()
{
if let Some(obj) = result.as_object_mut() {
obj.insert("modelCatalog".to_string(), model_catalog);
}
}
Ok(result)
}
AppType::Claude => {
let path = get_claude_settings_path();