From 9ae7e21b27c3872983f23b03a1593a7b40a93995 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 12 Aug 2026 21:37:29 +0900 Subject: [PATCH 1/2] fix(providers): keep hand-edited context windows through a POST overwrite The dashboard's provider payload type has no member for contextWindow or modelContextWindows, so an overwrite arrives without them. Registry enrichment then fills the absent fields from the seed and the stored row loses the user's values. For opencode-go the seed is exactly {"kimi-k3": 262144}, which is what #1409 reports finding in place of a hand-edited deepseek-v4-flash entry. apiKeyPool and modelCosts are already carried across this path for the same reason: the form does not send them, so absence must not mean deletion. These two fields are the same class of user data and were simply never added. Ownership is sampled before enrichment. After enrichProviderFromCatalog runs, an absent field and a registry-seeded one are indistinguishable, so a guard written as prov.x === undefined afterwards can never fire. When the client omits the map the stored value is the user's map alone. Merging the registry seed in would persist seed keys into user config as a side effect of an unrelated save, and router.ts already fills registry values beneath user entries at resolve time. This does not close #1409. The confirmed reproduction is a duplicate-name POST through Add Provider; the reporter's sequence was an upgrade plus a later full-config write, which points at the stale whole-document writer in #1273 and is not established here. Refs #1409, #1273 --- src/server/management/provider-routes.ts | 23 ++++ tests/management-provider-validation.test.ts | 114 +++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 76682c29b1..12d002ddc8 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -350,6 +350,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { } }); + // #1409: the add/edit form's payload type has no member for contextWindow or + // modelContextWindows, so an overwrite arrives without them. Registry enrichment then fills + // the absent fields from the seed and the stored row loses the user's values — for + // opencode-go the seed is exactly {"kimi-k3": 262144}, which is what the reporter found in + // place of their deepseek-v4-flash override. + describe("provider POST overwrite preserves hand-edited context windows (#1409)", () => { + async function seedProvider(url: URL, extra: Record): Promise { + return fetch(new URL("/api/providers", url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "opencode-go", + provider: { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", apiKey: "k", ...extra }, + }), + }); + } + + function freshHome(): void { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + } + + test("an omitted modelContextWindows keeps the user's map, without registry seed keys", async () => { + freshHome(); + const server = startServer(0); + try { + expect((await seedProvider(server.url, { modelContextWindows: { "deepseek-v4-flash": 900000 } })).status).toBe(200); + expect((await seedProvider(server.url, {})).status).toBe(200); + + // The user's key survives, and the registry seed is NOT persisted into user config: + // router.ts fills registry values beneath user entries at resolve time, so writing + // them here would be a side effect of an unrelated save. + expect(loadConfig().providers["opencode-go"]?.modelContextWindows).toEqual({ "deepseek-v4-flash": 900000 }); + } finally { + await server.stop(true); + } + }); + + test("a submitted modelContextWindows updates that key and keeps the others", async () => { + freshHome(); + const server = startServer(0); + try { + expect((await seedProvider(server.url, { modelContextWindows: { "deepseek-v4-flash": 900000 } })).status).toBe(200); + expect((await seedProvider(server.url, { modelContextWindows: { "kimi-k3": 300000 } })).status).toBe(200); + + expect(loadConfig().providers["opencode-go"]?.modelContextWindows) + .toEqual({ "deepseek-v4-flash": 900000, "kimi-k3": 300000 }); + } finally { + await server.stop(true); + } + }); + + test("an omitted contextWindow keeps the user's scalar", async () => { + freshHome(); + const server = startServer(0); + try { + expect((await seedProvider(server.url, { contextWindow: 777000 })).status).toBe(200); + expect((await seedProvider(server.url, {})).status).toBe(200); + + expect(loadConfig().providers["opencode-go"]?.contextWindow).toBe(777000); + } finally { + await server.stop(true); + } + }); + + test("a submitted contextWindow still wins", async () => { + freshHome(); + const server = startServer(0); + try { + expect((await seedProvider(server.url, { contextWindow: 777000 })).status).toBe(200); + expect((await seedProvider(server.url, { contextWindow: 512000 })).status).toBe(200); + + expect(loadConfig().providers["opencode-go"]?.contextWindow).toBe(512000); + } finally { + await server.stop(true); + } + }); + + test("a brand-new provider still receives the registry seed", async () => { + freshHome(); + const server = startServer(0); + try { + expect((await seedProvider(server.url, {})).status).toBe(200); + + // No prior row exists, so enrichment is authoritative and the seed must land. + expect(loadConfig().providers["opencode-go"]?.modelContextWindows).toBeDefined(); + } finally { + await server.stop(true); + } + }); + + test("PATCH can still delete a key with an explicit null", async () => { + freshHome(); + const server = startServer(0); + try { + expect((await seedProvider(server.url, { modelContextWindows: { "deepseek-v4-flash": 900000 } })).status).toBe(200); + + const patch = await fetch(new URL("/api/providers?name=opencode-go", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ modelContextWindows: { "deepseek-v4-flash": null } }), + }); + expect(patch.status).toBe(200); + + // Deletion is an explicit null through PATCH, which the POST carry-over must not undo. + expect(loadConfig().providers["opencode-go"]?.modelContextWindows?.["deepseek-v4-flash"]).toBeUndefined(); + } finally { + await server.stop(true); + } + }); + }); + test("provider management accepts modelCosts on the canonical openai provider", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); From 4a11d278a93de8a2d3fb77957aa6c48af2e28dfc Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 12 Aug 2026 21:45:10 +0900 Subject: [PATCH 2/2] docs(devlog): fix an unmatched test glob in the 040 phase doc tests/provider-routes*.test.ts does not exist, and an unmatched glob aborts the run under zsh. Point at the suite that actually holds this path's coverage. --- .../040_phase4_issue1409_context_window_overrides.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260812_five_bug_fix_campaign/040_phase4_issue1409_context_window_overrides.md b/devlog/_plan/260812_five_bug_fix_campaign/040_phase4_issue1409_context_window_overrides.md index d5b4d5bf59..a56dde5826 100644 --- a/devlog/_plan/260812_five_bug_fix_campaign/040_phase4_issue1409_context_window_overrides.md +++ b/devlog/_plan/260812_five_bug_fix_campaign/040_phase4_issue1409_context_window_overrides.md @@ -271,9 +271,12 @@ the untouched user key survives. ```bash bun x tsc --noEmit -bun test tests/provider-routes*.test.ts tests/management*.test.ts tests/config*.test.ts +bun test tests/management-provider-validation.test.ts tests/management*.test.ts tests/config*.test.ts ``` +(There is no `tests/provider-routes*.test.ts`; an unmatched glob aborts the run under zsh. +The management-provider validation suite is where this path's coverage lives.) + ## Delivery Branch `codex/1409-preserve-context-window-overrides`, PR against `dev`,