From 64cd6e5a91a4498fa279ba15cf227155dfb4d1c2 Mon Sep 17 00:00:00 2001 From: goodwilliam0126 <211597002+goodwilliam0126@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:26:50 +0900 Subject: [PATCH] fix(xai): normalize Responses web search tools --- src/adapters/openai-responses.ts | 67 +++++-- src/adapters/xai-web-search.ts | 185 ++++++++++++++++++ structure/04_transports-and-sidecars.md | 8 + tests/openai-responses-passthrough.test.ts | 19 +- ...responses-routed-web-search-fields.test.ts | 87 +++++++- tests/xai-web-search-compat.test.ts | 146 ++++++++++++++ 6 files changed, 482 insertions(+), 30 deletions(-) create mode 100644 src/adapters/xai-web-search.ts create mode 100644 tests/xai-web-search-compat.test.ts diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 323f9fbf40..d3b3e3de12 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -19,6 +19,7 @@ import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-co import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat"; import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; import { openaiResponsesUrl } from "./openai-responses-url"; +import { normalizeXaiResponsesWebSearch } from "./xai-web-search"; import { createAdapterTierMetadata, } from "../providers/fastwire"; @@ -1503,17 +1504,55 @@ function stripUnsupportedHostedTools(body: unknown): unknown { * provider capability metadata; an unclassified upstream keeps the fields. */ const OPENAI_ONLY_WEB_SEARCH_FIELDS = ["external_web_access", "search_context_size"] as const; -export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { - if (!isPlainObject(body) || !Array.isArray(body.tools)) return body; + +function stripOpenAiOnlyWebSearchFieldsFromTools(tools: unknown[]): { + tools: unknown[]; + changed: boolean; +} { let changed = false; - const tools = body.tools.map(t => { - if (!isPlainObject(t) || (t.type !== "web_search" && t.type !== "web_search_preview")) return t; - if (!OPENAI_ONLY_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(t, field))) return t; - const { external_web_access: _access, search_context_size: _size, ...rest } = t; + const stripped = tools.map(tool => { + if (!isPlainObject(tool) || (tool.type !== "web_search" && tool.type !== "web_search_preview")) { + return tool; + } + if (!OPENAI_ONLY_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(tool, field))) return tool; + const { external_web_access: _access, search_context_size: _size, ...rest } = tool; changed = true; return rest; }); - return changed ? { ...body, tools } : body; + return { tools: changed ? stripped : tools, changed }; +} + +export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { + if (!isPlainObject(body)) return body; + + let next: Record = body; + let changed = false; + if (Array.isArray(body.tools)) { + const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(body.tools); + if (stripped.changed) { + next = { ...next, tools: stripped.tools }; + changed = true; + } + } + + if (Array.isArray(body.input)) { + let inputChanged = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) { + return item; + } + const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(item.tools); + if (!stripped.changed) return item; + inputChanged = true; + return { ...item, tools: stripped.tools }; + }); + if (inputChanged) { + next = { ...next, input }; + changed = true; + } + } + + return changed ? next : body; } /** Replace every `input_image` part under a routed-compaction body with a short marker. */ @@ -1712,12 +1751,6 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const rewritten = rewriteRoutedToolSearchForUpstream(outBody); outBody = rewritten.body; convertedRoutedToolSearchNames = rewritten.names; - // xAI rejects these OpenAI web_search extensions with HTTP 400. Keep them - // for OpenAI API-key traffic and unclassified gateways; only an explicit - // provider capability denial activates the compatibility transform. - if (provider.supportsOpenAiWebSearchToolFields === false) { - outBody = stripOpenAiOnlyWebSearchFields(outBody); - } } if (!isCanonicalOpenAiForwardProvider(provider)) { // Codex 0.147 emits private namespace tool groups, while public/third-party Responses @@ -1726,6 +1759,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody); outBody = rewritten.body; convertedRoutedNamespaceToolAliases = rewritten.aliases; + // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the + // generic capability fallback removes the private OpenAI fields. + outBody = normalizeXaiResponsesWebSearch(outBody, provider); + // xAI and explicitly classified compatible gateways reject these OpenAI web_search + // extensions. Keep them for OpenAI API-key traffic and unclassified gateways. + if (provider.supportsOpenAiWebSearchToolFields === false) { + outBody = stripOpenAiOnlyWebSearchFields(outBody); + } // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } diff --git a/src/adapters/xai-web-search.ts b/src/adapters/xai-web-search.ts new file mode 100644 index 0000000000..ce72fe2c54 --- /dev/null +++ b/src/adapters/xai-web-search.ts @@ -0,0 +1,185 @@ +import type { OcxProviderConfig } from "../types"; + +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); +} + +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; +}; + +/** + * Translate Codex-private hosted-search fields to xAI's public Responses schema. + * + * xAI web search is live-only. A Codex cached/index-only declaration carries + * `external_web_access: false`; dropping that flag while keeping the tool would silently widen + * network access, so the whole tool is omitted instead. `true` maps to xAI's ordinary live + * `{type:"web_search"}` declaration. Requests that omit the private flag are already public-API + * shaped and retain their live-search behavior. + */ +function normalizeToolGroup(tools: unknown[]): ToolGroupRewrite { + const normalized: unknown[] = []; + let changed = false; + + for (const tool of tools) { + if (!isPlainObject(tool) || !isCodexWebSearchToolType(tool.type)) { + normalized.push(tool); + continue; + } + + const hasExternalAccess = Object.hasOwn(tool, "external_web_access"); + if (hasExternalAccess && tool.external_web_access !== true) { + // xAI has no cached/index-only equivalent. Fail closed instead of turning it into live search. + changed = true; + continue; + } + + const searchContentTypes = Array.isArray(tool.search_content_types) + ? tool.search_content_types + : undefined; + const enableImageSearch = searchContentTypes?.includes("image") === true; + const next: Record = { ...tool, type: CODEX_WEB_SEARCH_TOOL }; + 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; + } + + const toolChanged = Object.keys(next).length !== Object.keys(tool).length + || Object.entries(next).some(([key, value]) => tool[key] !== value); + changed ||= toolChanged; + normalized.push(toolChanged ? next : tool); + } + + return { tools: changed ? normalized : tools, changed }; +} + +function hasWebSearchTool(body: Record): boolean { + if (Array.isArray(body.tools) && body.tools.some(tool => + isPlainObject(tool) && isCodexWebSearchToolType(tool.type) + )) return true; + return Array.isArray(body.input) && body.input.some(item => + isPlainObject(item) + && item.type === "additional_tools" + && Array.isArray(item.tools) + && item.tools.some(tool => isPlainObject(tool) && isCodexWebSearchToolType(tool.type)) + ); +} + +function hasAnyDeclaredTool(body: Record): boolean { + if (Array.isArray(body.tools) && body.tools.length > 0) return true; + return Array.isArray(body.input) && body.input.some(item => + isPlainObject(item) + && item.type === "additional_tools" + && Array.isArray(item.tools) + && item.tools.length > 0 + ); +} + +/** Remove selectors that would still force a cached-only tool omitted above. */ +function normalizeToolChoice(body: Record): Record { + const choice = body.tool_choice; + if (choice === undefined) return body; + const hasSearch = hasWebSearchTool(body); + + if (isPlainObject(choice) && isCodexWebSearchToolType(choice.type)) { + if (!hasSearch) return { ...body, tool_choice: "none" }; + return choice.type === CODEX_WEB_SEARCH_TOOL + ? body + : { ...body, tool_choice: { ...choice, type: CODEX_WEB_SEARCH_TOOL } }; + } + if (isPlainObject(choice) && choice.type === "allowed_tools" && Array.isArray(choice.tools)) { + let changed = false; + const tools: unknown[] = []; + for (const tool of choice.tools) { + if (!isPlainObject(tool) || !isCodexWebSearchToolType(tool.type)) { + tools.push(tool); + continue; + } + if (!hasSearch) { + changed = true; + continue; + } + if (tool.type === CODEX_WEB_SEARCH_PREVIEW_TOOL) { + tools.push({ ...tool, type: CODEX_WEB_SEARCH_TOOL }); + changed = true; + } else { + tools.push(tool); + } + } + if (!changed) return body; + return { + ...body, + tool_choice: tools.length > 0 ? { ...choice, tools } : "none", + }; + } + if (choice === "required" && !hasAnyDeclaredTool(body)) { + return { ...body, tool_choice: "none" }; + } + return body; +} + +/** + * Make Codex's hosted web-search declaration acceptable to xAI Responses without changing other + * providers or mutating the caller-owned request body. + */ +export function normalizeXaiResponsesWebSearch( + body: unknown, + provider: Pick, +): unknown { + if (!isXaiPublicApi(provider) || !isPlainObject(body)) return body; + + let next: Record = body; + if (Array.isArray(body.tools)) { + const rewritten = normalizeToolGroup(body.tools); + if (rewritten.changed) { + next = { ...next }; + if (rewritten.tools.length > 0) next.tools = rewritten.tools; + else delete next.tools; + } + } + + if (Array.isArray(next.input)) { + let inputChanged = false; + const input: unknown[] = []; + for (const item of next.input) { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) { + input.push(item); + continue; + } + const rewritten = normalizeToolGroup(item.tools); + if (!rewritten.changed) { + input.push(item); + continue; + } + inputChanged = true; + if (rewritten.tools.length > 0) input.push({ ...item, tools: rewritten.tools }); + } + if (inputChanged) next = { ...next, input }; + } + + return normalizeToolChoice(next); +} diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 75410d38b3..b4df17b72a 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -162,6 +162,14 @@ not a separate tier policy. One write sets or clears the Grok 4.5 and 4.6 entrie preserving unrelated overrides; a pre-existing one-entry state is reported as mixed until the next switch write normalizes both. +[Decision Log] +- 목적과 의도: Keep Codex hosted web search usable on xAI's public Responses endpoint without forwarding private OpenAI-only fields that xAI rejects. +- 기존 구현 및 제약 조건: Codex emits `external_web_access`, `search_context_size`, `search_content_types`, and `user_location`; xAI documents a live-only `web_search` tool with domain filters and image flags, while Codex cached mode explicitly forbids external access. +- 검토한 주요 대안: Strip only the first rejected field; pass every hosted-search field unchanged; disable web search for all xAI turns; normalize only the exact official xAI API destination. +- 선택한 방식: On `https://api.x.ai` Responses traffic, lower live search to xAI's public shape, map image content requests to `enable_image_search`, remove unsupported OpenAI-private fields, and omit cached/index-only search plus stale selectors because xAI has no non-live equivalent. +- 다른 대안 대신 이 방식을 선택한 이유: One-field stripping exposes the next schema mismatch and turning `external_web_access:false` into xAI live search widens the caller's network policy; destination scoping leaves custom gateways and canonical OpenAI byte-shape native. +- 장점, 단점 및 영향: Grok 4.5/4.6 no longer fail every default Codex turn with an unsupported-argument 400; live search remains available when explicitly enabled, while cached search degrades to no hosted search on xAI rather than silently going live. + OpenCode Go documents `gpt-5.6-luna` on `/zen/go/v1/responses` while sibling models use its Chat or Anthropic endpoints. The built-in preset therefore selects `openai-responses` only for Luna and keeps the provider-wide `openai-chat` default for other non-pinned models. This endpoint correction diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 3da353dac0..1c95c49833 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -976,7 +976,7 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.tools[0]).toMatchObject({ type: "image_generation" }); }); - test("drops ChatGPT's external_web_access hint but keeps routed web search", () => { + test("normalizes xAI top-level and additional web search without stale tool choice", () => { const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", @@ -998,21 +998,18 @@ describe("OpenAI Responses passthrough sanitization", () => { tools: [{ type: "web_search", external_web_access: true, search_context_size: "medium" }], }], tools: [{ type: "web_search", external_web_access: false, filters: { allowed_domains: ["example.com"] } }], + tool_choice: { type: "web_search" }, }, }, { headers: new Headers() }); const body = JSON.parse(request.body) as { - tools: Record[]; + tools?: Record[]; input: Array<{ type: string; tools: Record[] }>; + tool_choice: Record; }; - expect(body.tools).toEqual([{ - type: "web_search", - filters: { allowed_domains: ["example.com"] }, - }]); - expect(body.input[0]?.tools).toEqual([{ - type: "web_search", - search_context_size: "medium", - }]); + expect(body.tools).toBeUndefined(); + expect(body.input[0]?.tools).toEqual([{ type: "web_search" }]); + expect(body.tool_choice).toEqual({ type: "web_search" }); }); test("preserves external_web_access on the canonical OpenAI forward route", () => { @@ -1070,7 +1067,7 @@ describe("OpenAI Responses passthrough sanitization", () => { input: Array<{ tools: Record[] }>; }; - expect(body.tools[0]).toEqual({ type: "web_search_preview" }); + expect(body.tools[0]).toEqual({ type: "web_search" }); expect(body.tools[1]).toMatchObject({ type: "function", name: "workspace__read" }); expect(body.tools[1]).not.toHaveProperty("defer_loading"); expect(body.input[0].tools[0]).not.toHaveProperty("defer_loading"); diff --git a/tests/responses-routed-web-search-fields.test.ts b/tests/responses-routed-web-search-fields.test.ts index f6dc65ac27..3f67df88f4 100644 --- a/tests/responses-routed-web-search-fields.test.ts +++ b/tests/responses-routed-web-search-fields.test.ts @@ -50,6 +50,32 @@ describe("stripOpenAiOnlyWebSearchFields", () => { const clean = { model: "m", tools: [{ type: "web_search" }] }; expect(stripOpenAiOnlyWebSearchFields(clean)).toBe(clean); }); + + test("strips a nested cached declaration even when no top-level tools exist", () => { + const body = { + model: "m", + input: [{ + type: "additional_tools", + tools: [{ + type: "web_search", + external_web_access: false, + search_context_size: "low", + filters: { allowed_domains: ["example.com"] }, + }], + }], + }; + + expect(stripOpenAiOnlyWebSearchFields(body)).toEqual({ + model: "m", + input: [{ + type: "additional_tools", + tools: [{ + type: "web_search", + filters: { allowed_domains: ["example.com"] }, + }], + }], + }); + }); }); describe("Responses buildRequest web_search capability", () => { @@ -69,17 +95,69 @@ describe("Responses buildRequest web_search capability", () => { }]); }); - test("registry xAI traffic strips fields its Responses API rejects", () => { + test("registry xAI traffic normalizes Codex search fields for its public 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" }]); + }); + + test("non-xAI classified gateways use generic field stripping, not xAI cached-search policy", () => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://responses.example.com/v1", + authMode: "key", + apiKey: "test-gateway-key", + supportsOpenAiWebSearchToolFields: false, + }; + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "test-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "test-model", + input: [{ + type: "additional_tools", + role: "developer", + tools: [{ + type: "web_search", + external_web_access: false, + search_context_size: "low", + user_location: { type: "approximate", country: "KR" }, + filters: { excluded_domains: ["blocked.example"] }, + }], + }], + tools: [{ + type: "web_search", + external_web_access: false, + search_context_size: "medium", + user_location: { type: "approximate" }, + filters: { allowed_domains: ["example.com"] }, + }], + tool_choice: { type: "web_search" }, + }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as Record; + expect(body.tools).toEqual([{ type: "web_search", user_location: { type: "approximate" }, + filters: { allowed_domains: ["example.com"] }, + }]); + expect(body.input).toEqual([{ + type: "additional_tools", + role: "developer", + tools: [{ + type: "web_search", + user_location: { type: "approximate", country: "KR" }, + filters: { excluded_domains: ["blocked.example"] }, + }], }]); + expect(body.tool_choice).toEqual({ type: "web_search" }); }); }); @@ -106,7 +184,7 @@ describe("routedProviderConfig web_search capability backfill", () => { expect(routed.supportsOpenAiWebSearchToolFields).toBe(false); }); - test("the routed row actually strips the fatal fields at the adapter", () => { + test("the routed row actually normalizes the search tool at the adapter", () => { const routed = routedProviderConfig("xai", { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", @@ -115,10 +193,7 @@ describe("routedProviderConfig web_search capability backfill", () => { }); const body = buildWebSearchBody({ ...routed, adapter: "openai-responses" }); - expect(body.tools).toEqual([{ - type: "web_search", - user_location: { type: "approximate" }, - }]); + expect(body.tools).toEqual([{ type: "web_search" }]); }); 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 new file mode 100644 index 0000000000..f8d2afc29c --- /dev/null +++ b/tests/xai-web-search-compat.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createProductionAdapter } from "../src/adapters/openai-responses"; +import { normalizeXaiResponsesWebSearch } from "../src/adapters/xai-web-search"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +function createXaiAdapter() { + return withTestTranslatorBudget(createProductionAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "forward", + headers: { authorization: "Bearer xai-oauth" }, + })); +} + +function buildBody(rawBody: Record): Record { + const request = createXaiAdapter().buildRequest({ + modelId: "grok-4.6", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + }); + return JSON.parse(request.body) as Record; +} + +describe("xAI Responses web-search compatibility", () => { + test("lowers Codex live-search fields to xAI's documented tool schema", () => { + const body = buildBody({ + model: "grok-4.6", + input: "latest xAI news", + tools: [{ + type: "web_search", + external_web_access: true, + filters: { allowed_domains: ["x.ai"] }, + user_location: { type: "approximate", country: "KR" }, + search_context_size: "high", + search_content_types: ["text", "image"], + }], + tool_choice: { type: "web_search" }, + }); + + expect(body.tools).toEqual([{ + type: "web_search", + filters: { allowed_domains: ["x.ai"] }, + 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", () => { + const body = buildBody({ + model: "grok-4.6", + tools: [{ type: "web_search", external_web_access: false }], + input: [ + { + type: "additional_tools", + role: "developer", + tools: [{ type: "web_search", external_web_access: false }], + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }, + ], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "web_search" }], + }, + }); + + expect(body.tools).toBeUndefined(); + expect(body.input).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }, + ]); + expect(body.tool_choice).toBe("none"); + }); + + test("keeps public xAI search declarations live when the private access flag is absent", () => { + const body = buildBody({ + model: "grok-4.6", + input: "latest xAI news", + tools: [{ + type: "web_search", + filters: { excluded_domains: ["example.com"] }, + enable_image_understanding: true, + }], + }); + + expect(body.tools).toEqual([{ + type: "web_search", + filters: { excluded_domains: ["example.com"] }, + enable_image_understanding: true, + }]); + }); + + test("normalizes the supported preview alias in declarations and selectors", () => { + const direct = buildBody({ + model: "grok-4.6", + input: "latest xAI news", + tools: [{ + type: "web_search_preview", + external_web_access: true, + search_context_size: "medium", + }], + tool_choice: { type: "web_search_preview" }, + }); + + expect(direct.tools).toEqual([{ type: "web_search" }]); + expect(direct.tool_choice).toEqual({ type: "web_search" }); + + const allowed = buildBody({ + model: "grok-4.6", + input: "latest xAI news", + tools: [{ type: "web_search_preview" }], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "web_search_preview" }], + }, + }); + + expect(allowed.tools).toEqual([{ type: "web_search" }]); + expect(allowed.tool_choice).toEqual({ + type: "allowed_tools", + mode: "required", + tools: [{ type: "web_search" }], + }); + }); + + test("does not rewrite OpenAI, lookalike, or nonstandard-port providers", () => { + const original = { + model: "gpt-5.6-sol", + tools: [{ type: "web_search", external_web_access: false }], + }; + for (const baseUrl of [ + "https://chatgpt.com/backend-api/codex", + "https://api.x.ai.example/v1", + "https://api.x.ai:8443/v1", + "http://api.x.ai/v1", + ]) { + expect(normalizeXaiResponsesWebSearch(original, { baseUrl })).toBe(original); + } + }); +});