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
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,7 @@
"responses-show-thinking-summary.test.ts": "responses",
"responses-snapshot-repair-server.test.ts": "responses",
"responses-snapshot-repair.test.ts": "responses",
"responses-sparse-terminal-tool-scope.test.ts": "responses",
"responses-spill-shutdown-clock.test.ts": "responses",
"responses-state-write-amplification.test.ts": "responses",
"responses-state.test.ts": "responses",
Expand Down Expand Up @@ -1555,6 +1556,7 @@
"ws-upstream.test.ts": "responses",
"ws-upstream-socks5.test.ts": "responses",
"xai-client.test.ts": "images",
"xai-empty-catalog-tool-choice.test.ts": "providers/xai",
"xai-oauth-retry.test.ts": "providers/xai",
"xai-refresh-lock.test.ts": "providers/xai",
"xai-responses-adjacency.test.ts": "providers/xai",
Expand Down
17 changes: 16 additions & 1 deletion src/adapters/xai-web-search.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { OcxProviderConfig } from "../types";
import { debugProviderDiagnostic } from "../lib/debug";
import { isXaiResponsesDestination } from "../providers/xai-transport";

const CODEX_WEB_SEARCH_TOOL = "web_search";
Expand Down Expand Up @@ -192,7 +193,21 @@ export function normalizeXaiResponsesWebSearch(
if (inputChanged) next = { ...next, input };
}

return normalizeToolChoice(next);
const normalized = normalizeToolChoice(next);
const choice = normalized.tool_choice;
if ((choice === "auto" || choice === "none") && !hasAnyDeclaredTool(normalized)) {
debugProviderDiagnostic("xai", "tool-choice-omitted", { choice });
const { tool_choice: _toolChoice, ...rest } = normalized;
// `auto` selects from the catalog, so a catalog with nothing in it makes it meaningless and
// the omission says nothing the request did not already say. `none` is the opposite: it is a
// prohibition, and on a request whose catalog this normalizer just emptied it is the only
// place the turn's client-call boundary is written down. Downstream repair reads that
// boundary off the final outbound body, so omitting the word alone would hand back a call the
// caller ruled out. Restate it as the explicit empty catalog, which carries the same deny-all
// and which this destination already receives whenever a caller sends one itself.
return choice === "none" && !Array.isArray(rest.tools) ? { ...rest, tools: [] } : rest;
}
return normalized;
}

function isLiveWebSearchTool(tool: unknown): boolean {
Expand Down
115 changes: 105 additions & 10 deletions src/server/grok-responses-snapshot-repair.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/** Strict terminal reconstruction selected by the Grok compatibility marker. */
import type { TranslatorBudget } from "../lib/translator-budget";
import { MAX_COMPLETED_OUTPUT_ITEMS, MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES } from "./relay";
import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
import { isPlainObject, jsonBlock, type RetainedOutputItem } from "./responses-snapshot-codec";
import { requestToolScope, type RequestToolScope } from "./responses-request-tool-scope";

type SparseTerminalOpenItem = {
type: string;
Expand All @@ -16,6 +17,20 @@ type SparseTerminalCompletedItem = RetainedOutputItem & {

const MAX_GROK_OPEN_ITEM_IDENTITY_BYTES = MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES;

/** Terminal event this repair publishes when it refused to reconstruct a call faithfully. */
export const GROK_REFUSED_TERMINAL_EVENT_TYPE = "response.incomplete";

/** `incomplete_details.reason` carried by that terminal. */
export const GROK_FORBIDDEN_TOOL_CALL_REASON = "forbidden_tool_call";

/** An upstream-supplied name reaches the terminal message; keep it bounded. */
const MAX_REPORTED_TOOL_NAME_CHARS = 100;

export function forbiddenToolCallMessage(name: string): string {
return `routed provider called "${name.slice(0, MAX_REPORTED_TOOL_NAME_CHARS)}", `
+ "which this request's tool selection excludes; the reconstructed output omits that call";
}

const GROK_TERMINAL_OUTPUT_ITEM_TYPES = new Set([
"message",
"reasoning",
Expand Down Expand Up @@ -167,6 +182,45 @@ function plausibleGrokOpenItem(
};
}

/**
* Publish the refusal on the terminal itself rather than as a silent omission.
*
* The ordinary reconstruction replaces only the data payload's `output`, so its event name still
* describes the payload. A refusal does not: the turn no longer completed the way the upstream
* said it did, so the event line moves with the status instead of leaving a client to read a
* clean finish off an unchanged `event: response.completed`.
*/
function refusedTerminalBlock(
block: string,
parsed: Record<string, unknown>,
response: Record<string, unknown>,
output: readonly Record<string, unknown>[],
refusedName: string | undefined,
): string {
const payload = JSON.stringify({
...parsed,
type: GROK_REFUSED_TERMINAL_EVENT_TYPE,
response: {
...response,
status: "incomplete",
output,
incomplete_details: {
reason: GROK_FORBIDDEN_TOOL_CALL_REASON,
...(refusedName === undefined ? {} : { message: forbiddenToolCallMessage(refusedName) }),
},
},
});
const rewritten = replaceSseDataPayload(block, payload);
const newline = block.includes("\r\n") ? "\r\n" : "\n";
let eventRewritten = false;
const lines = rewritten.split(/\r?\n/).map(line => {
if (eventRewritten || !line.startsWith("event:")) return line;
eventRewritten = true;
return `event: ${GROK_REFUSED_TERMINAL_EVENT_TYPE}`;
});
return lines.join(newline);
}

/**
* Narrow client repair for grok-build's Responses consumer.
*
Expand All @@ -176,12 +230,22 @@ function plausibleGrokOpenItem(
* empty output. Reconstruct only from real, unique, contiguous, bounded done
* events whose raw semantics are already valid. Any ambiguity stays byte-level
* fail-closed; the provider-opt-in snapshot repair above is unchanged.
*
* The terminal this publishes is one the upstream never sent, so it carries only what the final
* outbound request still authorized. A client call outside that request's tool selection is left
* out and the terminal says so explicitly. The declaration guard downstream answers the other
* half of the question — whether a name was declared at all — and keeps policing the raw stream,
* which this rewrite never edits.
*/
export function createGrokResponsesSparseTerminalBlockRewrite(
budget?: TranslatorBudget,
outboundRequestBody?: unknown,
): SseBlockRewrite {
const toolScope: RequestToolScope | undefined = requestToolScope(outboundRequestBody);
const openItems = new Map<number, SparseTerminalOpenItem>();
const completedItems = new Map<number, SparseTerminalCompletedItem>();
const withheldIndices = new Set<number>();
let withheldToolName: string | undefined;
let aggregateItemBytes = 0;
let aggregateOpenItemBytes = 0;
let tainted = false;
Expand All @@ -194,6 +258,8 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
}
openItems.clear();
completedItems.clear();
withheldIndices.clear();
withheldToolName = undefined;
aggregateItemBytes = 0;
aggregateOpenItemBytes = 0;
hasVisibleOutput = false;
Expand All @@ -217,7 +283,7 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
if (tainted) return;
const sourceBytes = Buffer.byteLength(JSON.stringify(item), "utf8");
if (sourceBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES
|| completedItems.size >= MAX_COMPLETED_OUTPUT_ITEMS
|| completedItems.size + withheldIndices.size >= MAX_COMPLETED_OUTPUT_ITEMS
|| aggregateItemBytes + sourceBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES) {
taintAndRelease();
return;
Expand All @@ -228,6 +294,24 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
hasVisibleOutput = hasVisibleOutput || visibleToGrok;
};

/**
* Record the position of a call this request forbade without retaining the item.
*
* Only the offending item is dropped. Tainting here instead would discard the assistant text
* that arrived in the same turn and leave the client the empty terminal this repair exists to
* fix, which punishes the caller for the provider's overreach. The index is kept so the
* contiguity proof below still covers the whole output.
*/
const withholdForbiddenCall = (index: number, name: string): void => {
if (tainted) return;
if (completedItems.size + withheldIndices.size >= MAX_COMPLETED_OUTPUT_ITEMS) {
taintAndRelease();
return;
}
withheldIndices.add(index);
withheldToolName ??= name.slice(0, MAX_REPORTED_TOOL_NAME_CHARS);
};

const closeOpenItem = (index: number): void => {
const open = openItems.get(index);
if (!open) return;
Expand Down Expand Up @@ -265,6 +349,7 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
const open = isPlainObject(parsed.item) ? plausibleGrokOpenItem(parsed.item) : null;
if (outputIndex === undefined || !open
|| openItems.has(outputIndex) || completedItems.has(outputIndex)
|| withheldIndices.has(outputIndex)
|| openItems.size >= MAX_COMPLETED_OUTPUT_ITEMS) {
taintAndRelease();
} else if (!tainted) {
Expand All @@ -284,7 +369,8 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
if (type === "response.output_item.done") {
const item = isPlainObject(parsed.item) ? parsed.item : null;
const proof = item ? trustedGrokCompletedItem(item) : null;
if (outputIndex === undefined || !proof || completedItems.has(outputIndex)) {
if (outputIndex === undefined || !proof
|| completedItems.has(outputIndex) || withheldIndices.has(outputIndex)) {
taintAndRelease();
return [block];
}
Expand All @@ -295,7 +381,12 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
return [block];
}
closeOpenItem(outputIndex);
retainCompletedItem(outputIndex, item!, proof.visibleToGrok);
const forbidden = toolScope?.forbiddenClientToolCallName(item!);
if (forbidden === undefined) {
retainCompletedItem(outputIndex, item!, proof.visibleToGrok);
} else {
withholdForbiddenCall(outputIndex, forbidden);
}
return [block];
}

Expand All @@ -312,14 +403,18 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
const outputIsAuthoritative = Array.isArray(output) && output.length > 0;
const outputIsSparse = !("output" in response)
|| (Array.isArray(output) && output.length === 0);
// A withheld call is a reason to publish on its own: the refusal has to reach the client
// even when nothing visible survived it, or the turn ends as an ordinary empty finish.
const refused = withheldIndices.size > 0;
if (!outputIsAuthoritative && outputIsSparse && terminalStatusConsistent
&& completedItems.size > 0 && openItems.size === 0 && hasVisibleOutput) {
&& openItems.size === 0 && (hasVisibleOutput || refused)) {
const ordered = [...completedItems.entries()].sort(([left], [right]) => left - right);
if (ordered.every(([index], position) => index === position)) {
out = jsonBlock({
...parsed,
response: { ...response, output: ordered.map(([, retained]) => retained.item) },
});
const positions = [...completedItems.keys(), ...withheldIndices].sort((left, right) => left - right);
if (positions.length > 0 && positions.every((index, position) => index === position)) {
const rebuilt = ordered.map(([, retained]) => retained.item);
out = refused
? refusedTerminalBlock(block, parsed, response, rebuilt, withheldToolName)
: jsonBlock({ ...parsed, response: { ...response, output: rebuilt } });
}
}
}
Expand Down
121 changes: 121 additions & 0 deletions src/server/responses-request-tool-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* The tool selection a Responses request actually authorized, read from the final outbound body.
*
* The undeclared-tool guard answers whether a NAME was declared. This answers a different
* question: whether this request still permits a client tool call at all, and which names it
* permits. `tool_choice: "none"`, a forced selector and an `allowed_tools` allow-list each narrow
* the catalog without removing a declaration, so a name can be declared and forbidden at the same
* time — and a repair that rebuilds a terminal from collected items would otherwise hand the
* client a call the caller ruled out.
*
* The scope is read from the OUTBOUND body, after every removal, rename and translation, because
* that is the request the destination answered. A catalog that ends up empty there authorizes no
* client call whatever the selector still says.
*/
import { dottedToolName, namespacedToolName } from "../types";
import {
CLIENT_EXECUTED_CALL_TYPES,
collectDeclaredWireToolNames,
hasExplicitWireToolCatalog,
} from "./responses-undeclared-tool-guard";
import { isPlainObject } from "./responses-snapshot-codec";

/** Every spelling one call item can be named by, so a selector match is not defeated by flattening. */
function callNameSpellings(item: Record<string, unknown>): readonly string[] {
const name = typeof item.name === "string" ? item.name : "";
if (name.length === 0) return [];
const namespace = typeof item.namespace === "string" && item.namespace.length > 0
? item.namespace
: undefined;
if (!namespace) return [name];
return [name, namespacedToolName(namespace, name), dottedToolName(namespace, 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.

P1 Badge Match qualified selectors by full tool identity

With two declared tools such as docs.search and admin.search, forcing {type: "function", namespace: "docs", name: "search"} also authorizes an emitted {namespace: "admin", name: "search"} because both callNameSpellings and selectorNameSpellings include the bare spelling search. The declaration guard permits both names because both tools are declared, so the sparse repair can reconstruct the explicitly unselected admin call. Qualified identities should match by their canonical namespace/name, with bare aliases resolved only when the catalog proves them unambiguous.

Useful? React with 👍 / 👎.

}

/** The names one `tool_choice` entry selects; empty when the entry names no client tool. */
function selectorNameSpellings(selector: unknown): readonly string[] {
if (!isPlainObject(selector)) return [];
const name = typeof selector.name === "string" ? selector.name : "";
if (name.length === 0) return [];
const namespace = typeof selector.namespace === "string" && selector.namespace.length > 0
? selector.namespace
: undefined;
if (!namespace) return [name];
return [name, namespacedToolName(namespace, name), dottedToolName(namespace, name)];
}

type ToolSelection =
| { readonly kind: "unrestricted" }
| { readonly kind: "deny_all" }
| { readonly kind: "allow"; readonly names: ReadonlySet<string> };

const UNRESTRICTED: ToolSelection = { kind: "unrestricted" };

/**
* Read the selector only where it states a client-call boundary.
*
* `auto`, `required` and an absent selector restrict nothing. A hosted selector
* (`{ type: "web_search" }`) forces a tool the PROVIDER runs and does not describe the client
* calls this turn may contain, so it is left alone rather than read as a deny-all: a false
* refusal would drop a call the caller could have executed.
*/
function toolSelection(body: Record<string, unknown>): ToolSelection {
const choice = body.tool_choice;
if (choice === "none") return { kind: "deny_all" };
if (!isPlainObject(choice)) return UNRESTRICTED;
if (choice.type === "allowed_tools") {
if (!Array.isArray(choice.tools)) return UNRESTRICTED;
const names = new Set<string>();
for (const entry of choice.tools) {
for (const spelling of selectorNameSpellings(entry)) names.add(spelling);
}
// An allow-list carrying no client tool — emptied by normalization, or hosted entries only —
// still bounds this turn: it allows no client call.
return { kind: "allow", names };
}
if (choice.type === "function" || choice.type === "custom") {
const names = new Set(selectorNameSpellings(choice));
return names.size > 0 ? { kind: "allow", names } : UNRESTRICTED;
}
return UNRESTRICTED;

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 Reject client calls under forced hosted choices

When tool_choice forces a hosted tool such as {type: "web_search"} and the catalog also contains a client function, returning UNRESTRICTED allows a sparse terminal to reconstruct that function call even though the request selected web search specifically. This also disagrees with the allowed_tools branch immediately above, where a hosted-only selection correctly denies every client call. Treat a forced non-client selector as deny-all for CLIENT_EXECUTED_CALL_TYPES rather than as unrestricted.

Useful? React with 👍 / 👎.

}

export type RequestToolScope = {
/**
* The name a client call is refused under, or undefined when this request permits it.
* A nameless call type is not answered here: only the declaration guard knows those.
*/
forbiddenClientToolCallName(item: Record<string, unknown>): string | undefined;
};

/**
* The client-call boundary this request states, or undefined when it states none.
*
* Returning undefined for an unrestricted request keeps every ordinary turn on the path it
* already had: a caller that selected nothing gets no new refusal.
*/
export function requestToolScope(body: unknown): RequestToolScope | undefined {
if (!isPlainObject(body)) return undefined;
const selection = toolSelection(body);
// A readable catalog that declares no client-executable name is authoritative, exactly as it is
// for the declaration guard: an explicit empty list denies every client call. An absent catalog
// says nothing — a passthrough request may omit `tools` and still receive a call the client
// understands.
const catalogDeniesClientCalls = hasExplicitWireToolCatalog(body)
&& collectDeclaredWireToolNames(body).size === 0;
if (selection.kind === "unrestricted" && !catalogDeniesClientCalls) return undefined;
return {
forbiddenClientToolCallName(item: Record<string, unknown>): string | undefined {
if (typeof item.type !== "string" || !CLIENT_EXECUTED_CALL_TYPES.has(item.type)) {
return undefined;
}
const spellings = callNameSpellings(item);
const reported = spellings[0];
if (reported === undefined) return undefined;
if (catalogDeniesClientCalls || selection.kind === "deny_all") return reported;
if (selection.kind === "allow") {
return spellings.some(spelling => selection.names.has(spelling)) ? undefined : reported;
}
return undefined;
},
};
}
5 changes: 4 additions & 1 deletion src/server/responses-undeclared-tool-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import {
import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";

/** Item types the client executes through a request-declared wire name. */
const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]);
export const CLIENT_EXECUTED_CALL_TYPES: ReadonlySet<string> = new Set([
"function_call",
"custom_tool_call",
]);
/** Codex groups ordinary top-level tools here; unlike an MCP namespace, it has no wire prefix. */
const BUILTIN_FUNCTIONS_NAMESPACE = "functions";

Expand Down
2 changes: 1 addition & 1 deletion src/server/responses/passthrough-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ export async function deliverPassthroughResponse(
? createGrokResponsesTimestampBlockRewrite()
: undefined,
grokClientCompatibilityEnabled
? createGrokResponsesSparseTerminalBlockRewrite(translatorBudget)
? createGrokResponsesSparseTerminalBlockRewrite(translatorBudget, nativeExchange.outboundRequestBody)

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 Keep refused sparse turns out of replay state

When a declared tool is excluded by tool_choice (for example, declared apply_patch with tool_choice: "none") and the provider sends its output_item.done followed by a sparse completed terminal, this rewrite emits an incomplete terminal without the call, but the parallel raw-stream inspector still reconstructs that call and passes it to rememberPassthroughResponseChecked. Because that persistence guard checks only whether the tool was declared, the refused turn enters previous_response_id replay state as completed and later continuations receive a tool call the client terminal never contained or answered. Propagate the selection refusal to inspection/persistence or apply the same scope before storing the response.

Useful? React with 👍 / 👎.

: undefined,
snapshotRepairEnabled
? createResponsesSnapshotBlockRewrite(nativeExchange.outboundRequestBody, translatorBudget)
Expand Down
Loading
Loading