From e8a21dc2e422af69316df3a4d85a0304185891b2 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:13:33 +0200 Subject: [PATCH 1/4] fix(catalog): retain configured combo targets omitted by live discovery Authoritative /models responses no longer drop model ids that a combo still targets, so failover combos stay in the Codex catalog (OCX-111 / #1308). --- src/codex/catalog.ts | 2 +- src/codex/catalog/provider-fetch.ts | 43 +++++++++++++- tests/codex-catalog.test.ts | 87 +++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index b7abbf581..a76610b93 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -5,7 +5,7 @@ export type { CatalogModel, MultiAgentMode } from "./catalog/parsing"; export { CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata"; export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled"; export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort"; -export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember } from "./catalog/provider-fetch"; +export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch"; export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation"; export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation"; export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync"; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 04553cf92..0f5ce9266 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -151,6 +151,11 @@ interface CapturedProviderGather { readonly policy: CatalogProviderDiscoveryPolicySnapshot; readonly request: CapturedModelsRequest; readonly observedAuth?: ModelsAuthResolution; + /** + * Configured model ids this provider must keep even when live discovery omits + * them — currently every combo target on this provider (OCX-111 / #1308). + */ + readonly retainConfiguredModelIds?: ReadonlySet; } interface GatherFlightCapture { @@ -381,6 +386,7 @@ function captureProviderGather( name: string, configured: OcxProviderConfig, authResolver: ModelsAuthResolver, + retainConfiguredModelIds?: ReadonlySet, ): CapturedProviderGather { const enriched = detachedClone(configured); enrichProviderFromRegistry(name, enriched); @@ -422,18 +428,47 @@ function captureProviderGather( policy, request, ...(observedAuth ? { observedAuth: Object.freeze({ ...observedAuth }) } : {}), + ...(retainConfiguredModelIds && retainConfiguredModelIds.size > 0 + ? { retainConfiguredModelIds } + : {}), }); } +/** Model ids each provider must retain for combo catalog derivation (OCX-111). */ +export function configuredComboTargetModelsByProvider( + config: Pick, +): Map> { + const byProvider = new Map>(); + for (const id of listComboIds(config)) { + const combo = getCombo(config, id); + if (!combo) continue; + for (const target of combo.targets) { + let models = byProvider.get(target.provider); + if (!models) { + models = new Set(); + byProvider.set(target.provider, models); + } + models.add(target.model); + } + } + return byProvider; +} + function captureGatherFlight( config: OcxConfig, createAuthResolver: ModelsAuthResolverFactory, ): GatherFlightCapture { const providerAuthOutcomes: CatalogGatherProviderAuthOutcome[] = []; const authResolver = createAuthResolver(providerAuthOutcomes); + const comboTargetsByProvider = configuredComboTargetModelsByProvider(config); const providers = Object.entries(config.providers) .filter(([, provider]) => provider.disabled !== true) - .map(([name, provider]) => captureProviderGather(name, provider, authResolver)); + .map(([name, provider]) => captureProviderGather( + name, + provider, + authResolver, + comboTargetsByProvider.get(name), + )); const discoveryPolicySnapshots = Object.freeze(providers.map(provider => provider.policy)); return Object.freeze({ discoveryPolicyIdentity: keyedGatherIdentity("catalog-discovery-policy-v1", discoveryPolicySnapshots), @@ -1249,7 +1284,11 @@ async function fetchProviderModelsWithAuth( if (dated) { // Reapply config hints so alias-keyed overrides (modelContextWindows etc.) win. live.push(applyProviderConfigHints(name, prov, { ...dated, id: m.id }, contextCap)); - } else if (seedVertexDefault || shouldRetainConfiguredProviderModel(name, m.id)) { + } else if ( + seedVertexDefault + || shouldRetainConfiguredProviderModel(name, m.id) + || captured.retainConfiguredModelIds?.has(m.id) === true + ) { live.push(m); } else { droppedConfiguredIds.push(m.id); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index b1ce13257..87789bf11 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1517,6 +1517,93 @@ describe("combo catalog capability intersection", () => { warn.mockRestore(); } }, 15_000); + + test("retains configured combo targets when authoritative live discovery omits them (OCX-111)", async () => { + // Repro from #1308 / OCX-111: live /models returns a different roster than the + // configured combo targets. Before the retain path, those ids were dropped from + // the authoritative catalog and the combo was omitted as incomplete. + clearModelCache("openrouter"); + clearModelCache("opencode-go"); + clearModelCache("command-code"); + const warning = spyOn(console, "warn").mockImplementation(() => {}); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + const id = url.includes("openrouter") + ? "openrouter/other-model" + : url.includes("opencode") + ? "other-flash" + : "other-pro"; + return new Response(JSON.stringify({ data: [{ id, owned_by: "provider" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + try { + resetCatalogRuntimeStateForTests(); + const rows = await gatherRoutedModels({ + port: 10100, + defaultProvider: "openrouter", + providers: { + openrouter: { + adapter: "openai-chat", + baseUrl: "https://openrouter.example.test/v1", + authMode: "key", + apiKey: "sk-test", + liveModels: true, + models: ["openai/gpt-5.6-luna"], + modelContextWindows: { "openai/gpt-5.6-luna": 200_000 }, + }, + "opencode-go": { + adapter: "openai-chat", + baseUrl: "https://opencode.example.test/go/v1", + authMode: "key", + apiKey: "sk-test", + liveModels: true, + models: ["deepseek-v4-flash"], + modelContextWindows: { "deepseek-v4-flash": 128_000 }, + }, + "command-code": { + adapter: "openai-chat", + baseUrl: "https://command-code.example.test/v1", + authMode: "key", + apiKey: "sk-test", + liveModels: true, + models: ["xiaomi/mimo-v2.5-pro"], + modelContextWindows: { "xiaomi/mimo-v2.5-pro": 160_000 }, + }, + }, + combos: { + failover: { + strategy: "failover", + targets: [ + { provider: "openrouter", model: "openai/gpt-5.6-luna", weight: 1 }, + { provider: "opencode-go", model: "deepseek-v4-flash", weight: 1 }, + { provider: "command-code", model: "xiaomi/mimo-v2.5-pro", weight: 1 }, + ], + }, + }, + }); + + const combo = rows.find(r => r.provider === "combo" && r.id === "failover"); + expect(combo).toBeDefined(); + expect(combo!.contextWindow).toBe(128_000); + expect(rows.some(r => r.provider === "openrouter" && r.id === "openai/gpt-5.6-luna")).toBe(true); + expect(rows.some(r => r.provider === "opencode-go" && r.id === "deepseek-v4-flash")).toBe(true); + expect(rows.some(r => r.provider === "command-code" && r.id === "xiaomi/mimo-v2.5-pro")).toBe(true); + const warningText = warning.mock.calls.flat().join(" "); + expect(warningText).not.toContain("member capabilities are incomplete"); + expect(warningText).not.toContain("omitted configured model ids"); + const { getLastComboCatalogOmissions } = await import("../src/codex/catalog"); + expect(getLastComboCatalogOmissions().some(item => item.id === "failover")).toBe(false); + } finally { + warning.mockRestore(); + globalThis.fetch = originalFetch; + clearModelCache("openrouter"); + clearModelCache("opencode-go"); + clearModelCache("command-code"); + } + }, 15_000); }); describe("Google Gemini catalog metadata", () => { From dbdae1eeb4b113d42ff41ea0156fc29df56d163e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:17:22 +0200 Subject: [PATCH 2/4] fix(catalog): retain combo-only targets missing from provider models Seed live-discovery candidates from combo target ids as well as providers.*.models so failover members defined only under combos stay catalogued. --- src/codex/catalog/provider-fetch.ts | 12 +++++++++++- tests/codex-catalog.test.ts | 7 ++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 0f5ce9266..5f74d3f30 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1033,7 +1033,17 @@ async function fetchProviderModelsWithAuth( && prov.googleMode === "vertex" && (prov.models?.length ?? 0) === 0 && Boolean(prov.defaultModel); - const configuredIds = seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : (prov.models ?? []); + const listedConfiguredIds = seedVertexDefault && prov.defaultModel + ? [prov.defaultModel] + : [...(prov.models ?? [])]; + // Combo targets may exist only under `combos.*.targets` (not in providers.*.models). + // Seed those ids here so the live-discovery retain loop can keep them (OCX-111). + const configuredIdSet = new Set(listedConfiguredIds); + for (const id of captured.retainConfiguredModelIds ?? []) configuredIdSet.add(id); + const configuredIds = [ + ...listedConfiguredIds, + ...[...configuredIdSet].filter(id => !listedConfiguredIds.includes(id)), + ]; const configured: CatalogModel[] = configuredIds.map(id => ({ id, provider: name, diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 87789bf11..ba82c0f72 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1520,8 +1520,8 @@ describe("combo catalog capability intersection", () => { test("retains configured combo targets when authoritative live discovery omits them (OCX-111)", async () => { // Repro from #1308 / OCX-111: live /models returns a different roster than the - // configured combo targets. Before the retain path, those ids were dropped from - // the authoritative catalog and the combo was omitted as incomplete. + // configured combo targets. Combo-only targets (not listed in providers.*.models) + // must still be retained via provider hints so the failover combo catalogs. clearModelCache("openrouter"); clearModelCache("opencode-go"); clearModelCache("command-code"); @@ -1560,7 +1560,8 @@ describe("combo catalog capability intersection", () => { authMode: "key", apiKey: "sk-test", liveModels: true, - models: ["deepseek-v4-flash"], + // Combo-only target: listed in combos but not providers.*.models. + models: [], modelContextWindows: { "deepseek-v4-flash": 128_000 }, }, "command-code": { From 28bbad12bc8126d6f51dc1618ddf7ae1453e267b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:24:02 +0200 Subject: [PATCH 3/4] fix(catalog): avoid leaking combo-only targets into the public catalog Keep combo-only ids on the #1305 synthesis path instead of seeding them into providers.*.models retention, and pin the OCX-111 regression to non-registry provider names. --- src/codex/catalog/provider-fetch.ts | 16 +++------ tests/codex-catalog.test.ts | 51 +++++++++++++++-------------- 2 files changed, 31 insertions(+), 36 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 5f74d3f30..2a5ceca10 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -153,7 +153,9 @@ interface CapturedProviderGather { readonly observedAuth?: ModelsAuthResolution; /** * Configured model ids this provider must keep even when live discovery omits - * them — currently every combo target on this provider (OCX-111 / #1308). + * them — combo targets that are also listed in providers.*.models (OCX-111). + * Combo-only ids (not in models[]) stay out of the public catalog and are + * synthesized for combo derivation instead (#1305). */ readonly retainConfiguredModelIds?: ReadonlySet; } @@ -1033,17 +1035,7 @@ async function fetchProviderModelsWithAuth( && prov.googleMode === "vertex" && (prov.models?.length ?? 0) === 0 && Boolean(prov.defaultModel); - const listedConfiguredIds = seedVertexDefault && prov.defaultModel - ? [prov.defaultModel] - : [...(prov.models ?? [])]; - // Combo targets may exist only under `combos.*.targets` (not in providers.*.models). - // Seed those ids here so the live-discovery retain loop can keep them (OCX-111). - const configuredIdSet = new Set(listedConfiguredIds); - for (const id of captured.retainConfiguredModelIds ?? []) configuredIdSet.add(id); - const configuredIds = [ - ...listedConfiguredIds, - ...[...configuredIdSet].filter(id => !listedConfiguredIds.includes(id)), - ]; + const configuredIds = seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : (prov.models ?? []); const configured: CatalogModel[] = configuredIds.map(id => ({ id, provider: name, diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index ba82c0f72..75260090a 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1520,18 +1520,20 @@ describe("combo catalog capability intersection", () => { test("retains configured combo targets when authoritative live discovery omits them (OCX-111)", async () => { // Repro from #1308 / OCX-111: live /models returns a different roster than the - // configured combo targets. Combo-only targets (not listed in providers.*.models) - // must still be retained via provider hints so the failover combo catalogs. - clearModelCache("openrouter"); - clearModelCache("opencode-go"); - clearModelCache("command-code"); + // configured combo targets. Ids listed in providers.*.models are retained when + // they are combo targets. Combo-only ids (not in models[]) still catalog the + // combo via synthesis without leaking a standalone provider row (#1305). + // Use non-registry provider names so enrichProviderFromRegistry cannot seed models[]. + clearModelCache("or-test"); + clearModelCache("go-test"); + clearModelCache("cc-test"); const warning = spyOn(console, "warn").mockImplementation(() => {}); const originalFetch = globalThis.fetch; globalThis.fetch = (async (input: RequestInfo | URL) => { const url = String(input); - const id = url.includes("openrouter") + const id = url.includes("or-test") ? "openrouter/other-model" - : url.includes("opencode") + : url.includes("go-test") ? "other-flash" : "other-pro"; return new Response(JSON.stringify({ data: [{ id, owned_by: "provider" }] }), { @@ -1543,30 +1545,30 @@ describe("combo catalog capability intersection", () => { resetCatalogRuntimeStateForTests(); const rows = await gatherRoutedModels({ port: 10100, - defaultProvider: "openrouter", + defaultProvider: "or-test", providers: { - openrouter: { + "or-test": { adapter: "openai-chat", - baseUrl: "https://openrouter.example.test/v1", + baseUrl: "https://or-test.example.test/v1", authMode: "key", apiKey: "sk-test", liveModels: true, models: ["openai/gpt-5.6-luna"], modelContextWindows: { "openai/gpt-5.6-luna": 200_000 }, }, - "opencode-go": { + "go-test": { adapter: "openai-chat", - baseUrl: "https://opencode.example.test/go/v1", + baseUrl: "https://go-test.example.test/v1", authMode: "key", apiKey: "sk-test", liveModels: true, - // Combo-only target: listed in combos but not providers.*.models. + // Combo-only target: not listed in providers.*.models — synthesis only. models: [], modelContextWindows: { "deepseek-v4-flash": 128_000 }, }, - "command-code": { + "cc-test": { adapter: "openai-chat", - baseUrl: "https://command-code.example.test/v1", + baseUrl: "https://cc-test.example.test/v1", authMode: "key", apiKey: "sk-test", liveModels: true, @@ -1578,9 +1580,9 @@ describe("combo catalog capability intersection", () => { failover: { strategy: "failover", targets: [ - { provider: "openrouter", model: "openai/gpt-5.6-luna", weight: 1 }, - { provider: "opencode-go", model: "deepseek-v4-flash", weight: 1 }, - { provider: "command-code", model: "xiaomi/mimo-v2.5-pro", weight: 1 }, + { provider: "or-test", model: "openai/gpt-5.6-luna", weight: 1 }, + { provider: "go-test", model: "deepseek-v4-flash", weight: 1 }, + { provider: "cc-test", model: "xiaomi/mimo-v2.5-pro", weight: 1 }, ], }, }, @@ -1589,9 +1591,10 @@ describe("combo catalog capability intersection", () => { const combo = rows.find(r => r.provider === "combo" && r.id === "failover"); expect(combo).toBeDefined(); expect(combo!.contextWindow).toBe(128_000); - expect(rows.some(r => r.provider === "openrouter" && r.id === "openai/gpt-5.6-luna")).toBe(true); - expect(rows.some(r => r.provider === "opencode-go" && r.id === "deepseek-v4-flash")).toBe(true); - expect(rows.some(r => r.provider === "command-code" && r.id === "xiaomi/mimo-v2.5-pro")).toBe(true); + expect(rows.some(r => r.provider === "or-test" && r.id === "openai/gpt-5.6-luna")).toBe(true); + expect(rows.some(r => r.provider === "cc-test" && r.id === "xiaomi/mimo-v2.5-pro")).toBe(true); + // Combo-only member must not leak as a standalone routed row. + expect(rows.some(r => r.provider === "go-test" && r.id === "deepseek-v4-flash")).toBe(false); const warningText = warning.mock.calls.flat().join(" "); expect(warningText).not.toContain("member capabilities are incomplete"); expect(warningText).not.toContain("omitted configured model ids"); @@ -1600,9 +1603,9 @@ describe("combo catalog capability intersection", () => { } finally { warning.mockRestore(); globalThis.fetch = originalFetch; - clearModelCache("openrouter"); - clearModelCache("opencode-go"); - clearModelCache("command-code"); + clearModelCache("or-test"); + clearModelCache("go-test"); + clearModelCache("cc-test"); } }, 15_000); }); From 516850518acc6d9867fc02331021458ac3610a74 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:56:49 +0200 Subject: [PATCH 4/4] fix(catalog): apply combo retention on cache and flight boundaries Include retainConfiguredModelIds in providerGraphIdentity and merge configured combo targets on every cached/stale/fallback return so a warm cache captured before a combo still surfaces OCX-111 members. --- src/codex/catalog/provider-fetch.ts | 180 ++++++++++++++++++++------- tests/codex-catalog.test.ts | 77 ++++++++++++ tests/codex-gather-authority.test.ts | 69 ++++++++++ 3 files changed, 280 insertions(+), 46 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 2a5ceca10..d28dd0bb7 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -497,6 +497,9 @@ function captureGatherFlight( // It is the one member of a provider row that is legitimately a function, // so it is dropped here rather than allowed to break every encode. provider: omitProviderTransportExecutor(provider.provider), + // Combo retention is capture-time state, not a provider-row field. Two + // gathers that share providers but differ in combo targets must not join. + retainConfiguredModelIds: [...(provider.retainConfiguredModelIds ?? [])].sort(), }))), discoveryPolicySnapshots, providers: Object.freeze(providers), @@ -1041,6 +1044,30 @@ async function fetchProviderModelsWithAuth( provider: name, ...catalogHintsFromProviderConfig(name, prov, id, contextCap), })); + const withConfiguredRetention = ( + models: CatalogModel[], + options?: { retainComboTargets?: boolean; warnDrops?: boolean }, + ): CatalogModel[] => { + const { models: merged, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ + name, + provider: prov, + models, + configured, + retainConfiguredModelIds: captured.retainConfiguredModelIds, + contextCap, + seedVertexDefault, + retainComboTargets: options?.retainComboTargets, + }); + if ( + options?.warnDrops === true + && droppedConfiguredIds.length > 0 + && name !== OPENAI_API_PROVIDER_ID + && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name) + ) { + warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds); + } + return merged; + }; // Static catalogs never need an OAuth refresh or an upstream model request. Clear any // discovery failure left by an older live configuration even when the account is logged out. if (prov.liveModels === false) { @@ -1084,12 +1111,17 @@ async function fetchProviderModelsWithAuth( // plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed. const cachedCursor = getFreshCached(name, ttlMs); if (cachedCursor) { - return observed(applyConfigHintsToCachedModels(name, prov, cachedCursor), "authoritative"); + return observed( + withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedCursor)), + "authoritative", + ); } if (isModelsFetchCoolingDown(name)) { const cooling = getStaleCached(name); return observed( - cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured, + withConfiguredRetention( + cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured, + ), "degraded", ); } @@ -1097,10 +1129,14 @@ async function fetchProviderModelsWithAuth( if (liveResult.ok) { const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models); const result = available.length > 0 ? available : configured; - // Count what discovery actually returned, not the configured rows we fall back to. - if (!setCached(name, result, Date.now(), cacheGeneration)) return observed(configured, "degraded"); + // Cache the discovery-filtered roster without combo retention so a later + // gather can re-apply the current capture's retain set on read. + const forCache = withConfiguredRetention(result, { retainComboTargets: false }); + if (!setCached(name, forCache, Date.now(), cacheGeneration)) { + return observed(withConfiguredRetention(configured), "degraded"); + } markProviderDiscoveryOk(name, liveResult.models.length); - return observed(result, "authoritative"); + return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); } if (isCurrentCacheGeneration()) { markModelsFetchFailure(name); @@ -1111,7 +1147,9 @@ async function fetchProviderModelsWithAuth( } const staleCursor = getStaleCached(name); return observed( - staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured, + withConfiguredRetention( + staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured, + ), "degraded", ); } @@ -1127,7 +1165,9 @@ async function fetchProviderModelsWithAuth( const fresh = getFreshCached(name, ttlMs); if (fresh) { return observed( - withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)), + withConfiguredRetention( + withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)), + ), "authoritative", ); // dedups Codex's frequent /v1/models polling within the TTL } @@ -1136,9 +1176,11 @@ async function fetchProviderModelsWithAuth( // fetch timeout on every catalog poll — the dashboard polls this path per page load. const stale = getStaleCached(name); return observed( - stale - ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) - : failedDiscoveryConfigured, + withConfiguredRetention( + stale + ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) + : failedDiscoveryConfigured, + ), "degraded", ); } @@ -1151,7 +1193,11 @@ async function fetchProviderModelsWithAuth( failure: ProviderModelDiscoveryFailure, ): { models: CatalogModel[]; fallback: "stale" | "configured"; shouldLog: boolean } => { if (!isCurrentCacheGeneration()) { - return { models: failedDiscoveryConfigured, fallback: "configured", shouldLog: false }; + return { + models: withConfiguredRetention(failedDiscoveryConfigured), + fallback: "configured", + shouldLog: false, + }; } // Decide logging BEFORE recording the new status, so we can compare against the prior one and // suppress an identical repeated failure (#395 log flood). The failure stays observable via the @@ -1161,9 +1207,11 @@ async function fetchProviderModelsWithAuth( markProviderDiscoveryFailed(name, failure); const stale = getStaleCached(name); return { - models: stale - ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) - : failedDiscoveryConfigured, + models: withConfiguredRetention( + stale + ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) + : failedDiscoveryConfigured, + ), fallback: stale ? "stale" : "configured", shouldLog, }; @@ -1239,9 +1287,12 @@ async function fetchProviderModelsWithAuth( ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}), }, contextCap)); - if (!setCached(name, live, Date.now(), cacheGeneration)) return observed(configured, "degraded"); + const forCache = withConfiguredRetention(live, { retainComboTargets: false }); + if (!setCached(name, forCache, Date.now(), cacheGeneration)) { + return observed(withConfiguredRetention(configured), "degraded"); + } markProviderDiscoveryOk(name, live.length); - return observed(live, "authoritative"); + return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); } const extracted = extractProviderModelItems(bounded.value, discovery); if (!extracted.ok) { @@ -1273,41 +1324,24 @@ async function fetchProviderModelsWithAuth( // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into // `live`; otherwise configured entries would be reported as discovered ones. const liveModelCount = live.length; - const liveIds = new Set(live.map(m => m.id)); - // Dated-release aliases (Anthropic pattern): older models may appear in the live catalog - // ONLY under their dated id (claude-haiku-4-5-20251001) while the config names the - // API-valid alias (claude-haiku-4-5). Such aliases are real, callable models — keep them - // in the authoritative catalog (alias id, hints from the dated live entry) instead of - // dropping them and warning on every poll. - const droppedConfiguredIds: string[] = []; - for (const m of configured) { - if (liveIds.has(m.id)) continue; - const dated = live.find(l => isDatedVariantId(l.id, m.id)); - if (dated) { - // Reapply config hints so alias-keyed overrides (modelContextWindows etc.) win. - live.push(applyProviderConfigHints(name, prov, { ...dated, id: m.id }, contextCap)); - } else if ( - seedVertexDefault - || shouldRetainConfiguredProviderModel(name, m.id) - || captured.retainConfiguredModelIds?.has(m.id) === true - ) { - live.push(m); - } else { - droppedConfiguredIds.push(m.id); - } - } - if (live.length === 0 && name !== OPENAI_API_PROVIDER_ID) { + // Dated-release aliases + configured retention (compat allow-list, combo targets, + // Vertex default). Cache without combo retention so a later gather re-applies the + // current capture's retain set on read (warm-cache OCX-111 / #1308). + const forCache = withConfiguredRetention(live, { retainComboTargets: false }); + const returned = withConfiguredRetention(forCache, { warnDrops: true }); + const droppedConfiguredIds = configured + .map(model => model.id) + .filter(id => !returned.some(model => model.id === id)); + if (returned.length === 0 && name !== OPENAI_API_PROVIDER_ID) { console.warn( `[opencodex] Provider model discovery for "${name}" returned an authoritative empty catalog; ${droppedConfiguredIds.length > 0 ? `dropping configured model ids: ${droppedConfiguredIds.join(", ")}` : "no models will be exposed"}.`, ); - } else if (droppedConfiguredIds.length > 0 - && name !== OPENAI_API_PROVIDER_ID - && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)) { - warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds); } - if (!setCached(name, live, Date.now(), cacheGeneration)) return observed(configured, "degraded"); + if (!setCached(name, forCache, Date.now(), cacheGeneration)) { + return observed(withConfiguredRetention(configured), "degraded"); + } markProviderDiscoveryOk(name, liveModelCount); - return observed(live, "authoritative"); + return observed(returned, "authoritative"); } catch (error) { if (error instanceof ProviderOutboundPolicyError) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" }); @@ -1354,6 +1388,60 @@ export function shouldRetainConfiguredProviderModel(providerName: string, modelI return false; } +/** + * Fold dated-release aliases and retain configured rows that must survive an + * authoritative live roster (compatibility allow-list, combo targets, Vertex + * default). Used on every discovery return — live, fresh cache, stale, and + * failure fallback — so a warm cache captured before a combo existed still + * surfaces the configured target (OCX-111 / #1308). + * + * Cache writes should pass `retainComboTargets: false` so combo retention is + * re-applied on read against the current capture, not frozen into the TTL entry. + */ +export function mergeConfiguredModelsIntoLiveCatalog(opts: { + name: string; + provider: OcxProviderConfig; + models: readonly CatalogModel[]; + configured: readonly CatalogModel[]; + retainConfiguredModelIds?: ReadonlySet; + contextCap?: number; + seedVertexDefault?: boolean; + retainComboTargets?: boolean; +}): { models: CatalogModel[]; droppedConfiguredIds: string[] } { + const { + name, + provider: prov, + configured, + retainConfiguredModelIds, + contextCap, + seedVertexDefault, + retainComboTargets = true, + } = opts; + const out = [...opts.models]; + const present = new Set(out.map(model => model.id)); + const droppedConfiguredIds: string[] = []; + for (const candidate of configured) { + if (present.has(candidate.id)) continue; + const dated = out.find(live => isDatedVariantId(live.id, candidate.id)); + if (dated) { + out.push(applyProviderConfigHints(name, prov, { ...dated, id: candidate.id }, contextCap)); + present.add(candidate.id); + continue; + } + if ( + seedVertexDefault === true + || shouldRetainConfiguredProviderModel(name, candidate.id) + || (retainComboTargets && retainConfiguredModelIds?.has(candidate.id) === true) + ) { + out.push(candidate); + present.add(candidate.id); + continue; + } + droppedConfiguredIds.push(candidate.id); + } + return { models: out, droppedConfiguredIds }; +} + export function filterCatalogVisibleModels( models: CatalogModel[], config: Pick, diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 75260090a..d654fd896 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1608,6 +1608,83 @@ describe("combo catalog capability intersection", () => { clearModelCache("cc-test"); } }, 15_000); + + test("warm cache still retains configured combo targets added inside the TTL (OCX-111)", async () => { + // Owner / CodeRabbit blocker: retention must apply on fresh-cache reads, not only + // after a live /models response. Warm the provider cache without a combo, then + // gather again with a combo before TTL expiry — the configured target must return. + clearModelCache("or-warm"); + clearModelCache("go-warm"); + const warning = spyOn(console, "warn").mockImplementation(() => {}); + let fetchCount = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + fetchCount += 1; + const url = String(input); + const id = url.includes("or-warm") ? "or-warm/other-model" : "go-warm/other"; + return new Response(JSON.stringify({ data: [{ id, owned_by: "provider" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + const baseProviders = { + "or-warm": { + adapter: "openai-chat" as const, + baseUrl: "https://or-warm.example.test/v1", + authMode: "key" as const, + apiKey: "sk-test", + liveModels: true as const, + models: ["openai/gpt-5.6-luna"], + modelContextWindows: { "openai/gpt-5.6-luna": 200_000 }, + }, + "go-warm": { + adapter: "openai-chat" as const, + baseUrl: "https://go-warm.example.test/v1", + authMode: "key" as const, + apiKey: "sk-test", + liveModels: false as const, + models: ["deepseek-v4-flash"], + modelContextWindows: { "deepseek-v4-flash": 128_000 }, + }, + }; + try { + resetCatalogRuntimeStateForTests(); + const withoutCombo = await gatherRoutedModels({ + port: 10100, + defaultProvider: "or-warm", + modelCacheTtlMs: 60_000, + providers: baseProviders, + }); + expect(fetchCount).toBe(1); + expect(withoutCombo.some(r => r.provider === "or-warm" && r.id === "openai/gpt-5.6-luna")).toBe(false); + expect(withoutCombo.some(r => r.provider === "or-warm" && r.id === "or-warm/other-model")).toBe(true); + + const withCombo = await gatherRoutedModels({ + port: 10100, + defaultProvider: "or-warm", + modelCacheTtlMs: 60_000, + providers: baseProviders, + combos: { + failover: { + strategy: "failover", + targets: [ + { provider: "or-warm", model: "openai/gpt-5.6-luna", weight: 1 }, + { provider: "go-warm", model: "deepseek-v4-flash", weight: 1 }, + ], + }, + }, + }); + expect(fetchCount).toBe(1); + expect(withCombo.some(r => r.provider === "or-warm" && r.id === "openai/gpt-5.6-luna")).toBe(true); + expect(withCombo.some(r => r.provider === "combo" && r.id === "failover")).toBe(true); + const warningText = warning.mock.calls.flat().join(" "); + expect(warningText).not.toContain("member capabilities are incomplete"); + } finally { + warning.mockRestore(); + globalThis.fetch = originalFetch; + clearModelCache("or-warm"); + clearModelCache("go-warm"); + } + }, 15_000); }); describe("Google Gemini catalog metadata", () => { diff --git a/tests/codex-gather-authority.test.ts b/tests/codex-gather-authority.test.ts index 124244f16..5b9724971 100644 --- a/tests/codex-gather-authority.test.ts +++ b/tests/codex-gather-authority.test.ts @@ -295,4 +295,73 @@ describe("catalog gather discovery-policy authority", () => { clearModelCache("together"); } }); + + test("different combo retention sets cannot join another admission's flight (OCX-111)", async () => { + // retainConfiguredModelIds is part of providerGraphIdentity. Concurrent gathers that + // share providers but differ in combo targets must not coalesce onto the wrong retain set. + clearModelCache("or-flight"); + clearGatherRoutedModelsInflight(); + + const firstResponse = deferred(); + let fetchCount = 0; + globalThis.fetch = (async () => { + fetchCount += 1; + if (fetchCount === 1) await firstResponse.promise; + return Response.json({ data: [{ id: "or-flight/other-model" }] }); + }) as typeof fetch; + + const provider = { + adapter: "openai-chat" as const, + baseUrl: "https://or-flight.example.test/v1", + authMode: "key" as const, + apiKey: "sk-flight", + liveModels: true as const, + models: ["openai/gpt-5.6-luna"], + modelContextWindows: { "openai/gpt-5.6-luna": 200_000 }, + }; + const withoutCombo = withStubbedProviderFetch({ + port: 10100, + defaultProvider: "or-flight", + modelCacheTtlMs: 0, + providers: { "or-flight": provider }, + }); + const withCombo = withStubbedProviderFetch({ + port: 10100, + defaultProvider: "or-flight", + modelCacheTtlMs: 0, + providers: { "or-flight": provider }, + combos: { + failover: { + strategy: "failover", + stickyLimit: 1, + defaultEffort: "medium", + alias: null, + nativeAlias: false, + displayName: null, + targets: [ + { provider: "or-flight", model: "openai/gpt-5.6-luna", weight: 1 }, + { provider: "or-flight", model: "or-flight/other-model", weight: 1 }, + ], + }, + }, + }); + + try { + const first = gatherRoutedModels(withoutCombo); + await Bun.sleep(20); + expect(fetchCount).toBe(1); + + const second = gatherRoutedModels(withCombo); + await Bun.sleep(20); + expect(fetchCount).toBe(2); + + firstResponse.resolve(); + const [noComboRows, comboRows] = await Promise.all([first, second]); + expect(noComboRows.some(r => r.provider === "or-flight" && r.id === "openai/gpt-5.6-luna")).toBe(false); + expect(comboRows.some(r => r.provider === "or-flight" && r.id === "openai/gpt-5.6-luna")).toBe(true); + } finally { + clearGatherRoutedModelsInflight(); + clearModelCache("or-flight"); + } + }); });