mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 02:14:43 +08:00
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:
+153
-19
@@ -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-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"? }, ...] }`.
|
||||
/// `{ "models": [{ "model", "displayName"?, "contextWindow"?, hidden overrides... }, ...] }`.
|
||||
///
|
||||
/// We only reverse-parse catalogs whose `model_catalog_json` path is the
|
||||
/// cc-switch–generated 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());
|
||||
|
||||
Reference in New Issue
Block a user