From 7e35bc1e00a3e0d34b630c65e02ac424ce56750f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 4 Jun 2026 11:59:05 +0200 Subject: [PATCH] fix(providers): skip unreadable OpenAI responses tool schemas --- .../providers/openai-responses-shared.test.ts | 47 ++++++++++ src/llm/providers/openai-responses-tools.ts | 87 +++++++++++++++---- 2 files changed, 116 insertions(+), 18 deletions(-) diff --git a/src/llm/providers/openai-responses-shared.test.ts b/src/llm/providers/openai-responses-shared.test.ts index 17d1896942ea..e2448f5a9564 100644 --- a/src/llm/providers/openai-responses-shared.test.ts +++ b/src/llm/providers/openai-responses-shared.test.ts @@ -122,6 +122,53 @@ describe("convertResponsesTools", () => { convertResponsesTools([zeta, alpha]).map((tool) => expectResponsesFunctionTool(tool).name), ).toEqual(["alpha", "zeta"]); }); + + it("skips unreadable tool schemas while preserving healthy strict tools", () => { + const unreadableSchemaTool = { + name: "bad_schema", + description: "Bad schema", + get parameters(): never { + throw new Error("parameters getter exploded"); + }, + } satisfies Tool; + const unreadableNestedSchemaTool = { + name: "bad_nested_schema", + description: "Bad nested schema", + parameters: { + type: "object", + get properties(): never { + throw new Error("properties getter exploded"); + }, + }, + } satisfies Tool; + const healthyTool = { + name: "lookup_weather", + description: "Get forecast", + parameters: {}, + } satisfies Tool; + + const converted = convertResponsesTools( + [unreadableSchemaTool, unreadableNestedSchemaTool, healthyTool], + { + model: nativeOpenAIModel, + }, + ); + + expect(converted).toEqual([ + { + type: "function", + name: "lookup_weather", + description: "Get forecast", + strict: true, + parameters: { + type: "object", + properties: {}, + required: [], + additionalProperties: false, + }, + }, + ]); + }); }); describe("convertResponsesMessages", () => { diff --git a/src/llm/providers/openai-responses-tools.ts b/src/llm/providers/openai-responses-tools.ts index 996a0592f7f7..0d3499612bb2 100644 --- a/src/llm/providers/openai-responses-tools.ts +++ b/src/llm/providers/openai-responses-tools.ts @@ -24,6 +24,11 @@ type ResponsesFunctionTool = { parameters: Record; strict?: boolean | null; }; +type ResponsesToolInput = { + name: string; + description?: string; + parameters: unknown; +}; // Converts OpenClaw tool schemas to OpenAI Responses tools, including strict-mode compatibility. const log = createSubsystemLogger("llm/openai-responses"); @@ -35,27 +40,58 @@ export function convertResponsesTools( tools: Tool[], options?: ConvertResponsesToolsOptions, ): OpenAITool[] { + const readableTools = snapshotResponsesToolInputs(tools); const strictSetting = resolveResponsesStrictToolSetting(options); - const strict = resolveResponsesStrictToolFlag(tools, strictSetting, options?.model); + const strict = resolveResponsesStrictToolFlag(readableTools, strictSetting, options?.model); // Sort tools before request construction so prompt-cache bytes stay deterministic. - return sortResponsesToolsByName(tools).map((tool) => { - const result: ResponsesFunctionTool = { - type: "function", - name: tool.name, - description: tool.description, - parameters: normalizeOpenAIStrictToolParameters( - tool.parameters, - strict === true, - options?.model?.compat as OpenAIToolSchemaCompat, - ) as Record, - }; - if (strict !== undefined) { - result.strict = strict; + return sortResponsesToolsByName(readableTools).flatMap((tool) => { + try { + const result: ResponsesFunctionTool = { + type: "function", + name: tool.name, + description: tool.description, + parameters: normalizeOpenAIStrictToolParameters( + tool.parameters, + strict === true, + options?.model?.compat as OpenAIToolSchemaCompat, + ) as Record, + }; + if (strict !== undefined) { + result.strict = strict; + } + return [result as OpenAITool]; + } catch { + return []; } - return result as OpenAITool; }); } +function snapshotResponsesToolInputs(tools: readonly Tool[]): ResponsesToolInput[] { + return tools.flatMap((tool) => { + try { + const description = tool.description; + const parameters = materializeResponsesToolParameters(tool.parameters); + return [ + { + name: tool.name, + ...(description !== undefined ? { description } : {}), + parameters, + }, + ]; + } catch { + return []; + } + }); +} + +function materializeResponsesToolParameters(parameters: unknown): unknown { + const encoded = JSON.stringify(parameters ?? {}); + if (encoded === undefined) { + throw new Error("OpenAI Responses tool parameters are not JSON serializable"); + } + return JSON.parse(encoded) as unknown; +} + function resolveResponsesStrictToolSetting( options: ConvertResponsesToolsOptions | undefined, ): boolean | null | undefined { @@ -72,13 +108,18 @@ function resolveResponsesStrictToolSetting( } function resolveResponsesStrictToolFlag( - tools: Tool[], + tools: readonly { name?: unknown; parameters: unknown }[], strictSetting: boolean | null | undefined, model: Model | undefined, ): boolean | undefined { - const strict = resolveOpenAIStrictToolFlagForInventory(tools, strictSetting); + let strict: boolean | undefined; + try { + strict = resolveOpenAIStrictToolFlagForInventory(tools, strictSetting); + } catch { + strict = strictSetting === true ? false : strictSetting === false ? false : undefined; + } if (strictSetting === true && strict === false && model && log.isEnabled("debug", "any")) { - const diagnostics = findOpenAIStrictToolSchemaDiagnostics(tools); + const diagnostics = readResponsesStrictToolSchemaDiagnostics(tools); if (shouldLogStrictToolDowngradeDiagnostic(diagnostics, model)) { const sample = diagnostics.slice(0, 5).map((entry) => ({ tool: entry.toolName ?? `tool[${entry.toolIndex}]`, @@ -100,6 +141,16 @@ function resolveResponsesStrictToolFlag( return strict; } +function readResponsesStrictToolSchemaDiagnostics( + tools: readonly { name?: unknown; parameters: unknown }[], +): ReturnType { + try { + return findOpenAIStrictToolSchemaDiagnostics(tools); + } catch { + return []; + } +} + function shouldLogStrictToolDowngradeDiagnostic( diagnostics: ReturnType, model: Model,