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`, 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 });