From ba7331c4764da85c11527215be4158f8d88e03c3 Mon Sep 17 00:00:00 2001 From: n33 <1741170479@qq.com> Date: Tue, 13 Oct 2026 21:56:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(models):=20=E7=BB=9F=E4=B8=80=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E7=AA=97=E5=8F=A3=E8=A6=86=E7=9B=96=E4=B8=8E?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/codex-plus-manager/src/App.tsx | 5 ++ apps/codex-plus-manager/src/i18n-en.ts | 1 + .../src/model-metadata.test.ts | 10 +++- apps/codex-plus-manager/src/model-metadata.ts | 6 ++- assets/inject/renderer-inject.js | 5 +- .../renderer-inject/70-model-catalog.js | 5 +- assets/inject/renderer-inject/manifest.json | 2 +- crates/codex-plus-core/src/model_catalog.rs | 51 ++++++++++++++++++- crates/codex-plus-core/src/model_suffix.rs | 47 ++++++++++++++--- crates/codex-plus-core/src/relay_config.rs | 9 ++-- crates/codex-plus-core/src/vision.rs | 38 +------------- crates/codex-plus-core/tests/model_catalog.rs | 32 ++++++++++++ docs/model-window-overrides.md | 11 ++++ 13 files changed, 165 insertions(+), 57 deletions(-) create mode 100644 docs/model-window-overrides.md diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 10834322d..45092050e 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -9669,6 +9669,11 @@ function RelayProfileEditor({ rows={7} /> {metadataImportError ?
{metadataImportError}
: null} + {metadataImportPreview?.contextWindow && row.window.trim() && metadataImportPreview.contextWindow !== row.window.trim() ? ( +
+ {tf("文档里的上下文窗口是 {0},当前列填的是 {1},已保留当前值;要用文档的值请先清空窗口。", [metadataImportPreview.contextWindow, row.window.trim()])} +
+ ) : null} {metadataImportPreview?.ignoredFields.length ? (
{tf("以下字段由 Codex++ 计算或维护,导入不会覆盖:{0}", [metadataImportPreview.ignoredFields.join(", ")])} diff --git a/apps/codex-plus-manager/src/i18n-en.ts b/apps/codex-plus-manager/src/i18n-en.ts index dc2e183ca..70f9a99be 100644 --- a/apps/codex-plus-manager/src/i18n-en.ts +++ b/apps/codex-plus-manager/src/i18n-en.ts @@ -1444,6 +1444,7 @@ export const EN_PLAIN: Record = { // Interpolated strings: tf("前缀 {0}", [x]) -> EN_TEMPLATE["前缀 {0}"] with {0} filled. export const EN_TEMPLATE: Record = { + "文档里的上下文窗口是 {0},当前列填的是 {1},已保留当前值;要用文档的值请先清空窗口。": "The document's context window is {0}, but the column holds {1}. The current value was kept; clear the window to use the document's value.", "{0} 个插件": "{0} plugins", "{0} / {1} 个插件": "{0} / {1} plugins", "查看插件 {0} 的详情": "View details for plugin {0}", diff --git a/apps/codex-plus-manager/src/model-metadata.test.ts b/apps/codex-plus-manager/src/model-metadata.test.ts index 3fccb876a..0a19279f2 100644 --- a/apps/codex-plus-manager/src/model-metadata.test.ts +++ b/apps/codex-plus-manager/src/model-metadata.test.ts @@ -467,7 +467,7 @@ describe("model metadata helpers", () => { }); it("导入文档写回模型行的规则覆盖", () => { - // 窗口不同才写;相同不写(避免多余 state 更新) + // 窗口只补空列 assert.deepStrictEqual( importDocumentSyncPatch({ window: "", autoCompact: "" }, { contextWindow: "500000", autoCompactPercent: null }), { window: "500000" }, @@ -476,6 +476,12 @@ describe("model metadata helpers", () => { importDocumentSyncPatch({ window: "500000", autoCompact: "" }, { contextWindow: "500000", autoCompactPercent: null }), {}, ); + // 手工填过的窗口是显式覆盖层:文档里的值不能把它换掉,否则「配好 1M 的模型 + // 粘贴供应商 models.json 后被上游 256K 覆盖」会再次出现。 + assert.deepStrictEqual( + importDocumentSyncPatch({ window: "1m", autoCompact: "50%" }, { contextWindow: "262144", autoCompactPercent: null }), + {}, + ); // 压缩比不同才写 assert.deepStrictEqual( importDocumentSyncPatch({ window: "500000", autoCompact: "90%" }, { contextWindow: "500000", autoCompactPercent: "80%" }), @@ -483,7 +489,7 @@ describe("model metadata helpers", () => { ); // JSON 未声明压缩比(null):不动行里的值 assert.deepStrictEqual( - importDocumentSyncPatch({ window: "500000", autoCompact: "90%" }, { contextWindow: "600000", autoCompactPercent: null }), + importDocumentSyncPatch({ window: "", autoCompact: "90%" }, { contextWindow: "600000", autoCompactPercent: null }), { window: "600000" }, ); // 空预览(粘贴清空/粘贴失败):整体 no-op diff --git a/apps/codex-plus-manager/src/model-metadata.ts b/apps/codex-plus-manager/src/model-metadata.ts index 1d92c12fb..42794424a 100644 --- a/apps/codex-plus-manager/src/model-metadata.ts +++ b/apps/codex-plus-manager/src/model-metadata.ts @@ -436,7 +436,9 @@ export function metadataSourceTags(options: { export type ModelRowSyncPatch = { window?: string; autoCompact?: string }; /// 导入文档解析结果 → 模型行补丁(JSON→行 的实时写回规则): -/// - 窗口:解析出有效值且与行现值不同才写(相同不写,避免多余 state 更新) +/// - 窗口:**只补空列**。手工填过的窗口是显式覆盖层,导入文档不能把它换掉 +/// (曾出现「配好 1M 的模型,粘贴供应商 models.json 后被上游 256K 覆盖」); +/// 想用文档的值就先把行内窗口清空。 /// - 压缩比:解析出显示值且与行现值不同才写;null(JSON 未声明)不动行, /// 避免粘贴别的模型 JSON 时清掉用户行里的值 export function importDocumentSyncPatch( @@ -444,7 +446,7 @@ export function importDocumentSyncPatch( preview: { contextWindow: string | null; autoCompactPercent: string | null }, ): ModelRowSyncPatch { const patch: ModelRowSyncPatch = {}; - if (preview.contextWindow && preview.contextWindow !== row.window) { + if (preview.contextWindow && !row.window.trim()) { patch.window = preview.contextWindow; } if (preview.autoCompactPercent && preview.autoCompactPercent !== row.autoCompact) { diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 70ab7d7f5..daa142ac9 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -11178,8 +11178,9 @@ const metadata = codexPlusModelMetadata(modelName); if (!descriptor || !metadata) return false; let changed = false; - for (const key of ["displayName", "description", "defaultReasoningEffort"]) { - if (typeof metadata[key] === "string" && metadata[key] && descriptor[key] !== metadata[key]) { + for (const key of ["displayName", "description", "defaultReasoningEffort", "contextWindow", "maxContextWindow", "context_window", "max_context_window"]) { + const valid = typeof metadata[key] === "string" ? !!metadata[key] : Number.isFinite(metadata[key]) && metadata[key] > 0; + if (valid && descriptor[key] !== metadata[key]) { descriptor[key] = metadata[key]; changed = true; } diff --git a/assets/inject/renderer-inject/70-model-catalog.js b/assets/inject/renderer-inject/70-model-catalog.js index f3f2ad9b9..05216134d 100644 --- a/assets/inject/renderer-inject/70-model-catalog.js +++ b/assets/inject/renderer-inject/70-model-catalog.js @@ -200,8 +200,9 @@ const metadata = codexPlusModelMetadata(modelName); if (!descriptor || !metadata) return false; let changed = false; - for (const key of ["displayName", "description", "defaultReasoningEffort"]) { - if (typeof metadata[key] === "string" && metadata[key] && descriptor[key] !== metadata[key]) { + for (const key of ["displayName", "description", "defaultReasoningEffort", "contextWindow", "maxContextWindow", "context_window", "max_context_window"]) { + const valid = typeof metadata[key] === "string" ? !!metadata[key] : Number.isFinite(metadata[key]) && metadata[key] > 0; + if (valid && descriptor[key] !== metadata[key]) { descriptor[key] = metadata[key]; changed = true; } diff --git a/assets/inject/renderer-inject/manifest.json b/assets/inject/renderer-inject/manifest.json index a2fb5f529..f65d41453 100644 --- a/assets/inject/renderer-inject/manifest.json +++ b/assets/inject/renderer-inject/manifest.json @@ -53,7 +53,7 @@ { "name": "70-model-catalog.js", "description": "全局状态、模型目录、模型白名单与解锁", - "lines": 1146 + "lines": 1147 }, { "name": "71-model-picker-groups.js", diff --git a/crates/codex-plus-core/src/model_catalog.rs b/crates/codex-plus-core/src/model_catalog.rs index 7853f9b89..dea3a7215 100644 --- a/crates/codex-plus-core/src/model_catalog.rs +++ b/crates/codex-plus-core/src/model_catalog.rs @@ -98,7 +98,7 @@ fn relay_profile_model_catalog_value(home: &Path, profile: &RelayProfile) -> Val profile.name.trim() }; let model_count = models.len(); - let model_metadata = model_ui_metadata_map_with_overrides(&models, &profile.model_metadata); + let model_metadata = relay_profile_model_ui_metadata_map(profile, &models); json!({ "status": if models.is_empty() { "not_configured" } else { "ok" }, "path": home.join("config.toml").to_string_lossy(), @@ -215,6 +215,55 @@ fn model_ui_metadata_map_with_overrides(models: &[String], user_json: &str) -> V Value::Object(metadata) } +fn relay_profile_model_ui_metadata_map(profile: &RelayProfile, models: &[String]) -> Value { + let mut metadata = model_ui_metadata_map_with_overrides(models, &profile.model_metadata) + .as_object().cloned().unwrap_or_default(); + let windows: std::collections::BTreeMap = + serde_json::from_str::(&profile.model_windows) + .ok() + .and_then(|value| value.as_object().cloned()) + .map(|object| object.into_iter() + .filter_map(|(key, value)| value.as_str().map(|text| (key, text.to_string()))) + .collect()) + .unwrap_or_default(); + let fallback = crate::model_suffix::parse_window_token(&profile.context_window); + for model in models { + let window = crate::model_suffix::lookup_model_map(&windows, model) + .and_then(|token| crate::model_suffix::parse_window_token(token)) + .or(fallback); + if let Some(window) = window { + let entry = metadata.entry(model.clone()).or_insert_with(|| json!({})); + for field in ["contextWindow", "maxContextWindow", "context_window", "max_context_window"] { + entry[field] = json!(window); + } + } + } + Value::Object(metadata) +} + +#[cfg(test)] +mod window_override_tests { + use super::*; + + #[test] + fn manual_window_precedes_profile_and_builtin_metadata() { + let models = vec!["gpt-5.5".to_string(), "vendor-model".to_string()]; + let profile = RelayProfile { + model_windows: r#"{"GPT-5.5[1M]":"1M"}"#.into(), + context_window: "128K".into(), + ..Default::default() + }; + let metadata = relay_profile_model_ui_metadata_map(&profile, &models); + for field in ["contextWindow", "maxContextWindow", "context_window", "max_context_window"] { + assert_eq!(metadata["gpt-5.5"][field], 1_000_000); + assert_eq!(metadata["vendor-model"][field], 128_000); + } + let metadata = relay_profile_model_ui_metadata_map(&RelayProfile::default(), &models); + assert!(metadata["gpt-5.5"]["contextWindow"].as_u64().is_some()); + assert!(metadata.get("vendor-model").is_none()); + } +} + pub async fn read_codex_model_catalog_from_home( home: &Path, env: &HashMap, diff --git a/crates/codex-plus-core/src/model_suffix.rs b/crates/codex-plus-core/src/model_suffix.rs index e20da5e6c..bc2a4b4fb 100644 --- a/crates/codex-plus-core/src/model_suffix.rs +++ b/crates/codex-plus-core/src/model_suffix.rs @@ -20,6 +20,29 @@ pub struct ModelCatalogEntry { pub auto_compact_percent: Option, } +/// 剥掉合法窗口后缀,保留模型 ID 的大小写。 +pub(crate) fn normalize_model_key(raw: &str) -> String { + parse_model_suffix(raw).0 +} + +/// 优先精确匹配,再剥后缀,最后按大小写不敏感匹配历史键。 +/// BTreeMap 保证同名大小写变体的回退结果稳定。 +pub(crate) fn lookup_model_map<'a, T>( + map: &'a std::collections::BTreeMap, + model: &str, +) -> Option<&'a T> { + if let Some(value) = map.get(model) { + return Some(value); + } + let slug = normalize_model_key(model); + if let Some(value) = map.get(&slug) { + return Some(value); + } + map.iter() + .find(|(key, _)| normalize_model_key(key).eq_ignore_ascii_case(&slug)) + .map(|(_, value)| value) +} + /// 解析单个模型条目的后缀,返回 (slug, 可选窗口)。 /// 括号内非合法窗口 token 时,整串作为 slug 且 window=None(不剥离括号)。 pub fn parse_model_suffix(raw: &str) -> (String, Option) { @@ -117,6 +140,8 @@ pub fn collect_catalog_entries( current_model: &str, ) -> Vec { // 先解析 model_list,保留顺序并去重;后缀已从 model_list 剥离,窗口来自 model_windows map。 + let windows = model_windows.iter().map(|(key, value)| (key.clone(), value.clone())).collect(); + let auto_compacts = model_auto_compact.iter().map(|(key, value)| (key.clone(), value.clone())).collect(); let mut seen = HashSet::new(); let mut list_entries: Vec = Vec::new(); for raw in model_list @@ -132,12 +157,10 @@ pub fn collect_catalog_entries( continue; } let suffix_window = suffix_window.or_else(|| { - model_windows - .get(&slug) + lookup_model_map(&windows, &slug) .and_then(|token| parse_window_token(token)) }); - let auto_compact_percent = model_auto_compact - .get(&slug) + let auto_compact_percent = lookup_model_map(&auto_compacts, &slug) .and_then(|token| parse_compact_percent(token)); list_entries.push(ModelCatalogEntry { display_name: slug.clone(), @@ -154,12 +177,10 @@ pub fn collect_catalog_entries( let (slug, suffix_window) = parse_model_suffix(current_model); if !slug.is_empty() { let suffix_window = suffix_window.or_else(|| { - model_windows - .get(&slug) + lookup_model_map(&windows, &slug) .and_then(|token| parse_window_token(token)) }); - let auto_compact_percent = model_auto_compact - .get(&slug) + let auto_compact_percent = lookup_model_map(&auto_compacts, &slug) .and_then(|token| parse_compact_percent(token)); entries.push(ModelCatalogEntry { display_name: slug.clone(), @@ -408,6 +429,16 @@ pub fn model_ui_metadata_with_override(slug: &str, user_override: Option<&Value> if metadata.get("codex_plus_default").and_then(Value::as_bool) == Some(true) { ui["codex_plus_default"] = json!(true); } + // 注入侧与原生目录使用同一份窗口信息。 + for (source, camel, snake) in [ + ("context_window", "contextWindow", "context_window"), + ("max_context_window", "maxContextWindow", "max_context_window"), + ] { + if let Some(window) = metadata.get(source) { + ui[camel] = window.clone(); + ui[snake] = window.clone(); + } + } if !has_builtin { // 单独声明 Fast 不能替原生描述符制造名称、描述或推理默认档。 for (ui_field, source_field) in [ diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index dbf0f2cfa..694c0031f 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -3584,8 +3584,8 @@ fn copy_standard_responses_catalog( let mut changed = false; let configured_windows = entries .iter() - .map(|entry| (entry.slug.as_str(), entry.suffix_window.or(fallback_window))) - .collect::>(); + .map(|entry| (entry.slug.clone(), entry.suffix_window.or(fallback_window))) + .collect::>(); for model in models { // Lite 能力跟随上游:官方登录 + ChatGPT 后端保留原值, // 其余上游强制关闭(外部目录的 Lite 标记可能照抄官方目录, @@ -3599,7 +3599,10 @@ fn copy_standard_responses_catalog( let Some(slug) = model.get("slug").and_then(Value::as_str) else { continue; }; - let Some(Some(window)) = configured_windows.get(slug) else { + // 外部 catalog 的 slug 与本地行的写法/大小写可能不一致,走三级回退查表, + // 否则配置好的窗口会在外部目录里整条失效、保留上游给的值。 + let Some(Some(window)) = crate::model_suffix::lookup_model_map(&configured_windows, slug) + else { continue; }; if model.get("context_window").and_then(Value::as_u64) != Some(*window) { diff --git a/crates/codex-plus-core/src/vision.rs b/crates/codex-plus-core/src/vision.rs index 962329192..a24a33a22 100644 --- a/crates/codex-plus-core/src/vision.rs +++ b/crates/codex-plus-core/src/vision.rs @@ -10,6 +10,8 @@ use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; use std::time::{Duration, Instant}; +use crate::model_suffix::lookup_model_map; + const BATCH_SIZE: usize = 5; /// 黄金窗口:Phase 1 同步补全的最近 N 轮 user 消息。 const GOLDEN_WINDOW_DEPTH: usize = 10; @@ -118,42 +120,6 @@ pub enum ImageHandling { Vlm, } -/// 归一化 per-model map 的查表 key:剥掉 `[1M]` 窗口后缀,**保留原大小写**。 -/// -/// 后缀剥离复用 `model_suffix::parse_model_suffix`(与 catalog 生成同一套语义), -/// 与前端 `model-windows.ts` 的 `modelMapKeyFromRowName` 逐字对齐: -/// 只剥「合法窗口后缀」,`x[abc]`/`x[0]` 这类非法后缀整串保留。 -fn normalize_model_key(raw: &str) -> String { - crate::model_suffix::parse_model_suffix(raw).0 -} - -/// 在 per-model map 中逐级查表:原始名 → 规范 slug(剥后缀)→ 剥后缀 + 大小写不敏感。 -/// -/// 三级回退与前端 `model-windows.ts::lookupModelMapEntry` 同源(issue #2345): -/// - 直接命中:上游请求里的 model 字符串与 map key 逐字相同; -/// - 规范 slug:请求带 `[1M]` 后缀而 key 不带(前端落盘的新数据); -/// - 归一化后大小写不敏感:**两侧都先剥后缀再比大小写**——只剥请求侧不够, -/// 历史数据的 key 是带后缀的行名原样(`deepseek-v4-pro[1M]`),请求侧不带后缀时 -/// 会整个漏掉。同时覆盖供应商 slug 大小写不统一(`GLM-5.3` vs `glm-5.3`)。 -/// -/// 大小写不敏感一级按 BTreeMap 的字典序取第一个匹配,保证同一份配置的多次请求 -/// 解析结果稳定(不会因 HashMap 迭代序在两个同名不同 case 的 key 之间抖动)。 -fn lookup_model_map<'a, T>( - map: &'a std::collections::BTreeMap, - model: &str, -) -> Option<&'a T> { - if let Some(value) = map.get(model) { - return Some(value); - } - let slug = normalize_model_key(model); - if let Some(value) = map.get(&slug) { - return Some(value); - } - map.iter() - .find(|(key, _)| normalize_model_key(key).to_lowercase() == slug.to_lowercase()) - .map(|(_, value)| value) -} - /// 解析 model_vlm JSON,返回该模型的图片处理模式。 /// /// 未命中任何一级回退时返回 `SendAsIs`(默认行为不变,per-profile 单值配置也 diff --git a/crates/codex-plus-core/tests/model_catalog.rs b/crates/codex-plus-core/tests/model_catalog.rs index 809c8bfc4..ea2bfb5d8 100644 --- a/crates/codex-plus-core/tests/model_catalog.rs +++ b/crates/codex-plus-core/tests/model_catalog.rs @@ -7,11 +7,43 @@ use std::thread; use codex_plus_core::model_catalog::{ read_codex_model_catalog, read_codex_model_catalog_from_home, }; +use codex_plus_core::model_suffix::collect_catalog_entries; use codex_plus_core::settings::{ BackendSettings, RelayMode, RelayProfile, RelayProtocol, SettingsStore, }; use serde_json::json; +#[test] +fn catalog_entries_apply_manual_windows_case_insensitively() { + // 上游 /models 返回的 id 与本地保存的 key 常只差大小写;精确查表会让用户 + // 配好的窗口整条落空,最后露出上游/Codex 默认的窗口。 + let windows = HashMap::from([("DeepSeek-Flash".to_string(), "1m".to_string())]); + let compacts = HashMap::from([("DeepSeek-Flash".to_string(), "50%".to_string())]); + let entries = collect_catalog_entries("deepseek-flash\nglm-5.3", &windows, &compacts, ""); + let deepseek = entries + .iter() + .find(|entry| entry.slug == "deepseek-flash") + .expect("模型条目缺失"); + assert_eq!(deepseek.suffix_window, Some(1_000_000)); + assert_eq!(deepseek.auto_compact_percent, Some(50_000_000)); + // 没有配置窗口的模型不该被牵连。 + let glm = entries + .iter() + .find(|entry| entry.slug == "glm-5.3") + .expect("模型条目缺失"); + assert_eq!(glm.suffix_window, None); +} + +#[test] +fn catalog_entries_strip_suffix_from_row_name_and_look_up_window() { + // 行名带 [1M] 后缀时窗口来自后缀本身;没有后缀时来自 model_windows。 + let entries = + collect_catalog_entries("deepseek-v4-pro[1M]", &HashMap::new(), &HashMap::new(), ""); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].slug, "deepseek-v4-pro"); + assert_eq!(entries[0].suffix_window, Some(1_000_000)); +} + #[tokio::test] async fn model_catalog_fetches_models_from_codex_config_provider() { let temp = tempfile::tempdir().unwrap(); diff --git a/docs/model-window-overrides.md b/docs/model-window-overrides.md new file mode 100644 index 000000000..2b0c404d8 --- /dev/null +++ b/docs/model-window-overrides.md @@ -0,0 +1,11 @@ +# 模型窗口覆盖 + +手工 `modelWindows` 优先于供应商顶层 `contextWindow`,未指定时沿用内置元数据。 +生成目录、外部目录覆盖和渲染端描述符使用同一份窗口信息。 + +模型键先精确匹配,再剥合法窗口后缀,最后按大小写不敏感匹配历史键。 +保存时保留原始 ID;同名大小写变体的回退按键的字典序决定。 + +导入 `models.json` 只补空窗口列,已填写的窗口保持不变。窗口不一致时, +导入面板显示当前值与文档值;清空窗口后可采用文档值。 +自动压缩默认值及模型显示顺序保持原行为。