Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 10 additions & 14 deletions src/adapters/xai-web-search.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
Expand All @@ -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<OcxProviderConfig, "baseUrl">): 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;
Expand Down Expand Up @@ -150,12 +138,20 @@ function normalizeToolChoice(body: Record<string, unknown>): Record<string, unkn
/**
* Make Codex's hosted web-search declaration acceptable to xAI Responses without changing other
* providers or mutating the caller-owned request body.
*
* Scoped to BOTH xAI Responses hosts, not just the public API. The 2026-08-22 probe recorded in
* `normalizeToolGroup` and in `isXaiResponsesDestination` already found the two hosts to be one
* dialect, but this gate stayed on `api.x.ai` alone, so the Grok CLI proxy — the OAuth lane — was
* left unnormalized. Re-probed 2026-08-27 against `cli-chat-proxy.grok.com`:
* `web_search_preview` -> 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<OcxProviderConfig, "baseUrl">,
): unknown {
if (!isXaiPublicApi(provider) || !isPlainObject(body)) return body;
if (!isXaiResponsesDestination(provider) || !isPlainObject(body)) return body;

let next: Record<string, unknown> = body;
if (Array.isArray(body.tools)) {
Expand Down
9 changes: 6 additions & 3 deletions tests/responses-routed-web-search-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"] },
Expand Down
44 changes: 44 additions & 0 deletions tests/xai-web-search-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading