From 7375a599311858d341bf50c6f66140dcabff5320 Mon Sep 17 00:00:00 2001 From: 0xMink Date: Wed, 11 Feb 2026 22:46:28 -0500 Subject: [PATCH] fix: strip reasoning_details from messages and $ref from tool schemas Strip `reasoning_details` and `reasoning_content` from messages before sending to provider APIs. These legacy fields survive JSON deserialization and the no-op TypeScript cast, causing providers to reject the request with "Extra inputs are not permitted." Strip `$ref` from tool schemas during normalization. MCP tool schemas containing unresolvable `$ref` references cause providers to reject the request with INVALID_ARGUMENT. Fixes #11429, Fixes #11430 --- src/api/providers/__tests__/anthropic.spec.ts | 39 +++++++ src/api/providers/anthropic.ts | 10 +- src/api/providers/openrouter.ts | 6 +- src/utils/__tests__/json-schema.spec.ts | 101 ++++++++++++++++++ src/utils/json-schema.ts | 1 + 5 files changed, 153 insertions(+), 4 deletions(-) diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index 75fcb119ad..331c210bcd 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -399,6 +399,45 @@ describe("AnthropicHandler", () => { expect(endChunk).toBeDefined() }) + it("should strip reasoning_details and reasoning_content from messages before sending to API", async () => { + setupStreamTextMock([{ type: "text-delta", text: "test" }]) + + // Simulate messages with extra legacy fields that survive JSON deserialization + const messagesWithExtraFields = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello" }], + }, + { + role: "assistant", + content: [{ type: "text" as const, text: "Hi" }], + reasoning_details: [{ type: "thinking", thinking: "some reasoning" }], + reasoning_content: "some reasoning content", + }, + { + role: "user", + content: [{ type: "text" as const, text: "Follow up" }], + }, + ] as any + + const stream = handler.createMessage(systemPrompt, messagesWithExtraFields) + + for await (const _chunk of stream) { + // Consume stream + } + + // Verify streamText was called exactly once + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0]![0] + for (const msg of callArgs.messages) { + expect(msg).not.toHaveProperty("reasoning_details") + expect(msg).not.toHaveProperty("reasoning_content") + } + // Verify the rest of the message is preserved + expect(callArgs.messages[1].role).toBe("assistant") + expect(callArgs.messages[1].content).toEqual([{ type: "text", text: "Hi" }]) + }) + it("should pass system prompt via system param with systemProviderOptions for cache control", async () => { setupStreamTextMock([{ type: "text-delta", text: "test" }]) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 1f519250fa..a9bbdaca86 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -76,8 +76,12 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa ): ApiStream { const modelConfig = this.getModel() - // Convert messages to AI SDK format - const aiSdkMessages = messages as ModelMessage[] + // Convert messages to AI SDK format, stripping extra fields from legacy + // ApiMessage objects that survive JSON deserialization (e.g. reasoning_details + // causes Anthropic 400: "Extra inputs are not permitted"). + const aiSdkMessages = messages.map( + ({ reasoning_details, reasoning_content, ...rest }: any) => rest, + ) as ModelMessage[] // Convert tools to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) @@ -122,7 +126,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa if (secondLastUserMsgIndex >= 0) targetIndices.add(secondLastUserMsgIndex) if (targetIndices.size > 0) { - this.applyCacheControlToAiSdkMessages(messages as ModelMessage[], targetIndices, cacheProviderOption) + this.applyCacheControlToAiSdkMessages(aiSdkMessages, targetIndices, cacheProviderOption) } // Build streamText request diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 8462a4ac03..733fe80c5d 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -148,7 +148,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH ? { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } : undefined - const aiSdkMessages = messages as ModelMessage[] + // Strip extra fields from legacy ApiMessage objects that survive JSON + // deserialization (e.g. reasoning_details causes provider 400 errors). + const aiSdkMessages = messages.map( + ({ reasoning_details, reasoning_content, ...rest }: any) => rest, + ) as ModelMessage[] const openrouter = this.createOpenRouterProvider({ reasoning, headers }) diff --git a/src/utils/__tests__/json-schema.spec.ts b/src/utils/__tests__/json-schema.spec.ts index 6f2096e626..a40d5fe57a 100644 --- a/src/utils/__tests__/json-schema.spec.ts +++ b/src/utils/__tests__/json-schema.spec.ts @@ -585,4 +585,105 @@ describe("normalizeToolSchema", () => { }) }) }) + + describe("$ref stripping", () => { + it("should strip $ref at the top level", () => { + const input = { + $ref: "#/components/schemas/Foo", + type: "object", + properties: { + name: { type: "string" }, + }, + } + + const result = normalizeToolSchema(input) + + expect(result.$ref).toBeUndefined() + expect(result.type).toBe("object") + expect(result.properties).toBeDefined() + }) + + it("should strip $ref in nested properties", () => { + const input = { + type: "object", + properties: { + response: { + $ref: "#/components/schemas/PlanningLogResponse", + type: "object", + properties: { + status: { type: "string" }, + }, + }, + }, + } + + const result = normalizeToolSchema(input) + + const props = result.properties as Record> + expect(props.response.$ref).toBeUndefined() + expect(props.response.type).toBe("object") + }) + + it("should strip $ref in deeply nested array items", () => { + const input = { + type: "object", + properties: { + items: { + type: "array", + items: { + type: "object", + properties: { + ref_field: { + $ref: "#/components/schemas/Nested", + type: "string", + }, + }, + }, + }, + }, + } + + const result = normalizeToolSchema(input) + + const props = result.properties as Record> + const itemsSchema = props.items.items as Record + const nestedProps = itemsSchema.properties as Record> + expect(nestedProps.ref_field.$ref).toBeUndefined() + expect(nestedProps.ref_field.type).toBe("string") + }) + + it("should handle $ref-only schema without crashing", () => { + const input = { + $ref: "#/components/schemas/Foo", + } + + const result = normalizeToolSchema(input) + + expect(result.$ref).toBeUndefined() + // Result is an empty schema (permissive) — acceptable for tool inputs + expect(result).not.toBeNull() + expect(result).toEqual({}) + }) + + it("should strip $ref inside anyOf variants", () => { + const input = { + type: "object", + properties: { + field: { + anyOf: [ + { $ref: "#/components/schemas/TypeA", type: "string" }, + { type: "null" }, + ], + }, + }, + } + + const result = normalizeToolSchema(input) + + const props = result.properties as Record> + const anyOf = props.field.anyOf as Record[] + expect(anyOf[0].$ref).toBeUndefined() + expect(anyOf[0].type).toBe("string") + }) + }) }) diff --git a/src/utils/json-schema.ts b/src/utils/json-schema.ts index cbcd3486d2..b957077c85 100644 --- a/src/utils/json-schema.ts +++ b/src/utils/json-schema.ts @@ -165,6 +165,7 @@ const NormalizedToolSchemaInternal: z.ZodType, z.ZodType minItems, maxItems, uniqueItems, + $ref: _ref, // Strip $ref — unresolvable references break provider APIs (e.g. Gemini 400) ...rest } = schema const result: Record = { ...rest }