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:
1 parent
b2432754e5
commit
01e66e0f4a
11 files changed
+256
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user