Skip to content

Commit d346be5

Browse files
authored
Merge pull request #124 from Wibias/codex/fix-xai-nested-tool-schema
fix(xai): normalize nested tool schemas
2 parents 0ec84e1 + 7e449d3 commit d346be5

2 files changed

Lines changed: 115 additions & 5 deletions

File tree

src/adapters/openai-chat.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,49 @@ function safeToolName(name: string | undefined): string {
125125
return sanitized;
126126
}
127127

128-
function toolsToChatFormat(parsed: OcxParsedRequest): unknown[] | undefined {
128+
const XAI_SCHEMA_BASE_URLS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]);
129+
130+
function isXaiSchemaTarget(provider: OcxProviderConfig): boolean {
131+
try {
132+
return XAI_SCHEMA_BASE_URLS.has(new URL(provider.baseUrl).hostname);
133+
} catch {
134+
return false;
135+
}
136+
}
137+
138+
function expandXaiRootObjectSchemas(schema: unknown): Record<string, unknown>[] | undefined {
139+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
140+
const obj = schema as Record<string, unknown>;
141+
const compositionKey = ["oneOf", "anyOf"].find(key => Array.isArray(obj[key]));
142+
if (!compositionKey) {
143+
if (obj.type !== undefined && obj.type !== "object") return undefined;
144+
return [{ ...obj, type: "object" }];
145+
}
146+
147+
const siblings = Object.fromEntries(Object.entries(obj).filter(([key]) => key !== compositionKey));
148+
const branches = obj[compositionKey];
149+
if (!Array.isArray(branches)) return undefined;
150+
const expanded: Record<string, unknown>[] = [];
151+
for (const branch of branches) {
152+
const variants = expandXaiRootObjectSchemas(branch);
153+
if (!variants) return undefined;
154+
for (const variant of variants) expanded.push({ ...siblings, ...variant });
155+
}
156+
return expanded.length > 0 ? expanded : undefined;
157+
}
158+
159+
function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown> | undefined {
160+
const variants = expandXaiRootObjectSchemas(parameters);
161+
if (!variants) return undefined;
162+
if (variants.length === 1) return variants[0];
163+
const root = parameters && typeof parameters === "object" && !Array.isArray(parameters)
164+
? parameters as Record<string, unknown>
165+
: {};
166+
const metadata = Object.fromEntries(Object.entries(root).filter(([key]) => key !== "oneOf" && key !== "anyOf" && key !== "type"));
167+
return { ...metadata, oneOf: variants };
168+
}
169+
170+
function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
129171
if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
130172
const allowed = isAllowedToolChoice(parsed.options.toolChoice)
131173
? new Set(parsed.options.toolChoice.allowedTools)
@@ -134,15 +176,21 @@ function toolsToChatFormat(parsed: OcxParsedRequest): unknown[] | undefined {
134176
? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed))
135177
: parsed.context.tools;
136178
if (tools.length === 0) return undefined;
137-
return tools.map(t => ({
179+
const xaiTarget = isXaiSchemaTarget(provider);
180+
const formatted = tools.flatMap(t => {
181+
const parameters = xaiTarget ? normalizeXaiToolParameters(t.parameters) : t.parameters;
182+
if (parameters === undefined) return [];
183+
return [{
138184
type: "function",
139185
function: {
140186
name: namespacedToolName(t.namespace, t.name),
141187
description: t.description,
142-
parameters: t.parameters,
188+
parameters,
143189
...(t.strict !== undefined ? { strict: t.strict } : {}),
144190
},
145-
}));
191+
}];
192+
});
193+
return formatted.length > 0 ? formatted : undefined;
146194
}
147195

148196
function toolChoiceToChatFormat(tc: OcxParsedRequest["options"]["toolChoice"], tools: OcxParsedRequest["context"]["tools"]): unknown {
@@ -190,7 +238,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
190238
}
191239

192240
const messages = messagesToChatFormat(parsed, provider);
193-
const tools = toolsToChatFormat(parsed);
241+
const tools = toolsToChatFormat(parsed, provider);
194242
const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools);
195243

196244
const body: Record<string, unknown> = {

tests/xai-transport.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, test } from "bun:test";
22
import { createOpenAIChatAdapter } from "../src/adapters/openai-chat";
3+
import { parseRequest } from "../src/responses/parser";
34
import { buildModelsRequest } from "../src/oauth";
45
import {
56
resolveProviderTransport,
@@ -85,6 +86,67 @@ describe("xAI auth-mode transport selection", () => {
8586
"x-xai-token-auth": "xai-grok-cli",
8687
});
8788
});
89+
90+
test("flattens nested root tool unions for xAI without changing other providers", () => {
91+
const schema = {
92+
oneOf: [
93+
{ type: "object", properties: { mode: { type: "string", enum: ["view"] } } },
94+
{ oneOf: [{ type: "object", properties: { path: { type: "string" } } }, { type: "object", properties: {} }] },
95+
],
96+
$defs: { shared: { type: "string" } },
97+
};
98+
const request = createOpenAIChatAdapter(provider("key")).buildRequest({
99+
...parsed(),
100+
context: { messages: [], tools: [{ name: "automation_update", description: "Update", parameters: schema }] },
101+
});
102+
const xaiParameters = (JSON.parse(request.body) as { tools: Array<{ function: { parameters: Record<string, unknown> } }> }).tools[0].function.parameters;
103+
104+
expect(xaiParameters.type).toBeUndefined();
105+
expect(xaiParameters.oneOf).toHaveLength(3);
106+
expect((xaiParameters.oneOf as Record<string, unknown>[]).every(branch => branch.type === "object")).toBe(true);
107+
expect(xaiParameters.$defs).toEqual(schema.$defs);
108+
109+
const otherRequest = createOpenAIChatAdapter({ ...provider("key"), baseUrl: "https://example.test/v1" }).buildRequest({
110+
...parsed(),
111+
context: { messages: [], tools: [{ name: "automation_update", description: "Update", parameters: schema }] },
112+
});
113+
expect((JSON.parse(otherRequest.body) as { tools: Array<{ function: { parameters: unknown } }> }).tools[0].function.parameters).toEqual(schema);
114+
});
115+
116+
test("omits an xAI tool whose root schema cannot be normalized safely", () => {
117+
const request = createOpenAIChatAdapter(provider("key")).buildRequest({
118+
...parsed(),
119+
context: { messages: [], tools: [{ name: "unsafe", description: "Unsafe", parameters: { oneOf: [{ type: "string" }] } }] },
120+
});
121+
expect(JSON.parse(request.body).tools).toBeUndefined();
122+
});
123+
124+
test("normalizes a tool loaded from tool_search history on later turns", () => {
125+
const parsedRequest = parseRequest({
126+
model: "xai/grok-4.5",
127+
input: [
128+
{ type: "tool_search_call", call_id: "search-1", arguments: { query: "automation" } },
129+
{
130+
type: "tool_search_output",
131+
call_id: "search-1",
132+
status: "completed",
133+
tools: [{
134+
type: "function",
135+
name: "automation_update",
136+
description: "Update an automation",
137+
parameters: { oneOf: [{ type: "object", properties: {} }, { oneOf: [{ type: "object", properties: {} }] }] },
138+
}],
139+
},
140+
{ type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] },
141+
],
142+
});
143+
const request = createOpenAIChatAdapter(provider("key")).buildRequest(parsedRequest);
144+
const body = JSON.parse(request.body) as { tools: Array<{ function: { name: string; parameters: Record<string, unknown> } }> };
145+
const tool = body.tools.find(entry => entry.function.name === "automation_update");
146+
147+
expect(tool?.function.parameters.oneOf).toHaveLength(2);
148+
expect((tool?.function.parameters.oneOf as Record<string, unknown>[]).every(branch => branch.type === "object")).toBe(true);
149+
});
88150
});
89151

90152
describe("xAI prompt-cache conv-id affinity", () => {

0 commit comments

Comments
 (0)