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
67 changes: 54 additions & 13 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown> = 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. */
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
Comment thread
goodwilliam0126 marked this conversation as resolved.
// Last, so promoted namespace children are also cleared of Codex-private fields.
outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false);
}
Expand Down
185 changes: 185 additions & 0 deletions src/adapters/xai-web-search.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<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;
};

/**
* 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<string, unknown> = { ...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<string, unknown>): 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<string, unknown>): 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<string, unknown>): Record<string, unknown> {
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<OcxProviderConfig, "baseUrl">,
): unknown {
if (!isXaiPublicApi(provider) || !isPlainObject(body)) return body;

let next: Record<string, unknown> = 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);
}
8 changes: 8 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 8 additions & 11 deletions tests/openai-responses-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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<string, unknown>[];
tools?: Record<string, unknown>[];
input: Array<{ type: string; tools: Record<string, unknown>[] }>;
tool_choice: Record<string, unknown>;
};

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", () => {
Expand Down Expand Up @@ -1070,7 +1067,7 @@ describe("OpenAI Responses passthrough sanitization", () => {
input: Array<{ tools: Record<string, unknown>[] }>;
};

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");
Expand Down
Loading
Loading