diff --git a/changelog.d/fixes/10940-opencode-limit-output.md b/changelog.d/fixes/10940-opencode-limit-output.md new file mode 100644 index 0000000000..9af54a2046 --- /dev/null +++ b/changelog.d/fixes/10940-opencode-limit-output.md @@ -0,0 +1 @@ +- fix(cli): always emit limit.output in generated OpenCode config so schema validation passes for metadata-less models (#10940) diff --git a/src/lib/cli-helper/config-generator/opencode.ts b/src/lib/cli-helper/config-generator/opencode.ts index c2915e5eb7..e3d3f0e950 100644 --- a/src/lib/cli-helper/config-generator/opencode.ts +++ b/src/lib/cli-helper/config-generator/opencode.ts @@ -243,9 +243,11 @@ function resolveContextLength(entry: CatalogModelEntry): number | undefined { * 1. Existing manual override in the user's opencode.json (`limit.context`). * 2. Catalog `context_length` / `max_context_window_tokens`. * - * If neither is available, the entry is returned WITHOUT a `limit` block so - * the caller can decide whether to skip the model entirely or surface a - * warning. We never fabricate a default context window. + * If neither is available, `limit.context` is simply omitted and OpenCode's + * own heuristics apply — we never fabricate a default context window. The + * entry ALWAYS carries a `limit` block, though: `limit.output` is a + * required field in OpenCode's v1 provider schema, so it is always emitted + * (falling back to 8K when nothing else is known) — see #10940. */ function buildModelEntry( id: string, @@ -301,25 +303,23 @@ function buildModelEntry( const output = typeof userOutput === "number" && userOutput > 0 ? userOutput : (catalogOutput ?? 8_192); - // Emit `limit` only if we have at least one of context/output. We never - // emit a half-baked limit block with only an `output` (would be misleading). - if ( - typeof context === "number" || - typeof userOutput === "number" || - typeof catalogOutput === "number" - ) { - const limit: { context?: number; input?: number; output?: number } = {}; - if (typeof context === "number") limit.context = context; - limit.output = output; - const userInput = existing?.limit?.input; - if (typeof userInput === "number" && userInput > 0) { - limit.input = userInput; - } else if (catalog) { - const maxInput = catalog.max_input_tokens; - if (typeof maxInput === "number" && maxInput > 0) limit.input = maxInput; - } - entry.limit = limit; + // `limit.output` is REQUIRED by OpenCode's v1 provider schema regardless of + // whether the catalog (or the user's existing config) knows the model's + // context window — a model with no catalog metadata at all must still get + // a `limit` block, or OpenCode rejects the whole config with "Missing key + // provider.omniroute.models.{model}.limit.output" (#10940). `output` above + // already resolves to a safe fallback (8K) when nothing else is known, so + // we always emit it; `context`/`input` are added only when actually known. + const limit: { context?: number; input?: number; output?: number } = { output }; + if (typeof context === "number") limit.context = context; + const userInput = existing?.limit?.input; + if (typeof userInput === "number" && userInput > 0) { + limit.input = userInput; + } else if (catalog) { + const maxInput = catalog.max_input_tokens; + if (typeof maxInput === "number" && maxInput > 0) limit.input = maxInput; } + entry.limit = limit; return entry; } diff --git a/tests/unit/cli-helper/config-generator.test.ts b/tests/unit/cli-helper/config-generator.test.ts index fde409b940..1b2e85732f 100644 --- a/tests/unit/cli-helper/config-generator.test.ts +++ b/tests/unit/cli-helper/config-generator.test.ts @@ -603,12 +603,15 @@ describe("config-generator", () => { input: 100000, output: 32768, }); - assert.strictEqual(models["no-metadata"].limit, undefined); + // #10940: `limit.output` is REQUIRED by OpenCode's v1 provider schema, + // so even a model with zero catalog metadata still gets a `limit` + // block carrying the fallback output value; `context`/`input` stay + // omitted since neither the catalog nor the user knows them. + assert.deepStrictEqual(models["no-metadata"].limit, { output: 8192 }); for (const model of Object.values(models) as Array<{ limit?: { output?: number } }>) { assert.ok( - model.limit === undefined || - (typeof model.limit.output === "number" && model.limit.output > 0), + typeof model.limit?.output === "number" && model.limit.output > 0, "every emitted limit must contain a positive output" ); } diff --git a/tests/unit/opencode-limit-output-10940.test.ts b/tests/unit/opencode-limit-output-10940.test.ts new file mode 100644 index 0000000000..653002ebe8 --- /dev/null +++ b/tests/unit/opencode-limit-output-10940.test.ts @@ -0,0 +1,85 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; + +/** + * Regression guard for #10940: OpenCode rejects a generated config with + * "Missing key provider.omniroute.models.{model}.limit.output" whenever a + * model has no catalog metadata (no `context_length`, no + * `max_output_tokens`) and no existing user override. `limit.output` is a + * REQUIRED field in OpenCode's v1 provider schema, so it must always be + * emitted — even when nothing is known about the model. + */ +describe("opencode config generator — limit.output always emitted (#10940)", () => { + function makeCatalogResponse(models: unknown[]): unknown { + return { object: "list", data: models }; + } + + function stubFetchOnce(body: unknown, status = 200) { + const original = globalThis.fetch; + // @ts-ignore — globalThis.fetch signature is compatible for our purposes + globalThis.fetch = (async () => { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + return { + restore: () => { + globalThis.fetch = original; + }, + }; + } + + it("RED-proving case: a model with NO context_length and NO max_output_tokens still gets a numeric limit.output", async () => { + // This model has no metadata whatsoever beyond its id — exactly the + // shape that used to leave `entry.limit` undefined entirely (issue #10940). + const stub = stubFetchOnce( + makeCatalogResponse([{ id: "metadataless-model", owned_by: "someProvider" }]) + ); + try { + const { generateOpencodeConfig } = await import( + "../../src/lib/cli-helper/config-generator/opencode.ts" + ); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + }); + const cfg = JSON.parse(out); + const entry = cfg.provider.omniroute.models["metadataless-model"]; + assert.ok(entry, "model entry must exist in the generated config"); + assert.ok(entry.limit, "entry.limit must be present even without catalog metadata"); + assert.strictEqual( + typeof entry.limit.output, + "number", + `entry.limit.output must be a number, got ${JSON.stringify(entry.limit?.output)}` + ); + assert.ok(entry.limit.output > 0, "entry.limit.output must be a positive number"); + // context stays unknown — we must NOT fabricate it. + assert.strictEqual(entry.limit.context, undefined); + } finally { + stub.restore(); + } + }); + + it("honors the catalog's max_output_tokens when present", async () => { + const stub = stubFetchOnce( + makeCatalogResponse([ + { id: "has-output-meta", owned_by: "someProvider", max_output_tokens: 4096 }, + ]) + ); + try { + const { generateOpencodeConfig } = await import( + "../../src/lib/cli-helper/config-generator/opencode.ts" + ); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + }); + const cfg = JSON.parse(out); + const entry = cfg.provider.omniroute.models["has-output-meta"]; + assert.strictEqual(entry.limit.output, 4096); + } finally { + stub.restore(); + } + }); +});