fix(codex): infer image capability for generated catalogs and resync takeover live on save

Mapped GPT models were rejected by Codex clients with "model does not
support image inputs". Two root causes:

- Catalog entries for native-Responses/Anthropic providers cloned a
  template whose input_modalities defaulted to ["text"], so every mapped
  model was advertised text-only. model_catalog_json replaces Codex's
  built-in model table wholesale, and both the TUI and the IDE extension
  block images pre-send when the current model is found without "image".
- Editing the current Codex provider during proxy takeover only refreshed
  the DB backup, so removing the mapping left a stale model_catalog_json
  pointer (and its text-only catalog file) active in live config.

Changes:

- New shared model_capabilities module: explicit row declaration first,
  then a confirmed text-only registry (exact tail match only — prefix
  matching removed, variants enumerated so future -vl/-vision models fail
  open), everything else unknown.
- Catalog generation writes input_modalities from that inference for all
  tool profiles: unknown models fail open to ["text","image"]; only
  confirmed text-only models are advertised as ["text"], giving users a
  clear client-side prompt instead of silent image stripping.
- Live catalog reverse-import collapses modalities that match current
  inference, so registry corrections are not frozen into hidden row
  overrides and the rectifier's heuristic opt-out keeps working.
- Saving the current Codex provider while takeover owns live now
  re-projects the live config (mirrors the hot-switch path), so mapping
  edits and removals take effect immediately.
- Media rectifier delegates to the shared module; its preflight toggle is
  documented (4 locales) as proxy-request-only, never affecting catalog
  capability declarations.
This commit is contained in:
Jason
2026-07-13 16:27:51 +08:00
parent 618723b42f
commit ac52c851bf
14 changed files with 627 additions and 193 deletions
+153 -19
View File
@@ -6,6 +6,7 @@ use crate::config::{
write_json_file, write_text_file,
};
use crate::error::AppError;
use crate::model_capabilities::{image_input_capability_from_modalities, ImageInputCapability};
use serde_json::{json, Value};
use std::fs;
use std::process::Command;
@@ -437,6 +438,17 @@ fn extract_codex_top_level_u64(config_text: &str, field: &str) -> Option<u64> {
.filter(|value| *value > 0)
}
fn codex_catalog_input_modalities(
model: &str,
declared_modalities: Option<&[String]>,
) -> Vec<String> {
let modalities = match image_input_capability_from_modalities(model, declared_modalities) {
ImageInputCapability::Unsupported => &["text"][..],
ImageInputCapability::Supported | ImageInputCapability::Unknown => &["text", "image"][..],
};
modalities.iter().map(|item| (*item).to_string()).collect()
}
fn codex_catalog_model_entry(
template: &Value,
spec: &CodexCatalogModelSpec,
@@ -459,6 +471,18 @@ fn codex_catalog_model_entry(
entry_obj.insert("availability_nux".to_string(), Value::Null);
entry_obj.insert("upgrade".to_string(), Value::Null);
// Image support is a model capability, not a tool-profile capability.
// Trust hidden preset metadata first, then the confirmed text-only registry;
// every unknown model fails open so GPT/relay aliases are never declared
// text-only merely because a template had a conservative default.
entry_obj.insert(
"input_modalities".to_string(),
json!(codex_catalog_input_modalities(
&spec.model,
spec.input_modalities.as_deref(),
)),
);
if profile != CodexCatalogToolProfile::ProxyChat {
// Native `/responses` and Anthropic gateways reject / drop Codex's freeform
// `apply_patch` (type=="custom") tool. Strip any key that would make Codex
@@ -491,9 +515,6 @@ fn codex_catalog_model_entry(
if let Some(parallel) = spec.supports_parallel_tool_calls {
entry_obj.insert("supports_parallel_tool_calls".to_string(), json!(parallel));
}
if let Some(modalities) = &spec.input_modalities {
entry_obj.insert("input_modalities".to_string(), json!(modalities));
}
}
entry
@@ -507,8 +528,9 @@ struct CodexCatalogModelSpec {
/// Per-row override for the native template's `supports_parallel_tool_calls`
/// (e.g. MiniMax=true, MiMo=false). Only consulted for `NativeResponses`.
supports_parallel_tool_calls: Option<bool>,
/// Per-row override for the native template's `input_modalities`
/// (e.g. `["text","image"]`). Only consulted for `NativeResponses`.
/// Hidden per-row capability declaration from built-in provider metadata.
/// When omitted, all catalog profiles consult the shared text-only model
/// registry and otherwise default to `["text", "image"]`.
input_modalities: Option<Vec<String>>,
/// Per-row override for the native template's `base_instructions` (the
/// model identity / system preamble). Carries each vendor's OFFICIAL value
@@ -1000,7 +1022,7 @@ 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"? }, ...] }`.
/// `{ "models": [{ "model", "displayName"?, "contextWindow"?, hidden overrides... }, ...] }`.
///
/// We only reverse-parse catalogs whose `model_catalog_json` path is the
/// cc-switchgenerated file (identified by filename
@@ -1008,14 +1030,14 @@ pub fn prepare_codex_config_text_with_model_catalog(
/// 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
/// `displayName`, `contextWindow`, and `inputModalities` 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.
/// display_name, `model_context_window` or 128_000 for context_window, and the
/// shared confirmed-text-only inference for input modalities). 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 also collapses
/// to blank, but the next save writes the same fallback so behavior is stable.
///
/// All failure modes (missing file, parse error, no `model_catalog_json`,
/// entries without `slug`) collapse to `Ok(None)` so callers can treat this
@@ -1070,9 +1092,8 @@ pub(crate) fn resolve_cc_switch_catalog_path(
}
/// 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.
/// frontend's simplified model-mapping 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())?;
@@ -1112,8 +1133,7 @@ fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) ->
}
// Preserve native-profile per-row overrides so a DB-SSOT-missing
// fallback round-trip doesn't silently drop them (they are ignored by
// the ProxyChat profile, so carrying them is harmless).
// fallback round-trip doesn't silently drop them.
if let Some(parallel) = entry
.get("supports_parallel_tool_calls")
.and_then(|v| v.as_bool())
@@ -1126,7 +1146,8 @@ fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) ->
.filter_map(|m| m.as_str())
.map(str::to_string)
.collect();
if !mods.is_empty() {
let inferred = codex_catalog_input_modalities(model, None);
if !mods.is_empty() && mods != inferred {
obj.insert("inputModalities".to_string(), json!(mods));
}
}
@@ -2797,6 +2818,85 @@ base_url = "https://production.api/v1"
);
}
#[test]
fn catalog_infers_image_input_independently_of_tool_profile() {
// Start from a deliberately text-only template to prove that every
// profile overwrites template defaults with shared capability logic.
let template = json!({
"input_modalities": ["text"],
"apply_patch_tool_type": "freeform"
});
let specs = vec![
CodexCatalogModelSpec {
model: "gpt-5.4".to_string(),
display_name: "GPT 5.4".to_string(),
context_window: 128_000,
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
},
CodexCatalogModelSpec {
model: "deepseek/deepseek-v4-pro".to_string(),
display_name: "DeepSeek V4 Pro".to_string(),
context_window: 128_000,
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
},
CodexCatalogModelSpec {
model: "glm-5.2v".to_string(),
display_name: "GLM 5.2V".to_string(),
context_window: 128_000,
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
},
CodexCatalogModelSpec {
model: "deepseek-v4-flash".to_string(),
display_name: "Explicit Visual Override".to_string(),
context_window: 128_000,
supports_parallel_tool_calls: None,
input_modalities: Some(vec!["text".to_string(), "image".to_string()]),
base_instructions: None,
},
CodexCatalogModelSpec {
model: "custom-text-alias".to_string(),
display_name: "Explicit Text Override".to_string(),
context_window: 128_000,
supports_parallel_tool_calls: None,
input_modalities: Some(vec!["text".to_string()]),
base_instructions: None,
},
];
for profile in [
CodexCatalogToolProfile::ProxyChat,
CodexCatalogToolProfile::NativeResponses,
CodexCatalogToolProfile::Anthropic,
] {
let catalog = codex_model_catalog_from_specs(&specs, &template, profile);
let models = catalog["models"].as_array().expect("models array");
let modalities = |slug: &str| {
models
.iter()
.find(|entry| entry["slug"] == slug)
.and_then(|entry| entry.get("input_modalities"))
.cloned()
.unwrap_or(Value::Null)
};
assert_eq!(modalities("gpt-5.4"), json!(["text", "image"]));
assert_eq!(modalities("deepseek/deepseek-v4-pro"), json!(["text"]));
assert_eq!(modalities("glm-5.2v"), json!(["text", "image"]));
assert_eq!(
modalities("deepseek-v4-flash"),
json!(["text", "image"]),
"explicit provider metadata must override the text-only registry"
);
assert_eq!(modalities("custom-text-alias"), json!(["text"]));
}
}
#[test]
fn native_responses_catalog_always_carries_base_instructions() {
// Regression guard for the "missing field `base_instructions`" parse
@@ -3146,6 +3246,40 @@ web_search = "disabled"
);
}
#[test]
fn build_simplified_catalog_squashes_inferred_modalities_and_keeps_overrides() {
let catalog = r#"{
"models": [
{ "slug": "gpt-5.4", "input_modalities": ["text", "image"] },
{ "slug": "deepseek-v4-pro", "input_modalities": ["text"] },
{ "slug": "gpt-text-override", "input_modalities": ["text"] },
{ "slug": "deepseek-v4-flash", "input_modalities": ["text", "image"] }
]
}"#;
let result = build_simplified_catalog_from_texts("", catalog).expect("entries");
let models = result.get("models").unwrap().as_array().unwrap();
assert!(
models[0].get("inputModalities").is_none(),
"GPT text+image is inferred and must not become a sticky hidden override"
);
assert!(
models[1].get("inputModalities").is_none(),
"confirmed text-only capability is inferred and must remain registry-driven"
);
assert_eq!(
models[2].get("inputModalities"),
Some(&json!(["text"])),
"an unknown model explicitly forced to text-only must round-trip"
);
assert_eq!(
models[3].get("inputModalities"),
Some(&json!(["text", "image"])),
"an explicit image override for a registered text-only model must round-trip"
);
}
#[test]
fn build_simplified_catalog_returns_none_when_unparseable() {
assert!(build_simplified_catalog_from_texts("", "not json").is_none());