Skip to content
Open
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
177 changes: 165 additions & 12 deletions src/utils/__tests__/json-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ describe("normalizeToolSchema", () => {
])
})

it("should recursively transform anyOf arrays", () => {
it("should flatten top-level anyOf and recursively transform nested schemas", () => {
// Top-level anyOf is flattened for provider compatibility (OpenRouter/Claude)
// but nested anyOf inside properties is preserved
const input = {
anyOf: [
{
Expand All @@ -165,18 +167,14 @@ describe("normalizeToolSchema", () => {

const result = normalizeToolSchema(input)

// additionalProperties: false should ONLY be on object types, not on null or primitive types
// Top-level anyOf should be flattened to the object variant
// Nested type array should be converted to anyOf
expect(result).toEqual({
anyOf: [
{
type: "object",
properties: {
optional: { anyOf: [{ type: "string" }, { type: "null" }] },
},
additionalProperties: false,
},
{ type: "null" },
],
type: "object",
properties: {
optional: { anyOf: [{ type: "string" }, { type: "null" }] },
},
additionalProperties: false,
})
})

Expand Down Expand Up @@ -459,5 +457,160 @@ describe("normalizeToolSchema", () => {
expect(props.url.type).toBe("string")
expect(props.url.description).toBe("URL to fetch")
})

describe("top-level anyOf/oneOf/allOf flattening", () => {
it("should flatten top-level anyOf to object schema", () => {
// This is the type of schema that caused the OpenRouter error:
// "input_schema does not support oneOf, allOf, or anyOf at the top level"
const input = {
anyOf: [
{
type: "object",
properties: {
name: { type: "string" },
},
required: ["name"],
},
{ type: "null" },
],
}

const result = normalizeToolSchema(input)

// Should flatten to the object variant
expect(result.anyOf).toBeUndefined()
expect(result.type).toBe("object")
expect(result.properties).toBeDefined()
expect((result.properties as Record<string, unknown>).name).toEqual({ type: "string" })
expect(result.additionalProperties).toBe(false)
})

it("should flatten top-level oneOf to object schema", () => {
const input = {
oneOf: [
{
type: "object",
properties: {
url: { type: "string" },
},
},
{
type: "object",
properties: {
path: { type: "string" },
},
},
],
}

const result = normalizeToolSchema(input)

// Should use the first object variant
expect(result.oneOf).toBeUndefined()
expect(result.type).toBe("object")
expect((result.properties as Record<string, unknown>).url).toBeDefined()
})

it("should flatten top-level allOf to object schema", () => {
const input = {
allOf: [
{
type: "object",
properties: {
base: { type: "string" },
},
},
{
properties: {
extra: { type: "number" },
},
},
],
}

const result = normalizeToolSchema(input)

// Should use the first object variant
expect(result.allOf).toBeUndefined()
expect(result.type).toBe("object")
})

it("should preserve description when flattening top-level anyOf", () => {
const input = {
description: "Input for the tool",
anyOf: [
{
type: "object",
properties: {
data: { type: "string" },
},
},
{ type: "null" },
],
}

const result = normalizeToolSchema(input)

expect(result.description).toBe("Input for the tool")
expect(result.anyOf).toBeUndefined()
expect(result.type).toBe("object")
})

it("should create generic object schema if no object variant found", () => {
const input = {
anyOf: [{ type: "string" }, { type: "number" }],
}

const result = normalizeToolSchema(input)

// Should create a fallback object schema
expect(result.anyOf).toBeUndefined()
expect(result.type).toBe("object")
expect(result.additionalProperties).toBe(false)
})

it("should NOT flatten nested anyOf (only top-level)", () => {
const input = {
type: "object",
properties: {
field: {
anyOf: [{ type: "string" }, { type: "null" }],
},
},
}

const result = normalizeToolSchema(input)

// Nested anyOf should be preserved
const props = result.properties as Record<string, Record<string, unknown>>
expect(props.field.anyOf).toBeDefined()
})

it("should handle MCP server schema with top-level anyOf", () => {
// Real-world example: some MCP servers define optional nullable root schemas
const input = {
$schema: "http://json-schema.org/draft-07/schema#",
anyOf: [
{
type: "object",
additionalProperties: false,
properties: {
issueId: { type: "string", description: "The issue ID" },
body: { type: "string", description: "The content" },
},
required: ["issueId", "body"],
},
],
}

const result = normalizeToolSchema(input)

expect(result.anyOf).toBeUndefined()
expect(result.type).toBe("object")
expect(result.properties).toBeDefined()
expect(result.required).toContain("issueId")
expect(result.required).toContain("body")
})
})
})
})
56 changes: 53 additions & 3 deletions src/utils/json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,14 +230,61 @@ const NormalizedToolSchemaInternal: z.ZodType<Record<string, unknown>, z.ZodType
}),
)

/**
* Flattens a schema with top-level anyOf/oneOf/allOf to a simple object schema.
* This is needed because some providers (OpenRouter, Claude) don't support
* schema composition keywords at the top level of tool input schemas.
*
* @param schema - The schema to flatten
* @returns A flattened schema without top-level composition keywords
*/
function flattenTopLevelComposition(schema: Record<string, unknown>): Record<string, unknown> {
const { anyOf, oneOf, allOf, ...rest } = schema

// If no top-level composition keywords, return as-is
if (!anyOf && !oneOf && !allOf) {
return schema
}

// Get the composition array to process (prefer anyOf, then oneOf, then allOf)
const compositionArray = (anyOf || oneOf || allOf) as Record<string, unknown>[] | undefined
if (!compositionArray || !Array.isArray(compositionArray) || compositionArray.length === 0) {
return schema
}

// Find the first non-null object type variant to use as the base
// This preserves the most information while making the schema compatible
const objectVariant = compositionArray.find(
(variant) =>
typeof variant === "object" &&
variant !== null &&
(variant.type === "object" || variant.properties !== undefined),
)

if (objectVariant) {
// Merge remaining properties with the object variant
return { ...rest, ...objectVariant }
}

// If no object variant found, create a generic object schema
// This is a fallback that allows any object structure
return {
type: "object",
additionalProperties: false,
...rest,
}
}

/**
* Normalizes a tool input JSON Schema to be compliant with JSON Schema draft 2020-12.
*
* This function performs three key transformations:
* This function performs four 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)
* 3. Strips unsupported `format` values (e.g., "uri") for OpenAI Structured Outputs compatibility
* 4. Flattens top-level anyOf/oneOf/allOf (required by OpenRouter/Claude which don't support
* schema composition keywords at the top level)
*
* Uses recursive parsing so transformations apply to all nested schemas automatically.
*
Expand All @@ -249,6 +296,9 @@ export function normalizeToolSchema(schema: Record<string, unknown>): Record<str
return schema
}

const result = NormalizedToolSchemaInternal.safeParse(schema)
return result.success ? result.data : schema
// First, flatten any top-level composition keywords before normalizing
const flattenedSchema = flattenTopLevelComposition(schema)

const result = NormalizedToolSchemaInternal.safeParse(flattenedSchema)
return result.success ? result.data : flattenedSchema
}
Loading