diff --git a/internal/gateway/anthropic.go b/internal/gateway/anthropic.go index 9806efcd..dbd81253 100644 --- a/internal/gateway/anthropic.go +++ b/internal/gateway/anthropic.go @@ -2,6 +2,7 @@ package gateway import ( "bytes" + "encoding/base64" "encoding/json" "fmt" "regexp" @@ -9,6 +10,7 @@ import ( "strings" "sync/atomic" "time" + "unicode/utf8" "github.com/tidwall/gjson" "github.com/yetone/magpie/internal/provider" @@ -122,10 +124,10 @@ func parseAnthropic(body []byte) (*Request, error) { msg.Parts = append(msg.Parts, Part{Kind: Image, MediaType: b.Source.MediaType, Data: b.Source.Data, URL: b.Source.URL}) } case "tool_use": - msg.Parts = append(msg.Parts, Part{Kind: ToolCall, ID: b.ID, Name: b.Name, Args: b.Input}) + msg.Parts = append(msg.Parts, Part{Kind: ToolCall, ID: anthropicInID(b.ID), Name: b.Name, Args: b.Input}) case "tool_result": out, images := toolOutput(b.Content) - msg.Parts = append(msg.Parts, Part{Kind: ToolResult, CallID: b.ToolUseID, Text: out, Images: images, IsError: b.IsError}) + msg.Parts = append(msg.Parts, Part{Kind: ToolResult, CallID: anthropicInID(b.ToolUseID), Text: out, Images: images, IsError: b.IsError}) case "thinking": msg.Parts = append(msg.Parts, Part{Kind: Thinking, Text: b.Thinking, Signature: b.Signature}) } @@ -724,6 +726,53 @@ func anthropicID(id string) string { return "msg_" + strings.TrimPrefix(id, "chatcmpl-") } +// Anthropic's Messages API, and Claude Code with it, takes a tool_use id +// only of [A-Za-z0-9_-]: Claude Code drops a call whose id has another +// character, and the turn fails "could not be parsed" (#1304). Other +// vendors' ids can have one (Devin's "Bash:0#a65b…", a plugin's or a +// relay's), so a call's id goes to an Anthropic client as anthropicOutID +// makes it, and an id that comes back in the client's request is turned +// back by anthropicInID, so the upstream that made the call gets its own +// id on the next turn. An id that is already +// safe goes both ways byte for byte, and Devin's "dv_…" ids, safe already, +// are not encoded twice. +const anthropicIDMark = "mp_" + +func anthropicOutID(id string) string { + if id == "" { + return id + } + return safeCallID(anthropicIDMark, id) +} + +func anthropicInID(id string) string { return rawCallID(anthropicIDMark, id) } + +// safeCallID is id as one of [A-Za-z0-9_-] only: an id with another +// character, or one beginning with mark, is mark and its base64url, which +// rawCallID turns back; any other id is itself. It keeps no state, so an +// id comes back the same from any request, after any restart. +func safeCallID(mark, id string) string { + if safeIDChars.MatchString(id) && !strings.HasPrefix(id, mark) { + return id + } + return mark + base64.RawURLEncoding.EncodeToString([]byte(id)) +} + +// rawCallID is the id safeCallID was given for id, or id itself when +// safeCallID didn't make it. +func rawCallID(mark, id string) string { + if !strings.HasPrefix(id, mark) || !safeIDChars.MatchString(id) { + return id + } + raw, err := base64.RawURLEncoding.DecodeString(id[len(mark):]) + if err != nil || len(raw) == 0 || !utf8.Valid(raw) || safeCallID(mark, string(raw)) != id { + return id + } + return string(raw) +} + +var safeIDChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + func (e *anthropicEncoder) start(ev Event) { if e.started { return @@ -796,7 +845,7 @@ func (e *anthropicEncoder) event(ev Event) { if id == "" { id = "toolu_" + newID() } - e.openBlock(ToolCall, map[string]any{"id": id, "name": ev.Name, "input": map[string]any{}}) + e.openBlock(ToolCall, map[string]any{"id": anthropicOutID(id), "name": ev.Name, "input": map[string]any{}}) case KToolArgs: if e.open == ToolCall && ev.Text != "" { e.args = true @@ -864,7 +913,7 @@ func renderAnthropic(res Result, model string) []byte { if id == "" { id = "toolu_" + newID() } - content = append(content, map[string]any{"type": "tool_use", "id": id, "name": p.Name, "input": argsOf(p)}) + content = append(content, map[string]any{"type": "tool_use", "id": anthropicOutID(id), "name": p.Name, "input": argsOf(p)}) case Search: id := "srvtoolu_" + newID() content = append(content, diff --git a/internal/gateway/anthropic_call_ids_test.go b/internal/gateway/anthropic_call_ids_test.go new file mode 100644 index 00000000..d76b69eb --- /dev/null +++ b/internal/gateway/anthropic_call_ids_test.go @@ -0,0 +1,192 @@ +package gateway + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// chatCallUpstream is a Chat upstream (a plugin's provider, a relay) that +// answers every request with one Bash call under id, and keeps the bodies +// it was sent. +func chatCallUpstream(t *testing.T, id string) (*httptest.Server, func() []string) { + t.Helper() + var mu sync.Mutex + var got []string + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + got = append(got, string(b)) + mu.Unlock() + quoted, _ := json.Marshal(id) + if !strings.Contains(string(b), `"stream":true`) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"id":"chatcmpl-1","object":"chat.completion","model":"m1","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":`+string(quoted)+`,"type":"function","function":{"name":"Bash","arguments":"{\"command\":\"date\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":5}}`) + return + } + w.Header().Set("Content-Type", "text/event-stream") + for _, c := range []string{ + `{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"m1","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":` + string(quoted) + `,"type":"function","function":{"name":"Bash","arguments":""}}]}}]}`, + `{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"m1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"command\":\"date\"}"}}]}}]}`, + `{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"m1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":5}}`, + } { + io.WriteString(w, "data: "+c+"\n\n") + } + io.WriteString(w, "data: [DONE]\n\n") + })) + t.Cleanup(up.Close) + return up, func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), got...) + } +} + +// anthropicCallID is the id of the tool_use in an Anthropic reply, streamed +// or whole. +func anthropicCallID(t *testing.T, body string) string { + t.Helper() + for _, line := range strings.Split(body, "\n") { + data, ok := strings.CutPrefix(line, "data: ") + if !ok { + continue + } + var ev struct { + ContentBlock struct{ Type, ID string } `json:"content_block"` + } + if json.Unmarshal([]byte(data), &ev) == nil && ev.ContentBlock.Type == "tool_use" { + return ev.ContentBlock.ID + } + } + var m struct{ Content []struct{ Type, ID string } } + json.Unmarshal([]byte(body), &m) + for _, p := range m.Content { + if p.Type == "tool_use" { + return p.ID + } + } + t.Fatalf("no tool_use in the reply: %s", body) + return "" +} + +// chatIDsSent is the ids of the calls and of the results in a Chat request. +func chatIDsSent(t *testing.T, body string) (calls, answers []string) { + t.Helper() + var q struct { + Messages []struct { + ToolCalls []struct{ ID string } `json:"tool_calls"` + ToolCallID string `json:"tool_call_id"` + } `json:"messages"` + } + if err := json.Unmarshal([]byte(body), &q); err != nil { + t.Fatalf("upstream request: %v: %s", err, body) + } + for _, m := range q.Messages { + for _, c := range m.ToolCalls { + calls = append(calls, c.ID) + } + if m.ToolCallID != "" { + answers = append(answers, m.ToolCallID) + } + } + return +} + +// callAndAnswer asks for a call through /v1/messages, then answers it the +// way Claude Code does, under the id it was given: the id the client saw, +// and the upstream's second request. +func callAndAnswer(t *testing.T, s *Server, stream bool, sent func() []string) (string, string) { + t.Helper() + post := func(body string) string { + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/messages", strings.NewReader(body))) + if rec.Code != 200 { + t.Fatalf("status %d: %s", rec.Code, rec.Body) + } + return rec.Body.String() + } + st := "false" + if stream { + st = "true" + } + ask := `{"role":"user","content":"用 Bash 跑一下 date"}` + id := anthropicCallID(t, post(`{"model":"up/m1","max_tokens":100,"stream":`+st+`,"messages":[`+ask+`]}`)) + before := len(sent()) + quoted, _ := json.Marshal(id) + post(`{"model":"up/m1","max_tokens":100,"stream":` + st + `,"messages":[` + ask + `, + {"role":"assistant","content":[{"type":"tool_use","id":` + string(quoted) + `,"name":"Bash","input":{"command":"date"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":` + string(quoted) + `,"content":"Thu Oct 8 10:00:00 CST 2026"}]}]}`) + got := sent() + if len(got) != before+1 { + t.Fatalf("upstream got %d requests for the answer, want 1", len(got)-before) + } + return id, got[len(got)-1] +} + +// A call id with a character Claude Code doesn't take, from any upstream +// (#1304: Devin SWE-2's, here through a Chat provider as a plugin or relay +// gives it), reaches the Anthropic client of [A-Za-z0-9_-] only, and the +// upstream gets its own id back on the call and on the result answering it. +func TestAnthropicCallIDsSafeBothWays(t *testing.T) { + fresh(t) + up, sent := chatCallUpstream(t, devinRawID) + if err := provider.Save(provider.Provider{ID: "up", Name: "Up", Key: "k", Chat: up.URL + "/v1", Models: []string{"m1"}}); err != nil { + t.Fatal(err) + } + s := New() + for _, stream := range []bool{true, false} { + id, next := callAndAnswer(t, s, stream, sent) + if !anthropicSafeID.MatchString(id) { + t.Fatalf("stream=%v: call id %q, want one of [A-Za-z0-9_-] only", stream, id) + } + calls, answers := chatIDsSent(t, next) + if len(calls) != 1 || calls[0] != devinRawID || len(answers) != 1 || answers[0] != devinRawID { + t.Errorf("stream=%v: upstream got calls %q, results %q; want %q for both", stream, calls, answers, devinRawID) + } + } +} + +// An id that is safe already goes to the client and back byte for byte, so +// prompt caching and every upstream that works today see no change; +// Devin's "dv_…" ids are not encoded a second time. +func TestAnthropicSafeCallIDsUnchanged(t *testing.T) { + for _, id := range []string{"toolu_01A09q90qw90lq917835lq9", "call_abc-123", "dv_QmFzaDowI2E2NWI2YTVl", devinOutID(devinRawID)} { + if got := anthropicOutID(id); got != id { + t.Errorf("anthropicOutID(%q) = %q, want it as it came", id, got) + } + if got := anthropicInID(id); got != id { + t.Errorf("anthropicInID(%q) = %q, want it as it came", id, got) + } + } + // a client's own id that looks like one of magpie's goes back as it came + for _, id := range []string{"mp_", "mp_!!", "mp_abc"} { + if anthropicInID(id) != id && anthropicOutID(anthropicInID(id)) != id { + t.Errorf("anthropicInID(%q) = %q, which doesn't go back to it", id, anthropicInID(id)) + } + } + // an upstream's safe id that begins "mp_" still comes back as it was + if out := anthropicOutID("mp_abc"); anthropicInID(out) != "mp_abc" || !anthropicSafeID.MatchString(out) { + t.Errorf(`"mp_abc" went out as %q and came back as %q`, out, anthropicInID(out)) + } + + fresh(t) + const safe = "call_9f8e7d6c5b4a" + up, sent := chatCallUpstream(t, safe) + if err := provider.Save(provider.Provider{ID: "up", Name: "Up", Key: "k", Chat: up.URL + "/v1", Models: []string{"m1"}}); err != nil { + t.Fatal(err) + } + s := New() + for _, stream := range []bool{true, false} { + id, next := callAndAnswer(t, s, stream, sent) + calls, answers := chatIDsSent(t, next) + if id != safe || len(calls) != 1 || calls[0] != safe || len(answers) != 1 || answers[0] != safe { + t.Errorf("stream=%v: client got %q; upstream got calls %q, results %q; want %q everywhere", stream, id, calls, answers, safe) + } + } +} diff --git a/internal/gateway/devin.go b/internal/gateway/devin.go index ef74faa9..4bd5a0b0 100644 --- a/internal/gateway/devin.go +++ b/internal/gateway/devin.go @@ -20,18 +20,15 @@ import ( "bytes" "compress/gzip" "context" - "encoding/base64" "encoding/binary" "encoding/json" "errors" "io" "math" "net/http" - "regexp" "runtime" "slices" "strings" - "unicode/utf8" "github.com/yetone/magpie/internal/provider" ) @@ -319,25 +316,9 @@ func inlineImages(ims []Part) []Part { // to Devin as it was, so its result still answers its call. An id that is // already safe goes out as it came, unless it begins "dv_". The Devin // plugin (packages/devin, outID and inID) maps the same way. -var devinSafeID = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) +func devinOutID(id string) string { return safeCallID("dv_", id) } -func devinOutID(id string) string { - if devinSafeID.MatchString(id) && !strings.HasPrefix(id, "dv_") { - return id - } - return "dv_" + base64.RawURLEncoding.EncodeToString([]byte(id)) -} - -func devinInID(id string) string { - if !strings.HasPrefix(id, "dv_") || !devinSafeID.MatchString(id) { - return id - } - raw, err := base64.RawURLEncoding.DecodeString(id[3:]) - if err != nil || len(raw) == 0 || !utf8.Valid(raw) || devinOutID(string(raw)) != id { - return id - } - return string(raw) -} +func devinInID(id string) string { return rawCallID("dv_", id) } // buildDevin is the GetChatMessage request for r, to the model uid. func buildDevin(r *Request, uid, key string) []byte {