fix(cli): always emit limit.output in OpenCode config (#10940)

This commit is contained in:
Xiangzhe
2026-08-21 12:34:07 -03:00
parent c130f2aa1c
commit b6d8e6ee38
4 changed files with 113 additions and 24 deletions

View File

@@ -0,0 +1 @@
- fix(cli): always emit limit.output in generated OpenCode config so schema validation passes for metadata-less models (#10940)

View File

@@ -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;
}

View File

@@ -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"
);
}

View File

@@ -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();
}
});
});