Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/api/providers/__tests__/bedrock-native-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,13 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
expect(executeCommandSchema.properties.cwd.description).toBe("Working directory (optional)")

// Second tool: line_ranges should be transformed from type: ["array", "null"] to anyOf
// with items moved inside the array variant (required by GPT-5-mini strict schema validation)
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.anyOf).toEqual([{ type: "array", items: { type: "integer" } }, { type: "null" }])
expect(lineRanges.type).toBeUndefined()
// items also gets additionalProperties: false from normalization
expect(lineRanges.items.type).toBe("integer")
// items should now be inside the array variant, not at root
expect(lineRanges.items).toBeUndefined()
expect(lineRanges.description).toBe("Optional line ranges")
})

Expand Down
37 changes: 27 additions & 10 deletions src/utils/__tests__/json-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ describe("normalizeToolSchema", () => {

const result = normalizeToolSchema(input)

// additionalProperties should NOT be added to array or primitive types
// Array-specific properties (items) should be moved inside the array variant
// This is required by strict schema validators like GPT-5-mini
expect(result).toEqual({
anyOf: [{ type: "array" }, { type: "null" }],
items: { type: "string" },
anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }],
description: "Optional array",
})
})
Expand Down Expand Up @@ -97,15 +97,15 @@ describe("normalizeToolSchema", () => {
const result = normalizeToolSchema(input)

// additionalProperties: false should ONLY be on object types
// Array-specific properties (items) should be moved inside the array variant
expect(result).toEqual({
type: "array",
items: {
type: "object",
properties: {
path: { type: "string" },
line_ranges: {
anyOf: [{ type: "array" }, { type: "null" }],
items: { type: "integer" },
anyOf: [{ type: "array", items: { type: "integer" } }, { type: "null" }],
},
},
additionalProperties: false,
Expand Down Expand Up @@ -143,7 +143,11 @@ describe("normalizeToolSchema", () => {
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" }])
// Array-specific properties (items) should be moved inside the array variant
expect(filesItemsProps.line_ranges.anyOf).toEqual([
{ type: "array", items: { type: "array", items: { type: "integer" } } },
{ type: "null" },
])
})

it("should recursively transform anyOf arrays", () => {
Expand Down Expand Up @@ -255,13 +259,26 @@ describe("normalizeToolSchema", () => {

const result = normalizeToolSchema(input)

// Verify the line_ranges was transformed
// Verify the line_ranges was transformed with items inside the array variant
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()
// Array-specific properties (items, minItems, maxItems) should be moved inside the array variant
expect(props.line_ranges.anyOf).toEqual([
{
type: "array",
items: {
type: "array",
items: { type: "integer" },
minItems: 2,
maxItems: 2,
},
},
{ type: "null" },
])
// items should NOT be at root level anymore
expect(props.line_ranges.items).toBeUndefined()
// Other properties are preserved at root level
expect(props.line_ranges.description).toBe("Optional line ranges")
})

Expand Down
50 changes: 48 additions & 2 deletions src/utils/json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,28 @@ const OPENAI_SUPPORTED_FORMATS = new Set([
"uuid",
])

/**
* Array-specific JSON Schema properties that must be nested inside array type variants
* when converting to anyOf format (JSON Schema draft 2020-12).
*/
const ARRAY_SPECIFIC_PROPERTIES = ["items", "minItems", "maxItems", "uniqueItems"] as const

/**
* Applies array-specific properties from source to target object.
* Only copies properties that are defined in the source.
*/
function applyArrayProperties(
target: Record<string, unknown>,
source: Record<string, unknown>,
): Record<string, unknown> {
for (const prop of ARRAY_SPECIFIC_PROPERTIES) {
if (source[prop] !== undefined) {
target[prop] = source[prop]
}
}
return target
}

/**
* Zod schema for JSON Schema primitive types
*/
Expand Down Expand Up @@ -133,18 +155,42 @@ const NormalizedToolSchemaInternal: z.ZodType<Record<string, unknown>, z.ZodType
})
.passthrough()
.transform((schema) => {
const { type, required, properties, additionalProperties, format, ...rest } = schema
const {
type,
required,
properties,
additionalProperties,
format,
items,
minItems,
maxItems,
uniqueItems,
...rest
} = schema
const result: Record<string, unknown> = { ...rest }

// Determine if this schema represents an object type
const isObjectType =
type === "object" || (Array.isArray(type) && type.includes("object")) || properties !== undefined

// Collect array-specific properties for potential use in type handling
const arrayProps = { items, minItems, maxItems, uniqueItems }

// If type is an array, convert to anyOf format (JSON Schema 2020-12)
// Array-specific properties must be moved inside the array variant
if (Array.isArray(type)) {
result.anyOf = type.map((t) => ({ type: t }))
result.anyOf = type.map((t) => {
if (t === "array") {
return applyArrayProperties({ type: t }, arrayProps)
}
return { type: t }
})
} else if (type !== undefined) {
result.type = type
// For single "array" type, preserve array-specific properties at root
if (type === "array") {
applyArrayProperties(result, arrayProps)
}
}

// Strip unsupported format values for OpenAI compatibility
Expand Down
Loading