From 790a581cf3c7f6716897a67c9f587356ae781525 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Wed, 26 Aug 2026 22:58:41 -0700 Subject: [PATCH] fix(xai): normalize web search on the Grok CLI proxy, not just the public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalizeXaiResponsesWebSearch` was gated on `isXaiPublicApi`, so it ran for api.x.ai and skipped cli-chat-proxy.grok.com — the OAuth lane. That gate contradicted the evidence already in this file: the 2026-08-22 probe recorded in `normalizeToolGroup` and in `isXaiResponsesDestination` had found the two hosts to be one dialect. Re-probed 2026-08-27 against the CLI proxy, one field per request: `web_search_preview` -> 422 `unknown variant`, `external_web_access` -> 400 on every value including `true`, `search_context_size` -> 400, while `user_location` and `search_content_types` -> 200. Identical to the public API. Two consequences, both reproduced at the adapter before the fix. A cached/index-only declaration became a LIVE web search. This normalizer omits the whole tool when `external_web_access` is not `true`, precisely so dropping the flag cannot silently widen network access. On the CLI proxy the normalizer never ran, and the downstream capability strip only DELETES that flag and keeps the tool — so a caller asking for no network reached xAI with an ordinary `{type:"web_search"}`. That is the widening this file exists to refuse, on the lane OAuth users actually take. A legacy `web_search_preview` reached the proxy verbatim and 422'd the entire turn, because the type conversion also lives behind the same gate. Both are fixed by scoping the gate to `isXaiResponsesDestination`, which covers both hosts, and `isXaiPublicApi` is removed with its last caller. `responses-routed-web-search-fields` had a case asserting that an unclassified row leaks the fatal fields at the CLI adapter; the host-scoped normalizer now strips them regardless of classification, which was already true for api.x.ai, so that expectation is corrected rather than adjusted. Reverting the gate turns the new coverage red. Co-Authored-By: Claude Opus 5 --- src/adapters/xai-web-search.ts | 24 +++++----- ...responses-routed-web-search-fields.test.ts | 9 ++-- tests/xai-web-search-compat.test.ts | 44 +++++++++++++++++++ 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/adapters/xai-web-search.ts b/src/adapters/xai-web-search.ts index 5cc4313c6f..a02a5755e8 100644 --- a/src/adapters/xai-web-search.ts +++ b/src/adapters/xai-web-search.ts @@ -1,8 +1,8 @@ import type { OcxProviderConfig } from "../types"; +import { isXaiResponsesDestination } from "../providers/xai-transport"; const CODEX_WEB_SEARCH_TOOL = "web_search"; const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview"; -const XAI_API_HOST = "api.x.ai"; function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -12,18 +12,6 @@ function isCodexWebSearchToolType(value: unknown): boolean { return value === CODEX_WEB_SEARCH_TOOL || value === CODEX_WEB_SEARCH_PREVIEW_TOOL; } -/** Match only xAI's documented public API, not arbitrary Responses-compatible gateways. */ -function isXaiPublicApi(provider: Pick): boolean { - try { - const url = new URL(provider.baseUrl); - return url.protocol === "https:" - && url.hostname.toLowerCase() === XAI_API_HOST - && (url.port === "" || url.port === "443"); - } catch { - return false; - } -} - type ToolGroupRewrite = { tools: unknown[]; changed: boolean; @@ -150,12 +138,20 @@ function normalizeToolChoice(body: Record): Record 422 `unknown variant`, `external_web_access` -> 400 on every value, + * `search_context_size` -> 400, while `user_location` and `search_content_types` -> 200. Identical + * to the public API, which is what makes one shared gate correct. */ export function normalizeXaiResponsesWebSearch( body: unknown, provider: Pick, ): unknown { - if (!isXaiPublicApi(provider) || !isPlainObject(body)) return body; + if (!isXaiResponsesDestination(provider) || !isPlainObject(body)) return body; let next: Record = body; if (Array.isArray(body.tools)) { diff --git a/tests/responses-routed-web-search-fields.test.ts b/tests/responses-routed-web-search-fields.test.ts index 48e961f8db..a2c2cb4136 100644 --- a/tests/responses-routed-web-search-fields.test.ts +++ b/tests/responses-routed-web-search-fields.test.ts @@ -213,7 +213,7 @@ describe("routedProviderConfig web_search capability backfill", () => { }]); }); - test("an equivalent unclassified OAuth row retains fatal fields at the CLI adapter", () => { + test("an unclassified OAuth row is still stripped at the CLI adapter by the host normalizer", () => { const routed = routedProviderConfig("xai", { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", @@ -227,10 +227,13 @@ describe("routedProviderConfig web_search capability backfill", () => { expect(transport.baseUrl).toBe("https://cli-chat-proxy.grok.com/v1"); expect(transport.supportsOpenAiWebSearchToolFields).toBeUndefined(); const body = buildWebSearchBody({ ...transport, adapter: "openai-responses" }); + // The capability backfill is no longer the only thing standing between a hand-edited row and + // a 400: `normalizeXaiResponsesWebSearch` is scoped to the xAI HOST rather than to the + // capability, so both fatal fields go regardless of how the row is classified. That was + // already true for api.x.ai; it now holds for the CLI proxy, which serves the same dialect. + // Everything xAI accepts still survives untouched. expect(body.tools).toEqual([{ type: "web_search", - external_web_access: true, - search_context_size: "medium", user_location: { type: "approximate" }, search_content_types: ["text"], filters: { allowed_domains: ["x.ai"] }, diff --git a/tests/xai-web-search-compat.test.ts b/tests/xai-web-search-compat.test.ts index 1ca59b0006..6230264a23 100644 --- a/tests/xai-web-search-compat.test.ts +++ b/tests/xai-web-search-compat.test.ts @@ -134,6 +134,50 @@ describe("xAI Responses web-search compatibility", () => { }); }); + test("normalizes the Grok CLI proxy identically to the public API", () => { + // Re-probed 2026-08-27 against cli-chat-proxy.grok.com: `web_search_preview` -> 422 + // "unknown variant", `external_web_access` -> 400 on every value including true, + // `search_context_size` -> 400, while `user_location` and `search_content_types` -> 200. + // The two hosts are one dialect, so one gate covers both. + const cliProvider = { baseUrl: "https://cli-chat-proxy.grok.com/v1" }; + + // A legacy `web_search_preview` reached the proxy verbatim and 422'd the whole turn. + expect(normalizeXaiResponsesWebSearch({ + model: "grok-4.6", + tools: [{ type: "web_search_preview", external_web_access: true }], + }, cliProvider)).toEqual({ + model: "grok-4.6", + tools: [{ type: "web_search" }], + }); + + // A cached/index-only declaration must NOT survive as live search. The downstream capability + // strip only deletes the flag, so leaving the CLI proxy unnormalized turned "no network" into + // an ordinary live web_search — the exact widening this normalizer exists to refuse. + expect(normalizeXaiResponsesWebSearch({ + model: "grok-4.6", + tools: [{ type: "web_search", external_web_access: false }], + }, cliProvider)).toEqual({ model: "grok-4.6" }); + + // Fields xAI accepts are still preserved on this host. + expect(normalizeXaiResponsesWebSearch({ + model: "grok-4.6", + tools: [{ + type: "web_search", + external_web_access: true, + search_context_size: "medium", + user_location: { type: "approximate", country: "US" }, + search_content_types: ["text"], + }], + }, cliProvider)).toEqual({ + model: "grok-4.6", + tools: [{ + type: "web_search", + user_location: { type: "approximate", country: "US" }, + search_content_types: ["text"], + }], + }); + }); + test("does not rewrite OpenAI, lookalike, or nonstandard-port providers", () => { const original = { model: "gpt-5.6-sol",