diff --git a/src/router.ts b/src/router.ts index fa2f72e831..35e34d75ca 100644 --- a/src/router.ts +++ b/src/router.ts @@ -355,6 +355,17 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(provider.supportsServiceTier === undefined && registryEntry.supportsServiceTier !== undefined ? { supportsServiceTier: registryEntry.supportsServiceTier } : {}), + // Registry-only web-search capability: without this backfill a saved provider row reaches + // the Responses adapter with the flag `undefined`, so the capability gate added in #2262 + // reads "unclassified" and forwards Codex's OpenAI-only `web_search` config fields. xAI + // rejects the whole request before inference ("Argument not supported: + // external_web_access"), which killed every routed Grok turn on the Responses lane. + // enrichProviderFromRegistry() already fills this, but the request path resolves through + // routedProviderConfig() and never called it. + ...(provider.supportsOpenAiWebSearchToolFields === undefined + && registryEntry.supportsOpenAiWebSearchToolFields !== undefined + ? { supportsOpenAiWebSearchToolFields: registryEntry.supportsOpenAiWebSearchToolFields } + : {}), ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent } : {}), diff --git a/tests/responses-routed-web-search-fields.test.ts b/tests/responses-routed-web-search-fields.test.ts index 7df6e24a63..f6dc65ac27 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 { routedProviderConfig } from "../src/router"; import type { OcxProviderConfig } from "../src/types"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; @@ -81,3 +82,52 @@ describe("Responses buildRequest web_search capability", () => { }]); }); }); + +// The request path resolves a saved provider row through routedProviderConfig(), NOT through +// enrichProviderFromRegistry(). Until this backfill existed, a saved xai row reached the +// Responses adapter with supportsOpenAiWebSearchToolFields === undefined, so the #2262 +// capability gate read "unclassified" and forwarded the fields; live xAI answered +// `400 Argument not supported: external_web_access` and every routed Grok turn on the +// Responses lane died before inference (verified against cli-chat-proxy.grok.com 2026-08-21). +// Asserting on the adapter with a hand-built provider cannot catch this — the gap is upstream +// of the adapter, in what the router hands it. +describe("routedProviderConfig web_search capability backfill", () => { + test("a saved xai row without the flag is classified by the registry", () => { + const saved: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + // The GUI Responses opt-in writes only modelAdapters; it never writes the capability. + modelAdapters: { "grok-4.6": "openai-responses" }, + }; + expect(saved.supportsOpenAiWebSearchToolFields).toBeUndefined(); + + const routed = routedProviderConfig("xai", saved); + expect(routed.supportsOpenAiWebSearchToolFields).toBe(false); + }); + + test("the routed row actually strips the fatal fields at the adapter", () => { + const routed = routedProviderConfig("xai", { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + modelAdapters: { "grok-4.6": "openai-responses" }, + }); + + const body = buildWebSearchBody({ ...routed, adapter: "openai-responses" }); + expect(body.tools).toEqual([{ + type: "web_search", + user_location: { type: "approximate" }, + }]); + }); + + test("an explicit saved value still overrides the registry default", () => { + const routed = routedProviderConfig("xai", { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + supportsOpenAiWebSearchToolFields: true, + }); + expect(routed.supportsOpenAiWebSearchToolFields).toBe(true); + }); +});