diff --git a/src/adapters/xai-web-search.ts b/src/adapters/xai-web-search.ts index ce72fe2c54..5cc4313c6f 100644 --- a/src/adapters/xai-web-search.ts +++ b/src/adapters/xai-web-search.ts @@ -60,10 +60,15 @@ function normalizeToolGroup(tools: unknown[]): ToolGroupRewrite { : undefined; const enableImageSearch = searchContentTypes?.includes("image") === true; const next: Record = { ...tool, type: CODEX_WEB_SEARCH_TOOL }; + // Only the two fields xAI actually refuses are removed. Probed 2026-08-22, one field per + // request, against BOTH xAI destinations (api.x.ai and cli-chat-proxy.grok.com): they behave + // identically — `external_web_access` 400s on every value including `true`, and + // `search_context_size` 400s, while `user_location`, `search_content_types`, `filters` and + // `enable_image_search` are all accepted. Deleting the accepted ones was a silent capability + // loss, and it contradicted the sibling layer, whose own probe note already records + // user_location/filters as accepted (tests/responses-routed-web-search-fields.test.ts). delete next.external_web_access; delete next.search_context_size; - delete next.search_content_types; - delete next.user_location; if (enableImageSearch && !Object.hasOwn(next, "enable_image_search")) { next.enable_image_search = true; } diff --git a/tests/responses-routed-web-search-fields.test.ts b/tests/responses-routed-web-search-fields.test.ts index 3f67df88f4..48e961f8db 100644 --- a/tests/responses-routed-web-search-fields.test.ts +++ b/tests/responses-routed-web-search-fields.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction, stripOpenAiOnlyWebSearchFields } from "../src/adapters/openai-responses"; import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; import { getProviderRegistryEntry } from "../src/providers/registry"; +import { resolveProviderTransport } from "../src/providers/xai-transport"; import { routedProviderConfig } from "../src/router"; import type { OcxProviderConfig } from "../src/types"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; @@ -23,6 +24,8 @@ function buildWebSearchBody(provider: OcxProviderConfig): Record { external_web_access: true, search_context_size: "medium", user_location: { type: "approximate" }, + search_content_types: ["text"], + filters: { allowed_domains: ["x.ai"] }, }]); }); - test("registry xAI traffic normalizes Codex search fields for its public Responses API", () => { + test("registry xAI traffic keeps accepted fields on the public api.x.ai Responses API", () => { const entry = getProviderRegistryEntry("xai"); if (!entry) throw new Error("xAI registry entry missing"); const provider = { ...providerConfigSeed(entry), adapter: "openai-responses" }; enrichProviderFromRegistry("xai", provider); const body = buildWebSearchBody(provider); - expect(body.tools).toEqual([{ type: "web_search" }]); + expect(body.tools).toEqual([{ + type: "web_search", + user_location: { type: "approximate" }, + search_content_types: ["text"], + filters: { allowed_domains: ["x.ai"] }, + }]); }); test("non-xAI classified gateways use generic field stripping, not xAI cached-search policy", () => { @@ -184,16 +194,47 @@ describe("routedProviderConfig web_search capability backfill", () => { expect(routed.supportsOpenAiWebSearchToolFields).toBe(false); }); - test("the routed row actually normalizes the search tool at the adapter", () => { + test("the registry-classified OAuth row strips only fatal fields at the CLI adapter", () => { const routed = routedProviderConfig("xai", { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "oauth", modelAdapters: { "grok-4.6": "openai-responses" }, }); + const transport = resolveProviderTransport("xai", routed); + + expect(transport.baseUrl).toBe("https://cli-chat-proxy.grok.com/v1"); + const body = buildWebSearchBody({ ...transport, adapter: "openai-responses" }); + expect(body.tools).toEqual([{ + type: "web_search", + user_location: { type: "approximate" }, + search_content_types: ["text"], + filters: { allowed_domains: ["x.ai"] }, + }]); + }); - const body = buildWebSearchBody({ ...routed, adapter: "openai-responses" }); - expect(body.tools).toEqual([{ type: "web_search" }]); + test("an equivalent unclassified OAuth row retains fatal fields at the CLI adapter", () => { + const routed = routedProviderConfig("xai", { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + modelAdapters: { "grok-4.6": "openai-responses" }, + }); + const unclassified = { ...routed }; + delete unclassified.supportsOpenAiWebSearchToolFields; + const transport = resolveProviderTransport("xai", unclassified); + + expect(transport.baseUrl).toBe("https://cli-chat-proxy.grok.com/v1"); + expect(transport.supportsOpenAiWebSearchToolFields).toBeUndefined(); + const body = buildWebSearchBody({ ...transport, adapter: "openai-responses" }); + 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"] }, + }]); }); test("an explicit saved value still overrides the registry default", () => { diff --git a/tests/xai-web-search-compat.test.ts b/tests/xai-web-search-compat.test.ts index f8d2afc29c..1ca59b0006 100644 --- a/tests/xai-web-search-compat.test.ts +++ b/tests/xai-web-search-compat.test.ts @@ -24,7 +24,12 @@ function buildBody(rawBody: Record): Record { } describe("xAI Responses web-search compatibility", () => { - test("lowers Codex live-search fields to xAI's documented tool schema", () => { + // Probed 2026-08-22, one field per request, against BOTH xAI destinations (api.x.ai and + // cli-chat-proxy.grok.com), which behave identically: external_web_access 400s on every value + // including `true`, search_context_size 400s, and user_location / search_content_types / + // filters / enable_image_search are all accepted. Only the two refused fields are removed; + // deleting the accepted ones was a silent capability loss. + test("removes only the fields xAI refuses and keeps the accepted ones", () => { const body = buildBody({ model: "grok-4.6", input: "latest xAI news", @@ -42,13 +47,13 @@ describe("xAI Responses web-search compatibility", () => { expect(body.tools).toEqual([{ type: "web_search", filters: { allowed_domains: ["x.ai"] }, + user_location: { type: "approximate", country: "KR" }, + search_content_types: ["text", "image"], enable_image_search: true, }]); expect(body.tool_choice).toEqual({ type: "web_search" }); expect(JSON.stringify(body)).not.toContain("external_web_access"); expect(JSON.stringify(body)).not.toContain("search_context_size"); - expect(JSON.stringify(body)).not.toContain("search_content_types"); - expect(JSON.stringify(body)).not.toContain("user_location"); }); test("omits cached-only search instead of silently widening it to xAI live search", () => {