diff --git a/src/providers/xai-transport.ts b/src/providers/xai-transport.ts index 1e56506ecc..e005abdf4e 100644 --- a/src/providers/xai-transport.ts +++ b/src/providers/xai-transport.ts @@ -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): 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", diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index 658a1c6bab..158e6585b8 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -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; + +export const PROVIDER_EXECUTED_DECLARATION_CALL_TYPES = new Map([ + ["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"], @@ -19,6 +41,7 @@ const NAMELESS_CLIENT_CALL_DISPLAY_NAMES = new Map([ ]); const EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES: ReadonlySet = new Set(); +const EMPTY_PROVIDER_EXECUTED_CALL_TYPES: ReadonlySet = new Set(); /** Supported hosted/private declarations that carry no client-executable wire name. */ const NAMELESS_TOOL_SPEC_TYPES = new Set([ @@ -127,6 +150,53 @@ function addNamelessClientCallTypes(callTypes: Set, specs: unknown): voi } } +function addProviderExecutedCallTypes( + callTypes: Set, + 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 { + const callTypes = new Set(); + 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, + 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 { const callTypes = new Set(); @@ -190,9 +260,14 @@ function undeclaredNameInItem( item: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet, + 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. @@ -214,14 +289,15 @@ export function undeclaredToolCallName( payload: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = 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; } @@ -231,10 +307,11 @@ export function undeclaredToolCallNameInResponse( response: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = 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; @@ -274,6 +351,7 @@ function failedBlocks(name: string, newline: string): readonly string[] { export function createUndeclaredToolCallGuardBlockRewrite( declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, + providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, ): SseBlockRewrite { let tripped = false; return (block: string) => { @@ -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"); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 67005be579..3aed07ef0f 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -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"; @@ -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"; @@ -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(); let request: Awaited>; try { request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget }); @@ -3061,6 +3068,7 @@ async function handleResponsesInner( payload, declaredWireToolNames, declaredNamelessClientCallTypes, + providerExecutedCallTypes, ) !== undefined) { inspectionSawUndeclaredTool = true; } @@ -3074,6 +3082,7 @@ async function handleResponsesInner( response, declaredWireToolNames, declaredNamelessClientCallTypes, + providerExecutedCallTypes, ) !== undefined ) { return; @@ -3725,6 +3734,7 @@ async function handleResponsesInner( ? createUndeclaredToolCallGuardBlockRewrite( declaredWireToolNames, declaredNamelessClientCallTypes, + providerExecutedCallTypes, ) : undefined, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); @@ -3946,6 +3956,7 @@ async function handleResponsesInner( JSON.parse(clientJson), declaredWireToolNames, declaredNamelessClientCallTypes, + providerExecutedCallTypes, ); } catch { return undefined; diff --git a/tests/responses-undeclared-tool-guard.test.ts b/tests/responses-undeclared-tool-guard.test.ts index f92b9fd083..1e93fb714d 100644 --- a/tests/responses-undeclared-tool-guard.test.ts +++ b/tests/responses-undeclared-tool-guard.test.ts @@ -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"; @@ -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(); + 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(), + )).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 { + 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> }; + 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"'); + }); +}); diff --git a/tests/xai-transport.test.ts b/tests/xai-transport.test.ts index 79e6cbf5e1..38cb29142f 100644 --- a/tests/xai-transport.test.ts +++ b/tests/xai-transport.test.ts @@ -3,6 +3,7 @@ import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; import { parseRequest } from "../src/responses/parser"; import { buildModelsRequest } from "../src/oauth"; import { + isXaiResponsesDestination, resolveProviderTransport, deriveXaiConvId, XAI_CONV_ID_HEADER, @@ -51,6 +52,27 @@ function parsed(): OcxParsedRequest { }; } +describe("xAI Responses destination detection", () => { + test.each([ + "https://api.x.ai/v1", + "https://api.x.ai:443/v1", + XAI_GROK_CLI_BASE_URL, + "https://CLI-CHAT-PROXY.GROK.COM:443/v1", + ])("accepts the exact xAI HTTPS destination %s", baseUrl => { + expect(isXaiResponsesDestination({ baseUrl })).toBe(true); + }); + + test.each([ + "http://api.x.ai/v1", + "https://api.x.ai:444/v1", + "https://api.x.ai.evil.test/v1", + "https://cli-chat-proxy.grok.com.evil.test/v1", + "not a URL", + ])("rejects a non-xAI or malformed destination %s", baseUrl => { + expect(isXaiResponsesDestination({ baseUrl })).toBe(false); + }); +}); + describe("xAI auth-mode transport selection", () => { test("OAuth selects the Grok CLI subscription transport and required headers", () => { const effective = resolveProviderTransport("xai", provider("oauth"));