Skip to content
Open
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
21 changes: 21 additions & 0 deletions src/providers/xai-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,27 @@ import { resolveGithubCopilotTransport } from "./github-copilot-transport";

export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";

/** The two hosts that serve xAI's Responses API: the public API and the Grok CLI proxy. */
const XAI_RESPONSES_HOSTS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]);

/**
* True when this provider's Responses traffic terminates at xAI itself.
*
* Probed 2026-08-22 one field per request: the two hosts accept and refuse exactly the same
* web_search fields, so they are one dialect rather than two. Matching is exact-host over
* https, which keeps lookalikes (`api.x.ai.evil.test`) and nonstandard ports out.
*/
export function isXaiResponsesDestination(provider: Pick<OcxProviderConfig, "baseUrl">): boolean {
try {
const url = new URL(provider.baseUrl);
return url.protocol === "https:"
&& XAI_RESPONSES_HOSTS.has(url.hostname.toLowerCase())
&& (url.port === "" || url.port === "443");
} catch {
return false;
}
}

export const XAI_GROK_COMPATIBILITY = {
version: "0.2.93",
userAgent: "opencodex-grok/0.2.93",
Expand Down
86 changes: 82 additions & 4 deletions src/server/responses-undeclared-tool-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ import { 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"]);

/**
* Hosted declarations whose response items the PROVIDER executes, keyed by the request
* declaration type. These need no client answer, so their names are deliberately absent from
* the request catalog and must not be read as an undeclared client tool.
*
* xAI surfaces hosted `x_search` as `custom_tool_call`. Probed 2026-08-23 against the OAuth CLI
* destination: its hosted calls use an `xs_call-` call-id prefix. Observed call names were
* `x_keyword_search`, `x_semantic_search`, and `x_user_search` — three literals for one tool,
* which is why authorization keys on the declaration, item type, and call-id prefix, never on
* the name.
*/
export type ProviderExecutedCallType = Readonly<{
itemType: string;
callIdPrefix: string;
}>;

type ProviderExecutedCallTypes = ReadonlySet<ProviderExecutedCallType>;

export const PROVIDER_EXECUTED_DECLARATION_CALL_TYPES = new Map<string, ProviderExecutedCallType>([
["x_search", { itemType: "custom_tool_call", callIdPrefix: "xs_call-" }],
]);

/** Nameless declaration kinds whose response items still require client execution. */
const NAMELESS_CLIENT_DECLARATION_CALL_TYPES = new Map([
["local_shell", "local_shell_call"],
Expand All @@ -19,6 +41,7 @@ const NAMELESS_CLIENT_CALL_DISPLAY_NAMES = new Map([
]);

const EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES: ReadonlySet<string> = new Set();
const EMPTY_PROVIDER_EXECUTED_CALL_TYPES: ReadonlySet<ProviderExecutedCallType> = new Set();

/** Supported hosted/private declarations that carry no client-executable wire name. */
const NAMELESS_TOOL_SPEC_TYPES = new Set([
Expand Down Expand Up @@ -127,6 +150,53 @@ function addNamelessClientCallTypes(callTypes: Set<string>, specs: unknown): voi
}
}

function addProviderExecutedCallTypes(
callTypes: Set<ProviderExecutedCallType>,
specs: unknown,
): void {
if (!Array.isArray(specs)) return;
for (const spec of specs) {
if (!isPlainObject(spec) || typeof spec.type !== "string") continue;
const callType = PROVIDER_EXECUTED_DECLARATION_CALL_TYPES.get(spec.type);
if (callType) callTypes.add(callType);
}
}

/**
* Item types this turn's hosted declarations authorize the PROVIDER to emit unnamed.
*
* Caller must gate this on the destination actually being that provider; a declaration alone
* is not authority, or any upstream could claim a hosted shape it never serves.
*/
export function collectProviderExecutedCallTypes(body: unknown): Set<ProviderExecutedCallType> {
const callTypes = new Set<ProviderExecutedCallType>();
if (!isPlainObject(body)) return callTypes;
addProviderExecutedCallTypes(callTypes, body.tools);
if (Array.isArray(body.input)) {
for (const item of body.input) {
if (
isPlainObject(item)
&& (item.type === "additional_tools" || item.type === "tool_search_output")
) addProviderExecutedCallTypes(callTypes, item.tools);
}
}
return callTypes;
}

function isAuthorizedProviderExecutedCall(
item: Record<string, unknown>,
callTypes: ProviderExecutedCallTypes,
): boolean {
if (typeof item.call_id !== "string") return false;
for (const callType of callTypes) {
if (
item.type === callType.itemType
&& item.call_id.startsWith(callType.callIdPrefix)
) return true;
}
return false;
}

/** Nameless client-call item types authorized by supported request tool declarations. */
export function collectDeclaredNamelessClientCallTypes(body: unknown): Set<string> {
const callTypes = new Set<string>();
Expand Down Expand Up @@ -190,9 +260,14 @@ function undeclaredNameInItem(
item: unknown,
declared: ReadonlySet<string>,
declaredNamelessClientCallTypes: ReadonlySet<string>,
providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES,
): string | undefined {
if (!isPlainObject(item)) return undefined;
if (typeof item.type !== "string") return undefined;
// The provider executes this exact measured shape itself, so there is no client name to
// authorize. The caller supplies these signatures only for the matching destination and
// declarations; the item must additionally carry the hosted call-id prefix.
if (isAuthorizedProviderExecutedCall(item, providerExecutedCallTypes)) return undefined;
const namelessDisplayName = NAMELESS_CLIENT_CALL_DISPLAY_NAMES.get(item.type);
if (namelessDisplayName !== undefined) {
// Only Codex's explicit `execution: "client"` form delegates tool search to the client.
Expand All @@ -214,14 +289,15 @@ export function undeclaredToolCallName(
payload: unknown,
declared: ReadonlySet<string>,
declaredNamelessClientCallTypes: ReadonlySet<string> = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES,
providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES,
): string | undefined {
if (!isPlainObject(payload)) return undefined;
if (payload.type === "response.output_item.added" || payload.type === "response.output_item.done") {
return undeclaredNameInItem(payload.item, declared, declaredNamelessClientCallTypes);
return undeclaredNameInItem(payload.item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes);
}
// Sparse gateways skip incremental items and only ever ship the terminal snapshot.
if (payload.type === "response.completed" || payload.type === "response.incomplete") {
return undeclaredToolCallNameInResponse(payload.response, declared, declaredNamelessClientCallTypes);
return undeclaredToolCallNameInResponse(payload.response, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes);
}
return undefined;
}
Expand All @@ -231,10 +307,11 @@ export function undeclaredToolCallNameInResponse(
response: unknown,
declared: ReadonlySet<string>,
declaredNamelessClientCallTypes: ReadonlySet<string> = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES,
providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES,
): string | undefined {
if (!isPlainObject(response) || !Array.isArray(response.output)) return undefined;
for (const item of response.output) {
const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes);
const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes);
if (name !== undefined) return name;
}
return undefined;
Expand Down Expand Up @@ -274,6 +351,7 @@ function failedBlocks(name: string, newline: string): readonly string[] {
export function createUndeclaredToolCallGuardBlockRewrite(
declared: ReadonlySet<string>,
declaredNamelessClientCallTypes: ReadonlySet<string> = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES,
providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES,
): SseBlockRewrite {
let tripped = false;
return (block: string) => {
Expand All @@ -286,7 +364,7 @@ export function createUndeclaredToolCallGuardBlockRewrite(
} catch {
return [block];
}
const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes);
const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes);
if (name === undefined) return [block];
tripped = true;
return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n");
Expand Down
13 changes: 12 additions & 1 deletion src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ import {
rotateProviderTransportOn429,
} from "../../providers/key-failover";
import { shouldAttemptImageTierRetry } from "../image-retry";
import { resolveProviderTransport } from "../../providers/xai-transport";
import { isXaiResponsesDestination, resolveProviderTransport } from "../../providers/xai-transport";
import type { WsData } from "../ws-bridge";
import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle";
import { redactSecretString, sanitizeLogMetadataString } from "../../lib/redact";
Expand Down Expand Up @@ -308,12 +308,14 @@ import {
import {
collectDeclaredNamelessClientCallTypes,
collectDeclaredWireToolNames,
collectProviderExecutedCallTypes,
createUndeclaredToolCallGuardBlockRewrite,
currentTurnWireToolCatalogBody,
hasExplicitWireToolCatalog,
undeclaredToolCallMessage,
undeclaredToolCallName,
undeclaredToolCallNameInResponse,
type ProviderExecutedCallType,
} from "../responses-undeclared-tool-guard";
import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair";
import { responsesJsonToSseStream } from "../responses-json-events";
Expand Down Expand Up @@ -2942,6 +2944,11 @@ async function handleResponsesInner(
const clientDeclaredNamelessCallTypes = collectDeclaredNamelessClientCallTypes(
clientToolAuthorizationBody,
);
// Hosted calls the PROVIDER runs itself. Gated on the destination actually being xAI, so a
// declaration alone cannot buy the exemption on some other upstream that never serves it.
const providerExecutedCallTypes = isXaiResponsesDestination(route.provider)
? collectProviderExecutedCallTypes(clientToolAuthorizationBody)
: new Set<ProviderExecutedCallType>();
let request: Awaited<ReturnType<typeof adapter.buildRequest>>;
try {
request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget });
Expand Down Expand Up @@ -3061,6 +3068,7 @@ async function handleResponsesInner(
payload,
declaredWireToolNames,
declaredNamelessClientCallTypes,
providerExecutedCallTypes,
) !== undefined) {
inspectionSawUndeclaredTool = true;
}
Expand All @@ -3074,6 +3082,7 @@ async function handleResponsesInner(
response,
declaredWireToolNames,
declaredNamelessClientCallTypes,
providerExecutedCallTypes,
) !== undefined
) {
return;
Expand Down Expand Up @@ -3725,6 +3734,7 @@ async function handleResponsesInner(
? createUndeclaredToolCallGuardBlockRewrite(
declaredWireToolNames,
declaredNamelessClientCallTypes,
providerExecutedCallTypes,
)
: undefined,
].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
Expand Down Expand Up @@ -3946,6 +3956,7 @@ async function handleResponsesInner(
JSON.parse(clientJson),
declaredWireToolNames,
declaredNamelessClientCallTypes,
providerExecutedCallTypes,
);
} catch {
return undefined;
Expand Down
128 changes: 128 additions & 0 deletions tests/responses-undeclared-tool-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import { describe, expect, test } from "bun:test";
import {
collectDeclaredNamelessClientCallTypes,
collectDeclaredWireToolNames,
collectProviderExecutedCallTypes,
createUndeclaredToolCallGuardBlockRewrite,
currentTurnWireToolCatalogBody,
hasExplicitWireToolCatalog,
undeclaredToolCallNameInResponse,
UNDECLARED_TOOL_CALL_ERROR_CODE,
type ProviderExecutedCallType,
} from "../src/server/responses-undeclared-tool-guard";
import { relaySseWithBlockRewrite } from "../src/server/sse-payload-rewrite";
import { handleResponses } from "../src/server/responses";
Expand Down Expand Up @@ -1248,3 +1250,129 @@ describe("undeclaredToolCallNameInResponse", () => {
)).toBeUndefined();
});
});

/**
* xAI runs hosted `x_search` itself and reports the activity as a `custom_tool_call` whose name
* is absent from the request catalog. Probed 2026-08-23 against the OAuth CLI destination: the
* provider's hosted calls carry an `xs_call-` call-id prefix. Observed names were
* `x_keyword_search`, `x_semantic_search`, and `x_user_search`, so authorization keys on the
* declaration, item type, and call-id prefix, never on the name.
*/
describe("provider-executed hosted calls", () => {
const declared = new Set(["shell"]);
const nameless = new Set<string>();
const xSearchAuthorized = collectProviderExecutedCallTypes({
tools: [{ type: "function", name: "shell" }, { type: "x_search" }],
});

function hostedCall(name: string) {
return { output: [{ type: "custom_tool_call", name, call_id: "xs_call-1" }] };
}

test("authorizes the provider's hosted call under any of its observed names", () => {
expect(collectProviderExecutedCallTypes({ tools: [{ type: "x_search" }] }))
.toEqual(new Set([{ itemType: "custom_tool_call", callIdPrefix: "xs_call-" }]));
for (const name of ["x_keyword_search", "x_semantic_search", "x_user_search"]) {
expect(undeclaredToolCallNameInResponse(
hostedCall(name), declared, nameless, xSearchAuthorized,
)).toBeUndefined();
}
});

test("without the x_search declaration the same item is still refused", () => {
const noHostedDeclaration = collectProviderExecutedCallTypes({
tools: [{ type: "function", name: "shell" }],
});
expect(noHostedDeclaration.size).toBe(0);
expect(undeclaredToolCallNameInResponse(
hostedCall("x_keyword_search"), declared, nameless, noHostedDeclaration,
)).toBe("x_keyword_search");
});

test("the caller gates on destination: an empty authorization set refuses the same item", () => {
// core.ts passes an empty set unless the route actually terminates at xAI, so a declaration
// alone cannot buy the exemption on an upstream that never serves the hosted tool.
expect(undeclaredToolCallNameInResponse(
hostedCall("x_keyword_search"), declared, nameless, new Set<ProviderExecutedCallType>(),
)).toBe("x_keyword_search");
});

test("#1700 still holds: an undeclared client tool is refused inside an authorized turn", () => {
expect(undeclaredToolCallNameInResponse(
{ output: [{ type: "custom_tool_call", name: "apply_patch", call_id: "call_patch" }] },
declared, nameless, xSearchAuthorized,
)).toBe("apply_patch");
});

test("a declared client tool is unaffected", () => {
expect(undeclaredToolCallNameInResponse(
{ output: [{ type: "function_call", name: "shell", call_id: "c1" }] },
declared, nameless, xSearchAuthorized,
)).toBeUndefined();
});
});

describe("xAI hosted-call authorization through handleResponses", () => {
const hostedCall = {
type: "custom_tool_call",
id: "ctc_search",
call_id: "xs_call-1",
name: "x_keyword_search",
input: "{}",
status: "completed",
};

async function post(baseUrl: string): Promise<Response> {
const config = {
port: 0,
defaultProvider: "fixture",
providers: {
fixture: {
adapter: "openai-responses",
baseUrl,
authMode: "key",
apiKey: "fixture-key",
},
},
} as OcxConfig;
const savedFetch = globalThis.fetch;
globalThis.fetch = (async () => Response.json({
id: "resp_search",
status: "completed",
output: [hostedCall],
})) as typeof fetch;
try {
return await handleResponses(new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "fixture/grok-4.6",
stream: false,
input: [{ role: "user", content: [{ type: "input_text", text: "search" }] }],
tools: [
{ type: "function", name: "shell", parameters: { type: "object" } },
{ type: "x_search" },
],
}),
}), config, { model: "", provider: "" });
} finally {
globalThis.fetch = savedFetch;
}
}

test("accepts the measured xs_call shape for an exact xAI destination", async () => {
const response = await post("https://api.x.ai/v1");

expect(response.status).toBe(200);
const body = await response.json() as { output: Array<Record<string, unknown>> };
expect(body.output[0]).toMatchObject(hostedCall);
});

test("rejects the identical item for a lookalike destination", async () => {
const response = await post("https://api.x.ai.evil.test/v1");

expect(response.status).toBe(502);
const body = await response.json() as { error: { message: string } };
expect(body.error.message).toContain('undeclared client tool "x_keyword_search"');
});
});
Loading
Loading