From 577a0e4377dd3853aa4b1b76ebae774fdbddddb7 Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 8 Oct 2026 20:05:39 +0800 Subject: [PATCH] gateway: every upstream's call ids reach an Anthropic client as [A-Za-z0-9_-] only and go back to the upstream as it gave them, not only Devin's (#1304, StarMoonCity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit d3581077 made Devin's "Bash:0#a65b…" ids safe in the built-in Devin only. Any other upstream whose output reaches Claude Code through /v1/messages (a plugin's provider, a Chat or Responses relay, another vendor) could give an id with another character, and Claude Code drops the call and fails the turn "could not be parsed". The mapping now sits at the Anthropic boundary: anthropicEncoder and renderAnthropic give the client anthropicOutID (an unsafe id, or one beginning "mp_", as "mp_" and its base64url), and parseAnthropic turns tool_use ids and tool_result tool_use_ids back with anthropicInID, so the upstream gets its own id on the next turn, before unsignCalls reads Gemini's signatures off it. It is stateless, the same scheme as Devin's (safeCallID/rawCallID, which devinOutID/devinInID now call with "dv_"). An id that is safe already, Devin's "dv_…" ones (built-in and plugin) among them, goes both ways byte for byte, so prompt caching and every upstream that works today see no change. buildAnthropic still gives an Anthropic upstream the ids as they came. Chat's tool_calls id and Responses' call_id are left as they are: Codex and pi take any id, and no client that refuses one is known. TestAnthropicCallIDsSafeBothWays gives the reporter's literal id from a Chat upstream through New().Handler() on /v1/messages, streamed and whole, and answers the call the way Claude Code does: with anthropicOutID/anthropicInID returning the id as it came (the old code) it fails "stream=true: call id \"Bash:0#a65b6a5e02194b87bbc796c4428c946d\", want one of [A-Za-z0-9_-] only"; with only anthropicInID taken out it fails with the upstream getting "mp_QmFz…" for its call and result. TestAnthropicSafeCallIDsUnchanged keeps toolu_/call_ ids and Devin's dv_ ids as they came, end to end too; with the mark set to "dv_" it fails on the double-encoded Devin ids. go test -tags nogui ./internal/gateway ./internal/provider pass on Linux (golang:1.26.8 in Docker, temp HOME, module cache read-only; this Mac's syspolicyd is pegged and new binaries hang at launch, so no darwin test run). go vet -tags nogui ./internal/gateway for darwin, GOOS=linux and GOOS=windows, and gofmt -l pass. Not tried with a real Devin account or a real Claude Code session; an Anthropic upstream relayed as-is (passthrough) keeps its ids as it gave them. --- internal/gateway/anthropic.go | 57 +++++- internal/gateway/anthropic_call_ids_test.go | 192 ++++++++++++++++++++ internal/gateway/devin.go | 23 +-- 3 files changed, 247 insertions(+), 25 deletions(-) create mode 100644 internal/gateway/anthropic_call_ids_test.go 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 {