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
35 changes: 35 additions & 0 deletions docs-site/src/content/docs/reference/configuration/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,41 @@ title: Catalog options
description: Optional native model omission in the generated Codex catalog.
---

## Reloading model metadata

Codex loads a local model catalog at startup. After OpenCodex changes that catalog,
run `ocx sync --restart-codex` to refresh existing clients. Publishing a new catalog
file does not update a client that is already running.

A Codex client with missing or stale model metadata can send every MCP tool definition
eagerly instead of using deferred tool discovery, which makes each prompt much larger.
When a Codex request declares 64 or more eager MCP tool definitions, OpenCodex logs a
warning with the count and the threshold, then forwards the request unchanged. The
warning contains no tool names or request content. Restart Codex to reload the catalog:
`ocx system codex-restart --yes` restarts it on request (it interrupts running turns), while
`ocx sync --restart-codex` restarts only after it writes a changed catalog or configuration.

The warning is shown once per session. A request without a session identity has no
session to warn once per, so it is warned once per request instead: a retry or compaction
handoff of the same request stays silent, and the next anonymous request warns again.
The session list is bounded, so an install running more than 1024 concurrent sessions can
see one repeat warning from the session evicted after the longest idle time.

The count covers top-level tools, the Responses Lite `additional_tools` declarations the
client sent with this request, and MCP tools rendered into the Code Mode `exec`
description. Codex resends the window's catalog at the head of every request, so a
resumed turn's client-sent declarations count too, including a turn that resends its whole
stored window under `previous_response_id`; only history the proxy itself restored from
`previous_response_id` state is excluded. Compaction, including Responses API
compaction triggers, counts only the top-level tool catalog. The check recognizes
canonical Codex client identities or Codex turn metadata; an incidental `codex` substring
in another client's name does not activate it. Explicitly deferred tools, conversation
text, and other API clients are not counted.

Routed models advertise deferred discovery. In Code Mode, discover available tools
through `ALL_TOOLS` and call them through `tools`; a separate `tool_search` function
is not required. Models using the direct tool interface can expose `tool_search`.

## Omit disabled native models

`omitDisabledNativeModels` is an optional boolean in `config.json`, defaulting to `false`.
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout-responses.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"passthrough-override.test.ts": "responses",
"passthrough-headers.test.ts": "responses",
"passthrough-abort.test.ts": "responses",
"codex-eager-tool-catalog.test.ts": "responses",
"current-turn-tool-catalog-direct.test.ts": "responses",
"current-turn-tool-catalog-namespace.test.ts": "responses",
"responses-workflow-send-ceiling.test.ts": "responses",
Expand Down
8 changes: 8 additions & 0 deletions src/responses/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,7 @@ bindSpillQueueStore({
// once. It records the boundary whether the proxy prepended the history or the client already
// carried it — the boundary is the same either way, and only its provenance differs.
const replayedInputPrefixLengths = new WeakMap<object, number>();
const clientCarriedReplays = new WeakSet<object>(); // overlap skip: the client resent the window itself
const replayFailures = new WeakMap<object, PreviousResponseReplayFailure>();
let loaded = false;
let persistTimer: ReturnType<typeof setTimeout> | null = null;
Expand Down Expand Up @@ -1150,6 +1151,7 @@ export function expandPreviousResponseInput(body: unknown, clientThreadId?: stri
// not re-acknowledge historical compaction markers (parser.ts) and stays visible to
// guidance de-duplication (collaboration.ts).
replayedInputPrefixLengths.set(unchanged, carried);
clientCarriedReplays.add(unchanged);
return unchanged;
}
}
Expand All @@ -1172,6 +1174,11 @@ export function previousResponseReplayPrefixLength(body: unknown): number {
return replayedInputPrefixLengths.get(body) ?? 0;
}

/** Leading items the PROXY prepended; 0 when the client resent the stored window itself (overlap skip). */
export function previousResponseRestoredPrefixLength(body: unknown): number {
return body && typeof body === "object" && clientCarriedReplays.has(body) ? 0 : previousResponseReplayPrefixLength(body);
}

/** Copy proxy-private replay provenance to an internal clone with the same materialized input. */
export function copyPreviousResponseReplayProvenance(source: unknown, target: unknown): void {
if (!source || typeof source !== "object" || Array.isArray(source)) return;
Expand All @@ -1181,6 +1188,7 @@ export function copyPreviousResponseReplayProvenance(source: unknown, target: un
const input = (target as { input?: unknown }).input;
if (!Array.isArray(input) || prefixLength > input.length) return;
replayedInputPrefixLengths.set(target, prefixLength);
if (clientCarriedReplays.has(source)) clientCarriedReplays.add(target);
}

/** True when this exact request could not replay because its task scope did not match. */
Expand Down
11 changes: 11 additions & 0 deletions src/server/responses/code-mode-collab-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,17 @@ function renderedNestedTools(description: string): RenderedTool[] {
return rendered;
}

/**
* Nested tools a codex-rs code-mode exec renders, read from its wire form: a top-level custom
* `exec` whose description opens with the template header. Each name needs a heading followed by
* its own declaration, so an example or quoted text in the description renders nothing.
*/
export function codeModeRenderedToolNames(name: unknown, description: unknown): string[] {
if (name !== CODE_MODE_EXEC_TOOL_NAME || typeof description !== "string"
|| !description.startsWith(CODE_MODE_EXEC_TEMPLATE_HEADER)) return [];
return renderedNestedTools(description).map(tool => tool.name);

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 Count oversized Code Mode catalogs instead of treating them as empty

For a canonical Codex request whose expanded exec.description exceeds 1,048,576 characters, renderedNestedTools() immediately returns [] because of its existing MAX_DESCRIPTION_LENGTH guard. Reusing it here therefore makes eagerCodexMcpToolCount() report zero: a dependency-free probe with 64 complete rendered MCP sections counted 64 below that limit and zero above it. These oversized catalogs are precisely the payload-inflation case this diagnostic is intended to catch, yet they receive no warning. Use a bounded counting strategy that can diagnose valid catalogs above this limit, and add regression coverage for that boundary.

Useful? React with 👍 / 👎.

}

/** A nested name that claims a collaboration surface other than V1's. */
function isForeignCollabName(name: string): boolean {
if (name.startsWith("mcp__")) return false; // MCP server tools are never native collaboration
Expand Down
156 changes: 156 additions & 0 deletions src/server/responses/codex-tool-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { currentTurnWireToolCatalogBody } from "../responses-undeclared-tool-guard";
import { sessionLaneIdFromRequest } from "../request-log-conversation";
import { codeModeRenderedToolNames } from "./code-mode-collab-surface";

/**
* Eager MCP definitions at which a Codex request is diagnosed as carrying an expanded catalog.
* Routed catalog rows advertise deferred discovery, so a Codex client with current model metadata
* keeps MCP tools deferred; one whose metadata is missing or stale expands every MCP tool into the
* request. The request is still forwarded unchanged: this is a diagnostic, never an admission
* rule, because an eager catalog stays a valid request (ADR-0022).
*/
export const EAGER_CODEX_MCP_TOOL_WARNING_THRESHOLD = 64;
/**
* Sessions already warned, least recently sent first. Bounded so a long-lived proxy cannot grow
* it without limit: the dedupe covers this many sessions and no more, so an operator running more
* sessions than that can see one repeat warning. Recency refreshes on every request a session
* sends, so the session being evicted is the one that has been idle longest.
*/
export const WARNED_SESSION_LIMIT = 1024;
const warnedSessions = new Map<string, true>();
/** Logical requests already warned without a session identity; weak so completed requests leave no trace. */
const warnedAnonymousRequests = new WeakSet<object>();
const CODEX_CLIENT_IDENTITY = /^(?:codex|codex_cli_rs|codex_app|codex_work_desktop|codexless_agent|Codex Desktop)(?:\/|$)/i;

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

/**
* Which wire surface a request is being counted for. Compaction sends its history as `input` rather
* than as declarations, so only its top-level `tools` are this request's catalog.
*/
export function eagerCodexToolCatalogSurface(
body: unknown,
compactionRecoveryKind: string | undefined,
): "responses" | "compact" {
if (compactionRecoveryKind === "compaction-v1") return "compact";
const input = isRecord(body) && Array.isArray(body.input) ? body.input : [];
return input.some(item => isRecord(item) && item.type === "compaction_trigger") ? "compact" : "responses";
}

function collectEagerMcpTools(specs: unknown[], names: Set<string>, namespace?: string): void {
for (const spec of specs) {
if (!isRecord(spec) || spec.defer_loading === true) continue;
// Codex namespaces are one level deep. A namespace nested inside another is not a Codex
// declaration, and recursing into caller-shaped depth could exhaust the stack.
if (spec.type === "namespace" && typeof spec.name === "string" && Array.isArray(spec.tools)) {
if (namespace === undefined) collectEagerMcpTools(spec.tools, names, spec.name);
continue;
}
const tool = isRecord(spec.function) ? spec.function : spec;
if (tool.defer_loading === true || typeof tool.name !== "string") continue;
// `functions` is the reserved unnamespaced group (as in the undeclared-tool guard and parser),
// so an MCP tool declared inside it keeps its own `mcp__` name.
const name = namespace && namespace !== "functions" ? `${namespace}__${tool.name}` : tool.name;
if (name.startsWith("mcp__")) names.add(name);
// Code Mode renders each enabled, non-deferred nested tool into the exec description. Codex
// sends exec bare or, on Responses Lite, inside its `functions` namespace.
if ((namespace === undefined || namespace === "functions") && spec.type === "custom") {
for (const nested of codeModeRenderedToolNames(tool.name, tool.description)) {
if (nested.startsWith("mcp__")) names.add(nested);
}
}
}
}

/**
* Responses Lite `additional_tools` the client sent for this request.
*
* Codex does not express "current turn" by position: a window resends its base catalog at the head
* of `input` on every request and appends an incremental `additional_tools` update wherever the
* change happened, including after a replayed tool call. Both are live declarations that this
* request pays for, so every client-sent item counts. Only what the proxy itself replayed from
* stored continuation state is history, and `currentTurnWireToolCatalogBody` removes it before
* this runs.
*/
function clientSentAdditionalTools(input: unknown[]): unknown[][] {
const groups: unknown[][] = [];
for (const item of input) {
if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) groups.push(item.tools);
}
return groups;
}

/**
* Eager MCP definitions a canonical Codex client declared for this request, or 0 for any other
* client. `replayPrefixLength` is the leading input the proxy restored from stored continuation
* state; those items are history and grant no declaration for this turn. Compaction reads only
* top-level `tools`: its `input` is history.
*/
export function eagerCodexMcpToolCount(
body: unknown,
headers: Headers,
surface: "responses" | "compact" = "responses",
replayPrefixLength?: number,
): number {
if (!headers.has("x-codex-turn-metadata")
&& !CODEX_CLIENT_IDENTITY.test(headers.get("originator") ?? "")
&& !CODEX_CLIENT_IDENTITY.test(headers.get("user-agent") ?? "")) return 0;
const current = currentTurnWireToolCatalogBody(body, replayPrefixLength);
if (!isRecord(current)) return 0;
const names = new Set<string>();
if (Array.isArray(current.tools)) collectEagerMcpTools(current.tools, names);
if (surface === "responses" && Array.isArray(current.input)) {
for (const tools of clientSentAdditionalTools(current.input)) collectEagerMcpTools(tools, names);
}
return names.size;
}

/**
* Warn once per session when a Codex request carries an eagerly expanded MCP catalog. Only the
* count and the threshold are logged; tool names, bodies and session identifiers are not.
*
* A request without a session identity has no session to warn once per, so the
* `logicalRequest` scope stands in for one: each logical request warns, and a retry, combo child
* or compaction replay that shares the scope does not repeat it. Callers that omit the scope fall
* back to the session only.
*/
export function warnEagerCodexToolCatalog(
body: unknown,
headers: Headers,
options: { surface?: "responses" | "compact"; replayPrefixLength?: number; logicalRequest?: object } = {},
): void {
const surface = options.surface ?? "responses";
const session = sessionLaneIdFromRequest(headers);
if (session !== undefined && warnedSessions.has(session)) {
// Refresh recency on every request the session sends, eager or not, so an active session is
// never the one evicted next. An already-warned session needs no count at all.
warnedSessions.delete(session);
warnedSessions.set(session, true);
return;
}
// A diagnostic must never change the request it observes: any failure while counting a
// malformed body skips the warning and lets the request continue exactly as before.
let count: number;
try { count = eagerCodexMcpToolCount(body, headers, surface, options.replayPrefixLength); } catch { return; }
if (count < EAGER_CODEX_MCP_TOOL_WARNING_THRESHOLD) return;
if (session !== undefined) {
while (warnedSessions.size >= WARNED_SESSION_LIMIT) {
const oldest = warnedSessions.keys().next().value;
if (oldest === undefined) break;
warnedSessions.delete(oldest);
}
warnedSessions.set(session, true);
} else if (options.logicalRequest !== undefined) {
if (warnedAnonymousRequests.has(options.logicalRequest)) return;
warnedAnonymousRequests.add(options.logicalRequest);
}
console.warn(
`[codex-catalog] Codex sent ${count} eager MCP tool definitions (warning threshold ${EAGER_CODEX_MCP_TOOL_WARNING_THRESHOLD}) `
+ "instead of deferred tool discovery; its model metadata may be missing or stale. The request was forwarded unchanged. "
+ "Restart Codex to reload the catalog ('ocx system codex-restart --yes'; it interrupts running turns). "
+ "Shown once per session, or once per request "
+ "when the client sends no session identity.",
);
}
3 changes: 3 additions & 0 deletions src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse }
import { linkRequestSessionLane, sessionLaneIdFromRequest, reasoningReplayConversationIdFromResponsesRequest, sessionIdHeaderFromRequest } from "../request-log-conversation";
import { recallComboForLane } from "./combo-session-recall";
import { redactHostedImageDisplayPaths } from "../responses-hosted-image-display";
import { warnEagerCodexToolCatalog } from "./codex-tool-catalog";

export const COMPACT_RESPONSE_MAX_BYTES = 32 * 1024 * 1024;

Expand Down Expand Up @@ -696,6 +697,8 @@ export async function handleResponsesCompact(
}
const clientModelLabel = options.clientModelLabel ?? clientFacingModelLabel(options.compactionRoutingOverride?.sourceModel ?? (body as { model?: unknown }).model);
options = { ...options, clientModelLabel };
// Compaction body `input` is forwarded history, so only the top-level catalog is counted.
warnEagerCodexToolCatalog(body, req.headers, { surface: "compact", logicalRequest: logCtx });
// Compaction forwards client history upstream too; keep local artifact paths out of it.
redactHostedImageDisplayPaths(body);
if (!options.compactionRoutingOverride) {
Expand Down
12 changes: 12 additions & 0 deletions src/server/responses/core-combo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,12 @@ import { formatErrorResponse } from "../../bridge";
import { SEND_BUDGET_EXHAUSTED_CODE } from "../../lib/errors";
import {
expandPreviousResponseInput,
previousResponseRestoredPrefixLength,
previousResponseReplayFailure,
previousResponseProviderState,
} from "../../responses/state";
import { hasUnreadableEncryptedAgentTask } from "./encrypted-payload";
import { eagerCodexToolCatalogSurface, warnEagerCodexToolCatalog } from "./codex-tool-catalog";
import { captureRouteStaticPolicy, routeConcreteModel, comboRouteDecisionTrace } from "../../router";
import { memoryModelRouteReason } from "./memory-models";
import { baseProviderLabel, poolAccountProviderLabel } from "../../providers/label";
Expand Down Expand Up @@ -469,6 +471,16 @@ export async function executeComboResponses(
if (combo.imageInput === "disabled" && comboRequestHasImageInput(body)) {
return formatErrorResponse(400, "invalid_request_error", "This combo does not accept image input");
}
// The combo ladder owns the whole logical request, so the catalog diagnostic is counted here for
// combo-routed turns; children run as concrete selections and would otherwise never report it.
// Counting after the replay expansion keeps restored history out of the tally.
if ((options.inboundWire ?? "responses") === "responses") {
warnEagerCodexToolCatalog(body, req.headers, {
surface: eagerCodexToolCatalogSurface(body, options.compactionRecoveryKind),
replayPrefixLength: previousResponseRestoredPrefixLength(body),
logicalRequest: logCtx,
});
}
const comboReplaySnapshot = {
sourceBody: body,
previousResponseInputExpanded: body !== rawBody
Expand Down
11 changes: 11 additions & 0 deletions src/server/responses/request-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
import {
copyPreviousResponseReplayProvenance,
expandPreviousResponseInput,
previousResponseRestoredPrefixLength,
previousResponseReplayFailure,
markBodyNonPersistable,
previousResponseProviderState,
Expand Down Expand Up @@ -183,6 +184,7 @@ import {
} from "./account-change-state";

import { redactHostedImageDisplayPaths } from "../responses-hosted-image-display";
import { eagerCodexToolCatalogSurface, warnEagerCodexToolCatalog } from "./codex-tool-catalog";

/** Policy-routing evidence. The direct lane's settings-only body has no conversation to scan. */
function routeEvidence(options: Pick<HandleResponsesOptions, "directRequest">, parsed: OcxParsedRequest): PolicyRequestEvidence {
Expand Down Expand Up @@ -456,6 +458,15 @@ export async function prepareResponsesRequest(
&& typeof (body as { previous_response_id?: unknown }).previous_response_id === "string");
options.responsesInstructionImage ||= responsesHasInstructionImage(body);
options.responsesFileImage ||= responsesHasFileImage(body);
// Counted after replay expansion so the catalog this request pays for is the one measured:
// input restored from stored continuation state is history, and the prefix removes it.
if (inboundWire === "responses" && !concreteSelection) {
warnEagerCodexToolCatalog(body, req.headers, {
surface: eagerCodexToolCatalogSurface(body, options.compactionRecoveryKind),
replayPrefixLength: previousResponseRestoredPrefixLength(body),
logicalRequest: logCtx,
});
}

// Spawn-message compatibility (both directions): agent_message task payloads ride in
// encrypted_content slots as plaintext. Rewrite them to input_text on the RAW body BEFORE
Expand Down
Loading
Loading