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
+151 -9
View File
@@ -1108,6 +1108,135 @@ command = "legacy-cmd"
);
}
#[tokio::test]
#[serial]
async fn update_current_codex_provider_refreshes_and_clears_catalog_during_takeover() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let state = AppState::new(db.clone());
let mut original = Provider::with_id(
"p1".into(),
"Codex A".into(),
json!({
"auth": { "OPENAI_API_KEY": "token-a" },
"config": r#"model_provider = "custom"
model = "old-model"
[model_providers.custom]
name = "Codex A"
base_url = "https://api.a.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#,
"modelCatalog": {
"models": [{ "model": "old-model" }]
}
}),
None,
);
original.meta = Some(ProviderMeta {
api_format: Some("openai_responses".into()),
..Default::default()
});
db.save_provider("codex", &original).expect("save provider");
db.set_current_provider("codex", "p1")
.expect("set current provider");
crate::settings::set_current_provider(&AppType::Codex, Some("p1"))
.expect("set local current provider");
db.update_proxy_config(ProxyConfig {
live_takeover_active: true,
listen_port: 0,
..Default::default()
})
.await
.expect("update proxy config");
{
let mut config = db
.get_proxy_config_for_app("codex")
.await
.expect("get app proxy config");
config.enabled = true;
db.update_proxy_config_for_app(config)
.await
.expect("enable Codex proxy config");
}
db.save_live_backup(
"codex",
&serde_json::to_string(&original.settings_config).expect("serialize backup"),
)
.await
.expect("seed live backup");
state
.proxy_service
.start()
.await
.expect("start proxy service");
state
.proxy_service
.sync_codex_live_from_provider_while_proxy_active(&original)
.await
.expect("seed taken-over Codex live config");
assert!(
state
.proxy_service
.detect_takeover_in_live_config_for_app(&AppType::Codex),
"seeded Codex live config should be recognized as takeover-owned"
);
let mut updated = original.clone();
updated.settings_config["config"] = json!(
r#"model_provider = "custom"
model = "gpt-5.4"
[model_providers.custom]
name = "Codex A"
base_url = "https://api.updated.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
);
updated.settings_config["modelCatalog"] = json!({
"models": [{ "model": "gpt-5.4", "displayName": "GPT 5.4" }]
});
ProviderService::update(&state, AppType::Codex, None, updated.clone())
.expect("update current Codex provider mapping");
let catalog_path = crate::codex_config::get_codex_model_catalog_path();
let catalog: Value = read_json_file(&catalog_path).expect("read generated catalog");
assert_eq!(catalog["models"][0]["slug"], "gpt-5.4");
assert_eq!(
catalog["models"][0]["input_modalities"],
json!(["text", "image"]),
"unknown/GPT models must fail open to image input"
);
let live_config = fs::read_to_string(crate::codex_config::get_codex_config_path())
.expect("read Codex config.toml");
assert!(live_config.contains("model_catalog_json"));
updated.settings_config["modelCatalog"] = json!({ "models": [] });
ProviderService::update(&state, AppType::Codex, None, updated)
.expect("remove current Codex provider mapping");
let live_config = fs::read_to_string(crate::codex_config::get_codex_config_path())
.expect("read Codex config.toml after mapping removal");
assert!(
!live_config.contains("model_catalog_json"),
"removing mappings during takeover must clear the stale catalog pointer"
);
state
.proxy_service
.stop()
.await
.expect("stop proxy service");
}
#[cfg(any(target_os = "macos", windows))]
#[tokio::test]
#[serial]
@@ -2191,15 +2320,28 @@ impl ProviderService {
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
}
if matches!(app_type, AppType::Claude)
&& futures::executor::block_on(state.proxy_service.is_running())
{
futures::executor::block_on(
state
.proxy_service
.sync_claude_live_from_provider_while_proxy_active(&provider),
)
.map_err(|e| AppError::Message(format!("同步 Claude Live 配置失败: {e}")))?;
if futures::executor::block_on(state.proxy_service.is_running()) {
if matches!(app_type, AppType::Claude) {
futures::executor::block_on(
state
.proxy_service
.sync_claude_live_from_provider_while_proxy_active(&provider),
)
.map_err(|e| {
AppError::Message(format!("同步 Claude Live 配置失败: {e}"))
})?;
} else if live_taken_over && matches!(app_type, AppType::Codex) {
// Codex model mappings are projected into a generated
// model_catalog_json file. Refresh takeover-owned Live
// immediately so adding/removing mappings cannot leave
// the previous catalog pointer and capabilities active.
futures::executor::block_on(
state
.proxy_service
.sync_codex_live_from_provider_while_proxy_active(&provider),
)
.map_err(|e| AppError::Message(format!("同步 Codex Live 配置失败: {e}")))?;
}
}
} else {
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;