Skip to content

Commit d52aa68

Browse files
fix(responses): give every function tool schema an object root
Providers that validate strictly, DeepSeek among them, reject a function schema whose root has no type. The parser forwarded (t.parameters ?? {}) untouched, so a tool declared without parameters — or with properties but no root type — went out as an invalid schema. Both routes are covered. The parser normalizes at build time, and the raw Responses passthrough normalizes top-level tools and input[].additional_tools.tools before serializing, because that path copies _rawBody and never reaches the parser. Fixing only the parser would have left every passthrough request still broken. Valid schemas are returned unchanged rather than rebuilt, so existing property sets cannot be reordered or dropped on the way through. PR #745 by @white-source proposed the right normalizers but carried no tests, which is why it could not land as-is. Ablating both normalizers fails three tests: the parser root-type case and the two serialized passthrough cases. Co-authored-by: Mr.Hat <white-source@users.noreply.github.com>
1 parent 67219b3 commit d52aa68

4 files changed

Lines changed: 150 additions & 3 deletions

File tree

src/adapters/openai-responses.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,47 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
354354
return !!v && typeof v === "object" && !Array.isArray(v);
355355
}
356356

357+
function normalizeFunctionToolSchema(tool: unknown): unknown {
358+
if (!isPlainObject(tool) || tool.type !== "function") return tool;
359+
if (isPlainObject(tool.parameters) && tool.parameters.type === "object") return tool;
360+
return {
361+
...tool,
362+
parameters: { ...(isPlainObject(tool.parameters) ? tool.parameters : {}), type: "object" },
363+
};
364+
}
365+
366+
function normalizeToolSchemas(body: unknown): unknown {
367+
if (!isPlainObject(body)) return body;
368+
369+
const normalizeTools = (tools: unknown[]): unknown[] => {
370+
let changed = false;
371+
const normalized = tools.map((tool) => {
372+
const fixed = normalizeFunctionToolSchema(tool);
373+
if (fixed !== tool) changed = true;
374+
return fixed;
375+
});
376+
return changed ? normalized : tools;
377+
};
378+
379+
let normalizedBody = body;
380+
if (Array.isArray(body.tools)) {
381+
const tools = normalizeTools(body.tools);
382+
if (tools !== body.tools) normalizedBody = { ...normalizedBody, tools };
383+
}
384+
if (Array.isArray(normalizedBody.input)) {
385+
let inputChanged = false;
386+
const input = normalizedBody.input.map((item) => {
387+
if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item;
388+
const tools = normalizeTools(item.tools);
389+
if (tools === item.tools) return item;
390+
inputChanged = true;
391+
return { ...item, tools };
392+
});
393+
if (inputChanged) normalizedBody = { ...normalizedBody, input };
394+
}
395+
return normalizedBody;
396+
}
397+
357398
const MAX_RESPONSES_CALL_ID_LENGTH = 64;
358399
const REPAIRED_CALL_ID_PREFIX = "call_ocx_";
359400
const REPAIRED_CALL_ID_DIGEST_LENGTH = MAX_RESPONSES_CALL_ID_LENGTH - REPAIRED_CALL_ID_PREFIX.length;
@@ -938,7 +979,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
938979
if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
939980
outBody = buildRoutedCompactionBody(outBody);
940981
}
941-
const sanitizedBody = stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody)))))));
982+
const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody))))))));
942983
return {
943984
url,
944985
method: "POST",

src/responses/parser.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,15 @@ function allowedToolName(tool: unknown): string | undefined {
137137
function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
138138
if (!tools) return undefined;
139139
const out: OcxTool[] = [];
140+
const normalizeParameters = (raw: unknown): Record<string, unknown> => {
141+
if (isObj(raw) && raw.type === "object") return raw;
142+
return { ...(isObj(raw) ? raw : {}), type: "object" };
143+
};
140144
const pushFn = (t: Record<string, unknown>, namespace?: string) => {
141145
const tool: OcxTool = {
142146
name: t.name as string,
143147
description: (t.description as string) ?? "",
144-
parameters: (t.parameters ?? {}) as Record<string, unknown>,
148+
parameters: normalizeParameters(t.parameters),
145149
};
146150
if (t.strict !== undefined) tool.strict = t.strict as boolean;
147151
if (namespace) tool.namespace = namespace;

tests/openai-responses-passthrough.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,80 @@ describe("OpenAI Responses key-auth URL construction", () => {
5050
});
5151

5252
describe("OpenAI Responses passthrough sanitization", () => {
53+
test("normalizes top-level function schemas in the serialized raw body (#745)", () => {
54+
const validParameters = {
55+
type: "object",
56+
properties: { path: { type: "string" } },
57+
required: ["path"],
58+
additionalProperties: false,
59+
};
60+
const request = createResponsesPassthroughAdapter(provider).buildRequest({
61+
modelId: "gpt-5.5",
62+
context: { messages: [] },
63+
stream: true,
64+
options: {},
65+
_rawBody: {
66+
model: "gpt-5.5",
67+
input: [],
68+
tools: [
69+
{ type: "function", name: "missing_parameters" },
70+
{ type: "function", name: "missing_root_type", parameters: { properties: { query: { type: "string" } } } },
71+
{ type: "function", name: "valid_schema", parameters: validParameters },
72+
],
73+
},
74+
}, { headers: new Headers() });
75+
const body = JSON.parse(request.body) as { tools: { name: string; parameters: Record<string, unknown> }[] };
76+
77+
expect(body.tools).toEqual([
78+
{ type: "function", name: "missing_parameters", parameters: { type: "object" } },
79+
{
80+
type: "function",
81+
name: "missing_root_type",
82+
parameters: { type: "object", properties: { query: { type: "string" } } },
83+
},
84+
{ type: "function", name: "valid_schema", parameters: validParameters },
85+
]);
86+
});
87+
88+
test("normalizes additional_tools function schemas in the serialized raw body (#745)", () => {
89+
const validParameters = {
90+
type: "object",
91+
properties: { path: { type: "string" } },
92+
required: ["path"],
93+
additionalProperties: false,
94+
};
95+
const request = createResponsesPassthroughAdapter(provider).buildRequest({
96+
modelId: "gpt-5.5",
97+
context: { messages: [] },
98+
stream: true,
99+
options: {},
100+
_rawBody: {
101+
model: "gpt-5.5",
102+
input: [{
103+
type: "additional_tools",
104+
tools: [
105+
{ type: "function", name: "missing_parameters" },
106+
{ type: "function", name: "missing_root_type", parameters: { properties: { query: { type: "string" } } } },
107+
{ type: "function", name: "valid_schema", parameters: validParameters },
108+
],
109+
}],
110+
},
111+
}, { headers: new Headers() });
112+
const body = JSON.parse(request.body) as {
113+
input: { type: string; tools: { name: string; parameters: Record<string, unknown> }[] }[];
114+
};
115+
116+
expect(body.input[0].tools).toEqual([
117+
{ type: "function", name: "missing_parameters", parameters: { type: "object" } },
118+
{
119+
type: "function",
120+
name: "missing_root_type",
121+
parameters: { type: "object", properties: { query: { type: "string" } } },
122+
},
123+
{ type: "function", name: "valid_schema", parameters: validParameters },
124+
]);
125+
});
126+
53127
test("model reasoning-summary opt-out strips unsupported delivery fields (#323)", () => {
54128
const adapter = createResponsesPassthroughAdapter({
55129
adapter: "openai-responses",
@@ -1008,7 +1082,7 @@ describe("OpenAI Responses hosted-tool name conflicts", () => {
10081082
const body = JSON.parse(request.body) as { tools: Array<Record<string, unknown>> };
10091083

10101084
expect(body.tools).toEqual([
1011-
{ type: "function", name: "image_gen", parameters: {} },
1085+
{ type: "function", name: "image_gen", parameters: { type: "object" } },
10121086
{ type: "image_generation" },
10131087
]);
10141088
});

tests/responses-parser.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,34 @@ import { describe, expect, test } from "bun:test";
22
import { parseRequest } from "../src/responses/parser";
33

44
describe("Responses parser", () => {
5+
test("normalizes function tool schemas to an object root without corrupting valid schemas (#745)", () => {
6+
const validParameters = {
7+
type: "object",
8+
properties: { path: { type: "string" } },
9+
required: ["path"],
10+
additionalProperties: false,
11+
};
12+
const parsed = parseRequest({
13+
model: "test-model",
14+
input: "test",
15+
tools: [
16+
{ type: "function", name: "missing_parameters" },
17+
{ type: "function", name: "missing_root_type", parameters: { properties: { query: { type: "string" } } } },
18+
{ type: "function", name: "valid_schema", parameters: validParameters },
19+
],
20+
});
21+
22+
expect(parsed.context.tools).toEqual([
23+
{ name: "missing_parameters", description: "", parameters: { type: "object" } },
24+
{
25+
name: "missing_root_type",
26+
description: "",
27+
parameters: { type: "object", properties: { query: { type: "string" } } },
28+
},
29+
{ name: "valid_schema", description: "", parameters: validParameters },
30+
]);
31+
});
32+
533
test("describes the exact apply_patch freeform envelope", () => {
634
const parsed = parseRequest({
735
model: "xai/grok-4.5",

0 commit comments

Comments
 (0)