mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
fa9dff4a06
commit
7375a59931
5 changed files with 153 additions and 4 deletions
|
|
@ -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" }])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue