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..d28dd0bb7 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -151,6 +151,13 @@ interface CapturedProviderGather { readonly policy: CatalogProviderDiscoveryPolicySnapshot; readonly request: CapturedModelsRequest; readonly observedAuth?: ModelsAuthResolution; + /** + * Configured model ids this provider must keep even when live discovery omits + * 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; } interface GatherFlightCapture { @@ -381,6 +388,7 @@ function captureProviderGather( name: string, configured: OcxProviderConfig, authResolver: ModelsAuthResolver, + retainConfiguredModelIds?: ReadonlySet, ): CapturedProviderGather { const enriched = detachedClone(configured); enrichProviderFromRegistry(name, enriched); @@ -422,18 +430,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), @@ -460,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), @@ -1004,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) { @@ -1047,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", ); } @@ -1060,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); @@ -1074,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", ); } @@ -1090,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 } @@ -1099,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", ); } @@ -1114,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 @@ -1124,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, }; @@ -1202,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) { @@ -1236,37 +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)) { - 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" }); @@ -1313,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 b1ce13257..d654fd896 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1517,6 +1517,174 @@ 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. 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("or-test") + ? "openrouter/other-model" + : url.includes("go-test") + ? "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: "or-test", + providers: { + "or-test": { + adapter: "openai-chat", + 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 }, + }, + "go-test": { + adapter: "openai-chat", + baseUrl: "https://go-test.example.test/v1", + authMode: "key", + apiKey: "sk-test", + liveModels: true, + // Combo-only target: not listed in providers.*.models — synthesis only. + models: [], + modelContextWindows: { "deepseek-v4-flash": 128_000 }, + }, + "cc-test": { + adapter: "openai-chat", + baseUrl: "https://cc-test.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: "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 }, + ], + }, + }, + }); + + 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 === "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"); + const { getLastComboCatalogOmissions } = await import("../src/codex/catalog"); + expect(getLastComboCatalogOmissions().some(item => item.id === "failover")).toBe(false); + } finally { + warning.mockRestore(); + globalThis.fetch = originalFetch; + clearModelCache("or-test"); + clearModelCache("go-test"); + 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"); + } + }); });