From 8d7a741540a44021dea3d6eda00aadc95f7a4249 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 19 Oct 2026 17:09:17 +0800 Subject: [PATCH] fix(models): cap only borrowed GPT windows and warn on oversized entries The 272K GPT cap (v4.0.13) applied to every window the metadata lookup returned, so platforms that really serve the 1.05M OpenAI API window (OpenRouter and other providers models.dev recognizes by address) were capped too. The risk the cap guards against is narrower: a relay that models.dev does not know falls back to the vendor's own entry, and its 1.05M lands on a model that is often served from a ChatGPT subscription with a 272K window. - Same-endpoint presets and the models.dev provider matched by address keep their own windows; only the window borrowed from the vendor entry (including when the matched provider lists none) is capped at 272K. - A window the user types is kept as is. When a GPT model's window is above 272K, the Codex, Hermes, OpenClaw, OpenCode and Pi forms show a note under the field: relay GPT usually has the subscription window, and an oversized one delays compaction until the upstream rejects the turn. Text added in zh / en / ja / zh-TW. Existing rows are not migrated. Codex GPT rows saved before v4.0.13 with an auto-filled 1.05M now take effect since #8385; the note shows when the form is opened. --- .../providers/forms/CodexFormFields.tsx | 6 +++ .../providers/forms/HermesFormFields.tsx | 6 ++- .../providers/forms/OpenClawFormFields.tsx | 6 ++- .../providers/forms/OpenCodeFormFields.tsx | 6 ++- .../providers/forms/PiProviderForm.tsx | 11 ++++- .../forms/shared/GptContextWindowHint.tsx | 41 +++++++++++++++++++ .../providers/forms/shared/index.ts | 1 + src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh-TW.json | 1 + src/i18n/locales/zh.json | 1 + src/lib/modelMetadata.ts | 18 ++++---- .../components/GptContextWindowHint.test.tsx | 21 ++++++++++ tests/components/PiProviderForm.test.tsx | 3 +- tests/lib/modelMetadata.test.ts | 26 +++++++++--- 15 files changed, 130 insertions(+), 19 deletions(-) create mode 100644 src/components/providers/forms/shared/GptContextWindowHint.tsx create mode 100644 tests/components/GptContextWindowHint.test.tsx diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index c5ce8e6f1..67ae352eb 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -56,6 +56,7 @@ import { DirectModeFormatWarning, EndpointField, FullUrlToggle, + GptContextWindowHint, ModelDropdown, type DirectModeFormatNotice, } from "./shared"; @@ -1677,6 +1678,11 @@ export function CodexFormFields({ + ))} diff --git a/src/components/providers/forms/HermesFormFields.tsx b/src/components/providers/forms/HermesFormFields.tsx index a34b6cd46..c2656fd44 100644 --- a/src/components/providers/forms/HermesFormFields.tsx +++ b/src/components/providers/forms/HermesFormFields.tsx @@ -16,7 +16,7 @@ import { } from "@/components/ui/select"; import { toast } from "@/lib/toast"; import { Download, Plus, Trash2, ChevronRight, Loader2 } from "lucide-react"; -import { ApiKeySection, ModelDropdown } from "./shared"; +import { ApiKeySection, GptContextWindowHint, ModelDropdown } from "./shared"; import { fetchModelsForConfig, showFetchModelsError, @@ -451,6 +451,10 @@ export function HermesFormFields({ }} placeholder="200000" /> +
+
+ + {t("providerForm.gptContextWindowHint", { + defaultValue: + "中转站的 GPT 多来自 ChatGPT 订阅,窗口只有 272K。窗口写大了不会提前压缩,等上游拒收时这一轮会失败;确认这家支持更大窗口再这样填。", + })} +

+ ); +} diff --git a/src/components/providers/forms/shared/index.ts b/src/components/providers/forms/shared/index.ts index d880f60f7..8c73b760b 100644 --- a/src/components/providers/forms/shared/index.ts +++ b/src/components/providers/forms/shared/index.ts @@ -4,4 +4,5 @@ export { ModelDropdown } from "./ModelDropdown"; export { ModelInputWithFetch } from "./ModelInputWithFetch"; export { DirectModeFormatWarning } from "./DirectModeFormatWarning"; export { FullUrlToggle } from "./FullUrlToggle"; +export { GptContextWindowHint } from "./GptContextWindowHint"; export type { DirectModeFormatNotice } from "./DirectModeFormatWarning"; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index baeeb48a2..159ec5e5a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1739,6 +1739,7 @@ "fetchModelsSuccess": "Found {{count}} models", "modelMetadataFilled": "Filled in context window and other parameters for {{model}} (source: {{source}}). Please double-check.", "modelMetadataFilledMany": "Filled in context window and other parameters for {{count}} models (source: {{source}}). Please double-check.", + "gptContextWindowHint": "GPT on relays usually comes from ChatGPT subscriptions, whose window is only 272K. A larger window delays compaction until the upstream rejects the request and the turn fails; only use it if this provider supports it.", "modelMetadataSourcePreset": "preset", "modelMetadataSourceBoth": "preset and models.dev", "modelPicker": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5464d8c0b..aa5c4b073 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1739,6 +1739,7 @@ "fetchModelsSuccess": "{{count}}件のモデルを取得", "modelMetadataFilled": "{{model}} のコンテキスト長などのパラメータを補完しました(出典:{{source}})。内容をご確認ください", "modelMetadataFilledMany": "{{count}} 件のモデルのコンテキスト長などのパラメータを補完しました(出典:{{source}})。内容をご確認ください", + "gptContextWindowHint": "中継サービスの GPT は多くが ChatGPT サブスクリプション由来で、ウィンドウは 272K しかありません。大きく設定すると圧縮が遅れ、上流に拒否された時点でそのターンが失敗します。このプロバイダーが対応している場合のみ設定してください。", "modelMetadataSourcePreset": "プリセット", "modelMetadataSourceBoth": "プリセットと models.dev", "modelPicker": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 199dfd96f..903b16853 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1740,6 +1740,7 @@ "fetchModelsSuccess": "取得 {{count}} 個模型", "modelMetadataFilled": "已補齊 {{model}} 的上下文等參數(來源:{{source}}),請核對", "modelMetadataFilledMany": "已補齊 {{count}} 個模型的上下文等參數(來源:{{source}}),請核對", + "gptContextWindowHint": "中轉站的 GPT 多來自 ChatGPT 訂閱,視窗只有 272K。視窗寫大了不會提前壓縮,等上游拒收時這一輪會失敗;確認這家支援更大視窗再這樣填。", "modelMetadataSourcePreset": "預設", "modelMetadataSourceBoth": "預設和 models.dev", "modelPicker": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1ee5cb032..7e35b1c89 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1739,6 +1739,7 @@ "fetchModelsSuccess": "获取到 {{count}} 个模型", "modelMetadataFilled": "已补全 {{model}} 的上下文等参数(来源:{{source}}),请核对", "modelMetadataFilledMany": "已补全 {{count}} 个模型的上下文等参数(来源:{{source}}),请核对", + "gptContextWindowHint": "中转站的 GPT 多来自 ChatGPT 订阅,窗口只有 272K。窗口写大了不会提前压缩,等上游拒收时这一轮会失败;确认这家支持更大窗口再这样填。", "modelMetadataSourcePreset": "预设", "modelMetadataSourceBoth": "预设和 models.dev", "modelPicker": { diff --git a/src/lib/modelMetadata.ts b/src/lib/modelMetadata.ts index 3d58e9d88..bdf4e7ee5 100644 --- a/src/lib/modelMetadata.ts +++ b/src/lib/modelMetadata.ts @@ -52,9 +52,10 @@ export interface PresetModelSource { /** * GPT 模型的窗口上限:Codex 官方目录给 GPT-5.5 以后各款的默认窗口。OpenAI API - * 写的是 1.05M,但很多中转站的 GPT 是从 ChatGPT 订阅分发的,按订阅的窗口卡; - * 只看地址分不出是哪种。窗口写大了客户端不压缩,直到上游拒收、这一轮失败; - * 写小只是提前压缩,所以一律按订阅的窗口封顶。预设里的 GPT 窗口同样遵守。 + * 写的是 1.05M,但很多中转站的 GPT 是从 ChatGPT 订阅分发的,按订阅的窗口卡。 + * 窗口写大了客户端不压缩,直到上游拒收、这一轮失败;写小只是提前压缩。所以 + * 认不出地址、只能借原厂条目的窗口时按订阅的窗口封顶;同地址预设和 models.dev + * 按地址认出的那家照用它们自己的值,用户手填的不经过这里。 */ export const GPT_CONTEXT_WINDOW_CAP = 272_000; @@ -338,7 +339,7 @@ function fieldCount(metadata: KnownModelMetadata): number { * 3. models.dev 里的原厂条目(不带价格:转售价不等于原厂价;思考档位照用, * 中转站绝大多数与原厂一致), * 认出了供应商也照样用它补供应商条目里没写的字段。 - * GPT 模型的窗口不论来源都按 {@link GPT_CONTEXT_WINDOW_CAP} 封顶。 + * GPT 模型的窗口只在取自原厂条目时按 {@link GPT_CONTEXT_WINDOW_CAP} 封顶。 * 都查不到返回 null。 */ export function resolveModelMetadata( @@ -382,13 +383,14 @@ export function resolveModelMetadata( } const vendorModel = findByVendor(index, id); if (vendorModel) { - add("models-dev", metadataFromModelsDev(vendorModel, false)); + const vendor = metadataFromModelsDev(vendorModel, false); + if (vendor.contextWindow !== undefined) { + vendor.contextWindow = capGptContextWindow(id, vendor.contextWindow); + } + add("models-dev", vendor); } } if (sources.length === 0) return null; - if (metadata.contextWindow !== undefined) { - metadata.contextWindow = capGptContextWindow(id, metadata.contextWindow); - } return { ...metadata, sources }; } diff --git a/tests/components/GptContextWindowHint.test.tsx b/tests/components/GptContextWindowHint.test.tsx new file mode 100644 index 000000000..66f466951 --- /dev/null +++ b/tests/components/GptContextWindowHint.test.tsx @@ -0,0 +1,21 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { GptContextWindowHint } from "@/components/providers/forms/shared"; + +const hint = (modelId: string | undefined, contextWindow: unknown) => + render( + , + ).container.textContent; + +describe("GptContextWindowHint", () => { + it("warns only when a GPT window exceeds the subscription window", () => { + expect(hint("gpt-6-astra", "1050000")).toContain("272K"); + expect(hint("openai/gpt-6.1-sol", 500000)).toContain("272K"); + + expect(hint("gpt-6-astra", "272000")).toBe(""); + expect(hint("gpt-6-astra", "")).toBe(""); + expect(hint("deepseek-v4", 1000000)).toBe(""); + expect(hint(undefined, 1050000)).toBe(""); + }); +}); diff --git a/tests/components/PiProviderForm.test.tsx b/tests/components/PiProviderForm.test.tsx index 28a3b2165..a8b2ae95c 100644 --- a/tests/components/PiProviderForm.test.tsx +++ b/tests/components/PiProviderForm.test.tsx @@ -1377,9 +1377,10 @@ describe("PiProviderForm", () => { await user.click( screen.getByRole("button", { name: "展开或收起模型详情" }), ); + // models.dev 按地址认出了这家,GPT 窗口照用它登记的值,不封顶。 await waitFor(() => expect(screen.getByLabelText("pi.form.contextWindow")).toHaveValue( - 272000, + 400000, ), ); expect(screen.getByLabelText("pi.form.maxTokens")).toHaveValue(128000); diff --git a/tests/lib/modelMetadata.test.ts b/tests/lib/modelMetadata.test.ts index 46b3030bf..34f387506 100644 --- a/tests/lib/modelMetadata.test.ts +++ b/tests/lib/modelMetadata.test.ts @@ -311,7 +311,7 @@ describe("resolveModelMetadata", () => { ).toBeNull(); }); - it("caps GPT windows at the subscription window whatever the source", () => { + it("caps GPT windows only when they are borrowed from the vendor entry", () => { const openai: ModelsDevResponse = { openai: { id: "openai", @@ -334,20 +334,33 @@ describe("resolveModelMetadata", () => { }, }, }, + gateway: { + id: "gateway", + api: "https://gateway.example.com/v1", + models: { + "gpt-6-astra": { + id: "gpt-6-astra", + canonical_model_id: "openai/gpt-6-astra", + }, + }, + }, }; const resolve = (id: string, baseUrl: string) => resolveModelMetadata(id, { baseUrl, modelsDev: openai })?.contextWindow; - // 认出了 OpenAI 自己、走原厂兜底的中转、带命名空间或平台前缀的写法都封顶。 - expect(resolve("gpt-6-astra", "https://api.openai.com/v1")).toBe( - GPT_CONTEXT_WINDOW_CAP, - ); + // 认不出地址、借原厂条目的中转封顶,带命名空间的写法也一样。 expect(resolve("gpt-6-astra", "https://relay.example.com/v1")).toBe( GPT_CONTEXT_WINDOW_CAP, ); expect(resolve("openai/GPT-6-Astra", "https://relay.example.com/v1")).toBe( GPT_CONTEXT_WINDOW_CAP, ); + // 认出了地址但那家没写窗口,借来的原厂窗口同样封顶。 + expect(resolve("gpt-6-astra", "https://gateway.example.com/v1")).toBe( + GPT_CONTEXT_WINDOW_CAP, + ); + // 按地址认出的那家、同地址预设照用自己的值。 + expect(resolve("gpt-6-astra", "https://api.openai.com/v1")).toBe(1050000); expect( resolveModelMetadata("openai-gpt-6-astra", { baseUrl: "https://relay.example.com/v1", @@ -356,8 +369,9 @@ describe("resolveModelMetadata", () => { "openai-gpt-6-astra": { contextWindow: 1050000 }, }), ], + modelsDev: openai, })?.contextWindow, - ).toBe(GPT_CONTEXT_WINDOW_CAP); + ).toBe(1050000); // 本来就小的 GPT 不往上抬,其他模型不受影响。 expect(resolve("gpt-4o", "https://api.openai.com/v1")).toBe(128000); expect(resolve("o9-big", "https://api.openai.com/v1")).toBe(1050000);