From a94f997f1e2823fa7397c6773d80dc44b7ccd86b Mon Sep 17 00:00:00 2001 From: funkpopo Date: Mon, 19 Oct 2026 16:24:54 +0800 Subject: [PATCH] fix(codex): honor configured GPT context windows (#8385) Explicit provider-scoped or per-model context windows now apply to mirrored official GPT catalog entries (context_window and max_context_window), with the auto-compact limit kept in sync; official defaults remain when nothing is configured. Gemini input-overflow errors on the Codex Chat path are normalized to context_length_exceeded and returned as a response.failed stream so Codex can compact, and non-string SSE error codes are stringified so upstream messages survive. Fixes #8366 --- docs/user-manual/en/2-providers/2.1-add.md | 8 + docs/user-manual/ja/2-providers/2.1-add.md | 8 + docs/user-manual/zh/2-providers/2.1-add.md | 8 + src-tauri/src/codex_config.rs | 299 ++++++++++++++++-- src-tauri/src/mode/controller.rs | 58 ++++ src-tauri/src/proxy/handlers.rs | 151 ++++++++- .../proxy/providers/streaming_codex_chat.rs | 88 ++++-- .../proxy/providers/transform_codex_chat.rs | 46 ++- 8 files changed, 594 insertions(+), 72 deletions(-) diff --git a/docs/user-manual/en/2-providers/2.1-add.md b/docs/user-manual/en/2-providers/2.1-add.md index b6d9902b9..af2c4501f 100644 --- a/docs/user-manual/en/2-providers/2.1-add.md +++ b/docs/user-manual/en/2-providers/2.1-add.md @@ -504,6 +504,14 @@ The form for every non-official Codex provider has a **Model Mapping** table tha - When **Default Model** is left empty, the first row of the mapping table is used as the default - **Codex must be restarted** to refresh the model list after changes (`model_catalog_json` is loaded at Codex startup) +GPT models retain the prompts, tools, and reasoning levels from Codex's official catalog, but **an explicit Context Window overrides the catalog default**; leaving it blank keeps that default. Set it to a window supported by your upstream: OpenAI documents 1,050,000 tokens for [GPT-6.1 Sol](https://developers.openai.com/api/docs/models/gpt-6.1-sol) and [GPT-6 Astra](https://developers.openai.com/api/docs/models/gpt-6-astra), while third-party providers may impose their own limits. + +Codex may display a smaller usable window. With the official catalog's 95% usable proportion, 272,000 appears as about 258k, 1,000,000 as about 950k, and 1,050,000 as about 997.5k. The advanced [`model_context_window`](https://developers.openai.com/codex/config-reference/#model_context_window) setting overrides all model windows for that provider; `model_auto_compact_token_limit` is the token threshold for automatic compaction, rather than the window size. In aggregation mode, each provider's window and compaction threshold are applied to its own model entries. + +When a window is explicitly configured, CC Switch synchronizes the catalog's context window, maximum window, and automatic compaction threshold. Unless `model_auto_compact_token_limit` is set separately, the threshold is 90% of the window (for example, 1,000,000 → 900,000 and 272,000 → 244,800). Provider-wide settings take precedence over per-model settings. + +For third-party models such as Gemini, a blank window uses the generic catalog default of 128,000. At 95%, Codex displays about 122k; this does not describe the upstream model's actual limit. Set the window according to your provider's supported limit. If Gemini still rejects the input token count, the local route converts that error into a context-window failure Codex can recognize for compaction recovery. Tokenization differences, images, and tool overhead can also affect the actual request size. + #### Reasoning Capability When the upstream format is Chat Completions, local routing converts Codex's outgoing reasoning request into parameters the upstream understands. The **Reasoning Capability** group in Advanced Options has two toggles: diff --git a/docs/user-manual/ja/2-providers/2.1-add.md b/docs/user-manual/ja/2-providers/2.1-add.md index 6299209db..5c0a56f99 100644 --- a/docs/user-manual/ja/2-providers/2.1-add.md +++ b/docs/user-manual/ja/2-providers/2.1-add.md @@ -504,6 +504,14 @@ Codex の非公式プロバイダーのフォームには、すべて **モデ - **デフォルトモデル** を空欄にすると、マッピング表の 1 行目がデフォルトで使用されます - 変更後、モデルリストを更新するには **Codex の再起動が必要** です(`model_catalog_json` は Codex 起動時に読み込まれます) +GPT モデルでは Codex 公式カタログのプロンプト、ツール、思考レベルを保持しますが、**コンテキストウィンドウを明示すると既定値を上書きできます**。空欄の場合は公式カタログの既定値を使用します。上流が対応する値を指定してください。OpenAI の [GPT-6.1 Sol](https://developers.openai.com/api/docs/models/gpt-6.1-sol) と [GPT-6 Astra](https://developers.openai.com/api/docs/models/gpt-6-astra) の仕様はどちらも 1,050,000 トークンですが、第三者プロバイダーでは独自の制限がある場合があります。 + +Codex が表示する利用可能なウィンドウは入力値より小さくなる場合があります。公式カタログの利用可能割合が 95% の場合、272,000 は約 258k、1,000,000 は約 950k、1,050,000 は約 997.5k と表示されます。詳細設定の [`model_context_window`](https://developers.openai.com/codex/config-reference/#model_context_window) はそのプロバイダーの全モデルのウィンドウを上書きします。`model_auto_compact_token_limit` は自動圧縮を開始するトークン数であり、ウィンドウのサイズではありません。集約モードでは各プロバイダーのウィンドウと圧縮しきい値を、それぞれのモデルエントリーに適用します。 + +ウィンドウを明示した場合、CC Switch はカタログのコンテキストウィンドウ、最大ウィンドウ、自動圧縮しきい値を同期します。`model_auto_compact_token_limit` を別途指定しない場合、圧縮しきい値はウィンドウの 90% です(例:1,000,000 → 900,000、272,000 → 244,800)。プロバイダー全体の設定はモデルごとの設定より優先されます。 + +Gemini などの第三者モデルでウィンドウが空欄の場合、汎用カタログの既定値は 128,000 です。利用可能割合が 95% なら Codex は約 122k と表示しますが、これは上流モデルの実際の上限を示しません。プロバイダーが対応する値を指定してください。Gemini が入力トークン数の超過を返した場合、ローカルルートは Codex が圧縮の復旧処理で認識できるコンテキスト超過イベントに変換します。モデル間のトークン分割の違い、画像、ツールの追加情報も実際のリクエストサイズに影響します。 + #### 思考能力(Reasoning) 上流フォーマットが Chat Completions の場合、ローカルルーティングは Codex が送る思考リクエストを上流が理解できるパラメータに変換します。高度なオプションの **思考能力** グループには 2 つのスイッチがあります: diff --git a/docs/user-manual/zh/2-providers/2.1-add.md b/docs/user-manual/zh/2-providers/2.1-add.md index 695e20e77..9e809a46e 100644 --- a/docs/user-manual/zh/2-providers/2.1-add.md +++ b/docs/user-manual/zh/2-providers/2.1-add.md @@ -504,6 +504,14 @@ Codex 非官方供应商的表单里都有 **模型映射** 表,用于声明 - **默认模型** 留空时,默认使用映射表的第一行 - **修改后需要重启 Codex** 才能刷新模型列表(`model_catalog_json` 在 Codex 启动时加载) +GPT 模型保留 Codex 官方目录的提示词、工具和思考档位,但**上下文窗口可以显式覆盖**;留空时沿用官方目录的默认值。请按上游实际支持的窗口填写:[OpenAI 的 GPT-6.1 Sol](https://developers.openai.com/api/docs/models/gpt-6.1-sol) 和 [GPT-6 Astra](https://developers.openai.com/api/docs/models/gpt-6-astra) 模型文档均标明 1,050,000 token,第三方供应商的限制仍以该供应商为准。 + +Codex 界面显示的可用窗口可能小于填写值。例如,官方目录的可用比例为 95% 时,272,000 显示约 258k,1,000,000 显示约 950k,1,050,000 显示约 997.5k。高级配置中的 [`model_context_window`](https://developers.openai.com/codex/config-reference/#model_context_window) 会统一覆盖该供应商的所有模型窗口;`model_auto_compact_token_limit` 则是触发自动压缩的 token 阈值,不是窗口大小。聚合模式下,每家供应商的窗口和压缩阈值分别写入各自的模型条目。 + +显式设置窗口时,CC Switch 会同步目录中的上下文窗口、最大窗口和自动压缩阈值;没有单独设置 `model_auto_compact_token_limit` 时,压缩阈值取窗口的 90%(例如 1,000,000 → 900,000,272,000 → 244,800)。供应商级设置优先于逐模型设置。 + +Gemini 等第三方模型的窗口留空时,通用目录默认使用 128,000;按 95% 计算,Codex 显示约 122k,这不代表上游只支持 122k。请按该供应商的实际限制填写窗口。如果 Gemini 仍返回输入 token 超限,本地路由会将其转换为 Codex 能识别的上下文超限事件,使客户端能够进入压缩恢复流程;不同模型的分词方式及图片、工具开销也会影响实际请求大小。 + #### 思考能力(Reasoning) 上游格式为 Chat Completions 时,本地路由会把 Codex 发出的思考请求转换成上游能理解的参数。高级选项的 **思考能力** 分组有两个开关: diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 3850a62ed..2160da7f8 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -2290,14 +2290,14 @@ fn find_codex_official_model<'a>(model: &str, candidates: &'a [Value]) -> Option }) } -/// 第三方供应商上命中官方的 GPT 行:官方条目整条照搬(提示词、工具、档位、窗口都以官方 -/// 为准,不接受行里的覆盖值;窗口不同时在 `config.toml` 里设 `model_context_window`), -/// 只改掉属于官方账号或官方后端的字段。 +/// 第三方供应商上命中官方的 GPT 行:保留官方的提示词、工具和档位,窗口接受显式覆盖, +/// 未配置时保持官方默认。其余只改掉属于官方账号或官方后端的字段。 fn codex_official_model_entry( official: &Value, model: &str, priority: usize, profile: CodexCatalogToolProfile, + context_window: Option, ) -> Value { let mut entry = official.clone(); let Some(obj) = entry.as_object_mut() else { @@ -2309,6 +2309,12 @@ fn codex_official_model_entry( } obj.insert("slug".to_string(), json!(model)); obj.insert("priority".to_string(), json!(1000 + priority)); + if let Some(window) = context_window { + obj.insert("context_window".to_string(), json!(window)); + // Codex clamps model_context_window to max_context_window. A stale + // official maximum would silently cap the user's explicit window. + obj.insert("max_context_window".to_string(), json!(window)); + } // 官方隐藏的条目用户明确写了,也要出现在选择器里。 obj.insert("visibility".to_string(), json!("list")); // 速度档、升级提示归官方账号。 @@ -2325,14 +2331,18 @@ fn codex_official_model_entry( entry } -/// 目录里的一行是不是按 `official` 原样镜像出来的:反向解析会保留的几项(显示名、窗口、 -/// 模态、并行工具调用)都和重新镜像的结果相同,只还原模型名才不丢东西。 +/// 除窗口外,反向解析会保留的显示名、模态、并行工具调用是否和官方镜像一致。 +/// 镜像的官方能力不用存回表单,显式窗口另行还原。 fn is_codex_official_mirror(entry: &Value, model: &str, official: &Value) -> bool { - let expected = - codex_official_model_entry(official, model, 0, CodexCatalogToolProfile::NativeResponses); + let expected = codex_official_model_entry( + official, + model, + 0, + CodexCatalogToolProfile::NativeResponses, + None, + ); [ "display_name", - "context_window", "input_modalities", "supports_parallel_tool_calls", ] @@ -2347,6 +2357,7 @@ fn codex_catalog_from_specs_for_row( config_text: &str, profile: CodexCatalogToolProfile, ) -> Result { + let windows = RowWindows::of(config_text); // Vendors that publish an OFFICIAL Codex models.json for their native // `/responses` gateway get it mirrored verbatim instead of the neutral // template: its freeform apply_patch, vendor harness base_instructions and @@ -2356,13 +2367,22 @@ fn codex_catalog_from_specs_for_row( let entries: Vec = specs .iter() .enumerate() - .map(|(index, spec)| codex_vendor_catalog_model_entry(&vendor_models, spec, index)) + .map(|(index, spec)| { + let mut entry = codex_vendor_catalog_model_entry(&vendor_models, spec, index); + if windows.window.is_some() + || windows.limit.is_some() + || spec.context_window.is_some() + { + sink_row_windows(&mut entry, &windows, true); + } + entry + }) .collect(); return Ok(json!({ "models": entries })); } - let default_context_window = - extract_codex_top_level_u64(config_text, "model_context_window").unwrap_or(128_000); + let configured_context_window = windows.window; + let default_context_window = configured_context_window.unwrap_or(128_000); // Native providers use the bundled clean template (no freeform apply_patch); // ProxyChat and Copilot clone the bundled classic tool template so their @@ -2388,9 +2408,17 @@ fn codex_catalog_from_specs_for_row( let entries: Vec = specs .iter() .enumerate() - .map( - |(index, spec)| match find_codex_official_model(&spec.model, &official) { - Some(found) => codex_official_model_entry(found, &spec.model, index, profile), + .map(|(index, spec)| { + let mut entry = match find_codex_official_model(&spec.model, &official) { + Some(found) => codex_official_model_entry( + found, + &spec.model, + index, + profile, + // Match Codex's global override and Stack's sink_row_windows: + // a provider-wide window takes precedence over a model's. + configured_context_window.or(spec.context_window), + ), None => codex_catalog_model_entry( &template, spec, @@ -2398,8 +2426,16 @@ fn codex_catalog_from_specs_for_row( profile, default_context_window, ), - }, - ) + }; + // A changed window needs a matching compaction threshold in single-provider + // catalogs too. Otherwise an official/template threshold can stay at 244800 + // even after the user requests 1M. Keep untouched official defaults intact. + if windows.window.is_some() || windows.limit.is_some() || spec.context_window.is_some() + { + sink_row_windows(&mut entry, &windows, true); + } + entry + }) .collect(); Ok(json!({ "models": entries })) } @@ -2653,7 +2689,7 @@ fn sink_row_windows(entry: &mut Value, windows: &RowWindows, third_party: bool) .then(|| obj.get("context_window").and_then(Value::as_u64)) .flatten() .filter(|window| *window > 0) - .map(|window| window * 9 / 10) + .map(|window| (u128::from(window) * 9 / 10) as u64) }); if let Some(limit) = limit { obj.insert("auto_compact_token_limit".to_string(), json!(limit)); @@ -2838,7 +2874,7 @@ fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) -> ) { continue; } - // 照搬官方的行只还原模型名,不能把官方值当成用户填的存回库里。通用模板没有 + // 官方镜像只还原模型名和显式窗口,不能把官方能力当成用户填的存回库里。通用模板没有 // `model_messages`(走 Anthropic 的行不照搬);旧版 ProxyChat 克隆的 gpt-5.5 模板 // 有,但带着用户填的显示名、窗口,下面逐项比对不上,照旧还原。 if entry @@ -2847,10 +2883,21 @@ fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) -> .is_some() { let official = official.get_or_insert_with(codex_openai_official_models); - if find_codex_official_model(model, official) - .is_some_and(|found| is_codex_official_mirror(entry, model, found)) + if let Some(found) = find_codex_official_model(model, official) + .filter(|found| is_codex_official_mirror(entry, model, found)) { - entries.push(json!({ "model": model })); + let mut restored = json!({ "model": model }); + let fallback_window = + extract_codex_top_level_u64(config_text, "model_context_window") + .or_else(|| found.get("context_window").and_then(Value::as_u64)); + if let Some(window) = entry + .get("context_window") + .and_then(Value::as_u64) + .filter(|window| *window > 0 && Some(*window) != fallback_window) + { + restored["contextWindow"] = json!(window); + } + entries.push(restored); continue; } } @@ -5051,6 +5098,8 @@ wire_api = "responses" "upgrade": { "model": "gpt-next" }, "context_window": 272_000, "max_context_window": 872_000, + "auto_compact_token_limit": 244_800, + "effective_context_window_percent": 95, "supports_image_detail_original": true, "supported_reasoning_levels": [ { "effort": "low", "description": "l" }, @@ -5109,8 +5158,8 @@ wire_api = "responses" let models = with_official_models(official_gpt_rows(), || { catalog_for( json!([ - // 行里的覆盖值对官方行不生效。 - { "model": "gpt-6-sol", "contextWindow": 128_000, "reasoningLevels": ["none"] }, + // 提示词、工具和思考档位保持官方值;未填窗口时也保留官方默认。 + { "model": "gpt-6-sol", "reasoningLevels": ["none"] }, { "model": "glm-5" } ]), "", @@ -5130,6 +5179,7 @@ wire_api = "responses" assert_eq!(gpt["tool_mode"], "code_mode_only"); assert_eq!(gpt["context_window"], 272_000); assert_eq!(gpt["max_context_window"], 872_000); + assert_eq!(gpt["auto_compact_token_limit"], 244_800); assert_eq!(gpt["default_reasoning_level"], "low"); assert_eq!(gpt["supported_reasoning_levels"][1]["effort"], "xhigh"); assert_eq!(gpt["supports_image_detail_original"], true); @@ -5150,6 +5200,209 @@ wire_api = "responses" assert_eq!(glm["shell_type"], "shell_command"); } + #[test] + fn official_gpt_rows_honor_explicit_context_windows() { + with_official_models(official_gpt_rows(), || { + for profile in [ + CodexCatalogToolProfile::NativeResponses, + CodexCatalogToolProfile::ProxyChat, + ] { + let models = catalog_for( + json!([ + { "model": "gpt-6-sol", "contextWindow": 1_050_000 }, + { "model": "openai/gpt-5.5", "contextWindow": "512000" }, + { "model": "gpt-6-sol-high", "contextWindow": 128_000 }, + { "model": "gpt-6", "contextWindow": "" } + ]), + "", + profile, + ); + for (model, window) in models.iter().zip([1_050_000, 512_000, 128_000]) { + assert_eq!(model["context_window"], window); + assert_eq!(model["max_context_window"], window); + assert_eq!(model["auto_compact_token_limit"], window * 9 / 10); + assert_eq!(model["effective_context_window_percent"], 95); + assert_eq!(model["apply_patch_tool_type"], "freeform"); + } + assert_eq!( + models[0]["model_messages"]["instructions_template"], + "GPT-6 Sol prompt" + ); + assert_eq!(models[3]["context_window"], 272_000); + assert_eq!(models[3]["max_context_window"], 872_000); + assert_eq!(models[3]["auto_compact_token_limit"], 244_800); + } + }); + } + + #[test] + fn official_gpt_rows_honor_global_context_window_overrides() { + with_official_models(official_gpt_rows(), || { + let models = catalog_for( + json!([ + { "model": "gpt-6-sol" }, + { "model": "gpt-5.5", "contextWindow": 512_000 } + ]), + "model_context_window = 1000000\nmodel_auto_compact_token_limit = 900000\n", + CodexCatalogToolProfile::NativeResponses, + ); + for model in models { + // Codex clamps config overrides to max_context_window, so both must match. + assert_eq!(model["context_window"], 1_000_000); + assert_eq!(model["max_context_window"], 1_000_000); + assert_eq!(model["auto_compact_token_limit"], 900_000); + } + }); + } + + #[test] + fn gemini_context_windows_and_compaction_limits_agree_in_every_catalog_mode() { + for profile in [ + CodexCatalogToolProfile::NativeResponses, + CodexCatalogToolProfile::ProxyChat, + ] { + for window in [272_000_u64, 1_000_000] { + let settings = json!({ "modelCatalog": { "models": [ + { "model": "gemini-3.8-flash-high", "contextWindow": window }, + { "model": "gemini-3.8-pro" } + ] } }); + let single = codex_model_catalog_from_settings(&settings, "", profile) + .unwrap() + .unwrap(); + assert_eq!(single["models"][0]["context_window"], window); + assert_eq!(single["models"][0]["max_context_window"], window); + assert_eq!( + single["models"][0]["auto_compact_token_limit"], + window * 9 / 10 + ); + assert_eq!(single["models"][1]["context_window"], 128_000); + + let stacked = plan_codex_stack_catalog( + CodexStackRoute::ThirdParty(CodexCatalogRow { + settings: &json!({}), + config_text: "", + profile, + }), + &[CodexStackCatalogMember { + key: "gemini", + provider_name: "Gemini", + row: CodexCatalogRow { + settings: &settings, + config_text: "", + profile, + }, + }], + false, + ) + .unwrap(); + let model = &stacked["models"][0]; + assert_eq!(model["slug"], "ccs-gemini/gemini-3.8-flash-high"); + assert_eq!(model["context_window"], window); + assert_eq!(model["auto_compact_token_limit"], window * 9 / 10); + + let global = codex_model_catalog_from_settings( + &settings, + "model_context_window = 512000\nmodel_auto_compact_token_limit = 400000\n", + profile, + ) + .unwrap() + .unwrap(); + for model in global["models"].as_array().unwrap() { + assert_eq!(model["context_window"], 512_000); + assert_eq!(model["max_context_window"], 512_000); + assert_eq!(model["auto_compact_token_limit"], 400_000); + } + } + } + } + + #[test] + fn official_gpt_context_windows_survive_catalog_round_trip() { + with_official_models(official_gpt_rows(), || { + for window in [128_000, 512_000, 1_050_000] { + let input = json!([{ "model": "gpt-6-sol", "contextWindow": window }]); + let catalog = json!({ "models": catalog_for( + input.clone(), "", CodexCatalogToolProfile::NativeResponses, + ) }); + let simplified = + build_simplified_catalog_from_texts("", &catalog.to_string()).unwrap(); + // Only the user's window is restored, without sticky official capabilities. + assert_eq!(simplified["models"], input); + let regenerated = codex_model_catalog_from_settings( + &json!({ "modelCatalog": simplified }), + "", + CodexCatalogToolProfile::NativeResponses, + ) + .unwrap() + .unwrap(); + assert_eq!(regenerated, catalog); + } + }); + } + + #[test] + fn official_gpt_global_context_windows_do_not_become_per_model_overrides() { + with_official_models(official_gpt_rows(), || { + let config = "model_context_window = 1000000\n"; + let catalog = json!({ "models": catalog_for( + json!([{ "model": "gpt-6-sol" }]), + config, + CodexCatalogToolProfile::NativeResponses, + ) }); + let simplified = + build_simplified_catalog_from_texts(config, &catalog.to_string()).unwrap(); + assert_eq!(simplified["models"], json!([{ "model": "gpt-6-sol" }])); + let regenerated = codex_model_catalog_from_settings( + &json!({ "modelCatalog": simplified }), + config, + CodexCatalogToolProfile::NativeResponses, + ) + .unwrap() + .unwrap(); + assert_eq!(regenerated, catalog); + }); + } + + #[test] + fn stacked_official_gpt_rows_keep_each_providers_context_window() { + with_official_models(official_gpt_rows(), || { + let route = json!({ "modelCatalog": { "models": [ + { "model": "gpt-6-sol", "contextWindow": 1_050_000 } + ] } }); + let member = json!({ "modelCatalog": { "models": [ + { "model": "gpt-6-sol", "contextWindow": 512_000 } + ] } }); + let catalog = plan_codex_stack_catalog( + CodexStackRoute::ThirdParty(CodexCatalogRow { + settings: &route, + config_text: "", + profile: CodexCatalogToolProfile::NativeResponses, + }), + &[CodexStackCatalogMember { + key: "relay", + provider_name: "Relay", + row: CodexCatalogRow { + settings: &member, + config_text: "", + profile: CodexCatalogToolProfile::ProxyChat, + }, + }], + false, + ) + .unwrap(); + let models = catalog["models"].as_array().unwrap(); + for (model, window) in models.iter().zip([1_050_000, 512_000]) { + assert_eq!(model["context_window"], window); + assert_eq!(model["max_context_window"], window); + assert_eq!(model["auto_compact_token_limit"], window * 9 / 10); + assert_eq!( + model["model_messages"]["instructions_template"], + "GPT-6 Sol prompt" + ); + } + }); + } + #[test] fn aliases_keep_their_own_name() { let models = with_official_models(official_gpt_rows(), || { diff --git a/src-tauri/src/mode/controller.rs b/src-tauri/src/mode/controller.rs index e3f305611..5d9c64d6a 100644 --- a/src-tauri/src/mode/controller.rs +++ b/src-tauri/src/mode/controller.rs @@ -7029,6 +7029,64 @@ model_provider = "c" .live_has_proxy_placeholder(&AppType::Codex)); } + #[tokio::test] + #[serial] + async fn codex_custom_context_windows_survive_updates_and_restart() { + let _home = Home::new(); + seed_codex("", None); + let mut relay = codex_native( + "relay", + "https://relay.example/v1", + "", + Some(json!({ "models": [ + { "model": "gpt-5.5", "contextWindow": 1_050_000 }, + { "model": "gpt-5.5-high", "contextWindow": 512_000 } + ] })), + ); + let gemini = codex_native( + "gemini", + "https://gemini.example/v1", + "", + Some(json!({ "models": [ + { "model": "gemini-3.8-flash-high", "contextWindow": 272_000 } + ] })), + ); + let state = state_with(AppType::Codex, &[relay.clone(), gemini], "relay").await; + enter(&state, &AppType::Codex, true).await.unwrap(); + let check = |gpt_window: u64, stacked: bool| { + let catalog = codex_catalog(); + let models = catalog["models"].as_array().unwrap(); + for (slug, window) in [("gpt-5.5", gpt_window), ("gpt-5.5-high", 512_000)] { + let model = models.iter().find(|model| model["slug"] == slug).unwrap(); + assert_eq!(model["context_window"], window); + assert_eq!(model["max_context_window"], window); + assert_eq!(model["auto_compact_token_limit"], window * 9 / 10); + } + if stacked { + let gemini = models + .iter() + .find(|model| model["slug"] == "ccs-gemini/gemini-3.8-flash-high") + .unwrap(); + assert_eq!(gemini["context_window"], 272_000); + assert_eq!(gemini["auto_compact_token_limit"], 244_800); + } + }; + check(1_050_000, false); + set_codex_member(&state, "gemini", true).await; + check(1_050_000, true); + relay.settings_config["modelCatalog"]["models"][0]["contextWindow"] = json!(750_000); + ProviderService::update(&state, AppType::Codex, None, relay).unwrap(); + check(750_000, true); + let catalog = codex_catalog(); + detach_all(&state).await; + startup(&state).await; + check(750_000, true); + assert_eq!(codex_catalog(), catalog); + set_codex_member(&state, "gemini", false).await; + check(750_000, false); + exit(&state, &AppType::Codex).await.unwrap(); + } + /// 子代理模型:路由模式照写路由那家的;Stack 模式不写(子代理跟随主模型),只删这两个 /// 键,`[agents]` 里用户自己的设置不动;Stack 模型都移除后恢复。 #[tokio::test] diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 81bf12f45..e6f329fd8 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -971,7 +971,7 @@ pub async fn handle_chat_completions( ctx.provider = provider; } log_forward_error(&state, &ctx, is_stream, &err.error).await; - return build_codex_proxy_error_response(&ctx, &endpoint, &err.error); + return build_codex_proxy_error_response(&ctx, &endpoint, &err.error, is_stream); } }; @@ -1096,7 +1096,7 @@ async fn handle_responses_for_app( ctx.provider = provider; } log_forward_error(&state, &ctx, is_stream, &err.error).await; - return build_codex_proxy_error_response(&ctx, &endpoint, &err.error); + return build_codex_proxy_error_response(&ctx, &endpoint, &err.error, is_stream); } }; @@ -1328,7 +1328,7 @@ async fn handle_codex_standalone_passthrough( ctx.provider = provider; } log_forward_error(&state, &ctx, false, &err.error).await; - return build_codex_proxy_error_response(&ctx, &endpoint, &err.error); + return build_codex_proxy_error_response(&ctx, &endpoint, &err.error, false); } }; @@ -1425,7 +1425,7 @@ async fn handle_responses_compact_for_app( ctx.provider = provider; } log_forward_error(&state, &ctx, is_stream, &err.error).await; - return build_codex_proxy_error_response(&ctx, &endpoint, &err.error); + return build_codex_proxy_error_response(&ctx, &endpoint, &err.error, is_stream); } }; @@ -1699,8 +1699,9 @@ async fn handle_codex_chat_to_responses_transform( if !status.is_success() { // 上游 Chat 错误体形状与 Responses 不一致(如 MiniMax 的 base_resp、自定义 detail 字段); // 直接透传会让 Codex 客户端无法识别错误码。这里统一转换为 Responses 风格 - // `{"error": {message, type, code, param}}`,保留原始 HTTP 状态码。 - return handle_codex_chat_error_response(response, ctx, status).await; + // `{"error": {message, type, code, param}}`。输入超限的流式请求返回 + // response.failed 以支持压缩恢复;其余错误保留原始 HTTP 状态码。 + return handle_codex_chat_error_response(response, ctx, status, is_stream).await; } if is_stream || response.is_sse() { @@ -1936,7 +1937,7 @@ async fn handle_codex_anthropic_to_responses_transform( let status = response.status(); if !status.is_success() { - return handle_codex_chat_error_response(response, ctx, status).await; + return handle_codex_chat_error_response(response, ctx, status, is_stream).await; } // Preserve live streaming when the gateway marks SSE correctly or omits an @@ -2172,11 +2173,13 @@ fn build_codex_anthropic_sse_response( /// 与正常响应分支配套:正常响应已经被改写成 Responses 形式,错误响应若仍保留 /// Chat 错误体(如 MiniMax 的 `{"base_resp": {"status_code": 2013}}`),Codex /// 客户端的错误处理就无法对齐字段。这里读取上游 body、规整成 -/// `{"error": {message, type, code, param}}` 并保留原始 HTTP 状态码。 +/// `{"error": {message, type, code, param}}`。输入超限的流式请求返回 +/// `response.failed` 以支持压缩恢复;其余错误保留原始 HTTP 状态码。 async fn handle_codex_chat_error_response( response: super::hyper_client::ProxyResponse, ctx: &RequestContext, status: axum::http::StatusCode, + is_stream: bool, ) -> Result { let body_timeout = if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 { @@ -2212,6 +2215,11 @@ async fn handle_codex_chat_error_response( }; let responses_error = transform_codex_chat::chat_error_to_response_error(Some(&parsed_value)); + if let Some(response) = + codex_context_window_sse_response(&ctx.request_model, &responses_error, status, is_stream) + { + return Ok(response); + } strip_entity_headers_for_rebuilt_body(&mut response_headers); strip_hop_by_hop_response_headers(&mut response_headers); @@ -2243,6 +2251,7 @@ async fn handle_codex_chat_error_response( /// 与 `handle_codex_chat_error_response`(处理上游真实错误响应、复制上游头)不同, /// 这里没有上游响应可参照,只产出一个 `application/json` 错误体。状态码走 /// `map_proxy_error_to_status`,该函数已与 `ProxyError::into_response` 对齐。 +/// 输入超限的 Responses 流式请求通过 `response.failed` 交给 Codex 的压缩恢复逻辑。 /// /// 注意:`endpoint` 经 `endpoint_with_query` 可能携带 query(如 `?beta=true`)并被 /// 原样写入错误体。当前 Codex 端点不在 query 里放凭证,故安全;若将来复用到 @@ -2251,10 +2260,23 @@ fn build_codex_proxy_error_response( ctx: &RequestContext, endpoint: &str, error: &ProxyError, + is_stream: bool, ) -> Result { let status = axum::http::StatusCode::from_u16(map_proxy_error_to_status(error)) .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); let body = codex_proxy_error_json(&ctx.provider.name, &ctx.request_model, endpoint, error); + let responses_endpoint = matches!( + endpoint.split('?').next(), + Some("/responses" | "/v1/responses") + ); + if let Some(response) = codex_context_window_sse_response( + &ctx.request_model, + &body, + status, + is_stream && responses_endpoint, + ) { + return Ok(response); + } let body = serde_json::to_vec(&body).map_err(|e| { log::error!("[Codex] 序列化代理错误体失败: {e}"); ProxyError::Internal(format!("Failed to serialize proxy error: {e}")) @@ -2273,6 +2295,44 @@ fn build_codex_proxy_error_response( }) } +/// Codex recognizes context-budget failures in `response.failed` SSE events. +/// A bare HTTP 400 is an invalid request to the client, even when its JSON has +/// the right code. Return a failed stream so local compaction can trim history +/// and retry. Other HTTP errors and non-streaming callers keep their status. +fn codex_context_window_sse_response( + model: &str, + body: &Value, + status: StatusCode, + is_stream: bool, +) -> Option { + if !is_stream + || status != StatusCode::BAD_REQUEST + || body.pointer("/error/code").and_then(Value::as_str) != Some("context_length_exceeded") + { + return None; + } + let response = json!({ + "id": format!("resp_{}", uuid::Uuid::new_v4().simple()), + "object": "response", + "created_at": chrono::Utc::now().timestamp(), + "status": "failed", + "model": model, + "output": [], + "error": body["error"], + }); + Some( + ( + StatusCode::OK, + [ + (axum::http::header::CONTENT_TYPE, "text/event-stream"), + (axum::http::header::CACHE_CONTROL, "no-cache"), + ], + super::providers::codex_responses_sse::response_failed(&response), + ) + .into_response(), + ) +} + fn codex_proxy_error_json( provider_name: &str, request_model: &str, @@ -4056,6 +4116,81 @@ mod stack_tests { (status, serde_json::from_slice(&bytes).unwrap()) } + #[tokio::test] + async fn gemini_http_overflow_reaches_codex_as_a_failed_stream() { + let state = proxy_state(); + let model = "ccs-gemini/gemini-3.8-flash-high"; + let provider = + crate::provider::Provider::with_id("gemini".into(), "Gemini".into(), json!({}), None); + let ctx = RequestContext::new( + &state, + &json!({}), + &axum::http::HeaderMap::new(), + AppType::Codex, + "Codex", + "codex", + Some(crate::mode::stack::StackTarget { + provider, + upstream_model: "gemini-3.8-flash-high".into(), + original_model: model.into(), + }), + ) + .await + .unwrap(); + let error = ProxyError::UpstreamError { + status: 400, + body: Some(json!({ "error": { + "code": 400, "status": "INVALID_ARGUMENT", + "message": "The input token count exceeds the maximum number of tokens allowed 1048576." + } }).to_string()), + }; + for endpoint in ["/responses", "/v1/responses?beta=true"] { + let response = build_codex_proxy_error_response(&ctx, endpoint, &error, true).unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], "text/event-stream"); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let sse = String::from_utf8(bytes.to_vec()).unwrap(); + assert!(sse.starts_with("event: response.failed\n")); + let data = sse + .lines() + .find_map(|line| line.strip_prefix("data: ")) + .unwrap(); + let event: Value = serde_json::from_str(data).unwrap(); + assert_eq!(event["type"], "response.failed"); + assert_eq!(event["response"]["status"], "failed"); + assert_eq!(event["response"]["model"], model); + assert_eq!( + event["response"]["error"]["code"], + "context_length_exceeded" + ); + assert_eq!(event["response"]["error"]["upstream_status"], 400); + assert!(!sse.contains("response.completed")); + } + // JSON consumers and other endpoints retain HTTP errors. + for (endpoint, stream) in [ + ("/responses", false), + ("/responses/compact", true), + ("/chat/completions", true), + ] { + let response = + build_codex_proxy_error_response(&ctx, endpoint, &error, stream).unwrap(); + let (status, body) = json_of(response).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "context_length_exceeded"); + } + let unrelated = ProxyError::UpstreamError { + status: 400, + body: Some( + json!({ "error": { "code": 400, "message": "Invalid API key" } }).to_string(), + ), + }; + let response = + build_codex_proxy_error_response(&ctx, "/responses", &unrelated, true).unwrap(); + let (status, body) = json_of(response).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], 400); + } + #[tokio::test] async fn unresolvable_stacked_ids_are_rejected_in_the_clients_protocol() { // 名单为空、没有任何供应商:回落到默认路由的话会报「没有供应商」,而不是 400。 diff --git a/src-tauri/src/proxy/providers/streaming_codex_chat.rs b/src-tauri/src/proxy/providers/streaming_codex_chat.rs index 964ff9492..640798517 100644 --- a/src-tauri/src/proxy/providers/streaming_codex_chat.rs +++ b/src-tauri/src/proxy/providers/streaming_codex_chat.rs @@ -6,10 +6,10 @@ use super::{ codex_compaction, inline_think::InlineThinkSplitter, transform_codex_chat::{ - chat_usage_to_responses_usage, custom_tool_input_from_chat_arguments, - response_id_from_chat_id, response_status_from_finish_reason, - response_tool_call_item_from_chat_name, response_tool_call_item_id_from_chat_name, - CodexToolContext, + chat_error_to_response_error, chat_usage_to_responses_usage, + custom_tool_input_from_chat_arguments, response_id_from_chat_id, + response_status_from_finish_reason, response_tool_call_item_from_chat_name, + response_tool_call_item_id_from_chat_name, CodexToolContext, }, }; use crate::proxy::json_canonical::canonicalize_tool_arguments_str; @@ -722,11 +722,23 @@ impl ChatToResponsesState { } fn failed_event(&mut self, message: String, error_type: Option) -> Bytes { - self.completed = true; let mut error = json!({ "message": message }); if let Some(error_type) = error_type.filter(|value| !value.is_empty()) { error["type"] = json!(error_type); } + self.failed_event_with_error(error) + } + + fn failed_event_with_error(&mut self, mut error: Value) -> Bytes { + // Codex expects Option; other JSON types would hide the upstream message. + if let Some(code) = error + .get_mut("code") + .filter(|code| !code.is_string() && !code.is_null()) + { + *code = json!(code.to_string()); + } + + self.completed = true; let mut response = self.base_response("failed", self.completed_output_items()); response["error"] = error; @@ -801,8 +813,8 @@ pub fn create_responses_sse_stream_from_chat_with_context (String, Option) { - let error = value.get("error").unwrap_or(value); - let message = error - .as_str() - .map(ToString::to_string) - .or_else(|| { - error - .get("message") - .or_else(|| error.get("detail")) - .and_then(|v| v.as_str()) - .map(ToString::to_string) - }) - .unwrap_or_else(|| error.to_string()); - let error_type = error - .get("type") - .or_else(|| error.get("code")) - .and_then(|v| v.as_str()) - .map(ToString::to_string); - - (message, error_type) -} - #[cfg(test)] mod tests { use super::*; @@ -1530,6 +1520,46 @@ mod tests { assert!(!output.contains("event: response.completed")); } + #[tokio::test] + async fn gemini_sse_numeric_error_code_preserves_message() { + for prefix in ["", "event: error\n"] { + let chunk = format!( + "{prefix}data: {{\"error\":{{\"code\":429,\"status\":\"RESOURCE_EXHAUSTED\",\"message\":\"Quota exceeded for metric\"}}}}\n\n" + ); + let output = collect(vec![&chunk, "data: [DONE]\n\n"]).await; + let events = parse_sse_events(&output); + let failed = events + .iter() + .find(|event| event["type"] == "response.failed") + .unwrap(); + + assert_eq!(failed["response"]["error"]["code"], "429"); + assert_eq!( + failed["response"]["error"]["message"], + "Quota exceeded for metric" + ); + assert!(!output.contains("event: response.completed")); + } + } + + #[tokio::test] + async fn gemini_sse_overflow_preserves_the_code_codex_uses_for_compaction() { + let output = collect(vec![ + "data: {\"error\":{\"code\":400,\"status\":\"INVALID_ARGUMENT\",\"message\":\"The input token count exceeds the maximum number of tokens allowed 1048576.\"}}\n\n", + "data: [DONE]\n\n", + ]).await; + let events = parse_sse_events(&output); + let failed = events + .iter() + .find(|event| event["type"] == "response.failed") + .unwrap(); + assert_eq!( + failed["response"]["error"]["code"], + "context_length_exceeded" + ); + assert!(!output.contains("event: response.completed")); + } + fn compaction_context() -> CodexToolContext { super::super::transform_codex_chat::build_codex_tool_context_from_request(&json!({ "input": [ diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index b60547e90..88496332b 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -2457,17 +2457,6 @@ pub fn chat_error_to_response_error(body: Option<&Value>) -> Value { }); }; - if let Some(text) = value.as_str() { - return json!({ - "error": { - "message": text, - "type": "upstream_error", - "code": serde_json::Value::Null, - "param": serde_json::Value::Null, - } - }); - } - let source = value.get("error").unwrap_or(value); let message = source @@ -2489,12 +2478,22 @@ pub fn chat_error_to_response_error(body: Option<&Value>) -> Value { .map(ToString::to_string) .unwrap_or_else(|| "upstream_error".to_string()); - let code = source + let mut code = source .get("code") .cloned() .or_else(|| source.pointer("/base_resp/status_code").cloned()) .unwrap_or(serde_json::Value::Null); + // Gemini uses numeric HTTP codes (or no code at all) for this error. Codex + // only recognizes context_length_exceeded as a context-budget failure; + // INVALID_ARGUMENT/upstream_error cannot trigger its compaction recovery. + if message + .to_ascii_lowercase() + .contains("input token count exceeds the maximum number of tokens allowed") + { + code = json!("context_length_exceeded"); + } + let param = source .get("param") .cloned() @@ -6243,6 +6242,29 @@ mod tests { assert_eq!(result["error"]["param"], "api_key"); } + #[test] + fn gemini_input_overflow_errors() { + let message = "The input token count exceeds the maximum number of tokens allowed 1048576."; + for input in [ + json!({ "error": { "code": 400, "status": "INVALID_ARGUMENT", "message": message } }), + json!({ "error": { "type": "invalid_request_error", "message": message } }), + json!({ "detail": message }), + json!(message), + ] { + let result = chat_error_to_response_error(Some(&input)); + assert_eq!(result["error"]["code"], "context_length_exceeded"); + assert_eq!(result["error"]["message"], message); + } + let ordinary_error = json!({ "error": { + "code": 400, "status": "INVALID_ARGUMENT", + "message": "max_output_tokens exceeds the maximum number of tokens allowed 65536." + } }); + assert_eq!( + chat_error_to_response_error(Some(&ordinary_error))["error"]["code"], + 400 + ); + } + #[test] fn chat_error_to_response_error_normalizes_minimax_base_resp() { // MiniMax 把错误塞在 base_resp 里,code 是数字而不是字符串