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", () => {