mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
fix: normalize MCP tool schemas for Bedrock and OpenAI strict mode (#10148)
This commit is contained in:
parent
0b86796b8f
commit
aa3b4ae9cc
5 changed files with 407 additions and 178 deletions
|
|
@ -91,21 +91,90 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
|
|||
const bedrockTools = convertToolsForBedrock(testTools)
|
||||
|
||||
expect(bedrockTools).toHaveLength(2)
|
||||
expect(bedrockTools[0]).toEqual({
|
||||
toolSpec: {
|
||||
name: "read_file",
|
||||
description: "Read a file from the filesystem",
|
||||
inputSchema: {
|
||||
json: {
|
||||
|
||||
// Check structure and key properties (normalizeToolSchema adds additionalProperties: false)
|
||||
const tool = bedrockTools[0]
|
||||
expect(tool.toolSpec.name).toBe("read_file")
|
||||
expect(tool.toolSpec.description).toBe("Read a file from the filesystem")
|
||||
expect(tool.toolSpec.inputSchema.json.type).toBe("object")
|
||||
expect(tool.toolSpec.inputSchema.json.properties.path.type).toBe("string")
|
||||
expect(tool.toolSpec.inputSchema.json.properties.path.description).toBe("The path to the file")
|
||||
expect(tool.toolSpec.inputSchema.json.required).toEqual(["path"])
|
||||
// normalizeToolSchema adds additionalProperties: false by default
|
||||
expect(tool.toolSpec.inputSchema.json.additionalProperties).toBe(false)
|
||||
})
|
||||
|
||||
it("should transform type arrays to anyOf for JSON Schema 2020-12 compliance", () => {
|
||||
const convertToolsForBedrock = (handler as any).convertToolsForBedrock.bind(handler)
|
||||
|
||||
// Tools with type: ["string", "null"] syntax (valid in draft-07 but not 2020-12)
|
||||
const toolsWithNullableTypes = [
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "execute_command",
|
||||
description: "Execute a command",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "The path to the file" },
|
||||
command: { type: "string", description: "The command to execute" },
|
||||
cwd: {
|
||||
type: ["string", "null"],
|
||||
description: "Working directory (optional)",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
required: ["command", "cwd"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read files",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
files: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
line_ranges: {
|
||||
type: ["array", "null"],
|
||||
items: { type: "integer" },
|
||||
description: "Optional line ranges",
|
||||
},
|
||||
},
|
||||
required: ["path", "line_ranges"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["files"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const bedrockTools = convertToolsForBedrock(toolsWithNullableTypes)
|
||||
|
||||
expect(bedrockTools).toHaveLength(2)
|
||||
|
||||
// First tool: cwd should be transformed from type: ["string", "null"] to anyOf
|
||||
const executeCommandSchema = bedrockTools[0].toolSpec.inputSchema.json as any
|
||||
expect(executeCommandSchema.properties.cwd.anyOf).toEqual([{ type: "string" }, { type: "null" }])
|
||||
expect(executeCommandSchema.properties.cwd.type).toBeUndefined()
|
||||
expect(executeCommandSchema.properties.cwd.description).toBe("Working directory (optional)")
|
||||
|
||||
// Second tool: line_ranges should be transformed from type: ["array", "null"] to anyOf
|
||||
const readFileSchema = bedrockTools[1].toolSpec.inputSchema.json as any
|
||||
const lineRanges = readFileSchema.properties.files.items.properties.line_ranges
|
||||
expect(lineRanges.anyOf).toEqual([{ type: "array" }, { type: "null" }])
|
||||
expect(lineRanges.type).toBeUndefined()
|
||||
// items also gets additionalProperties: false from normalization
|
||||
expect(lineRanges.items.type).toBe("integer")
|
||||
expect(lineRanges.description).toBe("Optional line ranges")
|
||||
})
|
||||
|
||||
it("should filter non-function tools", () => {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { ModelInfo as CacheModelInfo } from "../transform/cache-strategy/types"
|
|||
import { convertToBedrockConverseMessages as sharedConverter } from "../transform/bedrock-converse-format"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
import { shouldUseReasoningBudget } from "../../shared/api"
|
||||
import { normalizeToolSchema } from "../../utils/json-schema"
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
|
||||
/************************************************************************************
|
||||
|
|
@ -1212,6 +1213,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
|
|||
|
||||
/**
|
||||
* Convert OpenAI tool definitions to Bedrock Converse format
|
||||
* Transforms JSON Schema to draft 2020-12 compliant format required by Claude models.
|
||||
* @param tools Array of OpenAI ChatCompletionTool definitions
|
||||
* @returns Array of Bedrock Tool definitions
|
||||
*/
|
||||
|
|
@ -1225,7 +1227,9 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
|
|||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
inputSchema: {
|
||||
json: tool.function.parameters as Record<string, unknown>,
|
||||
// Normalize schema to JSON Schema draft 2020-12 compliant format
|
||||
// This converts type: ["T", "null"] to anyOf: [{type: "T"}, {type: "null"}]
|
||||
json: normalizeToolSchema(tool.function.parameters as Record<string, unknown>),
|
||||
},
|
||||
},
|
||||
}) as Tool,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type OpenAI from "openai"
|
||||
import { McpHub } from "../../../../services/mcp/McpHub"
|
||||
import { buildMcpToolName } from "../../../../utils/mcp-name"
|
||||
import { ToolInputSchema, type JsonSchema } from "../../../../utils/json-schema"
|
||||
import { normalizeToolSchema, type JsonSchema } from "../../../../utils/json-schema"
|
||||
|
||||
/**
|
||||
* Dynamically generates native tool definitions for all enabled tools across connected MCP servers.
|
||||
|
|
@ -43,14 +43,13 @@ export function getMcpServerTools(mcpHub?: McpHub): OpenAI.Chat.ChatCompletionTo
|
|||
|
||||
const originalSchema = tool.inputSchema as Record<string, unknown> | undefined
|
||||
|
||||
// Parse with ToolInputSchema to ensure additionalProperties: false is set recursively
|
||||
// Normalize schema for JSON Schema 2020-12 compliance (type arrays → anyOf)
|
||||
let parameters: JsonSchema
|
||||
if (originalSchema) {
|
||||
const result = ToolInputSchema.safeParse(originalSchema)
|
||||
parameters = result.success ? result.data : (originalSchema as JsonSchema)
|
||||
parameters = normalizeToolSchema(originalSchema) as JsonSchema
|
||||
} else {
|
||||
// No schema provided - create a minimal valid schema
|
||||
parameters = ToolInputSchema.parse({ type: "object" })
|
||||
parameters = { type: "object", additionalProperties: false } as JsonSchema
|
||||
}
|
||||
|
||||
const toolDefinition: OpenAI.Chat.ChatCompletionTool = {
|
||||
|
|
|
|||
|
|
@ -1,213 +1,268 @@
|
|||
import { ToolInputSchema } from "../json-schema"
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { normalizeToolSchema } from "../json-schema"
|
||||
|
||||
describe("ToolInputSchema", () => {
|
||||
it("should validate and default additionalProperties to false", () => {
|
||||
const schema = {
|
||||
describe("normalizeToolSchema", () => {
|
||||
it("should convert type array to anyOf for nullable string", () => {
|
||||
const input = {
|
||||
type: ["string", "null"],
|
||||
description: "Optional field",
|
||||
}
|
||||
|
||||
const result = normalizeToolSchema(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
description: "Optional field",
|
||||
additionalProperties: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should convert type array to anyOf for nullable array", () => {
|
||||
const input = {
|
||||
type: ["array", "null"],
|
||||
items: { type: "string" },
|
||||
description: "Optional array",
|
||||
}
|
||||
|
||||
const result = normalizeToolSchema(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
anyOf: [{ type: "array" }, { type: "null" }],
|
||||
items: { type: "string", additionalProperties: false },
|
||||
description: "Optional array",
|
||||
additionalProperties: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should preserve single type values", () => {
|
||||
const input = {
|
||||
type: "string",
|
||||
description: "Required field",
|
||||
}
|
||||
|
||||
const result = normalizeToolSchema(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "string",
|
||||
description: "Required field",
|
||||
additionalProperties: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should recursively transform nested properties", () => {
|
||||
const input = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
optional: {
|
||||
type: ["string", "null"],
|
||||
description: "Optional nested field",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
}
|
||||
|
||||
const result = ToolInputSchema.parse(schema)
|
||||
const result = normalizeToolSchema(input)
|
||||
|
||||
expect(result.type).toBe("object")
|
||||
expect(result.additionalProperties).toBe(false)
|
||||
})
|
||||
|
||||
it("should recursively apply defaults to nested schemas", () => {
|
||||
const schema = {
|
||||
expect(result).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
user: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
name: { type: "string", additionalProperties: false },
|
||||
optional: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
description: "Optional nested field",
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should recursively transform items in arrays", () => {
|
||||
const input = {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
line_ranges: {
|
||||
type: ["array", "null"],
|
||||
items: { type: "integer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = ToolInputSchema.parse(schema)
|
||||
const result = normalizeToolSchema(input)
|
||||
|
||||
expect(result.additionalProperties).toBe(false)
|
||||
expect((result.properties as any).user.additionalProperties).toBe(false)
|
||||
expect(result).toEqual({
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", additionalProperties: false },
|
||||
line_ranges: {
|
||||
anyOf: [{ type: "array" }, { type: "null" }],
|
||||
items: { type: "integer", additionalProperties: false },
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
additionalProperties: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should apply defaults to object schemas in array items", () => {
|
||||
const schema = {
|
||||
it("should handle deeply nested structures", () => {
|
||||
const input = {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
files: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = ToolInputSchema.parse(schema)
|
||||
|
||||
expect(result.additionalProperties).toBe(false)
|
||||
expect((result.properties as any).items.items.additionalProperties).toBe(false)
|
||||
})
|
||||
|
||||
it("should throw on invalid schema", () => {
|
||||
const invalidSchema = { type: "invalid-type" }
|
||||
|
||||
expect(() => ToolInputSchema.parse(invalidSchema)).toThrow()
|
||||
})
|
||||
|
||||
it("should use safeParse for error handling", () => {
|
||||
const invalidSchema = { type: "invalid-type" }
|
||||
|
||||
const result = ToolInputSchema.safeParse(invalidSchema)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should apply defaults in anyOf schemas", () => {
|
||||
const schema = {
|
||||
anyOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "string" }],
|
||||
}
|
||||
|
||||
const result = ToolInputSchema.parse(schema)
|
||||
|
||||
expect((result.anyOf as any)[0].additionalProperties).toBe(false)
|
||||
expect((result.anyOf as any)[1].additionalProperties).toBe(false)
|
||||
})
|
||||
|
||||
it("should apply defaults in oneOf schemas", () => {
|
||||
const schema = {
|
||||
oneOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "number" }],
|
||||
}
|
||||
|
||||
const result = ToolInputSchema.parse(schema)
|
||||
|
||||
expect((result.oneOf as any)[0].additionalProperties).toBe(false)
|
||||
expect((result.oneOf as any)[1].additionalProperties).toBe(false)
|
||||
})
|
||||
|
||||
it("should apply defaults in allOf schemas", () => {
|
||||
const schema = {
|
||||
allOf: [
|
||||
{ type: "object", properties: { a: { type: "string" } } },
|
||||
{ type: "object", properties: { b: { type: "number" } } },
|
||||
],
|
||||
}
|
||||
|
||||
const result = ToolInputSchema.parse(schema)
|
||||
|
||||
expect((result.allOf as any)[0].additionalProperties).toBe(false)
|
||||
expect((result.allOf as any)[1].additionalProperties).toBe(false)
|
||||
})
|
||||
|
||||
it("should apply defaults to tuple-style array items", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
tuple: {
|
||||
type: "array",
|
||||
items: [
|
||||
{ type: "object", properties: { a: { type: "string" } } },
|
||||
{ type: "object", properties: { b: { type: "number" } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = ToolInputSchema.parse(schema)
|
||||
|
||||
const tupleItems = (result.properties as any).tuple.items
|
||||
expect(tupleItems[0].additionalProperties).toBe(false)
|
||||
expect(tupleItems[1].additionalProperties).toBe(false)
|
||||
})
|
||||
|
||||
it("should preserve explicit additionalProperties: false", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
const result = ToolInputSchema.parse(schema)
|
||||
|
||||
expect(result.additionalProperties).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle deeply nested complex schemas", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
level1: {
|
||||
type: "object",
|
||||
properties: {
|
||||
level2: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
level3: {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
},
|
||||
},
|
||||
path: { type: "string" },
|
||||
line_ranges: {
|
||||
type: ["array", "null"],
|
||||
items: {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["path", "line_ranges"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = ToolInputSchema.parse(schema)
|
||||
const result = normalizeToolSchema(input)
|
||||
|
||||
expect(result.additionalProperties).toBe(false)
|
||||
expect((result.properties as any).level1.additionalProperties).toBe(false)
|
||||
expect((result.properties as any).level1.properties.level2.items.additionalProperties).toBe(false)
|
||||
expect((result.properties as any).level1.properties.level2.items.properties.level3.additionalProperties).toBe(
|
||||
false,
|
||||
)
|
||||
expect(result.properties).toBeDefined()
|
||||
const properties = result.properties as Record<string, Record<string, unknown>>
|
||||
const filesItems = properties.files.items as Record<string, unknown>
|
||||
const filesItemsProps = filesItems.properties as Record<string, Record<string, unknown>>
|
||||
expect(filesItemsProps.line_ranges.anyOf).toEqual([{ type: "array" }, { type: "null" }])
|
||||
})
|
||||
|
||||
it("should handle the real-world MCP memory create_entities schema", () => {
|
||||
// This is based on the actual schema that caused the OpenAI error
|
||||
const schema = {
|
||||
it("should recursively transform anyOf arrays", () => {
|
||||
const input = {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
optional: { type: ["string", "null"] },
|
||||
},
|
||||
},
|
||||
{ type: "null" },
|
||||
],
|
||||
}
|
||||
|
||||
const result = normalizeToolSchema(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
optional: { anyOf: [{ type: "string" }, { type: "null" }], additionalProperties: false },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
{ type: "null", additionalProperties: false },
|
||||
],
|
||||
additionalProperties: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle null or non-object input", () => {
|
||||
expect(normalizeToolSchema(null as any)).toBeNull()
|
||||
expect(normalizeToolSchema("string" as any)).toBe("string")
|
||||
expect(normalizeToolSchema(123 as any)).toBe(123)
|
||||
})
|
||||
|
||||
it("should transform additionalProperties when it is a schema object", () => {
|
||||
const input = {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: ["string", "null"],
|
||||
},
|
||||
}
|
||||
|
||||
const result = normalizeToolSchema(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should preserve additionalProperties when it is a boolean", () => {
|
||||
const input = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
const result = normalizeToolSchema(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle the read_file tool schema structure", () => {
|
||||
// This is the actual structure used in read_file tool
|
||||
const input = {
|
||||
type: "object",
|
||||
properties: {
|
||||
entities: {
|
||||
files: {
|
||||
type: "array",
|
||||
description: "List of files to read",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "The name of the entity" },
|
||||
entityType: { type: "string", description: "The type of the entity" },
|
||||
observations: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "An array of observation contents",
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Path to the file",
|
||||
},
|
||||
line_ranges: {
|
||||
type: ["array", "null"],
|
||||
description: "Optional line ranges",
|
||||
items: {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["name", "entityType", "observations"],
|
||||
required: ["path", "line_ranges"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
description: "An array of entities to create",
|
||||
minItems: 1,
|
||||
},
|
||||
},
|
||||
required: ["entities"],
|
||||
required: ["files"],
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
const result = ToolInputSchema.parse(schema)
|
||||
const result = normalizeToolSchema(input)
|
||||
|
||||
// Top-level object should have additionalProperties: false
|
||||
expect(result.additionalProperties).toBe(false)
|
||||
// Items in the entities array should have additionalProperties: false
|
||||
expect((result.properties as any).entities.items.additionalProperties).toBe(false)
|
||||
// Verify the line_ranges was transformed
|
||||
const files = (result.properties as Record<string, unknown>).files as Record<string, unknown>
|
||||
const items = files.items as Record<string, unknown>
|
||||
const props = items.properties as Record<string, Record<string, unknown>>
|
||||
expect(props.line_ranges.anyOf).toEqual([{ type: "array" }, { type: "null" }])
|
||||
// Verify other properties are preserved
|
||||
expect(props.line_ranges.items).toBeDefined()
|
||||
expect(props.line_ranges.description).toBe("Optional line ranges")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ export type JsonSchema = z4.core.JSONSchema.JSONSchema
|
|||
*/
|
||||
const JsonSchemaPrimitiveTypeSchema = z.enum(["string", "number", "integer", "boolean", "null"])
|
||||
|
||||
/**
|
||||
* All valid JSON Schema type values including object and array
|
||||
*/
|
||||
const JsonSchemaTypeSchema = z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")])
|
||||
|
||||
/**
|
||||
* Zod schema for JSON Schema enum values
|
||||
*/
|
||||
|
|
@ -37,7 +42,7 @@ const JsonSchemaEnumValueSchema = z.union([z.string(), z.number(), z.boolean(),
|
|||
export const ToolInputSchema: z.ZodType<JsonSchema> = z.lazy(() =>
|
||||
z
|
||||
.object({
|
||||
type: z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")]).optional(),
|
||||
type: JsonSchemaTypeSchema.optional(),
|
||||
properties: z.record(z.string(), ToolInputSchema).optional(),
|
||||
items: z.union([ToolInputSchema, z.array(ToolInputSchema)]).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
|
|
@ -61,3 +66,100 @@ export const ToolInputSchema: z.ZodType<JsonSchema> = z.lazy(() =>
|
|||
})
|
||||
.passthrough(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Schema for type field that accepts both single types and array types (draft-07 nullable syntax).
|
||||
* Array types like ["string", "null"] are transformed to anyOf format for 2020-12 compliance.
|
||||
*/
|
||||
const TypeFieldSchema = z.union([JsonSchemaTypeSchema, z.array(JsonSchemaTypeSchema)])
|
||||
|
||||
/**
|
||||
* Internal Zod schema that normalizes tool input JSON Schema to be compliant with JSON Schema draft 2020-12.
|
||||
*
|
||||
* This schema performs two key transformations:
|
||||
* 1. Sets `additionalProperties: false` by default (required by OpenAI strict mode)
|
||||
* 2. Converts deprecated `type: ["T", "null"]` array syntax to `anyOf` format
|
||||
* (required by Claude on Bedrock which enforces JSON Schema draft 2020-12)
|
||||
*
|
||||
* Uses recursive parsing so transformations apply to all nested schemas automatically.
|
||||
*/
|
||||
const NormalizedToolSchemaInternal: z.ZodType<Record<string, unknown>, z.ZodTypeDef, Record<string, unknown>> = z.lazy(
|
||||
() =>
|
||||
z
|
||||
.object({
|
||||
// Accept both single type and array of types, transform array to anyOf
|
||||
type: TypeFieldSchema.optional(),
|
||||
properties: z.record(z.string(), NormalizedToolSchemaInternal).optional(),
|
||||
items: z.union([NormalizedToolSchemaInternal, z.array(NormalizedToolSchemaInternal)]).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
additionalProperties: z.union([z.boolean(), NormalizedToolSchemaInternal]).default(false),
|
||||
description: z.string().optional(),
|
||||
default: z.unknown().optional(),
|
||||
enum: z.array(JsonSchemaEnumValueSchema).optional(),
|
||||
const: JsonSchemaEnumValueSchema.optional(),
|
||||
anyOf: z.array(NormalizedToolSchemaInternal).optional(),
|
||||
oneOf: z.array(NormalizedToolSchemaInternal).optional(),
|
||||
allOf: z.array(NormalizedToolSchemaInternal).optional(),
|
||||
$ref: z.string().optional(),
|
||||
minimum: z.number().optional(),
|
||||
maximum: z.number().optional(),
|
||||
minLength: z.number().optional(),
|
||||
maxLength: z.number().optional(),
|
||||
pattern: z.string().optional(),
|
||||
minItems: z.number().optional(),
|
||||
maxItems: z.number().optional(),
|
||||
uniqueItems: z.boolean().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((schema) => {
|
||||
const { type, required, properties, ...rest } = schema
|
||||
const result: Record<string, unknown> = { ...rest }
|
||||
|
||||
// If type is an array, convert to anyOf format (JSON Schema 2020-12)
|
||||
if (Array.isArray(type)) {
|
||||
result.anyOf = type.map((t) => ({ type: t }))
|
||||
} else if (type !== undefined) {
|
||||
result.type = type
|
||||
}
|
||||
|
||||
// Handle properties and required for strict mode
|
||||
if (properties) {
|
||||
result.properties = properties
|
||||
if (required) {
|
||||
const propertyKeys = Object.keys(properties)
|
||||
const filteredRequired = required.filter((key) => propertyKeys.includes(key))
|
||||
if (filteredRequired.length > 0) {
|
||||
result.required = filteredRequired
|
||||
}
|
||||
}
|
||||
} else if (result.type === "object" || (Array.isArray(type) && type.includes("object"))) {
|
||||
// For type: "object" without properties, add empty properties
|
||||
// This is required by OpenAI strict mode
|
||||
result.properties = {}
|
||||
}
|
||||
|
||||
return result
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* Normalizes a tool input JSON Schema to be compliant with JSON Schema draft 2020-12.
|
||||
*
|
||||
* This function performs two key transformations:
|
||||
* 1. Sets `additionalProperties: false` by default (required by OpenAI strict mode)
|
||||
* 2. Converts deprecated `type: ["T", "null"]` array syntax to `anyOf` format
|
||||
* (required by Claude on Bedrock which enforces JSON Schema draft 2020-12)
|
||||
*
|
||||
* Uses recursive parsing so transformations apply to all nested schemas automatically.
|
||||
*
|
||||
* @param schema - The JSON Schema to normalize
|
||||
* @returns A normalized schema object that is JSON Schema draft 2020-12 compliant
|
||||
*/
|
||||
export function normalizeToolSchema(schema: Record<string, unknown>): Record<string, unknown> {
|
||||
if (typeof schema !== "object" || schema === null) {
|
||||
return schema
|
||||
}
|
||||
|
||||
const result = NormalizedToolSchemaInternal.safeParse(schema)
|
||||
return result.success ? result.data : schema
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue