From f112779e6c3608567f7e080c152d69e5a1c3fe1b Mon Sep 17 00:00:00 2001 From: Vadim Rutkovsky Date: Fri, 31 Jul 2026 09:08:21 +0200 Subject: [PATCH] fix(provider): drop top-level schema composition for Anthropic MCP tools --- packages/opencode/src/provider/transform.ts | 12 +++ .../opencode/test/provider/transform.test.ts | 88 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 303ff11fdfd8..6d52171cca7c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1521,6 +1521,18 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 // Codex also applies lossy compaction above 4 KB; defer that until OpenCode needs the same schema budget. } + if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic") { + // Anthropic's input_schema does not support oneOf, allOf, or anyOf at the top + // level (nested composition inside properties is fine). MCP servers can still + // emit these, e.g. `anyOf: [{ required: ["route"] }, { required: ["file"] }]`. + if (isPlainObject(schema)) { + const dropped = ["oneOf", "allOf", "anyOf"] + schema = Object.fromEntries( + Object.entries(schema as JsonRecord).filter(([key]) => !dropped.includes(key)), + ) as JSONSchema7 + } + } + if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) { const sanitizeMoonshot = (obj: unknown): unknown => { if (obj === null || typeof obj !== "object") return obj diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 0b88fa59dff8..0cda1fe79eb2 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1629,6 +1629,94 @@ describe("ProviderTransform.schema - openai supported schema subset", () => { }) }) +describe("ProviderTransform.schema - anthropic top-level composition removal", () => { + const anthropicModel = { + providerID: "anthropic", + api: { + id: "claude-sonnet-4", + npm: "@ai-sdk/anthropic", + }, + } as any + + const vertexAnthropicModel = { + providerID: "google-vertex", + api: { + id: "claude-sonnet-4", + npm: "@ai-sdk/google-vertex/anthropic", + }, + } as any + + test("drops anyOf at the schema root", () => { + const result = ProviderTransform.schema(anthropicModel, { + type: "object", + properties: { + route: { type: "string" }, + file: { type: "string" }, + }, + anyOf: [{ required: ["route"] }, { required: ["file"] }], + } as any) as any + + expect(result).toEqual({ + type: "object", + properties: { + route: { type: "string" }, + file: { type: "string" }, + }, + }) + }) + + test("drops oneOf and allOf at the schema root", () => { + const result = ProviderTransform.schema(anthropicModel, { + type: "object", + properties: { query: { type: "string" } }, + oneOf: [{ required: ["query"] }], + allOf: [{ required: ["query"] }], + } as any) as any + + expect(result).toEqual({ + type: "object", + properties: { query: { type: "string" } }, + }) + }) + + test("keeps nested anyOf inside properties", () => { + const result = ProviderTransform.schema(anthropicModel, { + type: "object", + properties: { + value: { + anyOf: [{ type: "string" }, { type: "number" }], + }, + }, + } as any) as any + + expect(result.properties.value.anyOf).toEqual([{ type: "string" }, { type: "number" }]) + }) + + test("sanitizes Claude models served through Vertex AI", () => { + const result = ProviderTransform.schema(vertexAnthropicModel, { + type: "object", + anyOf: [{ required: ["route"] }, { required: ["file"] }], + } as any) as any + + expect(result).toEqual({ type: "object" }) + }) + + test("does not affect providers without anthropic-specific sanitization", () => { + const result = ProviderTransform.schema( + { + providerID: "mistral", + api: { id: "mistral-large", npm: "@ai-sdk/mistral" }, + } as any, + { + type: "object", + anyOf: [{ required: ["route"] }, { required: ["file"] }], + } as any, + ) as any + + expect(result.anyOf).toEqual([{ required: ["route"] }, { required: ["file"] }]) + }) +}) + describe("ProviderTransform.schema - moonshot $ref siblings", () => { const moonshotModel = { providerID: "moonshotai",