From 601dc425647ecd182c98e9f7eeb0adeed8b1e340 Mon Sep 17 00:00:00 2001 From: daisylovekris Date: Mon, 26 Oct 2026 21:59:01 +0800 Subject: [PATCH] fix(dsh): export a known output limit as `maxTokens` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DSH treats an exported `maxTokens` as the model's real output ceiling and sends it as `max_tokens`; when the field is absent it falls back to its own `DEFAULT_MAX_TOKENS = 32768`. The DSH exporter emitted only `contextWindow`, so every model outside DSH's bundled catalog silently ran on 32768 even when the real limit was far larger — long answers were cut off mid-sentence. The catalog row already carries the authoritative limit (`toExportModel` maps `maxOutputTokens` to `maxTokens`, and #5828 established that this value must win over the 32000 schema stand-in). The OMP exporter has consumed it since that fix; DSH simply never did. Add `knownOutputBudget()` next to `outputBudgetFor()`. The two differ only in the unknown case: `outputBudgetFor` must return a number because its callers cannot express "unknown", while `knownOutputBudget` returns undefined so a client that has a meaningful default is not handed the 32000 stand-in and does not claim a limit no model declared. Emitting `outputBudgetFor` here would have reintroduced exactly the over-reporting #5828 fixed. DSH now declares `maxTokens` only when the row (or the bundled per-model metadata) knows one, clamped to the exported context window, and stays silent otherwise — including when no context window is exported at all. Verified on a real install (opencodex 2.92.0): - exported entries carrying `maxTokens`: 0 -> 30 of 40 - entries that lost a `maxTokens` they previously declared: 0 - `contextWindow` values changed: 0 - `bun test tests/config/`: 733 pass, 0 fail - `tsc --noEmit`: clean The existing DSH test asserted `not.toHaveProperty("maxTokens")` on every row. That assertion predates #5828 and was never revisited when the other exporters started carrying the field, so it pinned the omission rather than a decision. It stays for rows with no known limit and gains a positive case. --- src/clients/config-export/dsh.ts | 9 ++++++++- src/clients/config-export/model-metadata.ts | 19 +++++++++++++++++++ tests/config/client-config-export.test.ts | 7 +++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/clients/config-export/dsh.ts b/src/clients/config-export/dsh.ts index 6c8ab2909f7..a29de31ee92 100644 --- a/src/clients/config-export/dsh.ts +++ b/src/clients/config-export/dsh.ts @@ -2,7 +2,7 @@ import type { ExportModel, ExportContext, ManagedContribution } from "./contracts"; import type { OcxConfig } from "../../types"; import { providerCodexAccountMode } from "../../providers/registry"; -import { normalizeExportModels, authoritativeContextWindow, exportModelLabel, singleFragment } from "./model-metadata"; +import { normalizeExportModels, authoritativeContextWindow, exportModelLabel, knownOutputBudget, singleFragment } from "./model-metadata"; import { OPENCODE_PROVIDER_ID } from "./constants"; @@ -26,6 +26,11 @@ export interface DshModelEntry { name: string; input: string[]; contextWindow?: number; + /** + * Output ceiling DSH should treat as the model's real `max_tokens`. DSH falls back to its + * own 32768 default when this is absent, so a limit it does not know stays undeclared. + */ + maxTokens?: number; reasoningEfforts?: Partial>; } @@ -97,12 +102,14 @@ export function buildDshClientConfig(ctx: ExportContext): DshGeneratedConfig { const input = dshInputModalities(model.inputModalities); if (input === null) continue; const contextWindow = authoritativeContextWindow(model.contextWindow); + const maxTokens = contextWindow === undefined ? undefined : knownOutputBudget(contextWindow, model); const reasoningEfforts = dshReasoningEfforts(model); models.push({ id: model.namespaced, name: exportModelLabel(model), input, ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(maxTokens !== undefined ? { maxTokens } : {}), ...(reasoningEfforts ? { reasoningEfforts } : {}), }); } diff --git a/src/clients/config-export/model-metadata.ts b/src/clients/config-export/model-metadata.ts index 47329432544..86017d4e698 100644 --- a/src/clients/config-export/model-metadata.ts +++ b/src/clients/config-export/model-metadata.ts @@ -31,6 +31,25 @@ export function outputBudgetFor(context: number, model: OpencodeCatalogModel): n return Math.min(limit, context); } +/** +* Known model output limit without the schema stand-in, clamped to context. +* +* `outputBudgetFor` always returns a number because the clients that require the field +* cannot express "unknown". A client that instead treats an absent `maxTokens` as a real +* default must not be handed the 32000 stand-in: that would claim a limit no model declared +* (#5828). Use this variant where "no claim" is representable. +* @param context - the context window this model already exports. +* @param model - the catalog row, which may carry an authoritative output limit. +* @returns the known limit clamped to `context`, or undefined when nothing is known. +*/ +export function knownOutputBudget(context: number, model: OpencodeCatalogModel): number | undefined { + const provider = model.provider ?? ""; + const metadata = getModelMetadata(resolveMetadataProvider(provider) ?? provider, model.id ?? model.namespaced); + const limit = authoritativeContextWindow(model.maxTokens) + ?? authoritativeContextWindow(metadata?.maxTokens); + return limit === undefined ? undefined : Math.min(limit, context); +} + /** Input limits are optional: unlike output, a missing limit needs no schema fallback. */ export function inputBudgetFor(context: number, model: OpencodeCatalogModel): number | undefined { const limit = authoritativeContextWindow(model.maxInputTokens); diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index c5fd8b90653..81bef88f1e9 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -544,7 +544,14 @@ describe("DSH rc.6 serializer", () => { expect(models.find(model => model.id === "a/context")?.contextWindow).toBe(128_000); expect(models.find(model => model.id === "a/fraction")).not.toHaveProperty("contextWindow"); expect(models.find(model => model.id === "a/zero")).not.toHaveProperty("contextWindow"); + // DSH reads maxTokens as the real output ceiling and falls back to 32768 without it, + // so a known limit must ride along with the context window (see #5828 for the same + // failure in the other exporters). No context window means no limit claim either. for (const model of models) expect(model).not.toHaveProperty("maxTokens"); + const withLimit = dshConfig(ctx({ + models: [{ namespaced: "a/limit", provider: "a", id: "limit", contextWindow: 200_000, maxTokens: 128_000 }], + }))["llm-pi-ai"].providers.opencodex!.models; + expect(withLimit[0]!.maxTokens).toBe(128_000); }); test("maps only authoritative effort levels to DSH level-to-wire entries", () => {