diff --git a/src/providers/stale-model-roster-migration.ts b/src/providers/stale-model-roster-migration.ts index 271db0b27c5..0d94641d29f 100644 --- a/src/providers/stale-model-roster-migration.ts +++ b/src/providers/stale-model-roster-migration.ts @@ -10,9 +10,9 @@ * This rewrites one thing: a saved `models` list that is still byte-for-byte the roster * this file names as `from`, on a provider that still carries the registry adapter, and * only while the registry currently seeds `to`. A list the user changed does not match and - * is left alone. For each added id it also fills a per-model context window and default - * effort, but only inside a container the row already has: enrichment fills those records - * all-or-nothing, so creating one here would hide the rest of the registry seed. + * is left alone. For each added id it also fills a per-model context window, output limit + * and default effort, but only inside a container the row already has: enrichment fills + * those records all-or-nothing, so creating one here would hide the rest of the registry seed. * Same shape and restraint as `stale-context-window-migration`. */ import { ALIBABA_INTL_TOKEN_PLAN_BASE_URL } from "./base-url-choices"; @@ -21,6 +21,7 @@ import { ALIBABA_TOKEN_PLAN_MODELS, ALIBABA_INTL_TOKEN_PLAN_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT_WINDOWS, + ALIBABA_TOKEN_PLAN_MAX_OUTPUT_TOKENS, MINIMAX_M31_DEFAULT_REASONING_EFFORT, MINIMAX_M31_FLASH_PREVIEW, MINIMAX_MODELS, @@ -37,6 +38,11 @@ export interface StaleModelRoster { to: readonly string[]; /** Per-model seed values for the added ids, written only into containers that exist. */ contextWindows?: Readonly>; + /** + * Custom catalog rows read the saved map directly, so an added id missing from a + * partial map would publish without the registry output limit. + */ + maxOutputTokens?: Readonly>; defaultReasoningEfforts?: Readonly>; /** * For a provider whose saved URL may point elsewhere (pay-as-you-go or Custom), the @@ -87,6 +93,7 @@ export const STALE_MODEL_ROSTERS: readonly StaleModelRoster[] = [ ].map(entry => ({ ...entry, contextWindows: ALIBABA_TOKEN_PLAN_CONTEXT_WINDOWS, + maxOutputTokens: ALIBABA_TOKEN_PLAN_MAX_OUTPUT_TOKENS, defaultReasoningEfforts: { "deepseek-v4.1-flash-1017": "high" }, })), ]; @@ -109,12 +116,17 @@ function servesRoster(baseUrl: unknown, destinations: readonly string[] | undefi return destinations.some(url => normalizedProviderEndpoint(url) === saved); } -function fillExisting(container: Record | undefined, values: Readonly> | undefined, ids: readonly string[]): void { - if (!container || !values) return; +function fillExisting(container: Record | undefined, values: Readonly> | undefined, ids: readonly string[]): boolean { + if (!container || !values) return false; + let filled = false; for (const id of ids) { const value = values[id]; - if (value !== undefined && !Object.prototype.hasOwnProperty.call(container, id)) container[id] = value; + if (value !== undefined && !Object.prototype.hasOwnProperty.call(container, id)) { + container[id] = value; + filled = true; + } } + return filled; } /** Pure projection. The caller decides whether to persist. */ @@ -130,10 +142,21 @@ export function projectStaleModelRosters( if (!prov) continue; if (!registrySeeds(entry.provider, prov.adapter, entry.to)) continue; if (!servesRoster(prov.baseUrl, entry.destinationBaseUrls)) continue; - if (!sameList(prov.models, entry.from)) continue; const added = entry.to.filter(id => !entry.from.includes(id)); + if (sameList(prov.models, entry.to)) { + // A row refreshed before output limits joined the refresh still has a partial map. + // Only that record is revisited: the earlier pass already filled windows and efforts, + // so refilling them could restore a key the operator has since removed. + if (fillExisting(prov.modelMaxOutputTokens, entry.maxOutputTokens, added)) { + changed = true; + warnings.push(`filled output limits for ${added.join(", ")} on the saved "${entry.provider}" row.`); + } + continue; + } + if (!sameList(prov.models, entry.from)) continue; prov.models = [...entry.to]; fillExisting(prov.modelContextWindows, entry.contextWindows, added); + fillExisting(prov.modelMaxOutputTokens, entry.maxOutputTokens, added); fillExisting(prov.modelDefaultReasoningEfforts, entry.defaultReasoningEfforts, added); changed = true; warnings.push( diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index cc4772b08fb..9b82b66beaf 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -597,4 +597,4 @@ The registry may seed `noStructuredOutputModels` at route resolution, unioned wi operator opt-outs, just as it seeds `noJsonSchemaModels`. The full opt-out wins on both translated and native Chat paths. For 1017 this deliberately omits JSON requests: strict schemas are rejected and a `json_object` fallback has not been verified. -The bare model's independently verified downgrade remains separate. Token Plan startup refreshes only exact previous registry rosters for both presets, adding dated models and removing retired International IDs. International rows refresh only on the Token Plan host or the registry default; pay-as-you-go and Custom destinations keep their lists. Edited or reordered lists, saved model defaults and existing per-model window and effort overrides remain untouched. Destination-based enrichment of custom-named rows fills per-model effort defaults, effort maps and structured-output restrictions from the matching registry destination or alias, and operator values win. Both Token Plan presets keep the hosts their official quick starts document; International custom-name enrichment remains excluded by its existing override guard. +The bare model's independently verified downgrade remains separate. Token Plan startup refreshes only exact previous registry rosters for both presets, adding dated models and removing retired International IDs. International rows refresh only on the Token Plan host or the registry default; pay-as-you-go and Custom destinations keep their lists. Edited or reordered lists, saved model defaults and existing per-model window, output-limit and effort overrides remain untouched; an added ID is backfilled only into such a map the row already has, and a row already on the current roster gets only its missing output limits, because custom catalog rows read the saved output-limit map directly. Destination-based enrichment of custom-named rows fills per-model effort defaults, effort maps and structured-output restrictions from the matching registry destination or alias, and operator values win. Both Token Plan presets keep the hosts their official quick starts document; International custom-name enrichment remains excluded by its existing override guard. diff --git a/tests/providers/model-roster-seed-repair.test.ts b/tests/providers/model-roster-seed-repair.test.ts index 6af9206d064..4b0530ede10 100644 --- a/tests/providers/model-roster-seed-repair.test.ts +++ b/tests/providers/model-roster-seed-repair.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "bun:test"; import { mergeConfiguredModelsIntoLiveCatalog } from "../../src/codex/catalog/provider-fetch"; +import { routedMaxOutputTokens } from "../../src/codex/catalog/model-hints"; import { projectStartupConfigRepairs } from "../../src/providers/model-rename-startup"; import { projectStaleModelRosters, STALE_MODEL_ROSTERS } from "../../src/providers/stale-model-roster-migration"; -import { MINIMAX_MODELS, MINIMAX_MODELS_BEFORE_M31 } from "../../src/providers/registry/model-seeds"; +import { ALIBABA_TOKEN_PLAN_MAX_OUTPUT_TOKENS, MINIMAX_MODELS, MINIMAX_MODELS_BEFORE_M31 } from "../../src/providers/registry/model-seeds"; import { PROVIDER_REGISTRY } from "../../src/providers/registry"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; @@ -164,6 +165,47 @@ describe.each([ expect(absent.providers![entry.provider]!.modelContextWindows).toBeUndefined(); expect(absent.providers![entry.provider]!.modelDefaultReasoningEfforts).toBeUndefined(); }); + test("backfills the added id's output limit into a partial saved map", () => { + // Custom catalog rows hand the saved provider straight to routedMaxOutputTokens, so a + // partial map without the added id published it with no limit when discovery had none. + const config = makeConfig([...before]); + const row = config.providers![entry.provider]!; + row.modelMaxOutputTokens = { "qwen3.8-max": 4_096 }; + expect(projectStaleModelRosters(config).changed).toBe(true); + const added = entry.to.filter(id => !before.includes(id)); + expect(row.modelMaxOutputTokens).toEqual({ + "qwen3.8-max": 4_096, ...Object.fromEntries(added.map(id => [id, ALIBABA_TOKEN_PLAN_MAX_OUTPUT_TOKENS[id]])), + }); + expect(routedMaxOutputTokens("custom-token-plan", row, { id: "deepseek-v4.1-flash-1017" } as never)).toBe(393_216); + const kept = makeConfig([...before]); + kept.providers![entry.provider]!.modelMaxOutputTokens = { "deepseek-v4.1-flash-1017": 8_192 }; + projectStaleModelRosters(kept); + expect(kept.providers![entry.provider]!.modelMaxOutputTokens!["deepseek-v4.1-flash-1017"]).toBe(8_192); + const absent = makeConfig([...before]); + projectStaleModelRosters(absent); + expect(absent.providers![entry.provider]!.modelMaxOutputTokens).toBeUndefined(); + }); + test("backfills output limits on a row an earlier refresh already moved to the current roster", () => { + const config = makeConfig([...entry.to]); + const row = config.providers![entry.provider]!; + row.modelContextWindows = {}; + row.modelMaxOutputTokens = { "qwen3.8-max": 4_096 }; + const first = projectStartupConfigRepairs(config); + expect(first.changed).toBe(true); + const saved = first.config.providers![entry.provider]!; + expect(saved.modelMaxOutputTokens!["deepseek-v4.1-flash-1017"]).toBe(393_216); + expect(saved.modelMaxOutputTokens!["qwen3.8-max"]).toBe(4_096); + expect(saved.modelContextWindows).toEqual({}); + expect(projectStartupConfigRepairs(structuredClone(first.config)).changed).toBe(false); + const kept = makeConfig([...entry.to]); + kept.providers![entry.provider]!.modelMaxOutputTokens = { "deepseek-v4.1-flash-1017": 8_192 }; + projectStaleModelRosters(kept); + expect(kept.providers![entry.provider]!.modelMaxOutputTokens!["deepseek-v4.1-flash-1017"]).toBe(8_192); + const absent = makeConfig([...entry.to]); + const original = structuredClone(absent); + expect(projectStaleModelRosters(absent).changed).toBe(false); + expect(absent).toEqual(original); + }); test("preserves a custom adapter", () => { const config = makeConfig([...before]); config.providers![entry.provider]!.adapter = "anthropic";