diff --git a/internal/protocol/ops/request_anthropic_server_tool_use.go b/internal/protocol/ops/request_anthropic_server_tool_use.go new file mode 100644 index 000000000..978f79403 --- /dev/null +++ b/internal/protocol/ops/request_anthropic_server_tool_use.go @@ -0,0 +1,117 @@ +package ops + +import ( + "fmt" + "regexp" + + "github.com/anthropics/anthropic-sdk-go" +) + +// serverToolUseIDPattern is the ID format api.anthropic.com enforces on +// server_tool_use blocks (messages.N.content.M.server_tool_use.id). +var serverToolUseIDPattern = regexp.MustCompile(`^srvtoolu_[a-zA-Z0-9_]+$`) + +// serverToolUseIDInvalidChars matches every character the pattern above rejects. +var serverToolUseIDInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9_]`) + +// SanitizeAnthropicV1ServerToolUseIDs rewrites server_tool_use block IDs in the +// replayed message history so they satisfy Anthropic's ^srvtoolu_[a-zA-Z0-9_]+$ +// requirement. History that passed through another provider (an OpenAI-converted +// response or a third-party Anthropic-compatible endpoint) can carry IDs minted +// in that provider's format; forwarding them verbatim makes api.anthropic.com +// reject the whole request with a 400. Result blocks referencing a rewritten ID +// via tool_use_id are remapped to keep the pairing intact. +func SanitizeAnthropicV1ServerToolUseIDs(req *anthropic.MessageNewParams) { + if req == nil { + return + } + + remap := make(map[string]string) + unnamed := 0 + for mi := range req.Messages { + for bi := range req.Messages[mi].Content { + stu := req.Messages[mi].Content[bi].OfServerToolUse + if stu == nil || serverToolUseIDPattern.MatchString(stu.ID) { + continue + } + newID, ok := remap[stu.ID] + if !ok { + newID = rewriteServerToolUseID(stu.ID, &unnamed) + if stu.ID != "" { + remap[stu.ID] = newID + } + } + stu.ID = newID + } + } + + if len(remap) == 0 { + return + } + for mi := range req.Messages { + for bi := range req.Messages[mi].Content { + if idRef := req.Messages[mi].Content[bi].GetToolUseID(); idRef != nil { + if newID, ok := remap[*idRef]; ok { + *idRef = newID + } + } + } + } +} + +// SanitizeAnthropicBetaServerToolUseIDs is the Beta-variant of +// SanitizeAnthropicV1ServerToolUseIDs. +func SanitizeAnthropicBetaServerToolUseIDs(req *anthropic.BetaMessageNewParams) { + if req == nil { + return + } + + remap := make(map[string]string) + unnamed := 0 + for mi := range req.Messages { + for bi := range req.Messages[mi].Content { + stu := req.Messages[mi].Content[bi].OfServerToolUse + if stu == nil || serverToolUseIDPattern.MatchString(stu.ID) { + continue + } + newID, ok := remap[stu.ID] + if !ok { + newID = rewriteServerToolUseID(stu.ID, &unnamed) + if stu.ID != "" { + remap[stu.ID] = newID + } + } + stu.ID = newID + } + } + + if len(remap) == 0 { + return + } + for mi := range req.Messages { + for bi := range req.Messages[mi].Content { + if idRef := req.Messages[mi].Content[bi].GetToolUseID(); idRef != nil { + if newID, ok := remap[*idRef]; ok { + *idRef = newID + } + } + } + } +} + +// rewriteServerToolUseID derives a conforming ID from a foreign one. The +// original ID is kept (with rejected characters replaced by "_") so the model +// can still correlate the block across turns, and the mapping stays +// deterministic for identical input. Empty IDs get a per-request counter since +// they carry nothing to correlate on. +func rewriteServerToolUseID(id string, unnamed *int) string { + if id == "" { + *unnamed++ + return fmt.Sprintf("srvtoolu_missing_%d", *unnamed) + } + sanitized := serverToolUseIDInvalidChars.ReplaceAllString(id, "_") + if serverToolUseIDPattern.MatchString(sanitized) { + return sanitized + } + return "srvtoolu_" + sanitized +} diff --git a/internal/protocol/ops/request_anthropic_server_tool_use_test.go b/internal/protocol/ops/request_anthropic_server_tool_use_test.go new file mode 100644 index 000000000..ab4210a04 --- /dev/null +++ b/internal/protocol/ops/request_anthropic_server_tool_use_test.go @@ -0,0 +1,248 @@ +package ops + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/anthropics/anthropic-sdk-go" +) + +const betaServerToolUseHistoryJSON = `{ + "model": "claude-sonnet-5", + "max_tokens": 1024, + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "search something"}]}, + {"role": "assistant", "content": [ + {"type": "text", "text": "searching"}, + {"type": "thinking", "thinking": "t", "signature": "s"}, + {"type": "server_tool_use", "id": "call_abc-123", "name": "web_search", "input": {"query": "q"}} + ]}, + {"role": "user", "content": [ + {"type": "web_search_tool_result", "tool_use_id": "call_abc-123", "content": []} + ]} + ] +}` + +func TestSanitizeAnthropicBetaServerToolUseIDs_RewritesInvalidIDAndRemapsResult(t *testing.T) { + var req anthropic.BetaMessageNewParams + if err := json.Unmarshal([]byte(betaServerToolUseHistoryJSON), &req); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + + SanitizeAnthropicBetaServerToolUseIDs(&req) + + stu := req.Messages[1].Content[2].OfServerToolUse + if stu == nil { + t.Fatalf("server_tool_use block lost after sanitize") + } + if !serverToolUseIDPattern.MatchString(stu.ID) { + t.Fatalf("server_tool_use id %q still invalid", stu.ID) + } + if !strings.Contains(stu.ID, "call_abc") { + t.Fatalf("rewritten id %q lost the original correlation hint", stu.ID) + } + + result := req.Messages[2].Content[0].OfWebSearchToolResult + if result == nil { + t.Fatalf("web_search_tool_result block lost after sanitize") + } + if result.ToolUseID != stu.ID { + t.Fatalf("tool_use_id %q not remapped to %q", result.ToolUseID, stu.ID) + } + + // The sanitized request must serialize with the new IDs. + b, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + if strings.Contains(string(b), "call_abc-123") { + t.Fatalf("serialized request still contains the invalid id: %s", b) + } +} + +func TestSanitizeAnthropicBetaServerToolUseIDs_KeepsValidIDs(t *testing.T) { + req := anthropic.BetaMessageNewParams{ + Messages: []anthropic.BetaMessageParam{ + betaAssistantMessage( + anthropic.NewBetaServerToolUseBlock("srvtoolu_01AbC", map[string]any{"query": "q"}, "web_search"), + ), + }, + } + + SanitizeAnthropicBetaServerToolUseIDs(&req) + + if got := req.Messages[0].Content[0].OfServerToolUse.ID; got != "srvtoolu_01AbC" { + t.Fatalf("valid id was rewritten to %q", got) + } +} + +func TestSanitizeAnthropicBetaServerToolUseIDs_EmptyIDsGetDistinctIDs(t *testing.T) { + req := anthropic.BetaMessageNewParams{ + Messages: []anthropic.BetaMessageParam{ + betaAssistantMessage( + anthropic.NewBetaServerToolUseBlock("", nil, "web_search"), + anthropic.NewBetaServerToolUseBlock("", nil, "web_search"), + ), + }, + } + + SanitizeAnthropicBetaServerToolUseIDs(&req) + + first := req.Messages[0].Content[0].OfServerToolUse.ID + second := req.Messages[0].Content[1].OfServerToolUse.ID + if !serverToolUseIDPattern.MatchString(first) || !serverToolUseIDPattern.MatchString(second) { + t.Fatalf("empty ids not rewritten: %q, %q", first, second) + } + if first == second { + t.Fatalf("empty ids collided: %q", first) + } +} + +func TestSanitizeAnthropicV1ServerToolUseIDs_RewritesInvalidIDAndRemapsResult(t *testing.T) { + req := anthropic.MessageNewParams{ + Messages: []anthropic.MessageParam{ + anthropic.NewAssistantMessage( + anthropic.NewServerToolUseBlock("toolu_xyz.9", map[string]any{"query": "q"}, "web_search"), + ), + { + Role: anthropic.MessageParamRoleUser, + Content: []anthropic.ContentBlockParamUnion{ + {OfWebSearchToolResult: &anthropic.WebSearchToolResultBlockParam{ToolUseID: "toolu_xyz.9"}}, + }, + }, + }, + } + + SanitizeAnthropicV1ServerToolUseIDs(&req) + + stu := req.Messages[0].Content[0].OfServerToolUse + if !serverToolUseIDPattern.MatchString(stu.ID) { + t.Fatalf("server_tool_use id %q still invalid", stu.ID) + } + if got := req.Messages[1].Content[0].OfWebSearchToolResult.ToolUseID; got != stu.ID { + t.Fatalf("tool_use_id %q not remapped to %q", got, stu.ID) + } +} + +func TestSanitizeAnthropicV1ServerToolUseIDs_SameOldIDMapsToSameNewID(t *testing.T) { + req := anthropic.MessageNewParams{ + Messages: []anthropic.MessageParam{ + anthropic.NewAssistantMessage( + anthropic.NewServerToolUseBlock("call_1", nil, "web_search"), + ), + anthropic.NewAssistantMessage( + anthropic.NewServerToolUseBlock("call_1", nil, "web_search"), + ), + }, + } + + SanitizeAnthropicV1ServerToolUseIDs(&req) + + first := req.Messages[0].Content[0].OfServerToolUse.ID + second := req.Messages[1].Content[0].OfServerToolUse.ID + if first != second { + t.Fatalf("same old id mapped to different new ids: %q vs %q", first, second) + } +} + +func TestSanitizeAnthropicBetaServerToolUseIDs_PrefixedButInvalidCharsNotDoublePrefixed(t *testing.T) { + req := anthropic.BetaMessageNewParams{ + Messages: []anthropic.BetaMessageParam{ + betaAssistantMessage( + anthropic.NewBetaServerToolUseBlock("srvtoolu_abc-def", nil, "web_search"), + ), + }, + } + + SanitizeAnthropicBetaServerToolUseIDs(&req) + + if got := req.Messages[0].Content[0].OfServerToolUse.ID; got != "srvtoolu_abc_def" { + t.Fatalf("expected srvtoolu_abc_def, got %q", got) + } +} + +func TestSanitizeAnthropicBetaServerToolUseIDs_MixedValidAndInvalid(t *testing.T) { + req := anthropic.BetaMessageNewParams{ + Messages: []anthropic.BetaMessageParam{ + betaAssistantMessage( + anthropic.NewBetaServerToolUseBlock("srvtoolu_ok1", nil, "web_search"), + anthropic.NewBetaServerToolUseBlock("call_bad-1", nil, "web_search"), + ), + anthropic.NewBetaUserMessage( + anthropic.BetaContentBlockParamUnion{OfWebSearchToolResult: &anthropic.BetaWebSearchToolResultBlockParam{ToolUseID: "srvtoolu_ok1"}}, + anthropic.BetaContentBlockParamUnion{OfWebSearchToolResult: &anthropic.BetaWebSearchToolResultBlockParam{ToolUseID: "call_bad-1"}}, + ), + }, + } + + SanitizeAnthropicBetaServerToolUseIDs(&req) + + if got := req.Messages[0].Content[0].OfServerToolUse.ID; got != "srvtoolu_ok1" { + t.Fatalf("valid id was rewritten to %q", got) + } + if got := req.Messages[1].Content[0].OfWebSearchToolResult.ToolUseID; got != "srvtoolu_ok1" { + t.Fatalf("result referencing valid id was remapped to %q", got) + } + rewritten := req.Messages[0].Content[1].OfServerToolUse.ID + if !serverToolUseIDPattern.MatchString(rewritten) { + t.Fatalf("invalid id not rewritten: %q", rewritten) + } + if got := req.Messages[1].Content[1].OfWebSearchToolResult.ToolUseID; got != rewritten { + t.Fatalf("result referencing invalid id not remapped: %q vs %q", got, rewritten) + } +} + +func TestSanitizeAnthropicBetaServerToolUseIDs_PlainToolResultRemapped(t *testing.T) { + req := anthropic.BetaMessageNewParams{ + Messages: []anthropic.BetaMessageParam{ + betaAssistantMessage( + anthropic.NewBetaServerToolUseBlock("ws-42", nil, "web_search"), + ), + anthropic.NewBetaUserMessage( + anthropic.NewBetaToolResultBlock("ws-42", "result", false), + ), + }, + } + + SanitizeAnthropicBetaServerToolUseIDs(&req) + + rewritten := req.Messages[0].Content[0].OfServerToolUse.ID + if got := req.Messages[1].Content[0].OfToolResult.ToolUseID; got != rewritten { + t.Fatalf("plain tool_result not remapped: %q vs %q", got, rewritten) + } +} + +func TestSanitizeAnthropicBetaServerToolUseIDs_RegularToolUseUntouched(t *testing.T) { + req := anthropic.BetaMessageNewParams{ + Messages: []anthropic.BetaMessageParam{ + betaAssistantMessage( + anthropic.NewBetaToolUseBlock("call_regular-1", map[string]any{}, "get_weather"), + ), + anthropic.NewBetaUserMessage( + anthropic.NewBetaToolResultBlock("call_regular-1", "sunny", false), + ), + }, + } + + SanitizeAnthropicBetaServerToolUseIDs(&req) + + if got := req.Messages[0].Content[0].OfToolUse.ID; got != "call_regular-1" { + t.Fatalf("regular tool_use id was rewritten to %q", got) + } + if got := req.Messages[1].Content[0].OfToolResult.ToolUseID; got != "call_regular-1" { + t.Fatalf("regular tool_result id was rewritten to %q", got) + } +} + +func TestSanitizeAnthropicServerToolUseIDs_NilRequestNoPanic(t *testing.T) { + SanitizeAnthropicV1ServerToolUseIDs(nil) + SanitizeAnthropicBetaServerToolUseIDs(nil) +} + +func betaAssistantMessage(blocks ...anthropic.BetaContentBlockParamUnion) anthropic.BetaMessageParam { + return anthropic.BetaMessageParam{ + Role: anthropic.BetaMessageParamRoleAssistant, + Content: blocks, + } +} diff --git a/internal/protocol/transform/vendor.go b/internal/protocol/transform/vendor.go index cf699e89e..75149435e 100644 --- a/internal/protocol/transform/vendor.go +++ b/internal/protocol/transform/vendor.go @@ -74,6 +74,7 @@ func (t *VendorTransform) applyAnthropicV1(ctx *TransformContext, req *anthropic case strings.Contains(url, "api.anthropic.com"), strings.Contains(url, "claude.ai"): req = ops.ApplyAnthropicV1ModelTransform(req, string(req.Model)) req = ops.ApplyAnthropicV1MetadataTransform(req, ctx.configExtraForMetadata()) + ops.SanitizeAnthropicV1ServerToolUseIDs(req) case strings.Contains(url, "api.deepseek.com"): ops.SanitizeAnthropicV1ThinkingConfig(req) ops.ApplyAnthropicV1DeepSeekThinkingPatch(req) @@ -89,6 +90,7 @@ func (t *VendorTransform) applyAnthropicBeta(ctx *TransformContext, req *anthrop case strings.Contains(url, "api.anthropic.com"), strings.Contains(url, "claude.ai"): req = ops.ApplyAnthropicBetaModelTransform(req, string(req.Model)) req = ops.ApplyAnthropicBetaMetadataTransform(req, ctx.configExtraForMetadata()) + ops.SanitizeAnthropicBetaServerToolUseIDs(req) case strings.Contains(url, "api.deepseek.com"): ops.SanitizeAnthropicBetaThinkingConfig(req) ops.ApplyAnthropicBetaDeepSeekThinkingPatch(req) diff --git a/internal/protocol/transform/vendor_test.go b/internal/protocol/transform/vendor_test.go index a9c997914..46d00ca1b 100644 --- a/internal/protocol/transform/vendor_test.go +++ b/internal/protocol/transform/vendor_test.go @@ -944,3 +944,61 @@ func TestVendorTransform_AnthropicV1_ThinkingBlocksFiltered(t *testing.T) { } assert.True(t, foundText, "text block should be preserved") } + +func newBetaRequestWithForeignServerToolUseID(model string) *anthropic.BetaMessageNewParams { + return &anthropic.BetaMessageNewParams{ + Model: anthropic.Model(model), + MaxTokens: 1024, + Messages: []anthropic.BetaMessageParam{ + { + Role: anthropic.BetaMessageParamRoleAssistant, + Content: []anthropic.BetaContentBlockParamUnion{ + anthropic.NewBetaServerToolUseBlock("call_foreign-1", map[string]any{"query": "q"}, "web_search"), + }, + }, + anthropic.NewBetaUserMessage( + anthropic.BetaContentBlockParamUnion{ + OfWebSearchToolResult: &anthropic.BetaWebSearchToolResultBlockParam{ToolUseID: "call_foreign-1"}, + }, + ), + }, + } +} + +func TestVendorTransform_AnthropicBeta_SanitizesServerToolUseIDs(t *testing.T) { + vt := NewVendorTransform() + + ctx := &TransformContext{ + Provider: &typ.Provider{APIBase: "https://api.anthropic.com"}, + Request: newBetaRequestWithForeignServerToolUseID("claude-sonnet-5"), + Extra: map[string]interface{}{}, + } + + require.NoError(t, vt.Apply(ctx)) + + req, ok := ctx.Request.(*anthropic.BetaMessageNewParams) + require.True(t, ok) + + stu := req.Messages[0].Content[0].OfServerToolUse + require.NotNil(t, stu) + assert.Regexp(t, `^srvtoolu_[a-zA-Z0-9_]+$`, stu.ID) + assert.Equal(t, stu.ID, req.Messages[1].Content[0].OfWebSearchToolResult.ToolUseID, + "web_search_tool_result must keep pointing at the rewritten server_tool_use id") +} + +func TestVendorTransform_NonAnthropicTarget_LeavesServerToolUseIDs(t *testing.T) { + vt := NewVendorTransform() + + ctx := &TransformContext{ + Provider: &typ.Provider{APIBase: "https://api.deepseek.com"}, + Request: newBetaRequestWithForeignServerToolUseID("deepseek-chat"), + Extra: map[string]interface{}{}, + } + + require.NoError(t, vt.Apply(ctx)) + + req, ok := ctx.Request.(*anthropic.BetaMessageNewParams) + require.True(t, ok) + assert.Equal(t, "call_foreign-1", req.Messages[0].Content[0].OfServerToolUse.ID, + "non-Anthropic targets must not have their ids rewritten") +}