fix(providers): skip unreadable OpenAI responses tool schemas

This commit is contained in:
Vincent Koc
2026-06-04 11:59:05 +02:00
parent e5f3bf99cc
commit 7e35bc1e00
2 changed files with 116 additions and 18 deletions

View File

@@ -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", () => {

View File

@@ -24,6 +24,11 @@ type ResponsesFunctionTool = {
parameters: Record<string, unknown>;
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<string, unknown>,
};
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<string, unknown>,
};
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<typeof findOpenAIStrictToolSchemaDiagnostics> {
try {
return findOpenAIStrictToolSchemaDiagnostics(tools);
} catch {
return [];
}
}
function shouldLogStrictToolDowngradeDiagnostic(
diagnostics: ReturnType<typeof findOpenAIStrictToolSchemaDiagnostics>,
model: Model,