Skip to content

Commit 439a9e9

Browse files
steipetevincentkoc
andauthored
fix(openai): quarantine unreadable tool schemas (#92921)
Snapshot unreadable OpenAI tool descriptors and schemas before payload construction, preserve healthy siblings, and reconcile hard tool choices with the surviving function inventory. Adds live-tested Responses and Chat Completions coverage, including allowed_tools, while keeping Anthropic regressions green. Related: #89413, #89013, #89016, #89378, #89543, #90200, #90283, #90286, #90397 Co-authored-by: Vincent Koc <[email protected]>
1 parent 1c86769 commit 439a9e9

14 files changed

Lines changed: 1515 additions & 112 deletions
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import OpenAI from "openai";
2+
import type { ChatCompletionCreateParamsNonStreaming } from "openai/resources/chat/completions.js";
3+
import type { ResponseCreateParamsNonStreaming } from "openai/resources/responses/responses.js";
4+
import { describe, expect, it } from "vitest";
5+
import type { Context, Model } from "../llm/types.js";
6+
import { isLiveTestEnabled } from "./live-test-helpers.js";
7+
import {
8+
buildOpenAICompletionsParams,
9+
buildOpenAIResponsesParams,
10+
} from "./openai-transport-stream.js";
11+
12+
const OPENAI_KEY = process.env.OPENAI_API_KEY ?? "";
13+
const LIVE = isLiveTestEnabled(["OPENAI_LIVE_TEST"]) && Boolean(OPENAI_KEY);
14+
const describeLive = LIVE ? describe : describe.skip;
15+
16+
const probeTools = [
17+
{
18+
name: "unreadable_probe",
19+
description: "Unreadable probe.",
20+
parameters: {
21+
type: "object",
22+
get properties(): never {
23+
throw new Error("live unreadable nested schema getter");
24+
},
25+
},
26+
},
27+
{
28+
name: "live_probe",
29+
description: "Return the requested probe value.",
30+
parameters: {
31+
type: "object",
32+
properties: { value: { type: "string" } },
33+
required: ["value"],
34+
additionalProperties: false,
35+
},
36+
},
37+
];
38+
39+
const context = {
40+
systemPrompt: "Call the requested function exactly once.",
41+
messages: [
42+
{
43+
role: "user",
44+
content: "Call live_probe with value exactly OPENAI_PROJECTION_OK.",
45+
timestamp: 1,
46+
},
47+
],
48+
tools: probeTools,
49+
} satisfies Context;
50+
51+
describeLive("OpenAI tool projection live", () => {
52+
const modelId = process.env.OPENCLAW_LIVE_OPENAI_TOOL_MODEL || "gpt-5.5";
53+
const client = new OpenAI({ apiKey: OPENAI_KEY });
54+
55+
it("calls a healthy Responses function after quarantining an unreadable sibling", async () => {
56+
const model = {
57+
id: modelId,
58+
name: modelId,
59+
api: "openai-responses",
60+
provider: "openai",
61+
baseUrl: "https://api.openai.com/v1",
62+
reasoning: true,
63+
input: ["text"],
64+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
65+
contextWindow: 200000,
66+
maxTokens: 256,
67+
} satisfies Model<"openai-responses">;
68+
const params = buildOpenAIResponsesParams(model, context, {
69+
maxTokens: 128,
70+
reasoning: "low",
71+
toolChoice: { type: "function", name: "live_probe" },
72+
});
73+
74+
const response = await client.responses.create({
75+
...params,
76+
stream: false,
77+
} as unknown as ResponseCreateParamsNonStreaming);
78+
const toolCall = response.output.find((item) => item.type === "function_call");
79+
80+
expect(toolCall).toMatchObject({
81+
type: "function_call",
82+
name: "live_probe",
83+
});
84+
expect(JSON.parse(toolCall?.arguments ?? "{}")).toEqual({
85+
value: "OPENAI_PROJECTION_OK",
86+
});
87+
}, 45_000);
88+
89+
it("calls a healthy Chat Completions function after quarantining an unreadable sibling", async () => {
90+
const model = {
91+
id: modelId,
92+
name: modelId,
93+
api: "openai-completions",
94+
provider: "openai",
95+
baseUrl: "https://api.openai.com/v1",
96+
reasoning: false,
97+
input: ["text"],
98+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
99+
contextWindow: 200000,
100+
maxTokens: 256,
101+
} satisfies Model<"openai-completions">;
102+
const params = buildOpenAICompletionsParams(model, context, {
103+
maxTokens: 128,
104+
toolChoice: {
105+
type: "allowed_tools",
106+
allowed_tools: {
107+
mode: "required",
108+
tools: [
109+
{ type: "function", function: { name: "unreadable_probe" } },
110+
{ type: "function", function: { name: "live_probe" } },
111+
],
112+
},
113+
},
114+
});
115+
const { stream_options: _streamOptions, ...nonStreamingParams } = params;
116+
117+
const response = await client.chat.completions.create({
118+
...nonStreamingParams,
119+
stream: false,
120+
} as unknown as ChatCompletionCreateParamsNonStreaming);
121+
const toolCall = response.choices[0]?.message.tool_calls?.[0];
122+
123+
if (!toolCall || toolCall.type !== "function") {
124+
throw new Error("OpenAI did not return the expected function tool call");
125+
}
126+
expect(toolCall).toMatchObject({
127+
type: "function",
128+
function: { name: "live_probe" },
129+
});
130+
expect(JSON.parse(toolCall.function.arguments)).toEqual({
131+
value: "OPENAI_PROJECTION_OK",
132+
});
133+
}, 45_000);
134+
});

0 commit comments

Comments
 (0)