Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions docs-site/src/content/docs/reference/configuration/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,13 +184,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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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**。
补充上下文元数据、对齐模态,或者把目标模型切换为可发现且兼容的能力。

Expand Down
2 changes: 1 addition & 1 deletion src/codex/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, augmentRoutedModelsWithMetadata } from "./catalog/provider-fetch";
export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, 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, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync";
Expand Down
11 changes: 8 additions & 3 deletions src/codex/catalog/aggregation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!),
Expand Down
111 changes: 107 additions & 4 deletions src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,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<string, CatalogModel>,
providers: ReadonlyMap<string, OcxProviderConfig>,
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 } : {}),
};
}
Comment on lines +674 to +719

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider naming usedFallback for what it actually measures.

Line 695 sets usedFallback whenever hintedContext is undefined. That includes the knownMaxInput branch at line 692, which is not the 128,000-token fallback. The behavior is still correct, because fallbackCapped at lines 698-701 additionally requires that the cap changed the value. Only the name is misleading for the next reader who touches this block.

♻️ Suggested rename
-  const usedFallback = hintedContext === undefined;
+  // True whenever the window came from maxInputTokens or the 128k fallback
+  // rather than from a hinted/discovered contextWindow.
+  const derivedContext = hintedContext === undefined;
   const cappedContext = applyProviderContextCap(uncappedContext, contextCap);
   const contextWindow = cappedContext ?? uncappedContext;
-  const fallbackCapped = usedFallback
+  const fallbackCapped = derivedContext
     && contextCap !== undefined
     && cappedContext !== undefined
     && cappedContext !== uncappedContext;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 } : {}),
};
}
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;
// True whenever the window came from maxInputTokens or the 128k fallback
// rather than from a hinted/discovered contextWindow.
const derivedContext = hintedContext === undefined;
const cappedContext = applyProviderContextCap(uncappedContext, contextCap);
const contextWindow = cappedContext ?? uncappedContext;
const fallbackCapped = derivedContext
&& 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 } : {}),
};
}
🧰 Tools
🪛 ast-grep (0.45.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFileSync } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/codex/catalog/provider-fetch.ts` around lines 674 - 719, Rename
usedFallback to reflect that it tracks whether context was derived from a
non-context-window value, including knownMaxInput, rather than specifically the
128k fallback; update its use in fallbackCapped while preserving the existing
behavior and conditions.


export function isDatedVariantId(liveId: string, configuredId: string): boolean {
if (!liveId.startsWith(`${configuredId}-`)) return false;
return /^\d{8}$/.test(liveId.slice(configuredId.length + 1));
Expand Down Expand Up @@ -1319,21 +1417,26 @@ 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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reapply catalog eligibility before synthesizing combo members

When a combo target was deliberately removed from all by the preceding shouldExposeRoutedModel filter—such as opencode-go/hy3-preview, which is explicitly excluded because the provider rejects it, or a standalone image/video-generation model—the empty lookup now causes resolveComboCatalogMember to synthesize that target from the enabled provider configuration. The resulting combo is advertised through /v1/models and can route requests to an uncallable or non-chat target, undoing the catalog's eligibility choke point; reject synthesized members that fail shouldExposeRoutedModel before deriving the combo.

Useful? React with 👍 / 👎.

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);
else warnUncataloguedComboOnce(id, combo, members, localOmissions);
}
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]));
Expand Down
Loading
Loading