From 4c6582cf35cd8c181f326836c1e0aad59f65054d Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 22 Aug 2026 16:12:58 -0700 Subject: [PATCH 1/2] fix(xai): stop stripping web_search fields xAI accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalizeXaiResponsesWebSearch` deleted `user_location` and `search_content_types` from every xAI web_search declaration. Both are accepted by the upstream, so this was a silent capability loss on the API-key path — a caller's location hint and content-type selection never reached the model. It also contradicted the sibling layer: `stripOpenAiOnlyWebSearchFields` removes exactly the two fields xAI refuses and deliberately KEEPS user_location/filters, with a probe note recording them as accepted (tests/responses-routed-web-search-fields.test.ts). The two layers disagreed about the same field, and the normalizer ran first, so it won. 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 400 on EVERY value, including `true` search_context_size 400 user_location 200 search_content_types 200 filters 200 enable_image_search 200 So only the two refused fields are removed now. The image-search mapping is kept: it compensates for nothing being deleted anymore, but dropping it would be a separate behavior change. Two assertions in responses-routed-web-search-fields.test.ts over-specified the result as a bare `{type:"web_search"}` while that file's own probe note says user_location is accepted; they now assert it is preserved. Gate: 14246 pass / 5 fail, and all five also fail on untouched upstream/dev (baseline: 6 fail, a superset). Zero regressions. The failing families (CL-07, autostart shim, release helper, shellStreamExec) are flaky and unrelated. --- src/adapters/xai-web-search.ts | 9 +++++++-- tests/responses-routed-web-search-fields.test.ts | 8 ++++++-- tests/xai-web-search-compat.test.ts | 11 ++++++++--- 3 files changed, 21 insertions(+), 7 deletions(-) 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..1fed3e6231 100644 --- a/tests/responses-routed-web-search-fields.test.ts +++ b/tests/responses-routed-web-search-fields.test.ts @@ -102,7 +102,9 @@ describe("Responses buildRequest web_search capability", () => { enrichProviderFromRegistry("xai", provider); const body = buildWebSearchBody(provider); - expect(body.tools).toEqual([{ type: "web_search" }]); + // user_location is ACCEPTED by both xAI destinations (this file's own probe note above, + // re-confirmed 2026-08-22); only the two refused fields must be gone. + expect(body.tools).toEqual([{ type: "web_search", user_location: { type: "approximate" } }]); }); test("non-xAI classified gateways use generic field stripping, not xAI cached-search policy", () => { @@ -193,7 +195,9 @@ describe("routedProviderConfig web_search capability backfill", () => { }); const body = buildWebSearchBody({ ...routed, adapter: "openai-responses" }); - expect(body.tools).toEqual([{ type: "web_search" }]); + // user_location is ACCEPTED by both xAI destinations (this file's own probe note above, + // re-confirmed 2026-08-22); only the two refused fields must be gone. + expect(body.tools).toEqual([{ type: "web_search", user_location: { type: "approximate" } }]); }); 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", () => { From 415aec65f77ef92958f755d909ca98f95a2e71a2 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 22 Aug 2026 21:33:13 -0700 Subject: [PATCH 2/2] test(xai): prove the capability backfill is causal on both destinations The two assertions this branch changed both built an api.x.ai provider, where the xAI normalizer strips the fatal fields before the capability gate runs. They would have passed with the backfill broken, and never touched the OAuth CLI destination the Responses opt-in actually targets. Now the OAuth row resolves through resolveProviderTransport("xai", routed), asserts it reaches cli-chat-proxy.grok.com, and checks the accepted fields survive there; an unclassified control asserts the fatal fields are RETAINED without classification, so a broken backfill fails. --- ...responses-routed-web-search-fields.test.ts | 55 ++++++++++++++++--- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/tests/responses-routed-web-search-fields.test.ts b/tests/responses-routed-web-search-fields.test.ts index 1fed3e6231..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); - // user_location is ACCEPTED by both xAI destinations (this file's own probe note above, - // re-confirmed 2026-08-22); only the two refused fields must be gone. - expect(body.tools).toEqual([{ type: "web_search", user_location: { type: "approximate" } }]); + 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", () => { @@ -186,18 +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" }); - // user_location is ACCEPTED by both xAI destinations (this file's own probe note above, - // re-confirmed 2026-08-22); only the two refused fields must be gone. - expect(body.tools).toEqual([{ type: "web_search", user_location: { type: "approximate" } }]); + 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", () => {