diff --git a/src/llm/providers/openai-completions.test.ts b/src/llm/providers/openai-completions.test.ts index 13895a809ecc..c1949426ab63 100644 --- a/src/llm/providers/openai-completions.test.ts +++ b/src/llm/providers/openai-completions.test.ts @@ -1,7 +1,7 @@ import type { ChatCompletionChunk } from "openai/resources/chat/completions.js"; import { describe, expect, it, vi } from "vitest"; import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js"; -import type { Context, Model } from "../types.js"; +import type { Context, Model, Tool } from "../types.js"; type DeepPartial = { [P in keyof T]?: DeepPartial }; type OpenAICompatibleDelta = DeepPartial & { @@ -117,6 +117,60 @@ function makeFinishChunk( } describe("OpenAI-compatible completions params", () => { + it("skips unreadable tool schemas while preserving healthy tools", async () => { + const unreadableSchemaTool = { + name: "bad_schema", + description: "Bad schema", + get parameters(): never { + throw new Error("parameters getter exploded"); + }, + } satisfies Tool; + const healthyTool = { + name: "lookup", + description: "Lookup", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + } satisfies Tool; + let capturedTools: unknown; + + const stream = streamOpenAICompletions( + createModel(32_000), + { + messages: [{ role: "user", content: "lookup", timestamp: 1 }], + tools: [unreadableSchemaTool, healthyTool], + } satisfies Context, + { + apiKey: "sk-test", + onPayload(payload) { + capturedTools = (payload as { tools?: unknown }).tools; + throw new Error("stop before network"); + }, + }, + ); + + const result = await stream.result(); + + expect(result.stopReason).toBe("error"); + expect(capturedTools).toEqual([ + { + type: "function", + function: { + name: "lookup", + description: "Lookup", + strict: false, + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + }, + }, + ]); + }); + it("clamps requested max tokens to the model output cap", async () => { let capturedMaxTokens: unknown; const stream = streamOpenAICompletions(createModel(32_000), context, { diff --git a/src/llm/providers/openai-completions.ts b/src/llm/providers/openai-completions.ts index 13a0229ee348..4560b58392d1 100644 --- a/src/llm/providers/openai-completions.ts +++ b/src/llm/providers/openai-completions.ts @@ -1155,16 +1155,26 @@ function convertTools( tools: Tool[], compat: ResolvedOpenAICompletionsCompat, ): OpenAI.Chat.Completions.ChatCompletionTool[] { - return tools.map((tool) => ({ - type: "function", - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters as Record, // TypeBox already generates JSON Schema - // Only include strict if provider supports it. Some reject unknown fields. - ...(compat.supportsStrictMode && { strict: false }), - }, - })); + return tools.flatMap((tool) => { + let parameters: Record; + try { + parameters = tool.parameters as Record; // TypeBox already generates JSON Schema + } catch { + return []; + } + return [ + { + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters, + // Only include strict if provider supports it. Some reject unknown fields. + ...(compat.supportsStrictMode && { strict: false }), + }, + }, + ]; + }); } function parseChunkUsage(