Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/codex-plus-manager/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9669,6 +9669,11 @@ function RelayProfileEditor({
rows={7}
/>
{metadataImportError ? <div className="relay-model-metadata-import-error" role="alert">{metadataImportError}</div> : null}
{metadataImportPreview?.contextWindow && row.window.trim() && metadataImportPreview.contextWindow !== row.window.trim() ? (
<div className="relay-model-metadata-import-warning" role="status">
{tf("文档里的上下文窗口是 {0},当前列填的是 {1},已保留当前值;要用文档的值请先清空窗口。", [metadataImportPreview.contextWindow, row.window.trim()])}
</div>
) : null}
{metadataImportPreview?.ignoredFields.length ? (
<div className="relay-model-metadata-import-warning" role="status">
{tf("以下字段由 Codex++ 计算或维护,导入不会覆盖:{0}", [metadataImportPreview.ignoredFields.join(", ")])}
Expand Down
1 change: 1 addition & 0 deletions apps/codex-plus-manager/src/i18n-en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1444,6 +1444,7 @@ export const EN_PLAIN: Record<string, string> = {

// Interpolated strings: tf("前缀 {0}", [x]) -> EN_TEMPLATE["前缀 {0}"] with {0} filled.
export const EN_TEMPLATE: Record<string, string> = {
"文档里的上下文窗口是 {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}",
Expand Down
10 changes: 8 additions & 2 deletions apps/codex-plus-manager/src/model-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ describe("model metadata helpers", () => {
});

it("导入文档写回模型行的规则覆盖", () => {
// 窗口不同才写;相同不写(避免多余 state 更新)
// 窗口只补空列
assert.deepStrictEqual(
importDocumentSyncPatch({ window: "", autoCompact: "" }, { contextWindow: "500000", autoCompactPercent: null }),
{ window: "500000" },
Expand All @@ -476,14 +476,20 @@ 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%" }),
{ autoCompact: "80%" },
);
// 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
Expand Down
6 changes: 4 additions & 2 deletions apps/codex-plus-manager/src/model-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,15 +436,17 @@ export function metadataSourceTags(options: {
export type ModelRowSyncPatch = { window?: string; autoCompact?: string };

/// 导入文档解析结果 → 模型行补丁(JSON→行 的实时写回规则):
/// - 窗口:解析出有效值且与行现值不同才写(相同不写,避免多余 state 更新)
/// - 窗口:**只补空列**。手工填过的窗口是显式覆盖层,导入文档不能把它换掉
/// (曾出现「配好 1M 的模型,粘贴供应商 models.json 后被上游 256K 覆盖」);
/// 想用文档的值就先把行内窗口清空。
/// - 压缩比:解析出显示值且与行现值不同才写;null(JSON 未声明)不动行,
/// 避免粘贴别的模型 JSON 时清掉用户行里的值
export function importDocumentSyncPatch(
row: { window: string; autoCompact: string },
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) {
Expand Down
5 changes: 3 additions & 2 deletions assets/inject/renderer-inject.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
5 changes: 3 additions & 2 deletions assets/inject/renderer-inject/70-model-catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion assets/inject/renderer-inject/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
{
"name": "70-model-catalog.js",
"description": "全局状态、模型目录、模型白名单与解锁",
"lines": 1146
"lines": 1147
},
{
"name": "71-model-picker-groups.js",
Expand Down
51 changes: 50 additions & 1 deletion crates/codex-plus-core/src/model_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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<String, String> =
serde_json::from_str::<Value>(&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<String, String>,
Expand Down
47 changes: 39 additions & 8 deletions crates/codex-plus-core/src/model_suffix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,29 @@ pub struct ModelCatalogEntry {
pub auto_compact_percent: Option<u32>,
}

/// 剥掉合法窗口后缀,保留模型 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<String, T>,
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<u64>) {
Expand Down Expand Up @@ -117,6 +140,8 @@ pub fn collect_catalog_entries(
current_model: &str,
) -> Vec<ModelCatalogEntry> {
// 先解析 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<ModelCatalogEntry> = Vec::new();
for raw in model_list
Expand All @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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 [
Expand Down
9 changes: 6 additions & 3 deletions crates/codex-plus-core/src/relay_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<std::collections::HashMap<_, _>>();
.map(|entry| (entry.slug.clone(), entry.suffix_window.or(fallback_window)))
.collect::<std::collections::BTreeMap<_, _>>();
for model in models {
// Lite 能力跟随上游:官方登录 + ChatGPT 后端保留原值,
// 其余上游强制关闭(外部目录的 Lite 标记可能照抄官方目录,
Expand All @@ -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) {
Expand Down
38 changes: 2 additions & 36 deletions crates/codex-plus-core/src/vision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, T>,
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 单值配置也
Expand Down
32 changes: 32 additions & 0 deletions crates/codex-plus-core/tests/model_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
11 changes: 11 additions & 0 deletions docs/model-window-overrides.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# 模型窗口覆盖

手工 `modelWindows` 优先于供应商顶层 `contextWindow`,未指定时沿用内置元数据。
生成目录、外部目录覆盖和渲染端描述符使用同一份窗口信息。

模型键先精确匹配,再剥合法窗口后缀,最后按大小写不敏感匹配历史键。
保存时保留原始 ID;同名大小写变体的回退按键的字典序决定。

导入 `models.json` 只补空窗口列,已填写的窗口保持不变。窗口不一致时,
导入面板显示当前值与文档值;清空窗口后可采用文档值。
自动压缩默认值及模型显示顺序保持原行为。