Skip to content

Commit 1f58a7d

Browse files
committed
fix(openai): skip unreadable responses tool schemas
1 parent efda591 commit 1f58a7d

4 files changed

Lines changed: 320 additions & 41 deletions

File tree

src/agents/openai-transport-stream.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createServer } from "node:http";
2-
import type { Api, Model } from "openclaw/plugin-sdk/llm";
2+
import type { Api, Model, Tool } from "openclaw/plugin-sdk/llm";
33
import { describe, expect, it, vi } from "vitest";
44
import {
55
buildOpenAIResponsesParams,
@@ -4608,6 +4608,69 @@ describe("openai transport stream", () => {
46084608
});
46094609
});
46104610

4611+
it("skips unreadable responses transport tools without downgrading healthy strict tools", () => {
4612+
const { proxy, revoke } = Proxy.revocable({}, {});
4613+
revoke();
4614+
const brokenDescriptor = {
4615+
get name(): string {
4616+
throw new Error("tool name unavailable");
4617+
},
4618+
description: "Broken",
4619+
parameters: {},
4620+
} as Tool;
4621+
const brokenSchema = {
4622+
name: "broken_schema",
4623+
description: "Broken",
4624+
parameters: proxy,
4625+
} as unknown as Tool;
4626+
4627+
const params = buildOpenAIResponsesParams(
4628+
{
4629+
id: "gpt-5.4",
4630+
name: "GPT-5.4",
4631+
api: "openai-responses",
4632+
provider: "openai",
4633+
baseUrl: "https://api.openai.com/v1",
4634+
reasoning: true,
4635+
input: ["text"],
4636+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
4637+
contextWindow: 200000,
4638+
maxTokens: 8192,
4639+
} satisfies Model<"openai-responses">,
4640+
{
4641+
systemPrompt: "system",
4642+
messages: [],
4643+
tools: [
4644+
brokenDescriptor,
4645+
brokenSchema,
4646+
{
4647+
name: "lookup_weather",
4648+
description: "Get forecast",
4649+
parameters: {},
4650+
},
4651+
],
4652+
} as never,
4653+
undefined,
4654+
) as {
4655+
tools?: Array<{ name?: string; strict?: boolean; parameters?: Record<string, unknown> }>;
4656+
};
4657+
4658+
expect(params.tools).toEqual([
4659+
{
4660+
type: "function",
4661+
name: "lookup_weather",
4662+
description: "Get forecast",
4663+
strict: true,
4664+
parameters: {
4665+
type: "object",
4666+
properties: {},
4667+
required: [],
4668+
additionalProperties: false,
4669+
},
4670+
},
4671+
]);
4672+
});
4673+
46114674
it("passes explicit Responses tool_choice when tools are present", () => {
46124675
const params = buildOpenAIResponsesParams(
46134676
{

src/agents/openai-transport-stream.ts

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { calculateCost } from "../llm/model-utils.js";
2626
import { resolveAzureDeploymentNameFromMap } from "../llm/providers/azure-deployment-map.js";
2727
import { convertMessages } from "../llm/providers/openai-completions.js";
2828
import { clampOpenAIPromptCacheKey } from "../llm/providers/openai-prompt-cache.js";
29+
import { convertResponsesTools as convertOpenAIResponsesTools } from "../llm/providers/openai-responses-tools.js";
2930
import type { Api, Context, Model } from "../llm/types.js";
3031
import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js";
3132
import { parseStreamingJson } from "../llm/utils/json-parse.js";
@@ -1274,33 +1275,6 @@ function convertResponsesMessages(
12741275
return messages;
12751276
}
12761277

1277-
function convertResponsesTools(
1278-
tools: NonNullable<Context["tools"]>,
1279-
model: OpenAIModeModel,
1280-
options?: { strict?: boolean | null },
1281-
): FunctionTool[] {
1282-
const strict = resolveOpenAIStrictToolFlagWithDiagnostics(tools, options?.strict, {
1283-
transport: "responses",
1284-
model,
1285-
});
1286-
return sortTransportToolsByName(tools).map((tool): FunctionTool => {
1287-
const result = {
1288-
type: "function" as const,
1289-
name: tool.name,
1290-
description: tool.description,
1291-
parameters: normalizeOpenAIStrictToolParameters(
1292-
tool.parameters,
1293-
strict === true,
1294-
model.compat,
1295-
) as Record<string, unknown>,
1296-
} as FunctionTool;
1297-
if (strict !== undefined) {
1298-
result.strict = strict;
1299-
}
1300-
return result;
1301-
});
1302-
}
1303-
13041278
function resolveOpenAIStrictToolFlagWithDiagnostics(
13051279
tools: NonNullable<Context["tools"]>,
13061280
strictSetting: boolean | null | undefined,
@@ -2257,11 +2231,12 @@ export function buildOpenAIResponsesParams(
22572231
params.service_tier = options.serviceTier;
22582232
}
22592233
if (context.tools) {
2260-
params.tools = convertResponsesTools(context.tools, model as OpenAIModeModel, {
2234+
params.tools = convertOpenAIResponsesTools(context.tools, {
2235+
model: model as OpenAIModeModel,
22612236
strict: resolveOpenAIStrictToolSetting(model as OpenAIModeModel, {
22622237
transport: "stream",
22632238
}),
2264-
});
2239+
}) as FunctionTool[];
22652240
if (options?.toolChoice) {
22662241
params.tool_choice = options.toolChoice;
22672242
}

src/llm/providers/openai-responses-shared.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,115 @@ describe("convertResponsesTools", () => {
122122
convertResponsesTools([zeta, alpha]).map((tool) => expectResponsesFunctionTool(tool).name),
123123
).toEqual(["alpha", "zeta"]);
124124
});
125+
126+
it("skips unreadable tool descriptors while preserving healthy tools", () => {
127+
const broken = {
128+
get name(): string {
129+
const error = new Error("tool name unavailable");
130+
error.toString = () => {
131+
throw new Error("unprintable tool error");
132+
};
133+
throw error;
134+
},
135+
description: "Broken",
136+
parameters: {},
137+
} as Tool;
138+
const healthy = {
139+
name: "lookup_weather",
140+
description: "Get forecast",
141+
parameters: {},
142+
} satisfies Tool;
143+
144+
const converted = convertResponsesTools([broken, healthy], { model: nativeOpenAIModel });
145+
146+
expect(converted).toEqual([
147+
{
148+
type: "function",
149+
name: "lookup_weather",
150+
description: "Get forecast",
151+
strict: true,
152+
parameters: {
153+
type: "object",
154+
properties: {},
155+
required: [],
156+
additionalProperties: false,
157+
},
158+
},
159+
]);
160+
});
161+
162+
it("skips unreadable schemas without downgrading healthy strict tools", () => {
163+
const { proxy, revoke } = Proxy.revocable({}, {});
164+
revoke();
165+
const broken = {
166+
name: "broken_schema",
167+
description: "Broken",
168+
parameters: proxy,
169+
} as unknown as Tool;
170+
const healthy = {
171+
name: "lookup_weather",
172+
description: "Get forecast",
173+
parameters: {},
174+
} satisfies Tool;
175+
176+
const converted = convertResponsesTools([broken, healthy], { model: nativeOpenAIModel });
177+
178+
expect(converted).toEqual([
179+
{
180+
type: "function",
181+
name: "lookup_weather",
182+
description: "Get forecast",
183+
strict: true,
184+
parameters: {
185+
type: "object",
186+
properties: {},
187+
required: [],
188+
additionalProperties: false,
189+
},
190+
},
191+
]);
192+
});
193+
194+
it("keeps valid shared schemas while skipping cyclic schemas", () => {
195+
const sharedProperty = { type: "string" };
196+
const sharedSchema = {
197+
type: "object",
198+
properties: {
199+
first: sharedProperty,
200+
second: sharedProperty,
201+
},
202+
required: ["first", "second"],
203+
additionalProperties: false,
204+
} satisfies Tool["parameters"];
205+
const cyclicSchema: Record<string, unknown> = { type: "object" };
206+
cyclicSchema.properties = { self: cyclicSchema };
207+
208+
const converted = convertResponsesTools(
209+
[
210+
{
211+
name: "cyclic_schema",
212+
description: "Broken",
213+
parameters: cyclicSchema,
214+
} as Tool,
215+
{
216+
name: "shared_schema",
217+
description: "Shared",
218+
parameters: sharedSchema,
219+
},
220+
],
221+
{ model: nativeOpenAIModel },
222+
);
223+
224+
expect(converted).toEqual([
225+
{
226+
type: "function",
227+
name: "shared_schema",
228+
description: "Shared",
229+
strict: true,
230+
parameters: sharedSchema,
231+
},
232+
]);
233+
});
125234
});
126235

127236
describe("convertResponsesMessages", () => {

0 commit comments

Comments
 (0)