diff --git a/src/adapters/base.ts b/src/adapters/base.ts index faeea0e959..44f1041053 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -72,6 +72,8 @@ export interface AdapterRequest { convertedRoutedCustomToolNames?: ReadonlySet; /** Client tool-search names actually lowered to upstream function calls for this request. */ convertedRoutedToolSearchNames?: ReadonlySet; + /** xAI-safe flat aliases created from Codex namespace tool containers. */ + convertedNamespaceToolAliases?: ReadonlyMap; /** Releases observation of a serialized request body after its final fetch attempt settles. */ releaseBodyObservation?: () => void; /** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */ diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index b85252b142..4bfda53075 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -13,6 +13,8 @@ import type { TranslatorBudget } from "../lib/translator-budget"; import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat"; import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat"; import { openaiResponsesUrl } from "./openai-responses-url"; +import { isXaiTransportBaseUrl } from "../providers/xai-transport"; +import { rewriteNamespaceToolsForXai } from "../responses/namespace-tool-compat"; import { createAdapterTierMetadata, } from "../providers/fastwire"; @@ -1506,6 +1508,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const forward = provider.authMode === "forward"; let convertedRoutedCustomToolNames: Set | undefined; let convertedRoutedToolSearchNames: Set | undefined; + let convertedNamespaceToolAliases: Map | undefined; const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; let outBody = stripPreviousResponseId( parsed._rawBody, @@ -1561,7 +1564,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = promoteClientLoadedTools(outBody); } if (provider.authMode !== "forward") { - const rewritten = rewriteRoutedCustomToolsForUpstream(outBody); + const rewritten = rewriteRoutedCustomToolsForUpstream(outBody, { + includeNativePassthrough: isXaiTransportBaseUrl(provider.baseUrl), + }); outBody = rewritten.body; convertedRoutedCustomToolNames = rewritten.names; } @@ -1572,6 +1577,11 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = rewritten.body; convertedRoutedToolSearchNames = rewritten.names; } + if (isXaiTransportBaseUrl(provider.baseUrl)) { + const rewritten = rewriteNamespaceToolsForXai(outBody); + outBody = rewritten.body; + convertedNamespaceToolAliases = rewritten.aliases; + } const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true }))))))); const finalBody = stripDisabledReasoningSummaries( normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId), @@ -1600,6 +1610,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): releaseBodyObservation, ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}), ...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}), + ...(convertedNamespaceToolAliases ? { convertedNamespaceToolAliases } : {}), ...(tierLog ? { tierLog } : {}), }; }, diff --git a/src/providers/xai-transport.ts b/src/providers/xai-transport.ts index 1e56506ecc..3c5252cfc5 100644 --- a/src/providers/xai-transport.ts +++ b/src/providers/xai-transport.ts @@ -4,6 +4,15 @@ import { resolveGithubCopilotTransport } from "./github-copilot-transport"; export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1"; +export function isXaiTransportBaseUrl(baseUrl: string): boolean { + try { + const host = new URL(baseUrl).hostname; + return host === "cli-chat-proxy.grok.com" || host === "api.x.ai"; + } catch { + return false; + } +} + export const XAI_GROK_COMPATIBILITY = { version: "0.2.93", userAgent: "opencodex-grok/0.2.93", diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index 83ddc1be19..1e7efa8260 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -18,7 +18,10 @@ export function customToolItemId(id: unknown): unknown { return id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id; } -export function collectRoutedCustomToolNames(body: unknown): Set { +export function collectRoutedCustomToolNames( + body: unknown, + options?: { includeNativePassthrough?: boolean }, +): Set { const names = new Set(); const visit = (value: unknown): void => { if (Array.isArray(value)) { @@ -29,7 +32,7 @@ export function collectRoutedCustomToolNames(body: unknown): Set { if ( value.type === "custom" && typeof value.name === "string" - && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(value.name) + && (options?.includeNativePassthrough === true || !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(value.name)) ) { names.add(value.name); } @@ -121,11 +124,14 @@ function rewriteForUpstream( return changed ? next : value; } -export function rewriteRoutedCustomToolsForUpstream(body: unknown): { +export function rewriteRoutedCustomToolsForUpstream( + body: unknown, + options?: { includeNativePassthrough?: boolean }, +): { body: unknown; names: Set; } { - const names = collectRoutedCustomToolNames(body); + const names = collectRoutedCustomToolNames(body, options); if (names.size === 0) return { body, names }; const callIds = new Set(); collectConvertedCallIds(body, names, callIds); diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts new file mode 100644 index 0000000000..2ded851a46 --- /dev/null +++ b/src/responses/namespace-tool-compat.ts @@ -0,0 +1,194 @@ +import { namespacedToolName } from "../types"; + +export interface NamespaceToolTarget { + namespace: string; + name: string; +} + +export class NamespaceToolCompatibilityError extends Error { + constructor(message: string) { + super(message); + this.name = "NamespaceToolCompatibilityError"; + } +} + +function isObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function toolGroups(body: Record): unknown[][] { + const groups: unknown[][] = []; + if (Array.isArray(body.tools)) groups.push(body.tools); + if (Array.isArray(body.input)) { + for (const item of body.input) { + if (isObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { + groups.push(item.tools); + } + } + } + return groups; +} + +function namespaceAlias(namespace: string, name: string): string { + return namespace === "functions" ? name : namespacedToolName(namespace, name); +} + +/** + * xAI's Responses endpoint does not accept Codex's client-only `namespace` tool container. + * Flatten only containers whose children remain callable without loss. Anything malformed, + * unsupported, or collision-prone fails closed instead of dropping a tool or widening access. + */ +export function rewriteNamespaceToolsForXai(body: unknown): { + body: unknown; + aliases: Map; +} { + if (!isObject(body)) return { body, aliases: new Map() }; + const groups = toolGroups(body); + if (!groups.some(group => group.some(tool => isObject(tool) && tool.type === "namespace"))) { + return { body, aliases: new Map() }; + } + + const flatNames = new Set(); + for (const group of groups) { + for (const tool of group) { + if (isObject(tool) && tool.type !== "namespace" && typeof tool.name === "string") { + flatNames.add(tool.name); + } + } + } + + const aliases = new Map(); + const emittedNamespaceNames = new Map(); + const rewriteGroup = (group: unknown[]): unknown[] => { + const rewritten: unknown[] = []; + for (const tool of group) { + if (!isObject(tool) || tool.type !== "namespace") { + rewritten.push(tool); + continue; + } + if (typeof tool.name !== "string" || tool.name.length === 0 || !Array.isArray(tool.tools)) { + throw new NamespaceToolCompatibilityError("xAI namespace tool requires a non-empty name and tools array"); + } + const namespace = tool.name; + for (const child of tool.tools) { + if (!isObject(child) || child.type !== "function" || typeof child.name !== "string" || child.name.length === 0) { + throw new NamespaceToolCompatibilityError( + `xAI cannot safely flatten unsupported child in namespace ${namespace}`, + ); + } + const alias = namespaceAlias(namespace, child.name); + if (flatNames.has(alias)) { + throw new NamespaceToolCompatibilityError(`xAI namespace tool alias collides with ${alias}`); + } + const target = { namespace, name: child.name }; + const previous = emittedNamespaceNames.get(alias); + if (previous && (previous.namespace !== namespace || previous.name !== child.name)) { + throw new NamespaceToolCompatibilityError(`xAI namespace tool alias is ambiguous: ${alias}`); + } + if (previous) { + throw new NamespaceToolCompatibilityError(`xAI namespace tool alias is duplicated: ${alias}`); + } + emittedNamespaceNames.set(alias, target); + if (namespace !== "functions") aliases.set(alias, target); + rewritten.push({ ...child, name: alias }); + } + } + return rewritten; + }; + + let tools = body.tools; + if (Array.isArray(tools)) tools = rewriteGroup(tools); + let input = body.input; + if (Array.isArray(input)) { + input = input.map(item => { + if (!isObject(item)) return item; + if (item.type === "additional_tools" && Array.isArray(item.tools)) { + return { ...item, tools: rewriteGroup(item.tools) }; + } + if (item.type === "function_call" && typeof item.namespace === "string" && typeof item.name === "string") { + const alias = namespaceAlias(item.namespace, item.name); + const target = emittedNamespaceNames.get(alias); + if (!target || target.namespace !== item.namespace || target.name !== item.name) { + throw new NamespaceToolCompatibilityError(`xAI namespace history references undeclared tool ${alias}`); + } + const { namespace: _namespace, ...rest } = item; + return { ...rest, name: alias }; + } + return item; + }); + } + + const rewriteChoiceEntry = (entry: unknown): unknown => { + if (!isObject(entry) || typeof entry.namespace !== "string" || typeof entry.name !== "string") return entry; + const alias = namespaceAlias(entry.namespace, entry.name); + const target = emittedNamespaceNames.get(alias); + if (!target || target.namespace !== entry.namespace || target.name !== entry.name) { + throw new NamespaceToolCompatibilityError(`xAI namespace tool choice references undeclared tool ${alias}`); + } + const { namespace: _namespace, ...rest } = entry; + return { ...rest, name: alias }; + }; + let toolChoice = body.tool_choice; + if (isObject(toolChoice)) { + if (toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) { + toolChoice = { ...toolChoice, tools: toolChoice.tools.map(rewriteChoiceEntry) }; + } else { + toolChoice = rewriteChoiceEntry(toolChoice); + } + } + + return { + body: { + ...body, + ...(tools !== body.tools ? { tools } : {}), + ...(input !== body.input ? { input } : {}), + ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}), + }, + aliases, + }; +} + +export function restoreNamespaceToolCalls( + value: unknown, + aliases: ReadonlyMap, +): { value: unknown; changed: boolean } { + if (Array.isArray(value)) { + let changed = false; + const restored = value.map(item => { + const result = restoreNamespaceToolCalls(item, aliases); + changed ||= result.changed; + return result.value; + }); + return changed ? { value: restored, changed: true } : { value, changed: false }; + } + if (!isObject(value)) return { value, changed: false }; + let changed = false; + const restored: Record = {}; + for (const [key, item] of Object.entries(value)) { + const result = restoreNamespaceToolCalls(item, aliases); + restored[key] = result.value; + changed ||= result.changed; + } + const target = value.type === "function_call" && typeof value.name === "string" + ? aliases.get(value.name) + : undefined; + if (target) { + restored.name = target.name; + restored.namespace = target.namespace; + changed = true; + } + return changed ? { value: restored, changed: true } : { value, changed: false }; +} + +export function restoreNamespaceToolCallsInJson( + text: string, + aliases: ReadonlyMap, +): string { + if (aliases.size === 0) return text; + try { + const restored = restoreNamespaceToolCalls(JSON.parse(text), aliases); + return restored.changed ? JSON.stringify(restored.value) : text; + } catch { + return text; + } +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3c7773e4e4..3e25a86087 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -274,6 +274,9 @@ import { import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair"; import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat"; +import { + restoreNamespaceToolCallsInJson, +} from "../../responses/namespace-tool-compat"; import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair"; import { collectDeclaredWireToolNames, @@ -2540,6 +2543,7 @@ async function handleResponsesInner( // would incorrectly disable restoration for the exact ambiguous-name case the alias fixes. routedToolSearchNames.add(name); } + const namespaceToolAliases = new Map(request.convertedNamespaceToolAliases ?? []); // #1700: the bridged paths refuse a call to a tool the request never declared // (`declaredToolNames`, src/bridge.ts). The passthrough had no equivalent, so a routed // provider's top-level `apply_patch` — which under Codex code mode exists only as a nested @@ -3071,8 +3075,12 @@ async function handleResponsesInner( && parsed._responseModelId !== parsed.modelId ? createResponsesModelPayloadRewrite(parsed._responseModelId) : undefined; - // Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first). + // Compose opt-in payload rewrites into one parse/stringify pass. Namespace restoration + // precedes custom-tool restoration so a namespaced custom tool recovers both shapes. const payloadRewrites = [ + namespaceToolAliases.size > 0 + ? (payload: string) => restoreNamespaceToolCallsInJson(payload, namespaceToolAliases) + : undefined, createImageGenCallRestoreRewrite(imageGenCallAliases), hasResponsesItemIdRepair(repairConfig) ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget) @@ -3291,7 +3299,10 @@ async function handleResponsesInner( inspectResponseLogJson(logCtx, text); const clientJson = (() => { const restored = restoreRoutedCustomCallsInJson( - restoreImageGenCallsInJson(text, imageGenCallAliases), + restoreNamespaceToolCallsInJson( + restoreImageGenCallsInJson(text, imageGenCallAliases), + namespaceToolAliases, + ), routedCustomToolNames, ); const restoredToolSearch = restoreRoutedToolSearchCallsInJson( diff --git a/tests/xai-responses-namespace-tools.test.ts b/tests/xai-responses-namespace-tools.test.ts new file mode 100644 index 0000000000..2523a6b7e1 --- /dev/null +++ b/tests/xai-responses-namespace-tools.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../src/adapters/openai-responses"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; +import type { OcxParsedRequest, OcxProviderConfig, OcxConfig } from "../src/types"; +import { + NamespaceToolCompatibilityError, + restoreNamespaceToolCallsInJson, +} from "../src/responses/namespace-tool-compat"; +import { handleResponses } from "../src/server/responses"; + +const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://cli-chat-proxy.grok.com/v1", + authMode: "key", + apiKey: "test-token", +}; + +function parsed(rawBody: Record): OcxParsedRequest { + return { + modelId: "grok-4.6", + context: { messages: [{ role: "user", content: "inspect", timestamp: 0 }] }, + stream: false, + options: {}, + _rawBody: rawBody, + }; +} + +function build(rawBody: Record) { + return createResponsesPassthroughAdapter(xaiProvider).buildRequest(parsed(rawBody), { + translatorBudget: createTestTranslatorBudget(), + }); +} + +describe("xAI Responses namespace tool compatibility", () => { + test("flattens the current Codex functions namespace without dropping callable tools", () => { + const request = build({ + model: "xai/grok-4.6", + input: "inspect", + tools: [ + { type: "function", name: "top", parameters: { type: "object" } }, + { type: "web_search" }, + { type: "function", name: "other", parameters: { type: "object" } }, + { + type: "namespace", + name: "functions", + description: "Codex callable tools", + tools: [ + { type: "function", name: "exec_command", description: "run", parameters: { type: "object" } }, + { type: "custom", name: "exec", description: "code", format: { type: "grammar" } }, + ], + }, + ], + }); + const body = JSON.parse(request.body) as { tools: Array> }; + + expect(body.tools.some(tool => tool.type === "namespace")).toBe(false); + expect(body.tools.some(tool => tool.type === "custom")).toBe(false); + expect(body.tools.map(tool => tool.name).filter(Boolean)).toEqual(["top", "other", "exec_command", "exec"]); + expect(request.convertedRoutedCustomToolNames).toContain("exec"); + expect(request.convertedNamespaceToolAliases?.size).toBe(0); + }); + + test("aliases MCP namespaces and restores the client-facing call", () => { + const request = build({ + model: "xai/grok-4.6", + input: [{ type: "function_call", call_id: "call_old", namespace: "mcp__fs", name: "read", arguments: "{}" }], + tools: [{ + type: "namespace", + name: "mcp__fs", + tools: [{ type: "function", name: "read", parameters: { type: "object" } }], + }], + tool_choice: { type: "function", namespace: "mcp__fs", name: "read" }, + }); + const body = JSON.parse(request.body) as { + tools: Array<{ type: string; name: string }>; + input: Array<{ namespace?: string; name: string }>; + tool_choice: { namespace?: string; name: string }; + }; + + expect(body.tools).toEqual([{ type: "function", name: "mcp__fs__read", parameters: { type: "object" } }]); + expect(body.input[0]).toMatchObject({ name: "mcp__fs__read" }); + expect(body.input[0].namespace).toBeUndefined(); + expect(body.tool_choice).toMatchObject({ type: "function", name: "mcp__fs__read" }); + const restored = restoreNamespaceToolCallsInJson( + JSON.stringify({ output: [{ type: "function_call", name: "mcp__fs__read", call_id: "call_1" }] }), + request.convertedNamespaceToolAliases!, + ); + expect(JSON.parse(restored).output[0]).toMatchObject({ + type: "function_call", + namespace: "mcp__fs", + name: "read", + call_id: "call_1", + }); + }); + + test("fails closed for unsupported children and alias collisions", () => { + expect(() => build({ + model: "xai/grok-4.6", + tools: [{ type: "namespace", name: "mcp__fs", tools: [{ type: "shell", name: "read" }] }], + })).toThrow(NamespaceToolCompatibilityError); + expect(() => build({ + model: "xai/grok-4.6", + tools: [ + { type: "function", name: "mcp__fs__read", parameters: { type: "object" } }, + { type: "namespace", name: "mcp__fs", tools: [{ type: "function", name: "read", parameters: {} }] }, + ], + })).toThrow("collides"); + }); + + test("leaves non-xAI Responses providers byte-compatible", () => { + const raw = { + model: "fixture/model", + tools: [{ type: "namespace", name: "functions", tools: [{ type: "function", name: "read", parameters: {} }] }], + }; + const request = createResponsesPassthroughAdapter({ + ...xaiProvider, + baseUrl: "https://fixture.test/v1", + }).buildRequest(parsed(raw), { translatorBudget: createTestTranslatorBudget() }); + expect(JSON.parse(request.body).tools[0].type).toBe("namespace"); + expect(request.convertedNamespaceToolAliases).toBeUndefined(); + }); + + test("round-trips a harmless namespaced call through handleResponses", async () => { + const savedFetch = globalThis.fetch; + let outbound: Record | undefined; + globalThis.fetch = (async (_input, init) => { + outbound = JSON.parse(String(init?.body)); + return Response.json({ + status: "completed", + output: [{ type: "function_call", name: "mcp__lab__inspect", call_id: "call_1", arguments: "{}" }], + }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { fixture: xaiProvider }, + } as OcxConfig; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/grok-4.6", + stream: false, + input: "inspect", + tools: [{ + type: "namespace", + name: "mcp__lab", + tools: [{ type: "function", name: "inspect", parameters: { type: "object" } }], + }], + }), + }), config, { model: "", provider: "" }); + expect((outbound?.tools as Array>)[0]).toMatchObject({ + type: "function", + name: "mcp__lab__inspect", + }); + expect((await response.json() as { output: Array> }).output[0]).toMatchObject({ + type: "function_call", + namespace: "mcp__lab", + name: "inspect", + }); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("restores a namespaced call on the streaming path Codex 0.148 uses", async () => { + const savedFetch = globalThis.fetch; + const item = { + type: "function_call", + name: "mcp__lab__inspect", + call_id: "call_stream", + arguments: "{}", + status: "completed", + }; + const upstream = [ + `event: response.output_item.added\ndata: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, item })}\n\n`, + `event: response.output_item.done\ndata: ${JSON.stringify({ type: "response.output_item.done", output_index: 0, item })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response: { status: "completed", output: [item] } })}\n\n`, + "data: [DONE]\n\n", + ].join(""); + globalThis.fetch = (async () => new Response(upstream, { + headers: { "content-type": "text/event-stream" }, + })) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { fixture: xaiProvider }, + } as OcxConfig; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/grok-4.6", + stream: true, + input: "inspect", + tools: [{ + type: "namespace", + name: "mcp__lab", + tools: [{ type: "function", name: "inspect", parameters: { type: "object" } }], + }], + }), + }), config, { model: "", provider: "" }); + const client = await response.text(); + expect(client).not.toContain("mcp__lab__inspect"); + expect(client.match(/\"namespace\":\"mcp__lab\"/g)).toHaveLength(3); + expect(client).toContain("data: [DONE]"); + } finally { + globalThis.fetch = savedFetch; + } + }); +});