Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids.
| `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. |
| `noStructuredOutputModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model. |
| `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. |
| `routedToolDiscovery?` | `"auto" \| "deferred" \| "direct"` | Routed-row Codex tool-discovery policy. `auto` (default) keeps the shipped behavior: deferred for non-Cursor routed rows, direct for Cursor, which is hard-fenced and ignores a configured `deferred`. Set `direct` only for a route proven incompatible with deferred discovery — it embeds every MCP declaration in the first request (a measured 2.7x turn-1 payload cost) and does **not** make an otherwise eligible tool reachable under code mode, where Codex installs nested tools on the `tools`/`ALL_TOOLS` globals either way. Independent of hosted web search (`web_search_tool_type`). |
| `modelRoutedToolDiscovery?` | `Record<string, "auto" \| "deferred" \| "direct">` | Per-model override of `routedToolDiscovery`, so one incompatible model on a mixed gateway does not penalize its siblings. Matching follows the usual model-key rules (exact id, family before `:`, case-insensitive); dated `-YYYYMMDD` variants are not matched, so name the exact failing model id. |
| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Disabled-by-default downstream SSE repair for exact placeholder ids, missing terminal ids, and (with `repairInvalidIds`) message/reasoning ids missing the canonical `msg_`/`rs_` prefix. Function-call ids are never rewritten. Built-in DeepSeek enables the last two by default. |
| `responsesSnapshotRepair?` | `boolean` | Disabled-by-default client-facing repair for sparse Responses lifecycle snapshots in SSE and JSON. Fills missing canonical status, output, and tool metadata while raw inspection and persistence remain unchanged. |
| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key providers only (`authMode: "key"`). Opt-in same-target 429 retry: when `retryOn429` is absent the feature is off; object presence enables it unless `enabled: false`. On 429 the proxy waits (upstream `Retry-After` or the fixed interval) and replays the identical request on the same key before any key failover — across the main text-turn recovery loop, the Responses passthrough wire, the image/video bridge, the web-search sidecar, and terminal continuations. Only pre-stream HTTP 429 responses are eligible for replay; custom `runTurn` transports are outside the HTTP retry loop. `attempts` counts same-key replays after the first 429 (total sends = `attempts` + 1) and is one request-wide budget shared by the main recovery loop, the terminal-guard continuation, and bridge retries. Exhausting `attempts` only stops further same-key replays: normal key failover or final-error handling then applies per the available targets — on the key-auth passthrough wire there is no failover, so the exhausted 429 surfaces as-is. Codex itself never retries 429, so this is the only defense for single-key providers. Defaults: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (any single wait is capped at `maxIntervalMs`, itself capped at 600000), `respectRetryAfter: true`. |
Expand Down
9 changes: 9 additions & 0 deletions src/codex/catalog/aggregation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";

import { catalogModelSlug } from "./parsing";
import type { CatalogModel } from "./parsing";
import { deriveComboToolDiscoveryMode } from "./tool-discovery";

export const openAiApiCollisionWarnings = new Set<string>();

Expand Down Expand Up @@ -173,6 +174,13 @@ export function deriveComboCatalogModel(
...(members.every(member => member.parallelToolCalls === true)
? { parallelToolCalls: true }
: {}),
// One public combo row cannot change capabilities after a target is selected, so a
// single direct-only member forces the whole combo direct rather than stranding it.
// Emitted only when it departs from the default, matching parallelToolCalls and the
// summary flag: an all-deferred combo stays byte-identical to the pre-override shape.
...(deriveComboToolDiscoveryMode(members.map(member => member.toolDiscoveryMode)) === "direct"
? { toolDiscoveryMode: "direct" as const }
: {}),
...(members.some(member => member.supportsReasoningSummaries === false) ? { supportsReasoningSummaries: false } : {}),
};
}
Expand Down Expand Up @@ -201,6 +209,7 @@ export function comboCatalogWarningSignature(
inputModalities: [...new Set(member?.inputModalities ?? [])].sort(),
reasoningEfforts: [...new Set(member?.reasoningEfforts ?? [])].sort(),
parallelToolCalls: member?.parallelToolCalls === true,
toolDiscoveryMode: member?.toolDiscoveryMode ?? "deferred",
supportsReasoningSummaries: member?.supportsReasoningSummaries !== false,
};
}).sort((a, b) => a.key.localeCompare(b.key)));
Expand Down
37 changes: 34 additions & 3 deletions src/codex/catalog/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES, nativeMultiAgentVersion } from "./metadata";
import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
import { isCursorRoute, type ResolvedRoutedToolDiscoveryMode } from "./tool-discovery";

export function legacyCatalogBackupPath(): string {
return join(getConfigDir(), "catalog-backup.json");
Expand Down Expand Up @@ -116,6 +117,13 @@ export interface CatalogModel {
inputModalities?: string[];
/** Provider opted into parallel tool calls (OcxProviderConfig.parallelToolCalls). */
parallelToolCalls?: boolean;
/**
* Resolved routed tool-discovery policy (OcxProviderConfig.routedToolDiscovery and its
* per-model map). Only ever `deferred` or `direct` — `auto` is resolved before it
* reaches a catalog row. Carried here so `normalizeRoutedCatalogEntry` never reaches
* back into global config, matching how parallelToolCalls and the modality hints flow.
*/
toolDiscoveryMode?: ResolvedRoutedToolDiscoveryMode;
/** Whether Codex may send Responses text.verbosity for this routed model. */
supportsVerbosity?: boolean;
supportsReasoningSummaries?: boolean;
Expand Down Expand Up @@ -378,7 +386,23 @@ export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode, v
return entries;
}

export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = false): RawEntry {
/**
* Route-scoped policy inputs. An options object rather than a third positional argument:
* the Cursor fence needs the provider identity as well as the mode, and the two existing
* positions stay put for the public callers (src/codex/catalog.ts re-export, tests).
*/
export interface RoutedCatalogEntryOptions {
/** Resolved discovery mode for this row. Defaults to `deferred` (the #1596 default). */
toolDiscoveryMode?: ResolvedRoutedToolDiscoveryMode;
/** Canonical provider id from the CatalogModel, when the caller has one. */
providerId?: string;
}

export function normalizeRoutedCatalogEntry(
entry: RawEntry,
parallelToolCalls = false,
options: RoutedCatalogEntryOptions = {},
): RawEntry {
delete entry.model_messages;
delete entry.tool_mode;
applyRoutedCodexToolMode(entry);
Expand All @@ -392,7 +416,9 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls =
// Routed rows cloned from native templates must not inherit OpenAI-only summary delivery.
// Per-model routed opt-ins can be added once provider metadata exposes this capability.
delete entry.supports_reasoning_summaries;
const isCursorEntry = typeof entry.slug === "string" && entry.slug.startsWith("cursor/");
// Provider identity first, slug prefix only as a fallback — one shared fence for both the
// template and template-less paths (see tool-discovery.ts isCursorRoute).
const isCursorEntry = isCursorRoute(entry.slug, options.providerId);

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 Preserve adapter-based Cursor identity during serialization

For a provider configured under a custom name with adapter: "cursor", the resolver correctly hard-fences discovery to direct, but deriveEntry() passes only model.provider here, so isCursorRoute() classifies it as non-Cursor. The resulting row incorrectly retains web_search_tool_type even though this transport bypasses the sidecar, and it also loses Cursor's automatic parallel-tool-call advertisement. Carry the adapter-derived Cursor identity through CatalogModel or pass an explicit Cursor flag to normalization.

Useful? React with 👍 / 👎.

// `supports_search_tool` selects Codex's deferred tool-discovery surface; it is not the hosted
// web-search capability. Routed rows also carry tool_mode=code_mode_only (below), and under code
// mode DEFERRED MCP tools remain callable through exec's `tools` global / ALL_TOOLS without any
Expand All @@ -408,7 +434,12 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls =
} else {
entry.web_search_tool_type = "text_and_image";
}
entry.supports_search_tool = !isCursorEntry;
// Cursor is hard-fenced to direct; every other routed row follows its resolved mode,
// which defaults to deferred so an unconfigured tree is byte-identical to #1596.
const effectiveMode: ResolvedRoutedToolDiscoveryMode = isCursorEntry
? "direct"
: options.toolDiscoveryMode ?? "deferred";
entry.supports_search_tool = effectiveMode === "deferred";
// Cursor's transport already serializes overlapping tool calls into atomic Responses tool events.
// Advertising parallel calls lets Codex send the same native capability bit it sends for OpenAI.
// Opt-in providers (OcxProviderConfig.parallelToolCalls, e.g. xAI) advertise it too: the
Expand Down
7 changes: 6 additions & 1 deletion src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } fr

import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing";
import type { CatalogModel } from "./parsing";
import { resolveConfiguredRoutedToolDiscoveryMode } from "./tool-discovery";
import { disabledNativeSlugs, hasComboTargets, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
import type { ComboCatalogOmission } from "./aggregation";
Expand Down Expand Up @@ -554,6 +555,8 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco
rsDel: prov.modelReasoningSummaryDelivery ?? null,
noVis: [...(prov.noVisionModels ?? [])].sort(),
ptc: prov.parallelToolCalls ?? null,
rtd: prov.routedToolDiscovery ?? null,
mrtd: prov.modelRoutedToolDiscovery ?? null,
gMode: prov.googleMode ?? null,
};
}
Expand Down Expand Up @@ -608,7 +611,6 @@ function configuredReasoningSummarySupport(prov: OcxProviderConfig | undefined,
}

export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel {
void name;
const configuredCap = configuredContextWindow(prov, model.id);
const configuredMaxInput = configuredMaxInputTokens(prov, model.id);
let inputModalities = configuredInputModalities(prov, model.id);
Expand Down Expand Up @@ -649,6 +651,9 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false)
? { parallelToolCalls: true }
: {}),
// Resolve routed discovery here so configured, live-discovered, cached and combo-derived
// rows all carry the same value and `auto` never reaches serialization.
toolDiscoveryMode: resolveConfiguredRoutedToolDiscoveryMode(name, prov, model.id).mode,
Comment on lines +654 to +656

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve discovery mode for replacement catalog rows

When the configured model is represented by customModels, the custom row replaces the hinted provider row but does not inherit toolDiscoveryMode; similarly, augmentRoutedModelsWithCapturedOpenAiApiRows() reconstructs trusted openai-apikey rows without this field. In either case a configured direct policy becomes undefined, serialization silently falls back to deferred, and combos derived from these rows also get the wrong mode. Apply the policy in a final common derivation pass or propagate it through both replacement paths.

AGENTS.md reference: src/AGENTS.md:L18-L18

Useful? React with 👍 / 👎.

};
const capped = applyProviderContextCap(hinted.contextWindow, providerCap);
if (providerCap !== undefined && capped !== hinted.contextWindow) {
Expand Down
20 changes: 17 additions & 3 deletions src/codex/catalog/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../accou


import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing";
import { isCursorRoute } from "./tool-discovery";
import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata";
import {
Expand Down Expand Up @@ -284,7 +285,10 @@ export function deriveEntry(
e.base_instructions = identifyRoutedModel(e.base_instructions, modelName);
}
applyReasoningLevels(e, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true);
normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true, {
toolDiscoveryMode: model?.toolDiscoveryMode,
providerId: model?.provider,
});
if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap);
applyCatalogModelMetadata(e, model);
if (model?.catalogKind) e.opencodex_catalog_kind = model.catalogKind;
Expand Down Expand Up @@ -314,15 +318,25 @@ export function deriveEntry(
// web-search metadata (runTurn transport bypasses the sidecar). Non-Cursor routed fallbacks
// advertise deferred discovery — code mode keeps deferred MCP callable (devlog
// 260813_tool_catalog_deferral/010+020); search=false costs a measured 2.7x turn-1 payload.
const isCursorFallback = isRouted && model?.provider === "cursor";
// Same fence helper as the template path, so a `cursor/`-aliased combo whose canonical
// provider is `combo` cannot be classified one way here and another way there.
const isCursorFallback = isRouted && isCursorRoute(slug, model?.provider);
// Cursor is hard-fenced to direct; every other routed fallback follows its resolved mode,
// defaulting to deferred so an unconfigured tree stays byte-identical to #1596.
const fallbackDiscoveryMode = isCursorFallback
? "direct"
: model?.toolDiscoveryMode ?? "deferred";
const entry: RawEntry = {
slug, display_name: routedDisplayName(slug), description: desc,
shell_type: "shell_command", visibility: "list", supported_in_api: true,
priority, base_instructions: "You are a helpful coding assistant.",
...(isRouted
? isCursorFallback
? { supports_search_tool: false }
: { web_search_tool_type: "text_and_image", supports_search_tool: true }
: {
web_search_tool_type: "text_and_image",
supports_search_tool: fallbackDiscoveryMode === "deferred",
}
: {}),
};
if (isRouted) {
Expand Down
Loading
Loading