From 5633309d81b2c64833cf5b1bc8a6f27a8970d986 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 23 Oct 2026 10:09:44 +0900 Subject: [PATCH 1/2] fix(providers): backfill Token Plan output limits on roster refresh The stale roster migration filled context windows and default efforts for the dated IDs it adds, but not output limits. A custom catalog row passes the saved provider straight to routedMaxOutputTokens, so a row that already had a partial modelMaxOutputTokens map published deepseek-v4.1-flash-1017 without the registry limit whenever discovery and generated metadata had none. The refresh now fills the added IDs into an existing map from the Token Plan seed and keeps any value the operator already saved. Refs #7555 --- src/providers/stale-model-roster-migration.ts | 14 ++++++++--- structure/providers-and-adapters.md | 2 +- .../model-roster-seed-repair.test.ts | 23 ++++++++++++++++++- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/providers/stale-model-roster-migration.ts b/src/providers/stale-model-roster-migration.ts index 271db0b27c5..2174c6cd099 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" }, })), ]; @@ -134,6 +141,7 @@ export function projectStaleModelRosters( const added = entry.to.filter(id => !entry.from.includes(id)); 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..ff7cfaec5aa 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, 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..f1d8795ef9e 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,26 @@ 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("preserves a custom adapter", () => { const config = makeConfig([...before]); config.providers![entry.provider]!.adapter = "anthropic"; From bd8067d1269f03cfce0ddb7a35c0e59d37a13dbc Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 23 Oct 2026 10:29:48 +0900 Subject: [PATCH 2/2] fix(providers): backfill output limits on already-refreshed Token Plan rows A row the earlier refresh already moved to the current roster no longer matches the previous seed, so it never reached the new output-limit fill and its partial modelMaxOutputTokens map stayed incomplete. A row whose list is exactly the current roster now gets only its missing output limits; operator values and absent maps stay untouched, and windows and efforts are not revisited because the earlier pass already filled them. Refs #7555 --- src/providers/stale-model-roster-migration.ts | 23 +++++++++++++++---- structure/providers-and-adapters.md | 2 +- .../model-roster-seed-repair.test.ts | 21 +++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/providers/stale-model-roster-migration.ts b/src/providers/stale-model-roster-migration.ts index 2174c6cd099..0d94641d29f 100644 --- a/src/providers/stale-model-roster-migration.ts +++ b/src/providers/stale-model-roster-migration.ts @@ -116,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. */ @@ -137,8 +142,18 @@ 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); diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index ff7cfaec5aa..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, output-limit and effort overrides remain untouched; an added ID is backfilled only into such a map the row already has, 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. +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 f1d8795ef9e..4b0530ede10 100644 --- a/tests/providers/model-roster-seed-repair.test.ts +++ b/tests/providers/model-roster-seed-repair.test.ts @@ -185,6 +185,27 @@ describe.each([ 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";