diff --git a/docs-site/public/pr-screenshots/1073-context-window-controls.jpg b/docs-site/public/pr-screenshots/1073-context-window-controls.jpg new file mode 100644 index 0000000000..fe157cf923 Binary files /dev/null and b/docs-site/public/pr-screenshots/1073-context-window-controls.jpg differ diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 4849f1d3a3..4289d5aec6 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -68,8 +68,8 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, these are the only discovered models. | | `liveModels?` | `boolean` | Fetch the live catalog on start/sync (default `true`). Custom providers use `${baseUrl}/models`; built-ins may use a registry URL and filter. | | `selectedModels?` | `string[]` | Catalog allowlist after discovery. Non-empty exposes only those ids; empty or omitted exposes all discovered models. | -| `contextWindow?` | `number` | Provider-wide Codex-visible context cap. Smaller live metadata is retained. | -| `modelContextWindows?` | `Record` | Per-model context caps. These override `contextWindow` and never raise smaller live metadata. | +| `contextWindow?` | `number` | Provider-wide context fallback when upstream metadata is absent; otherwise a cap that retains smaller live metadata. The Models dashboard exposes this separately from `providerContextCaps`. | +| `modelContextWindows?` | `Record` | Per-model context fallbacks/caps. These override `contextWindow`: an unknown window uses the configured value, while smaller live metadata remains authoritative. | | `modelInputModalities?` | `Record` | Per-model input hints such as `["text"]` or `["text", "image"]`. | | `modelMaxInputTokens?` | `Record` | Positive per-model max input limits used for catalog auto-compaction hints. | | `defaultMaxOutputTokens?` | `number` | Provider-wide `openai-chat` fallback when the client omits `max_output_tokens`. | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index bc23cf5a28..dba0a8747d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -465,6 +465,16 @@ export const de: Record = { "models.v2ThreadsInvalid": "Thread-Limit muss eine ganze Zahl >= 1 sein", "models.v2ThreadsApply": "Anwenden", "models.capValue": "Limit {value}", + "models.contextSettings": "Kontextfenster", + "models.contextSettingsTitle": "Kontextfenster — {provider}", + "models.contextDefault": "Anbieterstandard", + "models.contextModel": "Modell", + "models.contextModelOverride": "Modellüberschreibung", + "models.contextHint": "Wird verwendet, wenn Upstream-Metadaten fehlen; andernfalls begrenzt der Wert ein größeres gemeldetes Fenster. Leer lassen für automatische Erkennung.", + "models.contextAutomatic": "Automatische Erkennung", + "models.contextSaved": "Kontextfenster aktualisiert — gilt ab der nächsten Codex-Runde.", + "models.contextSaveFailed": "Kontextfenster konnten nicht gespeichert werden", + "models.contextInvalid": "Kontextfenster müssen positive ganze Zahlen sein", "models.contextCappedValue": "{value}-Limit", "models.setAll": "Alle setzen", "models.setAllHint": "Wendet das {value}-Kontext-Limit auf alle gerouteten Anbieter an. Native Anbieter bleiben unberührt.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c63a334950..2880ef7e78 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -484,6 +484,16 @@ export const en = { "models.v2ThreadsInvalid": "Thread limit must be an integer >= 1", "models.v2ThreadsApply": "Apply", "models.capValue": "Cap {value}", + "models.contextSettings": "Context windows", + "models.contextSettingsTitle": "Context windows — {provider}", + "models.contextDefault": "Provider default", + "models.contextModel": "Model", + "models.contextModelOverride": "Model override", + "models.contextHint": "Used when upstream metadata is missing; otherwise limits a larger reported window. Leave blank for automatic discovery.", + "models.contextAutomatic": "Automatic discovery", + "models.contextSaved": "Context windows updated — takes effect on the next Codex turn.", + "models.contextSaveFailed": "Failed to save context windows", + "models.contextInvalid": "Context windows must be positive whole numbers", "models.contextCappedValue": "{value} cap", "models.setAll": "Set all", "models.setAllHint": "Apply the {value} context cap to every routed provider. Native providers are unaffected.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 8bef10accf..538f2b7000 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -473,6 +473,16 @@ export const ja: Record = { "models.v2ThreadsInvalid": "スレッド上限は 1 以上の整数にしてください", "models.v2ThreadsApply": "適用", "models.capValue": "上限 {value}", + "models.contextSettings": "コンテキストウィンドウ", + "models.contextSettingsTitle": "コンテキストウィンドウ — {provider}", + "models.contextDefault": "プロバイダーのデフォルト", + "models.contextModel": "モデル", + "models.contextModelOverride": "モデル別の上書き", + "models.contextHint": "上流メタデータがない場合に使われ、メタデータがある場合は報告値の上限になります。自動検出に戻すには空欄にします。", + "models.contextAutomatic": "自動検出", + "models.contextSaved": "コンテキストウィンドウを更新しました — 次回の Codex ターンから有効です。", + "models.contextSaveFailed": "コンテキストウィンドウを保存できませんでした", + "models.contextInvalid": "コンテキストウィンドウは正の整数で指定してください", "models.contextCappedValue": "{value} 上限", "models.setAll": "すべて設定", "models.setAllHint": "{value} のコンテキスト上限をすべてのルーティング済みプロバイダーに適用します。ネイティブプロバイダーには影響しません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 4112cbd791..2fa80b4b30 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -476,6 +476,16 @@ export const ko: Record = { "models.v2ThreadsInvalid": "스레드 한도는 1 이상 정수여야 합니다", "models.v2ThreadsApply": "적용", "models.capValue": "{value} 제한", + "models.contextSettings": "컨텍스트 윈도우", + "models.contextSettingsTitle": "컨텍스트 윈도우 — {provider}", + "models.contextDefault": "프로바이더 기본값", + "models.contextModel": "모델", + "models.contextModelOverride": "모델별 재정의", + "models.contextHint": "업스트림 메타데이터가 없을 때 사용하며, 메타데이터가 있으면 더 큰 보고값의 상한으로 적용합니다. 자동 검색을 사용하려면 비워 두세요.", + "models.contextAutomatic": "자동 검색", + "models.contextSaved": "컨텍스트 윈도우가 업데이트되었습니다 — 다음 Codex 턴부터 적용됩니다.", + "models.contextSaveFailed": "컨텍스트 윈도우를 저장하지 못했습니다", + "models.contextInvalid": "컨텍스트 윈도우는 양의 정수여야 합니다", "models.contextCappedValue": "{value} 제한", "models.setAll": "전체 적용", "models.setAllHint": "{value} 컨텍스트 상한을 라우팅된 모든 프로바이더에 적용합니다. 네이티브 프로바이더는 영향을 받지 않습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index d38a236bc2..618d00cb69 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -478,6 +478,16 @@ export const ru: Record = { "models.v2ThreadsInvalid": "Лимит потоков должен быть целым числом >= 1", "models.v2ThreadsApply": "Применить", "models.capValue": "Лимит {value}", + "models.contextSettings": "Контекстные окна", + "models.contextSettingsTitle": "Контекстные окна — {provider}", + "models.contextDefault": "Значение провайдера", + "models.contextModel": "Модель", + "models.contextModelOverride": "Переопределение модели", + "models.contextHint": "Используется, если вышестоящие метаданные отсутствуют; иначе ограничивает большее заявленное окно. Оставьте поле пустым для автоматического определения.", + "models.contextAutomatic": "Автоматическое определение", + "models.contextSaved": "Контекстные окна обновлены — изменения вступят в силу на следующем ходе Codex.", + "models.contextSaveFailed": "Не удалось сохранить контекстные окна", + "models.contextInvalid": "Контекстные окна должны быть положительными целыми числами", "models.contextCappedValue": "Лимит {value}", "models.setAll": "Применить ко всем", "models.setAllHint": "Применяет лимит контекста {value} ко всем маршрутизируемым провайдерам. Нативные провайдеры не затрагиваются.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ba9ca825e8..0c2a8588a2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -473,6 +473,16 @@ export const zh: Record = { "models.v2ThreadsInvalid": "线程上限必须为 >= 1 的整数", "models.v2ThreadsApply": "应用", "models.capValue": "限制 {value}", + "models.contextSettings": "上下文窗口", + "models.contextSettingsTitle": "上下文窗口 — {provider}", + "models.contextDefault": "提供方默认值", + "models.contextModel": "模型", + "models.contextModelOverride": "模型覆盖值", + "models.contextHint": "上游缺少元数据时使用该值;上游已有元数据时,它只限制更大的报告值。留空则恢复自动发现。", + "models.contextAutomatic": "自动发现", + "models.contextSaved": "上下文窗口已更新 — 将在下一个 Codex 回合生效。", + "models.contextSaveFailed": "保存上下文窗口失败", + "models.contextInvalid": "上下文窗口必须为正整数", "models.contextCappedValue": "{value} 限制", "models.setAll": "全部设置", "models.setAllHint": "将 {value} 上下文上限应用到所有已路由的提供方。原生提供方不受影响。", diff --git a/gui/src/models-groups.ts b/gui/src/models-groups.ts index 193ae1e6a5..e6cce7f5cf 100644 --- a/gui/src/models-groups.ts +++ b/gui/src/models-groups.ts @@ -14,6 +14,8 @@ export interface ConfiguredProviderSummary { disabled?: boolean; liveModels?: boolean; models?: string[]; + contextWindow?: number; + modelContextWindows?: Record; discovery?: ProviderDiscoverySummary; } @@ -23,6 +25,8 @@ export interface ProviderModelGroup { native: boolean; liveModels: boolean; configuredModels: string[]; + contextWindow?: number; + modelContextWindows?: Record; discovery?: ProviderDiscoverySummary; } @@ -56,6 +60,8 @@ export function buildProviderModelGroups 0 && providerRows.every(row => row.native === true), liveModels: configured?.liveModels !== false, configuredModels: configured?.models ?? [], + contextWindow: configured?.contextWindow, + modelContextWindows: configured?.modelContextWindows, discovery: configured?.discovery, }; }) diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 3c94b66a81..6bc9ae33f2 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -174,6 +174,13 @@ export default function Models({ apiBase }: { apiBase: string }) { const [customFormModalities, setCustomFormModalities] = useState(["text"]); const [customSaving, setCustomSaving] = useState(false); const [customError, setCustomError] = useState(""); + const [contextModalProvider, setContextModalProvider] = useState(null); + const [contextModalModels, setContextModalModels] = useState([]); + const [contextModelId, setContextModelId] = useState(""); + const [contextDefaultDraft, setContextDefaultDraft] = useState(""); + const [contextModelDrafts, setContextModelDrafts] = useState>({}); + const [contextSaving, setContextSaving] = useState(false); + const [contextError, setContextError] = useState(""); const [hoveredModel, setHoveredModel] = useState<{ namespaced: string; rect: DOMRect } | null>(null); const hoverTimerRef = useRef | null>(null); const [shadowCall, setShadowCall] = useState(null); @@ -342,6 +349,75 @@ export default function Models({ apiBase }: { apiBase: string }) { */ const catalogCountReady = models.length > 0 || catalogState.data !== undefined; + const openContextSettings = (group: ProviderModelGroup) => { + const modelIds = [...new Set([ + ...group.rows.map(model => model.id), + ...group.configuredModels, + ])].sort(); + const modelId = modelIds[0] ?? ""; + setContextModalProvider(group.provider); + setContextModalModels(modelIds); + setContextModelId(modelId); + setContextDefaultDraft(group.contextWindow ? String(group.contextWindow) : ""); + setContextModelDrafts(Object.fromEntries( + Object.entries(group.modelContextWindows ?? {}) + .map(([model, window]) => [model, String(window)]), + )); + setContextError(""); + }; + + const selectContextModel = (modelId: string) => { + setContextModelId(modelId); + }; + + const parseContextWindowDraft = (raw: string): number | null | undefined => { + const normalized = raw.replace(/[_,\s]/g, ""); + if (!normalized) return null; + const value = Number(normalized); + return Number.isFinite(value) && Number.isInteger(value) && value > 0 + ? value + : undefined; + }; + + const saveContextSettings = async () => { + if (!contextModalProvider) return; + const providerWindow = parseContextWindowDraft(contextDefaultDraft); + const modelWindow = parseContextWindowDraft(contextModelDrafts[contextModelId] ?? ""); + if (providerWindow === undefined || modelWindow === undefined) { + setContextError(t("models.contextInvalid")); + return; + } + const group = groups.find(candidate => candidate.provider === contextModalProvider); + if (!group) { + setContextError(t("models.contextSaveFailed")); + return; + } + + setContextSaving(true); + setContextError(""); + try { + const body: Record = { contextWindow: providerWindow }; + if (contextModelId) { + body.modelContextWindows = { [contextModelId]: modelWindow }; + } + const response = await fetch( + `${apiBase}/api/providers?name=${encodeURIComponent(contextModalProvider)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + await readJsonOrThrow(response, t("models.contextSaveFailed")); + setContextModalProvider(null); + publishFeedback(true, t("models.contextSaved")); + await load(true); + } catch (error) { + setContextError(error instanceof Error ? error.message : t("models.contextSaveFailed")); + } finally { + setContextSaving(false); + } + }; // One-shot default collapse. It stays an effect on `groups` so CACHED groups collapse // immediately on first paint, even when revalidation is slow or fails; moving it into @@ -783,6 +859,14 @@ export default function Models({ apiBase }: { apiBase: string }) { {t("models.active", { active: activeCount, total: rows.length })}
+ {!isNative && ( + + )} {!isNative && ( +
+ + {contextError && {contextError}} +

{t("models.contextHint")}

+ +
+ + + {contextModalModels.length > 0 && ( + <> +
+ {t("models.contextModel")} + setContextModelDrafts(current => ({ + ...current, + [contextModelId]: event.target.value, + }))} + disabled={contextSaving} + placeholder={t("models.contextAutomatic")} + /> + + + )} +
+ +
+ + +
+
+ + )} + {customModalOpen && (
; enabled: boolean }> = []; + const contextBodies: Array<{ + contextWindow: number | null; + modelContextWindows: Record; + }> = []; + let providerContextWindow: number | undefined = 256_000; + let providerModelContextWindows: Record = { "claude-opus": 64_000 }; let failNext = false; let failCatalog = false; let modelFetches = 0; @@ -142,9 +148,22 @@ test("Models page combines final visibility, atomic actions, discovery status, a name: provider, liveModels: true, models: ids, + contextWindow: providerContextWindow, + modelContextWindows: providerModelContextWindows, discovery: { status: "failed", reason: "http", httpStatus: 401 }, }]); } + if (url.includes("/api/providers?name=") && init?.method === "PATCH") { + const body = JSON.parse(String(init.body)) as (typeof contextBodies)[number]; + contextBodies.push(body); + if (body.contextWindow === null) providerContextWindow = undefined; + else if (typeof body.contextWindow === "number") providerContextWindow = body.contextWindow; + for (const [model, value] of Object.entries(body.modelContextWindows ?? {})) { + if (value === null) delete providerModelContextWindows[model]; + else providerModelContextWindows = { ...providerModelContextWindows, [model]: value }; + } + return Response.json({ success: true }); + } if (url.endsWith("/api/selected-models")) return Response.json({ selected: { [provider]: selected }, available: { [provider]: ids } }); if (url.endsWith("/api/provider-context-caps")) return Response.json({ caps: {} }); if (url.endsWith("/api/combos")) return Response.json({ combos: [] }); @@ -196,6 +215,65 @@ test("Models page combines final visibility, atomic actions, discovery status, a expect(container.querySelector(".badge.badge-amber")?.textContent).toContain("Discovery failed"); expect(container.textContent).not.toContain("Not selected"); + await act(async () => buttonText("Context windows").click()); + const contextDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + const contextInputs = contextDialog.querySelectorAll("input"); + expect([...contextInputs].map(input => input.value)).toEqual(["256000", "64000"]); + const setValue = Object.getOwnPropertyDescriptor( + testWindow.HTMLInputElement.prototype, + "value", + )!.set!; + await act(async () => { + setValue.call(contextInputs[0]!, "350000"); + contextInputs[0]!.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + setValue.call(contextInputs[1]!, "100000"); + contextInputs[1]!.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + const pickContextModel = async (modelId: string) => { + await act(async () => { + contextDialog.querySelector('button.select-trigger[aria-label="Model"]')!.click(); + }); + const option = [...testWindow.document.querySelectorAll('[role="option"]')] + .find(candidate => candidate.textContent === modelId)!; + await act(async () => option.click()); + }; + await pickContextModel("claude-sonnet"); + expect(contextInputs[1]!.value).toBe(""); + await act(async () => { + setValue.call(contextInputs[1]!, "80000"); + contextInputs[1]!.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await pickContextModel("claude-opus"); + expect(contextInputs[1]!.value).toBe("100000"); + await pickContextModel("claude-sonnet"); + expect(contextInputs[1]!.value).toBe("80000"); + await pickContextModel("claude-opus"); + const applyContext = [...contextDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")!; + await act(async () => { + applyContext.click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + expect(contextBodies.at(-1)).toEqual({ + contextWindow: 350_000, + modelContextWindows: { "claude-opus": 100_000 }, + }); + expect(container.querySelector('[role="dialog"][aria-label="Context windows"]')).toBeNull(); + + await act(async () => buttonText("Context windows").click()); + const refreshFailureDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + failCatalog = true; + await act(async () => { + [...refreshFailureDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")! + .click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + expect(contextBodies).toHaveLength(2); + expect(container.querySelector('[role="dialog"][aria-label="Context windows"]')).toBeNull(); + expect(container.textContent).toContain("Context windows updated"); + failCatalog = false; + await act(async () => container.querySelector('button.select-trigger[aria-label="Shadow Call Intercept"]')?.click()); // The workspace Select portals its listbox to document.body, so the options are not inside // `container`. Query the document instead of the mount node. diff --git a/gui/tests/models-provider-head.test.ts b/gui/tests/models-provider-head.test.ts index 101906f171..bfa33beb0b 100644 --- a/gui/tests/models-provider-head.test.ts +++ b/gui/tests/models-provider-head.test.ts @@ -30,3 +30,18 @@ test("Models workspace stacks via content-width container query before mobile dr // Mobile media rule retained for drawer layouts. expect(css).toContain("@media (max-width: 768px)"); }); + +test("Models exposes provider and per-model context-window controls (#1073)", async () => { + const page = await Bun.file(new URL("../src/pages/Models.tsx", import.meta.url)).text(); + const groups = await Bun.file(new URL("../src/models-groups.ts", import.meta.url)).text(); + + expect(groups).toContain("contextWindow?: number"); + expect(groups).toContain("modelContextWindows?: Record"); + expect(page).toContain('t("models.contextSettings")'); + expect(page).toContain("modelContextWindows"); + expect(page).toMatch(/\/api\/providers\?name=.*method:\s*"PATCH"/s); + expect(page).toContain('className="models-context-fields"'); + + const css = await Bun.file(new URL("../src/styles-models-workspace.css", import.meta.url)).text(); + expect(css).toMatch(/\.models-context-fields\s*\{[^}]*gap:\s*var\(--space-4\)/s); +}); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 85c5a9400e..5582e447ef 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -165,6 +165,42 @@ function applyProviderPatchFields( next.liveModels = rawBody.liveModels; touched = true; } + // The Models page edits the catalog hints in place; keep them on the existing + // provider mutation path so validation, cache invalidation, and convergence stay unified (#1073). + if (Object.hasOwn(rawBody, "contextWindow")) { + const value = rawBody.contextWindow; + if (value === null) { + delete next.contextWindow; + } else if (typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0) { + next.contextWindow = value; + } else { + return { error: "contextWindow must be a positive finite integer or null" }; + } + touched = true; + } + if (Object.hasOwn(rawBody, "modelContextWindows")) { + const value = rawBody.modelContextWindows; + if (value === null) { + delete next.modelContextWindows; + } else { + if (!isPlainRecord(value)) return { error: "modelContextWindows must be a plain object or null" }; + const windows: Record = { ...(next.modelContextWindows ?? {}) }; + for (const [model, window] of Object.entries(value)) { + if (!model.trim()) return { error: "modelContextWindows keys must be nonblank model ids" }; + if (window === null) { + delete windows[model]; + continue; + } + if (typeof window !== "number" || !Number.isFinite(window) || !Number.isInteger(window) || window <= 0) { + return { error: "modelContextWindows values must be positive finite integers or null" }; + } + windows[model] = window; + } + if (Object.keys(windows).length > 0) next.modelContextWindows = windows; + else delete next.modelContextWindows; + } + touched = true; + } // headers is the one object-valued field in the mask. PATCH semantics merge it // shallowly into the existing block so a single fingerprint header can be added @@ -249,6 +285,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; /** Model-specific Codex catalog input modalities, e.g. ["text"] or ["text", "image"]. */ modelInputModalities?: Record; diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index e850d3f842..4f6396df68 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -1973,6 +1973,92 @@ describe("provider management validation", () => { // Unknown-only bodies are rejected. expect((await patch("extra", { bogus: 1 }))?.status).toBe(400); }); + + test("provider management exposes and persists context-window hints for Models GUI (#1073)", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { ...canonicalDirect }, + relay: { + adapter: "openai-chat", + baseUrl: "https://relay.example.test/v1", + apiKey: "sk-existing", + models: ["wide", "narrow"], + contextWindow: 256_000, + modelContextWindows: { narrow: 64_000 }, + }, + }, + }; + saveConfig(liveConfig); + + const request = async (method: "GET" | "PATCH", body?: unknown) => { + const req = new Request("http://127.0.0.1/api/providers?name=relay", { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return handleManagementAPI(req, new URL(req.url), liveConfig, { + createManagementConvergeCodex: catalogConvergenceFactory(() => {}), + }); + }; + + const listed = await request("GET"); + expect(listed?.status).toBe(200); + const rows = await listed!.json() as Array<{ + name: string; + contextWindow?: number; + modelContextWindows?: Record; + }>; + expect(rows.find(row => row.name === "relay")).toMatchObject({ + contextWindow: 256_000, + modelContextWindows: { narrow: 64_000 }, + }); + + const updated = await request("PATCH", { + contextWindow: 350_000, + modelContextWindows: { wide: 350_000 }, + }); + expect(updated?.status).toBe(200); + expect(liveConfig.providers.relay).toMatchObject({ + contextWindow: 350_000, + modelContextWindows: { wide: 350_000, narrow: 64_000 }, + }); + expect(loadConfig().providers.relay).toMatchObject({ + contextWindow: 350_000, + modelContextWindows: { wide: 350_000, narrow: 64_000 }, + }); + + for (const invalid of [ + { contextWindow: 0 }, + { contextWindow: 1.5 }, + { modelContextWindows: { "": 100_000 } }, + { modelContextWindows: { wide: -1 } }, + ]) { + expect((await request("PATCH", invalid))?.status).toBe(400); + } + expect(liveConfig.providers.relay).toMatchObject({ + contextWindow: 350_000, + modelContextWindows: { wide: 350_000, narrow: 64_000 }, + }); + + expect((await request("PATCH", { modelContextWindows: { wide: null } }))?.status).toBe(200); + expect(liveConfig.providers.relay.modelContextWindows).toEqual({ narrow: 64_000 }); + + const cleared = await request("PATCH", { + contextWindow: null, + modelContextWindows: null, + }); + expect(cleared?.status).toBe(200); + expect(liveConfig.providers.relay.contextWindow).toBeUndefined(); + expect(liveConfig.providers.relay.modelContextWindows).toBeUndefined(); + }); + test("provider PATCH manages custom headers with merge and clear semantics", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true });