providers, gateway, gui, cli: each usage window of an account can have a cap of its own beside the account's (willz on Discord)
willz asked for a cap per window: a friend's account should stop at 50%
of its five hours, while its week has its own 40%. Until now an account
had one usage cap, the same share of every window.
What the user sees: the account's pill still sets the cap for all its
windows and stays the default. Each window's meter on the account's row
is now a button: a click opens the app's own menu headed "Cap for
<window>" with the account's cap, No cap on this window, 50–90%, and
Other… typed in place. A window with its own cap says it beside its
figure ("Cap 50%", or "No cap"), its mark sits there, and the account's
pill says "Caps by window" when only windows have caps. The account is
held while any window is past its own cap, and the row's note, the
gateway's 429 and the Routing trace name that window's cap. CLI:
magpie provider account-cap <id> <account> --window "5 hours" 50
(none: no cap on that window; default: back to the account's), and
`account-cap <id>` lists the overrides.
How: Provider.AccountWindowCaps (providers.json, by account and the
window's capId, its name in lower case; 1–99, or 100 for none) beside
AccountCaps. provider.WindowCaps gives a window its share (its own, else
the account's); Allowance.CapHeld and capReached judge each window by
its own share and report the fullest holding window's cap.
provider.HoldShare becomes HoldCaps, used by gateway capHeld (plan and
group filtering, cappedError, trace) and codex_backend's route-or-relay
choice; codex_switch's NextLogin moves the agent off at per-window caps.
QuotaWindow carries capId; Limit.name is exported as Limit.Name. A
config with no window caps reads and routes exactly as before
(TestSetWindowCap reads an old one). The meter is a span with
role=button, Enter/Space: a <button> as a flex item of the wrapping
meter line kept WebKit's row at its one-line height until the next
layout, so the row grew under the pointer at the first click.
Verified:
- go test -tags nogui ./... in a sandbox HOME: all ok. New
TestWindowCapHeld and TestSetWindowCap (provider), TestWindowCapsHoldTheAccount
through the real gateway (5h 60%/cap 50 goes to the spare; 20/30 under
50/40 is used; 20/45 with the spare past its own cap is a 429 naming
"past its 40% cap"), TestAccountCapCmd --window cases.
- Break check: with WindowCaps.Of ignoring the window's own share,
TestWindowCapHeld fails ("5 hours at 60% of 50%: <nil>") and
TestWindowCapsHoldTheAccount fails (the request is answered 200 by the
capped account).
- internal/gui/tests/window-cap.test.cjs: Chromium and WebKit, 420 and
1100 wide, en, zh, zh-TW, ja, de (60 tests): held note per window,
marks, menu from the meter with no native select, keyboard, posts of
50, typed 45, 100 and 0, no page scroll on a click, no sideways scroll,
no left border stripes, every new string translated. With
windowOwnCapOf returning 0 it fails.
- account-cap, gui-zh-tw, gui-ja, gui-de, i18n-keys pass; full GUI suite
in both engines (483 files, concurrency 6): 4315 of 4323
pass; the 8 that failed (library-servers-bulk, purpose-filter,
routing-proxy, routing-zcode-blocked) touch no account row and all 32
of their tests pass when run alone
- gofmt -l clean; go vet -tags nogui ./... on darwin, linux and windows,
and windows without the tag for ., internal/gui, internal/provider,
internal/gateway; go build ./...
- Not tried in the real window with a live gateway or real accounts.
Docs: gateway-routing.md (HoldCaps, window caps, tests),
providers-accounts.md (AccountWindowCaps, tests), reference.md.
This commit is contained in:
1 parent
3c7473eb3f
commit
34a598372c
24 files changed
+985
-116
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
```
|
||||
|
||||
|
||||
@@ -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 <id> <account> --window <name>`) 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)'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
+109
-20
@@ -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 <button> here, a flex item of the
|
||||
// wrapping meter line, leaves WebKit's row its one-line height until the
|
||||
// next layout, and the row grows under the pointer at the first click
|
||||
const b = el("span", m.className + " aq-set" + (own ? " own" : ""));
|
||||
b.setAttribute("role", "button");
|
||||
b.tabIndex = 0;
|
||||
b.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); b.click(); } };
|
||||
b.append(...m.childNodes);
|
||||
if (own) b.append(el("span", "aq-own", own >= 100 ? t("No cap") : t("Cap {n}%", { n: own })));
|
||||
const reset = m.title || (w.resetsAt ? resetText(new Date(w.resetsAt), new Date(w.resetsAt).toLocaleString()) : "");
|
||||
b.title = [reset, own >= 100 ? t("No cap on {window}: it is used to 100%, whatever this account's cap. Click to change", { window: name })
|
||||
: own ? t("{window} has a cap of its own: magpie counts this account as used up once {window} is at {n}%, until it renews. Click to change", { window: name, n: own })
|
||||
: t("Click to give {window} a cap of its own, apart from this account's", { window: name })].filter(Boolean).join("\n");
|
||||
b.setAttribute("aria-haspopup", "menu");
|
||||
b.setAttribute("aria-expanded", "false");
|
||||
const set = (v) => accountAction("provider/windowcap", { id: p.id, account: user, window: w.capId, cap: v },
|
||||
v === 0 ? t("{window} of {who} follows the account's cap", { window: name, who: user })
|
||||
: v >= 100 ? t("{window} of {who} has no cap", { window: name, who: user })
|
||||
: t("{who} stops at {n}% of {window}", { who: user, n: v, window: name }));
|
||||
const other = () => {
|
||||
const i = input(own && own < 100 ? String(own) : "", "1–99", "text");
|
||||
i.className = "rename-in acap-in";
|
||||
i.inputMode = "numeric";
|
||||
i.setAttribute("aria-label", t("Cap for {window}, in percent", { window: name }));
|
||||
let done = false;
|
||||
const finish = (save) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const v = parseInt(i.value.replace("%", "").trim(), 10);
|
||||
if (save && i.value.trim() && !(v >= 1 && v <= 99)) { status(t("A cap is a share from 1% to 99%"), "err"); renderProviders(); return; }
|
||||
if (save && i.value.trim() && v !== own) set(v);
|
||||
else renderProviders();
|
||||
};
|
||||
i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") finish(true); else if (e.key === "Escape") finish(false); };
|
||||
i.onblur = () => finish(true);
|
||||
b.replaceWith(i);
|
||||
i.focus({ preventScroll: true });
|
||||
};
|
||||
b.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (b.classList.contains("open")) return closeProtoMenu();
|
||||
const mine = own && own < 100 ? own : 0;
|
||||
const shares = CAP_SHARES.includes(mine) || !mine ? CAP_SHARES : [...CAP_SHARES, mine].sort((a, c) => a - c);
|
||||
const opts = [
|
||||
{ v: 0, name: cap ? t("Account's cap · {n}%", { n: cap }) : t("Account's cap · none"), note: t("As the account's other windows"), literalName: true },
|
||||
{ v: 100, name: "No cap on this window", note: "Use this window to 100%" },
|
||||
];
|
||||
for (const n of shares) opts.push({ v: n, name: n + "%", note: "", literalName: true });
|
||||
opts.push({ v: -1, name: "Other…", note: "A share of your own, 1–99%" });
|
||||
openProtoMenu(b, opts, own, (v) => {
|
||||
if (v === -1) return other();
|
||||
if (v !== own) set(v);
|
||||
}, t("Cap for {window}", { window: name }), "sess-menu");
|
||||
};
|
||||
return b;
|
||||
}
|
||||
function accountCapPill(p, user, cap, direct) {
|
||||
const pill = el("button", "acap" + (cap ? " set" : ""), cap ? t("Cap {n}%", { n: cap }) : t("No cap"));
|
||||
// with no cap of its own but some windows', it says so
|
||||
const byWindow = !cap && windowCapsOf(p, user) > 0;
|
||||
const pill = el("button", "acap" + (cap || byWindow ? " set" : ""), cap ? t("Cap {n}%", { n: cap }) : byWindow ? t("Caps by window") : t("No cap"));
|
||||
pill.type = "button";
|
||||
pill.title = (cap ? t("Stops at {n}% of each usage window: once magpie reads a window at {n}% or past it, it counts this account as used up and sends it nothing more until the window renews. A turn already under way can still take it past {n}%, so the cap doesn't promise the rest is left. Click to change", { n: cap })
|
||||
: t("Used to 100% of its usage windows. Click to cap it at a share of each, so magpie goes on to the other accounts past it"))
|
||||
+ "\n" + t("A window can have a cap of its own: click its meter below")
|
||||
+ (direct ? "\n\n" + directNote(direct) : "");
|
||||
pill.setAttribute("aria-haspopup", "menu");
|
||||
pill.setAttribute("aria-expanded", "false");
|
||||
|
||||
@@ -3091,6 +3091,21 @@ const I18N = {
|
||||
"Stops at {n}% of each usage window: once magpie reads a window at {n}% or past it, it counts this account as used up and sends it nothing more until the window renews. A turn already under way can still take it past {n}%, so the cap doesn't promise the rest is left. Click to change": "每个用量窗口用到 {n}% 即停:magpie 读到某个窗口达到 {n}% 或更高后,就把该账号视为已用完,在窗口重置前不再给它发请求。已在进行中的请求仍可能让它超过 {n}%,所以上限不保证剩下的额度不被用掉。点击修改",
|
||||
"Used to 100% of its usage windows. Click to cap it at a share of each, so magpie goes on to the other accounts past it": "用量窗口会用到 100%。点击设置比例上限,超过后改用其他账号",
|
||||
"{who} stops at {n}% of each window": "{who} 每个窗口用到 {n}% 即停",
|
||||
"No cap on {window}: it is used to 100%, whatever this account's cap. Click to change": "{window}不设上限:无论此账号的上限是多少,都用到 100%。点击修改",
|
||||
"{window} has a cap of its own: magpie counts this account as used up once {window} is at {n}%, until it renews. Click to change": "{window}有自己的上限:{window}用到 {n}% 时,magpie 就把此账号算作用完,直到它重置。点击修改",
|
||||
"Click to give {window} a cap of its own, apart from this account's": "点击为{window}单独设一个上限,与此账号的上限分开",
|
||||
"{window} of {who} follows the account's cap": "{who} 的{window}改为沿用账号上限",
|
||||
"{window} of {who} has no cap": "{who} 的{window}不设上限",
|
||||
"{who} stops at {n}% of {window}": "{who} 的{window}用到 {n}% 即停",
|
||||
"Cap for {window}, in percent": "{window}的上限,百分比",
|
||||
"Account's cap · {n}%": "账号上限 · {n}%",
|
||||
"Account's cap · none": "账号上限 · 无",
|
||||
"As the account's other windows": "与账号的其他窗口相同",
|
||||
"No cap on this window": "此窗口不设上限",
|
||||
"Use this window to 100%": "此窗口用到 100%",
|
||||
"Cap for {window}": "{window}的上限",
|
||||
"Caps by window": "按窗口设上限",
|
||||
"A window can have a cap of its own: click its meter below": "每个窗口可单独设上限:点击下方它的用量条",
|
||||
"{who} has no usage cap": "{who} 已取消用量上限",
|
||||
"Serves only {models}": "只用于 {models}",
|
||||
"Every model of the provider goes to this account. Click to keep it for some only": "供应商的所有模型都会用到这个账号。点击可只留给部分模型",
|
||||
@@ -6893,6 +6908,21 @@ const I18N = {
|
||||
"Stops at {n}% of each usage window: once magpie reads a window at {n}% or past it, it counts this account as used up and sends it nothing more until the window renews. A turn already under way can still take it past {n}%, so the cap doesn't promise the rest is left. Click to change": "每個用量時段用到 {n}% 即停:magpie 讀到某個時段達到 {n}% 或更高後,就把該帳號視為已用完,在時段重置前不再傳送請求給它。已在進行中的請求仍可能讓它超過 {n}%,所以上限不保證剩下的額度不被用掉。點選修改",
|
||||
"Used to 100% of its usage windows. Click to cap it at a share of each, so magpie goes on to the other accounts past it": "用量時段會用到 100%。點選設定比例上限,超過後改用其他帳號",
|
||||
"{who} stops at {n}% of each window": "{who} 每個時段用到 {n}% 即停",
|
||||
"No cap on {window}: it is used to 100%, whatever this account's cap. Click to change": "{window}不設上限:無論此帳號的上限是多少,都用到 100%。點擊修改",
|
||||
"{window} has a cap of its own: magpie counts this account as used up once {window} is at {n}%, until it renews. Click to change": "{window}有自己的上限:{window}用到 {n}% 時,magpie 就把此帳號視為用完,直到它重置。點擊修改",
|
||||
"Click to give {window} a cap of its own, apart from this account's": "點擊為{window}單獨設一個上限,與此帳號的上限分開",
|
||||
"{window} of {who} follows the account's cap": "{who} 的{window}改為沿用帳號上限",
|
||||
"{window} of {who} has no cap": "{who} 的{window}不設上限",
|
||||
"{who} stops at {n}% of {window}": "{who} 的{window}用到 {n}% 即停",
|
||||
"Cap for {window}, in percent": "{window}的上限,百分比",
|
||||
"Account's cap · {n}%": "帳號上限 · {n}%",
|
||||
"Account's cap · none": "帳號上限 · 無",
|
||||
"As the account's other windows": "與帳號的其他時段相同",
|
||||
"No cap on this window": "此時段不設上限",
|
||||
"Use this window to 100%": "此時段用到 100%",
|
||||
"Cap for {window}": "{window}的上限",
|
||||
"Caps by window": "按時段設上限",
|
||||
"A window can have a cap of its own: click its meter below": "每個時段可單獨設上限:點擊下方它的用量條",
|
||||
"{who} has no usage cap": "{who} 已取消用量上限",
|
||||
"Serves only {models}": "只用於 {models}",
|
||||
"Every model of the provider goes to this account. Click to keep it for some only": "供應商的所有模型都會用到這個帳號。點選可只留給部分模型",
|
||||
@@ -10669,6 +10699,21 @@ const I18N = {
|
||||
"Stops at {n}% of each usage window: once magpie reads a window at {n}% or past it, it counts this account as used up and sends it nothing more until the window renews. A turn already under way can still take it past {n}%, so the cap doesn't promise the rest is left. Click to change": "各使用量ウィンドウの {n}% で止めます。magpie がウィンドウを {n}% 以上と読み取ると、ウィンドウが更新されるまでこのアカウントを使い切ったものとみなし、何も送りません。すでに進行中のリクエストで {n}% を超えることはあるため、残りが必ず残るわけではありません。クリックで変更",
|
||||
"Used to 100% of its usage windows. Click to cap it at a share of each, so magpie goes on to the other accounts past it": "使用量ウィンドウを 100% まで使用します。クリックすると各ウィンドウの割合で上限を設定し、超えたら magpie が他のアカウントに切り替えます",
|
||||
"{who} stops at {n}% of each window": "{who} は各ウィンドウの {n}% で停止",
|
||||
"No cap on {window}: it is used to 100%, whatever this account's cap. Click to change": "{window}は上限なし:このアカウントの上限にかかわらず 100% まで使います。クリックで変更",
|
||||
"{window} has a cap of its own: magpie counts this account as used up once {window} is at {n}%, until it renews. Click to change": "{window}に個別の上限があります:{window}が {n}% に達すると、リセットまでこのアカウントを使い切りとみなします。クリックで変更",
|
||||
"Click to give {window} a cap of its own, apart from this account's": "クリックで{window}にアカウントとは別の上限を設定",
|
||||
"{window} of {who} follows the account's cap": "{who} の{window}はアカウントの上限に従います",
|
||||
"{window} of {who} has no cap": "{who} の{window}は上限なし",
|
||||
"{who} stops at {n}% of {window}": "{who} は{window}の {n}% で停止",
|
||||
"Cap for {window}, in percent": "{window}の上限(%)",
|
||||
"Account's cap · {n}%": "アカウントの上限 · {n}%",
|
||||
"Account's cap · none": "アカウントの上限 · なし",
|
||||
"As the account's other windows": "アカウントの他のウィンドウと同じ",
|
||||
"No cap on this window": "このウィンドウは上限なし",
|
||||
"Use this window to 100%": "このウィンドウを 100% まで使う",
|
||||
"Cap for {window}": "{window}の上限",
|
||||
"Caps by window": "ウィンドウごとの上限",
|
||||
"A window can have a cap of its own: click its meter below": "ウィンドウごとに上限を設定できます:下のメーターをクリック",
|
||||
"{who} has no usage cap": "{who} の使用量上限を解除しました",
|
||||
"Serves only {models}": "{models} のみに使用",
|
||||
"Every model of the provider goes to this account. Click to keep it for some only": "プロバイダのすべてのモデルがこのアカウントを使います。クリックすると一部のモデル専用にできます",
|
||||
@@ -14468,6 +14513,21 @@ const I18N = {
|
||||
"Stops at {n}% of each usage window: once magpie reads a window at {n}% or past it, it counts this account as used up and sends it nothing more until the window renews. A turn already under way can still take it past {n}%, so the cap doesn't promise the rest is left. Click to change": "Stoppt bei {n} % jedes Zeitfensters: Sobald magpie ein Zeitfenster bei {n} % oder darüber liest, gilt dieses Konto bis zur Erneuerung als aufgebraucht und erhält nichts mehr. Eine schon laufende Anfrage kann es noch über {n} % bringen, die Grenze verspricht also nicht, dass der Rest übrig bleibt. Zum Ändern klicken",
|
||||
"Used to 100% of its usage windows. Click to cap it at a share of each, so magpie goes on to the other accounts past it": "Nutzt seine Zeitfenster zu 100 %. Klicken, um es auf einen Anteil zu begrenzen, sodass magpie danach zu den anderen Konten wechselt",
|
||||
"{who} stops at {n}% of each window": "{who} stoppt bei {n} % jedes Zeitfensters",
|
||||
"No cap on {window}: it is used to 100%, whatever this account's cap. Click to change": "Kein Limit für {window}: wird bis 100 % genutzt, egal welches Limit das Konto hat. Klicken zum Ändern",
|
||||
"{window} has a cap of its own: magpie counts this account as used up once {window} is at {n}%, until it renews. Click to change": "{window} hat ein eigenes Limit: Sobald {window} bei {n} % ist, gilt dieses Konto für magpie als aufgebraucht, bis es sich erneuert. Klicken zum Ändern",
|
||||
"Click to give {window} a cap of its own, apart from this account's": "Klicken, um {window} ein eigenes Limit zu geben, getrennt vom Limit des Kontos",
|
||||
"{window} of {who} follows the account's cap": "{window} von {who} folgt dem Limit des Kontos",
|
||||
"{window} of {who} has no cap": "{window} von {who} hat kein Limit",
|
||||
"{who} stops at {n}% of {window}": "{who} stoppt bei {n} % von {window}",
|
||||
"Cap for {window}, in percent": "Limit für {window}, in Prozent",
|
||||
"Account's cap · {n}%": "Limit des Kontos · {n} %",
|
||||
"Account's cap · none": "Limit des Kontos · keins",
|
||||
"As the account's other windows": "Wie die anderen Zeitfenster des Kontos",
|
||||
"No cap on this window": "Kein Limit für dieses Zeitfenster",
|
||||
"Use this window to 100%": "Dieses Zeitfenster bis 100 % nutzen",
|
||||
"Cap for {window}": "Limit für {window}",
|
||||
"Caps by window": "Limits je Zeitfenster",
|
||||
"A window can have a cap of its own: click its meter below": "Ein Zeitfenster kann ein eigenes Limit haben: auf seine Anzeige unten klicken",
|
||||
"{who} has no usage cap": "{who} hat kein Nutzungslimit",
|
||||
"Serves only {models}": "Nur für {models}",
|
||||
"Every model of the provider goes to this account. Click to keep it for some only": "Alle Modelle des Anbieters gehen an dieses Konto. Klicken, um es auf einige zu beschränken",
|
||||
|
||||
@@ -94,6 +94,10 @@ type providerJSON struct {
|
||||
// the usage cap each account the user capped is held at, in percent
|
||||
// of its windows, by its name in lower case (provider.AccountCaps)
|
||||
AccountCaps map[string]int `json:"accountCaps,omitempty"`
|
||||
// the share of a window each account is held at where the user set
|
||||
// one for that window apart, by the account and then the window's
|
||||
// capId (provider.AccountWindowCaps); 100 is none on that window
|
||||
AccountWindowCaps map[string]map[string]int `json:"accountWindowCaps,omitempty"`
|
||||
// where a custom provider's balance is asked (see provider.Balance)
|
||||
BalanceURL string `json:"balanceURL,omitempty"`
|
||||
BalancePath string `json:"balancePath,omitempty"`
|
||||
@@ -420,7 +424,7 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON {
|
||||
ID: p.ID, Name: p.Name, Icon: p.Icon, Preset: p.Preset, Host: p.Host(),
|
||||
Chat: p.Chat, Responses: p.Responses, Anthropic: p.Anthropic, Gemini: p.Gemini, Decide: p.Decide, BaseAPI: p.BaseAPI, ModelTest: p.ModelTest(), DecideTest: p.AsksDecideModels(),
|
||||
Catalog: p.Catalog, Website: p.Website, KeysURL: p.KeysURL,
|
||||
Proxy: p.Proxy, AccountProxies: p.AccountProxies, AccountModels: p.AccountModels, AccountCaps: p.AccountCaps, Headers: p.Headers, Searches: p.Searches, Cline: p.ClinePinnable(), PinUpstream: p.PinUpstream, Unredacted: p.Unredacted, BalanceURL: p.BalanceURL, BalancePath: p.BalancePath, ModelsURL: p.ModelsURL,
|
||||
Proxy: p.Proxy, AccountProxies: p.AccountProxies, AccountModels: p.AccountModels, AccountCaps: p.AccountCaps, AccountWindowCaps: p.AccountWindowCaps, Headers: p.Headers, Searches: p.Searches, Cline: p.ClinePinnable(), PinUpstream: p.PinUpstream, Unredacted: p.Unredacted, BalanceURL: p.BalanceURL, BalancePath: p.BalancePath, ModelsURL: p.ModelsURL,
|
||||
Ready: p.Ready(), Chosen: p.Models, Models: []modelJSON{}, Agents: []providerAgent{},
|
||||
Fallback: p.Fallback, Routing: p.Routing, Sink: p.Sink, Affinity: p.Affinity, KeepLogin: p.KeepLogin, KeepLoginAs: p.KeepLoginAs, Unlisted: p.Unlisted, Off: p.Off, Contexts: p.Contexts,
|
||||
MaxConcurrency: p.MaxConcurrency, PluginConcurrency: p.PluginConcurrency(), PriceRate: p.PriceRate,
|
||||
@@ -913,8 +917,11 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
|
||||
Account string `json:"account"`
|
||||
Allow []string `json:"allow"`
|
||||
// Cap, for accountcap: the share (1–99) of its windows the
|
||||
// account is used to at most, 0 for no cap
|
||||
Cap int `json:"cap"`
|
||||
// account is used to at most, 0 for no cap; for windowcap, the
|
||||
// share of Window alone (1–99, 100 for none on it), 0 to have
|
||||
// it follow the account's cap
|
||||
Cap int `json:"cap"`
|
||||
Window string `json:"window"`
|
||||
// Typed, for test and models: the request carries the editor's
|
||||
// form, which is tried as it stands before a Save (see typed)
|
||||
Typed bool `json:"typed"`
|
||||
@@ -1130,10 +1137,10 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
|
||||
if old != nil {
|
||||
in.AccountModels = old.AccountModels
|
||||
}
|
||||
// and their usage caps, with accountcap, and their limits on
|
||||
// requests at once, with accountconcurrency
|
||||
// and their usage caps, with accountcap and windowcap, and their
|
||||
// limits on requests at once, with accountconcurrency
|
||||
if old != nil {
|
||||
in.AccountCaps = old.AccountCaps
|
||||
in.AccountCaps, in.AccountWindowCaps = old.AccountCaps, old.AccountWindowCaps
|
||||
in.AccountConcurrency = old.AccountConcurrency
|
||||
}
|
||||
// a Zhipu key's team likewise: {} clears it
|
||||
@@ -1280,6 +1287,11 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
case "windowcap":
|
||||
if err := provider.SetWindowCap(in.ID, req.Account, req.Window, req.Cap); err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
case "accountconcurrency":
|
||||
if err := provider.SetAccountConcurrency(in.ID, req.Account, req.Limit); err != nil {
|
||||
fail(rw, err)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// Each usage window of an account can have a cap of its own beside the
|
||||
// account's (willz on Discord: a friend's account stops at 50% of its five
|
||||
// hours, while its week has its own 40%). A window's meter on the account's
|
||||
// row is a button: a click opens the app's own menu (no native select) of
|
||||
// the account's cap, no cap on this window, a share, or Other… typed in
|
||||
// place, each posted to provider/windowcap with the account, the window's
|
||||
// capId and the cap (0: follow the account's; 100: none on this window). The
|
||||
// meter says its own cap, its mark stands there, and the row's note says the
|
||||
// account is at its cap when any window is past its own. A click moves
|
||||
// nothing. Narrow and wide, in every language.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const inHours = (h) => new Date(Date.now() + h * 3600e3 + 60e3).toISOString();
|
||||
const fresh = () => ({
|
||||
codex: {
|
||||
id: "codex", name: "Codex", icon: "codex-color", chat: "", responses: "", anthropic: "", catalog: "",
|
||||
models: [{ id: "gpt-6", name: "", on: true }],
|
||||
agents: [], fallback: [], headers: {}, keyList: [], proxy: "",
|
||||
account: {
|
||||
agent: "codex", agentName: "Codex", user: "me@example.com", plan: "PLUS",
|
||||
logins: [
|
||||
{ user: "me@example.com", plan: "PLUS", active: true, on: true },
|
||||
{ user: "Friend@Example.com", plan: "PLUS", on: true },
|
||||
],
|
||||
},
|
||||
// the friend's five hours stop at 50%, its week at 40%; no account cap
|
||||
accountWindowCaps: { "friend@example.com": { "5 hours": 50, weekly: 40 } },
|
||||
},
|
||||
});
|
||||
const win = (name, used, h) => ({ name, capId: name.toLowerCase(), used, resetsAt: inHours(h), capped: true });
|
||||
// the friend at 20% of its five hours, 30% of its week: under both
|
||||
const under = () => ({
|
||||
"me@example.com": { provider: "codex", windows: [win("5 hours", 10, 4), win("Weekly", 10, 100)] },
|
||||
"Friend@Example.com": { provider: "codex", windows: [win("5 hours", 20, 4), win("Weekly", 30, 100)] },
|
||||
});
|
||||
// the friend at 60% of its five hours, past its own 50%; the week at 10%
|
||||
const past = () => ({
|
||||
"me@example.com": under()["me@example.com"],
|
||||
"Friend@Example.com": { provider: "codex", windows: [win("5 hours", 60, 3), win("Weekly", 10, 100)] },
|
||||
});
|
||||
|
||||
function serve(lang, posts, use) {
|
||||
const ps = fresh();
|
||||
const providers = { providers: [ps.codex], presets: [], excluded: [], gateway: { running: true, window: true } };
|
||||
const state = { agents: [], profiles: [], settings: { lang, theme: "light" } };
|
||||
return async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const json = (data) => route.fulfill({ json: data });
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json(state);
|
||||
if (url.pathname === "/api/providers") return json(providers);
|
||||
if (url.pathname === "/api/groups") return json({ groups: [] });
|
||||
if (url.pathname === "/api/plugins") return json({ plugins: [] });
|
||||
if (url.pathname === "/api/login/usage") return json(url.searchParams.get("agent") === "codex" ? use() : {});
|
||||
if (url.pathname === "/api/provider/windowcap" && route.request().method() === "POST") {
|
||||
const body = route.request().postDataJSON();
|
||||
posts.push(body);
|
||||
const p = providers.providers.find((x) => x.id === body.id);
|
||||
const all = structuredClone(p.accountWindowCaps || {}), k = body.account.toLowerCase();
|
||||
const caps = all[k] || {};
|
||||
if (body.cap) caps[body.window] = body.cap;
|
||||
else delete caps[body.window];
|
||||
if (Object.keys(caps).length) all[k] = caps; else delete all[k];
|
||||
p.accountWindowCaps = all;
|
||||
return json(providers);
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
await route.fulfill({ body: await fs.readFile(file), contentType });
|
||||
};
|
||||
}
|
||||
|
||||
const words = {
|
||||
en: { cap: (n) => `Cap ${n}%`, none: "No cap", byWindow: "Caps by window", held: "At its cap · back in 3h", head: "Cap for 5 hours", account: "Account's cap · none", off: "No cap on this window", other: "Other…" },
|
||||
zh: { cap: (n) => `上限 ${n}%`, none: "不设上限", byWindow: "按窗口设上限", held: "已达上限 · 3 小时后恢复", head: "5 小时的上限", account: "账号上限 · 无", off: "此窗口不设上限", other: "其他…" },
|
||||
"zh-TW": { cap: (n) => `上限 ${n}%`, none: null, byWindow: "按時段設上限", held: null, head: "5 小時的上限", account: "帳號上限 · 無", off: "此時段不設上限", other: null },
|
||||
ja: { cap: null, none: null, byWindow: "ウィンドウごとの上限", held: null, head: "5 時間の上限", account: "アカウントの上限 · なし", off: "このウィンドウは上限なし", other: null },
|
||||
de: { cap: null, none: null, byWindow: "Limits je Zeitfenster", held: null, head: "Limit für 5 Stunden", account: "Limit des Kontos · keins", off: "Kein Limit für dieses Zeitfenster", other: null },
|
||||
};
|
||||
const NEW = [
|
||||
"No cap on {window}: it is used to 100%, whatever this account's cap. Click to change",
|
||||
"{window} has a cap of its own: magpie counts this account as used up once {window} is at {n}%, until it renews. Click to change",
|
||||
"Click to give {window} a cap of its own, apart from this account's",
|
||||
"{window} of {who} follows the account's cap", "{window} of {who} has no cap", "{who} stops at {n}% of {window}",
|
||||
"Cap for {window}, in percent", "Account's cap · {n}%", "Account's cap · none", "As the account's other windows",
|
||||
"No cap on this window", "Use this window to 100%", "Cap for {window}", "Caps by window",
|
||||
"A window can have a cap of its own: click its meter below",
|
||||
];
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const width of [420, 1100]) {
|
||||
for (const lang of ["en", "zh", "zh-TW", "ja", "de"]) {
|
||||
const w = words[lang];
|
||||
const open = async (t, name, use) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(async () => {
|
||||
if (process.env.ARTIFACT_DIR) {
|
||||
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${width}-${lang}-${name}-window-cap.png`) });
|
||||
}
|
||||
await browser.close();
|
||||
});
|
||||
const page = await (await browser.newContext({ viewport: { width, height: 760 }, reducedMotion: "reduce" })).newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
const posts = [];
|
||||
await page.route("**/*", serve(lang, posts, use));
|
||||
await page.goto("http://magpie.test/?view=providers");
|
||||
await page.locator(".row.provider", { hasText: "Codex" }).first().click();
|
||||
await page.locator(".editor .accts .acc .aq-w").first().waitFor();
|
||||
return { page, errors, posts };
|
||||
};
|
||||
const row = (page, id) => page.locator(`.editor .accts .acc[data-account-id="${id}"]`);
|
||||
const meter = (page, id, i) => row(page, id).locator('.aq-set[role="button"]').nth(i);
|
||||
const menu = (page) => page.locator(".proto-menu");
|
||||
const item = (page, name) => menu(page).locator(".pm-item", { has: page.locator(".pm-name", { hasText: new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`) }) });
|
||||
const scrolled = (page) => page.evaluate(() => [window.scrollY, ...[...document.querySelectorAll("*")].filter((e) => e.scrollTop > 0).map((e) => `${e.className}:${e.scrollTop}`)].join(" "));
|
||||
const still = async (page, id, b) => {
|
||||
await b.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(150);
|
||||
const before = await row(page, id).evaluate((e) => Math.round(e.getBoundingClientRect().top)), sc = await scrolled(page);
|
||||
await b.click();
|
||||
await page.waitForTimeout(200);
|
||||
assert.equal(await row(page, id).evaluate((e) => Math.round(e.getBoundingClientRect().top)), before, `${id}'s row moved`);
|
||||
assert.equal(await scrolled(page), sc, "a click scrolled");
|
||||
};
|
||||
const posted = async (posts, n) => {
|
||||
for (let i = 0; i < 60 && posts.length === n; i++) await new Promise((r) => setTimeout(r, 50));
|
||||
return posts.at(-1);
|
||||
};
|
||||
const label = `${engine} ${width} ${lang}`;
|
||||
|
||||
test(`${label}: a window past its own cap holds the account; one under it doesn't`, async (t) => {
|
||||
const { page, errors } = await open(t, "past", past);
|
||||
// 60% of the five hours, past its own 50%: held, the mark at 50 and 40
|
||||
const held = row(page, "Friend@Example.com").locator(".acap-held");
|
||||
if (w.held) assert.equal(await held.textContent(), w.held);
|
||||
else assert.equal(await held.count(), 1);
|
||||
assert.match(await held.getAttribute("title"), /60/);
|
||||
assert.match(await held.getAttribute("title"), /50/);
|
||||
assert.deepEqual(await row(page, "Friend@Example.com").locator(".aq-cap").evaluateAll((ms) => ms.map((m) => m.style.left)), ["50%", "40%"]);
|
||||
// the pill says its caps are by window; each meter says its own
|
||||
assert.equal(await row(page, "Friend@Example.com").locator(".acap").textContent(), w.byWindow);
|
||||
if (w.cap) assert.deepEqual(await row(page, "Friend@Example.com").locator(".aq-own").allTextContents(), [w.cap(50), w.cap(40)]);
|
||||
else assert.equal(await row(page, "Friend@Example.com").locator(".aq-own").count(), 2);
|
||||
// me has no cap at all: no note, no marks, meters to set
|
||||
assert.equal(await row(page, "me@example.com").locator(".acap-held, .aq-cap, .aq-own").count(), 0);
|
||||
assert.equal(await row(page, "me@example.com").locator('.aq-set[role="button"]').count(), 2);
|
||||
// a meter is reached by the keyboard too: Enter opens its menu
|
||||
await meter(page, "me@example.com", 1).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await menu(page).waitFor();
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test(`${label}: 20% and 30% under their own 50% and 40% leave the account in use`, async (t) => {
|
||||
const { page, errors } = await open(t, "under", under);
|
||||
assert.equal(await row(page, "Friend@Example.com").locator(".acap-held").count(), 0);
|
||||
assert.equal(await row(page, "Friend@Example.com").locator(".aq-cap").count(), 2);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test(`${label}: a window's cap is set from its meter in the app's menu, typed, and lifted`, async (t) => {
|
||||
const { page, errors, posts } = await open(t, "set", under);
|
||||
// me's five hours: the menu, headed by the window, no native select
|
||||
await still(page, "me@example.com", meter(page, "me@example.com", 0));
|
||||
await menu(page).waitFor();
|
||||
assert.equal(await menu(page).locator(".pm-head").textContent(), w.head);
|
||||
const names = await menu(page).locator(".pm-name").allTextContents();
|
||||
assert.equal(names[0], w.account);
|
||||
assert.equal(names[1], w.off);
|
||||
assert.deepEqual(names.slice(2, 7), ["50%", "60%", "70%", "80%", "90%"]);
|
||||
assert.equal(names.length, 8);
|
||||
assert.equal(await page.locator("select").count(), 0, "no native select");
|
||||
let n = posts.length;
|
||||
await item(page, "50%").click();
|
||||
assert.deepEqual(await posted(posts, n), { id: "codex", account: "me@example.com", window: "5 hours", cap: 50 });
|
||||
await row(page, "me@example.com").locator(".aq-own").first().waitFor();
|
||||
if (w.cap) assert.equal(await row(page, "me@example.com").locator(".aq-own").first().textContent(), w.cap(50));
|
||||
assert.deepEqual(await row(page, "me@example.com").locator(".aq-cap").evaluateAll((ms) => ms.map((m) => m.style.left)), ["50%"]);
|
||||
|
||||
// the week: Other…, 45 typed in place
|
||||
await still(page, "me@example.com", meter(page, "me@example.com", 1));
|
||||
await menu(page).locator(".pm-item").last().click();
|
||||
const typed = row(page, "me@example.com").locator("input.acap-in");
|
||||
await typed.waitFor();
|
||||
await typed.fill("45");
|
||||
n = posts.length;
|
||||
await typed.press("Enter");
|
||||
assert.deepEqual(await posted(posts, n), { id: "codex", account: "me@example.com", window: "weekly", cap: 45 });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.editor .acc[data-account-id="me@example.com"] .aq-own').length === 2);
|
||||
|
||||
// the friend's five hours: no cap on this window, 100
|
||||
await still(page, "Friend@Example.com", meter(page, "Friend@Example.com", 0));
|
||||
n = posts.length;
|
||||
await item(page, w.off).click();
|
||||
assert.deepEqual(await posted(posts, n), { id: "codex", account: "Friend@Example.com", window: "5 hours", cap: 100 });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.editor .acc[data-account-id="Friend@Example.com"] .aq-cap').length === 1);
|
||||
if (w.none) assert.equal(await row(page, "Friend@Example.com").locator(".aq-own").first().textContent(), w.none);
|
||||
|
||||
// back to the account's: 0, its own goes
|
||||
await still(page, "Friend@Example.com", meter(page, "Friend@Example.com", 0));
|
||||
n = posts.length;
|
||||
await item(page, w.account).click();
|
||||
assert.deepEqual(await posted(posts, n), { id: "codex", account: "Friend@Example.com", window: "5 hours", cap: 0 });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.editor .acc[data-account-id="Friend@Example.com"] .aq-own').length === 1);
|
||||
|
||||
// the meters stay on the row's line, not wider than the page
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), "no page scroll sideways");
|
||||
const border = await page.evaluate(() => [...document.querySelectorAll(".accts .aq-set, .accts .aq-own, .accts .aq-cap, .proto-menu, .proto-menu *")].map((e) => getComputedStyle(e).borderLeftWidth).filter((b) => parseFloat(b) > 1));
|
||||
assert.deepEqual(border, [], "no border stripes");
|
||||
if (lang !== "en") {
|
||||
const missing = await page.evaluate(([l, keys]) => keys.filter((k) => !I18N[l][k]), [lang, NEW]);
|
||||
assert.deepEqual(missing, [], `every string has its ${lang}`);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,18 @@ package provider
|
||||
// of a request (gateway/fallback.go), and Codex or Claude Code signed in
|
||||
// to it are signed in to another (NextLogin).
|
||||
//
|
||||
// One share for every window: the windows differ by vendor (five hours and
|
||||
// a week, a month, Opus's own, a plugin's per-model ones) and by name, so a
|
||||
// cap per window would need each vendor's windows named in the settings,
|
||||
// where the ask, and the risk it is about, is one share of the whole
|
||||
// allowance. The on-demand windows (Aside) are not the allowance and never
|
||||
// count.
|
||||
// One share for every window by default, and a share of its own for any
|
||||
// window the user sets one on (willz on Discord: a friend's account stops
|
||||
// at 50% of its five hours while its week may go to 40%). A window is known
|
||||
// by its name as the vendor's reading gives it, in lower case
|
||||
// (WindowCapID): "5 hours", "weekly", "7 days · opus", a pool's "gemini ·
|
||||
// 5 hours" — the name routing already tells one reading's windows from the
|
||||
// next by. The windows differ by vendor, so the overrides are kept per
|
||||
// account (AccountWindowCaps), set where the window is shown. A window's
|
||||
// own share is 1–99, or 100 for "no cap on this window" under an account
|
||||
// cap; one with none of its own takes the account's. The account is held
|
||||
// while any window is at or past its own share. The on-demand windows
|
||||
// (Aside) are not the allowance and never count.
|
||||
//
|
||||
// The cap is the user's, not the vendor's: what it holds back never spends
|
||||
// a Codex reset. The resets are spent on the vendor's own 100% (weekUsedUp,
|
||||
@@ -22,6 +28,7 @@ package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -55,6 +62,119 @@ func AccountCapOf(id, user string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// WindowCaps is what holds an account at a share of its windows: its usage
|
||||
// cap for every window (All), the share of each window the user set one
|
||||
// on (Windows, by WindowCapID; 100 is none on that window), and Credits
|
||||
// for a Codex account set not to spend its credits, which holds any window
|
||||
// with no cap at the vendor's own 100%.
|
||||
type WindowCaps struct {
|
||||
All int
|
||||
Windows map[string]int
|
||||
Credits bool
|
||||
}
|
||||
|
||||
// WindowCapID is how a window is known to its cap: its name, trimmed, in
|
||||
// lower case.
|
||||
func WindowCapID(name string) string { return strings.ToLower(strings.TrimSpace(name)) }
|
||||
|
||||
// Of is the share at which the window named name holds the account, 0 for
|
||||
// none, and whether it is the credits that hold it there.
|
||||
func (c WindowCaps) Of(name string) (share int, credits bool) {
|
||||
share = c.All
|
||||
if v := c.Windows[WindowCapID(name)]; v > 0 {
|
||||
share = v
|
||||
}
|
||||
if share >= 100 {
|
||||
share = 0
|
||||
}
|
||||
if share <= 0 && c.Credits {
|
||||
return 100, true
|
||||
}
|
||||
return share, false
|
||||
}
|
||||
|
||||
// Holds says whether any window can hold the account before the vendor
|
||||
// does.
|
||||
func (c WindowCaps) Holds() bool {
|
||||
if c.All > 0 || c.Credits {
|
||||
return true
|
||||
}
|
||||
for _, v := range c.Windows {
|
||||
if v >= MinCap && v <= MaxCap {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CapsOf is the caps of the account user of p: its usage cap
|
||||
// and its windows' own. As with AccountCap, an account made afresh beside
|
||||
// the agent's own reads them from providers.json.
|
||||
func (p Provider) CapsOf(user string) WindowCaps {
|
||||
caps, wins := p.AccountCaps, p.AccountWindowCaps
|
||||
if caps == nil && wins == nil {
|
||||
if s, ok := storedPicks(p.ID); ok {
|
||||
caps, wins = s.AccountCaps, s.AccountWindowCaps
|
||||
}
|
||||
}
|
||||
k := accountKey(user)
|
||||
return WindowCaps{All: caps[k], Windows: wins[k]}
|
||||
}
|
||||
|
||||
// AccountCapsOf is CapsOf of the provider id.
|
||||
func AccountCapsOf(id, user string) WindowCaps {
|
||||
if s, ok := storedPicks(id); ok {
|
||||
k := accountKey(user)
|
||||
return WindowCaps{All: s.AccountCaps[k], Windows: s.AccountWindowCaps[k]}
|
||||
}
|
||||
return WindowCaps{}
|
||||
}
|
||||
|
||||
// SetWindowCap sets the share of the window named window of the account ref
|
||||
// of the subscription id: 1–99, 100 for no cap on that window whatever the
|
||||
// account's cap, or 0 to have it follow the account's cap again.
|
||||
func SetWindowCap(id, ref, window string, cap int) error {
|
||||
if cap != 0 && (cap < MinCap || cap > 100) {
|
||||
return fmt.Errorf("a window's cap is a share from %d%% to %d%%, or 100%% for none on it, not %d%%", MinCap, MaxCap, cap)
|
||||
}
|
||||
w := WindowCapID(window)
|
||||
if w == "" {
|
||||
return fmt.Errorf("name the usage window to cap")
|
||||
}
|
||||
p, err := Find(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Account == nil {
|
||||
return fmt.Errorf("%s has keys, not subscription accounts with usage windows to cap", p.Name)
|
||||
}
|
||||
r, ok := p.accountRef(ref)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s has no account %q", p.Name, ref)
|
||||
}
|
||||
k := accountKey(r)
|
||||
if p.AccountWindowCaps[k][w] == cap {
|
||||
return nil
|
||||
}
|
||||
all := map[string]map[string]int{}
|
||||
for u, m := range p.AccountWindowCaps {
|
||||
all[u] = maps.Clone(m)
|
||||
}
|
||||
if all[k] == nil {
|
||||
all[k] = map[string]int{}
|
||||
}
|
||||
if cap == 0 {
|
||||
delete(all[k], w)
|
||||
} else {
|
||||
all[k][w] = cap
|
||||
}
|
||||
if len(all[k]) == 0 {
|
||||
delete(all, k)
|
||||
}
|
||||
p.AccountWindowCaps = all
|
||||
return Save(*p)
|
||||
}
|
||||
|
||||
// SetAccountCap caps the account ref of the subscription id at cap percent
|
||||
// of its windows; 0 lifts the cap.
|
||||
func SetAccountCap(id, ref string, cap int) error {
|
||||
@@ -120,46 +240,90 @@ func normalAccountCaps(m map[string]int) map[string]int {
|
||||
return out
|
||||
}
|
||||
|
||||
// CapHeld says whether an allowance, as last known, has a window counting
|
||||
// model at or past cap percent at now: the account is held by its cap.
|
||||
// used is the share of the fullest such window, back when the last of them
|
||||
// renews (zero when one doesn't say). A window whose reset has passed is
|
||||
// empty again; no cap (0) holds nothing.
|
||||
func (a Allowance) CapHeld(model string, cap int, now time.Time) (held bool, used float64, back time.Time) {
|
||||
if cap <= 0 {
|
||||
return false, 0, time.Time{}
|
||||
// normalWindowCaps is m as it is kept: accounts and windows in lower case,
|
||||
// shares within MinCap–100, empty ones left out; nil for none.
|
||||
func normalWindowCaps(m map[string]map[string]int) map[string]map[string]int {
|
||||
var out map[string]map[string]int
|
||||
for u, ws := range m {
|
||||
u = accountKey(u)
|
||||
for w, c := range ws {
|
||||
w = WindowCapID(w)
|
||||
if u == "" || w == "" || c < MinCap || c > 100 {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]map[string]int{}
|
||||
}
|
||||
if out[u] == nil {
|
||||
out[u] = map[string]int{}
|
||||
}
|
||||
out[u][w] = c
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CapHold is how an account is held by its caps for a model: the share
|
||||
// used of the fullest window holding it, that window's cap, when the last
|
||||
// of those windows renews (zero when one doesn't say), and Credits when
|
||||
// only the credits hold it, at 100%.
|
||||
type CapHold struct {
|
||||
Used float64
|
||||
Cap int
|
||||
Back time.Time
|
||||
Credits bool
|
||||
}
|
||||
|
||||
// CapHeld says how an allowance, as last known, holds the account for
|
||||
// model at now: some window counting model at or past its own share of
|
||||
// caps (WindowCaps.Of), not renewed since; nil when none is. A window
|
||||
// whose reset has passed is empty again; no caps hold nothing.
|
||||
func (a Allowance) CapHeld(model string, caps WindowCaps, now time.Time) *CapHold {
|
||||
if !caps.Holds() {
|
||||
return nil
|
||||
}
|
||||
model = strings.ToLower(model)
|
||||
unknown := false
|
||||
var h *CapHold
|
||||
unknown, byCap := false, false
|
||||
for _, l := range a {
|
||||
if !l.applies(model) || l.Used < float64(cap) {
|
||||
share, credits := caps.Of(l.Name)
|
||||
if share <= 0 || !l.applies(model) || l.Used < float64(share) {
|
||||
continue
|
||||
}
|
||||
if !l.Resets.IsZero() && !l.Resets.After(now) {
|
||||
continue // renewed since it was read
|
||||
}
|
||||
held, used = true, max(used, l.Used)
|
||||
if h == nil {
|
||||
h = &CapHold{}
|
||||
}
|
||||
if l.Used > h.Used || h.Cap == 0 {
|
||||
h.Used, h.Cap = l.Used, share
|
||||
}
|
||||
byCap = byCap || !credits
|
||||
if l.Resets.IsZero() {
|
||||
unknown = true
|
||||
} else if l.Resets.After(back) {
|
||||
back = l.Resets
|
||||
} else if l.Resets.After(h.Back) {
|
||||
h.Back = l.Resets
|
||||
}
|
||||
}
|
||||
if unknown {
|
||||
back = time.Time{}
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return held, used, back
|
||||
if unknown {
|
||||
h.Back = time.Time{}
|
||||
}
|
||||
h.Credits = !byCap
|
||||
return h
|
||||
}
|
||||
|
||||
// capReached says whether an account's windows that stop it for every
|
||||
// model are, one of them, at or past cap percent at now — usedPast at the
|
||||
// cap, those renewed since they were read left out. No cap reaches nothing.
|
||||
func capReached(q SubscriptionQuota, cap int, now time.Time) bool {
|
||||
if cap <= 0 {
|
||||
return false
|
||||
}
|
||||
// model are, one of them, at or past its own share of caps at now —
|
||||
// usedPast at the share, those renewed since they were read left out. No
|
||||
// caps reach nothing.
|
||||
func capReached(q SubscriptionQuota, caps WindowCaps, now time.Time) bool {
|
||||
for _, w := range q.Windows {
|
||||
if w.Aside || w.Model != "" || w.Used < float64(cap) {
|
||||
share, _ := caps.Of(w.Name)
|
||||
if share <= 0 || w.Aside || w.Model != "" || w.Used < float64(share) {
|
||||
continue
|
||||
}
|
||||
if w.ResetsAt != nil && !w.ResetsAt.After(now) {
|
||||
@@ -182,6 +346,9 @@ func WithCapped(m map[string]SubscriptionQuota) map[string]SubscriptionQuota {
|
||||
for i, w := range q.Windows {
|
||||
w.Capped = !w.Aside && !w.Unlimited
|
||||
w.CapsSome = w.Capped && (w.Model != "" || w.matches != nil)
|
||||
if w.Capped {
|
||||
w.CapID = WindowCapID(w.Name)
|
||||
}
|
||||
ws[i] = w
|
||||
}
|
||||
if q.Windows != nil {
|
||||
|
||||
@@ -19,29 +19,30 @@ func TestCapHeld(t *testing.T) {
|
||||
{Used: 40, Resets: week, Span: 7 * 24 * time.Hour},
|
||||
{Used: 95, Resets: week, Span: 7 * 24 * time.Hour, Model: "opus"},
|
||||
}
|
||||
if held, used, back := a.CapHeld("gpt-6", 70, now); !held || used != 75 || !back.Equal(five) {
|
||||
t.Fatalf("at 75%% of 70%%: %v %v %v", held, used, back)
|
||||
all := func(n int) WindowCaps { return WindowCaps{All: n} }
|
||||
if h := a.CapHeld("gpt-6", all(70), now); h == nil || h.Used != 75 || h.Cap != 70 || !h.Back.Equal(five) || h.Credits {
|
||||
t.Fatalf("at 75%% of 70%%: %+v", h)
|
||||
}
|
||||
if held, _, _ := a.CapHeld("gpt-6", 80, now); held {
|
||||
if h := a.CapHeld("gpt-6", all(80), now); h != nil {
|
||||
t.Fatal("held below its cap")
|
||||
}
|
||||
if held, _, _ := a.CapHeld("gpt-6", 0, now); held {
|
||||
if h := a.CapHeld("gpt-6", all(0), now); h != nil {
|
||||
t.Fatal("held with no cap")
|
||||
}
|
||||
// Opus's own window counts for Opus alone
|
||||
if held, used, back := a.CapHeld("claude-opus-5", 90, now); !held || used != 95 || !back.Equal(week) {
|
||||
t.Fatalf("opus at 95%% of 90%%: %v %v %v", held, used, back)
|
||||
if h := a.CapHeld("claude-opus-5", all(90), now); h == nil || h.Used != 95 || !h.Back.Equal(week) {
|
||||
t.Fatalf("opus at 95%% of 90%%: %+v", h)
|
||||
}
|
||||
if held, _, _ := a.CapHeld("claude-sonnet-5", 90, now); held {
|
||||
if h := a.CapHeld("claude-sonnet-5", all(90), now); h != nil {
|
||||
t.Fatal("Opus's window held another model")
|
||||
}
|
||||
// renewed since it was read: empty again
|
||||
if held, _, _ := a.CapHeld("gpt-6", 70, five.Add(time.Minute)); held {
|
||||
if h := a.CapHeld("gpt-6", all(70), five.Add(time.Minute)); h != nil {
|
||||
t.Fatal("held by a window that has renewed")
|
||||
}
|
||||
// one not saying when it renews: back not known
|
||||
if held, _, back := (Allowance{{Used: 80}}).CapHeld("gpt-6", 70, now); !held || !back.IsZero() {
|
||||
t.Fatalf("no reset: %v %v", held, back)
|
||||
if h := (Allowance{{Used: 80}}).CapHeld("gpt-6", all(70), now); h == nil || !h.Back.IsZero() {
|
||||
t.Fatalf("no reset: %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,11 +63,11 @@ func TestCapReached(t *testing.T) {
|
||||
{"on-demand spending", QuotaWindow{Used: 90, ResetsAt: &later, Aside: true}, false},
|
||||
{"one model's", QuotaWindow{Used: 90, ResetsAt: &later, Model: "opus"}, false},
|
||||
} {
|
||||
if got := capReached(SubscriptionQuota{Windows: []QuotaWindow{c.w}}, 70, now); got != c.want {
|
||||
if got := capReached(SubscriptionQuota{Windows: []QuotaWindow{c.w}}, WindowCaps{All: 70}, now); got != c.want {
|
||||
t.Errorf("%s: %v", c.name, got)
|
||||
}
|
||||
}
|
||||
if capReached(SubscriptionQuota{Windows: []QuotaWindow{{Used: 99, ResetsAt: &later}}}, 0, now) {
|
||||
if capReached(SubscriptionQuota{Windows: []QuotaWindow{{Used: 99, ResetsAt: &later}}}, WindowCaps{}, now) {
|
||||
t.Error("no cap reached")
|
||||
}
|
||||
}
|
||||
@@ -104,6 +105,110 @@ func TestWithCappedMarksPerModelWindows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A window can have a cap of its own beside the account's (willz on
|
||||
// Discord): a friend's account stops at 50% of its five hours while its
|
||||
// week may go to 40%. The account is held while any window is at or past
|
||||
// its own share; a window with none of its own takes the account's.
|
||||
func TestWindowCapHeld(t *testing.T) {
|
||||
now := time.Now()
|
||||
five, week := now.Add(3*time.Hour), now.Add(4*24*time.Hour)
|
||||
// the vendor's readings, as allowanceOf makes them: windows by name
|
||||
read := func(fiveUsed, weekUsed float64) Allowance {
|
||||
return allowanceOf([]QuotaWindow{
|
||||
{Name: "5 hours", Used: fiveUsed, ResetsAt: &five, Span: 5 * time.Hour},
|
||||
{Name: "Weekly", Used: weekUsed, ResetsAt: &week, Span: 7 * 24 * time.Hour},
|
||||
}, now)
|
||||
}
|
||||
friend := WindowCaps{Windows: map[string]int{"5 hours": 50, "weekly": 40}}
|
||||
// the five hours at 60%, past its 50%: held, at that window's cap
|
||||
if h := read(60, 10).CapHeld("gpt-6", friend, now); h == nil || h.Cap != 50 || h.Used != 60 || !h.Back.Equal(five) {
|
||||
t.Fatalf("5 hours at 60%% of 50%%: %+v", h)
|
||||
}
|
||||
// the week at 30% of its 40%, the five hours at 20% of 50%: used
|
||||
if h := read(20, 30).CapHeld("gpt-6", friend, now); h != nil {
|
||||
t.Fatalf("both under their own caps, held: %+v", h)
|
||||
}
|
||||
// the week at 45%, past its 40%, though under the five hours' 50%
|
||||
if h := read(20, 45).CapHeld("gpt-6", friend, now); h == nil || h.Cap != 40 || !h.Back.Equal(week) {
|
||||
t.Fatalf("week at 45%% of 40%%: %+v", h)
|
||||
}
|
||||
// one window's own beside the account's: the week follows the account
|
||||
mixed := WindowCaps{All: 10, Windows: map[string]int{"5 hours": 50}}
|
||||
if h := read(30, 5).CapHeld("gpt-6", mixed, now); h != nil {
|
||||
t.Fatalf("5 hours at 30%% of its own 50%%, held: %+v", h)
|
||||
}
|
||||
if h := read(30, 12).CapHeld("gpt-6", mixed, now); h == nil || h.Cap != 10 {
|
||||
t.Fatalf("week at 12%% of the account's 10%%: %+v", h)
|
||||
}
|
||||
// 100 on a window is none there, whatever the account's
|
||||
free := WindowCaps{All: 10, Windows: map[string]int{"weekly": 100}}
|
||||
if h := read(5, 95).CapHeld("gpt-6", free, now); h != nil {
|
||||
t.Fatalf("week with no cap of its own held: %+v", h)
|
||||
}
|
||||
// set not to spend its credits, that window is still held at 100%
|
||||
free.Credits = true
|
||||
if h := read(5, 100).CapHeld("gpt-6", free, now); h == nil || h.Cap != 100 || !h.Credits {
|
||||
t.Fatalf("credits off, week at 100%%: %+v", h)
|
||||
}
|
||||
// the window's name is matched in any case
|
||||
if c, _ := (WindowCaps{Windows: map[string]int{"weekly": 40}}).Of(" WEEKLY "); c != 40 {
|
||||
t.Fatalf("named in another case: %d", c)
|
||||
}
|
||||
// capReached, for the agent signed in, goes by each window's own too
|
||||
ws := []QuotaWindow{{Name: "5 hours", Used: 60, ResetsAt: &five}, {Name: "Weekly", Used: 30, ResetsAt: &week}}
|
||||
if !capReached(SubscriptionQuota{Windows: ws}, friend, now) {
|
||||
t.Fatal("5 hours at 60% of 50% not reached")
|
||||
}
|
||||
ws[0].Used = 20
|
||||
if capReached(SubscriptionQuota{Windows: ws}, friend, now) {
|
||||
t.Fatal("reached under both windows' own caps")
|
||||
}
|
||||
}
|
||||
|
||||
// Old settings, with no window's own cap, are read as they were: one
|
||||
// share every window. A window's own is kept in lower case, and 0 has the
|
||||
// window follow the account's cap again.
|
||||
func TestSetWindowCap(t *testing.T) {
|
||||
signIn(t) // me@example.com on codex
|
||||
if err := SetAccountCap("codex", "me@example.com", 70); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := AccountCapsOf("codex", "me@example.com")
|
||||
if c.All != 70 || c.Windows != nil {
|
||||
t.Fatalf("an old cap read as %+v", c)
|
||||
}
|
||||
if s, _ := c.Of("Weekly"); s != 70 {
|
||||
t.Fatalf("the week with the account's cap: %d", s)
|
||||
}
|
||||
if err := SetWindowCap("codex", "ME@example.com", "Weekly", 40); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c = AccountCapsOf("codex", "me@example.com")
|
||||
if s, _ := c.Of("weekly"); s != 40 || c.All != 70 {
|
||||
t.Fatalf("the week's own: %+v", c)
|
||||
}
|
||||
if s, _ := c.Of("5 hours"); s != 70 {
|
||||
t.Fatalf("the five hours not following the account's cap: %d", s)
|
||||
}
|
||||
for _, bad := range []int{-1, 101} {
|
||||
if err := SetWindowCap("codex", "me@example.com", "Weekly", bad); err == nil {
|
||||
t.Errorf("%d taken", bad)
|
||||
}
|
||||
}
|
||||
if err := SetWindowCap("codex", "me@example.com", "", 40); err == nil {
|
||||
t.Error("a window with no name taken")
|
||||
}
|
||||
if err := SetWindowCap("codex", "me@example.com", "weekly", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := AccountCapsOf("codex", "me@example.com"); c.Windows != nil {
|
||||
t.Fatalf("cleared, still %+v", c)
|
||||
}
|
||||
if p, _ := Find("codex"); p.AccountWindowCaps != nil || p.AccountCap("me@example.com") != 70 {
|
||||
t.Fatalf("saved as %+v", p.AccountWindowCaps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCap(t *testing.T) {
|
||||
for in, want := range map[string]int{"70": 70, "70%": 70, " 5 % ": 5, "off": 0, "none": 0, "0": 0, "100": 0, "-": 0} {
|
||||
if got, err := ParseCap(in); err != nil || got != want {
|
||||
@@ -129,7 +234,7 @@ func TestCapNeverSpendsACodexReset(t *testing.T) {
|
||||
{Name: "Weekly", Used: 80, ResetsAt: &week, Span: 7 * 24 * time.Hour},
|
||||
}
|
||||
q := SubscriptionQuota{Provider: "codex", Windows: ws}
|
||||
if !capReached(q, 70, now) {
|
||||
if !capReached(q, WindowCaps{All: 70}, now) {
|
||||
t.Fatal("80% isn't past a 70% cap")
|
||||
}
|
||||
if weekUsedUp(ws, now) != nil {
|
||||
@@ -141,7 +246,7 @@ func TestCapNeverSpendsACodexReset(t *testing.T) {
|
||||
if spendExpiringNow(ws, &ResetCredits{Count: 1, Until: &until}, now) {
|
||||
t.Fatal("a reset with 20 days to run is spent at 80%")
|
||||
}
|
||||
if held, _, _ := allowanceOf(ws, now).CapHeld("gpt-6", 70, now); !held {
|
||||
if h := allowanceOf(ws, now).CapHeld("gpt-6", WindowCaps{All: 70}, now); h == nil {
|
||||
t.Fatal("routing doesn't hold it")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ type reading struct {
|
||||
// still full: the five hours started again in a week used up.
|
||||
func (a Allowance) renewedFrom(was Allowance, wasAt time.Time, share float64, now time.Time) bool {
|
||||
type key struct{ name, model string }
|
||||
keyOf := func(l Limit) key { return key{l.name, l.Model} }
|
||||
keyOf := func(l Limit) key { return key{l.Name, l.Model} }
|
||||
read := map[key]bool{}
|
||||
for _, l := range a {
|
||||
read[keyOf(l)] = true
|
||||
|
||||
@@ -43,17 +43,13 @@ func SetCodexCredits(user string, on bool) error {
|
||||
return settings.Save(s)
|
||||
}
|
||||
|
||||
// HoldShare is the share of its windows (in percent) at which routing
|
||||
// holds the account user of p, of agent, as used up: its usage cap when it
|
||||
// has one, 100 for a Codex account set not to spend its credits, 0 when
|
||||
// nothing holds it before the vendor does. noCredits says it is the credits
|
||||
// that hold it.
|
||||
func HoldShare(p Provider, agent, user string) (share int, noCredits bool) {
|
||||
if c := p.AccountCap(user); c > 0 {
|
||||
return c, false
|
||||
}
|
||||
if agent == "codex" && user != "" && !CodexCredits(user) {
|
||||
return 100, true
|
||||
}
|
||||
return 0, false
|
||||
// HoldCaps is the shares of its windows at which routing holds the
|
||||
// account user of p, of agent, as used up: its usage cap and its windows'
|
||||
// own (account_caps.go), and for a Codex account set not to spend its
|
||||
// credits 100% of any window with no cap. Holds() is false when nothing
|
||||
// holds it before the vendor does.
|
||||
func HoldCaps(p Provider, agent, user string) WindowCaps {
|
||||
c := p.CapsOf(user)
|
||||
c.Credits = agent == "codex" && user != "" && !CodexCredits(user)
|
||||
return c
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func TestCodexCreditsSwitch(t *testing.T) {
|
||||
{"claude", "me@example.com", 0, false},
|
||||
{"codex", "capped@example.com", 70, false},
|
||||
} {
|
||||
if share, no := HoldShare(p, c.agent, c.user); share != c.share || no != c.noCredits {
|
||||
if share, no := HoldCaps(p, c.agent, c.user).Of("5 hours"); share != c.share || no != c.noCredits {
|
||||
t.Errorf("%s %s: held at %d (credits %v)", c.agent, c.user, share, no)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestNoteCodexLimitsHoldsTheCap(t *testing.T) {
|
||||
{Name: "7 days", Used: 40, ResetsAt: &week, Span: 7 * 24 * time.Hour}}}}}
|
||||
loginUsageCache.Unlock()
|
||||
handed := Allowances("codex") // what routing holds already isn't changed under it
|
||||
if held, _, _ := handed["Me@example.com"].CapHeld("gpt-5.5", 99, now); held {
|
||||
if h := handed["Me@example.com"].CapHeld("gpt-5.5", WindowCaps{All: 99}, now); h != nil {
|
||||
t.Fatal("held at 96%")
|
||||
}
|
||||
|
||||
@@ -87,9 +87,9 @@ func TestNoteCodexLimitsHoldsTheCap(t *testing.T) {
|
||||
NoteCodexLimits("codex", "new@example.com", []CodexLimit{{Used: 99, Span: 5 * time.Hour}})
|
||||
|
||||
m := Allowances("codex")
|
||||
held, used, back := m["Me@example.com"].CapHeld("gpt-5.5", 99, now)
|
||||
if !held || used != 99 || !back.Equal(renews) {
|
||||
t.Fatalf("after the reply: held %v at %v%% till %s", held, used, back)
|
||||
h := m["Me@example.com"].CapHeld("gpt-5.5", WindowCaps{All: 99}, now)
|
||||
if h == nil || h.Used != 99 || !h.Back.Equal(renews) {
|
||||
t.Fatalf("after the reply: held %+v", h)
|
||||
}
|
||||
if u := m["Me@example.com"][1].Used; u != 40 {
|
||||
t.Fatalf("the week took the five hours' share: %v%%", u)
|
||||
|
||||
@@ -183,7 +183,7 @@ func NextLogin(ctx context.Context, agent string) (from, to string, back, ok boo
|
||||
// (account_caps.go): it is moved off, and none is moved to or back to
|
||||
// till the window it filled renews
|
||||
capped := func(user string, q SubscriptionQuota) bool {
|
||||
return capReached(q, AccountCapOf(agent, user), now)
|
||||
return capReached(q, AccountCapsOf(agent, user), now)
|
||||
}
|
||||
if first != nil && first.On && first.Lapsed == "" {
|
||||
if q, known := u[first.User]; known && q.Error == "" && !usedPast(q, backShare, now) && !capped(first.User, q) {
|
||||
|
||||
@@ -165,6 +165,7 @@ func moveAccountSettings(id string, moves map[string]string) error {
|
||||
moved = moveKey(p.AccountProxies, from, to) || moved
|
||||
moved = moveKey(p.AccountModels, from, to) || moved
|
||||
moved = moveKey(p.AccountCaps, from, to) || moved
|
||||
moved = moveKey(p.AccountWindowCaps, from, to) || moved
|
||||
moved = moveKey(p.AccountConcurrency, from, to) || moved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,12 @@ type Provider struct {
|
||||
// in lower case: past it, routing takes the account for used up until
|
||||
// the window renews (see account_caps.go). One not in it has no cap.
|
||||
AccountCaps map[string]int `json:"accountCaps,omitempty"`
|
||||
// AccountWindowCaps is, for a subscription, the share of each usage
|
||||
// window an account is used to at most where the user set one for that
|
||||
// window apart (willz on Discord), by the account's name in lower case
|
||||
// and then the window's WindowCapID: 1–99, or 100 for none on it. A
|
||||
// window not in it takes the account's AccountCaps.
|
||||
AccountWindowCaps map[string]map[string]int `json:"accountWindowCaps,omitempty"`
|
||||
|
||||
// BalanceURL, when set, is where the vendor tells what is left on a
|
||||
// key, asked with the key the way a chat request carries it; BalancePath
|
||||
@@ -401,6 +407,13 @@ func (p Provider) clone() Provider {
|
||||
p.Headers = maps.Clone(p.Headers)
|
||||
p.AccountProxies = maps.Clone(p.AccountProxies)
|
||||
p.AccountCaps = maps.Clone(p.AccountCaps)
|
||||
if p.AccountWindowCaps != nil {
|
||||
m := make(map[string]map[string]int, len(p.AccountWindowCaps))
|
||||
for k, v := range p.AccountWindowCaps {
|
||||
m[k] = maps.Clone(v)
|
||||
}
|
||||
p.AccountWindowCaps = m
|
||||
}
|
||||
p.AccountConcurrency = maps.Clone(p.AccountConcurrency)
|
||||
p.Contexts = maps.Clone(p.Contexts)
|
||||
if p.AccountModels != nil {
|
||||
@@ -446,7 +459,7 @@ func allProviders() []Provider {
|
||||
a.Models, a.Unlisted, a.Off, a.Fallback, a.Routing, a.Affinity, a.KeepLogin, a.KeepLoginAs, a.Contexts, a.Family = pk.Models, pk.Unlisted, pk.Off, pk.Fallback, pk.Routing, pk.Affinity, pk.KeepLogin, pk.KeepLoginAs, pk.Contexts, pk.Family
|
||||
a.Sink = pk.Sink
|
||||
a.Proxy, a.AccountProxies, a.AccountModels = pk.Proxy, pk.AccountProxies, pk.AccountModels
|
||||
a.AccountCaps = pk.AccountCaps
|
||||
a.AccountCaps, a.AccountWindowCaps = pk.AccountCaps, pk.AccountWindowCaps
|
||||
a.MaxConcurrency, a.PinUpstream = pk.MaxConcurrency, pk.PinUpstream
|
||||
a.AccountConcurrency, a.QueueLimit, a.QueueWait = pk.AccountConcurrency, pk.QueueLimit, pk.QueueWait
|
||||
a.MaxRPM = pk.MaxRPM
|
||||
@@ -567,7 +580,7 @@ func Save(p Provider) error {
|
||||
if p.ID == "kiro" {
|
||||
key = p.Key
|
||||
}
|
||||
p = Provider{ID: p.ID, Key: key, Models: p.Models, Unlisted: p.Unlisted, Off: p.Off, Fallback: p.Fallback, Routing: p.Routing, Sink: p.Sink, Affinity: p.Affinity, KeepLogin: p.KeepLogin, KeepLoginAs: p.KeepLoginAs, Contexts: p.Contexts, Family: p.Family, Proxy: p.Proxy, AccountProxies: p.AccountProxies, AccountModels: p.AccountModels, AccountCaps: p.AccountCaps, MaxConcurrency: p.MaxConcurrency, AccountConcurrency: p.AccountConcurrency, QueueLimit: p.QueueLimit, QueueWait: p.QueueWait, MaxRPM: p.MaxRPM, PinUpstream: p.PinUpstream, Hidden: hiddenAccount(p.ID), Quiet: quietAccount(p.ID), Tucked: tuckedAccount(p.ID)}
|
||||
p = Provider{ID: p.ID, Key: key, Models: p.Models, Unlisted: p.Unlisted, Off: p.Off, Fallback: p.Fallback, Routing: p.Routing, Sink: p.Sink, Affinity: p.Affinity, KeepLogin: p.KeepLogin, KeepLoginAs: p.KeepLoginAs, Contexts: p.Contexts, Family: p.Family, Proxy: p.Proxy, AccountProxies: p.AccountProxies, AccountModels: p.AccountModels, AccountCaps: p.AccountCaps, AccountWindowCaps: p.AccountWindowCaps, MaxConcurrency: p.MaxConcurrency, AccountConcurrency: p.AccountConcurrency, QueueLimit: p.QueueLimit, QueueWait: p.QueueWait, MaxRPM: p.MaxRPM, PinUpstream: p.PinUpstream, Hidden: hiddenAccount(p.ID), Quiet: quietAccount(p.ID), Tucked: tuckedAccount(p.ID)}
|
||||
} else {
|
||||
p.AccountProxies = keyProxies(p) // a provider of keys proxies each key apart
|
||||
if subscriptionID(p.ID) && !stored(p.ID) {
|
||||
@@ -868,6 +881,7 @@ func normalize(p Provider) Provider {
|
||||
p.AccountProxies = normalAccountProxies(p.AccountProxies)
|
||||
p.AccountModels = normalAccountModels(p.AccountModels)
|
||||
p.AccountCaps = normalAccountCaps(p.AccountCaps)
|
||||
p.AccountWindowCaps = normalWindowCaps(p.AccountWindowCaps)
|
||||
p.ZhipuTeam = p.ZhipuTeam.normal()
|
||||
p.remoteMagpieEndpoints()
|
||||
for _, u := range []*string{&p.Chat, &p.Responses, &p.Anthropic, &p.Gemini, &p.Decide, &p.Website, &p.KeysURL} {
|
||||
|
||||
@@ -125,7 +125,7 @@ type Limit struct {
|
||||
Unit string
|
||||
matches func(string) bool
|
||||
partial bool // of a reading that may leave windows out (QuotaWindow.partial)
|
||||
name string // QuotaWindow.Name: which window it is, one reading to the next
|
||||
Name string // QuotaWindow.Name: which window it is, one reading to the next (WindowCapID)
|
||||
// ResetRunsOut is when the reset the account spends by itself before
|
||||
// it runs out does (resetRunsOut): spent then, it starts this window
|
||||
// again — at Restarts, which routing takes for the window's renewal
|
||||
@@ -623,7 +623,7 @@ func allowanceOf(ws []QuotaWindow, now time.Time) Allowance {
|
||||
if w.Aside {
|
||||
continue
|
||||
}
|
||||
l := Limit{Used: w.Used, Span: w.Span, Model: w.Model, Amount: w.Amount, Of: w.Limit, Unit: w.Unit, matches: w.matches, partial: w.partial, name: w.Name}
|
||||
l := Limit{Used: w.Used, Span: w.Span, Model: w.Model, Amount: w.Amount, Of: w.Limit, Unit: w.Unit, matches: w.matches, partial: w.partial, Name: w.Name}
|
||||
if ids := families[w.Model]; ids != nil && w.Family != "" && w.matches == nil {
|
||||
l.Model = ""
|
||||
l.matches = func(model string) bool { return ids[model] }
|
||||
|
||||
@@ -65,6 +65,9 @@ type QuotaWindow struct {
|
||||
// only — one model's own, or a pool's — so the cap holds the account
|
||||
// for those alone, and the GUI says which.
|
||||
CapsSome bool `json:"capsSome,omitempty"`
|
||||
// CapID is, beside Capped, how the window is known to a cap of its
|
||||
// own (WindowCapID), for the GUI to set and show one.
|
||||
CapID string `json:"capId,omitempty"`
|
||||
// Holds is what the whole window holds, reckoned from magpie's own
|
||||
// calls through the account (usage.WithWindowHolds), for the GUI only;
|
||||
// nil where that can't be told honestly.
|
||||
|
||||
+72
-11
@@ -42,6 +42,9 @@ const providerUsage = `usage:
|
||||
magpie provider account-cap <id> [account [percent|off]]
|
||||
use a subscription account up to a share of each usage window (e.g. 70):
|
||||
at it, routing takes the account for used up until the window renews
|
||||
magpie provider account-cap <id> <account> --window <name> [percent|none|default]
|
||||
a share for one window alone (e.g. "5 hours" 50): none is no cap on it,
|
||||
default has it follow the account's cap
|
||||
magpie provider account-concurrency <id> [account|key [n|off|default]]
|
||||
how many requests one account or key has out at once, over every model,
|
||||
routing group and agent: its own, off for none, default for the provider's
|
||||
@@ -1133,10 +1136,30 @@ func accountModelsCmd(rest []string) error {
|
||||
|
||||
// accountCapCmd shows, or sets with a share or off, the usage cap of a
|
||||
// subscription's accounts: the share of each window one is used to at most
|
||||
// (provider.AccountCaps).
|
||||
// (provider.AccountCaps); with --window <name>, the share of that window
|
||||
// alone (provider.AccountWindowCaps).
|
||||
func accountCapCmd(rest []string) error {
|
||||
if len(rest) < 1 {
|
||||
return fmt.Errorf("magpie provider account-cap <id> [account [percent|off]]")
|
||||
usage := fmt.Errorf("magpie provider account-cap <id> [account [percent|off]]\n magpie provider account-cap <id> <account> --window <name> [percent|none|default]")
|
||||
window, windowSet := "", false
|
||||
for i := 0; i < len(rest); i++ {
|
||||
a := rest[i]
|
||||
if v, ok := strings.CutPrefix(a, "--window="); ok {
|
||||
window, windowSet = v, true
|
||||
rest = slices.Delete(slices.Clone(rest), i, i+1)
|
||||
i--
|
||||
continue
|
||||
}
|
||||
if a == "--window" {
|
||||
if i+1 >= len(rest) {
|
||||
return usage
|
||||
}
|
||||
window, windowSet = rest[i+1], true
|
||||
rest = slices.Delete(slices.Clone(rest), i, i+2)
|
||||
i--
|
||||
}
|
||||
}
|
||||
if len(rest) < 1 || windowSet && (len(rest) < 2 || strings.TrimSpace(window) == "") {
|
||||
return usage
|
||||
}
|
||||
p, err := provider.Find(rest[0])
|
||||
if err != nil {
|
||||
@@ -1146,12 +1169,23 @@ func accountCapCmd(rest []string) error {
|
||||
return fmt.Errorf("%s has keys, not subscription accounts with usage windows to cap", p.Name)
|
||||
}
|
||||
if len(rest) > 2 {
|
||||
cap, err := provider.ParseCap(rest[2])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := provider.SetAccountCap(p.ID, rest[1], cap); err != nil {
|
||||
return err
|
||||
if windowSet {
|
||||
cap, err := parseWindowCap(rest[2])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = provider.SetWindowCap(p.ID, rest[1], window, cap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
cap, err := provider.ParseCap(rest[2])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := provider.SetAccountCap(p.ID, rest[1], cap); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p, err = provider.Find(p.ID); err != nil {
|
||||
return err
|
||||
@@ -1169,15 +1203,42 @@ func accountCapCmd(rest []string) error {
|
||||
return nil
|
||||
}
|
||||
for _, r := range refs {
|
||||
if c := p.AccountCap(r); c > 0 {
|
||||
fmt.Printf("%s · capped at %d%% of each usage window\n", r, c)
|
||||
caps := p.CapsOf(r)
|
||||
if caps.All > 0 {
|
||||
fmt.Printf("%s · capped at %d%% of each usage window\n", r, caps.All)
|
||||
} else if len(caps.Windows) > 0 {
|
||||
fmt.Println(r, muted.Render("· no cap on its other windows: used to 100%"))
|
||||
} else {
|
||||
fmt.Println(r, muted.Render("· no cap: used to 100%"))
|
||||
}
|
||||
for _, w := range slices.Sorted(maps.Keys(caps.Windows)) {
|
||||
if c := caps.Windows[w]; c >= 100 {
|
||||
fmt.Printf(" %s · no cap on this window\n", w)
|
||||
} else {
|
||||
fmt.Printf(" %s · capped at %d%%\n", w, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseWindowCap reads a window's own share as the CLI takes it: "50",
|
||||
// "50%", none (off, 100) for no cap on that window, default (-) to follow
|
||||
// the account's cap.
|
||||
func parseWindowCap(s string) (int, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "default", "account", "-", "0":
|
||||
return 0, nil
|
||||
case "none", "off", "no", "100", "100%":
|
||||
return 100, nil
|
||||
}
|
||||
n, err := provider.ParseCap(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("a window's cap is a share from %d to %d (percent), none for no cap on it, or default to follow the account's cap, not %q", provider.MinCap, provider.MaxCap, s)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// accountConcurrencyCmd shows, or sets, the limit on requests at once of a
|
||||
// provider's accounts or keys (#892): each one's own, else the provider's
|
||||
// Concurrency. What runs and waits now is the gateway's, at GET
|
||||
|
||||
Reference in new issue
Block a user