From fc49fbeb6b76ce3497a6aaab734c7c7f53efcca8 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:04:25 -0300 Subject: [PATCH] fix(providers): backfill combo context limit from snapshot after cold start (#13000) Root cause was two-fold in @omniroute/opencode-plugin: (1) the static catalog builder (buildStaticProviderEntry, used by createOmniRouteConfigHook) computed a combo's limit.context purely as Math.min(member context_length) and never consulted the server-computed combo.computed_context_length field at all, unlike the dynamic hook's mapComboToModelV2; (2) even where computed_context_length IS consulted, a background refresh whose live /api/combos response comes back without the field (e.g. right after a restart, before the server finishes recomputing it) had no fallback to the last-known-good disk snapshot, so it silently overwrote a correct 245000 limit with the degraded Math.min(member) value (163840 in the reported case). Fix: buildStaticProviderEntry now prefers computed_context_length the same way mapComboToModelV2 does, and doRefresh() backfills a combo's missing/zero computed_context_length from warmSnapshot.rawCombos by id before caching/publishing/persisting, scoped to that single field only. Regression test: @omniroute/opencode-plugin/tests/issue-13000-cold-start-combo-limit.test.ts --- @omniroute/opencode-plugin/package.json | 2 +- @omniroute/opencode-plugin/src/index.ts | 42 +++- ...issue-13000-cold-start-combo-limit.test.ts | 221 ++++++++++++++++++ .../13000-combo-context-limit-cold-start.md | 1 + 4 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 @omniroute/opencode-plugin/tests/issue-13000-cold-start-combo-limit.test.ts create mode 100644 changelog.d/fixes/13000-combo-context-limit-cold-start.md diff --git a/@omniroute/opencode-plugin/package.json b/@omniroute/opencode-plugin/package.json index 4dc257e274..e36e20b984 100644 --- a/@omniroute/opencode-plugin/package.json +++ b/@omniroute/opencode-plugin/package.json @@ -23,7 +23,7 @@ "scripts": { "build": "tsup", "clean": "rm -rf dist", - "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/telemetry.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts tests/models-fetcher.test.ts", + "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/telemetry.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts tests/models-fetcher.test.ts tests/issue-13000-cold-start-combo-limit.test.ts", "prepublishOnly": "npm run clean && npm run build && npm test" }, "keywords": [ diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 39e3c6f274..f00acf9416 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -4631,9 +4631,21 @@ export function buildStaticProviderEntry( .map((m) => m.max_output_tokens) .filter((v): v is number => typeof v === "number" && v > 0); - if (contextValues.length > 0 && outputValues.length > 0) { + // Prefer the server-computed aggregate (accounts for explicit + // context_length overrides and members outside memberEntries, e.g. + // not yet resolved in /v1/models) over the raw Math.min(member) + // lower bound. Mirrors mapComboToModelV2's limit.context logic + // (#13000) so the static catalog and the dynamic hook agree. + const preferredContext = + typeof combo.computed_context_length === "number" && combo.computed_context_length > 0 + ? combo.computed_context_length + : contextValues.length > 0 + ? Math.min(...contextValues) + : undefined; + + if (preferredContext !== undefined && outputValues.length > 0) { entry.limit = { - context: Math.min(...contextValues), + context: preferredContext, output: Math.min(...outputValues), }; } @@ -5511,6 +5523,32 @@ export function createOmniRouteConfigHook( const modelsFetchOk = !modelsFetchThrew && localRawModels.length > 0; + // Snapshot backfill for computed_context_length: a live /api/combos + // response can come back without this field (server hasn't finished + // recomputing it yet, e.g. just after a restart) even though the + // combo's members and identity are otherwise unchanged. When that + // happens, prefer the last-known-good value from the warm disk + // snapshot over the Math.min(member) fallback in + // mapComboToModelV2() — never overwrite any other combo field + // (models/name/etc.) with stale data, only this one derived number. + if (warmSnapshot) { + const snapshotComboById = new Map(warmSnapshot.rawCombos.map((c) => [c.id, c])); + for (const combo of localRawCombos) { + const hasLive = + typeof combo.computed_context_length === "number" && + combo.computed_context_length > 0; + if (hasLive) continue; + const stale = snapshotComboById.get(combo.id); + if ( + stale && + typeof stale.computed_context_length === "number" && + stale.computed_context_length > 0 + ) { + combo.computed_context_length = stale.computed_context_length; + } + } + } + // Disk-cache fallback (cold first run, no warm snapshot): when the // live fetch returned no models AND features.diskCache !== false, // hydrate from the last-known-good snapshot so OC still surfaces a diff --git a/@omniroute/opencode-plugin/tests/issue-13000-cold-start-combo-limit.test.ts b/@omniroute/opencode-plugin/tests/issue-13000-cold-start-combo-limit.test.ts new file mode 100644 index 0000000000..640c0678ca --- /dev/null +++ b/@omniroute/opencode-plugin/tests/issue-13000-cold-start-combo-limit.test.ts @@ -0,0 +1,221 @@ +/** + * Repro for #13000: combo context limits fall back to Math.min(member) + * instead of using computed_context_length after cold start — no disk + * snapshot fallback. + * + * Scenario (mirrors the report): a warm disk snapshot holds the combo with + * its correct server-computed `computed_context_length` (245000, from all 6 + * members). After a restart, the live refresh's combos fetch returns the + * SAME combo but without `computed_context_length` (e.g. the value hasn't + * propagated yet), and the live models fetch only resolves 2 of the 6 + * members (the rest not yet in /v1/models). The background refresh then + * republishes the provider block built from this degraded live data, + * downgrading a previously-known-good 245000 limit to Math.min(163840, + * 1_000_000) = 163840 — exactly the member-minimum described in the issue. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import type { Config } from "@opencode-ai/plugin"; + +import { + createOmniRouteConfigHook, + _resetInflightRefresh, + type OmniRouteAutoCombosFetcher, + type OmniRouteCombosFetcher, + type OmniRouteCompressionMetaFetcher, + type OmniRouteEnrichmentFetcher, + type OmniRouteFetchCache, + type OmniRouteModelsFetcher, + type OmniRouteProvidersFetcher, + type OmniRouteRawCombo, + type OmniRouteRawModelEntry, + type OmniRouteReadAuthJson, + type OmniRouteStaticProviderEntry, + type OmniRouteDiskSnapshotReader, + type OmniRouteDiskSnapshotWriter, +} from "../src/index.js"; + +test.beforeEach(() => { + _resetInflightRefresh(); +}); + +function stubReadAuthJson(value: Record): OmniRouteReadAuthJson { + return async () => value as never; +} + +function authStub() { + return stubReadAuthJson({ + "opencode-omniroute": { + type: "api", + key: "sk-test", + baseURL: "https://or.example.com/v1", + }, + }); +} + +function makeInput(): Config { + return { provider: {} } as unknown as Config; +} + +// The two members resolvable in the degraded live /v1/models response. +const MEMBER_DEEPSEEK: OmniRouteRawModelEntry = { + id: "deepseek-v4-pro", + capabilities: { tool_calling: true, reasoning: true, vision: false, thinking: false }, + context_length: 163_840, + max_output_tokens: 64_000, + input_modalities: ["text"], + output_modalities: ["text"], +}; + +const MEMBER_GLM: OmniRouteRawModelEntry = { + id: "glm-5.2", + capabilities: { tool_calling: true, reasoning: true, vision: false, thinking: false }, + context_length: 1_000_000, + max_output_tokens: 16_384, + input_modalities: ["text"], + output_modalities: ["text"], +}; + +// The other member that IS present once the server is fully warm. +const MEMBER_GLM_53_HIGH: OmniRouteRawModelEntry = { + id: "GLM-5.3-high", + capabilities: { tool_calling: true, reasoning: true, vision: false, thinking: false }, + context_length: 245_000, + max_output_tokens: 128_000, + input_modalities: ["text"], + output_modalities: ["text"], +}; + +const COMBO_MODELS: OmniRouteRawCombo["models"] = [ + { kind: "model", model: "deepseek-v4-pro", weight: 25 }, + { kind: "model", model: "glm-5.2", weight: 25 }, + { kind: "model", model: "GLM-5.3-high", weight: 50 }, +]; + +test("issue #13000: warm combo limit (245000) survives a degraded post-restart refresh instead of downgrading to Math.min(member)", async () => { + const warmSnapshot: Omit = { + rawModels: [MEMBER_DEEPSEEK, MEMBER_GLM, MEMBER_GLM_53_HIGH], + rawCombos: [ + { + id: "orchestrator", + name: "orchestrator", + models: COMBO_MODELS, + computed_context_length: 245_000, + }, + ], + rawAutoCombos: [], + rawEnrichment: new Map(), + rawCompressionCombos: [], + rawConnections: [], + }; + + const fetcher: OmniRouteModelsFetcher = async () => [MEMBER_DEEPSEEK, MEMBER_GLM]; + const combosFetcher: OmniRouteCombosFetcher = async () => [ + { + id: "orchestrator", + name: "orchestrator", + models: COMBO_MODELS, + // computed_context_length intentionally omitted. + }, + ]; + const autoCombosFetcher: OmniRouteAutoCombosFetcher = async () => []; + const enrichmentFetcher: OmniRouteEnrichmentFetcher = async () => new Map(); + const compressionMetaFetcher: OmniRouteCompressionMetaFetcher = async () => []; + const providersFetcher: OmniRouteProvidersFetcher = async () => []; + + const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => warmSnapshot; + const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {}; + + const sharedCache: OmniRouteFetchCache = new Map(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute", modelCacheTtl: 60_000 }, + { + readAuthJson: authStub(), + fetcher, + combosFetcher, + autoCombosFetcher, + enrichmentFetcher, + compressionMetaFetcher, + providersFetcher, + diskSnapshotReader, + diskSnapshotWriter, + cache: sharedCache, + } + ); + + const input = makeInput(); + await hook(input); + + // Let the detached background refresh (degraded live data) complete and + // republish the block. + await new Promise((r) => setTimeout(r, 100)); + + const entryAfter = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; + const comboModelAfter = entryAfter.models["orchestrator"]; + assert.ok(comboModelAfter, "combo model still published after refresh"); + + assert.equal( + comboModelAfter.limit.context, + 245_000, + `expected the combo limit to stay at the known-good 245000, but got ${comboModelAfter.limit.context} ` + + `(Math.min(member) fallback — the exact bug described in #13000)` + ); +}); + +test("issue #13000 (control): no warm snapshot exists — Math.min(member) fallback is still used (expected, documented behavior)", async () => { + const fetcher: OmniRouteModelsFetcher = async () => [MEMBER_DEEPSEEK, MEMBER_GLM]; + const combosFetcher: OmniRouteCombosFetcher = async () => [ + { + id: "orchestrator", + name: "orchestrator", + models: COMBO_MODELS, + // computed_context_length intentionally omitted. + }, + ]; + const autoCombosFetcher: OmniRouteAutoCombosFetcher = async () => []; + const enrichmentFetcher: OmniRouteEnrichmentFetcher = async () => new Map(); + const compressionMetaFetcher: OmniRouteCompressionMetaFetcher = async () => []; + const providersFetcher: OmniRouteProvidersFetcher = async () => []; + + // No prior snapshot on disk. + const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined; + const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {}; + + const sharedCache: OmniRouteFetchCache = new Map(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute", modelCacheTtl: 60_000 }, + { + readAuthJson: authStub(), + fetcher, + combosFetcher, + autoCombosFetcher, + enrichmentFetcher, + compressionMetaFetcher, + providersFetcher, + diskSnapshotReader, + diskSnapshotWriter, + cache: sharedCache, + } + ); + + const input = makeInput(); + await hook(input); + + const entryAfter = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; + const comboModelAfter = entryAfter.models["orchestrator"]; + assert.ok(comboModelAfter, "combo model published on cold first run"); + + // No snapshot to backfill from — Math.min(163840, 1_000_000) = 163840. + assert.equal( + comboModelAfter.limit.context, + 163_840, + "pure cold start with no snapshot must keep using the Math.min(member) fallback" + ); +}); diff --git a/changelog.d/fixes/13000-combo-context-limit-cold-start.md b/changelog.d/fixes/13000-combo-context-limit-cold-start.md new file mode 100644 index 0000000000..c475d32218 --- /dev/null +++ b/changelog.d/fixes/13000-combo-context-limit-cold-start.md @@ -0,0 +1 @@ +- fix(providers): stop `@omniroute/opencode-plugin` combo context limits from downgrading to the raw `Math.min(member)` lower bound after a restart — the static catalog now honors the server-computed `computed_context_length` (mirroring the dynamic hook), and a background refresh with a degraded `/api/combos` response backfills the field from the last-known-good disk snapshot instead of overwriting it (#13000) — thanks @morpheus9393