Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 3 additions & 2 deletions src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,8 +411,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
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);
// generic capability fallback removes the private OpenAI fields. Grok relayed through
// OpenCode Go speaks the same dialect, which the final model id and URL identify.
outBody = normalizeXaiResponsesWebSearch(outBody, provider, { modelId: parsed.modelId, responseUrl: url });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
outBody = injectXaiResponsesXSearch(outBody, provider, parsed._replayPrefixLen);
// xAI and explicitly classified compatible gateways reject these OpenAI web_search
// extensions. Keep them for OpenAI API-key traffic and unclassified gateways.
Expand Down
29 changes: 19 additions & 10 deletions src/adapters/opencode-go-additional-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}

/**
* True only for the exact OpenCode Go Responses resource. Lookalike hosts, other Zen paths,
* credentials, queries, fragments and unparsable URLs are all excluded, so a custom provider
* that merely resembles Go never inherits a Go-specific body rewrite.
*/
export function isOpenCodeGoResponsesUrl(responseUrl: string): boolean {
let destination: URL;
try {
destination = new URL(responseUrl);
} catch {
return false;
}
return destination.origin === "https://opencode.ai"
&& destination.pathname === "/zen/go/v1/responses"
&& !destination.username && !destination.password
&& !destination.href.includes("?") && !destination.href.includes("#");
}

/** Console Go accepts public tools but rejects the private additional_tools input wrapper. */
export function normalizeOpenCodeGoAdditionalTools(
body: unknown,
responseUrl: string,
replayPrefixLength = 0,
): unknown {
let destination: URL;
try {
destination = new URL(responseUrl);
} catch {
return body;
}
if (destination.origin !== "https://opencode.ai"
|| destination.pathname !== "/zen/go/v1/responses"
|| destination.username || destination.password
|| destination.href.includes("?") || destination.href.includes("#")) return body;
if (!isOpenCodeGoResponsesUrl(responseUrl)) return body;
if (!isRecord(body) || !Array.isArray(body.input)) return body;
// Do not replace a malformed top-level catalog with a partial promoted one.
if (body.tools !== undefined && !Array.isArray(body.tools)) return body;
Expand Down
28 changes: 27 additions & 1 deletion src/adapters/xai-web-search.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { OcxProviderConfig } from "../types";
import { debugProviderDiagnostic } from "../lib/debug";
import { isXaiResponsesDestination } from "../providers/xai-transport";
import { isOpenCodeGoResponsesUrl } from "./opencode-go-additional-tools";

const CODEX_WEB_SEARCH_TOOL = "web_search";
const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview";
Expand Down Expand Up @@ -146,6 +147,24 @@ function hasToolType(tools: unknown, type: string): boolean {
return Array.isArray(tools) && tools.some(tool => isPlainObject(tool) && tool.type === type);
}

/**
* OpenCode Go serves Grok on its Responses endpoint by relaying to xAI, and relays xAI's schema
* verbatim. Probed 2026-09-26 on `https://opencode.ai/zen/go/v1/responses` with grok-4.6 and
* grok-4.7, one field per request: `external_web_access` and `search_context_size` -> 400
* "Argument not supported", `web_search_preview` -> 422 `unknown variant`, while a plain
* `web_search` with `search_content_types`, `enable_image_search`, `user_location` or
* `filters` -> 200 and performs live searches. That is the xAI dialect exactly, so the same
* normalization applies. Sibling Go models (Luna, Muse Spark) accept the OpenAI fields and are
* left alone. Membership is by family prefix: an exact-id match is what let grok-4.7 reach this
* endpoint unnormalized once it joined grok-4.6 on the Responses wire.
*/
function isOpenCodeGoGrokResponses(modelId: unknown, responseUrl: string | undefined): boolean {
return typeof modelId === "string"
&& modelId.trim().toLowerCase().startsWith("grok-")
&& typeof responseUrl === "string"
&& isOpenCodeGoResponsesUrl(responseUrl);
}

/**
* Make Codex's hosted web-search declaration acceptable to xAI Responses without changing other
* providers or mutating the caller-owned request body.
Expand All @@ -157,12 +176,19 @@ function hasToolType(tools: unknown, type: string): boolean {
* `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.
*
* Grok models on the exact OpenCode Go Responses URL speak the same dialect and share this gate;
* see `isOpenCodeGoGrokResponses`. `target` carries the final model id and request URL for that
* check, and callers without it keep the xAI-host-only behavior.
*/
export function normalizeXaiResponsesWebSearch(
body: unknown,
provider: Pick<OcxProviderConfig, "baseUrl">,
target?: { modelId?: unknown; responseUrl?: string },
): unknown {
if (!isXaiResponsesDestination(provider) || !isPlainObject(body)) return body;
if (!isPlainObject(body)) return body;
if (!isXaiResponsesDestination(provider)
&& !isOpenCodeGoGrokResponses(target?.modelId, target?.responseUrl)) return body;

let next: Record<string, unknown> = body;
if (Array.isArray(body.tools)) {
Expand Down
17 changes: 9 additions & 8 deletions src/responses/hosted-tool-policy.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
/** Hosted tools rejected by specific native model slugs or exact provider destinations. */
/**
* Hosted tools rejected by specific native model slugs or exact provider destinations.
*
* Currently empty. Its only row removed hosted web search for grok-4.6 on OpenCode Go, but that
* destination rejects two OpenAI-private fields rather than the tool: the xAI web-search
* normalizer in `src/adapters/xai-web-search.ts` now removes those fields for every Grok model
* there, and search works. Add a row only for a destination that refuses the tool itself.
*/
const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{
match: (model: string, baseUrl?: string) => boolean;
tools: ReadonlySet<string>;
}> = [
{
match: (model, baseUrl) => model === "grok-4.6"
&& baseUrl?.replace(/\/+$/, "") === "https://opencode.ai/zen/go/v1",
tools: new Set(["web_search", "web_search_preview"]),
},
];
}> = [];

/**
* Hosted-tool declaration names an operator may list in `unsupportedHostedTools`.
Expand Down
9 changes: 6 additions & 3 deletions structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,9 +279,12 @@ but rejected hosted `web_search` with HTTP 400 `unsupported_request`, which mean
prompt failed before the model answered, because Codex's hosted declaration travelled with it. A
provider nobody has classified can now describe itself in config.

The declaration is additive to that table, not a replacement for it. The table still covers
destinations that reject a tool regardless of configuration, so an operator who never heard of the
field stays protected; a declaration can only deny more, never re-enable a known-broken pairing.
The declaration is additive to that table, not a replacement for it. The table is for destinations
that reject a tool regardless of configuration, so an operator who never heard of the field stays
protected; a declaration can only deny more, never re-enable a known-broken pairing. The table is
currently empty: its only row removed hosted search for grok-4.6 on OpenCode Go, but that
destination refuses two OpenAI-private fields rather than the tool, and
`src/adapters/xai-web-search.ts` now normalizes those fields for every Grok model there.

Two properties are deliberate. Spelling variants of one capability are aliased, so declaring
`web_search` also denies `web_search_preview` — the rest of the proxy already folds that pair into
Expand Down
5 changes: 5 additions & 0 deletions structure/providers/xai-grok.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ the global subscription classifier is unchanged. The rewrite itself is owned by
remain in either the top-level catalog or `additional_tools`. Cached-only search removal follows
the same rule. When an omitted `none` selector stated the turn's only client-call prohibition,
the explicit empty `tools` catalog preserves that denial. Available forced function selectors remain intact.
The whole web-search normalization also runs for any `grok-*` model whose final request URL is
exactly `https://opencode.ai/zen/go/v1/responses`: OpenCode Go relays Grok to xAI and returns xAI's
schema errors verbatim (`external_web_access` and `search_context_size` -> 400,
`web_search_preview` -> 422), while sibling Go models accept the OpenAI fields and keep them.
Regression coverage: `tests/providers/opencode-go-grok46-responses.test.ts`.
`src/adapters/openai-responses/request-strips.ts` preserves valid xAI custom-call item ids and
repairs missing/invalid ids from a stable digest of the JSON-encoded `(call_id, name, input)`
string tuple. Incomplete tuples remain unchanged, and call/result pairing uses the original call id.
Expand Down
96 changes: 50 additions & 46 deletions tests/providers/opencode-go-grok46-responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,50 +69,38 @@ describe("OpenCode Go Grok Responses compatibility", () => {
.toEqual({ effort: "xhigh" });
});

test("drops the hosted search tool that this exact destination rejects", () => {
test.each(["grok-4.6", "grok-4.7"])("keeps hosted search for %s and removes only the fields xAI refuses", model => {
const functionTool = { type: "function", name: "lookup", parameters: { type: "object" } };
const body = build("grok-4.6", {
const userLocation = { type: "approximate", country: "IT" };
const body = build(model, {
tools: [
{ type: "web_search", search_context_size: "medium" },
{ type: "web_search_preview" },
{
type: "web_search",
external_web_access: true,
search_context_size: "medium",
user_location: userLocation,
search_content_types: ["text", "image"],
},
{ type: "web_search_preview", external_web_access: true },
functionTool,
],
});

expect(body.tools).toEqual([functionTool]);
expect(body.tools).toEqual([
{ type: "web_search", user_location: userLocation, search_content_types: ["text", "image"], enable_image_search: true },
{ type: "web_search" },
functionTool,
]);
});

test("promotes additional_tools-only declarations before dropping refused hosted search", () => {
test("omits a cached-only search declaration instead of widening it to live search", () => {
const functionTool = { type: "function", name: "lookup", parameters: { type: "object" } };
const body = build("grok-4.6", {
input: [{
type: "additional_tools",
tools: [{ type: "web_search_preview" }, functionTool],
}],
});

expect(body.input).toEqual([]);
expect(body.tools).toEqual([functionTool]);
});

test("disables an explicit choice for a removed hosted tool", () => {
const body = build("grok-4.6", {
tools: [{ type: "web_search" }],
tool_choice: { type: "web_search" },
});

expect(body.tools).toEqual([]);
expect(body.tool_choice).toBe("none");
});

test("narrows allowed_tools to declarations that remain", () => {
const functionTool = { type: "function", name: "lookup", parameters: { type: "object" } };
const body = build("grok-4.6", {
tools: [{ type: "web_search_preview" }, functionTool],
const body = build("grok-4.7", {
tools: [{ type: "web_search", external_web_access: false }, functionTool],
tool_choice: {
type: "allowed_tools",
mode: "required",
tools: [{ type: "web_search_preview" }, { type: "function", name: "lookup" }],
tools: [{ type: "web_search" }, { type: "function", name: "lookup" }],
},
});

Expand All @@ -124,23 +112,37 @@ describe("OpenCode Go Grok Responses compatibility", () => {
});
});

test("disables required mode when every declared tool is removed", () => {
const body = build("grok-4.6", {
tools: [{ type: "web_search" }],
tool_choice: "required",
test("normalizes hosted search promoted out of additional_tools", () => {
const functionTool = { type: "function", name: "lookup", parameters: { type: "object" } };
const body = build("grok-4.7", {
input: [{
type: "additional_tools",
tools: [{ type: "web_search_preview", external_web_access: true, search_context_size: "low" }, functionTool],
}],
});

expect(body.tools).toEqual([]);
expect(body.tool_choice).toBe("none");
expect(body.input).toEqual([]);
expect(body.tools).toEqual([{ type: "web_search" }, functionTool]);
});

test("preserves hosted search for another model on OpenCode Go", () => {
const webSearch = { type: "web_search", search_context_size: "medium" };
const webSearch = { type: "web_search", external_web_access: true, search_context_size: "medium" };
const body = build("gpt-5.6-luna", { tools: [webSearch] });

expect(body.tools).toEqual([webSearch]);
});

test.each([
"https://opencode.ai/zen/v1",
"https://opencode.ai.evil.test/zen/go/v1",
"https://example.test/v1",
])("leaves Grok web_search fields alone on the unrelated destination %s", baseUrl => {
const webSearch = { type: "web_search", external_web_access: true, search_context_size: "medium" };
const body = build("grok-4.7", { tools: [webSearch] }, provider(baseUrl));

expect(body.tools).toEqual([webSearch]);
});

test("preserves hosted search for Grok 4.6 on another destination", () => {
const webSearch = { type: "web_search", search_context_size: "medium" };
const body = build("grok-4.6", { tools: [webSearch] }, provider("https://api.x.ai/v1"));
Expand Down Expand Up @@ -195,19 +197,21 @@ describe("OpenCode Go additional_tools placement", () => {
});
});

test("keeps nameless hosted tools for Luna and prunes Go Grok selectors after promotion", () => {
test("keeps nameless hosted tools for Luna and keeps them for Go Grok after promotion", () => {
const web = { type: "web_search" };
const raw = { input: [{ type: "additional_tools", tools: [web, lookup] }], tool_choice: {
type: "allowed_tools", mode: "required", tools: [web, { type: "function", name: "lookup" }],
} };
expect(build("gpt-5.6-luna", raw).tools).toEqual([web, lookup]);
const grok = build("grok-4.6", raw);
expect(grok.tools).toEqual([lookup]);
expect(grok.tool_choice).toEqual({
type: "allowed_tools", mode: "required", tools: [{ type: "function", name: "lookup" }],
});
expect(build("grok-4.6", { input: [{ type: "additional_tools", tools: [web] }], tool_choice: "required" }))
.toMatchObject({ input: [], tools: [], tool_choice: "none" });
expect(grok.tools).toEqual([web, lookup]);
expect(grok.tool_choice).toEqual(raw.tool_choice);
const cachedOnly = { type: "web_search", external_web_access: false };
// xAI 400s a `none` selector with nothing to select, so the deny-all is restated as the
// explicit empty catalog, exactly as on the direct xAI destinations.
const denied = build("grok-4.6", { input: [{ type: "additional_tools", tools: [cachedOnly] }], tool_choice: "required" });
expect(denied).toMatchObject({ input: [], tools: [] });
expect(denied).not.toHaveProperty("tool_choice");
});

test("does not promote additional tools restored from continuation history", () => {
Expand Down
17 changes: 8 additions & 9 deletions tests/responses/responses-hosted-tool-declaration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,17 +127,16 @@ describe("provider-declared unsupported hosted tools", () => {
}, ["web_search"]).input).toEqual([{ type: "additional_tools", tools: [functionTool] }]);
});

test("the declaration is additive to the built-in destination table, not a replacement", () => {
// An operator who declares only image_generation must still be protected from the
// known-broken grok-4.6 destination, and a declaration must not disable that table.
test("the declaration denies only what it names", () => {
// The built-in table is empty: OpenCode Go Grok, its former only row, accepts hosted
// web_search once the xAI-refused fields are normalized away (xai-web-search.ts), so
// neither an unrelated declaration nor the table may remove it there.
const declaredImageOnly = declaredUnsupportedHostedTools({ unsupportedHostedTools: ["image_generation"] });

expect(isHostedToolUnsupportedForModel(
"grok-4.6",
"web_search",
"https://opencode.ai/zen/go/v1",
declaredImageOnly,
)).toBe(true);
for (const model of ["grok-4.6", "grok-4.7"]) {
expect(isHostedToolUnsupportedForModel(model, "web_search", "https://opencode.ai/zen/go/v1", declaredImageOnly))
.toBe(false);
}
expect(isHostedToolUnsupportedForModel("grok-4.6", "web_search", GATEWAY_BASE_URL, declaredImageOnly))
.toBe(false);
expect(isHostedToolUnsupportedForModel(GATEWAY_MODEL, "image_gen", GATEWAY_BASE_URL, declaredImageOnly))
Expand Down
Loading