@@ -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} )
0 commit comments