gateway, usage, gui: a relay's reply of tool calls ended "end_turn" or "tool_calls" goes on to the calls, a tool_use whose start carries its input keeps it, an Anthropic stream that just stops is an error, and every request says its protocol and the upstream's stop reason (蓝猫 on Discord)

蓝猫's custom Anthropic-protocol provider in front of Grok ended the agent's
turn a few words in with no error, where Alma on the same endpoint ran on.
Replayed from a fake upstream, three ways an Anthropic-shaped relay's
stream was read wrong when magpie translated it for an agent of another
protocol:

- a reply of tool calls whose stop_reason said end_turn, stop or OpenAI's
  tool_calls was handed on as finish_reason "stop": the agent ended its
  turn instead of running the calls. stopFromAnthropic takes tool_calls,
  function_call, length and content_filter; collector.finish makes a
  "stop" beside tool calls "tool".
- a tool_use block whose content_block_start carries its whole input,
  with no input_json_delta after, lost the input (the call went with {}).
  anthropicDecoder emits it, and skips deltas for that block after.
- a stream that ended cleanly with no stop_reason and no message_stop was
  finished as a whole reply. streamTranslated now takes it as cut: asked
  to go on where it can, else ended with "the reply ended before it was
  complete", as the passthrough already did.

Each request now keeps the upstream's own stop reason (Usage.Stop /
usage.Record.stop: stop_reason, finish_reason, a Responses status), from
the passthrough's sniffer and the translated stream, shown as "Upstream
stop reason" in a request's details on Usage › Requests. Each row there
is tagged with the protocol it was sent upstream in, "Chat → Anthropic"
when translated, told from its endpoint, so older rows have it too; the
details say Protocol. en/zh/ja/de, no left border, no select, a click
moves nothing.

relay_stop_test.go's four tests fail without the change (finish_reason
"stop" for tool calls under end_turn/tool_calls/stop, arguments "" for a
whole-input start, a cut stream finished "stop", no stop recorded) and pass
with it; the passthrough case sends "data:" without a space and CRLF.
ledger-protocol.test.cjs passes in Chromium and WebKit, en and zh.
go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test
-tags nogui ./... under a temp HOME pass.
This commit is contained in:
yetone committed 2026-10-05 22:47:21 +08:00
1 parent df95ac6bc2
commit c5b57b9077
13 files changed
+485 -9

No files matched your search

+40 -5
View File
@@ -507,30 +507,60 @@ func buildAnthropic(r *Request, model string) []byte {
}
// anthropicDecoder leaves out the blocks of Anthropic's own server tools —
// a web search it ran — whose input is no call of the client's.
type anthropicDecoder struct{ server map[int]bool }
// a web search it ran — whose input is no call of the client's. A
// tool_use block whose start carries its whole input, as a relay in front
// of another vendor's model may send it with no input_json_delta after
// (蓝猫 on Discord), keeps that input; Anthropic's own start has an empty
// one, its deltas after.
type anthropicDecoder struct {
server map[int]bool
whole map[int]bool // the tool_use blocks whose start carried their input
}
func (d *anthropicDecoder) decode(data string, emit func(Event)) error {
var ev struct {
Type string `json:"type"`
Index int `json:"index"`
ContentBlock struct {
Type string `json:"type"`
Type string `json:"type"`
Input json.RawMessage `json:"input"`
} `json:"content_block"`
Delta struct {
Type string `json:"type"`
} `json:"delta"`
}
if json.Unmarshal([]byte(data), &ev) == nil {
switch ev.Type {
case "content_block_start":
d.server[ev.Index] = ev.ContentBlock.Type == "server_tool_use"
d.whole[ev.Index] = false
if ev.ContentBlock.Type == "tool_use" && fullInput(ev.ContentBlock.Input) {
if err := decodeAnthropic(data, emit); err != nil {
return err
}
d.whole[ev.Index] = true
emit(Event{Kind: KToolArgs, Text: string(ev.ContentBlock.Input)})
return nil
}
case "content_block_delta", "content_block_stop":
if d.server[ev.Index] {
return nil
}
if d.whole[ev.Index] && ev.Delta.Type == "input_json_delta" {
return nil // the input came whole already
}
}
}
return decodeAnthropic(data, emit)
}
// fullInput says whether a tool_use start's input is the call's own: an
// object with something in it.
func fullInput(in json.RawMessage) bool {
var m map[string]any
return json.Unmarshal(in, &m) == nil && len(m) > 0
}
// decodeAnthropic turns an Anthropic event stream into events.
func decodeAnthropic(data string, emit func(Event)) error {
var ev struct {
@@ -639,9 +669,14 @@ func stopFromAnthropic(s string) string {
// the second is Claude 4.5+ running into its context window
// before max_tokens: the reply is cut short all the same
return "length"
case "tool_use":
case "tool_use",
// an Anthropic-shaped relay in front of another vendor's model,
// passing on OpenAI's reasons as they came (蓝猫 on Discord)
"tool_calls", "function_call":
return "tool"
case "refusal":
case "length":
return "length"
case "refusal", "content_filter":
return "filter"
}
return "stop"
+29 -1
View File
@@ -17,6 +17,7 @@ package gateway
import (
"context"
"encoding/json"
"errors"
"io"
"net"
@@ -37,6 +38,19 @@ const streamRetries = 2
// without waiting on a vendor that keeps the connection open after it.
var errStreamCut = errors.New("stream cut mid-reply")
// errEndedShort is an Anthropic stream that ended, its connection closed
// cleanly, before it said its reply had: the passthrough's word for it.
var errEndedShort = errors.New("the reply ended before it was complete")
// anthropicEnds says whether an Anthropic stream's event ends the reply:
// its message_stop (a stop_reason is upstreamStop's), or an error.
func anthropicEnds(data string) bool {
var v struct {
Type string `json:"type"`
}
return json.Unmarshal([]byte(data), &v) == nil && lastEvent(v.Type)
}
// groupTryKey marks a try of a routing group's member. A member whose
// stream breaks with an error mid-reply is failed as it used to be — the
// group answers the agent's retry with another of its members (#733) —
@@ -328,8 +342,11 @@ func (s *Server) streamTranslated(w http.ResponseWriter, r *http.Request, p prov
t := &textCallSee{names: names, see: see}
attemptSee, held = t.event, t.release
}
ended := false // the upstream said its reply ended
serr = readSSEAlive(rd, func(_, data string) error {
u.add(Usage{Upstream: upstreamOf([]byte(data))})
st := upstreamStop([]byte(data))
u.add(Usage{Upstream: upstreamOf([]byte(data)), Stop: st})
ended = ended || st != "" || actual == provider.Anthropic && anthropicEnds(data)
if err := dec(data, attemptSee); err != nil {
return err
}
@@ -342,6 +359,14 @@ func (s *Server) streamTranslated(w http.ResponseWriter, r *http.Request, p prov
if errors.Is(serr, errStreamCut) {
serr = nil
}
if serr == nil && failed == "" && !ended && actual == provider.Anthropic && r.Context().Err() == nil {
// an Anthropic stream that just stopped — no
// stop_reason, no message_stop — is a reply cut
// short, not a finished one: a relay's (蓝猫 on
// Discord) read as whole ended the agent's turn a
// few words in, with nothing to say why
serr = errEndedShort
}
}
}
if serr == nil && failed == "" {
@@ -387,6 +412,9 @@ func (s *Server) streamTranslated(w http.ResponseWriter, r *http.Request, p prov
// the upstream died mid-reply: say so in the client's own
// protocol instead of finishing as if all went well
failed = cutMidReply(p.Name, serr)
if errors.Is(serr, errEndedShort) {
failed = p.Name + ": " + errEndedShort.Error()
}
}
if failed != "" {
// the same refusal as the 429's, said inside the reply
+2 -2
View File
@@ -2167,7 +2167,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
Input: call.Usage.Input, Output: call.Usage.Output, CacheRead: call.Usage.CacheRead,
CacheWrite: call.Usage.CacheWrite, CacheWrite1h: call.Usage.CacheWrite1h, Reasoning: call.Usage.Reasoning, Effort: sent, Millis: call.Millis, Status: call.Status,
TTFT: call.TTFT, FirstText: call.FirstText, Sent: sentMs, Session: sessionOf(r.Header), NativeSession: nativeSessionOf(r.Header), Kind: call.Kind,
RequestID: call.Usage.RequestID, ResponseID: call.Usage.ResponseID, Endpoint: endpointOf(r, from, call.To), Archive: call.archiveName()}
RequestID: call.Usage.RequestID, ResponseID: call.Usage.ResponseID, Endpoint: endpointOf(r, from, call.To), Stop: call.Usage.Stop, Archive: call.archiveName()}
failedWith(&rec, call.Status, call.Error, call.Usage.ErrType)
withBodies(&rec, &call)
appendUsage(r, rec)
@@ -3663,7 +3663,7 @@ func decoder(proto provider.Protocol) func(data string, emit func(Event)) error
d := &codeAssistDecoder{}
return d.decode
}
d := &anthropicDecoder{server: map[int]bool{}}
d := &anthropicDecoder{server: map[int]bool{}, whole: map[int]bool{}}
return d.decode
}
+11 -1
View File
@@ -220,6 +220,10 @@ type Usage struct {
// ResponseID is the final client response ID, independent of request headers.
ResponseID string `json:"response_id,omitempty"`
ErrType string `json:"err_type,omitempty"`
// Stop is why the upstream said its reply ended, in its own words
// (stop_reason, finish_reason, a Responses status): "" when it said
// none, as a stream that just stops does (upstreamStop)
Stop string `json:"stop,omitempty"`
}
// prompt is every token the prompt came to, as OpenAI's and Gemini's
@@ -263,6 +267,9 @@ func (u *Usage) add(v Usage) {
if v.ErrType != "" {
u.ErrType = v.ErrType
}
if v.Stop != "" {
u.Stop = v.Stop
}
}
// Result is a whole reply, for non-streaming clients.
@@ -354,7 +361,10 @@ func (c *collector) add(ev Event) {
func (c *collector) finish() Result {
c.closeTool()
if c.res.Stop == "" {
// a reply of tool calls ended "stop" (a relay's end_turn beside its
// tool_use blocks, 蓝猫 on Discord) is the client's to run the calls
// of: told it stopped, an agent ends its turn there
if c.res.Stop == "" || c.res.Stop == "stop" {
c.res.Stop = "stop"
for _, p := range c.res.Parts {
if p.Kind == ToolCall {
+165
View File
@@ -0,0 +1,165 @@
package gateway
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/usage"
)
// Anthropic-shaped relays in front of other vendors' models (Grok's, as
// 蓝猫 on Discord had it) end their streams in ways Anthropic's own API
// doesn't: a reply of tool calls whose stop_reason says end_turn, or
// OpenAI's "tool_calls"; a tool_use block whose content_block_start
// carries the whole input and no input_json_delta after it; a stream that
// just stops, with no stop_reason and no message_stop. An agent told such
// a reply ended its turn ends it, a few words in, as if done.
// relayAnthropic serves a translated request's model from an Anthropic
// upstream answering with events, as they are, and returns the Chat
// stream magpie made of it.
func relayAnthropic(t *testing.T, events ...string) string {
t.Helper()
fresh(t)
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.Copy(io.Discard, r.Body)
w.Header().Set("Content-Type", "text/event-stream")
for _, ev := range events {
io.WriteString(w, "data: "+ev+"\n\n")
}
}))
t.Cleanup(up.Close)
p := provider.Provider{ID: "up", Name: "UP", Key: "k", Models: []string{"grok-4"}, Anthropic: up.URL}
if err := provider.Save(p); err != nil {
t.Fatal(err)
}
code, body := post(t, "/v1/chat/completions", `{"model":"up/grok-4","stream":true,"messages":[{"role":"user","content":"list files"}],`+
`"tools":[{"type":"function","function":{"name":"Bash","parameters":{"type":"object","properties":{"command":{"type":"string"}}}}}]}`)
if code != 200 {
t.Fatalf("status %d: %s", code, body)
}
return body
}
// chatFinish is a Chat stream's finish_reason, chatArgs the arguments of
// its tool calls, run together.
func chatFinish(body string) string {
var out string
for _, ev := range events(body) {
for _, c := range choices(ev) {
if s, _ := c["finish_reason"].(string); s != "" {
out = s
}
}
}
return out
}
func chatArgs(body string) string {
var sb strings.Builder
for _, ev := range events(body) {
for _, c := range choices(ev) {
d, _ := c["delta"].(map[string]any)
calls, _ := d["tool_calls"].([]any)
for _, x := range calls {
call, _ := x.(map[string]any)
fn, _ := call["function"].(map[string]any)
s, _ := fn["arguments"].(string)
sb.WriteString(s)
}
}
}
return sb.String()
}
const (
relayToolStart = `{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"Bash","input":{}}}`
relayToolArgs = `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"command\":\"ls\"}"}}`
relayToolStop = `{"type":"content_block_stop","index":1}`
relayEnd = `{"type":"message_stop"}`
)
func relayStop(reason string) string {
return `{"type":"message_delta","delta":{"stop_reason":"` + reason + `"},"usage":{"output_tokens":9}}`
}
// A reply of tool calls is the agent's to run, whatever stop_reason the
// relay gave it.
func TestRelayToolCallsStopForTools(t *testing.T) {
for _, reason := range []string{"end_turn", "tool_calls", "stop"} {
t.Run(reason, func(t *testing.T) {
body := relayAnthropic(t, anthStart("grok-4", 5), anthText("Let me look."), relayToolStart, relayToolArgs, relayToolStop, relayStop(reason), relayEnd)
if got := chatFinish(body); got != "tool_calls" {
t.Errorf("finish_reason %q, want tool_calls: %s", got, body)
}
if got := chatArgs(body); got != `{"command":"ls"}` {
t.Errorf("arguments %q", got)
}
})
}
}
// A tool_use block that starts with its whole input, no delta after it,
// keeps that input.
func TestRelayToolInputInStart(t *testing.T) {
start := `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}}`
body := relayAnthropic(t, anthStart("grok-4", 5), start, `{"type":"content_block_stop","index":0}`, relayStop("tool_use"), relayEnd)
if got := chatArgs(body); got != `{"command":"ls"}` {
t.Errorf("arguments %q, want the start's input: %s", got, body)
}
if got := chatFinish(body); got != "tool_calls" {
t.Errorf("finish_reason %q", got)
}
}
// A stream that stops with no stop_reason and no message_stop is a reply
// cut short, and the client is told so rather than handed a finished one.
func TestRelayStreamEndedShort(t *testing.T) {
body := relayAnthropic(t, anthStart("grok-4", 5), anthText("Let me look at the"))
if chatFinish(body) == "stop" {
t.Errorf("a cut reply finished as whole: %s", body)
}
if !strings.Contains(body, "ended before it was complete") {
t.Errorf("no error for the cut reply: %s", body)
}
// a stream with a stop_reason and no message_stop said it was done
body = relayAnthropic(t, anthStart("grok-4", 5), anthText("Done."), relayStop("end_turn"))
if got := chatFinish(body); got != "stop" || strings.Contains(body, "ended before") {
t.Errorf("finish_reason %q: %s", got, body)
}
}
// The upstream's own stop reason is kept with the request, for a reply
// that ended too soon to be told apart by: the relay's word, not magpie's.
func TestRelayStopReasonRecorded(t *testing.T) {
relayAnthropic(t, anthStart("grok-4", 5), anthText("Done."), relayStop("end_turn"), relayEnd)
recs := usage.Load(time.Time{})
if len(recs) != 1 || recs[0].Stop != "end_turn" {
t.Fatalf("records %+v, want stop end_turn", recs)
}
// and a reply relayed as it came, to an agent of the same protocol
fresh(t)
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.Copy(io.Discard, r.Body)
w.Header().Set("Content-Type", "text/event-stream")
for _, ev := range []string{anthStart("grok-4", 5), anthText("Done."), relayStop("tool_calls"), relayEnd} {
io.WriteString(w, "data:"+ev+"\r\n\r\n")
}
}))
defer up.Close()
if err := provider.Save(provider.Provider{ID: "up", Name: "UP", Key: "k", Models: []string{"grok-4"}, Anthropic: up.URL}); err != nil {
t.Fatal(err)
}
if code, body := post(t, "/v1/messages", `{"model":"up/grok-4","stream":true,"max_tokens":9,"messages":[{"role":"user","content":"hi"}]}`); code != 200 {
t.Fatalf("status %d: %s", code, body)
}
recs = usage.Load(time.Time{})
if len(recs) != 1 || recs[0].Stop != "tool_calls" {
t.Fatalf("records %+v, want stop tool_calls", recs)
}
}
+3
View File
@@ -127,6 +127,9 @@ func (s *usageSniffer) parse(b []byte) {
if up := upstreamOf(b); up != "" {
s.u.Upstream = up
}
if st := upstreamStop(b); st != "" {
s.u.Stop = st
}
switch s.proto {
case provider.Gemini:
// Factory's generateContent chunks, the same usageMetadata Code
+48
View File
@@ -38,3 +38,51 @@ func upstreamOf(b []byte) string {
}
return ""
}
// upstreamStop is why a reply (a JSON body, or one event of its stream)
// says it ended, in the upstream's own words: an Anthropic stop_reason, a
// Chat finish_reason, a Responses reply's status and the reason it was
// incomplete. Kept with the request, so a reply that ended too soon shows
// what the upstream said of it — a relay's "end_turn" after a few words,
// or nothing at all (蓝猫 on Discord). "" when this one says none.
func upstreamStop(b []byte) string {
if !bytes.Contains(b, []byte(`stop_reason"`)) && !bytes.Contains(b, []byte(`finish_reason"`)) && !bytes.Contains(b, []byte(`"response.`)) {
return ""
}
var v struct {
Type string `json:"type"`
StopReason string `json:"stop_reason"`
Delta struct {
StopReason string `json:"stop_reason"`
} `json:"delta"`
Choices []struct {
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Response struct {
Status string `json:"status"`
IncompleteDetails *struct {
Reason string `json:"reason"`
} `json:"incomplete_details"`
} `json:"response"`
}
if json.Unmarshal(b, &v) != nil {
return ""
}
switch {
case v.Delta.StopReason != "":
return v.Delta.StopReason
case v.StopReason != "":
return v.StopReason
case strings.HasPrefix(v.Type, "response.") && v.Response.Status != "" && v.Response.Status != "in_progress" && v.Response.Status != "queued":
if d := v.Response.IncompleteDetails; d != nil && d.Reason != "" {
return v.Response.Status + ": " + d.Reason
}
return v.Response.Status
}
for _, c := range v.Choices {
if c.FinishReason != "" {
return c.FinishReason
}
}
return ""
}
+1
View File
@@ -914,6 +914,7 @@ body.window .field.solo { grid-column: auto; }
.led .src.official::before { content: ""; width: 6px; height: 3.5px; border-left: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: translateY(-1px) rotate(-45deg); }
.led .src.access-api { --badge-ink: var(--tk-f); }
.led .src.access-subscription { --badge-ink: var(--tk-k); }
.led .src.proto { --badge-ink: var(--muted); font-weight: 500; }
.led .duration { font-weight: 550; }
.led .duration-fast { color: var(--tk-s); }
.led .duration-slow { color: var(--amber); }
+29
View File
@@ -13622,6 +13622,28 @@ function ledServed(r) {
return k;
}
// the protocol a request was sent upstream in, told by the path it went
// out on, and the one its agent spoke when that was another: the
// endpoint reads "/v1/chat/completions → /v1/messages" for a request
// translated, the agent's own path alone for one sent as it came (蓝猫 on
// Discord). null for a request with no path kept, a session file's.
const ledProtoOf = (path) => /\/messages\b/.test(path) ? "Anthropic" : /\/responses\b/.test(path) ? "Responses"
: /\/chat\/completions\b/.test(path) ? "Chat" : /generateContent|\/generate\b/.test(path) ? "Gemini" : "";
function ledProtos(r) {
if (!r.ep || r.source === "log") return null;
const [a, b] = String(r.ep).split(" → ");
const from = ledProtoOf(a), to = b ? ledProtoOf(b) : from;
if (!to) return null;
return { from: from || to, to };
}
function ledProtoBadge(r) {
const p = ledProtos(r);
if (!p) return null;
const k = el("span", "src proto proto-" + p.to.toLowerCase(), p.from !== p.to ? p.from + " → " + p.to : p.to);
k.title = p.from !== p.to ? t("The agent spoke {from}; sent upstream as {to}", p) : t("Sent upstream as {to}, as the agent spoke it", p);
return k;
}
// a request that failed: told by its status, or, for one read from a
// session file, which records none, by the error that ended it
const ledFailed_ = (r) => r.status >= 400 || !!r.err;
@@ -13669,6 +13691,11 @@ function ledDetail(r, cols) {
if (ledRowSpeed(r)) add("Speed", t("{n} tok/s", { n: ledNum(Math.round(ledRowSpeed(r))) }));
add("Request ID", r.rid);
add("Endpoint", r.ep);
const protos = ledProtos(r);
if (protos) add("Protocol", protos.from !== protos.to ? protos.from + " → " + protos.to : protos.to);
// why the upstream said its reply ended, in its own words: a reply that
// ended too soon is told apart by it (蓝猫 on Discord)
add("Upstream stop reason", r.stop);
// the provider an aggregator (OpenRouter …) said answered behind it
add("Upstream provider", r.upstream);
add("Session ID", r.session);
@@ -14424,6 +14451,8 @@ function renderLedger() {
badge.title = t(access);
badges.append(badge);
}
const proto = ledProtoBadge(r);
if (proto) badges.append(proto);
if (local && r.session_account) {
const k = el("span", "src local", t("Local session"));
k.title = t("Read from the agent's session file. The account is shown only when local metadata identifies it; no service provider is inferred.");
+12
View File
@@ -2811,6 +2811,10 @@ const I18N = {
"Upstream: {upstream}": "上游:{upstream}",
"The aggregator passed the request on to {upstream}, as its reply says: the provider that actually answered it.": "聚合供应商把这条请求转给了 {upstream},由它实际作答(聚合供应商的回复里写明)。",
"Upstream provider": "上游",
"Protocol": "协议",
"Upstream stop reason": "上游结束原因",
"The agent spoke {from}; sent upstream as {to}": "agent 用 {from} 协议请求;以 {to} 协议发往上游",
"Sent upstream as {to}, as the agent spoke it": "以 {to} 协议发往上游,与 agent 所用协议相同",
"{sent} is a routing group of the remote magpie, and it routed the request to {served}: the group picking one of its models, not the vendor swapping the model.": "{sent} 是远程 magpie 的路由组,请求被路由给 {served}:这是路由组选择了组内模型,并非服务商替换了模型。",
"Open Routing": "打开路由",
"The vendor was asked for {sent}, but its reply says {served} answered: likely another model. The same model under a dated name or spelled otherwise isn't marked.": "向服务商请求的是 {sent},但回复说是 {served} 作答,很可能换成了别的模型。(同一模型只是带了日期或写法不同,不会这样标出。)",
@@ -6173,6 +6177,10 @@ const I18N = {
"Upstream: {upstream}": "上流:{upstream}",
"The aggregator passed the request on to {upstream}, as its reply says: the provider that actually answered it.": "アグリゲーターはこのリクエストを {upstream} に回したと応答に書いています。実際に応答したプロバイダーです。",
"Upstream provider": "上流プロバイダー",
"Protocol": "プロトコル",
"Upstream stop reason": "上流の終了理由",
"The agent spoke {from}; sent upstream as {to}": "エージェントは {from} で要求し、上流へは {to} で送信",
"Sent upstream as {to}, as the agent spoke it": "エージェントと同じ {to} で上流へ送信",
"{sent} is a routing group of the remote magpie, and it routed the request to {served}: the group picking one of its models, not the vendor swapping the model.": "{sent} はリモート magpie のルーティンググループで、リクエストを {served} にルーティングしました。グループがモデルを選んだもので、ベンダーがモデルを差し替えたのではありません。",
"Open Routing": "ルーティングを開く",
"The vendor was asked for {sent}, but its reply says {served} answered: likely another model. The same model under a dated name or spelled otherwise isn't marked.": "ベンダーには {sent} をリクエストしましたが、応答では {served} が答えたとあり、別のモデルに替えられた可能性があります。(同じモデルに日付が付いただけ、または表記が違うだけなら表示しません。)",
@@ -9535,6 +9543,10 @@ const I18N = {
"Upstream: {upstream}": "Upstream: {upstream}",
"The aggregator passed the request on to {upstream}, as its reply says: the provider that actually answered it.": "Der Aggregator hat die Anfrage laut seiner Antwort an {upstream} weitergegeben: der Anbieter, der sie tatsächlich beantwortet hat.",
"Upstream provider": "Upstream-Anbieter",
"Protocol": "Protokoll",
"Upstream stop reason": "Endgrund des Upstreams",
"The agent spoke {from}; sent upstream as {to}": "Der Agent sprach {from}; an den Upstream als {to} gesendet",
"Sent upstream as {to}, as the agent spoke it": "An den Upstream als {to} gesendet, wie der Agent es sprach",
"{sent} is a routing group of the remote magpie, and it routed the request to {served}: the group picking one of its models, not the vendor swapping the model.": "{sent} ist eine Weiterleitungsgruppe des entfernten magpie und hat die Anfrage an {served} weitergeleitet: Die Gruppe wählt eines ihrer Modelle, nicht der Anbieter tauscht das Modell.",
"Open Routing": "Weiterleitung öffnen",
"The vendor was asked for {sent}, but its reply says {served} answered: likely another model. The same model under a dated name or spelled otherwise isn't marked.": "Beim Anbieter wurde {sent} angefragt, laut Antwort hat aber {served} geantwortet – wahrscheinlich ein anderes Modell. (Dasselbe Modell mit Datum oder anderer Schreibweise wird nicht markiert.)",
+12
View File
@@ -1851,3 +1851,15 @@ string has zh, ja and de. English and Chinese, Chromium and WebKit.
```sh
node --test internal/gui/tests/lane-limit.test.cjs
```
`ledger-protocol.test.cjs` checks the protocol each request was sent
upstream in, on Usage › Requests (蓝猫 on Discord): a row translated from
its agent's protocol is tagged "Chat → Anthropic", one sent as it came
"Anthropic" or "Responses", one read from a session file nothing. A
request's details say Protocol and the upstream's own stop reason, when it
gave one. A click opens the details and moves nothing; no left border;
every string has zh, ja and de. English and Chinese, Chromium and WebKit.
```sh
node --test internal/gui/tests/ledger-protocol.test.cjs
```
+128
View File
@@ -0,0 +1,128 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// The protocol each request was sent upstream in (蓝猫 on Discord: 「请求」
// 列表里每条请求显示它用的是 Anthropic / Responses / Chat 哪种协议): the
// Usage page's Requests list tags each row with it, "Chat → Anthropic"
// for one translated from the agent's own, and a request's details name
// it beside the upstream's own stop reason. A row read from a session
// file, which has no path, has no tag. No click moves the page, no
// left-border accent. English and Chinese; the API is faked.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const reader = require("./reader.cjs");
const assets = path.resolve(__dirname, "../assets");
const now = new Date();
const at = (i) => new Date(now.getTime() - (i + 1) * 60e3).toISOString();
const row = (i, ep, stop, extra) => ({
t: at(i), agent: "opencode", agentName: "OpenCode", icon: "opencode", provider: "relay", providerName: "Relay",
req: "relay/grok-4", model: "grok-4", served: "grok-4", in: 300, out: 40, ms: 2000, status: 200, rid: "r-" + i,
...(ep ? { ep } : {}), ...(stop ? { stop } : {}), ...extra,
});
const ROWS = [
row(0, "/v1/chat/completions → /v1/messages", "end_turn"),
row(1, "/v1/messages", "tool_use"),
row(2, "/v1/responses", ""),
row(3, "", "", { source: "log", status: 0, provider: "", providerName: "" }),
];
function serve(lang) {
const state = { agents: [{ id: "opencode", name: "OpenCode", path: "/test/opencode.json", fields: [] }], profiles: [], settings: { lang, theme: "light" } };
return async (route) => {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json(state);
if (url.pathname === "/api/gateway/trace") {
if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3));
return json({ mine: true, now: now.toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] });
}
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
if (url.pathname === "/api/providers") return json({ providers: [], excluded: [], gateway: { running: true, window: true } });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/usage/requests") return json({ period: url.searchParams.get("period"), rows: ROWS, offset: 0, total: ROWS.length, calls: ROWS.length, errors: 0, input: 1200, output: 160, cache_read: 0, cache_write: 0, reasoning: 0, cost: 0, unpriced: ROWS.length, agents: [{ id: "opencode", name: "OpenCode", icon: "opencode" }] });
if (url.pathname === "/api/usage/requests/content") return json({ found: false, why: "read" });
if (url.pathname === "/api/usage/quotas") return json([]);
if (url.pathname === "/api/usage") return json({ calls: 4, errors: 0, input: 1200, output: 160, cache_read: 0, cache_write: 0, reasoning: 0, unpriced: 4, cost: 0, bucket: "day", series: [], agents: [], models: [], path: "~/.config/magpie/usage.jsonl" });
if (url.pathname === "/api/sessions") return json({ sessions: [], dirs: [] });
if (url.pathname === "/api/sessions/stats") return json({ from: "", to: "", days: [], agents: {} });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await route.fulfill({ body: await fs.readFile(file), contentType });
};
}
const want = {
en: { proto: "Protocol", stop: "Upstream stop reason", translated: /The agent spoke Chat; sent upstream as Anthropic/ },
zh: { proto: "协议", stop: "上游结束原因", translated: /agent 用 Chat 协议请求;以 Anthropic 协议发往上游/ },
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
test(`${engine} ${lang}: each request names its protocol`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const context = await browser.newContext({ viewport: { width: 1280, height: 760 }, reducedMotion: "reduce" });
const page = await context.newPage();
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang));
t.after(async () => {
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-protocol.png`), fullPage: true });
}
await browser.close();
});
await page.goto("http://magpie.test/?view=usage");
await page.locator("#usageTab .opt").nth(1).click();
const rows = page.locator(".led tbody tr.led-row");
await rows.nth(ROWS.length - 1).waitFor({ timeout: 15000 });
// the list: every request the gateway logged says its protocol
const tags = await rows.evaluateAll((rs) => rs.map((r) => r.querySelector(".src.proto")?.textContent || ""));
assert.deepEqual(tags, ["Chat → Anthropic", "Anthropic", "Responses", ""]);
const look = await page.locator(".led .src.proto").evaluateAll((es) => es.map((e) => {
const cell = e.closest("td").getBoundingClientRect(), b = e.getBoundingClientRect(), cs = getComputedStyle(e);
return { inCell: b.left >= cell.left - 0.5 && b.right <= cell.right + 0.5 && b.width > 0, full: e.clientWidth >= e.scrollWidth, border: cs.borderLeftWidth, top: cs.borderTopWidth, title: e.title };
}));
for (const l of look) {
assert(l.inCell && l.full, JSON.stringify(l));
assert.equal(l.border, l.top, "no left-border accent");
}
assert.match(look[0].title, want[lang].translated);
// a request's details: the protocol, and what the upstream said of its end
const top = () => page.locator("#view-usage").evaluate((v) => v.scrollTop);
const dd = async (i, dt) => {
await reader.inView(page, rows.nth(i));
const was = await top();
const scrolled = await page.evaluate(() => [scrollX, scrollY]);
await rows.nth(i).click();
await page.waitForTimeout(200);
assert.equal(await top(), was, "a click moved the page");
assert.deepEqual(await page.evaluate(() => [scrollX, scrollY]), scrolled);
const box = page.locator(".led-detail").last();
await box.waitFor();
const v = await box.evaluate((b, dts) => dts.map((dt) => [...b.querySelectorAll("dt")].find((d) => d.textContent === dt)?.nextElementSibling?.textContent || ""), dt);
await rows.nth(i).click(); // closed again
return v;
};
assert.deepEqual(await dd(0, [want[lang].proto, want[lang].stop]), ["Chat → Anthropic", "end_turn"]);
assert.deepEqual(await dd(1, [want[lang].proto, want[lang].stop]), ["Anthropic", "tool_use"]);
assert.deepEqual(await dd(2, [want[lang].proto, want[lang].stop]), ["Responses", ""]);
assert.deepEqual(await dd(3, [want[lang].proto, want[lang].stop]), ["", ""]);
if (process.env.ARTIFACT_DIR) await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-protocol-list.png`) });
for (const l of ["zh", "ja", "de"]) {
const missing = await page.evaluate((l) => ["Protocol", "Upstream stop reason",
"The agent spoke {from}; sent upstream as {to}", "Sent upstream as {to}, as the agent spoke it"].filter((k) => !I18N[l][k]), l);
assert.deepEqual(missing, [], `every string has its ${l}`);
}
assert.deepEqual(errors, []);
});
}
}
+5
View File
@@ -95,6 +95,11 @@ type Record struct {
// ResponseID is the response object ID actually sent to the client.
ResponseID string `json:"response_id,omitempty"`
Endpoint string `json:"ep,omitempty"`
// Stop is why the upstream said its reply ended, in its own words
// (stop_reason, finish_reason …): "" when it said none, a stream that
// just stopped. Told apart by it, a reply that ended too soon is the
// upstream's own end_turn or a cut (蓝猫 on Discord).
Stop string `json:"stop,omitempty"`
// Session is the conversation the call was part of, as its agent names
// it (X-Magpie-Session, or the session header Claude Code, Codex or
// OpenCode sends): several sessions on one model told apart