mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
1e71015e54
commit
4e61f778fe
3 changed files with 382 additions and 38 deletions
360
src/api/providers/__tests__/base-provider.spec.ts
Normal file
360
src/api/providers/__tests__/base-provider.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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>)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue