diff --git a/src/api/providers/__tests__/base-provider.spec.ts b/src/api/providers/__tests__/base-provider.spec.ts new file mode 100644 index 0000000000..2f1246e2b5 --- /dev/null +++ b/src/api/providers/__tests__/base-provider.spec.ts @@ -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) + }) + }) +}) diff --git a/src/api/providers/base-provider.ts b/src/api/providers/base-provider.ts index 64d99b3f0c..647c03c1a6 100644 --- a/src/api/providers/base-provider.ts +++ b/src/api/providers/base-provider.ts @@ -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) } /** diff --git a/src/api/providers/cerebras.ts b/src/api/providers/cerebras.ts index d0ff747688..8c46054ba8 100644 --- a/src/api/providers/cerebras.ts +++ b/src/api/providers/cerebras.ts @@ -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 }