Skip to content

Commit 8eb65f5

Browse files
committed
fix(providers): skip unreadable OpenAI completion tool schemas
1 parent 474d6e5 commit 8eb65f5

2 files changed

Lines changed: 75 additions & 11 deletions

File tree

src/llm/providers/openai-completions.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ChatCompletionChunk } from "openai/resources/chat/completions.js";
22
import { describe, expect, it, vi } from "vitest";
33
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js";
4-
import type { Context, Model } from "../types.js";
4+
import type { Context, Model, Tool } from "../types.js";
55

66
type DeepPartial<T> = { [P in keyof T]?: DeepPartial<T[P]> };
77
type OpenAICompatibleDelta = DeepPartial<ChatCompletionChunk["choices"][number]["delta"]> & {
@@ -117,6 +117,60 @@ function makeFinishChunk(
117117
}
118118

119119
describe("OpenAI-compatible completions params", () => {
120+
it("skips unreadable tool schemas while preserving healthy tools", async () => {
121+
const unreadableSchemaTool = {
122+
name: "bad_schema",
123+
description: "Bad schema",
124+
get parameters(): never {
125+
throw new Error("parameters getter exploded");
126+
},
127+
} satisfies Tool;
128+
const healthyTool = {
129+
name: "lookup",
130+
description: "Lookup",
131+
parameters: {
132+
type: "object",
133+
properties: { query: { type: "string" } },
134+
required: ["query"],
135+
},
136+
} satisfies Tool;
137+
let capturedTools: unknown;
138+
139+
const stream = streamOpenAICompletions(
140+
createModel(32_000),
141+
{
142+
messages: [{ role: "user", content: "lookup", timestamp: 1 }],
143+
tools: [unreadableSchemaTool, healthyTool],
144+
} satisfies Context,
145+
{
146+
apiKey: "sk-test",
147+
onPayload(payload) {
148+
capturedTools = (payload as { tools?: unknown }).tools;
149+
throw new Error("stop before network");
150+
},
151+
},
152+
);
153+
154+
const result = await stream.result();
155+
156+
expect(result.stopReason).toBe("error");
157+
expect(capturedTools).toEqual([
158+
{
159+
type: "function",
160+
function: {
161+
name: "lookup",
162+
description: "Lookup",
163+
strict: false,
164+
parameters: {
165+
type: "object",
166+
properties: { query: { type: "string" } },
167+
required: ["query"],
168+
},
169+
},
170+
},
171+
]);
172+
});
173+
120174
it("clamps requested max tokens to the model output cap", async () => {
121175
let capturedMaxTokens: unknown;
122176
const stream = streamOpenAICompletions(createModel(32_000), context, {

src/llm/providers/openai-completions.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,16 +1155,26 @@ function convertTools(
11551155
tools: Tool[],
11561156
compat: ResolvedOpenAICompletionsCompat,
11571157
): OpenAI.Chat.Completions.ChatCompletionTool[] {
1158-
return tools.map((tool) => ({
1159-
type: "function",
1160-
function: {
1161-
name: tool.name,
1162-
description: tool.description,
1163-
parameters: tool.parameters as Record<string, unknown>, // TypeBox already generates JSON Schema
1164-
// Only include strict if provider supports it. Some reject unknown fields.
1165-
...(compat.supportsStrictMode && { strict: false }),
1166-
},
1167-
}));
1158+
return tools.flatMap((tool) => {
1159+
let parameters: Record<string, unknown>;
1160+
try {
1161+
parameters = tool.parameters as Record<string, unknown>; // TypeBox already generates JSON Schema
1162+
} catch {
1163+
return [];
1164+
}
1165+
return [
1166+
{
1167+
type: "function",
1168+
function: {
1169+
name: tool.name,
1170+
description: tool.description,
1171+
parameters,
1172+
// Only include strict if provider supports it. Some reject unknown fields.
1173+
...(compat.supportsStrictMode && { strict: false }),
1174+
},
1175+
},
1176+
];
1177+
});
11681178
}
11691179

11701180
function parseChunkUsage(

0 commit comments

Comments
 (0)