Unverified 提交 d8774673 authored 作者: wwwillchen-bot's avatar wwwillchen-bot 提交者: GitHub

fix: sanitize tool-call inputs to prevent LiteLLM invalid dict error (#2890)

should fix #2879. ## Summary - Add defensive sanitization ensuring tool-call `input` fields are always valid objects (at minimum `{}`) before messages reach the Anthropic API - Prevents LiteLLM from sending empty strings as `tool_use.input` when converting OpenAI→Anthropic format, which causes `400 invalid_request_error: Input should be a valid dictionary` - Adds sanitization in two locations: `cleanMessageForOpenAI` (stored messages) and `maybeCaptureRetryReplayEvent` (stream replay events) ## Context This is a known LiteLLM bug pattern (issues [#5063](https://github.com/BerriAI/litellm/issues/5063), [#15322](https://github.com/BerriAI/litellm/issues/15322), [#19061](https://github.com/BerriAI/litellm/issues/19061)). When `function.arguments` is empty or malformed, LiteLLM's `json.loads()` fails and falls back to passing the raw string as `input`, which Anthropic rejects. ## Test plan - [x] Added 3 new unit tests for `cleanMessageForOpenAI` covering empty string, null, and valid inputs - [x] All 847 existing tests pass - [x] Lint, format, and type checks pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2890" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarWill Chen <willchen90@gmail.com> Co-authored-by: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
上级 31c1a145
...@@ -267,6 +267,93 @@ describe("parseAiMessagesJson", () => { ...@@ -267,6 +267,93 @@ describe("parseAiMessagesJson", () => {
expect(part.providerOptions).toBeUndefined(); expect(part.providerOptions).toBeUndefined();
}); });
it("should sanitize tool-call with empty string input to empty object", () => {
const msg: DbMessageForParsing = {
id: 30,
role: "assistant",
content: "fallback",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-456",
toolName: "execute_sql",
input: "",
},
],
},
] as ModelMessage[],
},
};
const result = parseAiMessagesJson(msg);
const part = (result[0].content as any[])[0];
expect(part.toolCallId).toBe("call-456");
expect(part.input).toEqual({});
});
it("should sanitize tool-call with null input to empty object", () => {
const msg: DbMessageForParsing = {
id: 31,
role: "assistant",
content: "fallback",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-789",
toolName: "read_file",
input: null,
},
],
},
] as ModelMessage[],
},
};
const result = parseAiMessagesJson(msg);
const part = (result[0].content as any[])[0];
expect(part.toolCallId).toBe("call-789");
expect(part.input).toEqual({});
});
it("should preserve valid tool-call input objects", () => {
const msg: DbMessageForParsing = {
id: 32,
role: "assistant",
content: "fallback",
aiMessagesJson: {
sdkVersion: AI_MESSAGES_SDK_VERSION,
messages: [
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-valid",
toolName: "read_file",
input: { path: "/test" },
},
],
},
] as ModelMessage[],
},
};
const result = parseAiMessagesJson(msg);
const part = (result[0].content as any[])[0];
expect(part.toolCallId).toBe("call-valid");
expect(part.input).toEqual({ path: "/test" });
});
it("should strip itemId from reasoning parts but preserve reasoningEncryptedContent when followed by output", () => { it("should strip itemId from reasoning parts but preserve reasoningEncryptedContent when followed by output", () => {
const msg: DbMessageForParsing = { const msg: DbMessageForParsing = {
id: 22, id: 22,
......
...@@ -51,6 +51,7 @@ function stripItemIdFromPart(part: Record<string, unknown>): boolean { ...@@ -51,6 +51,7 @@ function stripItemIdFromPart(part: Record<string, unknown>): boolean {
* Clean up a message's content parts for OpenAI compatibility: * Clean up a message's content parts for OpenAI compatibility:
* 1. Strip itemId from provider metadata (prevents "Item with id not found" errors) * 1. Strip itemId from provider metadata (prevents "Item with id not found" errors)
* 2. Filter orphaned reasoning parts (prevents "reasoning without following item" errors) * 2. Filter orphaned reasoning parts (prevents "reasoning without following item" errors)
* 3. Ensure tool-call input is always a valid object (prevents LiteLLM sending empty string as input when converting OpenAI→Anthropic format)
* *
* When messages contain `providerMetadata.openai.itemId` values, the AI SDK converts * When messages contain `providerMetadata.openai.itemId` values, the AI SDK converts
* these to `item_reference` payloads. If OpenAI has expired those items, this causes * these to `item_reference` payloads. If OpenAI has expired those items, this causes
...@@ -63,7 +64,7 @@ function stripItemIdFromPart(part: Record<string, unknown>): boolean { ...@@ -63,7 +64,7 @@ function stripItemIdFromPart(part: Record<string, unknown>): boolean {
* *
* Returns the original message if no changes were needed, or a new message with cleaned content. * Returns the original message if no changes were needed, or a new message with cleaned content.
*/ */
export function cleanMessageForOpenAI<T extends ModelMessage>(message: T): T { export function cleanMessage<T extends ModelMessage>(message: T): T {
if (typeof message.content === "string" || !Array.isArray(message.content)) { if (typeof message.content === "string" || !Array.isArray(message.content)) {
return message; return message;
} }
...@@ -94,6 +95,16 @@ export function cleanMessageForOpenAI<T extends ModelMessage>(message: T): T { ...@@ -94,6 +95,16 @@ export function cleanMessageForOpenAI<T extends ModelMessage>(message: T): T {
didModify = true; didModify = true;
} }
// Ensure tool-call input is always a valid object (prevents LiteLLM
// sending empty string as input when converting OpenAI→Anthropic format)
if (
part.type === "tool-call" &&
(!part.input || typeof part.input !== "object")
) {
part.input = {};
didModify = true;
}
cleanedContent.push(part); cleanedContent.push(part);
} }
...@@ -104,11 +115,8 @@ export function cleanMessageForOpenAI<T extends ModelMessage>(message: T): T { ...@@ -104,11 +115,8 @@ export function cleanMessageForOpenAI<T extends ModelMessage>(message: T): T {
return { ...message, content: cleanedContent } as T; return { ...message, content: cleanedContent } as T;
} }
/** function cleanMessages(messages: ModelMessage[]): ModelMessage[] {
* Clean all messages in an array for OpenAI compatibility. return messages.map(cleanMessage);
*/
function cleanMessagesForOpenAI(messages: ModelMessage[]): ModelMessage[] {
return messages.map(cleanMessageForOpenAI);
} }
/** Maximum size in bytes for ai_messages_json (10MB) */ /** Maximum size in bytes for ai_messages_json (10MB) */
...@@ -163,7 +171,7 @@ export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] { ...@@ -163,7 +171,7 @@ export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] {
Array.isArray(parsed) && Array.isArray(parsed) &&
parsed.every((m) => m && typeof m.role === "string") parsed.every((m) => m && typeof m.role === "string")
) { ) {
return cleanMessagesForOpenAI(parsed); return cleanMessages(parsed);
} }
if ( if (
...@@ -177,7 +185,7 @@ export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] { ...@@ -177,7 +185,7 @@ export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] {
(m: ModelMessage) => m && typeof m.role === "string", (m: ModelMessage) => m && typeof m.role === "string",
) )
) { ) {
return cleanMessagesForOpenAI((parsed as AiMessagesJsonV6).messages); return cleanMessages((parsed as AiMessagesJsonV6).messages);
} }
} }
......
...@@ -1358,7 +1358,8 @@ function maybeCaptureRetryReplayEvent( ...@@ -1358,7 +1358,8 @@ function maybeCaptureRetryReplayEvent(
type: "tool-call", type: "tool-call",
toolCallId: part.toolCallId, toolCallId: part.toolCallId,
toolName: part.toolName, toolName: part.toolName,
input: part.input, input:
typeof part.input === "object" && part.input !== null ? part.input : {},
}); });
return; return;
} }
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
import { ImagePart, ModelMessage, TextPart, UserModelMessage } from "ai"; import { ImagePart, ModelMessage, TextPart, UserModelMessage } from "ai";
import type { UserMessageContentPart, Todo } from "./tools/types"; import type { UserMessageContentPart, Todo } from "./tools/types";
import { cleanMessageForOpenAI } from "@/ipc/utils/ai_messages_utils"; import { cleanMessage } from "@/ipc/utils/ai_messages_utils";
/** /**
* Check if a single todo is incomplete (pending or in_progress). * Check if a single todo is incomplete (pending or in_progress).
...@@ -158,7 +158,7 @@ export function prepareStepMessages< ...@@ -158,7 +158,7 @@ export function prepareStepMessages<
// Clean messages for OpenAI compatibility during multi-step agent flows: // Clean messages for OpenAI compatibility during multi-step agent flows:
// 1. Strip itemId to prevent "Item with id not found" errors // 1. Strip itemId to prevent "Item with id not found" errors
// 2. Filter orphaned reasoning to prevent "reasoning without following item" errors // 2. Filter orphaned reasoning to prevent "reasoning without following item" errors
const filteredMessages = messages.map(cleanMessageForOpenAI); const filteredMessages = messages.map(cleanMessage);
// Check if we need to return modified options // Check if we need to return modified options
const hasInjections = allInjectedMessages.length > 0; const hasInjections = allInjectedMessages.length > 0;
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论