fix: ensure JSON Schema draft 2020-12 compliance for OpenAI-compatible providers

- Update convertToolSchemaForOpenAI in base-provider.ts to use normalizeToolSchema
  which properly converts type arrays (e.g., ["string", "null"]) to anyOf format
  required by JSON Schema draft 2020-12
- Update Cerebras stripUnsupportedSchemaFields to handle anyOf/oneOf/allOf arrays
- Add comprehensive tests for base-provider convertToolSchemaForOpenAI function

Fixes #10320
This commit is contained in:
Roo Code 2025-12-24 05:35:39 +00:00
parent 1e71015e54
commit 4e61f778fe
3 changed files with 382 additions and 38 deletions

View file

@ -0,0 +1,360 @@
// npx vitest run api/providers/__tests__/base-provider.spec.ts
import { Anthropic } from "@anthropic-ai/sdk"
import type { ModelInfo } from "@roo-code/types"
import { BaseProvider } from "../base-provider"
import type { ApiHandlerCreateMessageMetadata } from "../../index"
import { ApiStream } from "../../transform/stream"
// Create a concrete test implementation of the abstract BaseProvider class
class TestProvider extends BaseProvider {
createMessage(
_systemPrompt: string,
_messages: Anthropic.Messages.MessageParam[],
_metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
throw new Error("Not implemented for tests")
}
getModel(): { id: string; info: ModelInfo } {
return {
id: "test-model",
info: {
maxTokens: 4096,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.5,
outputPrice: 1.5,
},
}
}
// Expose protected method for testing
public testConvertToolSchemaForOpenAI(schema: any): any {
return this.convertToolSchemaForOpenAI(schema)
}
// Expose protected method for testing
public testConvertToolsForOpenAI(tools: any[] | undefined): any[] | undefined {
return this.convertToolsForOpenAI(tools)
}
}
describe("BaseProvider", () => {
let provider: TestProvider
beforeEach(() => {
provider = new TestProvider()
})
describe("convertToolSchemaForOpenAI", () => {
describe("JSON Schema draft 2020-12 compliance", () => {
it("should convert type array to anyOf for nullable string", () => {
const input = {
type: "object",
properties: {
field: {
type: ["string", "null"],
description: "Optional field",
},
},
}
const result = provider.testConvertToolSchemaForOpenAI(input)
// Should have anyOf instead of type array
expect(result.properties.field.anyOf).toEqual([{ type: "string" }, { type: "null" }])
expect(result.properties.field.type).toBeUndefined()
expect(result.properties.field.description).toBe("Optional field")
})
it("should convert type array to anyOf for nullable array with items inside array variant", () => {
const input = {
type: "object",
properties: {
files: {
type: ["array", "null"],
items: { type: "string" },
description: "Optional array",
},
},
}
const result = provider.testConvertToolSchemaForOpenAI(input)
// Array-specific properties (items) should be moved inside the array variant
expect(result.properties.files.anyOf).toEqual([
{ type: "array", items: { type: "string" } },
{ type: "null" },
])
expect(result.properties.files.items).toBeUndefined()
expect(result.properties.files.description).toBe("Optional array")
})
it("should preserve single type values", () => {
const input = {
type: "object",
properties: {
name: {
type: "string",
description: "Required field",
},
},
}
const result = provider.testConvertToolSchemaForOpenAI(input)
expect(result.properties.name.type).toBe("string")
expect(result.properties.name.description).toBe("Required field")
})
it("should handle deeply nested structures with type arrays", () => {
const input = {
type: "object",
properties: {
files: {
type: "array",
items: {
type: "object",
properties: {
path: { type: "string" },
line_ranges: {
type: ["array", "null"],
items: { type: "integer" },
},
},
},
},
},
}
const result = provider.testConvertToolSchemaForOpenAI(input)
// The nested line_ranges should have anyOf format with items inside array variant
const nestedProps = result.properties.files.items.properties
expect(nestedProps.line_ranges.anyOf).toEqual([
{ type: "array", items: { type: "integer" } },
{ type: "null" },
])
expect(nestedProps.line_ranges.items).toBeUndefined()
})
})
describe("OpenAI strict mode compatibility", () => {
it("should set additionalProperties: false for object types", () => {
const input = {
type: "object",
properties: {
name: { type: "string" },
},
}
const result = provider.testConvertToolSchemaForOpenAI(input)
expect(result.additionalProperties).toBe(false)
})
it("should force additionalProperties to false even when set to true", () => {
const input = {
type: "object",
properties: {
name: { type: "string" },
},
additionalProperties: true,
}
const result = provider.testConvertToolSchemaForOpenAI(input)
expect(result.additionalProperties).toBe(false)
})
it("should not add additionalProperties to primitive types", () => {
const input = {
type: "string",
description: "A string field",
}
const result = provider.testConvertToolSchemaForOpenAI(input)
expect(result.additionalProperties).toBeUndefined()
})
})
describe("format field handling", () => {
it("should preserve supported format values", () => {
const input = {
type: "object",
properties: {
timestamp: {
type: "string",
format: "date-time",
},
},
}
const result = provider.testConvertToolSchemaForOpenAI(input)
expect(result.properties.timestamp.format).toBe("date-time")
})
it("should strip unsupported format values like uri", () => {
const input = {
type: "object",
properties: {
url: {
type: "string",
format: "uri",
description: "A URL",
},
},
}
const result = provider.testConvertToolSchemaForOpenAI(input)
expect(result.properties.url.format).toBeUndefined()
expect(result.properties.url.type).toBe("string")
expect(result.properties.url.description).toBe("A URL")
})
})
describe("edge cases", () => {
it("should handle null input", () => {
const result = provider.testConvertToolSchemaForOpenAI(null)
expect(result).toBeNull()
})
it("should handle non-object input", () => {
const result = provider.testConvertToolSchemaForOpenAI("string")
expect(result).toBe("string")
})
it("should handle read_file tool schema structure", () => {
// This is similar to the actual read_file tool schema that caused issues
const input = {
type: "object",
properties: {
files: {
type: "array",
description: "List of files to read",
items: {
type: "object",
properties: {
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: ["path", "line_ranges"],
additionalProperties: false,
},
minItems: 1,
},
},
required: ["files"],
additionalProperties: false,
}
const result = provider.testConvertToolSchemaForOpenAI(input)
// Verify the line_ranges was transformed correctly
const filesItems = result.properties.files.items
const lineRanges = filesItems.properties.line_ranges
// Should have anyOf with items inside array variant
expect(lineRanges.anyOf).toBeDefined()
expect(lineRanges.anyOf).toHaveLength(2)
// Array variant should have items, minItems, maxItems
const arrayVariant = lineRanges.anyOf.find((v: any) => v.type === "array")
expect(arrayVariant).toBeDefined()
expect(arrayVariant.items).toBeDefined()
// items should NOT be at root level anymore
expect(lineRanges.items).toBeUndefined()
})
})
})
describe("convertToolsForOpenAI", () => {
it("should return undefined for undefined input", () => {
const result = provider.testConvertToolsForOpenAI(undefined)
expect(result).toBeUndefined()
})
it("should convert function tool schemas", () => {
const tools = [
{
type: "function",
function: {
name: "test_tool",
description: "A test tool",
parameters: {
type: "object",
properties: {
field: {
type: ["string", "null"],
},
},
},
},
},
]
const result = provider.testConvertToolsForOpenAI(tools)
expect(result).toBeDefined()
expect(result![0].function.strict).toBe(true)
// Should have converted type array to anyOf
expect(result![0].function.parameters.properties.field.anyOf).toBeDefined()
})
it("should disable strict mode for MCP tools", () => {
const tools = [
{
type: "function",
function: {
name: "mcp--server--tool",
description: "An MCP tool",
parameters: {
type: "object",
properties: {
field: { type: "string" },
},
},
},
},
]
const result = provider.testConvertToolsForOpenAI(tools)
expect(result).toBeDefined()
expect(result![0].function.strict).toBe(false)
// MCP tool parameters should not be modified
expect(result![0].function.parameters.type).toBe("object")
})
it("should pass through non-function tools unchanged", () => {
const tools = [
{
type: "other",
data: "some data",
},
]
const result = provider.testConvertToolsForOpenAI(tools)
expect(result).toEqual(tools)
})
})
})

View file

@ -6,6 +6,7 @@ import type { ApiHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { ApiStream } from "../transform/stream"
import { countTokens } from "../../utils/countTokens"
import { isMcpTool } from "../../utils/mcp-name"
import { normalizeToolSchema } from "../../utils/json-schema"
/**
* Base class for API providers that implements common functionality.
@ -52,50 +53,22 @@ export abstract class BaseProvider implements ApiHandler {
}
/**
* Converts tool schemas to be compatible with OpenAI's strict mode by:
* - Ensuring all properties are in the required array (strict mode requirement)
* - Converting nullable types (["type", "null"]) to non-nullable ("type")
* - Recursively processing nested objects and arrays
* Converts tool schemas to be compatible with OpenAI's strict mode and
* JSON Schema draft 2020-12 by:
* - Setting additionalProperties: false for object types (strict mode requirement)
* - Converting nullable types (["type", "null"]) to anyOf format (draft 2020-12 requirement)
* - Stripping unsupported format values for OpenAI Structured Outputs compatibility
* - Recursively processing nested schemas
*
* This matches the behavior of ensureAllRequired in openai-native.ts
* This uses normalizeToolSchema from json-schema.ts which handles all transformations.
* Required by third-party proxies that enforce JSON Schema draft 2020-12 (e.g., Claude API proxies).
*/
protected convertToolSchemaForOpenAI(schema: any): any {
if (!schema || typeof schema !== "object" || schema.type !== "object") {
if (!schema || typeof schema !== "object") {
return schema
}
const result = { ...schema }
if (result.properties) {
const allKeys = Object.keys(result.properties)
// OpenAI strict mode requires ALL properties to be in required array
result.required = allKeys
// Recursively process nested objects and convert nullable types
const newProps = { ...result.properties }
for (const key of allKeys) {
const prop = newProps[key]
// Handle nullable types by removing null
if (prop && Array.isArray(prop.type) && prop.type.includes("null")) {
const nonNullTypes = prop.type.filter((t: string) => t !== "null")
prop.type = nonNullTypes.length === 1 ? nonNullTypes[0] : nonNullTypes
}
// Recursively process nested objects
if (prop && prop.type === "object") {
newProps[key] = this.convertToolSchemaForOpenAI(prop)
} else if (prop && prop.type === "array" && prop.items?.type === "object") {
newProps[key] = {
...prop,
items: this.convertToolSchemaForOpenAI(prop.items),
}
}
}
result.properties = newProps
}
return result
return normalizeToolSchema(schema as Record<string, unknown>)
}
/**

View file

@ -88,6 +88,17 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan
result.items = this.stripUnsupportedSchemaFields(result.items)
}
// Recursively process anyOf/oneOf/allOf arrays (for JSON Schema draft 2020-12 compliance)
if (result.anyOf) {
result.anyOf = result.anyOf.map((variant: any) => this.stripUnsupportedSchemaFields(variant))
}
if (result.oneOf) {
result.oneOf = result.oneOf.map((variant: any) => this.stripUnsupportedSchemaFields(variant))
}
if (result.allOf) {
result.allOf = result.allOf.map((variant: any) => this.stripUnsupportedSchemaFields(variant))
}
return result
}