mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix(anthropic): rewrite tool names in one body pass
This commit is contained in:
@@ -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"}},
|
||||
|
||||
Reference in New Issue
Block a user