gateway: a reply a Claude subscription account answered carries its anthropic-ratelimit-unified-5h/7d headers, so a Claude Code signed in to claude.ai shows the 5-hour and weekly limits in its status line (claude-hud, #1257)

Claude Code tells the allowance in its rate_limit_event, not as headers, so
magpie says what it keeps of the account again as Anthropic does: a share
0-1 and a Unix-seconds reset per window, set as the request comes in and
again as the reply begins, after the turn's own event.

Claude Code 2.1.293 reads them only when it is signed in to claude.ai (the
sign-in field's claudeai); with magpie's key it ignores them.

Run: go test -tags nogui ./... (temp HOME); the two new tests fail without
limitHeaders; GOOS=linux/windows go vet of gateway and provider; real
Claude Code 2.1.293 in a sandbox HOME against a fake server sending these
headers: its statusline stdin had rate_limits.five_hour/seven_day with
used_percentage 42/17 when OAuth-signed in, none with ANTHROPIC_AUTH_TOKEN.
Not run: a real Claude account through a running magpie.
This commit is contained in:
yetone committed 2026-10-08 15:28:52 +08:00
1 parent b2432754e5
commit 01e66e0f4a
11 files changed
+256 -10

No files matched your search

+22 -1
View File
@@ -94,10 +94,31 @@ A run started anew still sends every image in the conversation. Replacing
earlier images with a placeholder would change what the model sees, and
magpie doesn't do it.
## The account's allowance on the reply (#1257)
Claude Code tells a run's account allowance in its stream-json
`rate_limit_event`, not as HTTP headers. The bridge keeps it
(`provider.NoteClaudeLimits`) and says it again on the reply as the
`anthropic-ratelimit-unified-{5h,7d,7d_oi}-{utilization,reset}` headers
Anthropic sends (`limitHeaders`, from `provider.KeptClaudeLimits`): a share
from 0 to 1 and a Unix-seconds reset, for each kept window that says when
it renews. They are set when the request comes in, from what is kept, and
again as the reply begins (`relay`'s `begin`), once the turn's own
`rate_limit_event` is in. A run on Claude Code's own home that has moved to
another account says nothing (`ClaudeCodeMovedOff`), as its event isn't
kept either.
A Claude Code signed in to claude.ai (its sign-in field set to
`claudeai`, `claudeSignIns` in `internal/agent/claude.go`) reads these into its status line's `rate_limits`, which
claude-hud shows. One that signs in to magpie with magpie's key
(`ANTHROPIC_AUTH_TOKEN`) ignores them: Claude Code 2.1.293 reads the
headers only for a claude.ai subscriber. A reply another provider answered
carries none.
## Verification
```sh
go test -tags nogui ./internal/gateway/ -run 'ClaudeRewritten|ClaudeSessionTempFiles|ClaudeLetGo|ClaudeSessionFiles|ClaudeOldSessions' -count=1
go test -tags nogui ./internal/gateway/ -run 'ClaudeRewritten|ClaudeSessionTempFiles|ClaudeLetGo|ClaudeSessionFiles|ClaudeOldSessions|ClaudeSubscriptionReplySaysTheAllowance|ClaudeAllowanceHeaders' -count=1
```
`claude_rewritten_test.go` has a case for each relaxation and one for each
+1 -1
View File
@@ -30,7 +30,7 @@ func TestClaudeChatCacheWrites(t *testing.T) {
req := &Request{Model: "claude-haiku-4-5", Stream: c.stream}
var usage Usage
code, msg := relay(rec, httptest.NewRequest("POST", "/v1/chat/completions", nil), provider.Chat, "Claude Code", req, segment, &usage,
func() {}, func(string, string, bool) {})
func() {}, nil, func(string, string, bool) {})
if code != 200 || msg != "" {
t.Fatalf("reply: %d %s", code, msg)
}
+1 -1
View File
@@ -49,7 +49,7 @@ func claudeReply(t *testing.T, run *subscriptionRun, lines []string, hold bool)
req := &Request{Model: "claude-opus-5-5", Stream: true}
var usage Usage
relay(rec, httptest.NewRequest("POST", "/v1/messages", nil), provider.Anthropic, "Claude Code", req, segment, &usage,
func() {}, func(said, s string, k bool) { ended, ok = s, k })
func() {}, nil, func(said, s string, k bool) { ended, ok = s, k })
raw = rec.Body.String()
var tools []string
for _, l := range strings.Split(raw, "\n") {
@@ -0,0 +1,150 @@
package gateway
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/testenv"
)
// A reply a Claude subscription account answered says that account's
// allowance as Anthropic says it to Claude Code, in its
// anthropic-ratelimit-unified-* headers: a Claude Code signed in to
// claude.ai reads them into its status line's rate_limits, where
// claude-hud shows the 5-hour and weekly figures (#1257). The run's agent
// tells them in its rate_limit_event, which comes before the reply's first
// event, so the reply says this turn's.
func TestClaudeSubscriptionReplySaysTheAllowance(t *testing.T) {
five := time.Now().Add(3 * time.Hour).Unix()
week := time.Now().Add(4 * 24 * time.Hour).Unix()
user := "hud-" + strconv.FormatInt(time.Now().UnixNano(), 36) + "@example.com"
limits := `{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","rateLimitType":"five_hour","utilization":0.42,"resetsAt":` + strconv.FormatInt(five, 10) +
`,"unifiedWindows":{"five_hour":{"utilization":0.42,"resetsAt":` + strconv.FormatInt(five, 10) + `},"seven_day":{"utilization":0.17,"resetsAt":` + strconv.FormatInt(week, 10) + `}}}}`
answer := []string{
`{"type":"stream_event","event":{"type":"message_start","message":{"id":"m1","model":"claude-sonnet-5","usage":{"input_tokens":3,"output_tokens":1}}}}`,
`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"OK"}}}`,
`{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}}`,
`{"type":"stream_event","event":{"type":"message_stop"}}`,
}
ask := func(name, owner string, stream bool, lines []string) *httptest.ResponseRecorder {
t.Helper()
s := New()
start := func(_ context.Context, req *Request) (*subscriptionRun, <-chan Event, error) {
run := &subscriptionRun{bridge: s.subscription, owner: owner, token: "hud", pending: map[string]chan mcpToolResult{}}
events := run.attach()
go run.readOutput(strings.NewReader(strings.Join(lines, "\n") + "\n"))
return run, events, nil
}
body := `{"model":"claude-sonnet-5","max_tokens":100,"stream":` + strconv.FormatBool(stream) + `,"messages":[{"role":"user","content":"hi"}]}`
rec := httptest.NewRecorder()
var u Usage
if code, msg := s.serveSubscription(rec, httptest.NewRequest("POST", "/v1/messages", strings.NewReader(body)), provider.Anthropic, name, "claude-sonnet-5", owner, []byte(body), &u, start); code != 200 {
t.Fatalf("%d %s", code, msg)
}
return rec
}
says := func(rec *httptest.ResponseRecorder, want map[string]string) {
t.Helper()
for k, v := range want {
if got := rec.Header().Get(k); got != v {
t.Errorf("%s: %q, want %q (headers %v)", k, got, v, rec.Header())
}
}
}
owner := "claude\x00" + user
told := map[string]string{
"anthropic-ratelimit-unified-5h-utilization": "0.42",
"anthropic-ratelimit-unified-5h-reset": strconv.FormatInt(five, 10),
"anthropic-ratelimit-unified-7d-utilization": "0.17",
"anthropic-ratelimit-unified-7d-reset": strconv.FormatInt(week, 10),
}
for _, stream := range []bool{true, false} {
t.Run("stream="+strconv.FormatBool(stream), func(t *testing.T) {
// the turn tells it
says(ask("Claude Code", owner, stream, append([]string{limits}, answer...)), told)
// a turn that tells nothing says what is kept
says(ask("Claude Code", owner, stream, answer), told)
})
}
// another account's reply says nothing of this one's
if rec := ask("Claude Code", "claude\x00other-"+user, true, answer); rec.Header().Get("anthropic-ratelimit-unified-5h-utilization") != "" {
t.Errorf("another account's reply says %v", rec.Header())
}
// another agent's run (Codex's, Cursor's) has no Claude allowance to say
if rec := ask("Agent", owner, true, answer); rec.Header().Get("anthropic-ratelimit-unified-5h-utilization") != "" {
t.Errorf("another agent's reply says %v", rec.Header())
}
}
// Through the gateway as it runs, to a Claude Code signed in to the
// account and answering as Claude Code does, streamed or not: the reply's
// headers carry what its rate_limit_event said, past the writer that holds
// a reply while another account may still take over.
func TestClaudeAllowanceHeadersThroughTheGateway(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("a shell script stands in for Claude Code")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
os.WriteFile(filepath.Join(home, ".claude", ".credentials.json"), mustJSON(map[string]any{"claudeAiOauth": map[string]any{
"accessToken": "tok-me", "refreshToken": "r-me", "expiresAt": time.Now().Add(24 * time.Hour).UnixMilli(), "subscriptionType": "pro"}}), 0o600)
os.WriteFile(filepath.Join(home, ".claude.json"), mustJSON(map[string]any{
"oauthAccount": map[string]any{"emailAddress": "hud@example.com", "accountUuid": "u-hud"}}), 0o600)
five := strconv.FormatInt(time.Now().Add(2*time.Hour).Unix(), 10)
week := strconv.FormatInt(time.Now().Add(5*24*time.Hour).Unix(), 10)
dir := t.TempDir()
testenv.Program(t, filepath.Join(dir, "claude"), `#!/bin/sh
if [ "$1" = auth ]; then echo '{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","email":"hud@example.com","subscriptionType":"pro"}'; exit 0; fi
while read -r line; do
echo '{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.63,"resetsAt":`+five+`},"seven_day":{"utilization":0.08,"resetsAt":`+week+`}}}}'
echo '{"type":"stream_event","event":{"type":"message_start","message":{"id":"m","model":"claude-sonnet-5","usage":{"input_tokens":1}}}}'
echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}}'
echo '{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}}'
echo '{"type":"stream_event","event":{"type":"message_stop"}}'
echo '{"type":"result","subtype":"success","is_error":false,"result":"pong"}'
done
`)
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
provider.ForgetAccounts()
t.Cleanup(provider.ForgetAccounts)
s := New()
t.Cleanup(s.subscription.abortAll)
srv := httptest.NewServer(s.Handler())
defer srv.Close()
for _, stream := range []string{"true", "false"} {
res, err := http.Post(srv.URL+"/v1/messages", "application/json", strings.NewReader(
`{"model":"claude/claude-sonnet-5","max_tokens":50,"stream":`+stream+`,"messages":[{"role":"user","content":"ping `+stream+`"}]}`))
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode != 200 || !strings.Contains(string(b), "pong") {
t.Fatalf("stream=%s: %d %s", stream, res.StatusCode, b)
}
for k, v := range map[string]string{
"anthropic-ratelimit-unified-5h-utilization": "0.63",
"anthropic-ratelimit-unified-5h-reset": five,
"anthropic-ratelimit-unified-7d-utilization": "0.08",
"anthropic-ratelimit-unified-7d-reset": week,
} {
if got := res.Header.Get(k); got != v {
t.Errorf("stream=%s: %s %q, want %q (headers %v)", stream, k, got, v, res.Header)
}
}
}
}
+51 -2
View File
@@ -52,6 +52,7 @@ import (
"regexp"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
@@ -3064,6 +3065,41 @@ func (s *Server) serveClaudeSubscription(w http.ResponseWriter, r *http.Request,
return s.serveSubscription(w, r, from, "Claude Code", model, owner, body, usage, start)
}
// limitHeaders says on a reply what is kept of the allowance of the account
// a run's owner names, as Anthropic says it to Claude Code: its
// anthropic-ratelimit-unified-* headers, which a Claude Code signed in to
// claude.ai reads into its status line's rate_limits (claude-hud's 5-hour
// and weekly figures, #1257). Its run tells them in its rate_limit_event,
// not as headers, so they are said again from what magpie keeps.
func limitHeaders(w http.ResponseWriter, owner string) {
user, own := ownerAccount(owner)
if user == "" || own && provider.ClaudeCodeMovedOff(user) {
return
}
set := func(k, v string) {
if hw, ok := w.(*holdWriter); ok {
hw.note(k, v) // keepAlive may send the headers before the reply has them
} else {
w.Header().Set(k, v)
}
}
for _, l := range provider.KeptClaudeLimits(user) {
if win, ok := ratelimitWindows[l.Kind]; ok {
set("anthropic-ratelimit-unified-"+win+"-utilization", strconv.FormatFloat(l.Used, 'f', -1, 64))
set("anthropic-ratelimit-unified-"+win+"-reset", strconv.FormatInt(l.ResetsAt, 10))
}
}
}
// ratelimitWindows are the windows Claude Code reads from
// anthropic-ratelimit-unified-<window>-*, by the kind it names them in its
// rate_limit_event.
var ratelimitWindows = map[string]string{
"five_hour": "5h",
"seven_day": "7d",
"seven_day_overage_included": "7d_oi",
}
// serveSubscription answers a request through an agent's own binary: a new
// turn starts it, a request carrying tool results resumes the turn waiting
// on them.
@@ -3199,7 +3235,14 @@ func (s *Server) serveSubscription(w http.ResponseWriter, r *http.Request, from
run.abort()
}
}
return relay(w, r, from, name, req, events, usage, abort, func(said, stop string, ok bool) {
// the allowance as kept now, and again as the reply begins, its turn
// having told it by then
var begin func()
if name == "Claude Code" {
limitHeaders(w, owner)
begin = func() { limitHeaders(w, owner) }
}
return relay(w, r, from, name, req, events, usage, abort, begin, func(said, stop string, ok bool) {
if !gone() {
return // the client went first: letGo has the run
}
@@ -3211,7 +3254,7 @@ func (s *Server) serveSubscription(w http.ResponseWriter, r *http.Request, from
// there is no answer to give, ended when one was given, before its last
// event goes out.
func relay(w http.ResponseWriter, r *http.Request, from provider.Protocol, name string, req *Request, events <-chan Event, usage *Usage,
abort func(), ended func(said, stop string, ok bool)) (int, string) {
abort, begin func(), ended func(said, stop string, ok bool)) (int, string) {
stream := req.Stream
if stream {
// an error before any of the answer — out of quota, rate limited —
@@ -3241,6 +3284,9 @@ func relay(w http.ResponseWriter, r *http.Request, from provider.Protocol, name
}
return writeError(w, from, code, name+": "+msg), msg
}
if begin != nil {
begin()
}
sw := newSSEWriter(w)
enc := encoder(from, sw, req, usage)
var failed, said, stop string
@@ -3299,6 +3345,9 @@ func relay(w http.ResponseWriter, r *http.Request, from provider.Protocol, name
return writeError(w, from, code, name+": "+col.err), col.err
}
ended(said, stop, col.err == "" && r.Context().Err() == nil)
if begin != nil {
begin()
}
res := col.finish()
usage.add(res.Usage)
usage.add(Usage{Served: res.Model})
+1 -1
View File
@@ -228,7 +228,7 @@ func (s *Server) serveCursor(w http.ResponseWriter, r *http.Request, from provid
if events == nil {
return writeError(w, from, status, msg), msg
}
return relay(w, r, from, "Cursor", req, events, usage, cancel, func(string, string, bool) {})
return relay(w, r, from, "Cursor", req, events, usage, cancel, nil, func(string, string, bool) {})
}
// askCursor is a round for Cursor's API.
+1 -1
View File
@@ -65,7 +65,7 @@ func (s *Server) serveDevin(w http.ResponseWriter, r *http.Request, from provide
if events == nil {
return writeError(w, from, status, msg), msg
}
return relay(w, r, from, "Devin", req, events, usage, cancel, func(string, string, bool) {})
return relay(w, r, from, "Devin", req, events, usage, cancel, nil, func(string, string, bool) {})
}
// askDevin is a round for Devin's API.
+1 -1
View File
@@ -225,7 +225,7 @@ func TestRelayKeepsClientAlive(t *testing.T) {
rec := httptest.NewRecorder()
req := &Request{Model: "m1", Stream: true}
r := httptest.NewRequest("POST", "/v1/messages", nil)
code, failed := relay(rec, r, provider.Anthropic, "Bridge", req, events, &Usage{}, func() {}, func(string, string, bool) {})
code, failed := relay(rec, r, provider.Anthropic, "Bridge", req, events, &Usage{}, func() {}, nil, func(string, string, bool) {})
if code != 200 || failed != "" {
t.Fatalf("relay: %d %q", code, failed)
}
+1 -1
View File
@@ -63,7 +63,7 @@ func (s *Server) serveKiro(w http.ResponseWriter, r *http.Request, from provider
if events == nil {
return writeError(w, from, status, msg), msg
}
return relay(w, r, from, "Kiro", req, events, usage, cancel, func(string, string, bool) {})
return relay(w, r, from, "Kiro", req, events, usage, cancel, nil, func(string, string, bool) {})
}
// askKiro is a round for Kiro's API.
+1 -1
View File
@@ -574,7 +574,7 @@ func (s *Server) searchReply(w http.ResponseWriter, r *http.Request, from provid
defer close(events)
s.searchRounds(ctx, &q, tool.Name, first, ask, events)
}()
return relay(w, r, from, name, req, events, usage, cancel, func(string, string, bool) {})
return relay(w, r, from, name, req, events, usage, cancel, nil, func(string, string, bool) {})
}
func (s *Server) searchRounds(ctx context.Context, q *Request, tool string, in <-chan Event, ask round, out chan<- Event) {
+26
View File
@@ -96,3 +96,29 @@ func NoteClaudeLimits(user string, ls []ClaudeLimit) {
c.Unlock()
StaleAllowance("claude", user)
}
// KeptClaudeLimits is what is kept of user's allowance as of now, in the
// windows Claude Code names (ClaudeLimit's kinds), each that says when it
// renews: what Claude Code last said as it answered, or its /usage read.
// Nil when nothing is kept.
func KeptClaudeLimits(user string) []ClaudeLimit {
claudeUsage.Lock()
e, ok := claudeUsage.m[strings.ToLower(user)]
claudeUsage.Unlock()
if !ok {
return nil
}
var out []ClaudeLimit
for _, w := range e.windows(time.Now()) {
if w.ResetsAt == nil {
continue // a window that renews when nobody knows says nothing of its own
}
for _, k := range claudeKinds {
if k.name == w.Name {
out = append(out, ClaudeLimit{Kind: k.kind, Used: w.Used / 100, ResetsAt: w.ResetsAt.Unix()})
break
}
}
}
return out
}