diff --git a/src/api/providers/__tests__/bedrock-native-tools.spec.ts b/src/api/providers/__tests__/bedrock-native-tools.spec.ts index e8b13dccc4c..8325f94bfb6 100644 --- a/src/api/providers/__tests__/bedrock-native-tools.spec.ts +++ b/src/api/providers/__tests__/bedrock-native-tools.spec.ts @@ -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", () => { diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 27dce1bb9fe..572d1e01036 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -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" /************************************************************************************ @@ -1208,6 +1209,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 */ @@ -1221,7 +1223,9 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH name: tool.function.name, description: tool.function.description, inputSchema: { - json: tool.function.parameters as Record, + // 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), }, }, }) as Tool, diff --git a/src/core/prompts/tools/native-tools/mcp_server.ts b/src/core/prompts/tools/native-tools/mcp_server.ts index aff8f068ed4..3fbd1fbcf4a 100644 --- a/src/core/prompts/tools/native-tools/mcp_server.ts +++ b/src/core/prompts/tools/native-tools/mcp_server.ts @@ -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 | 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 = { diff --git a/src/utils/__tests__/json-schema.spec.ts b/src/utils/__tests__/json-schema.spec.ts index b2607dc2556..9e7eeb2e171 100644 --- a/src/utils/__tests__/json-schema.spec.ts +++ b/src/utils/__tests__/json-schema.spec.ts @@ -1,213 +1,268 @@ -import { ToolInputSchema } from "../json-schema" +import { describe, it, expect } from "vitest" +import { normalizeToolSchema } from "../json-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, + }) + }) -describe("ToolInputSchema", () => { - it("should validate and default additionalProperties to false", () => { - const schema = { + 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) - - expect(result.type).toBe("object") - expect(result.additionalProperties).toBe(false) - }) + const result = normalizeToolSchema(input) - 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) - - expect(result.additionalProperties).toBe(false) - expect((result.properties as any).user.additionalProperties).toBe(false) + const result = normalizeToolSchema(input) + + 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" }, + path: { type: "string" }, + line_ranges: { + type: ["array", "null"], + items: { + type: "array", + items: { type: "integer" }, + }, + }, }, + required: ["path", "line_ranges"], }, }, }, } - 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" } + const result = normalizeToolSchema(input) - expect(() => ToolInputSchema.parse(invalidSchema)).toThrow() + expect(result.properties).toBeDefined() + const properties = result.properties as Record> + const filesItems = properties.files.items as Record + const filesItemsProps = filesItems.properties as Record> + expect(filesItemsProps.line_ranges.anyOf).toEqual([{ type: "array" }, { type: "null" }]) }) - 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" }], + it("should recursively transform anyOf arrays", () => { + const input = { + anyOf: [ + { + type: "object", + properties: { + optional: { type: ["string", "null"] }, + }, + }, + { type: "null" }, + ], } - const result = ToolInputSchema.parse(schema) - - expect((result.oneOf as any)[0].additionalProperties).toBe(false) - expect((result.oneOf as any)[1].additionalProperties).toBe(false) - }) + const result = normalizeToolSchema(input) - it("should apply defaults in allOf schemas", () => { - const schema = { - allOf: [ - { type: "object", properties: { a: { type: "string" } } }, - { type: "object", properties: { b: { type: "number" } } }, + expect(result).toEqual({ + anyOf: [ + { + type: "object", + properties: { + optional: { anyOf: [{ type: "string" }, { type: "null" }], additionalProperties: false }, + }, + additionalProperties: false, + }, + { type: "null", additionalProperties: false }, ], - } - - const result = ToolInputSchema.parse(schema) + additionalProperties: false, + }) + }) - expect((result.allOf as any)[0].additionalProperties).toBe(false) - expect((result.allOf as any)[1].additionalProperties).toBe(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 apply defaults to tuple-style array items", () => { - const schema = { + it("should transform additionalProperties when it is a schema object", () => { + const input = { type: "object", - properties: { - tuple: { - type: "array", - items: [ - { type: "object", properties: { a: { type: "string" } } }, - { type: "object", properties: { b: { type: "number" } } }, - ], - }, + additionalProperties: { + type: ["string", "null"], }, } - 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) - }) + const result = normalizeToolSchema(input) - it("should preserve explicit additionalProperties: false", () => { - const schema = { + expect(result).toEqual({ type: "object", - properties: { - name: { type: "string" }, + properties: {}, + additionalProperties: { + anyOf: [{ type: "string" }, { type: "null" }], + additionalProperties: false, }, - additionalProperties: false, - } - - const result = ToolInputSchema.parse(schema) - - expect(result.additionalProperties).toBe(false) + }) }) - it("should handle deeply nested complex schemas", () => { - const schema = { + it("should preserve additionalProperties when it is a boolean", () => { + const input = { type: "object", - properties: { - level1: { - type: "object", - properties: { - level2: { - type: "array", - items: { - type: "object", - properties: { - level3: { - type: "object", - properties: { - value: { type: "string" }, - }, - }, - }, - }, - }, - }, - }, - }, + additionalProperties: false, } - 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).toEqual({ + type: "object", + properties: {}, + additionalProperties: false, + }) }) - 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 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).files as Record + const items = files.items as Record + const props = items.properties as Record> + 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") }) }) diff --git a/src/utils/json-schema.ts b/src/utils/json-schema.ts index a857cdb6415..de34a8669b5 100644 --- a/src/utils/json-schema.ts +++ b/src/utils/json-schema.ts @@ -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 = 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 = 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, z.ZodTypeDef, Record> = 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 = { ...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): Record { + if (typeof schema !== "object" || schema === null) { + return schema + } + + const result = NormalizedToolSchemaInternal.safeParse(schema) + return result.success ? result.data : schema +}