From 56cfb8fbba07f45b22c8eca5e6b9263a34de0db6 Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 23:21:24 +0800 Subject: [PATCH] =?UTF-8?q?gateway,=20provider:=20a=20subscription=20refus?= =?UTF-8?q?ed=20for=20a=20pool=20of=20its=20allowance=20that=20counts=20so?= =?UTF-8?q?me=20models=20only=20rests=20for=20that=20model,=20not=20the=20?= =?UTF-8?q?account=20(Xiaopodev=20on=20X)=20=E2=80=94=20Cursor's=20Other?= =?UTF-8?q?=20Models=20used=20up=20kept=20Auto=20and=20Composer,=20still?= =?UTF-8?q?=20in=20Cursor=20Models,=20off=20the=20account=20too:=20the=20r?= =?UTF-8?q?efusal=20rested=20the=20whole=20account=20by=20its=20restKey.?= =?UTF-8?q?=20Allowance.Pooled=20says=20a=20refusal=20is=20the=20model's?= =?UTF-8?q?=20alone=20when=20the=20windows=20full=20for=20it=20each=20coun?= =?UTF-8?q?t=20some=20models=20only=20(a=20plugin's=20models/notModels=20p?= =?UTF-8?q?ool,=20Opus's=20own=20week),=20or=20none=20known=20full,=20only?= =?UTF-8?q?=20pools=20count=20it;=20restAfter=20then=20rests=20restID=20(a?= =?UTF-8?q?ccount/model),=20which=20every=20rest=20check=20already=20looks?= =?UTF-8?q?=20at=20beside=20restKey.=20Tested=20with=20the=20owner's=20rea?= =?UTF-8?q?l=20Cursor=20account=20in=20a=20sandbox=20HOME:=20its=20real=20?= =?UTF-8?q?pools=20read=20Auto=20and=20Composer=20in=20Cursor=20Models,=20?= =?UTF-8?q?Opus=20and=20GPT=20in=20Other=20Models;=20after=20an=20Other=20?= =?UTF-8?q?Models=20refusal=20for=20claude-opus-5-5=20only=20that=20model?= =?UTF-8?q?=20rested,=20and=20cursor/auto=20and=20cursor/composer-2.5=20an?= =?UTF-8?q?swered=20200=20through=20the=20account.=20TestAllowancePooled?= =?UTF-8?q?=20and=20TestPoolRefusalRestsTheModelOnly=20are=20new;=20the=20?= =?UTF-8?q?latter=20fails=20without=20the=20change.=20go=20vet,=20go=20bui?= =?UTF-8?q?ld=20(darwin,=20GOOS=3Dlinux=20nogui,=20GOOS=3Dwindows)=20and?= =?UTF-8?q?=20go=20test=20-tags=20nogui=20./...=20under=20a=20temp=20HOME?= =?UTF-8?q?=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/fallback.go | 17 +++++-- internal/gateway/pool_rest_test.go | 59 ++++++++++++++++++++++ internal/gateway/routing.go | 7 +++ internal/provider/allowance_pooled_test.go | 50 ++++++++++++++++++ internal/provider/routing.go | 27 ++++++++++ 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 internal/gateway/pool_rest_test.go create mode 100644 internal/provider/allowance_pooled_test.go diff --git a/internal/gateway/fallback.go b/internal/gateway/fallback.go index 3b68f92d..2a734a77 100644 --- a/internal/gateway/fallback.go +++ b/internal/gateway/fallback.go @@ -94,15 +94,26 @@ func (c candidate) isOpenRouterFree() bool { return c.p.Preset == "openrouter" && strings.HasSuffix(c.model, ":free") } -// restID is a free OpenRouter model's own rest key. A rate limit on that -// model is its free-tier limit, while an account-level rest stays on restKey. +// restID is the candidate's model's own rest key: a free OpenRouter +// model's, whose rate limit is its free-tier limit, and a subscription's, +// out of a pool of its allowance that counts some models only (pooled). +// An account-level rest stays on restKey. func (c candidate) restID() string { - if c.isOpenRouterFree() { + if c.isOpenRouterFree() || c.p.Account != nil { return c.restKey() + "/" + c.model } return c.restKey() } +// pooled says whether the candidate's subscription, refused for its +// allowance, is out for its model alone (provider.Allowance.Pooled). +func (c candidate) pooled(now time.Time) bool { + if c.p.Account == nil { + return false + } + return allowances(c.p.Account.UsageAgent())[c.p.Account.User].Pooled(c.model, provider.SpentShareOf(c.p.Routing), now) +} + // who is the key or account itself, however many the provider has on: a // provider's one key rests as the provider, and as itself once another // is added, and a conversation it answered stays with it all the same. diff --git a/internal/gateway/pool_rest_test.go b/internal/gateway/pool_rest_test.go new file mode 100644 index 00000000..5a0d39c7 --- /dev/null +++ b/internal/gateway/pool_rest_test.go @@ -0,0 +1,59 @@ +package gateway + +import ( + "net/http" + "testing" + "time" + + "github.com/yetone/magpie/internal/provider" +) + +// A subscription refused for a pool of its allowance that counts some +// models only rests for that model alone: the account still takes the +// models the pool doesn't count (Xiaopodev on X: Cursor's Other Models +// used up kept Auto off the account too). One of the whole account's +// windows used up rests the account. +func TestPoolRefusalRestsTheModelOnly(t *testing.T) { + old := allowances + defer func() { allowances = old }() + fresh(t) + now := time.Now() + week := now.Add(39 * time.Hour) + allowances = func(string) map[string]provider.Allowance { + return map[string]provider.Allowance{ + "me@x.com": { + {Used: 100, Resets: week, Span: 7 * 24 * time.Hour, Model: "opus"}, + {Used: 40, Resets: week, Span: 7 * 24 * time.Hour}, + }, + "out@x.com": {{Used: 100, Resets: week, Span: 7 * 24 * time.Hour}}, + } + } + acct := func(user, model string) candidate { + return candidate{p: provider.Provider{ID: "claude", Account: &provider.Account{Agent: "claude", User: user}}, model: model, rest: "claude"} + } + s := &Server{} + refused := []byte(`{"error":{"message":"usage limit reached for this model"}}`) + + opus, sonnet := acct("me@x.com", "claude-opus-4-7"), acct("me@x.com", "claude-sonnet-4-6") + r := s.restAfter(opus, 429, http.Header{}, refused) + if r.Why != failQuota || r.Key != opus.restID() || r.By != "window" { + t.Fatalf("Opus out of its week rests %+v", r) + } + if _, ok := restOf(sonnet.restKey()); ok { + t.Fatal("Opus out of its own week rests the account") + } + if _, ok := restOf(sonnet.restID()); ok { + t.Fatal("Opus out of its own week rests Sonnet") + } + if !spentAfter([]candidate{opus}) || spentAfter([]candidate{sonnet}) { + t.Fatal("spentAfter doesn't go by the model's rest") + } + + whole := acct("out@x.com", "claude-sonnet-4-6") + if r := s.restAfter(whole, 429, http.Header{}, refused); r.Key != whole.restKey() { + t.Fatalf("the account's week used up rests %s, not the account", r.Key) + } + if _, ok := restOf(acct("out@x.com", "claude-opus-4-7").restKey()); !ok { + t.Fatal("the account's week used up leaves its other models in") + } +} diff --git a/internal/gateway/routing.go b/internal/gateway/routing.go index 6ebff614..44ee919b 100644 --- a/internal/gateway/routing.go +++ b/internal/gateway/routing.go @@ -332,6 +332,10 @@ func (s *Server) restAfterMarked(c candidate, status int, header http.Header, bo now := time.Now() d := fallbackCooldown why := failureOf(c, status, body) + // read before the allowance is asked again below: out of a pool that + // counts some models only, the account's others are still in theirs + // (Cursor's Auto with Other Models used up) + pooled := why == failQuota && c.pooled(now) r := Rest{Why: why, Status: status, By: "cooldown"} if why == failProxy { // the account is as good as it was; the proxy is the user's to start @@ -402,6 +406,9 @@ func (s *Server) restAfterMarked(c candidate, status int, header http.Header, bo if why == failRate && c.isOpenRouterFree() && sharedPool { id = c.restID() } + if pooled { + id = c.restID() + } r.Key = id restingUntil.Lock() restingUntil.m[id] = r.Until diff --git a/internal/provider/allowance_pooled_test.go b/internal/provider/allowance_pooled_test.go new file mode 100644 index 00000000..895ff074 --- /dev/null +++ b/internal/provider/allowance_pooled_test.go @@ -0,0 +1,50 @@ +package provider + +import ( + "testing" + "time" + + "github.com/yetone/magpie/internal/plugin" +) + +// A refusal for a model counted by a pool of some models only is that +// model's, not the account's: Cursor's Other Models used up leaves Auto in +// Cursor Models (Xiaopodev on X), Opus's own week leaves Sonnet; one of +// the whole account's windows used up is the account's. +func TestAllowancePooled(t *testing.T) { + now := time.Now() + month := now.Add(20 * 24 * time.Hour) + pool := map[string]bool{"auto": true, "composer-2": true} + q := quotaOfPlugin(SubscriptionQuota{}, plugin.Usage{Windows: []plugin.UsageWindow{ + {Name: "Cursor Models", Used: 12, Models: []string{"auto", "composer-2"}}, + {Name: "Other Models", Used: 100, NotModels: []string{"auto", "composer-2"}}, + {Name: "Total", Used: 60, Aside: true}, + }}) + cursor := allowanceOf(q.Windows, now) + for _, m := range []string{"auto", "composer-2", "claude-4.6-sonnet", "gpt-5.5"} { + if !cursor.Pooled(m, 100, now) { + t.Errorf("Cursor: %s refused isn't the model's alone", m) + } + if used, _ := cursor.For(m, now); (used == 100) == pool[m] { + t.Errorf("Cursor: %s used %v", m, used) + } + } + + claude := Allowance{ + {Used: 100, Resets: month, Span: 7 * 24 * time.Hour, Model: "opus"}, + {Used: 40, Resets: month, Span: 7 * 24 * time.Hour}, + } + if !claude.Pooled("claude-opus-4-7", 100, now) { + t.Error("Opus's own week used up rests the account") + } + if claude.Pooled("claude-sonnet-4-6", 100, now) { + t.Error("Sonnet, refused with no pool of its full, counts as pooled") + } + claude[1].Used = 100 + if claude.Pooled("claude-opus-4-7", 100, now) { + t.Error("the account's week used up rests only Opus") + } + if (Allowance{}).Pooled("auto", 100, now) { + t.Error("an allowance not known counts as pooled") + } +} diff --git a/internal/provider/routing.go b/internal/provider/routing.go index f8f734c3..64d58db1 100644 --- a/internal/provider/routing.go +++ b/internal/provider/routing.go @@ -265,6 +265,33 @@ func (a Allowance) Full(model string, share float64, now time.Time) time.Time { return t } +// Pooled says whether a refusal of model for its allowance leaves the +// account's other models alone: the windows that count model and are full +// at share are each of some models only (Opus's own week, Cursor's Other +// Models pool) — or, none known full, no window of the whole account +// counts it, only pools. Then it is that model which is out, not the +// account: Cursor's Other Models used up leaves Auto and Composer in +// theirs (Xiaopodev on X). +func (a Allowance) Pooled(model string, share float64, now time.Time) bool { + model = strings.ToLower(model) + full, pooled, whole := false, false, false + for _, l := range a { + if !l.applies(model) { + continue + } + scoped := l.Model != "" || l.matches != nil + if l.Used >= share && (l.Resets.IsZero() || l.Resets.After(now)) { + if !scoped { + return false + } + full = true + } + pooled = pooled || scoped + whole = whole || !scoped + } + return full || pooled && !whole +} + // budgetSpan is the shortest window that is a budget rather than a rate // cap: the week (Kiro's month too), not the five hours in it — what the // five hours leave at their reset is nothing lost.