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", () => {
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", () => {
const msg: DbMessageForParsing = {
id: 22,
......
......@@ -51,6 +51,7 @@ function stripItemIdFromPart(part: Record<string, unknown>): boolean {
* Clean up a message's content parts for OpenAI compatibility:
* 1. Strip itemId from provider metadata (prevents "Item with id not found" 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
* these to `item_reference` payloads. If OpenAI has expired those items, this causes
......@@ -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.
*/
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)) {
return message;
}
......@@ -94,6 +95,16 @@ export function cleanMessageForOpenAI<T extends ModelMessage>(message: T): T {
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);
}
......@@ -104,11 +115,8 @@ export function cleanMessageForOpenAI<T extends ModelMessage>(message: T): T {
return { ...message, content: cleanedContent } as T;
}
/**
* Clean all messages in an array for OpenAI compatibility.
*/
function cleanMessagesForOpenAI(messages: ModelMessage[]): ModelMessage[] {
return messages.map(cleanMessageForOpenAI);
function cleanMessages(messages: ModelMessage[]): ModelMessage[] {
return messages.map(cleanMessage);
}
/** Maximum size in bytes for ai_messages_json (10MB) */
......@@ -163,7 +171,7 @@ export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] {
Array.isArray(parsed) &&
parsed.every((m) => m && typeof m.role === "string")
) {
return cleanMessagesForOpenAI(parsed);
return cleanMessages(parsed);
}
if (
......@@ -177,7 +185,7 @@ export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] {
(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(
type: "tool-call",
toolCallId: part.toolCallId,
toolName: part.toolName,
input: part.input,
input:
typeof part.input === "object" && part.input !== null ? part.input : {},
});
return;
}
......
......@@ -7,7 +7,7 @@
import { ImagePart, ModelMessage, TextPart, UserModelMessage } from "ai";
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).
......@@ -158,7 +158,7 @@ export function prepareStepMessages<
// Clean messages for OpenAI compatibility during multi-step agent flows:
// 1. Strip itemId to prevent "Item with id not found" 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
const hasInjections = allInjectedMessages.length > 0;
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论