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