fix(anthropic): rewrite tool names in one body pass

This commit is contained in:
wucm667
2026-09-29 15:00:16 +08:00
parent 9a62841fd1
commit 27a9421e6a
3 changed files with 119 additions and 50 deletions
@@ -1,6 +1,8 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"hash/fnv"
"math/rand"
@@ -167,6 +169,11 @@ func buildToolNameRewriteFromBody(body []byte) *ToolNameRewrite {
return rw
}
type toolNameSpan struct {
start, end int
value []byte
}
// applyToolNameRewriteToBody 把已构造的 ToolNameRewrite 应用到 body 上:
//
// - 改写 $.tools[*].name(仅对 shouldMimicToolName 通过的 tool)
@@ -181,68 +188,66 @@ func applyToolNameRewriteToBody(body []byte, rw *ToolNameRewrite) []byte {
return body
}
// gjson child indexes are absolute offsets into the original body. Gather all
// replacements before changing its length, then copy the body only once.
var edits []toolNameSpan
addName := func(name gjson.Result) {
if !name.Exists() {
return
}
fake, ok := rw.Forward[name.String()]
if !ok {
return
}
encoded, err := json.Marshal(fake)
if err != nil || name.Index < 0 || name.Index+len(name.Raw) > len(body) ||
!bytes.Equal(body[name.Index:name.Index+len(name.Raw)], []byte(name.Raw)) {
return
}
edits = append(edits, toolNameSpan{name.Index, name.Index + len(name.Raw), encoded})
}
tools := gjson.GetBytes(body, "tools")
if tools.IsArray() {
idx := -1
tools.ForEach(func(_, t gjson.Result) bool {
idx++
if !shouldMimicToolName(t.Get("type").String()) {
return true
}
name := t.Get("name").String()
if name == "" {
return true
}
fake, ok := rw.Forward[name]
if !ok {
return true
}
if next, err := sjson.SetBytes(body, fmt.Sprintf("tools.%d.name", idx), fake); err == nil {
body = next
tools.ForEach(func(_, tool gjson.Result) bool {
if shouldMimicToolName(tool.Get("type").String()) {
addName(tool.Get("name"))
}
return true
})
}
if tc := gjson.GetBytes(body, "tool_choice"); tc.Exists() && tc.Get("type").String() == "tool" {
name := tc.Get("name").String()
if fake, ok := rw.Forward[name]; ok {
if next, err := sjson.SetBytes(body, "tool_choice.name", fake); err == nil {
body = next
}
}
if choice := gjson.GetBytes(body, "tool_choice"); choice.Get("type").String() == "tool" {
addName(choice.Get("name"))
}
// 同步改写历史消息中的 tool_use.name,确保它和 tools[] 中的假名一致。
// 否则 Anthropic 会因为 tool_use 引用了未声明的原始工具名而拒绝请求。
messages := gjson.GetBytes(body, "messages")
if messages.IsArray() {
messages.ForEach(func(msgKey, msg gjson.Result) bool {
msgIdx := int(msgKey.Num)
if messages := gjson.GetBytes(body, "messages"); messages.IsArray() {
messages.ForEach(func(_, msg gjson.Result) bool {
content := msg.Get("content")
if !content.IsArray() {
return true
}
content.ForEach(func(blkKey, blk gjson.Result) bool {
blkIdx := int(blkKey.Num)
if blk.Get("type").String() != "tool_use" {
return true
}
name := blk.Get("name").String()
if name == "" {
return true
}
if fake, ok := rw.Forward[name]; ok {
path := fmt.Sprintf("messages.%d.content.%d.name", msgIdx, blkIdx)
if next, err := sjson.SetBytes(body, path, fake); err == nil {
body = next
if content.IsArray() {
content.ForEach(func(_, block gjson.Result) bool {
if block.Get("type").String() == "tool_use" {
addName(block.Get("name"))
}
}
return true
})
return true
})
}
return true
})
}
if len(edits) != 0 {
sort.Slice(edits, func(i, j int) bool { return edits[i].start < edits[j].start })
var out []byte
out = make([]byte, 0, len(body))
pos := 0
for _, edit := range edits {
if edit.start < pos { // Ignore any overlapping result from malformed JSON.
continue
}
out = append(out, body[pos:edit.start]...)
out = append(out, edit.value...)
pos = edit.end
}
body = append(out, body[pos:]...)
}
body = applyToolsLastCacheBreakpoint(body)
return body
@@ -295,3 +295,66 @@ func TestBuildDynamicToolMap_FakeNameShape(t *testing.T) {
require.True(t, strings.Contains(fake, head), "fake %q should contain head3 %q of %q", fake, head, name)
}
}
func TestApplyToolNameRewriteToBody_Spans(t *testing.T) {
body := []byte(`{"tools":[{"name":"sessions_\"quoted"},{"name":"session_last","defer_loading":true,"cache_control":{"type":"ephemeral"}}],"tool_choice":{"type":"tool","name":"sessions_\"quoted"},"messages":[{"content":[{"type":"tool_use","name":"sessions_\"quoted","input":{"name":"sessions_\"quoted"}},{"type":"tool_use"}]}],"metadata":{"name":"sessions_\"quoted"}}`)
out := applyToolNameRewriteToBody(body, buildToolNameRewriteFromBody(body))
require.True(t, gjson.ValidBytes(out))
for _, path := range []string{"tools.0.name", "tool_choice.name", "messages.0.content.0.name"} {
require.Equal(t, `cc_sess_"quoted`, gjson.GetBytes(out, path).String())
}
require.Equal(t, `sessions_"quoted`, gjson.GetBytes(out, "messages.0.content.0.input.name").String())
require.Equal(t, `sessions_"quoted`, gjson.GetBytes(out, "metadata.name").String())
require.False(t, gjson.GetBytes(out, "tools.1.cache_control").Exists())
require.Equal(t, "5m", gjson.GetBytes(out, "tools.0.cache_control.ttl").String())
require.False(t, gjson.GetBytes(out, "messages.0.content.1.name").Exists())
}
func TestApplyToolNameRewriteToBody_DuplicateNames(t *testing.T) {
body := []byte(`{"tools":[{"name":"sessions_first","name":"sessions_second"}],"messages":[{"content":[{"type":"tool_use","name":"sessions_first","name":"sessions_second"}]}]}`)
out := applyToolNameRewriteToBody(body, buildToolNameRewriteFromBody(body))
require.True(t, gjson.ValidBytes(out))
require.Equal(t, 2, strings.Count(string(out), `"name":"cc_sess_first","name":"sessions_second"`))
}
func TestApplyToolNameRewriteToBody_NonStringReferences(t *testing.T) {
body := []byte(`{"tools":[{"name":"123"}],"tool_choice":{"type":"tool","name":123},"messages":[{"content":[{"type":"tool_use","name":123},{"type":"tool_use","name":true}]}]}`)
rw := &ToolNameRewrite{Forward: map[string]string{"123": "replacement", "true": "also_replaced"}}
out := applyToolNameRewriteToBody(body, rw)
require.True(t, gjson.ValidBytes(out))
require.Equal(t, "replacement", gjson.GetBytes(out, "tools.0.name").String())
require.Equal(t, `"replacement"`, gjson.GetBytes(out, "tool_choice.name").Raw)
require.Equal(t, `"replacement"`, gjson.GetBytes(out, "messages.0.content.0.name").Raw)
require.Equal(t, `"also_replaced"`, gjson.GetBytes(out, "messages.0.content.1.name").Raw)
}
func makeLongToolHistory() []byte {
var b strings.Builder
_, _ = b.WriteString(`{"tools":[{"name":"sessions_list"}],"tool_choice":{"type":"tool","name":"sessions_list"},"messages":[`)
for i := 0; i < 1000; i++ {
if i > 0 {
_ = b.WriteByte(',')
}
_, _ = b.WriteString(`{"content":[{"type":"tool_use","name":"sessions_list","input":{"name":"sessions_list"}}]}`)
}
_, _ = b.WriteString(`]}`)
return []byte(b.String())
}
func TestApplyToolNameRewriteToBody_LongHistory(t *testing.T) {
body := makeLongToolHistory()
out := applyToolNameRewriteToBody(body, buildToolNameRewriteFromBody(body))
require.True(t, gjson.ValidBytes(out))
require.Equal(t, "cc_sess_list", gjson.GetBytes(out, "messages.999.content.0.name").String())
require.Equal(t, "sessions_list", gjson.GetBytes(out, "messages.999.content.0.input.name").String())
}
func BenchmarkApplyToolNameRewriteToBody_LongHistory(b *testing.B) {
body := makeLongToolHistory()
rw := buildToolNameRewriteFromBody(body)
b.ReportAllocs()
b.SetBytes(int64(len(body)))
for i := 0; i < b.N; i++ {
_ = applyToolNameRewriteToBody(body, rw)
}
}
@@ -134,6 +134,7 @@ func TestOpenAIImagesJSONKeepalive_KeepsOAuthNonStreamResponseValid(t *testing.T
}()
stop := StartOpenAIImagesJSONKeepalive(c, 5*time.Millisecond)
waitForOpenAIImagesJSONKeepalive(t, c)
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},