provider: a remote magpie's cards carry what was read behind the requests — the account switching's, the Codex warm-up's and routing's readings of each account (LoginUsage), routing's of each key (KeyAllowance) — and after a restart the readings kept in quotas.json, not only what the Usage page last read (#1313, jorben)

A magpie serving only other magpies (`magpie serve` in a container) has nobody on its Usage page, so GET /v1/magpie/quotas/cards, answered by CachedCards from the page's caches (subscriptionUsageCache, planQuotaCache, keyBalanceCache), said "nothing read on that magpie yet" while its Codex account's usage was read every few minutes behind the requests: those readings land in loginUsageCache (and a key's in keyAllowances), which CachedCards never looked at; after a restart even a page-read card was gone until the page was opened, though quotas.json held it.

CachedCards still asks nobody. It now lays each account's latest LoginUsage reading over its card when it is newer by readSeq (perLogin's cards are that same reading, so the page's own later read stands), adds a card for an account read that way and never on the page, named and drawn as the page names it (accountCard: perLogin now takes that face), and falls back to the reading kept on disk, dated (AsOf). Keys the same way: routing's KeyAllowance reading now keeps what was read with the windows (plan, balance, parts, readSeq) and is laid over the key's plan or balance card, or makes one; with no card read since start, the plan and balance cards kept in quotas.json are told. Cursor (known by asking its CLI), Grok (read by home) and a Kiro key keep the page's cards only. The local Usage page is unchanged.

Not fixed here: the quota history (/v1/magpie/quotas/history) is still written only by the page's reads.

Red before: with CachedCards as on origin/main and everything else in place, TestRemoteMagpieShowsBackgroundReadings (gateway, through lanGuard(New().Handler()) and a remote-magpie provider: CodexUsedUp, then RemoteCards) fails with the reporter's card ("nothing read on that magpie yet; refresh to have it read"), and TestCachedCardsTellBackgroundReadings, TestCachedCardsKeepALaterPageRead and TestCachedCardsTellKeyReadings fail; all pass with it. ForgetKeptCardsForTest is called from the gateway's fresh() so TestRemoteMagpieQuotas's "nothing read" holds under -count.

Run (Docker golang:1.26.8, the Mac's syspolicyd pegged): go test -tags nogui ./internal/... (internal/agent as uid 1000), go test -race ./internal/provider ./internal/gateway, go test -count=2 -run TestRemoteMagpie ./internal/gateway; go vet -tags nogui for darwin, linux and windows on internal/provider and internal/gateway; gofmt -l. No app.js change.
This commit is contained in:
yetone committed 2026-10-08 20:48:49 +08:00
1 parent 58f45d0530
commit 137ebee82f
9 files changed
+565 -33

No files matched your search

+1 -1
View File
@@ -27,7 +27,7 @@ keys and accounts per request is in [Gateway routing and fallback](gateway-routi
| Sign-in in magpie | `StartSignIn` runs the vendor's own browser OAuth with its client and takes the tokens at a callback on this machine. A browser that can't reach that callback (magpie on a server or in Docker) ends on a page that won't load; its address, pasted (`SubmitSignInCallback`), goes through the same callback handler (`pastedCallback`). A plugin's browser sign-in (`auto`) whose page names a port on this machine to come back to takes its pasted address too: magpie opens it there on 127.0.0.1, for the plugin's own listener (`pluginCallback`, `loopbackPorts`); Command Code's page posts its key from script, so it offers the API key way instead. A Claude account is signed in by Claude Code itself. Signing in brings back an account removed from magpie (`ShowAccount`). A sign-in started in magpie (`StartSignIn`, `StartPluginSignIn`) reads as done (`SignInStatus`, `WaitSignIn`) only after `finish` has run `ShowAccount` for its account, so the window, which opens the account as soon as it sees done, finds it listed. If providers.json can't be written then, the account stays hidden and the sign-in still ends done. Signing in to a plugin's provider from the command line (`pluginLogin`, with a key or the browser) brings it back too. It also takes off the mark a sign-in its vendor turned away left on the account (`Lapsed`) and shows again the agent's own account removed in magpie (`clearPluginLapse`). The window's plugin sign-in does the same with the browser (`pluginDone`, then `finish`) or with a key (`PluginAPIKey`); a key and the command line share these steps in `PluginSignedIn`. | [`signin.go`](../../internal/provider/signin.go), [`claude_signin.go`](../../internal/provider/claude_signin.go), [`pluginsignin.go`](../../internal/provider/pluginsignin.go), [`pluginsignin_paste.go`](../../internal/provider/pluginsignin_paste.go), [`plugins_cli.go`](../../plugins_cli.go) |
| Used-up switch | When the account Codex or Claude Code is signed in to has used its allowance, the agent is signed in to the next account that is on and has room (#208). | [`codex_switch.go`](../../internal/provider/codex_switch.go) |
| Routing groups | User-made groups from the Routing view, and auto-found groups of the same model across providers (derived, never stored). Members are `provider/model[:effort]`. A group can nest another (`group/<id>`); `SaveGroup` refuses loops. `Match` takes a glob or `re:` pattern (#766). | [`group.go`](../../internal/provider/group.go) |
| Allowance | `Quotas` asks every vendor at once: subscription windows (`SubscriptionUsage`), key-bought plans (`PlanQuotas`) and key balances (`KeyBalances`). The Usage page, `magpie quota --json` and `GET /v1/magpie/quotas` read it. A single card can be read again on its own (#840). A remote magpie's cards come from what it has kept (`CachedCards`, `GET /v1/magpie/quotas/cards`) and are never read there for this computer except by a card's refresh (`POST /v1/magpie/quotas/refresh`, throttled per card). | [`quotas.go`](../../internal/provider/quotas.go), [`subscription_usage.go`](../../internal/provider/subscription_usage.go), [`planquota.go`](../../internal/provider/planquota.go), [`balance.go`](../../internal/provider/balance.go), [`quota_refresh.go`](../../internal/provider/quota_refresh.go), [`remote_quotas.go`](../../internal/provider/remote_quotas.go) |
| Allowance | `Quotas` asks every vendor at once: subscription windows (`SubscriptionUsage`), key-bought plans (`PlanQuotas`) and key balances (`KeyBalances`). The Usage page, `magpie quota --json` and `GET /v1/magpie/quotas` read it. A single card can be read again on its own (#840). A remote magpie's cards come from what it has kept (`CachedCards`, `GET /v1/magpie/quotas/cards`) and are never read there for this computer except by a card's refresh (`POST /v1/magpie/quotas/refresh`, throttled per card). What it has kept is the Usage page's cards with the readings made behind the requests since laid over them, a later one over an earlier by `readSeq` (`cards_kept.go`): each account's `LoginUsage` reading (the used-up switch, the Codex warm-up, routing's `Allowances`), and each key's `KeyAllowance` windows on its plan or balance card. An account or key read that way and never on the page gets a card from that reading, and after a restart, before either is read again, the cards come from `quotas.json`, dated (`AsOf`). A magpie serving only other magpies has nobody on its page (#1313). Cursor, Grok and a Kiro key keep the page's cards only. | [`cards_kept.go`](../../internal/provider/cards_kept.go), [`quotas.go`](../../internal/provider/quotas.go), [`subscription_usage.go`](../../internal/provider/subscription_usage.go), [`planquota.go`](../../internal/provider/planquota.go), [`balance.go`](../../internal/provider/balance.go), [`quota_refresh.go`](../../internal/provider/quota_refresh.go), [`remote_quotas.go`](../../internal/provider/remote_quotas.go) |
| A key's own windows | A sub2api key given a spending limit per 5 hours, day or 7 days answers its `/v1/usage` with `rate_limits` and no `remaining` (`readSub2APIKeyLimits`). With the Balance URL set to it, the key's card shows them as the windows *5 hours*, *1 day*, *7 days*, in USD, beside a balance field the user wrote; *Check balance* and `magpie provider show` give what its fullest window has left (`Balance`). `KeyAllowance` gives routing the same windows: never waited for, read in the background at most once a minute, a failed read keeping the last, and seeded from the card's last reading (`quotas.json`) after a restart. A key bought as a plan (Kimi Code, GLM Coding Plan and Z.ai, MiniMax Coding Plan, OpenCode Go, Command Code: `planQuotaSourceOf`) gives routing its card's windows the same way (`planKeyWindows`, Zhipu's team quota behind a key with none of its own); the Usage page's read of its card is taken at once (`noteKeyAllowance`), and a key there that turns out pay-as-you-go, with no windows, is asked again only every 30 minutes (`keyNoWindowsAge`). StepFun's Step Plan is read through a sign-in, not the key, and isn't one of them. | [`sub2api_usage.go`](../../internal/provider/sub2api_usage.go), [`key_allowance.go`](../../internal/provider/key_allowance.go) |
| Last served | `served.json` records when each account, plan or key last answered, shown as `lastServedAt` (#570). | [`served.go`](../../internal/provider/served.go) |
| Protocol detection | Works out which APIs a base URL speaks when a provider is added. Detection, endpoint tests and model tests share `probe`, which adds `anthropic-version` only for Anthropic Messages; OpenAI probes omit that default so a relay that refuses it can answer. User-supplied headers still apply through `Sign`. | [`detect.go`](../../internal/provider/detect.go), [`test.go`](../../internal/provider/test.go) |
+1
View File
@@ -56,6 +56,7 @@ func fresh(t *testing.T) {
levelled.Unlock()
provider.ForgetCopilotForTest()
provider.ForgetRemoteCardsForTest()
provider.ForgetKeptCardsForTest()
remoteRefreshes.Lock()
remoteRefreshes.at = map[string]time.Time{}
remoteRefreshes.Unlock()
+71
View File
@@ -2,11 +2,16 @@ package gateway
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
@@ -111,3 +116,69 @@ func TestRemoteMagpieQuotas(t *testing.T) {
t.Errorf("refused requests read the vendor: %d", n)
}
}
// #1313 (jorben): a magpie in a container serves only other magpies, so
// nobody opens its Usage page, while its Codex account's usage is read
// behind the requests (the account switching, CodexUsedUp). A GUI on
// another computer with that magpie as its provider is shown the
// reading on the account's card, through the gateway, and asking for the
// cards asks ChatGPT nothing.
func TestRemoteMagpieShowsBackgroundReadings(t *testing.T) {
fresh(t)
t.Setenv("MAGPIE_ADDR", "")
home := os.Getenv("HOME")
jwt := func(m map[string]any) string {
b, _ := json.Marshal(m)
return "h." + base64.RawURLEncoding.EncodeToString(b) + ".s"
}
// an account no other test reads, so no reading of it is cached
os.MkdirAll(filepath.Join(home, ".codex"), 0o755)
os.WriteFile(filepath.Join(home, ".codex", "auth.json"), mustJSON(map[string]any{"auth_mode": "chatgpt", "tokens": map[string]any{
"id_token": jwt(map[string]any{"email": "jorben-1313@example.com", "https://api.openai.com/auth": map[string]any{"chatgpt_plan_type": "pro", "chatgpt_account_id": "acct-1313"}}),
"access_token": jwt(map[string]any{"exp": time.Now().Add(time.Hour).Unix()}),
"refresh_token": "r", "account_id": "acct-1313"}}), 0o600)
provider.ForgetAccounts()
t.Cleanup(provider.ForgetAccounts)
var asked atomic.Int32
chatgpt := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/backend-api/wham/usage" {
w.WriteHeader(http.StatusNotFound)
return
}
asked.Add(1)
w.Write([]byte(`{"plan_type":"pro","rate_limit":{"allowed":true,"limit_reached":false,
"primary_window":{"used_percent":37,"limit_window_seconds":18000,"reset_after_seconds":3600},
"secondary_window":{"used_percent":12,"limit_window_seconds":604800,"reset_after_seconds":86400}}}`))
}))
defer chatgpt.Close()
was := provider.CodexBase
provider.CodexBase = chatgpt.URL + "/backend-api/codex"
t.Cleanup(func() { provider.CodexBase = was })
gw := httptest.NewServer(lanGuard(New().Handler()))
defer gw.Close()
if err := provider.Save(provider.Provider{ID: "office", Name: "Office", Preset: provider.RemoteMagpiePreset, Key: "sk-magpie-office", Chat: gw.URL}); err != nil {
t.Fatal(err)
}
ctx := context.Background()
// read behind the requests, as the account switching reads it
if provider.CodexUsedUp(ctx) {
t.Fatal("held at 37%")
}
if n := asked.Load(); n != 1 {
t.Fatalf("the background read asked ChatGPT %d times", n)
}
var codex *provider.SubscriptionQuota
cs := provider.RemoteCards(ctx)
for i, q := range cs {
if q.Provider == "office/codex" && q.User == "jorben-1313@example.com" {
codex = &cs[i]
}
}
if codex == nil || len(codex.Windows) == 0 || codex.Windows[0].Used != 37 || codex.Error != "" || codex.Kind != "subscription" {
t.Fatalf("the remote's Codex card: %+v (all: %+v)", codex, cs)
}
if n := asked.Load(); n != 1 {
t.Fatalf("asking for the cards asked ChatGPT: %d", n)
}
}
+284
View File
@@ -0,0 +1,284 @@
package provider
// The cards another magpie is told (CachedCards) as fresh as this magpie
// knows them, not only as the Usage page last read them (jorben, #1313).
// A magpie that serves only other magpies (`magpie serve` in a container)
// has nobody on its Usage page, so the page's cards were never read
// there, while the same accounts' allowances are read all the time
// behind the requests: the account switching and the Codex warm-up
// (LoginUsage), routing (Allowances, KeyAllowance). Those readings now
// stand on the cards in place of an older one, an account or key with no
// card yet gets one from them, and after a restart, before anything is
// read, the cards are the ones kept on disk (quotas.json, as of when they
// were read). Nothing here asks a vendor.
import (
"slices"
"strings"
"time"
"github.com/yetone/magpie/internal/plugin"
)
// cardFace is what a card is named and drawn with.
type cardFace struct{ name, icon string }
// accountCard is the face of the card the Usage page gives each account
// of agent, as its usage is read (LoginUsage): a plugin's ("plugin:<id>")
// as the plugin's cards are.
func accountCard(agent string) cardFace {
if pp, ok := pluginOfAgent(agent); ok && strings.HasPrefix(agent, "plugin:") {
return pluginCardFace(pp)
}
switch agent {
case "claude":
return cardFace{"Claude Code", "claude-color"}
case "codex":
return cardFace{"Codex", "codex-color"}
case "copilot":
return cardFace{"Copilot", "githubcopilot"}
case QoderCNID:
return cardFace{"Qoder CN", "qoder"}
case "gemini", "antigravity":
app, _ := googleAppOf(agent)
return cardFace{app.name, app.icon}
}
if c, ok := movedCards[agent]; ok {
return cardFace{c.name, c.icon}
}
return cardFace{}
}
func pluginCardFace(pp plugin.Provider) cardFace {
name, icon := pluginCard(pp)
return cardFace{name, icon}
}
// cardAgents are the agents whose accounts each have a card of their own
// on the Usage page, read as LoginUsage reads them. Cursor's account is
// known by asking its CLI, Grok's are read by home, and a Kiro key has
// one card for itself: their cards stay as the page read them.
var cardAgents = []string{"claude", "codex", "copilot", "kiro", "zcode", "workbuddy", WorkBuddyAIID,
CommandCodePlanID, "qoder", QoderCNID, "zed", "devin", "factory", MiMoID, "gemini", "antigravity"}
// cardLogins are the accounts the Usage page has a card of its own for,
// as LoginUsage reads them, a built-in moved onto its plugin by the
// plugin's.
func cardLogins() []Login {
agents := []string{}
for _, id := range cardAgents {
if Moved(id) || id == "kiro" && kiroKey() != "" {
continue
}
agents = append(agents, id)
}
for _, pp := range plugin.Cached() {
agents = append(agents, pluginAgent(pp))
}
seen := map[string]bool{}
var out []Login
for _, agent := range agents {
ls, _ := usageLogins(agent)
for _, l := range ls {
if k := l.Agent + "/" + strings.ToLower(l.User); l.User != "" && !seen[k] {
seen[k] = true
out = append(out, l)
}
}
}
return out
}
// withAccountReadings is cards with each account's latest reading, made
// for the Usage page or behind it (LoginUsage), in place of an older
// one. An account without a card gets one, from its reading, else from
// the one kept on disk (quotas.json) as of when it was made.
func withAccountReadings(cards []SubscriptionQuota) []SubscriptionQuota {
ls := cardLogins()
if len(ls) == 0 {
return cards
}
read := map[string]SubscriptionQuota{}
c := &loginUsageCache
c.Lock()
for _, l := range ls {
k := l.Agent + "/" + strings.ToLower(l.User)
if e, ok := c.m[k]; ok {
read[k] = e.read
}
}
c.Unlock()
out := slices.Clone(cards)
for _, l := range ls {
id := loginProvider(l)
q, ok := read[l.Agent+"/"+strings.ToLower(l.User)]
i := slices.IndexFunc(out, func(x SubscriptionQuota) bool {
return x.From == "" && x.Provider == id && strings.EqualFold(x.User, l.User)
})
if i >= 0 {
// the same reading (perLogin's) or a later one
if ok && q.readSeq >= out[i].readSeq {
q.Provider, q.Name, q.Icon, q.User = id, out[i].Name, out[i].Icon, out[i].User
out[i] = q
}
continue
}
if !ok {
if q, ok = lastReading(id + "/" + strings.ToLower(l.User)); !ok {
continue
}
}
f := accountCard(l.Agent)
q.Provider, q.Name, q.Icon, q.User = id, f.name, f.icon, l.User
out = append(out, q)
}
return out
}
// lastReading is the reading kept on disk under key (keepLast), with its
// date (AsOf).
func lastReading(key string) (SubscriptionQuota, bool) {
c := &lastQuotas
c.Lock()
defer c.Unlock()
c.load()
return c.reading(key)
}
// cardKeys are the keys p's plan and balance cards are read with, by the
// account each card names ("" for a provider's one card): the key in use
// first, then each other one on, named as PlanQuotas and KeyBalances name
// them.
func cardKeys(p Provider) map[string]string {
others := 0
for _, k := range p.Keys {
if !k.Off && k.Key != "" && k.Key != p.Key {
others++
}
}
if others == 0 {
return map[string]string{"": p.Key}
}
out := map[string]string{}
name := func(n, key string) string {
if n == "" {
return Mask(key)
}
return n
}
out[name(p.KeyName, p.Key)] = p.Key
for _, k := range p.Keys {
if !k.Off && k.Key != "" && k.Key != p.Key {
out[name(k.Name, k.Key)] = k.Key
}
}
return out
}
// keyCardProviders are the providers whose keys can have a plan or
// balance card.
func keyCardProviders() []Provider {
var out []Provider
for _, p := range All() {
if !p.Hidden && !p.Off && p.Account == nil && p.Key != "" {
out = append(out, p)
}
}
return out
}
// keptKeyCards are the plan and balance cards of the keys magpie has, as
// they were last read and kept on disk (quotas.json): what another magpie
// is shown before this one reads them again after a restart.
func keptKeyCards(ps []Provider) (plans, balances []SubscriptionQuota) {
plans, balances = []SubscriptionQuota{}, []SubscriptionQuota{}
for _, p := range ps {
for user, key := range cardKeys(p) {
for _, kind := range []string{"plan", "balance"} {
q, ok := lastReading(p.ID + "/" + keyTag(kind, key))
if !ok || !strings.EqualFold(q.User, user) {
continue // kept under the name it had then
}
q.Provider, q.Name, q.Icon = p.ID, p.Name, p.Icon
if kind == "plan" {
plans = append(plans, q)
} else {
balances = append(balances, q)
}
}
}
}
return plans, balances
}
// withKeyReadings is the plan or balance cards (kind) with the windows
// routing read of their keys since (KeyAllowance), and what was read with
// them, in place of the card's own, which only the Usage page reads. A key
// routing has read that has no card yet gets one.
func withKeyReadings(cards []SubscriptionQuota, ps []Provider, kind string) []SubscriptionQuota {
out := slices.Clone(cards)
for _, p := range ps {
if !strings.HasPrefix(keyCardTag(p), kind+" ") {
continue // the key's windows are its other card's
}
for user, key := range cardKeys(p) {
r := keyRead(p.ID + "#" + keyID(key))
if r.seq == 0 {
continue
}
i := slices.IndexFunc(out, func(q SubscriptionQuota) bool {
return q.From == "" && q.Provider == p.ID && strings.EqualFold(q.User, user)
})
var q SubscriptionQuota
switch {
case i >= 0 && r.seq <= out[i].readSeq:
continue // the card was read since
case i >= 0:
q = out[i]
case len(r.ws) == 0 && r.balance == "":
continue // a key without a plan, nor a balance read
default:
q = SubscriptionQuota{Provider: p.ID, Name: p.Name, Icon: p.Icon, User: user}
}
at := r.at
q.Windows, q.ReadAt, q.AsOf, q.Error, q.readSeq = slices.Clone(r.ws), &at, nil, "", r.seq
if q.Windows == nil {
q.Windows = []QuotaWindow{}
}
if r.plan != "" {
q.Plan = r.plan
}
if r.balance != "" {
q.Balance, q.BalanceParts = r.balance, r.parts
}
if i >= 0 {
out[i] = q
} else {
out = append(out, q)
}
}
}
return out
}
// keyRead is what routing last read of the key id (provider id#key id).
func keyRead(id string) keyReading {
c := &keyAllowances
c.Lock()
defer c.Unlock()
if e := c.m[id]; e != nil {
return e.read
}
return keyReading{}
}
// keyReading is a key's windows as routing read them, with the plan or
// balance read with them, for its card.
type keyReading struct {
ws []QuotaWindow
plan string
balance string
parts []BalancePart
at time.Time
seq uint64 // quotaReading's, begun as it was asked
}
+142
View File
@@ -0,0 +1,142 @@
package provider
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
// cardOf is the card cs has of provider and user, nil when none.
func cardOf(cs []SubscriptionQuota, provider, user string) *SubscriptionQuota {
for i := range cs {
if cs[i].Provider == provider && cs[i].User == user {
return &cs[i]
}
}
return nil
}
// #1313 (jorben): a magpie serving only other magpies has nobody on its
// Usage page, while its Codex accounts' usage is read all the time behind
// the requests (the account switching, CodexUsedUp). What another magpie
// is told of its cards (CachedCards) is that reading, not "nothing read";
// after a restart, before anything is read again, it is the reading kept
// on disk, dated. Telling it asks nobody.
func TestCachedCardsTellBackgroundReadings(t *testing.T) {
signIn(t) // Codex on me@example.com
rememberLogins(true)
var asked atomic.Int32
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/backend-api/wham/usage" {
w.WriteHeader(http.StatusNotFound)
return
}
asked.Add(1)
w.Write([]byte(`{"plan_type":"pro","rate_limit":{"allowed":true,"limit_reached":false,
"primary_window":{"used_percent":37,"limit_window_seconds":18000,"reset_after_seconds":3600},
"secondary_window":{"used_percent":12,"limit_window_seconds":604800,"reset_after_seconds":86400}}}`))
}))
defer fake.Close()
old := CodexBase
CodexBase = fake.URL + "/backend-api/codex"
t.Cleanup(func() { CodexBase = old })
forget := func() {
loginUsageCache.Lock()
loginUsageCache.m = nil
loginUsageCache.Unlock()
subscriptionUsageCache.Lock()
subscriptionUsageCache.data, subscriptionUsageCache.at = nil, time.Time{}
subscriptionUsageCache.Unlock()
ForgetKeptCardsForTest()
}
forget()
t.Cleanup(forget)
if q := cardOf(CachedCards(time.Now()), "codex", "me@example.com"); q != nil {
t.Fatalf("a card before anything was read: %+v", q)
}
if CodexUsedUp(context.Background()) {
t.Fatal("held at 37%")
}
used := func(q *SubscriptionQuota) float64 {
if q == nil || len(q.Windows) == 0 {
return -1
}
return q.Windows[0].Used
}
q := cardOf(CachedCards(time.Now()), "codex", "me@example.com")
if used(q) != 37 || q.Name != "Codex" || q.Kind != "subscription" || q.AsOf != nil || q.Error != "" {
t.Fatalf("after the background read: %+v", q)
}
// restarted: nothing read yet, the reading kept on disk, dated
loginUsageCache.Lock()
loginUsageCache.m = nil
loginUsageCache.Unlock()
ForgetKeptCardsForTest()
q = cardOf(CachedCards(time.Now()), "codex", "me@example.com")
if used(q) != 37 || q.AsOf == nil || q.Name != "Codex" {
t.Fatalf("after a restart: %+v", q)
}
if n := asked.Load(); n != 1 {
t.Fatalf("ChatGPT asked %d times, want 1 (the background read)", n)
}
}
// The page's card stands when it was read after the background's reading.
func TestCachedCardsKeepALaterPageRead(t *testing.T) {
signIn(t)
rememberLogins(true)
t.Cleanup(func() {
loginUsageCache.Lock()
loginUsageCache.m = nil
loginUsageCache.Unlock()
subscriptionUsageCache.Lock()
subscriptionUsageCache.data = nil
subscriptionUsageCache.Unlock()
ForgetKeptCardsForTest()
})
loginUsageCache.Lock()
loginUsageCache.m = map[string]loginUsageEntry{"codex/me@example.com": {at: time.Now(),
read: SubscriptionQuota{Provider: "codex", User: "me@example.com", Windows: []QuotaWindow{{Name: "5h", Used: 10}}, readSeq: 5}}}
loginUsageCache.Unlock()
subscriptionUsageCache.Lock()
subscriptionUsageCache.data = []SubscriptionQuota{{Provider: "codex", Name: "Codex", User: "me@example.com",
Windows: []QuotaWindow{{Name: "5h", Used: 20}}, readSeq: 6}}
subscriptionUsageCache.Unlock()
if q := cardOf(CachedCards(time.Now()), "codex", "me@example.com"); q == nil || q.Windows[0].Used != 20 {
t.Fatalf("the later page read: %+v", q)
}
subscriptionUsageCache.Lock()
subscriptionUsageCache.data[0].readSeq = 4
subscriptionUsageCache.Unlock()
if q := cardOf(CachedCards(time.Now()), "codex", "me@example.com"); q == nil || q.Windows[0].Used != 10 || q.Name != "Codex" {
t.Fatalf("the later background read: %+v", q)
}
}
// The sibling for keys: routing reads a sub2api key's windows behind the
// requests (KeyAllowance); another magpie is told them on the key's card,
// one made for it when the Usage page never read it.
func TestCachedCardsTellKeyReadings(t *testing.T) {
keyLimitsHome(t)
week := time.Now().Add(72 * time.Hour).Truncate(time.Second)
srv, asked := sub2apiServer(t, func() ([]byte, int) { return weekUsed(t, "720", week), 200 })
p := sub2apiKey(srv)
if err := Save(p); err != nil {
t.Fatal(err)
}
if q := cardOf(CachedCards(time.Now()), "sub2api", ""); q != nil {
t.Fatalf("a card before anything was read: %+v", q)
}
waitAllowance(t, p, func(Allowance) bool { return true })
q := cardOf(CachedCards(time.Now()), "sub2api", "")
if q == nil || q.Kind != "balance" || len(q.Windows) != 2 || q.Windows[1].Name != "7 days" || q.Windows[1].Used != 90 || q.Name != "Sub2API" || q.ReadAt == nil {
t.Fatalf("after routing's read: %+v", q)
}
if n := asked.Load(); n != 1 {
t.Fatalf("sub2api asked %d times, want 1", n)
}
}
+22 -8
View File
@@ -34,6 +34,9 @@ type keyAllowance struct {
// stale: made stale (StaleKeyAllowance) while a reading was out, which
// was asked before the key said it was out and is followed by another
stale bool
// read is what routing last read of the key, for its card
// (withKeyReadings), which only the Usage page reads
read keyReading
}
// keyAllowanceAge is how long a key's windows as read go unasked again;
@@ -114,9 +117,11 @@ func KeyAllowance(p Provider) (Allowance, bool) {
// fails keeps what was known, and is tried again a minute later: a failed
// read is not known, never empty.
func readKeyAllowance(p Provider, id string, gen int) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ctx, seq := quotaReading(context.Background())
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
ws, err := keyWindows(ctx, p)
r, err := keyWindows(ctx, p)
ws := r.ws
now := time.Now()
c := &keyAllowances
c.Lock()
@@ -127,6 +132,10 @@ func readKeyAllowance(p Provider, id string, gen int) {
}
e.loading, e.at = false, now
renewed := err == nil && e.keep(allowanceOf(ws, now), SpentShareOf(p.Routing), now)
if err == nil {
r.at, r.seq = now, seq
e.read = r
}
if e.stale {
e.stale, e.loading, e.at = false, true, time.Time{}
go readKeyAllowance(p, id, gen)
@@ -151,18 +160,22 @@ func (e *keyAllowance) keep(a Allowance, share float64, now time.Time) (renewed
// is asked (KeyBalances, PlanQuotas): the provider as saved, with the key,
// rather than as a request has it, which leaves out the base URLs of the
// protocols a key isn't made for.
func keyWindows(ctx context.Context, p Provider) ([]QuotaWindow, error) {
func keyWindows(ctx context.Context, p Provider) (keyReading, error) {
q := p
if saved, err := Find(p.ID); err == nil {
q = *saved
q.Key = p.Key
}
var r keyReading
var err error
if src, ok := planQuotaSourceOf(q); ok && !sub2APIKeyLimits(q) {
_, ws, _, err := planKeyWindows(q.Via(ctx), q, src, q.Key)
return ws, err
r.plan, r.ws, _, err = planKeyWindows(q.Via(ctx), q, src, q.Key)
return r, err
}
_, _, ws, _, err := balanceRead(ctx, q)
return ws, err
var parts []BalancePart
r.balance, parts, r.ws, _, err = balanceRead(ctx, q)
r.parts = cardParts(parts)
return r, err
}
// keyCardTag is what p's key's card is kept on disk by (keepLast): its
@@ -230,7 +243,8 @@ func StaleKeyAllowance(p Provider) {
// forgetKeyAllowances has every key's windows read again at its next ask:
// a key or its Balance URL changed (ForgetBalances). What was read stands
// until then — fresher than its card's, which routing's reads never write
// — and a key changed is another key, with windows of its own.
// (they are laid over it only for another magpie, withKeyReadings) — and
// a key changed is another key, with windows of its own.
func forgetKeyAllowances() {
c := &keyAllowances
c.Lock()
+2 -4
View File
@@ -186,8 +186,7 @@ func pluginUsageFetches(via func(string) context.Context, hidden, placed map[str
if len(ls) == 0 {
continue
}
name, icon := pluginCard(pp)
out = append(out, perLogin(via(id), ls, name, icon)...)
out = append(out, perLogin(via(id), ls, pluginCardFace(pp))...)
}
return out
}
@@ -196,8 +195,7 @@ func pluginUsageFetches(via func(string) context.Context, hidden, placed map[str
func pluginUsageFetchesOf(via func(string) context.Context, id string) []func() SubscriptionQuota {
for _, pp := range plugin.Cached() {
if pp.ID == id {
name, icon := pluginCard(pp)
return perLogin(via(id), pluginUsageLogins(pp), name, icon)
return perLogin(via(id), pluginUsageLogins(pp), pluginCardFace(pp))
}
}
return nil
+27 -5
View File
@@ -21,7 +21,8 @@ import (
// remote's ("office/codex"), so they never meet this computer's own.
//
// Only the magpie holding the sign-ins asks the vendors. It answers
// RemoteCardsPath from what it has kept (CachedCards) and asks nobody;
// RemoteCardsPath from what it has kept (CachedCards: its page's cards and
// what was read behind the requests since, cards_kept.go) and asks nobody;
// a card's refresh here asks it to read that card again
// (RemoteRefreshPath), which is the one time a vendor is asked for us.
@@ -49,15 +50,16 @@ const errRemoteNoShare = "remote magpie doesn't share its quotas; update magpie
// each with its kind, for another magpie to show: no vendor is asked,
// whatever their age, and another magpie's cards shown here aren't
// passed on.
//
// The cards are the page's with what was read of the same accounts and
// keys behind it since (cards_kept.go): a magpie serving only other
// magpies has nobody on its page (#1313).
func CachedCards(now time.Time) []SubscriptionQuota {
c := &subscriptionUsageCache
c.Lock()
data := c.data
c.Unlock()
subs := []SubscriptionQuota{}
if data != nil {
subs = withDailyCredits(visibleQuotas(data), now)
}
subs := withDailyCredits(visibleQuotas(withAccountReadings(data)), now)
p := &planQuotaCache
p.Lock()
plans := slices.Clone(p.data)
@@ -66,6 +68,17 @@ func CachedCards(now time.Time) []SubscriptionQuota {
b.Lock()
balances := slices.Clone(b.data)
b.Unlock()
ps := keyCardProviders()
if plans == nil || balances == nil {
keptPlans, keptBalances := keptKeyCards(ps)
if plans == nil {
plans = keptPlans
}
if balances == nil {
balances = keptBalances
}
}
plans, balances = withKeyReadings(plans, ps, "plan"), withKeyReadings(balances, ps, "balance")
out := []SubscriptionQuota{}
for _, g := range []struct {
kind string
@@ -110,6 +123,15 @@ func ForgetRemoteCardsForTest() {
c.Unlock()
}
// ForgetKeptCardsForTest has the cards kept on disk (quotas.json) read
// again from the home a test has set: they are read once per process.
func ForgetKeptCardsForTest() {
c := &lastQuotas
c.Lock()
c.m, c.loaded = nil, false
c.Unlock()
}
// remoteMagpies are the remote magpies this magpie uses.
func remoteMagpies() []Provider {
var out []Provider
+15 -15
View File
@@ -349,7 +349,7 @@ func fetchSubscriptionUsage(ctx context.Context) []SubscriptionQuota {
// signed out, Claude Code's own allowance is none: the account in
// its place is a saved one, read as the others are
if ls := accountsOf("claude"); len(ls) > 1 || p.Account.standIn {
fetches = append(fetches, perLogin(via("claude"), ls, "Claude Code", "claude-color")...)
fetches = append(fetches, perLogin(via("claude"), ls, accountCard("claude"))...)
} else {
fetches = append(fetches, withUser(ctx, p.Account.User, func() SubscriptionQuota {
return claudeSubscriptionUsage(viaLogin("claude", p.Account.User), p.Account.User)
@@ -365,7 +365,7 @@ func fetchSubscriptionUsage(ctx context.Context) []SubscriptionQuota {
if home, err := os.UserHomeDir(); err == nil {
if p, ok := codexAccount(home); ok && !hidden["codex"] {
if ls := accountsOf("codex"); len(ls) > 1 {
fetches = append(fetches, perLogin(via("codex"), ls, "Codex", "codex-color")...)
fetches = append(fetches, perLogin(via("codex"), ls, accountCard("codex"))...)
} else {
auth := filepath.Join(home, ".codex", "auth.json")
fetches = append(fetches, withUser(ctx, p.Account.User, func() SubscriptionQuota { return codexSubscriptionUsage(viaLogin("codex", p.Account.User), auth) }))
@@ -379,43 +379,43 @@ func fetchSubscriptionUsage(ctx context.Context) []SubscriptionQuota {
// gateway's GET /v1/magpie/quotas, however fresh its reading was
// (subscription_usage_test.go, TestCopilotQuotaWithoutEditorsSignIn).
if ls := copilotLoginList(); len(ls) > 0 && !hidden["copilot"] {
fetches = append(fetches, perLogin(via("copilot"), ls, "Copilot", "githubcopilot")...)
fetches = append(fetches, perLogin(via("copilot"), ls, accountCard("copilot"))...)
}
}
if moved("kiro") {
} else if key := kiroKey(); key != "" && !hidden["kiro"] {
fetches = append(fetches, func() SubscriptionQuota { return keepReading(ctx, readNow(kiroQuotaAt(via("kiro"), key, "")), "") })
} else if !hidden["kiro"] {
fetches = append(fetches, perLogin(via("kiro"), kiroLoginList(), "Kiro", "kiro-color")...)
fetches = append(fetches, perLogin(via("kiro"), kiroLoginList(), accountCard("kiro"))...)
}
if !moved("zcode") && !hidden["zcode"] {
fetches = append(fetches, perLogin(via("zcode"), zcodeLoginList(), "ZCode", "zcode")...)
fetches = append(fetches, perLogin(via("zcode"), zcodeLoginList(), accountCard("zcode"))...)
}
for _, w := range []*wbSite{wbCN, wbAI} {
if !moved(w.id) && !hidden[w.id] {
fetches = append(fetches, perLogin(via(w.id), wbLoginList(w), w.name, "workbuddy-color")...)
fetches = append(fetches, perLogin(via(w.id), wbLoginList(w), accountCard(w.id))...)
}
}
if !moved(CommandCodePlanID) && !hidden[CommandCodePlanID] {
fetches = append(fetches, perLogin(via(CommandCodePlanID), cmdLoginList(), "Command Code", "commandcode")...)
fetches = append(fetches, perLogin(via(CommandCodePlanID), cmdLoginList(), accountCard(CommandCodePlanID))...)
}
if !moved("qoder") && !hidden["qoder"] {
fetches = append(fetches, perLogin(via("qoder"), loginsOf(qoderLogins()), "Qoder", "qoder")...)
fetches = append(fetches, perLogin(via("qoder"), loginsOf(qoderLogins()), accountCard("qoder"))...)
}
if !moved(QoderCNID) && !hidden[QoderCNID] {
fetches = append(fetches, perLogin(via(QoderCNID), loginsOf(qoderLoginsOf(QoderCNID)), "Qoder CN", "qoder")...)
fetches = append(fetches, perLogin(via(QoderCNID), loginsOf(qoderLoginsOf(QoderCNID)), accountCard(QoderCNID))...)
}
if !moved("zed") && !hidden["zed"] {
fetches = append(fetches, perLogin(via("zed"), zedLoginList(), "Zed", "zed")...)
fetches = append(fetches, perLogin(via("zed"), zedLoginList(), accountCard("zed"))...)
}
if !moved("devin") && !hidden["devin"] {
fetches = append(fetches, perLogin(via("devin"), devinLoginList(), "Devin", "devin")...)
fetches = append(fetches, perLogin(via("devin"), devinLoginList(), accountCard("devin"))...)
}
if !moved("factory") && !hidden["factory"] {
fetches = append(fetches, perLogin(via("factory"), factoryLoginList(), "Factory", "factory")...)
fetches = append(fetches, perLogin(via("factory"), factoryLoginList(), accountCard("factory"))...)
}
if !moved(MiMoID) && !hidden[MiMoID] {
fetches = append(fetches, perLogin(via(MiMoID), mimoLoginList(), "Xiaomi MiMo", "mimocode")...)
fetches = append(fetches, perLogin(via(MiMoID), mimoLoginList(), accountCard(MiMoID))...)
}
for _, agent := range []string{"gemini", "antigravity"} {
if hidden[agent] {
@@ -465,12 +465,12 @@ func withUser(ctx context.Context, user string, f func() SubscriptionQuota) func
// perLogin fetches each account's allowance on a card of its own: the
// reading LoginUsage shows beside the account (loginReading).
func perLogin(ctx context.Context, ls []Login, name, icon string) []func() SubscriptionQuota {
func perLogin(ctx context.Context, ls []Login, face cardFace) []func() SubscriptionQuota {
var out []func() SubscriptionQuota
for _, l := range refreshLogins(ctx, ls) {
out = append(out, func() SubscriptionQuota {
q := loginReading(ctx, l).read
q.Name, q.Icon, q.User = name, icon, l.User
q.Name, q.Icon, q.User = face.name, face.icon, l.User
return q
})
}