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
This commit is contained in:
0xMink 2026-02-11 22:46:28 -05:00
parent fa9dff4a06
commit 7375a59931
5 changed files with 153 additions and 4 deletions

View file

@ -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" }])

View file

@ -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

View file

@ -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 })

View file

@ -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<string, Record<string, unknown>>
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<string, Record<string, unknown>>
const itemsSchema = props.items.items as Record<string, unknown>
const nestedProps = itemsSchema.properties as Record<string, Record<string, unknown>>
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<string, Record<string, unknown>>
const anyOf = props.field.anyOf as Record<string, unknown>[]
expect(anyOf[0].$ref).toBeUndefined()
expect(anyOf[0].type).toBe("string")
})
})
})

View file

@ -165,6 +165,7 @@ const NormalizedToolSchemaInternal: z.ZodType<Record<string, unknown>, z.ZodType
minItems,
maxItems,
uniqueItems,
$ref: _ref, // Strip $ref — unresolvable references break provider APIs (e.g. Gemini 400)
...rest
} = schema
const result: Record<string, unknown> = { ...rest }