quota: each subscription account, plan and key says when it last answered, and the latest is marked last (#570: quota --json / GET /v1/magpie/quotas 增加每个订阅、plan、key 最近一次应答请求的时间) — under routing Smart nobody is first for good, so a menu-bar widget had nothing to tell the one in use by: a Quota's asOf is when its reading was taken, accounts --json covers signed-in Codex and Claude accounts only, and /v1/magpie/route wants a session. Each Quota in magpie quota --json and GET /v1/magpie/quotas now has lastServedAt (RFC3339, left out when it hasn't answered) and last: true on the latest (each of a key's plan and balance, which are one key). The gateway notes each answer where servedCandidate counts it (provider.NoteServed): an account by its agent and user, a key by its provider and key id, matched to a card by its user, or by the key's name or mask, or, for a card with no user, by any of its provider's. It is kept in served.json beside providers.json, entries older than 30 days dropped, since magpie quota runs in a process of its own and computes the report itself (provider.QuotaReport) rather than asking the gateway: both read the file, so they say the same, and a restart keeps it. TestQuotasLastServedAt (gateway: a request answered by one key, its balance card has lastServedAt and last, the other key's neither) and TestQuotaJSONLastServedAt (the CLI's --json, fake CLIs and keychain on PATH) fail without the change; TestQuotasLastServed covers the matching (account by user, key by name and by mask, a card with no user, the tie, read back from the file alone). README tells the fields. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-02 22:16:17 +08:00
1 parent 1c047f3685
commit 5d7c7d2808
8 files changed
+399 -2

No files matched your search

+6
View File
@@ -588,6 +588,12 @@ so a UI can follow a turn with one request at a time. Only the session
named is told; like `/v1/magpie/quotas`, it answers this machine, and
another only with the key of a gateway shared on the local network.
To show the one in use without a session, `GET /v1/magpie/quotas` (and
`magpie quota --json`, the same list) has `lastServedAt` on each
subscription account, plan and key that answered a request through the
gateway in the last 30 days, and `last: true` on the latest. It is kept
in `served.json` beside `providers.json`, so a restart keeps it.
The *Gateway* tab in the app has this as copy buttons and ready-made
snippets (shell, curl, Python, Node) for each API, the list of model ids,
and the recent calls; `MAGPIE_DEBUG=1` logs every call to the terminal.
+72
View File
@@ -0,0 +1,72 @@
package gateway
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
// GET /v1/magpie/quotas tells when each key last answered a request through
// the gateway, and marks the latest last (#570); one that never did has
// neither.
func TestQuotasLastServedAt(t *testing.T) {
fresh(t)
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/balance" {
io.WriteString(w, `{"balance":"7"}`)
return
}
servedBy(provider.Chat, "sol")(w, r)
}))
t.Cleanup(up.Close)
for _, id := range []string{"qa", "qb"} {
if err := provider.Save(provider.Provider{ID: id, Name: strings.ToUpper(id), Key: "k-" + id, Models: []string{"sol"},
Chat: up.URL + "/v1", BalanceURL: up.URL + "/balance", BalancePath: "balance"}); err != nil {
t.Fatal(err)
}
}
provider.ForgetBalances()
s := New()
before := time.Now().Add(-time.Second)
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"qa/sol","messages":[{"role":"user","content":"hi"}]}`)))
if rec.Code != 200 {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
rec = httptest.NewRecorder()
r := httptest.NewRequest("GET", "/v1/magpie/quotas", nil)
r.RemoteAddr = "127.0.0.1:5000"
s.Handler().ServeHTTP(rec, r)
var out struct {
Data []map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err, rec.Body)
}
seen := 0
for _, q := range out.Data {
switch q["provider"] {
case "qa":
seen++
s, _ := q["lastServedAt"].(string)
at, err := time.Parse(time.RFC3339, s)
if err != nil || at.Before(before) || q["last"] != true {
t.Errorf("served key: %v (%v)", q, err)
}
case "qb":
seen++
if _, ok := q["lastServedAt"]; ok || q["last"] != nil {
t.Errorf("a key that never answered: %v", q)
}
}
}
if seen != 2 {
t.Fatalf("quotas %s", rec.Body)
}
}
+1
View File
@@ -65,6 +65,7 @@ func served(rest, key string, tokens int) {
// when it has one, the candidate's model-specific rest.
func servedCandidate(c candidate, tokens int) {
served(c.restKey(), c.restKey(), tokens)
provider.NoteServed(c.p, time.Now())
if id := c.restID(); id != c.restKey() {
clearRest(id)
}
+6 -1
View File
@@ -79,6 +79,11 @@ type Quota struct {
Renew string `json:"renew,omitempty"`
// Resets are a Codex account's rate-limit resets, when it holds any.
Resets *ResetCredits `json:"resets,omitempty"`
// LastServedAt is when the account, plan or key last answered a
// request through the gateway, nil when it hasn't in the last 30 days
// (served.go); Last is on the latest of them (#570).
LastServedAt *time.Time `json:"lastServedAt,omitempty"`
Last bool `json:"last,omitempty"`
}
// QuotaSpan is one window of an allowance: how much of it is used and
@@ -114,7 +119,7 @@ func QuotaReport(ctx context.Context, now time.Time) []Quota {
out = append(out, r)
}
}
return out
return withServed(out, LastServed())
}
// ResetClock is when a window starts again, on the clock: "14:30" today,
+167
View File
@@ -0,0 +1,167 @@
package provider
// When each subscription account, plan and key last answered a request
// through the gateway (#570): magpie quota --json and the gateway's GET
// /v1/magpie/quotas tell it as each Quota's lastServedAt, and mark the
// latest last, so a menu-bar widget can show the one in use — under
// routing Smart nobody is first for good. It is kept on disk (served.json,
// next to the providers), so the CLI, a process of its own, reads what the
// gateway noted, and a restart doesn't forget it.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// servedKeep is how long an answer is remembered: a key or account gone
// since is dropped from the file after it.
const servedKeep = 30 * 24 * time.Hour
var servedAt = struct {
sync.Mutex
m map[string]time.Time // ServedID → when it last answered
path string // the file m goes with: another (a test's HOME) starts afresh
}{m: map[string]time.Time{}}
// ours is servedAt.m for the file at path. Called with servedAt held.
func ours(path string) map[string]time.Time {
if servedAt.path != path {
servedAt.path, servedAt.m = path, map[string]time.Time{}
}
return servedAt.m
}
func servedPath() string { return filepath.Join(filepath.Dir(Path()), "served.json") }
// ServedID names who answers for p, as its Quota is found by: an account
// by its agent and user ("codex@a@b.c"), a key by its provider and the
// key's id ("deepseek#k1a2b3c"), else the provider.
func ServedID(p Provider) string {
if a := p.Account; a != nil {
return loginProvider(Login{Agent: a.Agent}) + "@" + strings.ToLower(a.User)
}
if p.Key != "" {
return p.ID + "#" + KeyID(p.Key)
}
return p.ID
}
// NoteServed records that p just answered a request, at at.
func NoteServed(p Provider, at time.Time) {
id := ServedID(p)
path := servedPath()
servedAt.Lock()
defer servedAt.Unlock()
mine := ours(path)
if at.Before(mine[id]) {
return
}
mine[id] = at
m := readServed(path)
for k, t := range mine {
if t.After(m[k]) {
m[k] = t
}
}
for k, t := range m {
if at.Sub(t) > servedKeep {
delete(m, k)
}
}
if b, err := json.Marshal(m); err == nil && os.MkdirAll(filepath.Dir(path), 0o700) == nil {
writeFileAtomic(path, b)
}
}
// LastServed is when each ServedID last answered: what this process noted
// and what the file holds, whichever gateway wrote it.
func LastServed() map[string]time.Time {
path := servedPath()
m := readServed(path)
servedAt.Lock()
for k, t := range ours(path) {
if t.After(m[k]) {
m[k] = t
}
}
servedAt.Unlock()
for k, t := range m {
if time.Since(t) > servedKeep {
delete(m, k)
}
}
return m
}
func readServed(path string) map[string]time.Time {
m := map[string]time.Time{}
if b, err := os.ReadFile(path); err == nil {
json.Unmarshal(b, &m)
}
if m == nil {
m = map[string]time.Time{}
}
return m
}
// withServed sets each quota's LastServedAt from served, and Last on the
// latest (on each of them, when a key's plan and balance tie).
func withServed(qs []Quota, served map[string]time.Time) []Quota {
if len(served) == 0 {
return qs
}
var keys map[string][]KeyInfo // provider → its keys, read once
var latest time.Time
for i := range qs {
q := &qs[i]
var at time.Time
latestOf := func(id string) {
if t, ok := served[id]; ok && t.After(at) {
at = t
}
}
user := strings.ToLower(q.User)
switch {
case q.Kind == "subscription" && user != "":
latestOf(q.Provider + "@" + user)
case q.User == "":
// the one account or key it has, or any of them: a balance
// one account has whichever key asks
for id, t := range served {
if (id == q.Provider || strings.HasPrefix(id, q.Provider+"@") || strings.HasPrefix(id, q.Provider+"#")) && t.After(at) {
at = t
}
}
default:
// a plan's or balance's key, by the name or mask it is told by
if keys == nil {
keys = map[string][]KeyInfo{}
for _, p := range All() {
keys[p.ID] = p.KeyList()
}
}
for _, k := range keys[q.Provider] {
if k.Name == q.User || k.Name == "" && k.Masked == q.User {
latestOf(q.Provider + "#" + k.ID)
}
}
}
if !at.IsZero() {
t := at.UTC()
q.LastServedAt = &t
if at.After(latest) {
latest = at
}
}
}
for i := range qs {
if qs[i].LastServedAt != nil && qs[i].LastServedAt.Equal(latest) {
qs[i].Last = true
}
}
return qs
}
+61
View File
@@ -0,0 +1,61 @@
package provider
import (
"encoding/json"
"os"
"strings"
"testing"
"time"
)
// Each quota is told when its account, plan or key last answered (#570):
// an account by its user, a key by the name or mask its card has, a card
// with no user by any of its provider's; the latest is marked last.
func TestQuotasLastServed(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if err := Save(Provider{ID: "dk", Name: "DK", Key: "sk-first-key-0001", KeyName: "work", Chat: "http://127.0.0.1:1/v1",
Keys: []KeyAccount{{Key: "sk-other-key-0002"}}}); err != nil {
t.Fatal(err)
}
now := time.Now().Truncate(time.Second)
NoteServed(Provider{ID: "codex", Account: &Account{Agent: "codex", User: "A@x.com"}}, now.Add(-time.Hour))
NoteServed(Provider{ID: "dk", Key: "sk-other-key-0002"}, now.Add(-time.Minute))
NoteServed(Provider{ID: "solo", Key: "sk-solo"}, now.Add(-2*time.Hour))
// the CLI is a process of its own: what it is told is read from the file
servedAt.Lock()
servedAt.m = map[string]time.Time{}
servedAt.Unlock()
qs := withServed([]Quota{
{Provider: "codex", Kind: "subscription", User: "a@x.com"},
{Provider: "codex", Kind: "subscription", User: "b@x.com"},
{Provider: "dk", Kind: "plan", User: "work"},
{Provider: "dk", Kind: "plan", User: Mask("sk-other-key-0002")},
{Provider: "dk", Kind: "balance"},
{Provider: "solo", Kind: "balance"},
{Provider: "none", Kind: "balance"},
}, LastServed())
want := []*time.Time{ptr(now.Add(-time.Hour)), nil, nil, ptr(now.Add(-time.Minute)), ptr(now.Add(-time.Minute)), ptr(now.Add(-2 * time.Hour)), nil}
for i, q := range qs {
if (q.LastServedAt == nil) != (want[i] == nil) || q.LastServedAt != nil && !q.LastServedAt.Equal(*want[i]) {
t.Errorf("%d %s %s %q: lastServedAt %v, want %v", i, q.Provider, q.Kind, q.User, q.LastServedAt, want[i])
}
if last := i == 3 || i == 4; q.Last != last {
t.Errorf("%d %s %q: last %v", i, q.Provider, q.User, q.Last)
}
}
b, _ := json.Marshal(qs[0])
if !strings.Contains(string(b), `"lastServedAt":"`+now.Add(-time.Hour).UTC().Format(time.RFC3339)+`"`) || strings.Contains(string(b), `"last"`) {
t.Errorf("json %s", b)
}
if b, _ := json.Marshal(qs[1]); strings.Contains(string(b), "lastServedAt") || strings.Contains(string(b), `"last"`) {
t.Errorf("never served: %s", b)
}
if _, err := os.Stat(servedPath()); err != nil {
t.Fatal("not kept on disk:", err)
}
}
func ptr(t time.Time) *time.Time { return &t }
+2 -1
View File
@@ -20,7 +20,8 @@ const quotaUsage = `usage: magpie quota [<provider>…] [--json]
magpie quota alert [<percent>|off] [--balance <amount>|off]
what is left of every subscription, plan and key magpie has: each window's use and
when it starts again, and each key's balance, asked of the vendors now (or less than
a minute ago). --json is for scripts and agents; the gateway answers the same at
a minute ago). --json is for scripts and agents, each entry with lastServedAt, when it
last answered through the gateway, and last: true on the latest; the gateway answers the same at
GET http://127.0.0.1:3425/v1/magpie/quotas, and to another machine only while magpie is
shared on the local network, with its key as the API key (Authorization: Bearer or x-api-key)
A Codex account that holds rate-limit resets says how many; quota reset spends one,
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/yetone/magpie/internal/agentenv"
"github.com/yetone/magpie/internal/provider"
)
// magpie quota --json tells when each key last answered through the gateway
// — a process of its own, which wrote it down — and marks the latest last,
// as GET /v1/magpie/quotas does (#570).
func TestQuotaJSONLastServedAt(t *testing.T) {
groupsHome(t)
for _, k := range agentenv.Vars {
t.Setenv(k, "")
os.Unsetenv(k)
}
// no agent's CLI nor the keychain is asked: what answers is inert
bin := t.TempDir()
for _, name := range []string{"security", "secret-tool", "claude", "codex", "cursor-agent", "devin", "grok", "kiro-cli"} {
if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, `{"balance":"7"}`) }))
t.Cleanup(up.Close)
for _, id := range []string{"a", "b"} {
p, err := provider.Find(id)
if err != nil {
t.Fatal(err)
}
p.BalanceURL, p.BalancePath = up.URL, "balance"
if err := provider.Save(*p); err != nil {
t.Fatal(err)
}
}
provider.ForgetBalances()
at := time.Now().Add(-time.Minute).Truncate(time.Second)
a, _ := provider.Find("a")
provider.NoteServed(*a, at)
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
old := os.Stdout
os.Stdout = w
done := make(chan []byte)
go func() { b, _ := io.ReadAll(r); done <- b }()
err = quotaCmd([]string{"quota", "a", "b", "--json"})
w.Close()
os.Stdout = old
out := <-done
if err != nil {
t.Fatal(err)
}
var qs []map[string]any
if err := json.Unmarshal(out, &qs); err != nil {
t.Fatal(err, string(out))
}
if len(qs) != 2 {
t.Fatalf("%s", out)
}
for _, q := range qs {
switch q["provider"] {
case "a":
if q["lastServedAt"] != at.UTC().Format(time.RFC3339) || q["last"] != true {
t.Errorf("served: %v", q)
}
case "b":
if _, ok := q["lastServedAt"]; ok || q["last"] != nil {
t.Errorf("never served: %v", q)
}
}
}
}