diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index d4792b26..a80c0edd 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -1358,6 +1358,91 @@ fn sort_codex_catalog_specs_for_picker( indexed_specs.into_iter().map(|(_, spec)| spec).collect() } +/// 读取 Codex 官方模型缓存(models 数组)。 +/// +/// CC Switch 接管后会把路由目录写进 models_cache.json(etag 标记为 CC_SWITCH 拥有), +/// 官方原始档位在 backup 文件里。此处与 enrich_codex_catalog_with_official_metadata +/// 保持同一选择逻辑:缓存被 CC Switch 拥有时优先读 backup。 +/// 任何读取/解析失败都返回 None(静默降级,不阻断投影)。 +fn codex_official_models_cache() -> Option> { + let cache_path = get_codex_models_cache_path(); + let backup_path = get_codex_models_cache_backup_path(); + let existing_cache = read_json_file_if_exists(&cache_path).ok().flatten(); + let official_cache = match existing_cache.as_ref() { + Some(cache) if codex_models_cache_is_cc_switch_owned(cache) => { + read_json_file_if_exists(&backup_path) + .ok() + .flatten() + .or_else(|| existing_cache.clone()) + } + _ => existing_cache, + }?; + let models = official_cache.get("models").and_then(Value::as_array)?.clone(); + Some(models) +} + +/// 从 Codex 官方缓存为指定 slug 构造 reasoning capability。 +/// +/// 官方缓存字段是 snake_case,`supported_reasoning_levels` 可能是字符串数组 +/// (["low","medium",...])或对象数组([{"effort":"low","description":...},...]): +/// CCSM 写入的 cache 为字符串数组,官方 backup 为对象数组,两种都兼容。 +/// 官方 GPT 模型走 OpenAI 顶层 `reasoning_effort` 字段,effort_map 用 identity。 +/// 任何校验失败都返回 None(保守降级为 Unknown,不产生虚假档位)。 +fn official_reasoning_capability_for_model( + model: &str, + official_models: &[Value], +) -> Option { + use crate::proxy::providers::codex_reasoning::{ + CodexModelReasoningCapability, CodexModelReasoningUpstream, + }; + let entry = official_models.iter().find(|entry| { + entry + .get("slug") + .and_then(Value::as_str) + .is_some_and(|slug| slug.eq_ignore_ascii_case(model)) + })?; + let levels: Vec = entry + .get("supported_reasoning_levels") + .and_then(Value::as_array)? + .iter() + .filter_map(|level| { + level + .as_str() + .or_else(|| level.get("effort").and_then(Value::as_str)) + .map(str::trim) + .map(ToString::to_string) + }) + .filter(|level| !level.is_empty()) + .collect(); + if levels.is_empty() { + return None; + } + let default_effort = entry + .get("default_reasoning_level") + .and_then(Value::as_str) + .map(str::trim) + .filter(|level| !level.is_empty()) + .map(ToString::to_string); + let capability = CodexModelReasoningCapability { + supported: true, + supported_efforts: levels.clone(), + default_effort, + disable_allowed: false, + upstream: CodexModelReasoningUpstream { + format: "string".to_string(), + parameter: "reasoning_effort".to_string(), + effort_map: levels + .into_iter() + .map(|level| (level.clone(), level)) + .collect(), + }, + output_format: None, + source: Some("official".to_string()), + }; + capability.validate().ok()?; + Some(capability) +} + fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec { let Some(models) = settings .get("modelCatalog") @@ -1373,6 +1458,7 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec Vec mod tests { use super::*; + #[test] + fn official_reasoning_capability_reads_snake_case_levels() { + let official = serde_json::json!([{ + "slug": "gpt-5.6-luna", + "supported_reasoning_levels": ["low", "medium", "high", "xhigh", "max"], + "default_reasoning_level": "medium" + }]); + let models = official.as_array().unwrap().clone(); + let capability = official_reasoning_capability_for_model("gpt-5.6-luna", &models) + .expect("luna official capability"); + assert_eq!( + capability.supported_efforts, + vec!["low", "medium", "high", "xhigh", "max"] + ); + assert_eq!(capability.default_effort.as_deref(), Some("medium")); + assert_eq!(capability.source.as_deref(), Some("official")); + // 官方档位含 ultra 的模型(sol/terra)也能通过 validate + let sol = serde_json::json!([{ + "slug": "gpt-5.6-sol", + "supported_reasoning_levels": ["low", "medium", "high", "xhigh", "max", "ultra"], + "default_reasoning_level": "low" + }]); + let sol_models = sol.as_array().unwrap().clone(); + let sol_capability = official_reasoning_capability_for_model("gpt-5.6-sol", &sol_models) + .expect("sol official capability"); + assert!(sol_capability.supported_efforts.contains(&"ultra".to_string())); + // 不匹配的 slug 返回 None + assert!(official_reasoning_capability_for_model("gpt-5.6-sol", &models).is_none()); + } + + #[test] + fn official_reasoning_capability_accepts_object_levels_from_backup() { + // 官方 backup 文件(models_cache.cc-switch-backup.json)里 + // supported_reasoning_levels 是对象数组 {effort, description},必须兼容。 + let official = serde_json::json!([{ + "slug": "gpt-5.6-luna", + "supported_reasoning_levels": [ + {"effort": "low", "description": "Fast"}, + {"effort": "medium", "description": "Balanced"}, + {"effort": "high", "description": "Deep"}, + {"effort": "xhigh", "description": "Extra deep"}, + {"effort": "max", "description": "Maximum"} + ], + "default_reasoning_level": "medium" + }]); + let models = official.as_array().unwrap().clone(); + let capability = official_reasoning_capability_for_model("gpt-5.6-luna", &models) + .expect("object levels must resolve"); + assert_eq!( + capability.supported_efforts, + vec!["low", "medium", "high", "xhigh", "max"] + ); + assert_eq!(capability.default_effort.as_deref(), Some("medium")); + } + fn codex_subagent_profile_status_json( settings: &Value, provider_context: Option<&ProviderClassificationContext>, @@ -6200,7 +6342,7 @@ mod tests { "supportKind": "effort_levels", "source": "builtin", "confidence": "confirmed", - "codexSelectableEfforts": ["none", "low", "medium", "high", "xhigh", "max"], + "codexSelectableEfforts": ["low", "high", "max"], "providerAcceptedEfforts": ["low", "high", "max"], "providerDefaultEffort": "high", "disableAllowed": true, @@ -6296,7 +6438,7 @@ mod tests { "supportKind": "effort_levels", "source": "builtin", "confidence": "confirmed", - "codexSelectableEfforts": ["none", "low", "medium", "high", "xhigh", "max"], + "codexSelectableEfforts": ["low", "high", "max"], "providerAcceptedEfforts": ["low", "high", "max"], "providerDefaultEffort": "high", "disableAllowed": true, @@ -7054,7 +7196,7 @@ mod tests { "supportKind": "effort_levels", "source": "builtin", "confidence": "confirmed", - "codexSelectableEfforts": ["none", "low", "medium", "high", "xhigh", "max"], + "codexSelectableEfforts": ["low", "high", "max"], "providerAcceptedEfforts": ["low", "high", "max"], "providerDefaultEffort": "high", "disableAllowed": true, @@ -10547,14 +10689,8 @@ openai_base_url = "http://127.0.0.1:15721/v1" assert_eq!(entry["default_reasoning_level"], "high"); assert_eq!(entry["defaultReasoningEffort"], "high"); - assert_eq!( - levels, - vec!["none", "low", "medium", "high", "xhigh", "max"] - ); - assert_eq!( - desktop_levels, - vec!["none", "low", "medium", "high", "xhigh", "max"] - ); + assert_eq!(levels, vec!["low", "high", "max"]); + assert_eq!(desktop_levels, vec!["low", "high", "max"]); assert!(!levels.contains(&"ultra")); } diff --git a/src-tauri/src/codex_subagent_profiles.rs b/src-tauri/src/codex_subagent_profiles.rs index bf01aa3b..b2c7d09e 100644 --- a/src-tauri/src/codex_subagent_profiles.rs +++ b/src-tauri/src/codex_subagent_profiles.rs @@ -1,7 +1,7 @@ //! Codex V2 questionnaire persistence, validation, compilation, and safe preview projection. use crate::proxy::providers::codex_reasoning::{ - CodexReasoningEffort, ResolvedSubagentReasoningCapability, + CodexReasoningEffort, ReasoningSupportKind, ResolvedSubagentReasoningCapability, }; use serde::ser::{SerializeMap, SerializeStruct}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -820,14 +820,31 @@ fn compile_reasoning_policy( "fixed reasoning policy requires effort", ) })?; - if !capability.codex_selectable_efforts.contains(&effort) { - return Err(validation_error( - "unsupported_reasoning_effort", - Some(profile_key), - "fixed reasoning effort is not supported by the target model", - )); + match capability.support_kind { + // 能力明确:宽校验 + 保留显式档位。legacy/schema1 声明的映射档位 + // (xhigh/medium 等)经 effort_map 映射后若落在模型真实档位内 + // (如 high),则编译通过并**保留原档位**,由代理层运行时映射 + // 到上游;映射后仍不在可选档位内才拒绝(Unsupported/BooleanOnly + // 的 selectable 集合不含该档位,同样会在此拒绝)。 + ReasoningSupportKind::EffortLevels + | ReasoningSupportKind::BooleanOnly + | ReasoningSupportKind::Unsupported => { + let resolved = capability.effort_map.get(&effort).unwrap_or(&effort); + if capability.codex_selectable_efforts.contains(resolved) { + Ok(Some(effort)) + } else { + return Err(validation_error( + "unsupported_reasoning_effort", + Some(profile_key), + "fixed reasoning effort is not supported by the target model", + )); + } + } + // 能力未知(Unknown):无法验证档位合法性,但 Unknown ≠ Unsupported。 + // 信任用户显式声明的 Fixed effort,避免 schema1 旧配置(legacy effort + // 迁移为 Fixed)在模型目录未声明能力时直接编译失败(v27/v28 回归)。 + ReasoningSupportKind::Unknown => Ok(Some(effort)), } - Ok(Some(effort)) } ReasoningRuntimePolicy::Disabled => { if !capability.disable_allowed @@ -2650,6 +2667,41 @@ mod tests { ); } + #[test] + fn reasoning_policy_fixed_unknown_capability_trusts_declared_effort() { + // schema1 旧配置(legacy reasoningEffort)迁移为 Fixed 后,目标模型能力 + // Unknown 时不得编译失败——Unknown ≠ Unsupported,信任用户显式声明。 + // v27/v28 曾因 selectable 为空集而报 unsupported_reasoning_effort, + // 导致 "Unable to inspect Codex subagent profiles" 全线失败。 + let capability = ResolvedSubagentReasoningCapability { + support_kind: ReasoningSupportKind::Unknown, + source: None, + confidence: ReasoningConfidence::Unverified, + codex_selectable_efforts: vec![], + provider_accepted_efforts: vec![], + provider_default_effort: None, + disable_allowed: false, + effort_map: BTreeMap::new(), + }; + assert_eq!( + compile_reasoning_policy( + &fixed_reasoning(CodexReasoningEffort::High), + &capability, + "legacy-model", + ) + .expect("Unknown capability + Fixed must compile"), + Some(CodexReasoningEffort::High) + ); + // EffortLevels 能力下 Ultra 仍被拒绝(能力明确时不放行) + let deepseek = deepseek_reasoning(); + assert!(compile_reasoning_policy( + &fixed_reasoning(CodexReasoningEffort::Ultra), + &deepseek, + "flash", + ) + .is_err()); + } + #[test] fn codex_subagent_v2_delegated_effort_is_not_selected_from_complex_strength() { generated_for_profile( diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index 8bd29219..bad10a26 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -1928,12 +1928,9 @@ fn encode_codex_capability_effort_mode( .map(|effort| effort.as_str()) .collect::>() .join(","); - let mappings = supported_efforts + let mappings = effort_map .iter() - .map(|effort| { - let mapped = effort_map.get(effort).unwrap_or(effort); - format!("{}={}", effort.as_str(), mapped.as_str()) - }) + .map(|(source, target)| format!("{}={}", source.as_str(), target.as_str())) .collect::>() .join(","); format!("capability|{allowed}|{mappings}") @@ -2794,6 +2791,26 @@ mod tests { }; use serde_json::json; + #[test] + fn capability_effort_mode_keeps_wide_mappings_for_narrow_selectable() { + let capability = crate::proxy::providers::codex_reasoning::builtin_reasoning_capability_for_model( + "deepseek-v4-flash", + ) + .expect("deepseek builtin"); + let resolved = crate::proxy::providers::codex_reasoning::resolve_subagent_reasoning_capability( + Some(&capability), + ); + let mode = encode_codex_capability_effort_mode( + &resolved.codex_selectable_efforts, + &resolved.effort_map, + ); + // allowed 收窄为真实档位,mappings 保留 medium/xhigh 上游映射(宽映射兜底) + assert_eq!( + mode, + "capability|low,high,max|low=low,medium=high,high=high,xhigh=high,max=max" + ); + } + fn create_provider(config: serde_json::Value) -> Provider { Provider { id: "test".to_string(), diff --git a/src-tauri/src/proxy/providers/codex_reasoning.rs b/src-tauri/src/proxy/providers/codex_reasoning.rs index 4467224e..66c1dcac 100644 --- a/src-tauri/src/proxy/providers/codex_reasoning.rs +++ b/src-tauri/src/proxy/providers/codex_reasoning.rs @@ -206,14 +206,9 @@ pub fn resolve_subagent_reasoning_capability( } } - let selectable_set = effort_map - .keys() + let selectable_set = provider_effort_set + .iter() .copied() - .chain( - capability - .disable_allowed - .then_some(CodexReasoningEffort::None), - ) .collect::>(); let codex_selectable_efforts = CodexReasoningEffort::ORDERED .into_iter() @@ -258,9 +253,21 @@ pub fn builtin_reasoning_capability_for_model( model: &str, ) -> Option { let normalized = model.trim().to_ascii_lowercase(); - if !matches!(normalized.as_str(), "deepseek-v4-flash" | "deepseek-v4-pro") { + // 官方维护清单:DeepSeek V4 与 Kimi K3 均支持 reasoning_effort: low/high/max(默认 high)。 + // 保持精确匹配,未知第三方模型不得继承 GPT 通用档位。 + if !matches!( + normalized.as_str(), + "deepseek-v4-flash" | "deepseek-v4-pro" | "k3" | "k3-256k" + ) { return None; } + // DeepSeek Responses 返回 reasoning_content 字段;Kimi 响应字段未确认, + // 不声明 output_format(代理层按默认行为处理,避免错误字段破坏转换)。 + let output_format = if normalized.starts_with("deepseek") { + Some("reasoning_content".into()) + } else { + None + }; Some(CodexModelReasoningCapability { supported: true, supported_efforts: vec!["low".into(), "high".into(), "max".into()], @@ -279,7 +286,7 @@ pub fn builtin_reasoning_capability_for_model( .into_iter() .collect(), }, - output_format: Some("reasoning_content".into()), + output_format, source: Some("builtin".into()), }) } @@ -287,9 +294,34 @@ pub fn builtin_reasoning_capability_for_model( pub fn reasoning_capability_from_model_entry( model_entry: &Value, ) -> Option { - let value = model_entry.get("reasoning")?; - let capability: CodexModelReasoningCapability = serde_json::from_value(value.clone()).ok()?; - capability.validate().ok()?; + let Some(value) = model_entry.get("reasoning") else { + return None; + }; + if value.is_null() { + // reasoning: null 与缺失等价,均视为"未声明" + return None; + } + let model = model_entry + .get("model") + .and_then(Value::as_str) + .unwrap_or("?"); + let capability: CodexModelReasoningCapability = match serde_json::from_value(value.clone()) { + Ok(capability) => capability, + Err(error) => { + // 声明存在但无法解析:打日志暴露问题,避免用户手动声明 + // 被静默当作"未声明"而清空(v27/v28 回归)。 + log::warn!( + "Codex reasoning declaration for model {model} is not parseable and will be ignored: {error}" + ); + return None; + } + }; + if let Err(error) = capability.validate() { + log::warn!( + "Codex reasoning declaration for model {model} is invalid and will be ignored: {error}" + ); + return None; + } Some(capability) } @@ -354,7 +386,7 @@ mod tests { ); assert_eq!( resolved.codex_selectable_efforts, - efforts(&["none", "low", "medium", "high", "xhigh", "max"]) + efforts(&["low", "high", "max"]) ); assert_eq!( resolved.effort_map.get(&CodexReasoningEffort::Medium), @@ -425,4 +457,19 @@ mod tests { assert!(builtin_reasoning_capability_for_model("deepseek-v4-flash-preview").is_none()); assert!(builtin_reasoning_capability_for_model("vendor/deepseek-v4-pro").is_none()); } + + #[test] + fn restores_exact_kimi_k3_capabilities() { + for model in ["k3", "K3-256K", "k3-256k"] { + let capability = builtin_reasoning_capability_for_model(model) + .unwrap_or_else(|| panic!("{model} must resolve a Kimi capability")); + assert_eq!(capability.supported_efforts, vec!["low", "high", "max"]); + assert_eq!(capability.default_effort.as_deref(), Some("high")); + // Kimi 响应字段未确认,output_format 保持 None + assert_eq!(capability.output_format, None); + assert_eq!(capability.source.as_deref(), Some("builtin")); + } + assert!(builtin_reasoning_capability_for_model("k3-ultra").is_none()); + assert!(builtin_reasoning_capability_for_model("vendor/k3").is_none()); + } } diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index c90213bb..6fefd62e 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -889,16 +889,21 @@ fn map_capability_reasoning_effort<'a>( let _kind = sections.next(); let allowed = sections.next().unwrap_or_default(); let mappings = sections.next().unwrap_or_default(); + // 宽映射兜底:medium/xhigh 等映射档位直接命中,返回上游档位(如 high)。 + // 先查映射再校验 allowed,避免 Codex 端仍发送映射档位时被 fail closed。 + if let Some(target) = mappings + .split(',') + .filter_map(|mapping| mapping.split_once('=')) + .find_map(|(source, target)| (source == effort).then_some(target)) + { + return Ok(target); + } if !allowed.split(',').any(|candidate| candidate == effort) { return Err(ProxyError::TransformError(format!( "reasoning effort `{effort}` is not supported; allowed=[{allowed}]" ))); } - Ok(mappings - .split(',') - .filter_map(|mapping| mapping.split_once('=')) - .find_map(|(source, target)| (source == effort).then_some(target)) - .unwrap_or(effort)) + Ok(effort) } /// 写入 vLLM/HF chat template 常用的嵌套 thinking 开关,同时保留已有 kwargs。 @@ -2564,6 +2569,32 @@ pub fn chat_error_to_response_error(body: Option<&Value>) -> Value { mod tests { use super::*; + #[test] + fn capability_effort_mapping_narrow_display_wide_remap() { + let mode = + "capability|low,high,max|low=low,medium=high,high=high,xhigh=high,max=max"; + // 映射档位兜底:medium/xhigh 命中 effort_map 映射,转发到上游 high + assert_eq!( + map_capability_reasoning_effort("medium", mode).unwrap(), + "high" + ); + assert_eq!( + map_capability_reasoning_effort("xhigh", mode).unwrap(), + "high" + ); + // 真实档位 identity 映射 + assert_eq!( + map_capability_reasoning_effort("low", mode).unwrap(), + "low" + ); + assert_eq!( + map_capability_reasoning_effort("max", mode).unwrap(), + "max" + ); + // 未知档位:无映射且不在 allowed,仍 fail closed + assert!(map_capability_reasoning_effort("foo", mode).is_err()); + } + fn large_test_image_data_url() -> String { let bytes = b"CC_SWITCH_TOOL_MEDIA_SENTINEL".repeat(400); format!("data:image/png;base64,{}", STANDARD.encode(bytes)) diff --git a/src/components/codex/CodexRouterWorkspacePage.tsx b/src/components/codex/CodexRouterWorkspacePage.tsx index 65e06c23..83ee5f37 100644 --- a/src/components/codex/CodexRouterWorkspacePage.tsx +++ b/src/components/codex/CodexRouterWorkspacePage.tsx @@ -716,6 +716,9 @@ function providerWithFetchedModelCatalog( ? { supports_image: model.supports_image } : {}), ...(model.vision !== undefined ? { vision: model.vision } : {}), + // 模型目录刷新必须保留已有 reasoning 声明(用户手动声明的档位/能力)。 + // 否则 /models 拉取重建会把声明清空,导致档位消失(K3/Qwen 均受影响)。 + ...(model.reasoning ? { reasoning: model.reasoning } : {}), } satisfies CodexCatalogModelDraft; }); const byFetchedModel = new Map(); diff --git a/src/components/codex/CodexSubagentProfileEditor.tsx b/src/components/codex/CodexSubagentProfileEditor.tsx index 05f0b8e1..3078b43b 100644 --- a/src/components/codex/CodexSubagentProfileEditor.tsx +++ b/src/components/codex/CodexSubagentProfileEditor.tsx @@ -88,7 +88,9 @@ function profileToneFor( profile: CodexSubagentV2Profile, status?: CodexSubagentProfileStatus, ): ProfileTone { - if (status?.routable === false) return "unroutable"; + // 只有真不可路由(编译状态 Unroutable)才标"不可路由"; + // 未启用的可路由 profile(编译状态 Disabled)应显示为草稿。 + if (status?.status === "unroutable") return "unroutable"; return profile.enabled ? "enabled-routable" : "draft"; } @@ -877,7 +879,7 @@ export function CodexSubagentProfileEditor({ if (providerKind === "official" && !showOfficialProfiles) return false; if (profileFilter === "enabled" && !profile.enabled) return false; if (profileFilter === "draft" && profile.enabled) return false; - if (profileFilter === "unroutable" && status?.routable !== false) { + if (profileFilter === "unroutable" && status?.status !== "unroutable") { return false; } const haystack = [ @@ -1134,7 +1136,10 @@ export function CodexSubagentProfileEditor({ variant="outline" aria-label={`编辑 ${profile.model}`} onClick={() => setOpenProfileKey(profileKey)} - disabled={isSaving || status?.routable === false} + // 真不可路由(编译状态 Unroutable)一律禁止编辑; + // 未启用的可路由 profile(编译状态 Disabled)必须可编辑, + // 否则死锁(v28 回归:未启用 → routable=false → 禁编辑)。 + disabled={isSaving || status?.status === "unroutable"} className="shrink-0" > 编辑 @@ -1143,8 +1148,12 @@ export function CodexSubagentProfileEditor({ updateProfile(profileKey, (current) => ({ @@ -1782,12 +1791,12 @@ function ProfileSummary({ - {status?.routable === false ? "不可路由" : "可路由"} + {status?.status === "unroutable" ? "不可路由" : "可路由"} { - const supportedEfforts = event - .target.checked + const checked = + event.target.checked; + const supportedEfforts = checked ? [ ...row.reasoning! .supportedEfforts, @@ -3074,11 +3075,40 @@ export function CodexFormFields({ ? row.reasoning! .defaultEffort : supportedEfforts[0]; + // 取消勾选时同步清理 effortMap 中指向被移除档位的孤儿映射, + // 否则保存后后端 validate(target 必须在 supportedEfforts) + // 会拒绝整份声明并被静默清空(v27/v28 回归)。 + const nextEffortMap: Record< + string, + CodexReasoningEffort + > = { + ...(row.reasoning!.upstream + .effortMap ?? {}), + }; + if (!checked) { + for (const [ + source, + target, + ] of Object.entries( + nextEffortMap, + )) { + if (target === effort) { + delete nextEffortMap[ + source + ]; + } + } + } handleUpdateCatalogRow(index, { reasoning: { ...row.reasoning!, supportedEfforts, defaultEffort, + upstream: { + ...row.reasoning! + .upstream, + effortMap: nextEffortMap, + }, source: "user", }, }); diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 10459c3b..442389b6 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -23,6 +23,7 @@ import type { CodexModelCatalogConfig, CodexRoutingConfig, CodexChatReasoning, + CodexReasoningEffort, PromptCacheRoutingMode, ClaudeApiKeyField, } from "@/types"; @@ -231,6 +232,24 @@ export const normalizeCodexCatalogModelsForSave = ( throw new Error(`${model}: reasoning effortMap is missing ${effort}`); } } + // 与后端 CodexModelReasoningCapability::validate 对齐: + // effortMap 每个 target 必须是 supportedEfforts 中的档位。 + // 此前只校验 key 存在性,孤儿映射(指向已移除档位)会落库后 + // 被后端拒绝并在投影时静默清空,用户手动声明"消失"。 + if (reasoning.upstream.effortMap) { + for (const [source, target] of Object.entries( + reasoning.upstream.effortMap, + )) { + if ( + target && + !reasoning.supportedEfforts.includes(target as CodexReasoningEffort) + ) { + throw new Error( + `${model}: reasoning effortMap target "${target}" (source "${source}") is not in supportedEfforts`, + ); + } + } + } } normalized.push({