From 39f677cb9e9dccf575350446abf9ee3104dfb00b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E4=BF=8A=E6=B1=9F?= Date: Fri, 7 Aug 2026 01:22:34 +0800 Subject: [PATCH 1/2] fix(catalog): synthesize incomplete combo members with context fallback Recover multi-target combos when discovery omits context windows by synthesizing members from provider config (or a 128k fallback clamped by providerContextCaps). Prefer known maxInputTokens over inventing 128k, skip disabled providers, and treat unknown effort ladders as wildcards in catalog derivation while empty ladders stay restrictive. --- .../docs/reference/configuration/routing.md | 13 +- .../zh-cn/reference/configuration/routing.md | 7 +- src/codex/catalog.ts | 2 +- src/codex/catalog/aggregation.ts | 11 +- src/codex/catalog/provider-fetch.ts | 131 ++++++++-- tests/codex-catalog.test.ts | 247 +++++++++++++++++- 6 files changed, 381 insertions(+), 30 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 862dd9867c..78d3c87df9 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`, or — when the provider is known but every source + 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 an unknown/disabled 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 b718ed4ed7..d23b34c3a6 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`,或者——当提供方已知但所有来源都未给出窗口时—— + 保守的 128,000 token 回退(若配置了 `providerContextCaps` 则会按上限夹紧);以及 - 非空的 `inputModalities` 交集,其中省略的成员值按 `["text"]` 处理。 -如果是一个没有上下文元数据的裸 relay id,或者目标之间的模态互不相交,combo 就会从 +目标位于未知/已禁用且无 discovery 行的提供方,或目标之间的模态互不相交时,combo 会从 目录中移除。同步时会输出一条汇总警告,仪表板会将其标记为 **Needs attention**。 补充上下文元数据、对齐模态,或者把目标模型切换为可发现且兼容的能力。 diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 3ee398ecf3..ca8f48659d 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 03b0e17c96..ce3a6ccab3 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 545a387c35..7ccd9b433e 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -607,6 +607,113 @@ 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/missing providers stay unresolved. + * When hints still omit contextWindow, apply COMBO_MEMBER_CONTEXT_FALLBACK so a + * live row without ctx (common for LiteLLM / custom xai ids) 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, + }; + // Reuse the same hint path configured/static catalog rows use when a provider + // config exists; otherwise keep the base (possibly incomplete) discovery row. + 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); + // Prefer config/discovery; fall back so incomplete live rows still catalog. + // Pure ghosts (no discovery row) on a known provider still synthesize: live + // discovery is often incomplete for LiteLLM/custom relays, and dropping the + // whole combo solely for a missing /v1/models row is worse than a stub window. + const uncappedContext = hintedContext + ?? knownMaxInput + ?? (existing || prov ? COMBO_MEMBER_CONTEXT_FALLBACK : undefined); + if (uncappedContext === undefined) return undefined; + // Fallback bypasses applyProviderConfigHints' cap path — clamp here so a + // providerContextCaps value below 128k still wins (same semantics as hints). + 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"]; + // applyProviderConfigHints already folds configuredReasoningEfforts; keep an + // explicit fallback for stubs that only had base fields. + 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)); @@ -736,17 +843,6 @@ function modelInputModalities( value === "text" || value === "image" || value === "audio" )); if (explicit && explicit.length > 0) return explicit; - const architecture = plainRecord(item.architecture); - const architectureModality = typeof architecture?.modality === "string" - ? normalizedMetadataString(architecture.modality, 64) - : undefined; - if (architectureModality?.includes("->")) { - const [rawInput = ""] = architectureModality.split("->"); - const inferred = rawInput - .split("+") - .filter(value => value === "text" || value === "image" || value === "audio"); - if (inferred.length > 0) return [...new Set(inferred)]; - } if (capabilityRecord?.vision === false) return ["text"]; if (capabilityRecord?.vision === true || capabilities?.some(value => value === "vision" || value === "image-input")) { return ["text", "image"]; @@ -1273,11 +1369,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 +1389,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 1f6896f8a2..29175a1a17 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,227 @@ 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). + expect(resolveComboCatalogMember( + { provider: "a", model: "ghost" }, + new Map(), + new Map([["a", { adapter: "openai-chat", baseUrl: "https://a.example/v1" }]]), + 200_000, + )).toMatchObject({ + contextWindow: 128_000, + maxInputTokens: 128_000, + }); + // 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", () => { From 99c63dbfd6ebbbc2fa29913da678de919260f1d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E4=BF=8A=E6=B1=9F?= Date: Fri, 7 Aug 2026 08:46:35 +0800 Subject: [PATCH 2/2] fix(catalog): restore architecture modalities; tighten docs and tests Rebuild resolveComboCatalogMember on current provider-fetch so the Nebius architecture.modality path is not regressed. Document maxInputTokens before the 128k fallback and that disabled providers never catalog. Pin contextCapped falsy when a high cap does not clamp. --- .../docs/reference/configuration/routing.md | 14 +++++----- .../zh-cn/reference/configuration/routing.md | 4 +-- src/codex/catalog/provider-fetch.ts | 28 ++++++++++--------- tests/codex-catalog.test.ts | 7 +++-- 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 78d3c87df9..c9926f23bd 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -183,15 +183,15 @@ A combo remains directly routable even when it cannot be listed. `ocx sync`, `/v Codex picker list it only when every target exposes capabilities that can be intersected: - a positive `contextWindow`, from live metadata, registry hints, provider - `modelContextWindows` / `contextWindow`, or — when the provider is known but every source - omits a window — a conservative 128,000-token fallback (clamped by `providerContextCaps` when - set); and + `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 target on an unknown/disabled 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. +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 d23b34c3a6..18d08cbe16 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 @@ -83,11 +83,11 @@ selector 校验、冲突规则和隐私说明见[提供方配置](/reference/con 即使某个 combo 不能被列出,它仍然可以直接路由。只有当所有目标都暴露出可以交集的能力时,`ocx sync`、`/v1/models` 和 Codex 选择器才会列出它: - 一个正的 `contextWindow`,来源可以是实时元数据、注册表提示、提供方的 - `modelContextWindows` / `contextWindow`,或者——当提供方已知但所有来源都未给出窗口时—— + `modelContextWindows` / `contextWindow`、成员行上已知的正 `maxInputTokens`,或者——当提供方已知且启用但所有来源仍未给出窗口时—— 保守的 128,000 token 回退(若配置了 `providerContextCaps` 则会按上限夹紧);以及 - 非空的 `inputModalities` 交集,其中省略的成员值按 `["text"]` 处理。 -目标位于未知/已禁用且无 discovery 行的提供方,或目标之间的模态互不相交时,combo 会从 +目标位于已禁用提供方(即使有完整 discovery 行)、未知且无 discovery 行的提供方,或目标之间的模态互不相交时,combo 会从 目录中移除。同步时会输出一条汇总警告,仪表板会将其标记为 **Needs attention**。 补充上下文元数据、对齐模态,或者把目标模型切换为可发现且兼容的能力。 diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 7ccd9b433e..c318448fc1 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -607,6 +607,7 @@ 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 @@ -619,9 +620,9 @@ const COMBO_MEMBER_CONTEXT_FALLBACK = 128_000; * 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/missing providers stay unresolved. - * When hints still omit contextWindow, apply COMBO_MEMBER_CONTEXT_FALLBACK so a - * live row without ctx (common for LiteLLM / custom xai ids) does not drop the + * 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( @@ -661,8 +662,6 @@ export function resolveComboCatalogMember( id: target.model, provider: target.provider, }; - // Reuse the same hint path configured/static catalog rows use when a provider - // config exists; otherwise keep the base (possibly incomplete) discovery row. const hinted = prov ? applyProviderConfigHints(target.provider, prov, base, contextCap) : base; @@ -676,16 +675,10 @@ export function resolveComboCatalogMember( : (typeof base.maxInputTokens === "number" && base.maxInputTokens > 0 ? base.maxInputTokens : undefined); - // Prefer config/discovery; fall back so incomplete live rows still catalog. - // Pure ghosts (no discovery row) on a known provider still synthesize: live - // discovery is often incomplete for LiteLLM/custom relays, and dropping the - // whole combo solely for a missing /v1/models row is worse than a stub window. const uncappedContext = hintedContext ?? knownMaxInput ?? (existing || prov ? COMBO_MEMBER_CONTEXT_FALLBACK : undefined); if (uncappedContext === undefined) return undefined; - // Fallback bypasses applyProviderConfigHints' cap path — clamp here so a - // providerContextCaps value below 128k still wins (same semantics as hints). const usedFallback = hintedContext === undefined; const cappedContext = applyProviderContextCap(uncappedContext, contextCap); const contextWindow = cappedContext ?? uncappedContext; @@ -695,8 +688,6 @@ export function resolveComboCatalogMember( && cappedContext !== uncappedContext; const inputModalities = hinted.inputModalities ?? base.inputModalities ?? ["text"]; - // applyProviderConfigHints already folds configuredReasoningEfforts; keep an - // explicit fallback for stubs that only had base fields. const reasoningEfforts = hinted.reasoningEfforts ?? (prov ? configuredReasoningEfforts(prov, target.model) : undefined) ?? base.reasoningEfforts; @@ -843,6 +834,17 @@ function modelInputModalities( value === "text" || value === "image" || value === "audio" )); if (explicit && explicit.length > 0) return explicit; + const architecture = plainRecord(item.architecture); + const architectureModality = typeof architecture?.modality === "string" + ? normalizedMetadataString(architecture.modality, 64) + : undefined; + if (architectureModality?.includes("->")) { + const [rawInput = ""] = architectureModality.split("->"); + const inferred = rawInput + .split("+") + .filter(value => value === "text" || value === "image" || value === "audio"); + if (inferred.length > 0) return [...new Set(inferred)]; + } if (capabilityRecord?.vision === false) return ["text"]; if (capabilityRecord?.vision === true || capabilities?.some(value => value === "vision" || value === "image-input")) { return ["text", "image"]; diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 29175a1a17..3b02345363 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -841,15 +841,18 @@ describe("combo catalog capability intersection", () => { contextCapped: true, }); // Cap above the fallback leaves 128k (no artificial raise, no capped flag). - expect(resolveComboCatalogMember( + const aboveCap = resolveComboCatalogMember( { provider: "a", model: "ghost" }, new Map(), new Map([["a", { adapter: "openai-chat", baseUrl: "https://a.example/v1" }]]), 200_000, - )).toMatchObject({ + ); + 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" },