mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
fix(cli): always emit limit.output in OpenCode config (#10940)
This commit is contained in:
1
changelog.d/fixes/10940-opencode-limit-output.md
Normal file
1
changelog.d/fixes/10940-opencode-limit-output.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(cli): always emit limit.output in generated OpenCode config so schema validation passes for metadata-less models (#10940)
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
85
tests/unit/opencode-limit-output-10940.test.ts
Normal file
85
tests/unit/opencode-limit-output-10940.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user