Skip to content

Commit d63031f

Browse files
committed
fix(codex): decouple provider catalog from menu mapping
Root cause: the Codex provider form reused takeoverEnabled for both catalog/context editing and Codex /model menu projection, while backend live projection treated any modelCatalog as a model_catalog_json signal. Native Responses providers therefore could not fetch/save /models context unless the menu-mapping toggle was enabled, and saving a catalog risked unintentionally changing the Codex model menu. Fix: add meta.codexLocalModelMapping as the independent projection flag, persist modelCatalog for non-official Codex providers regardless of that flag, and strip modelCatalog only when writing live Codex config with explicit mapping disabled. MultiRouter routes still force catalog projection, and old providers without the new flag retain legacy projection behavior. UI: rename the ambiguous toggle to 'Show in Codex /model menu', rename local model routing to Codex multi-model routing, keep the catalog/context editor visible when menu mapping is off, and highlight fetch/protocol probe actions. Validation: pnpm vitest run tests/components/CodexFormFields.test.tsx tests/components/ProviderForm.codexPreset.test.tsx tests/config/codexChatProviderPresets.test.ts; pnpm typecheck; cargo test --manifest-path src-tauri\\Cargo.toml codex_model_catalog_projection --lib -- --nocapture; cargo test --manifest-path src-tauri\\Cargo.toml provider_meta_roundtrips_codex_local_model_mapping --lib -- --nocapture; cargo test --manifest-path src-tauri\\Cargo.toml codex_live_projection --lib -- --nocapture; cargo fmt --manifest-path src-tauri\\Cargo.toml --check; git diff --check
1 parent 8a63dd1 commit d63031f

17 files changed

Lines changed: 445 additions & 101 deletions

File tree

memory.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# CC Switch Repository Memory
22

3+
## 2026-07-05 Codex Provider Catalog vs Menu Mapping Boundary
4+
5+
- 用户反馈新版 Codex provider 配置里,只有打开“需要本地路由映射”才展开模型列表;但 Responses 原生 provider 也应该能获取 `/models`、保存模型目录和上下文窗口。根因是前端把 `takeoverEnabled` 同时当成“目录/上下文编辑开关”和“Codex /model 菜单映射开关”,后端旧语义又把 `modelCatalog` 的存在直接解释成要写 `model_catalog_json`
6+
- 新不变量:`settingsConfig.modelCatalog` 是 cc-switch 的模型目录、上下文窗口、MultiRouter/子 Agent 候选元数据 SSOT;`meta.codexLocalModelMapping` 只控制单 provider 是否把该目录投射到 Codex `/model` 菜单和本地模型名映射。关闭菜单映射时仍要保存 catalog;开启 MultiRouter routes 时仍强制投射聚合 catalog。
7+
- UI 文案边界:把“需要本地路由映射”改成“在 Codex /model 菜单中显示”,把顶部“本地模型路由”改成“Codex 多模型路由”,明确前者是菜单显示/单 provider 模型名映射,后者是一个 provider 内按 `body.model` 分流到多上游。`获取模型列表``测试 Chat / Responses` 是配置主操作,不能被菜单映射开关隐藏。
8+
- 兼容边界:旧 provider 没有 `codexLocalModelMapping` 字段但已有 `modelCatalog` 时继续沿用旧行为投射,避免老用户升级后 `/model` 菜单消失;新 provider 显式保存 `false` 后,live 写入前会移除投射用的 `modelCatalog`,但 DB 里的目录元数据保持不变。
9+
310
## 2026-07-05 MultiRouter Wizard Nested Provider Dialog Layering
411

512
- 用户反馈 MultiRouter 配置向导里保存/新增 provider 时弹窗像没到最前或卡死。根因不是 provider 保存 API 卡住,而是向导内再打开新增 provider 后,新增面板内部的二级弹层仍按默认层级 portal 到 `document.body``CodexFormFields` 的混合协议拆分/route 编辑确认、`UniversalProviderFormModal``ConfirmDialog` 等可能被 `FullScreenPanel z-[140]` 或向导壳遮住。

src-tauri/src/provider.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,15 @@ pub struct ProviderMeta {
539539
/// Codex Responses -> Chat Completions reasoning capability metadata.
540540
#[serde(rename = "codexChatReasoning", skip_serializing_if = "Option::is_none")]
541541
pub codex_chat_reasoning: Option<CodexChatReasoningConfig>,
542+
/// Codex 单供应商模型目录是否投射为 `/model` 菜单映射。
543+
///
544+
/// `modelCatalog` 本身还承担模型目录、上下文窗口、多路路由候选等元数据职责;
545+
/// 这个开关只控制是否写出 `model_catalog_json` 以及对应的本地模型名映射。
546+
#[serde(
547+
rename = "codexLocalModelMapping",
548+
skip_serializing_if = "Option::is_none"
549+
)]
550+
pub codex_local_model_mapping: Option<bool>,
542551
/// Custom User-Agent for local proxy routing.
543552
#[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")]
544553
pub custom_user_agent: Option<String>,
@@ -593,6 +602,19 @@ impl ProviderMeta {
593602
self.codex_fast_mode.unwrap_or(false)
594603
}
595604

605+
/// 是否把 Codex `modelCatalog` 投射到真实 `config.toml`/模型菜单。
606+
///
607+
/// 旧数据没有 `codexLocalModelMapping` 字段,若已经存在 `modelCatalog` 则沿用旧行为继续投射;
608+
/// 新数据显式保存 `false` 后,目录仍保留在 DB 里,但不会改写 Codex `/model` 菜单。
609+
pub fn codex_model_catalog_projection_enabled(&self, settings_config: &Value) -> bool {
610+
if codex_routing_has_enabled_routes(settings_config) {
611+
return true;
612+
}
613+
614+
self.codex_local_model_mapping
615+
.unwrap_or_else(|| settings_config.get("modelCatalog").is_some())
616+
}
617+
596618
/// 经校验的 Provider 级自定义 User-Agent。见 [`parse_custom_user_agent`]。
597619
pub fn custom_user_agent_header(&self) -> Result<Option<HeaderValue>, InvalidHeaderValue> {
598620
parse_custom_user_agent(self.custom_user_agent.as_deref())
@@ -618,6 +640,26 @@ impl ProviderMeta {
618640
}
619641
}
620642

643+
/// 判断 Codex provider 是否启用了多上游路由;多路由需要强制投射模型目录供 Codex 菜单和路由解析使用。
644+
fn codex_routing_has_enabled_routes(settings_config: &Value) -> bool {
645+
let Some(routing) = settings_config.get("codexRouting") else {
646+
return false;
647+
};
648+
649+
if routing
650+
.get("enabled")
651+
.and_then(Value::as_bool)
652+
.is_some_and(|enabled| !enabled)
653+
{
654+
return false;
655+
}
656+
657+
routing
658+
.get("routes")
659+
.and_then(Value::as_array)
660+
.is_some_and(|routes| !routes.is_empty())
661+
}
662+
621663
impl ProviderManager {
622664
/// 获取所有供应商
623665
pub fn get_all_providers(&self) -> &IndexMap<String, Provider> {
@@ -1072,6 +1114,60 @@ mod tests {
10721114
assert_eq!(overrides.body.unwrap()["temperature"], 0.2);
10731115
}
10741116

1117+
#[test]
1118+
fn provider_meta_roundtrips_codex_local_model_mapping() {
1119+
let meta = ProviderMeta {
1120+
codex_local_model_mapping: Some(false),
1121+
..ProviderMeta::default()
1122+
};
1123+
1124+
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
1125+
assert_eq!(value["codexLocalModelMapping"], false);
1126+
assert!(value.get("codex_local_model_mapping").is_none());
1127+
1128+
let decoded: ProviderMeta =
1129+
serde_json::from_value(value).expect("deserialize ProviderMeta");
1130+
assert_eq!(decoded.codex_local_model_mapping, Some(false));
1131+
}
1132+
1133+
#[test]
1134+
fn codex_model_catalog_projection_respects_explicit_menu_mapping_flag() {
1135+
let settings = json!({
1136+
"modelCatalog": { "models": [{ "model": "gpt-5.5" }] }
1137+
});
1138+
1139+
let disabled = ProviderMeta {
1140+
codex_local_model_mapping: Some(false),
1141+
..ProviderMeta::default()
1142+
};
1143+
assert!(
1144+
!disabled.codex_model_catalog_projection_enabled(&settings),
1145+
"explicit false means catalog metadata should not be projected into Codex /model"
1146+
);
1147+
1148+
let legacy = ProviderMeta::default();
1149+
assert!(
1150+
legacy.codex_model_catalog_projection_enabled(&settings),
1151+
"legacy providers without the new flag keep the old modelCatalog projection behavior"
1152+
);
1153+
1154+
let routed = json!({
1155+
"modelCatalog": { "models": [{ "model": "gpt-5.5" }] },
1156+
"codexRouting": {
1157+
"enabled": true,
1158+
"routes": [{
1159+
"id": "relay",
1160+
"match": { "models": ["gpt-5.5-relay"] },
1161+
"upstream": { "apiFormat": "openai_responses" }
1162+
}]
1163+
}
1164+
});
1165+
assert!(
1166+
disabled.codex_model_catalog_projection_enabled(&routed),
1167+
"MultiRouter plans must still project the aggregate catalog even when the single-provider menu flag is false"
1168+
);
1169+
}
1170+
10751171
#[test]
10761172
fn provider_with_id_populates_defaults() {
10771173
let settings_config = json!({

src-tauri/src/services/provider/live.rs

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,25 @@ pub(crate) fn write_live_with_common_config(
560560
write_live_snapshot(app_type, &effective_provider)
561561
}
562562

563+
/// 为 Codex live 写入构造配置:保留 DB 中的 `modelCatalog` 元数据,但只有菜单映射开启时才投射到 live。
564+
fn codex_settings_for_live_projection(provider: &Provider) -> Value {
565+
let should_project = provider
566+
.meta
567+
.as_ref()
568+
.map(|meta| meta.codex_model_catalog_projection_enabled(&provider.settings_config))
569+
.unwrap_or_else(|| provider.settings_config.get("modelCatalog").is_some());
570+
571+
if should_project {
572+
return provider.settings_config.clone();
573+
}
574+
575+
let mut settings = provider.settings_config.clone();
576+
if let Some(obj) = settings.as_object_mut() {
577+
obj.remove("modelCatalog");
578+
}
579+
settings
580+
}
581+
563582
/// 只刷新 Codex provider 的 live `config.toml`,不覆盖当前 `auth.json`。
564583
///
565584
/// 接管态切回 official 时,`disable_takeover` 已经先把接管前的 live 登录态恢复到
@@ -572,14 +591,14 @@ pub(crate) fn write_codex_config_only_with_common_config(
572591
let mut effective_provider = provider.clone();
573592
effective_provider.settings_config =
574593
build_effective_settings_with_common_config(db, &AppType::Codex, provider)?;
575-
let settings = effective_provider
576-
.settings_config
594+
let settings_for_live = codex_settings_for_live_projection(&effective_provider);
595+
let settings = settings_for_live
577596
.as_object()
578597
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
579598
let config_text = settings.get("config").and_then(|value| value.as_str());
580599

581600
crate::codex_config::write_codex_provider_config_only_with_catalog(
582-
&effective_provider.settings_config,
601+
&settings_for_live,
583602
effective_provider.category.as_deref(),
584603
config_text,
585604
)
@@ -827,8 +846,8 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
827846
));
828847
}
829848
AppType::Codex => {
830-
let obj = provider
831-
.settings_config
849+
let settings_for_live = codex_settings_for_live_projection(provider);
850+
let obj = settings_for_live
832851
.as_object()
833852
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
834853
let auth = obj
@@ -837,7 +856,7 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
837856
let config_str = obj.get("config").and_then(|v| v.as_str());
838857

839858
crate::codex_config::write_codex_provider_live_with_catalog(
840-
&provider.settings_config,
859+
&settings_for_live,
841860
provider.category.as_deref(),
842861
auth,
843862
config_str,
@@ -1927,6 +1946,61 @@ web_search = true
19271946
assert!(!applied_config.contains("[model_providers.router]"));
19281947
}
19291948

1949+
#[test]
1950+
fn codex_live_projection_removes_catalog_when_menu_mapping_is_disabled() {
1951+
let mut provider = Provider::with_id(
1952+
"codex-native".to_string(),
1953+
"Native Responses".to_string(),
1954+
json!({
1955+
"auth": { "OPENAI_API_KEY": "sk-test" },
1956+
"config": "model_provider = \"native\"\n",
1957+
"modelCatalog": {
1958+
"models": [{ "model": "gpt-5.5", "contextWindow": 272000 }]
1959+
}
1960+
}),
1961+
None,
1962+
);
1963+
provider.meta = Some(crate::provider::ProviderMeta {
1964+
codex_local_model_mapping: Some(false),
1965+
..Default::default()
1966+
});
1967+
1968+
let projected = codex_settings_for_live_projection(&provider);
1969+
1970+
assert!(
1971+
projected.get("modelCatalog").is_none(),
1972+
"live projection must hide catalog when the Codex menu mapping flag is off"
1973+
);
1974+
assert!(
1975+
provider.settings_config.get("modelCatalog").is_some(),
1976+
"DB-side provider settings keep catalog metadata for /models and context windows"
1977+
);
1978+
}
1979+
1980+
#[test]
1981+
fn codex_live_projection_keeps_legacy_catalog_without_new_flag() {
1982+
let mut provider = Provider::with_id(
1983+
"codex-legacy".to_string(),
1984+
"Legacy Mapping".to_string(),
1985+
json!({
1986+
"auth": { "OPENAI_API_KEY": "sk-test" },
1987+
"config": "model_provider = \"legacy\"\n",
1988+
"modelCatalog": {
1989+
"models": [{ "model": "deepseek-v4-flash" }]
1990+
}
1991+
}),
1992+
None,
1993+
);
1994+
provider.meta = Some(crate::provider::ProviderMeta::default());
1995+
1996+
let projected = codex_settings_for_live_projection(&provider);
1997+
1998+
assert!(
1999+
projected.get("modelCatalog").is_some(),
2000+
"old providers without codexLocalModelMapping must keep the previous catalog projection behavior"
2001+
);
2002+
}
2003+
19302004
#[test]
19312005
fn codex_common_config_provider_only_snippet_is_not_detected_or_removed() {
19322006
let settings = json!({

src-tauri/src/services/provider/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2751,7 +2751,7 @@ impl ProviderService {
27512751
&& Self::codex_provider_requires_local_proxy(_provider)
27522752
{
27532753
log::info!(
2754-
"Codex provider '{}' 需要本地路由映射,自动启用 Codex 接管并通过本地代理转换",
2754+
"Codex provider '{}' 需要 Chat 转换或多模型路由,自动启用 Codex 接管并通过本地代理处理",
27552755
id
27562756
);
27572757
block_on_tauri_runtime(

0 commit comments

Comments
 (0)