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",