Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions internal/protocol/ops/request_anthropic_server_tool_use.go
Original file line number Diff line number Diff line change
@@ -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
}
248 changes: 248 additions & 0 deletions internal/protocol/ops/request_anthropic_server_tool_use_test.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
2 changes: 2 additions & 0 deletions internal/protocol/transform/vendor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading