Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit f5d32e7

Browse files
authored
Fix LiteLLM tool ID validation errors for Bedrock proxy (#10990)
1 parent 3edf71e commit f5d32e7

2 files changed

Lines changed: 206 additions & 1 deletion

File tree

src/api/providers/__tests__/lite-llm.spec.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,4 +718,206 @@ describe("LiteLLMHandler", () => {
718718
})
719719
})
720720
})
721+
722+
describe("tool ID normalization", () => {
723+
it("should truncate tool IDs longer than 64 characters", async () => {
724+
const optionsWithBedrock: ApiHandlerOptions = {
725+
...mockOptions,
726+
litellmModelId: "bedrock/anthropic.claude-3-sonnet",
727+
}
728+
handler = new LiteLLMHandler(optionsWithBedrock)
729+
730+
vi.spyOn(handler as any, "fetchModel").mockResolvedValue({
731+
id: "bedrock/anthropic.claude-3-sonnet",
732+
info: { ...litellmDefaultModelInfo, maxTokens: 8192 },
733+
})
734+
735+
// Create a tool ID longer than 64 characters
736+
const longToolId = "toolu_" + "a".repeat(70) // 76 characters total
737+
738+
const systemPrompt = "You are a helpful assistant"
739+
const messages: Anthropic.Messages.MessageParam[] = [
740+
{ role: "user", content: "Hello" },
741+
{
742+
role: "assistant",
743+
content: [
744+
{ type: "text", text: "I'll help you with that." },
745+
{ type: "tool_use", id: longToolId, name: "read_file", input: { path: "test.txt" } },
746+
],
747+
},
748+
{
749+
role: "user",
750+
content: [{ type: "tool_result", tool_use_id: longToolId, content: "file contents" }],
751+
},
752+
]
753+
754+
const mockStream = {
755+
async *[Symbol.asyncIterator]() {
756+
yield {
757+
choices: [{ delta: { content: "Response" } }],
758+
usage: { prompt_tokens: 100, completion_tokens: 20 },
759+
}
760+
},
761+
}
762+
763+
mockCreate.mockReturnValue({
764+
withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
765+
})
766+
767+
const generator = handler.createMessage(systemPrompt, messages)
768+
for await (const _chunk of generator) {
769+
// Consume
770+
}
771+
772+
// Verify that tool IDs are truncated to 64 characters or less
773+
const createCall = mockCreate.mock.calls[0][0]
774+
const assistantMessage = createCall.messages.find(
775+
(msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0,
776+
)
777+
const toolMessage = createCall.messages.find((msg: any) => msg.role === "tool")
778+
779+
expect(assistantMessage).toBeDefined()
780+
expect(assistantMessage.tool_calls[0].id.length).toBeLessThanOrEqual(64)
781+
782+
expect(toolMessage).toBeDefined()
783+
expect(toolMessage.tool_call_id.length).toBeLessThanOrEqual(64)
784+
})
785+
786+
it("should not modify tool IDs that are already within 64 characters", async () => {
787+
const optionsWithBedrock: ApiHandlerOptions = {
788+
...mockOptions,
789+
litellmModelId: "bedrock/anthropic.claude-3-sonnet",
790+
}
791+
handler = new LiteLLMHandler(optionsWithBedrock)
792+
793+
vi.spyOn(handler as any, "fetchModel").mockResolvedValue({
794+
id: "bedrock/anthropic.claude-3-sonnet",
795+
info: { ...litellmDefaultModelInfo, maxTokens: 8192 },
796+
})
797+
798+
// Create a tool ID within 64 characters
799+
const shortToolId = "toolu_01ABC123" // Well under 64 characters
800+
801+
const systemPrompt = "You are a helpful assistant"
802+
const messages: Anthropic.Messages.MessageParam[] = [
803+
{ role: "user", content: "Hello" },
804+
{
805+
role: "assistant",
806+
content: [
807+
{ type: "text", text: "I'll help you with that." },
808+
{ type: "tool_use", id: shortToolId, name: "read_file", input: { path: "test.txt" } },
809+
],
810+
},
811+
{
812+
role: "user",
813+
content: [{ type: "tool_result", tool_use_id: shortToolId, content: "file contents" }],
814+
},
815+
]
816+
817+
const mockStream = {
818+
async *[Symbol.asyncIterator]() {
819+
yield {
820+
choices: [{ delta: { content: "Response" } }],
821+
usage: { prompt_tokens: 100, completion_tokens: 20 },
822+
}
823+
},
824+
}
825+
826+
mockCreate.mockReturnValue({
827+
withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
828+
})
829+
830+
const generator = handler.createMessage(systemPrompt, messages)
831+
for await (const _chunk of generator) {
832+
// Consume
833+
}
834+
835+
// Verify that tool IDs are unchanged
836+
const createCall = mockCreate.mock.calls[0][0]
837+
const assistantMessage = createCall.messages.find(
838+
(msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0,
839+
)
840+
const toolMessage = createCall.messages.find((msg: any) => msg.role === "tool")
841+
842+
expect(assistantMessage).toBeDefined()
843+
expect(assistantMessage.tool_calls[0].id).toBe(shortToolId)
844+
845+
expect(toolMessage).toBeDefined()
846+
expect(toolMessage.tool_call_id).toBe(shortToolId)
847+
})
848+
849+
it("should maintain uniqueness with hash suffix when truncating", async () => {
850+
const optionsWithBedrock: ApiHandlerOptions = {
851+
...mockOptions,
852+
litellmModelId: "bedrock/anthropic.claude-3-sonnet",
853+
}
854+
handler = new LiteLLMHandler(optionsWithBedrock)
855+
856+
vi.spyOn(handler as any, "fetchModel").mockResolvedValue({
857+
id: "bedrock/anthropic.claude-3-sonnet",
858+
info: { ...litellmDefaultModelInfo, maxTokens: 8192 },
859+
})
860+
861+
// Create two tool IDs that differ only near the end
862+
const longToolId1 = "toolu_" + "a".repeat(60) + "_suffix1"
863+
const longToolId2 = "toolu_" + "a".repeat(60) + "_suffix2"
864+
865+
const systemPrompt = "You are a helpful assistant"
866+
const messages: Anthropic.Messages.MessageParam[] = [
867+
{ role: "user", content: "Hello" },
868+
{
869+
role: "assistant",
870+
content: [
871+
{ type: "text", text: "I'll help." },
872+
{ type: "tool_use", id: longToolId1, name: "read_file", input: { path: "test1.txt" } },
873+
{ type: "tool_use", id: longToolId2, name: "read_file", input: { path: "test2.txt" } },
874+
],
875+
},
876+
{
877+
role: "user",
878+
content: [
879+
{ type: "tool_result", tool_use_id: longToolId1, content: "file1 contents" },
880+
{ type: "tool_result", tool_use_id: longToolId2, content: "file2 contents" },
881+
],
882+
},
883+
]
884+
885+
const mockStream = {
886+
async *[Symbol.asyncIterator]() {
887+
yield {
888+
choices: [{ delta: { content: "Response" } }],
889+
usage: { prompt_tokens: 100, completion_tokens: 20 },
890+
}
891+
},
892+
}
893+
894+
mockCreate.mockReturnValue({
895+
withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
896+
})
897+
898+
const generator = handler.createMessage(systemPrompt, messages)
899+
for await (const _chunk of generator) {
900+
// Consume
901+
}
902+
903+
// Verify that truncated tool IDs are unique (hash suffix ensures this)
904+
const createCall = mockCreate.mock.calls[0][0]
905+
const assistantMessage = createCall.messages.find(
906+
(msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0,
907+
)
908+
909+
expect(assistantMessage).toBeDefined()
910+
expect(assistantMessage.tool_calls).toHaveLength(2)
911+
912+
const id1 = assistantMessage.tool_calls[0].id
913+
const id2 = assistantMessage.tool_calls[1].id
914+
915+
// Both should be truncated to 64 characters
916+
expect(id1.length).toBeLessThanOrEqual(64)
917+
expect(id2.length).toBeLessThanOrEqual(64)
918+
919+
// They should be different (hash suffix ensures uniqueness)
920+
expect(id1).not.toBe(id2)
921+
})
922+
})
721923
})

src/api/providers/lite-llm.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ApiHandlerOptions } from "../../shared/api"
99

1010
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
1111
import { convertToOpenAiMessages } from "../transform/openai-format"
12+
import { sanitizeOpenAiCallId } from "../../utils/tool-id"
1213

1314
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
1415
import { RouterProvider } from "./router-provider"
@@ -115,7 +116,9 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
115116
): ApiStream {
116117
const { id: modelId, info } = await this.fetchModel()
117118

118-
const openAiMessages = convertToOpenAiMessages(messages)
119+
const openAiMessages = convertToOpenAiMessages(messages, {
120+
normalizeToolCallId: sanitizeOpenAiCallId,
121+
})
119122

120123
// Prepare messages with cache control if enabled and supported
121124
let systemMessage: OpenAI.Chat.ChatCompletionMessageParam

0 commit comments

Comments
 (0)