diff --git a/account_cap_cli_test.go b/account_cap_cli_test.go index 03b59cc9..ca212654 100644 --- a/account_cap_cli_test.go +++ b/account_cap_cli_test.go @@ -65,6 +65,35 @@ func TestAccountCapCmd(t *testing.T) { if c := provider.AccountCapOf("codex", "me@example.com"); c != 0 { t.Fatalf("off left %d", c) } + // a window's own (willz on Discord): the five hours at 50%, the week + // with none, then back to the account's + if err := providerCmd([]string{"provider", "account-cap", "codex", "me@example.com", "--window", "5 Hours", "50%"}); err != nil { + t.Fatal(err) + } + if err := providerCmd([]string{"provider", "account-cap", "codex", "me@example.com", "--window=Weekly", "none"}); err != nil { + t.Fatal(err) + } + c := provider.AccountCapsOf("codex", "me@example.com") + if five, _ := c.Of("5 hours"); five != 50 || c.Windows["weekly"] != 100 || c.All != 0 { + t.Fatalf("window caps %+v", c) + } + if err := providerCmd([]string{"provider", "account-cap", "codex", "me@example.com", "--window", "5 hours"}); err != nil { + t.Fatal(err) // shown + } + if err := providerCmd([]string{"provider", "account-cap", "codex", "me@example.com", "--window", "5 hours", "lots"}); err == nil { + t.Fatal("a window's share of lots taken") + } + if err := providerCmd([]string{"provider", "account-cap", "codex", "--window", "5 hours"}); err == nil { + t.Fatal("a window with no account taken") + } + for _, w := range []string{"5 hours", "weekly"} { + if err := providerCmd([]string{"provider", "account-cap", "codex", "me@example.com", "--window", w, "default"}); err != nil { + t.Fatal(err) + } + } + if c := provider.AccountCapsOf("codex", "me@example.com"); c.Windows != nil { + t.Fatalf("default left %+v", c) + } if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k1", Chat: "http://127.0.0.1:1/v1"}); err != nil { t.Fatal(err) } diff --git a/docs/reference.md b/docs/reference.md index d1674ab8..ab6d2356 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1018,6 +1018,10 @@ Codex and most other subscriptions read every account from the vendor this way. A Codex account is also known from each reply ChatGPT sends magpie for it, which says what the account has used: an account near its usage cap is held from the next turn on (#1295). A +usage cap is set on the account's row for each of its windows, and a +window can have one of its own, set from its meter: a five-hour window at +50% and a weekly one at 40%, or no cap on one window. The account is held +while any window is past its own. A usage cap is still a stop on what magpie has read, not a guarantee: a turn already under way can take an account past it, so a 99% cap doesn't promise 1% is left. Claude is different: magpie never asks diff --git a/docs/subsystems/gateway-routing.md b/docs/subsystems/gateway-routing.md index ad70b86d..0f1288a8 100644 --- a/docs/subsystems/gateway-routing.md +++ b/docs/subsystems/gateway-routing.md @@ -35,7 +35,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md). - A gateway key held to some models (`keyHolds`, #882) gets a 403 for any other model. - Sealed tasks (#619) are checked here. A Codex subagent's task its lead's `spawn_agent` sealed goes only to a ChatGPT account, or back to the Responses provider that answered the lead (`leadProvider`, a relay of the ChatGPT backend such as Sub2API, #1109), relayed as it came even when web search is offered. A lead's turn carrying its subagent's sealed reply (`send_message`) goes back to the Responses provider that answered the lead's own conversation (`leadProvider` of the request's `conversationID`, #1237); otherwise it is refused before anyone is asked (`sealedReader`, `sealedTaskError`). Who answered is first what affinity remembers; when affinity has let it go (a day since, a restart past the 512 conversations `affinity.json` keeps, or the lead's last reply broken off, `unanswered`), the usage log's answers to the lead's thread and the request's own conversation in the last 30 days count too (`loggedAnswers`, #1367), and `leadFirst` reads the lead's key or account from them. A refused or failed call and a title answer nothing, and Codex's own sign-in is told of as the ChatGPT backend. The refusal names who sealed the task when it is known, and says how to go on with it. - Rules and image inputs are handled here too. -3. Candidates come from `planGroup` for a group or `plan` for a provider. They are filtered by `sealedReaders` and `allowedCandidates`. An account past its share is held out of the candidates (`capHeld`): a usage cap's share, or 100% of a window for a Codex account set not to spend its credits (`provider.HoldShare`, `CodexNoCredits`). When every candidate is held and one held for its credits spends its resets by itself, `autoReset` spends a reset on it and it is tried (`planned.held`); otherwise a capped or barred set returns `cappedError` or `barredError`. Then `affine` moves the conversation's last answerer first, unless it is resting or nearly used up. An `X-Magpie-Account` header (`AccountHeader`) pins the request to one account: only that account is tried, and when it can't answer the caller is told why. +3. Candidates come from `planGroup` for a group or `plan` for a provider. They are filtered by `sealedReaders` and `allowedCandidates`. An account past its share is held out of the candidates (`capHeld`): a usage cap's share, each window at its own where the account gives it one (`AccountWindowCaps`, willz on Discord: 50% of the five hours, 40% of the week) and at the account's cap otherwise, or 100% of a window for a Codex account set not to spend its credits (`provider.HoldCaps`, `WindowCaps.Of`, `CodexNoCredits`). The account is held while any window is past its own share, and `cappedError` and the trace name the fullest such window's share and cap. When every candidate is held and one held for its credits spends its resets by itself, `autoReset` spends a reset on it and it is tried (`planned.held`); otherwise a capped or barred set returns `cappedError` or `barredError`. Then `affine` moves the conversation's last answerer first, unless it is resting or nearly used up. An `X-Magpie-Account` header (`AccountHeader`) pins the request to one account: only that account is tried, and when it can't answer the caller is told why. 4. The candidate loop calls `attempt` on each candidate in turn, through a `holdWriter`. The writer holds the reply until it is clearly passing. A candidate that fails before anything is sent rests (`failure`): out of credit for 30 minutes, out of quota until reset, rate limited until retry-after, otherwise a minute and longer on repeats. A 400, 403, 404 or 422 that says the model isn't served (`unservedWords`) and says nothing of quota, credit or a rate limit rests only that key's or account's model (`modelRefused`, keyed by `restID`, its backoff counted apart): its other models are still served first in order (#1235, SenseNova's 403 "model is not available in the current token plan"). A subscription account that fails any other way with a window full, by magpie's latest reading of its windows, rests until that window renews. Both quota rests and window rests are held to `longestQuota` (8 days), so a reset time read wrong (a year off) doesn't bench the account for that long. The next candidate is then tried. A failed provider waits `fallbackCooldown` (1 minute) at the back. The vendor's safety filter refusing a reply rests nobody, whether it refuses the turn before any of the reply is said or once some of it has been said (`refused`, `refusedAfter`). The ChatGPT backend's 502 "response protection is unavailable" is about the request's content, not the account: the same history fails the same on each of its accounts and models. It rests nobody, isn't tried again after a moment, and isn't asked of the provider's other accounts or models; another provider left in a group is asked once (`protectionRefused`, `elsewhere`). 5. The last candidate's error reaches the agent. A try that answered is recorded (`served`, `answered`) for routing, affinity and usage. @@ -65,7 +65,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md). ## Verification ```sh -go test -tags nogui ./internal/gateway -run 'TestQuiet|TestSilentHeld|TestRotateSpreadsSessions|TestRouting|TestSmartRouting|TestFallback|TestNoFallbackForOtherErrors|TestLastFallbackErrorReachesTheAgent|TestRateLimitedSinksToTheBack|TestGroupRateLimitedSinks|TestAffinity|TestSeveralKeysOnTakeOverFromEachOther|TestModelNotInPlanRestsTheModelNotTheKey|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool|TestCodexNoCredits|TestClaudeGoesOnMessagesWhereTheRelayHasThem|TestAutoRelaysOnTheClientsOwnAPI|TestSearchOfferedStaysOnTheClientsAPI|TestCodexSearchAnsweredOnResponses|TestAgentEffortOnCursorLocal|TestAzureCompactionWithoutMagpiesReasoning|TestCodexCompact|TestProtection|TestNotAnAPIReply|TestAPIReplyUntouched|TestCodexBackendWebPage|TestSub2APIKeyLimitIsQuota|TestKeysWeighedByTheirWindows|TestGroupWeighsKeyWindows|TestKeyOutOfItsWindowRestsUntilReset|TestKeyPoolErrorDoesNotRestForWindow|TestKeyBackOnceItsWindowIsNotFull|TestRestingKeyReadAgainInAnOrderedGroup|TestClaudeAccountBackOnceItsWindowRenews|TestThinkingStreams|TestHeldThinkingKeepsTheAgentAlive|TestRefusalAfterThinkingFailsOver|TestGeminiEmptyRepliesAskedAgain|TestLocalUnredacted|TestCORS|TestForeignPagesRefused|TestGeminiThinkingOffAtItsLowest|TestGeminiThoughtsMarkedInExtraContent|TestClaudeSignInNotToldItsSignInFailed|TestSignInWriterReleasesA403|TestSignInBetaStaysWithMagpie|TestStructuredOutput|TestResponsesTextKeepsVerbosityBesideFormat|TestRefusedFormatToldInWords|RPM|TestQueued|TestCustomGeminiUpstream' +go test -tags nogui ./internal/gateway -run 'TestQuiet|TestSilentHeld|TestRotateSpreadsSessions|TestRouting|TestSmartRouting|TestFallback|TestNoFallbackForOtherErrors|TestLastFallbackErrorReachesTheAgent|TestRateLimitedSinksToTheBack|TestGroupRateLimitedSinks|TestAffinity|TestSeveralKeysOnTakeOverFromEachOther|TestModelNotInPlanRestsTheModelNotTheKey|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool|TestCodexNoCredits|TestWindowCapsHoldTheAccount|TestAccountCap|TestClaudeGoesOnMessagesWhereTheRelayHasThem|TestAutoRelaysOnTheClientsOwnAPI|TestSearchOfferedStaysOnTheClientsAPI|TestCodexSearchAnsweredOnResponses|TestAgentEffortOnCursorLocal|TestAzureCompactionWithoutMagpiesReasoning|TestCodexCompact|TestProtection|TestNotAnAPIReply|TestAPIReplyUntouched|TestCodexBackendWebPage|TestSub2APIKeyLimitIsQuota|TestKeysWeighedByTheirWindows|TestGroupWeighsKeyWindows|TestKeyOutOfItsWindowRestsUntilReset|TestKeyPoolErrorDoesNotRestForWindow|TestKeyBackOnceItsWindowIsNotFull|TestRestingKeyReadAgainInAnOrderedGroup|TestClaudeAccountBackOnceItsWindowRenews|TestThinkingStreams|TestHeldThinkingKeepsTheAgentAlive|TestRefusalAfterThinkingFailsOver|TestGeminiEmptyRepliesAskedAgain|TestLocalUnredacted|TestCORS|TestForeignPagesRefused|TestGeminiThinkingOffAtItsLowest|TestGeminiThoughtsMarkedInExtraContent|TestClaudeSignInNotToldItsSignInFailed|TestSignInWriterReleasesA403|TestSignInBetaStaysWithMagpie|TestStructuredOutput|TestResponsesTextKeepsVerbosityBesideFormat|TestRefusedFormatToldInWords|RPM|TestQueued|TestCustomGeminiUpstream' go test -tags nogui ./internal/provider -run 'TestGroup|TestCodexCreditsSwitch|TestRenewedAccountForgetsItsAllowance|TestNative|TestKeyAllowance|TestPlanKeyAllowance|TestClaudeAccountToldRenewed|TestPluginAccountToldRenewed|TestSkipsRedaction|TestSetRPM' ``` diff --git a/docs/subsystems/providers-accounts.md b/docs/subsystems/providers-accounts.md index bd384703..a397afb1 100644 --- a/docs/subsystems/providers-accounts.md +++ b/docs/subsystems/providers-accounts.md @@ -58,12 +58,16 @@ keys and accounts per request is in [Gateway routing and fallback](gateway-routi - A gateway key held to some accounts (#905) is held to those of the providers its list names: a provider it names no account of, the key uses as it always did. The list keeps an account by its stable id — `logins.json`'s `id`, set once written and kept through renames; the agents that keep no logins (and plugins) are known by a hash of the account's name, so an account renamed there drops out of a key's list, holding it closer, never wider. Storing an id for every agent is a larger change, not this one's. - A sub2api key's windows are fixed: each starts at the key's first request after it was empty, aligned to the relay's day, and a window not started yet says `used 0` with no `reset_at`, so it is taken to renew a span from now. They count this key's spend only, not the relay's accounts behind it. `ForgetBalances` (a key or Balance URL changed) has every key's windows read again at its next ask, a read begun before dropped; until then the last reading stands, fresher than the card's, which routing's reads never write. A key changed is another key (`keyID`), with windows of its own. - Whether a Codex account spends its credits (`CodexNoCredits` in settings, opt-out, by lower-cased email) is the gateway's routing only. It never changes which account Codex is signed in to: `NextLogin` moves off an account at its routing's spent share, 100% at most, credits or not. +- A usage cap is kept per account (`AccountCaps`, 1–99) and, beside it, per window (`AccountWindowCaps`, by the account and the window's `capId`, its name in lower case: `WindowCapID`). A window's own share wins over the account's; 100 is no cap on that window. `CapsOf` gives both (`WindowCaps`), `CapHeld` and `capReached` judge each window by its own, and `SetWindowCap` (GUI `provider/windowcap`, `magpie provider account-cap --window `) sets one. A config without window caps reads as before. - A window whose reset time has passed has started again, whatever share it was read at (`resetPassed`). That holds for a reading kept from before (`AsOf`) and for a fresh one: Claude Code's `/usage` gives a reset to the minute and can lag it, so a run just after a reset can still say 100% with that reset. `claudeWindows` hands such a window back empty on every path, and `usedPast`, `usedUp`, `BackAt` and `capReached` leave it out when they judge an account spent, for Codex, Claude Code and `magpie quota wait` alike. A card can still show a kept reading as it was read (`reported`, for a Claude card while `/usage` is unavailable); that changes what it shows, never which account is used. A reset `/usage` gives as a time alone ("resets 3pm") can be read as its next one, a day on (`claudeResetTime`), and is then not seen as passed here. ## Verification ```sh go test -tags nogui ./internal/provider -run 'TestAccount|TestCodex|TestGroup|TestQuota|TestLogin|TestSignIn|TestSeveralKeys|TestKeyProtocol|TestSetKeyWeight|TestReadSub2APIKeyLimits|TestSub2APIKeyLimitsOnItsCard|TestKeyAllowance|TestPlanKeyAllowance|TestPayAsYouGoKeyAskedSeldom|TestClaudeAccountToldRenewed|TestPluginAccountToldRenewed' +go test -tags nogui ./internal/provider -run 'TestCapHeld|TestCapReached|TestWindowCapHeld|TestSetWindowCap' +go test -tags nogui . -run TestAccountCapCmd +node --test internal/gui/tests/account-cap.test.cjs internal/gui/tests/window-cap.test.cjs go test -tags nogui ./internal/provider -run 'TestVolcengine|TestQianfan|TestBedrock' go test -tags nogui ./internal/provider -run '^TestProviderProbesUseProtocolHeaders$' go test -tags nogui ./internal/provider -run '^Test(Partners|CountPartner|NoticePartners)' diff --git a/internal/gateway/account_cap_test.go b/internal/gateway/account_cap_test.go index b98409d2..104044f0 100644 --- a/internal/gateway/account_cap_test.go +++ b/internal/gateway/account_cap_test.go @@ -132,3 +132,90 @@ func TestAccountCapSpendsNoCodexReset(t *testing.T) { t.Fatalf("cap lifted: %d %s tried %q spent %q", code, body, tried, spent) } } + +// windowUsage stands in for the accounts' windows as last read, named as +// the vendor's reading names them: each user's five hours and week. +func windowUsage(t *testing.T, used map[string][2]float64) { + t.Helper() + old := allowances + t.Cleanup(func() { allowances = old }) + five, week := time.Now().Add(3*time.Hour), time.Now().Add(4*24*time.Hour) + allowances = func(string) map[string]provider.Allowance { + out := map[string]provider.Allowance{} + for u, n := range used { + out[u] = provider.Allowance{ + {Name: "5 hours", Used: n[0], Resets: five, Span: 5 * time.Hour}, + {Name: "Weekly", Used: n[1], Resets: week, Span: 7 * 24 * time.Hour}, + } + } + return out + } +} + +// Each window can have a cap of its own (willz on Discord): a friend's +// account stops when its five hours reach 50%, while its week may go to +// 40%. At 60% of its five hours it is held, though its week is low, and +// the request goes to the next account; at 20% and 30% it is under both +// and takes the request. The other account, with one cap for every window +// as before, is held by that one alone. +func TestWindowCapsHoldTheAccount(t *testing.T) { + codexSignedIn(t, "spare@example.com") + b := newResetBackend(t) + if err := provider.SetRouting("codex", provider.Ordered); err != nil { + t.Fatal(err) + } + for w, c := range map[string]int{"5 hours": 50, "Weekly": 40} { + if err := provider.SetWindowCap("codex", "me@example.com", w, c); err != nil { + t.Fatal(err) + } + } + if err := provider.SetAccountCap("codex", "spare@example.com", 10); err != nil { + t.Fatal(err) + } + try := func(used map[string][2]float64) (int, string, string, *Server) { + t.Helper() + windowUsage(t, used) + b.mu.Lock() + b.tried = nil + b.mu.Unlock() + srv := New() + code, body := resetPost(t, srv) + tried, _ := b.seen() + return code, body, tried, srv + } + + // five hours at 60% of its 50%: held, the spare (5% of its 10%) answers + code, body, tried, srv := try(map[string][2]float64{"me@example.com": {60, 10}, "spare@example.com": {5, 5}}) + if code != 200 || tried != "acct-2" { + t.Fatalf("5 hours past its own cap: %d %s tried %s", code, body, tried) + } + var left []string + for _, r := range srv.Trace(t.Context(), 0, 0).Routes { + for _, w := range r.Left { + if w.Capped > 0 && !w.Barred { + left = append(left, fmt.Sprintf("%s %g/%d", w.Who, w.Used, w.Capped)) + } + } + } + if len(left) != 1 || left[0] != "me@example.com 60/50" { + t.Fatalf("traced as capped: %v", left) + } + + // five hours at 20% of 50%, week at 30% of 40%: used, first in order + if code, body, tried, _ := try(map[string][2]float64{"me@example.com": {20, 30}, "spare@example.com": {5, 5}}); code != 200 || tried != "acct-1" { + t.Fatalf("under both its windows' caps: %d %s tried %s", code, body, tried) + } + + // week at 45%, past its own 40% though under the five hours' 50%; the + // spare's week at 12%, past its one cap of 10%: both held, refused, + // each named with the cap of the window that holds it + code, body, tried, _ = try(map[string][2]float64{"me@example.com": {20, 45}, "spare@example.com": {5, 12}}) + if code != 429 || tried != "" { + t.Fatalf("both held: %d tried %q", code, tried) + } + for _, want := range []string{"usage cap reached", "(me@example.com) is at 45%", "past its 40% cap", "spare@example.com", "past its 10% cap"} { + if !strings.Contains(body, want) { + t.Errorf("no %q in %s", want, body) + } + } +} diff --git a/internal/gateway/codex_backend.go b/internal/gateway/codex_backend.go index 9d113f85..8e7ebc7a 100644 --- a/internal/gateway/codex_backend.go +++ b/internal/gateway/codex_backend.go @@ -449,7 +449,7 @@ func codexAccounts(r *http.Request, model string) (string, bool) { // through routing, which holds it there, even alone: relayed as it // came, nothing would if ok && p.Account != nil && p.Account.Agent == "codex" { - if share, _ := provider.HoldShare(p, "codex", p.Account.User); share > 0 { + if provider.HoldCaps(p, "codex", p.Account.User).Holds() { return id, true } } diff --git a/internal/gateway/fallback.go b/internal/gateway/fallback.go index f68f7dfa..0828875b 100644 --- a/internal/gateway/fallback.go +++ b/internal/gateway/fallback.go @@ -51,8 +51,8 @@ type candidate struct { capped *capHold } -// capHold is how an account is held at its usage cap: the cap, the share -// of the fullest window at or past it, and when the last such window +// capHold is how an account is held at its usage cap: the cap of the +// fullest window at or past its own cap, that window's share used, and when the last such window // renews (zero when one doesn't say). noCredits: it is a Codex account // held at 100% as it is set not to spend its credits (cap is 100 then). type capHold struct { @@ -491,23 +491,24 @@ func planLevel(g provider.Group, ms []provider.Member, depth int, from provider. } // capHeld is how the account acct of p is held at its usage cap for -// model at now, or at 100% as a Codex account set not to spend its -// credits; nil when nothing holds it or it is below the share. Its windows +// model at now — a window at or past its own cap, else the account's — or +// at 100% as a Codex account set not to spend its credits; nil when +// nothing holds it or every window is below its share. Its windows // are those last read (allowances), as smart routing weighs them. func capHeld(p, acct provider.Provider, model string, now time.Time) *capHold { if acct.Account == nil { return nil } a := acct.Account - share, noCredits := provider.HoldShare(p, a.Agent, a.User) - if share <= 0 { + caps := provider.HoldCaps(p, a.Agent, a.User) + if !caps.Holds() { return nil } - held, used, back := allowances(a.UsageAgent())[a.User].CapHeld(model, share, now) - if !held { + h := allowances(a.UsageAgent())[a.User].CapHeld(model, caps, now) + if h == nil { return nil } - return &capHold{cap: share, used: used, back: back, noCredits: noCredits} + return &capHold{cap: h.Cap, used: h.Used, back: h.Back, noCredits: h.Credits} } // cappedError says why a request for model went nowhere when every account diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 7a4205ad..773abe6e 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -2141,6 +2141,13 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .acc .aq-track.capped { position: relative; overflow: visible; } .acc .aq-track.capped i { overflow: hidden; } .acc .aq-cap { position: absolute; top: -2px; bottom: -2px; width: 2px; margin-left: -1px; border-radius: 1px; background: var(--accent); } +/* a window's meter sets its own cap (willz on Discord): a button that + looks as the meter does, and the window's own cap beside its figure */ +.acc .aq-win { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } +.acc .aq-set { padding: 1px 4px; margin: -1px -4px; border-radius: 4px; cursor: default; } +.acc .aq-set:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +.acc .aq-set:hover, .acc .aq-set.open { background: var(--pill-hover); } +.acc .aq-own { color: var(--accent); font-weight: 500; } .acc .aq-w b { color: var(--fg-2); font-weight: 600; font-variant-numeric: tabular-nums; } .acc .aq-w.full b, .acc .aq-w.full .aq-r { color: var(--fg); } .acc .aq-w.full .aq-track i { background: var(--fg); } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 8271589d..433260fb 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -10933,10 +10933,12 @@ function renderAccounts(a, p) { // and the five hours run to 100%) const direct = l.active && !several && (a.agent === "claude" || a.agent === "codex") ? a.agentName : ""; if (p) row.append(accountCapPill(p, l.user, cap, direct)); + // and each window's own, set on its meter (willz on Discord) + const capFor = (w) => windowCapOf(p, l.user, w); // how many requests it has out at once, and waiting (#892) if (p) row.append(laneLimitPill(p, l.user, p.id + "@" + String(l.user).toLowerCase(), l.user)); - const held = capHeldOf(quota?.[l.user], cap); - if (held) row.append(capHeldNote(held, cap, several, direct)); + const held = capHeldOf(quota?.[l.user], capFor); + if (held) row.append(capHeldNote(held, held.cap, several, direct)); row.append(el("span", "grow")); if (unusable(l)) { row.classList.add("signed-out"); @@ -11011,7 +11013,7 @@ function renderAccounts(a, p) { row.append(forget, use); } } - row.append(accountQuota(l.lapsed ? { [l.user]: { error: t(l.lapsed) } } : quota, l.user, cap)); + row.append(accountQuota(l.lapsed ? { [l.user]: { error: t(l.lapsed) } } : quota, l.user, capFor, p ? (m, w) => windowCapMeter(m, p, l.user, w) : null)); row.classList.add("with-aq"); // not :has(.aq), which Safari 15.0 lacks (#220) if (amBox) row.append(amBox); list.append(row); @@ -11342,7 +11344,7 @@ function balanceFix(p) { // accountQuota: an account's allowance as a line of small meters under its // name, the reset time on the ones nearly used up, credits and held resets. -function accountQuota(data, user, cap = 0) { +function accountQuota(data, user, capFor = () => 0, settable = null) { const line = el("div", "aq"); if (!data) { line.append(el("span", "skeleton sk-aq"), el("span", "skeleton sk-aq")); @@ -11383,7 +11385,7 @@ function accountQuota(data, user, cap = 0) { const counted = q.windows.filter((w) => !w.unlimited); const ws = familyWindows(counted); if (!ws.length) { line.append(el("span", "aq-none", t("Unlimited"))); return line; } - if (ws.some((w) => w.members)) return poolLine(line, ws, q, cap); + if (ws.some((w) => w.members)) return poolLine(line, ws, q, capFor, settable); line.title = ws.slice(2).map((w) => w.tiers ? tiersText(w) : t(w.name) + " " + quotaText(w)).join(ws !== counted ? "\n" : " · "); if (q.asOf) line.title = [line.title, asOfText(q)].filter(Boolean).join("\n"); for (const w of ws.slice(0, 2)) { @@ -11393,7 +11395,7 @@ function accountQuota(data, user, cap = 0) { const fill = el("i"); fill.style.width = quotaFill(w) + "%"; track.append(fill); - capMark(track, w, cap); + capMark(track, w, capFor(w)); m.append(el("span", "aq-n", t(w.name)), track, el("b", "", quotaText(w))); if (w.resetsAt) { const at = new Date(w.resetsAt); @@ -11401,7 +11403,7 @@ function accountQuota(data, user, cap = 0) { if (used >= 80) m.append(el("span", "aq-r", resetInText(at))); } if (w.tiers) m.title = tiersText(w); - line.append(m); + line.append(settable ? settable(m, w) : m); } return line; } @@ -11409,7 +11411,7 @@ function accountQuota(data, user, cap = 0) { // poolLine: an account row's pools, each its name then its 5-hour and // weekly meters (Gemini 5h ▬ 95% 7d ▬ 75%), two pools on the line and the // rest in its tooltip. -function poolLine(line, ws, q, cap = 0) { +function poolLine(line, ws, q, capFor = () => 0, settable = null) { const pools = []; for (const w of ws) { const k = w.members ? w.pool : "\0" + pools.length; @@ -11426,8 +11428,11 @@ function poolLine(line, ws, q, cap = 0) { const fill = el("i"); fill.style.width = quotaFill(w) + "%"; track.append(fill); - capMark(track, w, cap); - m.append(el("span", "aq-k", w.window ? shortWindow(w.window) : ""), track, el("b", "", quotaText(w))); + capMark(track, w, capFor(w)); + // each of a pool's windows is set apart: its own part of the meter + const part = el("span", "aq-win"); + part.append(el("span", "aq-k", w.window ? shortWindow(w.window) : ""), track, el("b", "", quotaText(w))); + m.append(settable ? settable(part, w) : part); } const w = p.ws[0]; m.title = p.ws.map((x) => t(x.name) + " " + quotaText(x) + (x.resetsAt ? " · " + resetText(new Date(x.resetsAt), new Date(x.resetsAt).toLocaleString()) : "")).join("\n") @@ -11445,29 +11450,45 @@ function poolLine(line, ws, q, cap = 0) { // counts shows where it is, and a note says when it holds the account. const CAP_SHARES = [50, 60, 70, 80, 90]; function accountCapOf(p, user) { return p?.accountCaps?.[String(user).toLowerCase()] || 0; } +// A window's own cap (provider.AccountWindowCaps, willz on Discord): a +// friend's account stops at 50% of its five hours while its week may go to +// 40%. Kept by the account and the window's capId, 1–99, or 100 for no cap +// on that window; a window with none of its own takes the account's cap. +// windowOwnCapOf is the window's own, 0 when it follows the account's. +function windowOwnCapOf(p, user, w) { + return (w?.capId && p?.accountWindowCaps?.[String(user).toLowerCase()]?.[w.capId]) || 0; +} +function windowCapsOf(p, user) { return Object.keys(p?.accountWindowCaps?.[String(user).toLowerCase()] || {}).length; } +// windowCapOf: the share the window holds the account at, 0 for none +function windowCapOf(p, user, w) { + const own = windowOwnCapOf(p, user, w); + if (own) return own >= 100 ? 0 : own; + return accountCapOf(p, user); +} // capHeldOf: how the account is held at cap, as the gateway reads it — -// a window the cap counts at or past it, not yet renewed — or null: the -// fullest such window's share, and when the last of them renews (0 when -// one doesn't say). -function capHeldOf(q, cap) { - if (!cap || !q || q.error || !q.windows?.length) return null; +// a window at or past its cap (capFor: its own, else the account's), not +// yet renewed — or null: the fullest such window's share and its cap, and +// when the last of them renews (0 when one doesn't say). +function capHeldOf(q, capFor) { + if (!q || q.error || !q.windows?.length) return null; const now = Date.now(); // a window of some models only (Opus's week, a pool's) holds the account // for those alone (#760): the account's own windows say when it is back // when one of them holds it, else those windows do and are named - const all = { used: 0, back: 0, unknown: false, n: 0 }, some = { used: 0, back: 0, unknown: false, n: 0, names: [] }; + const all = { used: 0, cap: 0, back: 0, unknown: false, n: 0 }, some = { used: 0, cap: 0, back: 0, unknown: false, n: 0, names: [] }; for (const w of q.windows) { - if (!w.capped || w.used < cap) continue; + const cap = w.capped ? capFor(w) : 0; + if (!cap || w.used < cap) continue; const r = w.resetsAt ? Date.parse(w.resetsAt) : 0; if (r && r <= now) continue; // renewed since it was read const h = w.capsSome ? some : all; h.n++; - h.used = Math.max(h.used, w.used); + if (w.used >= h.used) { h.used = w.used; h.cap = cap; } if (w.capsSome && !some.names.includes(w.name)) some.names.push(w.name); if (!r) h.unknown = true; else h.back = Math.max(h.back, r); } const h = all.n ? all : some.n ? some : null; - return h && { used: h.used, back: h.unknown ? 0 : h.back, all: h === all, some: some.names }; + return h && { used: h.used, cap: h.cap, back: h.unknown ? 0 : h.back, all: h === all, some: some.names }; } // directNote: why the cap can't stop agent, signed in to the account and // asking its vendor itself, with no other account on to move it to @@ -11506,11 +11527,79 @@ function capMark(track, w, cap) { track.classList.add("capped"); track.append(mk); } +// windowCapMeter: a window's meter on an account's row as the button that +// sets the window's own cap, in the app's menu: the account's cap, none on +// this window, a share, or one typed in place. A window of a model family +// shown as one figure (tiers) stands for several windows and isn't set +// here; one the cap doesn't count (on-demand) has none to set. +function windowCapMeter(m, p, user, w) { + if (!w.capId || w.tiers) return m; + const own = windowOwnCapOf(p, user, w), cap = accountCapOf(p, user); + const name = t(w.name); + // a span acting as the button: a