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
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,7 @@
"responses-inbound-store-default.test.ts": "responses",
"responses-item-id-repair.test.ts": "responses",
"responses-json-events.test.ts": "responses",
"responses-legacy-dotted-tool-name-repair.test.ts": "responses",
"responses-muse-tool-name-alias.test.ts": "responses",
"responses-native-main-refresh.test.ts": "responses",
"responses-opaque-blob-recovery.test.ts": "responses",
Expand Down
7 changes: 7 additions & 0 deletions src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { TranslatorBudget } from "../../lib/translator-budget";
import { rewriteRoutedCustomToolsForUpstream } from "../../responses/custom-tool-compat";
import { rewriteRoutedToolSearchForUpstream } from "../../responses/tool-search-compat";
import { rewriteRoutedNamespaceToolsForUpstream } from "../../responses/namespace-tool-compat";
import { repairLegacyDottedToolCallNames } from "../../responses/legacy-dotted-tool-name-repair";
import { preparePlaintextV2AgentMessages } from "../../responses/plaintext-v2-agent-messages";
import { isMetaAiResponsesDestination, rewriteMuseToolNamesForUpstream } from "../../responses/muse-tool-name-alias";
import { openaiResponsesUrl } from "../openai-responses-url";
Expand Down Expand Up @@ -335,6 +336,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
// every turn, and a strict parser rejects the whole request over the missing key —
// `queries` for DeepSeek (#930), `query` for Console Go (#3071).
outBody = backfillWebSearchQueries(outBody);
// #5095: a conversation that already contains a `default.`-prefixed call name is refused by
// the upstream `^[a-zA-Z0-9_-]+$` name pattern on every later turn that replays it, so the
// task cannot be compacted or continued at all. Repair the replayed item here, before the
// canonical-destination split below, because the reported failure was a side chat on a plain
// OpenAI model inheriting history a routed provider had damaged.
outBody = repairLegacyDottedToolCallNames(outBody);
if (!isCanonicalOpenAiForwardProvider(provider)) {
outBody = stripInternalChatMessageMetadataPassthrough(outBody);
// The same class of private field, one level up, but keyed on the DESTINATION rather than
Expand Down
134 changes: 134 additions & 0 deletions src/responses/legacy-dotted-tool-name-repair.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { collectResponsesToolGroups } from "./tool-groups";
import { isSchemaValidResponsesToolName, wireToolInnerName } from "./tool-name-aliases";
import { CODE_MODE_HELPER_WIRE_NAMES, dottedToolName, namespacedToolName } from "../types";

/**
* The one invented namespace this proxy is known to have emitted. Routed providers flatten a
* namespaced call as `ns.name`, and a provider that invents the grouping picks `default`; #3402,
* #4176 and #4412 all repaired that spelling on the way IN, each on its own path.
*
* This is deliberately one literal prefix rather than a rule. "Strip everything before the first
* dot" would silently rewrite a legitimate tool name that happens to contain a dot in some other
* provider's vocabulary, and a replayed history item is the worst possible place to guess: it names
* a call that already happened.
*/
const LEGACY_INVENTED_NAMESPACE_PREFIX = "default.";

/** Item types whose `name` the upstream validates against the tool-name pattern. */
const NAMED_CALL_ITEM_TYPES = new Set(["function_call", "custom_tool_call"]);

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

type DeclarationView = {
/** Wire spellings the caller's own catalog declares, including flattened namespace forms. */
readonly spellings: ReadonlySet<string>;
/** How many declared identities answer to a given inner name. */
readonly ownersByInnerName: ReadonlyMap<string, number>;
};

/**
* Read the request's own catalog. Namespace children contribute their inner name and both
* flattened spellings, which is what makes a declared `default` namespace distinguishable from an
* invented one.
*/
function readDeclarations(body: unknown): DeclarationView {
const spellings = new Set<string>();
const owners = new Map<string, number>();
const identities = new Map<string, Set<string>>();
const claim = (innerName: string, identity: string): void => {
const seen = identities.get(innerName);
if (seen === undefined) identities.set(innerName, new Set([identity]));
else seen.add(identity);
};
for (const group of collectResponsesToolGroups(body)) {
for (const spec of group) {
if (!isPlainObject(spec)) continue;
if (spec.type === "namespace" && Array.isArray(spec.tools)) {
const namespace = typeof spec.name === "string" ? spec.name : undefined;
if (namespace === undefined) continue;
for (const child of spec.tools) {
const inner = wireToolInnerName(child);
if (!inner) continue;
spellings.add(namespacedToolName(namespace, inner));
spellings.add(dottedToolName(namespace, inner));
claim(inner, JSON.stringify([namespace, inner]));
}
continue;
}
const name = wireToolInnerName(spec);
if (!name) continue;
spellings.add(name);
claim(name, JSON.stringify([null, name]));
}
}
for (const [inner, owning] of identities) owners.set(inner, owning.size);
return { spellings, ownersByInnerName: owners };
}

/**
* Canonical name a legacy dotted alias should be replayed under, or undefined to leave it alone.
*
* Fail-closed in both directions that matter. A dotted spelling the caller's catalog actually
* declares is a real tool identity, so it is never stripped — rewriting it would change which tool
* the history says was called. A suffix claimed by two declared identities is ambiguous and is
* left as it is. Only two things resolve: a suffix that names exactly one declared tool, and the
* bounded set of code-mode helper spellings, which a code-mode catalog never declares because they
* exist only as nested `tools.*` helpers inside `exec`.
*/
function repairedCallName(name: string, declarations: DeclarationView): string | undefined {
if (isSchemaValidResponsesToolName(name)) return undefined;
if (!name.startsWith(LEGACY_INVENTED_NAMESPACE_PREFIX)) return undefined;
if (declarations.spellings.has(name)) return undefined;
const suffix = name.slice(LEGACY_INVENTED_NAMESPACE_PREFIX.length);
if (!isSchemaValidResponsesToolName(suffix)) return undefined;
const owners = declarations.ownersByInnerName.get(suffix);
if (owners === 1) return suffix;
if (owners !== undefined) return undefined;
return CODE_MODE_HELPER_WIRE_NAMES.has(suffix) ? suffix : undefined;
}

function repairedItem(item: unknown, declarations: DeclarationView): unknown {
if (!isPlainObject(item)) return item;
if (typeof item.type !== "string" || !NAMED_CALL_ITEM_TYPES.has(item.type)) return item;
// An item carrying an explicit namespace is a namespaced identity whose `name` is the inner
// name; it is not the flattened shape this repairs, and its own field is the authority.
if (item.namespace !== undefined) return item;
if (typeof item.name !== "string") return item;
const repaired = repairedCallName(item.name, declarations);
return repaired === undefined ? item : { ...item, name: repaired };
}

/**
* Make an already-damaged conversation replayable.
*
* A `function_call` whose name violates `^[a-zA-Z0-9_-]+$` is not a one-turn failure: Codex stores
* the item, and the upstream then refuses EVERY later request that replays it
* (`Invalid 'input[N].name': ...`) — including the automatic compaction a long task or a side chat
* triggers. The emit-side fix stops new items from being created; without this the conversations
* that already contain one stay permanently unusable, which is the part of #5095 that actually
* hurts.
*
* Only replayed input items are touched. The caller's tool catalog is never rewritten, and a body
* with nothing to repair is returned by reference.
*/
export function repairLegacyDottedToolCallNames(body: unknown): unknown {
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
if (!body.input.some(item =>
isPlainObject(item)
&& typeof item.type === "string"
&& NAMED_CALL_ITEM_TYPES.has(item.type)
&& typeof item.name === "string"
&& item.name.startsWith(LEGACY_INVENTED_NAMESPACE_PREFIX)
)) return body;

const declarations = readDeclarations(body);
let changed = false;
const input = body.input.map(item => {
const repaired = repairedItem(item, declarations);
if (repaired !== item) changed = true;
return repaired;
});
return changed ? { ...body, input } : body;
}
8 changes: 7 additions & 1 deletion src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { parseRequest } from "../../responses/parser";
import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses";
import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state";
import { repairLegacyDottedToolCallNames } from "../../responses/legacy-dotted-tool-name-repair";
import { NoEligiblePolicyCandidateError, routeCompactionModel } from "../../router";
import { evidenceFromBody } from "../../routing/request-evidence";
import {
Expand Down Expand Up @@ -845,7 +846,12 @@ export async function handleResponsesCompact(
// The regular /v1/responses path applies sanitizeReasoningInputContent via the adapter's
// buildRequest, but the compact endpoint forwards directly. Apply the same sanitizer here
// so routed-model reasoning items (reasoning_text content) don't 400 the ChatGPT backend.
const compactBody = sanitizeReasoningInputContent(compactBodyRaw) as typeof compactBodyRaw;
// #5095: the compact endpoint forwards `raw` directly, so it needs the same legacy dotted
// call-name repair the adapter applies. A damaged item here refuses the compaction itself,
// which is the request a long task depends on to keep going.
const compactBody = repairLegacyDottedToolCallNames(
sanitizeReasoningInputContent(compactBodyRaw),
) as typeof compactBodyRaw;
{
const binding = conversationStateBindingFromAuth(authCtx, codexPoolAffinityKey(req.headers));
if (binding) {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
export type { OcxTool, OcxToolChoice } from "./types/tools";
export {
CODE_MODE_EXEC_TOOL_NAME,
CODE_MODE_HELPER_WIRE_NAMES,
dottedToolName,
namespacedToolName,
normalizeDeclaredToolName,
Expand Down
14 changes: 14 additions & 0 deletions src/types/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ const CODE_MODE_HELPER_TOOL_NAMES = [
*/
export const CODE_MODE_EXEC_TOOL_NAME = "exec";

/**
* The nested-helper spellings, as a membership view of the same list.
*
* A code-mode catalog never DECLARES any of them — they exist only as `tools.<helper>(...)` inside
* `exec` — so a recorded call under one of these names can only have come from a provider echoing
* the helper, which is what makes the set usable as a bounded recovery vocabulary for stored
* history (#5095). Kept beside the tuple it is built from so the two can never drift; this is a
* different question from `NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES` below, which also covers `exec`
* itself because declaring THAT name is what turns normalization on.
*/
export const CODE_MODE_HELPER_WIRE_NAMES: ReadonlySet<string> = new Set<string>(
CODE_MODE_HELPER_TOOL_NAMES,
);

/**
* Spellings that may never be MANUFACTURED as a bare alias for a namespaced tool.
*
Expand Down
18 changes: 18 additions & 0 deletions structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,24 @@ resolves to nothing declared stays refused by the #1700 guard, which is the pre-
intended outcome: an invalid name that cannot be resolved must end the turn visibly rather than
reach stored history.

Stopping the emission is only half of it, because Codex stores what it received. A conversation
that already contains one `default.`-prefixed call name is refused on every later turn that
replays it, so the task cannot be compacted or continued at all and no upgrade reaches it.
`repairLegacyDottedToolCallNames` (`src/responses/legacy-dotted-tool-name-repair.ts`) repairs the
replayed item on the way out, in `buildRequest` beside `backfillWebSearchQueries` and again in
`src/server/responses/compact.ts`, which forwards the caller's body directly. It runs before the
canonical-destination split because the reported failure was a side chat on a plain OpenAI model
inheriting history a routed provider had damaged.

What it will resolve is bounded on purpose, and only replayed `input` items are eligible — the
caller's tool catalog is never rewritten. A dotted spelling the catalog itself declares is a real
tool identity and is left alone; a suffix claimed by two declared identities is ambiguous and is
left alone; a suffix that names exactly one declared tool, or one of the code-mode helper spellings
in `CODE_MODE_HELPER_WIRE_NAMES` (which a code-mode catalog never declares), resolves to that name.
There is no rule that strips whatever precedes the first dot: a legitimate tool name may contain
one in another provider's vocabulary, and a replayed item names a call that already happened, which
is the worst place to guess.

Membership enforcement is that flag, `enforceDeclaredToolNames`, and only the `responses` inbound
wire enforces. A routed provider that names a tool the request never declared ends the turn there:
`src/bridge/sse.ts` emits `response.failed` and `src/bridge/response-json.ts` returns a failed
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,7 @@
"responses-inbound-store-default.test.ts": "responses",
"responses-item-id-repair.test.ts": "responses",
"responses-json-events.test.ts": "responses",
"responses-legacy-dotted-tool-name-repair.test.ts": "responses",
"responses-muse-tool-name-alias.test.ts": "responses",
"responses-native-main-refresh.test.ts": "responses",
"responses-opaque-blob-recovery.test.ts": "responses",
Expand Down
Loading
Loading