Skip to content
Closed
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
2 changes: 2 additions & 0 deletions src/adapters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export interface AdapterRequest {
convertedRoutedCustomToolNames?: ReadonlySet<string>;
/** Client tool-search names actually lowered to upstream function calls for this request. */
convertedRoutedToolSearchNames?: ReadonlySet<string>;
/** xAI-safe flat aliases created from Codex namespace tool containers. */
convertedNamespaceToolAliases?: ReadonlyMap<string, { namespace: string; name: string }>;
/** 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. */
Expand Down
13 changes: 12 additions & 1 deletion src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1506,6 +1508,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
const forward = provider.authMode === "forward";
let convertedRoutedCustomToolNames: Set<string> | undefined;
let convertedRoutedToolSearchNames: Set<string> | undefined;
let convertedNamespaceToolAliases: Map<string, { namespace: string; name: string }> | undefined;
const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true;
let outBody = stripPreviousResponseId(
parsed._rawBody,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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),
Expand Down Expand Up @@ -1600,6 +1610,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
releaseBodyObservation,
...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}),
...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}),
...(convertedNamespaceToolAliases ? { convertedNamespaceToolAliases } : {}),
...(tierLog ? { tierLog } : {}),
};
},
Expand Down
9 changes: 9 additions & 0 deletions src/providers/xai-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 10 additions & 4 deletions src/responses/custom-tool-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
export function collectRoutedCustomToolNames(
body: unknown,
options?: { includeNativePassthrough?: boolean },
): Set<string> {
const names = new Set<string>();
const visit = (value: unknown): void => {
if (Array.isArray(value)) {
Expand All @@ -29,7 +32,7 @@ export function collectRoutedCustomToolNames(body: unknown): Set<string> {
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);
}
Expand Down Expand Up @@ -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<string>;
} {
const names = collectRoutedCustomToolNames(body);
const names = collectRoutedCustomToolNames(body, options);
if (names.size === 0) return { body, names };
const callIds = new Set<string>();
collectConvertedCallIds(body, names, callIds);
Expand Down
194 changes: 194 additions & 0 deletions src/responses/namespace-tool-compat.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}

function toolGroups(body: Record<string, unknown>): 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<string, NamespaceToolTarget>;
} {
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<string>();
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<string, NamespaceToolTarget>();
const emittedNamespaceNames = new Map<string, NamespaceToolTarget>();
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<string, NamespaceToolTarget>,
): { 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<string, unknown> = {};
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, NamespaceToolTarget>,
): 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;
}
}
15 changes: 13 additions & 2 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading