diff --git a/core/providers/anthropic/chat.go b/core/providers/anthropic/chat.go index 4e0d141fcb2..4b433ab9345 100644 --- a/core/providers/anthropic/chat.go +++ b/core/providers/anthropic/chat.go @@ -682,9 +682,10 @@ func ToAnthropicChatRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.Bif for i < len(messages) && messages[i].Role == schemas.ChatMessageRoleTool { toolMsg := messages[i] if toolMsg.ChatToolMessage != nil && toolMsg.ChatToolMessage.ToolCallID != nil { + sanitizedToolUseID := providerUtils.SanitizeAnthropicToolUseID(*toolMsg.ChatToolMessage.ToolCallID) toolResult := AnthropicContentBlock{ Type: AnthropicContentBlockTypeToolResult, - ToolUseID: toolMsg.ChatToolMessage.ToolCallID, + ToolUseID: &sanitizedToolUseID, } // Convert tool result content @@ -783,7 +784,7 @@ func ToAnthropicChatRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.Bif for _, toolCall := range msg.ChatAssistantMessage.ToolCalls { toolUse := AnthropicContentBlock{ Type: AnthropicContentBlockTypeToolUse, - ID: toolCall.ID, + ID: providerUtils.SanitizeAnthropicToolUseIDPtr(toolCall.ID), Name: toolCall.Function.Name, } @@ -1180,7 +1181,7 @@ func ToAnthropicChatResponse(bifrostResp *schemas.BifrostChatResponse) *Anthropi content = append(content, AnthropicContentBlock{ Type: AnthropicContentBlockTypeToolUse, - ID: toolCall.ID, + ID: providerUtils.SanitizeAnthropicToolUseIDPtr(toolCall.ID), Name: toolCall.Function.Name, Input: inputRaw, }) @@ -1592,7 +1593,7 @@ func ToAnthropicChatStreamResponse(bifrostResp *schemas.BifrostChatResponse) str streamResp.Index = &choice.Index streamResp.ContentBlock = &AnthropicContentBlock{ Type: AnthropicContentBlockTypeToolUse, - ID: toolCall.ID, + ID: providerUtils.SanitizeAnthropicToolUseIDPtr(toolCall.ID), Name: toolCall.Function.Name, } } else if toolCall.Function.Arguments != "" { diff --git a/core/providers/anthropic/responses.go b/core/providers/anthropic/responses.go index 47b9db9fa08..25f7edf6537 100644 --- a/core/providers/anthropic/responses.go +++ b/core/providers/anthropic/responses.go @@ -2184,8 +2184,8 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp // Note: Computer tool calls should not be converted to thinking blocks contentBlock := &AnthropicContentBlock{ Type: AnthropicContentBlockTypeToolUse, - ID: bifrostResp.Item.ID, // The tool use ID - Name: schemas.Ptr(string(AnthropicToolNameComputer)), // "computer" + ID: providerUtils.SanitizeAnthropicToolUseIDPtr(bifrostResp.Item.ID), // The tool use ID + Name: schemas.Ptr(string(AnthropicToolNameComputer)), // "computer" } // Always start with empty input for streaming compatibility @@ -2203,8 +2203,8 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp // Build the content_block as server_tool_use contentBlock := &AnthropicContentBlock{ Type: AnthropicContentBlockTypeServerToolUse, - ID: bifrostResp.Item.ID, // The tool use ID - Name: schemas.Ptr(string(AnthropicToolNameWebSearch)), // "web_search" + ID: providerUtils.SanitizeAnthropicToolUseIDPtr(bifrostResp.Item.ID), // The tool use ID + Name: schemas.Ptr(string(AnthropicToolNameWebSearch)), // "web_search" } // Deliver the query whole in content_block_start (no input_json_delta), @@ -2224,7 +2224,7 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp if tm := bifrostResp.Item.ResponsesToolMessage; tm != nil && tm.Caller != nil { contentBlock.Caller = &AnthropicToolCaller{ Type: AnthropicToolCallerType(tm.Caller.Type), - ToolID: tm.Caller.ToolID, + ToolID: providerUtils.SanitizeAnthropicToolUseIDPtr(tm.Caller.ToolID), } } @@ -2240,6 +2240,7 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp if bifrostResp.Item.ResponsesToolMessage != nil && bifrostResp.Item.ResponsesToolMessage.CallID != nil { toolUseID = bifrostResp.Item.ResponsesToolMessage.CallID } + toolUseID = providerUtils.SanitizeAnthropicToolUseIDPtr(toolUseID) streamResp.ContentBlock = &AnthropicContentBlock{ Type: AnthropicContentBlockTypeServerToolUse, ID: toolUseID, @@ -2271,7 +2272,7 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp addedState.codeExecToolNameByItem[reverseStreamItemKey(bifrostResp)] = toolName streamResp.ContentBlock = &AnthropicContentBlock{ Type: AnthropicContentBlockTypeServerToolUse, - ID: toolUseID, + ID: providerUtils.SanitizeAnthropicToolUseIDPtr(toolUseID), Name: schemas.Ptr(toolName), Input: json.RawMessage("{}"), } @@ -2361,7 +2362,7 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp } else { contentBlock.Type = AnthropicContentBlockTypeToolUse if bifrostResp.Item.ResponsesToolMessage != nil { - contentBlock.ID = bifrostResp.Item.ResponsesToolMessage.CallID + contentBlock.ID = providerUtils.SanitizeAnthropicToolUseIDPtr(bifrostResp.Item.ResponsesToolMessage.CallID) contentBlock.Name = bifrostResp.Item.ResponsesToolMessage.Name // Always start with empty input for streaming compatibility contentBlock.Input = json.RawMessage("{}") @@ -2383,7 +2384,7 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp case schemas.ResponsesMessageTypeMCPCall: contentBlock.Type = AnthropicContentBlockTypeMCPToolUse if bifrostResp.Item.ResponsesToolMessage != nil { - contentBlock.ID = bifrostResp.Item.ID + contentBlock.ID = providerUtils.SanitizeAnthropicToolUseIDPtr(bifrostResp.Item.ID) contentBlock.Name = bifrostResp.Item.ResponsesToolMessage.Name if bifrostResp.Item.ResponsesToolMessage.ResponsesMCPToolCall != nil { contentBlock.ServerName = &bifrostResp.Item.ResponsesToolMessage.ResponsesMCPToolCall.ServerLabel @@ -2639,14 +2640,14 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp resultBlock := &AnthropicContentBlock{ Type: AnthropicContentBlockTypeWebSearchToolResult, - ToolUseID: bifrostResp.Item.ID, // Link to the server_tool_use block + ToolUseID: providerUtils.SanitizeAnthropicToolUseIDPtr(bifrostResp.Item.ID), // Link to the server_tool_use block Content: &AnthropicContent{ContentBlocks: resultContentBlocks}, } // Carry the programmatic-tool-calling caller onto the result too. if tm != nil && tm.Caller != nil { resultBlock.Caller = &AnthropicToolCaller{ Type: AnthropicToolCallerType(tm.Caller.Type), - ToolID: tm.Caller.ToolID, + ToolID: providerUtils.SanitizeAnthropicToolUseIDPtr(tm.Caller.ToolID), } } events = append(events, @@ -2743,6 +2744,7 @@ func ToAnthropicResponsesStreamResponse(ctx *schemas.BifrostContext, bifrostResp if bifrostResp.Item.ResponsesToolMessage != nil && bifrostResp.Item.ResponsesToolMessage.CallID != nil { toolUseID = bifrostResp.Item.ResponsesToolMessage.CallID } + toolUseID = providerUtils.SanitizeAnthropicToolUseIDPtr(toolUseID) serverIdx := state.blockIndexFor(reverseStreamItemKey(bifrostResp)) @@ -5508,7 +5510,7 @@ func convertBifrostFunctionCallToAnthropicToolUse(ctx *schemas.BifrostContext, m } if msg.ResponsesToolMessage.CallID != nil { - toolUseBlock.ID = msg.ResponsesToolMessage.CallID + toolUseBlock.ID = providerUtils.SanitizeAnthropicToolUseIDPtr(msg.ResponsesToolMessage.CallID) } if msg.ResponsesToolMessage.Name != nil { toolUseBlock.Name = msg.ResponsesToolMessage.Name @@ -5546,7 +5548,7 @@ func convertBifrostFunctionCallOutputToAnthropicToolResultBlock(msg *schemas.Res if msg.ResponsesToolMessage != nil { toolResultBlock := AnthropicContentBlock{ Type: AnthropicContentBlockTypeToolResult, - ToolUseID: msg.ResponsesToolMessage.CallID, + ToolUseID: providerUtils.SanitizeAnthropicToolUseIDPtr(msg.ResponsesToolMessage.CallID), CacheControl: msg.CacheControl, } @@ -5602,7 +5604,7 @@ func convertBifrostComputerCallOutputToAnthropicToolResultBlock(msg *schemas.Res if msg.ResponsesToolMessage != nil && msg.ResponsesToolMessage.CallID != nil { toolResultBlock := AnthropicContentBlock{ Type: AnthropicContentBlockTypeToolResult, - ToolUseID: msg.ResponsesToolMessage.CallID, + ToolUseID: providerUtils.SanitizeAnthropicToolUseIDPtr(msg.ResponsesToolMessage.CallID), } // Handle output @@ -5633,7 +5635,7 @@ func convertBifrostMCPCallOutputToAnthropicToolResultBlock(msg *schemas.Response if msg.ResponsesToolMessage != nil && msg.ResponsesToolMessage.CallID != nil { toolResultBlock := AnthropicContentBlock{ Type: AnthropicContentBlockTypeMCPToolResult, - ToolUseID: msg.ResponsesToolMessage.CallID, + ToolUseID: providerUtils.SanitizeAnthropicToolUseIDPtr(msg.ResponsesToolMessage.CallID), } // Handle output @@ -5688,7 +5690,7 @@ func convertBifrostComputerCallToAnthropicToolUse(msg *schemas.ResponsesMessage) Name: schemas.Ptr(string(AnthropicToolNameComputer)), } if msg.ResponsesToolMessage.CallID != nil { - toolUseBlock.ID = msg.ResponsesToolMessage.CallID + toolUseBlock.ID = providerUtils.SanitizeAnthropicToolUseIDPtr(msg.ResponsesToolMessage.CallID) } if msg.ResponsesToolMessage.Name != nil { toolUseBlock.Name = msg.ResponsesToolMessage.Name @@ -5714,7 +5716,7 @@ func convertBifrostMCPCallToAnthropicToolUse(msg *schemas.ResponsesMessage) *Ant } if msg.ID != nil { - toolUseBlock.ID = msg.ID + toolUseBlock.ID = providerUtils.SanitizeAnthropicToolUseIDPtr(msg.ID) } toolUseBlock.Name = msg.ResponsesToolMessage.Name @@ -5741,7 +5743,7 @@ func convertBifrostMCPCallToAnthropicToolUse(msg *schemas.ResponsesMessage) *Ant func convertBifrostMCPCallOutputToAnthropicMessage(msg *schemas.ResponsesMessage) *AnthropicMessage { toolResultBlock := AnthropicContentBlock{ Type: AnthropicContentBlockTypeMCPToolResult, - ID: msg.ResponsesToolMessage.CallID, + ID: providerUtils.SanitizeAnthropicToolUseIDPtr(msg.ResponsesToolMessage.CallID), } if msg.ResponsesToolMessage.Output != nil { @@ -5764,7 +5766,7 @@ func convertBifrostMCPApprovalToAnthropicToolUse(msg *schemas.ResponsesMessage) } if msg.ID != nil { - toolUseBlock.ID = msg.ID + toolUseBlock.ID = providerUtils.SanitizeAnthropicToolUseIDPtr(msg.ID) } toolUseBlock.Name = msg.ResponsesToolMessage.Name @@ -5800,7 +5802,7 @@ func convertBifrostWebSearchCallToAnthropicBlocks(msg *schemas.ResponsesMessage) // sandbox) must be re-emitted on both the server_tool_use and the result block. var caller *AnthropicToolCaller if c := msg.ResponsesToolMessage.Caller; c != nil { - caller = &AnthropicToolCaller{Type: AnthropicToolCallerType(c.Type), ToolID: c.ToolID} + caller = &AnthropicToolCaller{Type: AnthropicToolCallerType(c.Type), ToolID: providerUtils.SanitizeAnthropicToolUseIDPtr(c.ToolID)} } // 1. Create server_tool_use block for the web search @@ -5811,7 +5813,7 @@ func convertBifrostWebSearchCallToAnthropicBlocks(msg *schemas.ResponsesMessage) } if msg.ID != nil { - serverToolUseBlock.ID = msg.ID + serverToolUseBlock.ID = providerUtils.SanitizeAnthropicToolUseIDPtr(msg.ID) } // Extract the query from the action @@ -5852,6 +5854,7 @@ func convertBifrostWebSearchCallToAnthropicBlocks(msg *schemas.ResponsesMessage) } else { toolUseID = msg.ID } + toolUseID = providerUtils.SanitizeAnthropicToolUseIDPtr(toolUseID) webSearchResultBlock := AnthropicContentBlock{ Type: AnthropicContentBlockTypeWebSearchToolResult, ToolUseID: toolUseID, @@ -5949,10 +5952,11 @@ func convertBifrostWebFetchCallToAnthropicBlocks(msg *schemas.ResponsesMessage) } else { toolUseID = msg.ID } + toolUseID = providerUtils.SanitizeAnthropicToolUseIDPtr(toolUseID) var caller *AnthropicToolCaller if tm.Caller != nil { - caller = &AnthropicToolCaller{Type: AnthropicToolCallerType(tm.Caller.Type), ToolID: tm.Caller.ToolID} + caller = &AnthropicToolCaller{Type: AnthropicToolCallerType(tm.Caller.Type), ToolID: providerUtils.SanitizeAnthropicToolUseIDPtr(tm.Caller.ToolID)} } serverToolUseBlock := AnthropicContentBlock{ @@ -6032,6 +6036,7 @@ func convertBifrostAdvisorCallToAnthropicBlocks(msg *schemas.ResponsesMessage) [ } else { toolUseID = msg.ID } + toolUseID = providerUtils.SanitizeAnthropicToolUseIDPtr(toolUseID) // 1. server_tool_use block — advisor input is always empty. serverToolUseBlock := AnthropicContentBlock{ @@ -6277,6 +6282,7 @@ func convertBifrostCodeExecCallToAnthropicBlocks(msg *schemas.ResponsesMessage) } else { toolUseID = msg.ID } + toolUseID = providerUtils.SanitizeAnthropicToolUseIDPtr(toolUseID) toolName := cec.ToolName if toolName == "" { @@ -6304,7 +6310,7 @@ func convertBifrostCodeExecCallToAnthropicBlocks(msg *schemas.ResponsesMessage) if cec.Caller != nil { serverToolUse.Caller = &AnthropicToolCaller{ Type: AnthropicToolCallerType(cec.Caller.Type), - ToolID: cec.Caller.ToolID, + ToolID: providerUtils.SanitizeAnthropicToolUseIDPtr(cec.Caller.ToolID), } } @@ -6380,7 +6386,7 @@ func convertBifrostCodeExecCallToAnthropicBlocks(msg *schemas.ResponsesMessage) if cec.Caller != nil { resultBlock.Caller = &AnthropicToolCaller{ Type: AnthropicToolCallerType(cec.Caller.Type), - ToolID: cec.Caller.ToolID, + ToolID: providerUtils.SanitizeAnthropicToolUseIDPtr(cec.Caller.ToolID), } } @@ -6418,7 +6424,7 @@ func convertBifrostComputerCallOutputToAnthropicMessage(msg *schemas.ResponsesMe if msg.ResponsesToolMessage != nil { toolResultBlock := AnthropicContentBlock{ Type: AnthropicContentBlockTypeToolResult, - ToolUseID: msg.ResponsesToolMessage.CallID, + ToolUseID: providerUtils.SanitizeAnthropicToolUseIDPtr(msg.ResponsesToolMessage.CallID), } if msg.ResponsesToolMessage.Output != nil { diff --git a/core/providers/utils/utils.go b/core/providers/utils/utils.go index 6ee9898856d..0dc4e033a4c 100644 --- a/core/providers/utils/utils.go +++ b/core/providers/utils/utils.go @@ -27,6 +27,7 @@ import ( "time" "github.com/bytedance/sonic" + "github.com/cespare/xxhash/v2" "github.com/maximhq/bifrost/core/network" "github.com/maximhq/bifrost/core/schemas" "github.com/tidwall/gjson" @@ -40,6 +41,57 @@ import ( // "_ts_". const ThoughtSignatureSeparator = "_ts_" +// anthropicUnsafeToolUseIDCharRegex matches any character outside Anthropic's +// required tool_use/tool_result id charset (^[a-zA-Z0-9_-]+$). +var anthropicUnsafeToolUseIDCharRegex = regexp.MustCompile(`[^A-Za-z0-9_-]+`) + +// maxSanitizedAnthropicToolUseIDLen bounds the sanitized id length, matching the +// 64-char cap this codebase already applies to tool identifiers elsewhere (e.g. +// OpenAI's call_id, Bedrock's tool-name aliasing) so a long, non-conforming +// upstream id can't sanitize into something Anthropic still rejects for length. +const maxSanitizedAnthropicToolUseIDLen = 64 + +// SanitizeAnthropicToolUseID rewrites a tool_use/tool_result id to satisfy Anthropic's +// ^[a-zA-Z0-9_-]+$ requirement. Some upstream providers (e.g. Kimi/Gemini-compatible +// backends) emit ids containing ':' or '.', which Anthropic's API rejects with a 400 +// when such a conversation is replayed through the Anthropic provider. The mapping is +// deterministic (hash of the original id) so a tool_use id and its matching tool_result +// id always sanitize to the same value within a request, matching the alias pattern +// used for Bedrock tool names (see bedrockAliasToolName). +func SanitizeAnthropicToolUseID(id string) string { + // The empty string doesn't match Anthropic's pattern either (it requires at + // least one character), so it needs the same hash-based rewrite as ids with + // disallowed characters rather than being passed through unchanged. + if id != "" && !anthropicUnsafeToolUseIDCharRegex.MatchString(id) { + return id + } + // Use the full 64-bit hash (not a 32-bit truncation) to keep collisions + // between distinct ids astronomically unlikely, since two tool_use blocks + // sharing an id would make Anthropic's replies ambiguous or rejected. + hash := fmt.Sprintf("%016x", xxhash.Sum64String(id)) + semantic := strings.Trim(anthropicUnsafeToolUseIDCharRegex.ReplaceAllString(id, "_"), "_") + if semantic == "" { + return hash + } + if maxSemanticLen := maxSanitizedAnthropicToolUseIDLen - len(hash) - 1; len(semantic) > maxSemanticLen { + semantic = strings.Trim(semantic[:maxSemanticLen], "_") + } + if semantic == "" { + return hash + } + return hash + "_" + semantic +} + +// SanitizeAnthropicToolUseIDPtr is SanitizeAnthropicToolUseID for an optional id. +// Returns nil unchanged. +func SanitizeAnthropicToolUseIDPtr(id *string) *string { + if id == nil { + return nil + } + sanitized := SanitizeAnthropicToolUseID(*id) + return &sanitized +} + // StripThoughtSignature returns the base tool-call ID without any embedded provider // reasoning signature. It is deterministic, so a tool call and its matching output strip // to the same ID. Providers that cannot use the signature (e.g. OpenAI, which caps call_id diff --git a/core/providers/utils/utils_test.go b/core/providers/utils/utils_test.go index 12c68eca835..f7389480bef 100644 --- a/core/providers/utils/utils_test.go +++ b/core/providers/utils/utils_test.go @@ -1938,6 +1938,76 @@ func TestStripThoughtSignature(t *testing.T) { } } +func TestSanitizeAnthropicToolUseID(t *testing.T) { + cases := []struct { + name string + in string + }{ + {"empty", ""}, + {"already valid", "call_abc123_XYZ-9"}, + {"kimi-style colon and dot", "functions.Bash:0"}, + {"gemini-style slash", "projects/foo/tool/1"}, + {"only unsafe chars", "::.."}, + {"long id with one unsafe char", "a-very-long-tool-call-identifier-that-goes-on-and-on:0"}, + {"long id with many unsafe chars", strings.Repeat("segment.with.dots/and:colons/", 5)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := SanitizeAnthropicToolUseID(tc.in) + + // Anthropic's pattern requires at least one character, so an already-valid, + // non-empty id is the only case left unchanged; everything else (including + // the empty string) must be rewritten to a non-empty, conforming id. + if tc.in != "" && !anthropicUnsafeToolUseIDCharRegex.MatchString(tc.in) { + if got != tc.in { + t.Errorf("SanitizeAnthropicToolUseID(%q) = %q, want unchanged", tc.in, got) + } + return + } + if got == "" { + t.Errorf("SanitizeAnthropicToolUseID(%q) = empty, want a non-empty conforming id", tc.in) + } + if anthropicUnsafeToolUseIDCharRegex.MatchString(got) { + t.Errorf("SanitizeAnthropicToolUseID(%q) = %q, still contains unsafe characters", tc.in, got) + } + if len(got) > maxSanitizedAnthropicToolUseIDLen { + t.Errorf("SanitizeAnthropicToolUseID(%q) = %q (len %d), exceeds %d-char cap", tc.in, got, len(got), maxSanitizedAnthropicToolUseIDLen) + } + if got2 := SanitizeAnthropicToolUseID(tc.in); got2 != got { + t.Errorf("SanitizeAnthropicToolUseID(%q) is not deterministic: %q != %q", tc.in, got, got2) + } + }) + } + + // A tool_use id and its matching tool_result id must sanitize identically, + // since Anthropic requires them to reference the same value. + toolUseID := "functions.get_weather:0" + if SanitizeAnthropicToolUseID(toolUseID) != SanitizeAnthropicToolUseID(toolUseID) { + t.Error("matching tool_use/tool_result ids diverged after sanitization") + } + + // Distinct ids that collapse to the same replaced-character skeleton must + // still sanitize to distinct values (hash is computed on the original id). + if SanitizeAnthropicToolUseID("functions.Bash:0") == SanitizeAnthropicToolUseID("functions.Bash:1") { + t.Error("distinct tool ids sanitized to the same value") + } +} + +func TestSanitizeAnthropicToolUseIDPtr(t *testing.T) { + if got := SanitizeAnthropicToolUseIDPtr(nil); got != nil { + t.Errorf("SanitizeAnthropicToolUseIDPtr(nil) = %v, want nil", got) + } + + id := "functions.Bash:0" + got := SanitizeAnthropicToolUseIDPtr(&id) + if got == nil { + t.Fatal("SanitizeAnthropicToolUseIDPtr returned nil for non-nil input") + } + if *got != SanitizeAnthropicToolUseID(id) { + t.Errorf("SanitizeAnthropicToolUseIDPtr(%q) = %q, want %q", id, *got, SanitizeAnthropicToolUseID(id)) + } +} + // finalizerTestTracer is a minimal schemas.Tracer that models only the // deferred-span lifecycle: a span stays parked until ClearDeferredSpan runs. // It records the status passed to EndSpan so tests can assert span outcomes.