diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 862dd9867..c9926f23b 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -182,13 +182,16 @@ Per-request route-decision traces are recorded when a policy profile executes. A combo remains directly routable even when it cannot be listed. `ocx sync`, `/v1/models`, and the Codex picker list it only when every target exposes capabilities that can be intersected: -- a positive `contextWindow`, from live metadata, registry hints, or provider - `modelContextWindows` / `contextWindow`; and +- a positive `contextWindow`, from live metadata, registry hints, provider + `modelContextWindows` / `contextWindow`, a known positive `maxInputTokens` on the member row, + or — when the provider is known and enabled but every source still omits a window — a + conservative 128,000-token fallback (clamped by `providerContextCaps` when set); and - a non-empty `inputModalities` intersection, treating an omitted member value as `["text"]`. -A bare relay id with no context metadata or targets with disjoint modalities removes the combo from -the catalog. Sync emits a summary warning and the dashboard marks it **Needs attention**. Add context -metadata, align modalities, or target models with discoverable compatible capabilities. +A target on a disabled provider (even with a complete discovery row), on an unknown provider with +no discovery row, or targets with disjoint modalities, removes the combo from the catalog. Sync +emits a summary warning and the dashboard marks it **Needs attention**. Add context metadata, +align modalities, or target models with discoverable compatible capabilities. ## Request history and routing analytics diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md index b718ed4ed..18d08cbe1 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md @@ -82,11 +82,12 @@ selector 校验、冲突规则和隐私说明见[提供方配置](/reference/con 即使某个 combo 不能被列出,它仍然可以直接路由。只有当所有目标都暴露出可以交集的能力时,`ocx sync`、`/v1/models` 和 Codex 选择器才会列出它: -- 一个正的 `contextWindow`,来源可以是实时元数据、注册表提示,或提供方的 - `modelContextWindows` / `contextWindow`;以及 +- 一个正的 `contextWindow`,来源可以是实时元数据、注册表提示、提供方的 + `modelContextWindows` / `contextWindow`、成员行上已知的正 `maxInputTokens`,或者——当提供方已知且启用但所有来源仍未给出窗口时—— + 保守的 128,000 token 回退(若配置了 `providerContextCaps` 则会按上限夹紧);以及 - 非空的 `inputModalities` 交集,其中省略的成员值按 `["text"]` 处理。 -如果是一个没有上下文元数据的裸 relay id,或者目标之间的模态互不相交,combo 就会从 +目标位于已禁用提供方(即使有完整 discovery 行)、未知且无 discovery 行的提供方,或目标之间的模态互不相交时,combo 会从 目录中移除。同步时会输出一条汇总警告,仪表板会将其标记为 **Needs attention**。 补充上下文元数据、对齐模态,或者把目标模型切换为可发现且兼容的能力。 diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 3ee398ecf..ca8f48659 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -5,7 +5,7 @@ export type { CatalogModel, MultiAgentMode } from "./catalog/parsing"; export { NATIVE_OPENAI_MODELS, 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, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch"; +export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata, resolveComboCatalogMember } 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, effectiveSubagentRoster, buildCatalogEntries, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync"; diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index 03b0e17c9..ce3a6ccab 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -131,9 +131,14 @@ export function deriveComboCatalogModel( const inputModalities = intersectStrings( members.map(member => member.inputModalities ?? ["text"]), ); - const reasoningEfforts = intersectStrings( - members.map(member => member.reasoningEfforts ?? []), - ); + // Unknown ladders (`undefined`) are wildcards for catalog derivation — same + // boundary as the GUI picker. An explicit empty ladder still constrains. + const advertisedLadders = members + .map(member => member.reasoningEfforts) + .filter((ladder): ladder is string[] => ladder !== undefined); + const reasoningEfforts = advertisedLadders.length === 0 + ? [] + : intersectStrings(advertisedLadders); const contextWindow = Math.min(...members.map(member => member.contextWindow!)); const maxInputTokens = Math.min( ...members.map(member => member.maxInputTokens ?? member.contextWindow!), diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 545a387c3..c318448fc 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -607,6 +607,104 @@ export function applyConfigHintsToCachedModels(name: string, prov: OcxProviderCo return models.map(model => applyProviderConfigHints(name, prov, model, contextCap)); } + +/** + * Last-resort context window for combo member synthesis when discovery and + * provider config both omit one. Matches the catalog entry default in + * `normalizeRoutedCatalogEntry` so incomplete live rows still catalog. + */ +const COMBO_MEMBER_CONTEXT_FALLBACK = 128_000; + +/** + * Resolve a combo target to a catalog member for derivation. + * Prefer discovery metadata; when the target is missing from the gather map or + * lacks a positive contextWindow, synthesize from the (registry-enriched) + * provider config so combos remain catalogued when targets are configured but + * discovery metadata is incomplete. Disabled providers stay unresolved. + * When hints still omit contextWindow, prefer known maxInputTokens, else + * COMBO_MEMBER_CONTEXT_FALLBACK so a live row without ctx does not drop the + * whole combo from the public catalog. + */ +export function resolveComboCatalogMember( + target: { provider: string; model: string }, + memberByKey: ReadonlyMap, + providers: ReadonlyMap, + contextCap?: number, +): CatalogModel | undefined { + const existing = memberByKey.get(targetKey(target)); + const prov = providers.get(target.provider); + // Disabled providers never contribute members — even a complete discovery row + // is unusable for catalog derivation while the provider is off. + if (prov?.disabled === true) return undefined; + + // Complete live/configured rows still honor providerContextCaps so a high + // discovery window cannot outrun an operator-configured cap. + if ( + existing + && typeof existing.contextWindow === "number" + && existing.contextWindow > 0 + ) { + const capped = applyProviderContextCap(existing.contextWindow, contextCap); + if (capped === undefined || capped === existing.contextWindow) return existing; + const maxInput = typeof existing.maxInputTokens === "number" && existing.maxInputTokens > 0 + ? Math.min(existing.maxInputTokens, capped) + : capped; + return { + ...existing, + contextWindow: capped, + maxInputTokens: maxInput, + contextCap, + contextCapped: true as const, + }; + } + + const base: CatalogModel = existing ?? { + id: target.model, + provider: target.provider, + }; + const hinted = prov + ? applyProviderConfigHints(target.provider, prov, base, contextCap) + : base; + const hintedContext = typeof hinted.contextWindow === "number" && hinted.contextWindow > 0 + ? hinted.contextWindow + : undefined; + // Prefer a known positive maxInputTokens over inventing 128k when discovery + // advertised an input limit but no context window (common thin /models rows). + const knownMaxInput = typeof hinted.maxInputTokens === "number" && hinted.maxInputTokens > 0 + ? hinted.maxInputTokens + : (typeof base.maxInputTokens === "number" && base.maxInputTokens > 0 + ? base.maxInputTokens + : undefined); + const uncappedContext = hintedContext + ?? knownMaxInput + ?? (existing || prov ? COMBO_MEMBER_CONTEXT_FALLBACK : undefined); + if (uncappedContext === undefined) return undefined; + const usedFallback = hintedContext === undefined; + const cappedContext = applyProviderContextCap(uncappedContext, contextCap); + const contextWindow = cappedContext ?? uncappedContext; + const fallbackCapped = usedFallback + && contextCap !== undefined + && cappedContext !== undefined + && cappedContext !== uncappedContext; + + const inputModalities = hinted.inputModalities ?? base.inputModalities ?? ["text"]; + const reasoningEfforts = hinted.reasoningEfforts + ?? (prov ? configuredReasoningEfforts(prov, target.model) : undefined) + ?? base.reasoningEfforts; + const maxInputTokens = knownMaxInput !== undefined + ? Math.min(knownMaxInput, contextWindow) + : contextWindow; + + return { + ...hinted, + inputModalities, + ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), + contextWindow, + maxInputTokens, + ...(fallbackCapped ? { contextCap, contextCapped: true as const } : {}), + }; +} + export function isDatedVariantId(liveId: string, configuredId: string): boolean { if (!liveId.startsWith(`${configuredId}-`)) return false; return /^\d{8}$/.test(liveId.slice(configuredId.length + 1)); @@ -1273,11 +1371,19 @@ async function gatherRoutedModelsUncached( if (!memberByKey.has(key)) memberByKey.set(key, synthetic); } } + // Enriched (registry-hydrated) provider clones — shared by combo member synthesis and + // custom-model vision-sidecar inheritance so both see the same merged registry view. + const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider])); for (const id of listComboIds(config)) { const combo = getCombo(config, id); if (!combo) continue; const members = combo.targets - .map(target => memberByKey.get(targetKey(target))) + .map(target => resolveComboCatalogMember( + target, + memberByKey, + enrichedByName, + providerContextCap(config, target.provider), + )) .filter((member): member is CatalogModel => member !== undefined); const derived = deriveComboCatalogModel(id, combo, members); if (derived) all.push(derived); @@ -1285,9 +1391,6 @@ async function gatherRoutedModelsUncached( } replaceLastComboCatalogOmissions(localOmissions); all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider))); - // Enriched (registry-hydrated) provider clones, keyed by name — the same view used above so - // custom rows get the same noVisionModels / inputModalities treatment as discovered rows. - const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider])); // Provider-derived rows keyed by their Codex-facing slug: a custom override replaces the row // with the same slug below, so that row's provider capability metadata is the inheritance source. const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model])); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 1f6896f8a..3b0234536 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels as gatherRoutedModelsDirect, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, shouldExposeRoutedModel } from "../src/codex/catalog"; +import { augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels as gatherRoutedModelsDirect, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, resolveComboCatalogMember, shouldExposeRoutedModel } from "../src/codex/catalog"; import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; import { CURSOR_STATIC_MODELS, @@ -50,7 +50,7 @@ function normalizedCombo( strategy: "failover", stickyLimit: 1, defaultEffort: "medium", - alias: null, + alias: null, targets: [ { provider: "a", model: "m1", weight: 1 }, { provider: "b", model: "m2", weight: 1 }, @@ -197,6 +197,24 @@ describe("combo catalog capability intersection", () => { ])?.defaultReasoningEffort).toBe("medium"); }); + test("treats undefined effort ladders as wildcards and empty ladders as restrictive", () => { + // One recovered target with no ladder must not zero the advertised intersection. + expect(deriveComboCatalogModel("wildcard", normalizedCombo({ defaultEffort: "medium" }), [ + memberA, + { ...memberB, reasoningEfforts: undefined }, + ])).toEqual(expect.objectContaining({ + reasoningEfforts: ["low", "medium", "high"], + defaultReasoningEffort: "medium", + })); + // Explicit empty ladder still constrains to nothing. + const empty = deriveComboCatalogModel("empty", normalizedCombo({ defaultEffort: "medium" }), [ + memberA, + { ...memberB, reasoningEfforts: [] }, + ]); + expect(empty?.reasoningEfforts).toEqual([]); + expect(empty).not.toHaveProperty("defaultReasoningEffort"); + }); + test("fails closed for missing members, unknown context, duplicate targets, and empty modalities", () => { expect(deriveComboCatalogModel("missing", normalizedCombo(), [memberA])).toBeNull(); expect(deriveComboCatalogModel("context", normalizedCombo(), [ @@ -499,7 +517,9 @@ describe("combo catalog capability intersection", () => { }, combos: { mixed: { targets: [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }] }, - hidden: { targets: [{ provider: "a", model: warningSentinel }] }, + // Unknown provider (not just unlisted model) — synthesis cannot invent a member, + // and the secret in the model id must still be redacted in the omission warning. + hidden: { targets: [{ provider: warningSentinel, model: "m1" }] }, }, disabledModels: ["combo/mixed"], }; @@ -671,6 +691,230 @@ describe("combo catalog capability intersection", () => { expect(openaiRows).toEqual([]); expect(rows.some(r => r.provider === "combo" && r.id === "solo")).toBe(true); }); + + test("synthesizes missing combo targets from provider config metadata", async () => { + // Target model is not in models[] (so never lands in memberByKey) but provider + // config carries context/modalities/efforts — combo derivation must still catalog. + const config: OcxConfig = { + port: 10100, + defaultProvider: "a", + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://a.example/v1", + liveModels: false, + models: ["listed"], + modelContextWindows: { listed: 200_000, unlisted: 128_000 }, + modelInputModalities: { unlisted: ["text"] }, + modelReasoningEfforts: { unlisted: ["low", "medium", "high"] }, + }, + b: { + adapter: "openai-chat", + baseUrl: "https://b.example/v1", + liveModels: false, + models: ["m2"], + modelContextWindows: { m2: 100_000 }, + modelReasoningEfforts: { m2: ["low", "medium"] }, + }, + }, + combos: { + recovered: { + targets: [ + { provider: "a", model: "unlisted" }, + { provider: "b", model: "m2" }, + ], + }, + }, + }; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + resetCatalogRuntimeStateForTests(); + const rows = await gatherRoutedModels(config); + const combo = rows.find(r => r.provider === "combo" && r.id === "recovered"); + expect(combo).toBeDefined(); + expect(combo!.contextWindow).toBe(100_000); + expect(combo!.inputModalities).toEqual(["text"]); + expect(combo!.reasoningEfforts).toEqual(["low", "medium"]); + // Synthesized member must not leak as a standalone routed row. + expect(rows.some(r => r.provider === "a" && r.id === "unlisted")).toBe(false); + const { getLastComboCatalogOmissions } = await import("../src/codex/catalog"); + expect(getLastComboCatalogOmissions().some(item => item.id === "recovered")).toBe(false); + } finally { + warn.mockRestore(); + } + }, 15_000); + + test("resolveComboCatalogMember fills incomplete members from provider config", () => { + // Member is present but lacks contextWindow; provider.contextWindow + efforts complete it. + const incomplete = { + provider: "a", + id: "m1", + // no contextWindow — the incomplete_metadata trigger + }; + const memberByKey = new Map([["a/m1", incomplete]]); + const providers = new Map([ + ["a", { + adapter: "openai-chat" as const, + baseUrl: "https://a.example/v1", + contextWindow: 200_000, + modelReasoningEfforts: { m1: ["low", "medium", "high"] }, + }], + ]); + const resolved = resolveComboCatalogMember( + { provider: "a", model: "m1" }, + memberByKey, + providers, + ); + expect(resolved).toMatchObject({ + provider: "a", + id: "m1", + contextWindow: 200_000, + maxInputTokens: 200_000, + inputModalities: ["text"], + reasoningEfforts: ["low", "medium", "high"], + }); + // Complete members are returned as-is without re-synthesis side effects. + const complete = { + provider: "a", + id: "m1", + contextWindow: 99_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["high"], + }; + expect(resolveComboCatalogMember( + { provider: "a", model: "m1" }, + new Map([["a/m1", complete]]), + providers, + )).toBe(complete); + // Complete members still honor an operator-configured context cap. + expect(resolveComboCatalogMember( + { provider: "a", model: "m1" }, + new Map([["a/m1", complete]]), + providers, + 50_000, + )).toMatchObject({ + contextWindow: 50_000, + maxInputTokens: 50_000, + contextCap: 50_000, + contextCapped: true, + }); + // Disabled providers never contribute — even with a complete discovery row. + expect(resolveComboCatalogMember( + { provider: "a", model: "m1" }, + new Map([["a/m1", complete]]), + new Map([["a", { adapter: "openai-chat", baseUrl: "https://a.example/v1", disabled: true }]]), + )).toBeUndefined(); + // Thin live rows with max_input_tokens but no contextWindow prefer that limit + // over inventing the 128k fallback. + expect(resolveComboCatalogMember( + { provider: "a", model: "thin" }, + new Map([["a/thin", { provider: "a", id: "thin", maxInputTokens: 8_192 }]]), + new Map([["a", { adapter: "openai-chat", baseUrl: "https://a.example/v1" }]]), + )).toMatchObject({ + contextWindow: 8_192, + maxInputTokens: 8_192, + }); + // Known provider without context metadata still gets the conservative fallback. + expect(resolveComboCatalogMember( + { provider: "a", model: "ghost" }, + new Map(), + new Map([["a", { adapter: "openai-chat", baseUrl: "https://a.example/v1" }]]), + )).toMatchObject({ + provider: "a", + id: "ghost", + contextWindow: 128_000, + maxInputTokens: 128_000, + inputModalities: ["text"], + }); + // Provider contextCap below the 128k fallback clamps the synthesized window. + expect(resolveComboCatalogMember( + { provider: "a", model: "ghost" }, + new Map(), + new Map([["a", { adapter: "openai-chat", baseUrl: "https://a.example/v1" }]]), + 64_000, + )).toMatchObject({ + provider: "a", + id: "ghost", + contextWindow: 64_000, + maxInputTokens: 64_000, + contextCap: 64_000, + contextCapped: true, + }); + // Cap above the fallback leaves 128k (no artificial raise, no capped flag). + const aboveCap = resolveComboCatalogMember( + { provider: "a", model: "ghost" }, + new Map(), + new Map([["a", { adapter: "openai-chat", baseUrl: "https://a.example/v1" }]]), + 200_000, + ); + expect(aboveCap).toMatchObject({ + contextWindow: 128_000, + maxInputTokens: 128_000, + }); + // Cap may be recorded for bookkeeping (contextCapped: false) but must not claim a clamp. + expect(aboveCap?.contextCapped).toBeFalsy(); + // No provider entry and no discovery row — cannot invent a member. + expect(resolveComboCatalogMember( + { provider: "missing", model: "ghost" }, + new Map(), + new Map(), + )).toBeUndefined(); + }); + + test("still omits combos when synthesis cannot recover hard failures", async () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "a", + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://a.example/v1", + liveModels: false, + models: ["m1"], + modelContextWindows: { m1: 128_000 }, + // Disjoint modalities with b → empty intersection (incompatible_modalities). + modelInputModalities: { m1: ["image"] }, + }, + b: { + adapter: "openai-chat", + baseUrl: "https://b.example/v1", + liveModels: false, + models: ["m2"], + modelContextWindows: { m2: 128_000 }, + modelInputModalities: { m2: ["audio"] }, + }, + }, + combos: { + disjoint: { + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + ], + }, + ghost: { + // Provider does not exist — synthesis cannot invent a member. + targets: [{ provider: "missing-provider", model: "never-configured" }], + }, + }, + }; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + resetCatalogRuntimeStateForTests(); + const rows = await gatherRoutedModels(config); + expect(rows.some(r => r.provider === "combo" && r.id === "disjoint")).toBe(false); + expect(rows.some(r => r.provider === "combo" && r.id === "ghost")).toBe(false); + const { getLastComboCatalogOmissions } = await import("../src/codex/catalog"); + const omissions = getLastComboCatalogOmissions(); + expect(omissions.find(item => item.id === "disjoint")).toMatchObject({ + reason: "incompatible_modalities", + }); + expect(omissions.find(item => item.id === "ghost")).toMatchObject({ + reason: "incomplete_metadata", + }); + } finally { + warn.mockRestore(); + } + }, 15_000); }); describe("Google Gemini catalog metadata", () => {