provider, gateway: a usage window whose reset has passed is renewed on every path, and an account resting out of it is back once a reading finds it renewed (#1200)
* provider: a usage window whose reset has passed is renewed on every path, so a fresh Claude /usage reading at 100% a minute after its reset no longer counts the account spent or moves Claude Code off it
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% used · resets <a minute ago>".
claudeWindows handed that fresh reading back raw while its cached and
header paths already went through elapsed, and usedPast judged Used
without looking at ResetsAt. NextLogin dropped passed windows only for a
Claude reading kept from before (AsOf) and only for the account the agent
is on, not for the account to go back to or the spares.
- claudeWindows: the fresh /usage path returns its windows as of now
(elapsed), as the cached and header paths do. What is kept in
claudeUsage stays as read.
- resetPassed is the one test for a window that has started again.
usedPast, usedUp, UsedUp and BackAt take now and leave such a window out,
for Codex and Claude Code alike; NextLogin's Claude-and-AsOf special case
goes, since every account it looks at (on, back, spares) is now judged
that way. capReached already was.
- quota_last's reading still decides whether a Codex hold came with a
used-up window from the reading as it was read (usedUp at the zero time),
so the hold ends when that window starts again, as before.
- magpie quota wait and the Codex expiring resets pass their own now, so
quota wait no longer waits out a window that has already reset.
- docs/subsystems/providers-accounts.md says so, and that a reset /usage
gives as a time alone ("resets 3pm") is read as its next one by
claudeResetTime, which this change can't see as passed.
Verified under a temporary HOME with GOPATH, GOMODCACHE and GOCACHE
pinned: go vet ./..., go build, GOOS=linux go build -tags nogui and
GOOS=windows go build pass. go test -tags nogui ./... passes except
internal/gui's TestSetAnswersWhileDevinAndProvidersHang once in the full
run (set slow/slow-1 took 1.67s against its 1.5s limit, with other
sessions' suites running); it passed -race -count=20 on this branch and on
upstream/main, and internal/gui passed on its own. go test -race -count=20
on the new tests passes.
New tests (window_renewed_test.go) and what fails without the change:
- TestFreshClaudeReadingPastItsResetIsNotSpent: with the fresh path
returning raw ws, "the session reads 100% (reset ... 14:20:00 UTC)". The
control with its reset an hour away is spent and moves to the spare.
- TestNextLoginTakesAPassedResetAsRenewed (codex and claude): with usedPast
and BackAt ignoring ResetsAt and the old AsOf drop back, NextLogin moves
off an account whose full window reset a minute ago (fresh, and for Codex
kept), won't move to a spare or back to the first whose full window has
reset; the controls with resets an hour away are unchanged.
- TestUsedUpLeavesOutARenewedWindow: with BackAt and usedUp ignoring
ResetsAt, "a window reset a minute ago stops the account".
TestClaudeUsageExpiredReadingDoesNotSwitchAccounts and TestCodexHeld* pass
unchanged. Not tried against a real Claude or Codex account here.
* provider, gateway, docs: a subscription account resting out of its window is back once a reading finds it renewed
A Claude account rested for a full five hours or week waited out the whole
rest (the time Claude Code's refusal named, or c.full), even when a new
/usage reading or the reset passing showed the window started again: only
a Codex reset spent and a key's reading ever told OnRenewed.
provider: Allowances, the reading routing goes by for every account,
keeps each account's last reading and when it was taken (usedCache.seen,
kept through a failed reading). A new reading that finds the account full
till sooner than the last (renewedFrom, the rule keyAllowances' keep uses
for keys) tells OnRenewed as the agent its usage is read under
(Account.UsageAgent: "claude", or "plugin:<id>" for a plugin-served
subscription) and the user. Scopes are the whole account, and each pool of
some models with the whole account's windows over it. A window used below
SpentShareOf its routing, or whose reset passed, is full no more; one full
with its reset unknown is full for good. Only windows in both readings
are compared. So a first, failed, alike or partial reading tells nothing,
nor does a five hours renewed inside a used-up week, nor two windows of
one name. Hooks are told after usedCache is unlocked and before the
reading's waiters are released. renewedNow drops the account's seen
reading so a spent reset is told once.
gateway: renewed lifts only an account's rests out of quota or by
"window"; a rest for a rate limit, credit, verification or a failure
stays, as a key's already did. Before, it only ran for a Codex reset spent;
now readings reach it for every subscription.
docs: providers-accounts (Allowance step, test list), gateway-routing
(account rests lifted by a reading, test lists), reference (when an
account runs out).
Verified: TestClaudeAccountToldRenewed (a fake /usage: 100% tells
nothing, 99% tells nothing, 3% tells once, more use doesn't tell again; a
kept window whose reset passes tells once), TestPluginAccountToldRenewed
(plugin:fakeco; a failed reading in between, a reset spent, and a reset
passed while the vendor still says 100%), TestRenewedFrom (table, including
the five hours inside a full week and same-named windows), and
TestClaudeAccountBackOnceItsWindowRenews (gateway: a Claude account resting
by "resets" is back once a reading shows 4%, another still full keeps its
rest). TestQuotaRestsUntilItsReset now checks a rate-limit and a backoff
rest survive renewed. Without the tell, all three renewal tests fail (told
[], still rests by resets); mutating each branch of renewedFrom/fullTill
fails TestRenewedFrom or the plugin test. Under a temporary HOME: go vet
./..., go build, GOOS=linux go build -tags nogui, GOOS=windows go build,
go test -tags nogui ./... and go test -race -count=20 (the gateway test also -count=50) on the new and
changed tests pass. Not tried with a real Claude account.
Not covered here: once a /usage run prints Claude's limit refusal,
claudeWindows returns that error until /usage or a header succeeds again,
so Allowances skips the account and the reset passing tells nothing. That
path is claudeWindows' own; the change making passed windows count as
renewed there lets this tell reach it.
* gateway: a Codex subagent's sealed task goes back to the Responses provider that answered its lead (a Sub2API relay of the ChatGPT backend), relayed as it came, instead of being refused before it is asked; the refusal names who sealed it (#1109)
Codex on Ultra with s2a/gpt-6-astra for both the lead and the subagent,
s2a a Sub2API relay: the relay's ChatGPT backend sealed the lead's
spawn_agent task, and magpie turned the subagent away because only a
Codex account counted as able to read it, telling a user already on a
magpie model to "use a Magpie-served model for the lead".
The provider that answered the lead (leadProvider, from the lead
thread's affinity in any scope) now reads it too when it is a key
provider serving the model on Responses; the task is relayed, even
when Codex offers web_search (it was translated, which dropped the
agent_message's encrypted_content). A subagent on another provider, or
whose lead magpie never answered, is still refused before anyone is
asked, with a message naming the lead's provider when known.
Verified: TestSealedTaskGoesBackToTheRelayThatSealedIt (lead and
subagent on s2a/gpt-6-astra, with Codex's web_search tool) fails without
the change with the old 400; without only the relay part it fails with
the ciphertext dropped from the upstream request.
TestSealedTaskRefusedByAnotherRelay covers a lead on s2b and an unknown
lead. go test -race ./internal/gateway, go vet ./..., GOOS=linux and
GOOS=windows builds pass.
* codexcat: a third-party entry for a model Codex has its own entry for (s2a/gpt-6-astra) says the same multi_agent_reasoning_effort, so Codex's Ultra sends xhigh for it as for OpenAI's gpt-6-astra instead of falling back to max (#1108)
Codex resolves Ultra to the entry's multi_agent_reasoning_effort, and to
max when the entry has none and lists max. magpie's entries never carried
the field, so the same model ran at max through a relay and at xhigh
through the ChatGPT account. The value is taken from Codex's own entry in
models_cache.json for the model's slug (after the provider prefix and a
dated snapshot suffix), only when the third-party entry offers Ultra and
the effort itself; models whose own entry says nothing (gpt-6-sol today)
keep Codex's max. The gateway's literal "ultra" -> max mapping is
unchanged: Codex sends the resolved effort, not "ultra".
TestUltraEffortAsCodexOwnEntry, built from the reporter's entries, fails
without the change ("s2a/gpt-6-astra: multi_agent_reasoning_effort <nil>,
want xhigh"). go test -race ./internal/codexcat ./internal/gateway
./internal/agent and go vet ./... pass in a sandbox HOME.
* gateway: a DeepSeek model whose Codex request is passed through as it is (OpenCode Go's deepseek-v4.1-flash) gets its reasoning back as reasoning_text, and is never asked without it (#1104)
Codex hands a reasoning item back as it got it; one from a turn magpie
translated, or another provider in a mixed group served, has only its
summary. Passed through to OpenCode Go's /responses, DeepSeek refused the
request with "The `reasoning_text` in the thinking mode must be passed back
to the API". That refusal names the input, so passthrough asked once more
without every unsealed reasoning item (withoutBareReasoning) and marked the
model as refusing them, which left the reasoning out of every later request.
For a model that wants its reasoning back (replaysReasoning, the predicate
#388's buildResponses uses), a reasoning item without reasoning_text now
gets its summary as one, and withoutBareReasoning is never applied to it.
TestDeepSeekReasoningPassedThrough plays OpenCode Go with the reporter's
error word for word. Without the change it fails ("reasoning_text sent =
\"\", want the summary"); with only the strip guards taken out it fails ("a
reasoning item with nothing in it was answered"). go test -race
./internal/gateway, -race -count=10 on the new test, and go vet ./... pass.
* agent: a catalog sync or a model pick keeps the keys a user or another tool added to magpie's provider block (Pi's providers.magpie.compat.sendSessionAffinityHeaders from pi-cache-optimizer) instead of writing the block whole (#1103)
syncJSON and Pi's writeMagpie set providers.magpie to a freshly built
object, so a key magpie doesn't write there was dropped on every sync.
theirsKept now merges magpie's block into what the file holds: keys magpie
writes are its own, objects in both are merged key by key, and anything
else (compat, an options key, Crush's extra_headers) stays. The model list
is still magpie's whole, so a model gone from the catalog leaves it, and a
sync with nothing new writes nothing.
The same goes for every agent that writes the block whole: Pi and OmO,
Aside, Pencil, OpenCode (and the agents built on it), OpenChamber, Crush
and Command Code.
Verified: TestPiKeepsTheirKeysInMagpiesProvider (the reporter's
compat.sendSessionAffinityHeaders: true in Pi's models.json, through
SyncCatalog and a pick) and TestOpenCodeAndCrushKeepTheirKeysInMagpiesProvider
fail without the change ("compat.sendSessionAffinityHeaders dropped by the
sync", "opencode sync") and pass with it. go test -race ./internal/agent
and go vet ./... pass in a sandbox HOME.
* providers: a SiliconFlow key's card says SiliconFlow retired its balance query and has no API in its place yet, and that the balance and vouchers are on its console, instead of "410 Gone: {...}" (#1094)
SiliconFlow stopped /v1/user/info on 2026-08-14 (release notes, 2026.08.11)
and answers it with 410 {"code":20092,"message":"This endpoint is
deprecated and is no longer available.","data":null}. Its docs and release
notes name no replacement yet ("new interfaces will be announced
separately"), and the balance given before 2025-12 became vouchers, seen
under 余额充值 → 代金券 on the console. So the card now says that and links
cloud.siliconflow.cn (cloud.siliconflow.com for the international site). A
key SiliconFlow refuses (401 30014 "Token is invalid.") is still said as it
came, and a 200 is still read.
TestSiliconFlowRetiredBalanceQuery replays the reporter's literal 410 body
for both hosts, plus the real 401 body and the documented 200; without the
change it fails with `410: true 410 Gone: {"code":20092,...}, want it
retired and https://cloud.siliconflow.cn named`. go test -race
./internal/provider and go vet ./... pass. Not tried with a real
SiliconFlow key (none on this machine); the 401 body was read from the
live endpoint with a dummy key.
* gui tests: routing-failed-open measures the 400's row once the live page has followed the newest request and drawn its story
In WebKit the live cases read the row's top as soon as the list was there,
before the page had followed the newest request and drawn its story above
the list (.rt-log grows 17 → 104px), so the row was 87px lower by the click
and "picking the request moved the page" failed — with the test's own
commit's assets too (d6096e2f), so it is the test's timing, not a
regression. The test now waits for the newest row to be pressed and the
story's steps, then for the row's top to hold still, before clicking.
Before: webkit en/zh live failed 2 of 2 (row 524 → 611). After: 8 of 8
(chromium and webkit, en and zh, live and kept day), three runs.
* gui tests: profile-confirm finds and measures the home chip in one call, so a list drawn again in between doesn't read as a chip at 0
Under load "closing them leaves the chip where it is: 0 → 259" failed in
both engines: the chip's top before opening its details read 0 while a probe
right after read it at 259. The locator found the chip, the profiles list
was drawn again from the state that came back (renderProfiles replaces the
chips), and the evaluate then measured the detached old chip, whose
bounding rect is all zeros. The chip never moved; the test's two-step
lookup raced the redraw. top() now finds the chip and measures it inside
one page.evaluate.
Eight copies at once (chromium and webkit, en and zh, window and panel):
before 9 and 15 of 80 failed in two runs; after 80 of 80 pass.
* usage: in a narrow window the Requests table's account column keeps room for both source badges, where it cut off "Subscription" (since fdcdb5af, #860)
fdcdb5af's tighter fit capped .led .where at 110px. The account cell's
source badges ("✓ Official" and "Subscription" in English, ~149px) don't
wrap and the cell hides its overflow, so at 440px the second badge was cut
off at the cell's edge. The 110px cap now goes on the account's name
(.where-name, still cut with an ellipsis); the column keeps the tight
fit's 160px cap, enough for the badges.
session-identity's "both badges share one row below the account at 440"
caught this only when the machine was loaded: it measured right after
setViewportSize, before ledFit's frame had applied the tighter fit, so on
an idle machine it saw the 1200px layout and passed. It now waits two
frames and 100ms first. With that wait, the test fails on fdcdb5af's
assets (en, chromium and webkit: badge right 457 > cell right 410) and
passes on fdcdb5af^ and with this change. ledger-*, usage-ledger* (incl.
usage-ledger-fit, #860's) and session-identity pass in chromium and
webkit: 90 of 90.
* gui tests: purpose-filter doesn't count WebKit's "ResizeObserver loop completed with undelivered notifications" as a page error
webkit en and zh failed in the full suite and under load with errors ==
['ResizeObserver loop completed with undelivered notifications.']. Traced
with every ResizeObserver callback logged per frame: picking a purpose
shrinks the Usage page from 100 rows, the click's hold (heldSizes in
app.js) gives the view room again before the paint, and the view's 8px
scrollbar coming back narrows #view-usage 1068 → 1060, so #ledWrap,
#ledChart, table.led and the Usage head are told a second time in the
same frame, past the loop's depth. The browser delivers that round on the
next frame; nothing is painted wrong. WebKit reports it as a page error,
Chromium doesn't. The test now ignores that one message, with the reason
beside it.
Eight WebKit copies at once: before, 1 to 3 of 6–8 failed with it; after,
16 of 16 pass.
* providers: an OpenCode Go or Zen provider saved with its chat URL alone sends GPT, Grok, Muse Spark and MiniMax on the API OpenCode serves them on, not chat, and "Model does not support this protocol" says which URL was asked (#1215)
The reporter's "OpenCode Go 1x" had only chat=https://opencode.ai/zen/go/v1
and no models.dev catalog (its native_endpoints were /v1/chat/completions
for every model). Go serves each model on one API of three, as models.dev's
per-model provider.npm says and OpenCode itself asks it (@ai-sdk/openai ->
/responses, @ai-sdk/anthropic -> /messages, the rest chat), so with chat the
only URL, gpt-6-luna, gpt-5.6-luna, grok-4.6/4.7, minimax-m2.7 and
muse-spark-1.2/1.3-contributor had nowhere else to go and Go answered 400
on all three of magpie's entry points.
- normalize fills in the other two of Zen's or Go's APIs (/v1 for chat and
Responses, the root for Messages) and its catalog, only where the
provider has none of its own (openCodeEndpoints); other hosts unchanged.
- ListedAPIs reads the per-model API from the list of the gateway the URL
is at (opencode-go for /zen/go, opencode for /zen), whatever catalog was
saved: Go and Zen serve MiniMax M2.7 on different APIs, and an import at
/zen/v1 could be saved with opencode-go's.
- A wrong-endpoint error with no other API left now ends "(asked at <url>;
<model> is served on /v1/responses, which <provider> has no URL for)".
Verified: TestOpenCodeGoModelsGoOnTheirOwnAPI (gateway, all 3 client APIs x
5 models through the handler) and TestOpenCodeEndpointsFilledIn fail without
the change with the reporter's exact "OpenCode Go 1x: Model does not support
this protocol."; TestOtherRelayKeepsItsOneAPI and the relay cases keep
other providers as they were. go test -tags nogui ./... green under a temp
HOME; go vet, go build, GOOS=linux/windows builds. Real upstream: probed
https://opencode.ai/zen/go/v1 with no key only (grok-4.7 is "not supported
for format oa-compat" on chat and "format anthropic" on messages, passes to
the key check on responses); no keyed request was made.
* gui: a routing group's rule by intent has its own entry, "Route by what it asks for" beside Add a rule, and a group whose effort Jev picks offers it under the classifier (CherryL1quor on X: 模型路由能不能用 jev,现在只有思考强度能用 jev)
An intent was one of a rule's seven conditions, so a reader who had Jev
picking the effort took it for that alone. "Route by what it asks for"
adds a rule with its intent box focused. Jev is picked as the classifier
when there is one, as the effort's Picked per turn already did, and the
classifier row shows at once, before anything is typed. Typing an intent
into any rule picks Jev too when no classifier is set. A group whose
effort Jev picks, with no intent yet, says under the classifier "It can
pick the model too: add a rule with an intent", which adds the same rule.
Translated in zh, ja and de.
routing-intent-entry.test.cjs (Chromium and WebKit, en and zh, 1100 and
440 wide) checks that both buttons stay inside the editor and that the
clicked one stays under the pointer as the rule comes in above it. It
also checks the focus, that Jev is picked, the save as
{use: b/flash, intent} with classifier typesafe/jev-latest, and the offer
under an effort-only Jev. It fails on the old assets (no such button).
gui-ja, gui-de, i18n-keys, click-scroll, routing-compact, -time, -save,
-save-blank, -effort-row, -effort-change, -levels, -days-fit and every
group-* suite pass (159 tests). Not tried with a real TypeSafe key.
* docs: Intent routing's setup names Route by what it asks for, Jev as the classifier with a TypeSafe provider, and the offer under a group whose effort Jev picks (en, zh, ja)
* proc: the login shell magpie asks for its PATH runs in a session of its own, so it can't take the terminal from magpie (DD on Discord: magpie web → zsh: suspended (tty input))
askShell ran `$SHELL -ilc` (and ReadCLI's zsh, bash and fish, for Settings › Command line; LoginPath for Library › RTK) in magpie's own session and process group. An interactive zsh puts itself in the terminal's foreground, so while it ran, magpie's group was in the background of the terminal it was started from: Ctrl-C no longer reached `magpie web`, and anything left in magpie's group that touched the terminal — the bash asked beside it, whose profile runs in magpie's group without job control, or another child — had the kernel send SIGTTIN to the whole group, stopping magpie ("zsh: suspended (tty input)"). Run with `&`, the user's own shell then got EIO reading the terminal and exited. A timeout also killed only the shell, leaving what its profile started holding the terminal.
The shell is now a probe (ProbeContext): it leads a session with no controlling terminal, and its timeout and EndProbes end its whole group. It still answers with its PATH and variables.
TestAskShellOwnSession (a fake shell answering with its process group; a real zsh still gives a PATH) fails without the change: "the login shell ran in magpie's process group …". Reproduced on a pty under an interactive zsh -f, temp HOME, MAGPIE_ADDR 127.0.0.1:47811, magpie web on 47812, ~/.zshrc `sleep 2`, ~/.bash_profile reading /dev/tty, GET /api/cli: the old build prints "zsh: suspended (tty input) magpie web" in the foreground and "[1] + suspended (tty input)" with `&` (then "zsh: error on TTY read"); with a plain profile it loses the foreground (ps S+ → S). The fixed build stays S+ / running in both, and /api/cli still says zsh and bash are known. go vet -tags nogui ./..., GOOS=linux go build -tags nogui, GOOS=windows go build, and go test -tags nogui of internal/proc, internal/update, internal/library and internal/gui under a temp HOME pass; -race -count=10 of the new test passes. Not tried with DD's own profile, or on Linux.
* library: the skills market offers magpie's own magpie-quota skill first, marked official, so an agent can read what is left of every subscription, plan and key (magpie quota --json) and wait for allowance (magpie quota wait); it lives in magpie-community/plugins, and its card shows no installs or skills.sh link until skills.sh lists it (ttmouse on X: magpie quota works, suggested an official skill to go with it)
Verified: TestFeaturedSkills; market-featured-skill.test.cjs, Chromium and
WebKit, en/zh/ja/de at 520px, 8 pass (4 fail on the old library.js);
market-have and skill-online-local pass (34); a livemarket-tagged run of
InstallMarketSkill in a sandbox HOME fetched the real skill from GitHub
into the library; npx skills add magpie-community/plugins -l lists it.
go test ./...: all pass but TestSetAnswersWhileDevinAndProvidersHang
(2.8s under two other -count=5 suites); alone -count=5 passes.
* proc tests: TestAskShellOwnSession reads magpie's session with x/sys/unix, so the package's tests build on Linux (#1219)
bbf99143's test called syscall.Getsid, which Go's syscall package has on
macOS and the BSDs but not on Linux: go vet ./... (and every test run) on
Linux stopped at "undefined: syscall.Getsid", so the ubuntu Test job failed
at Vet on main and on every PR. golang.org/x/sys/unix, already required,
has Getsid on every Unix.
Verified: GOOS=linux, darwin, freebsd and android go vet ./internal/proc;
go test -run TestAskShellOwnSession ./internal/proc on macOS. Linux is run
by this PR's ubuntu job; there is no Linux machine here.
* library: a skill or server switched off for every agent is saved with "agents": [] not null, a check's finds beside skills on no agent come with [] too, and the Library page draws a find without its agents and says why when it can't draw a check's reply, where it threw on n.agents and went blank after Check for updates (#1217)
* library: installing a set of skills skips the ones in the way and installs the rest; the toast names the ones already in the library and the ones skipped (lc on Discord: 安装skills的时候,没有跳过已经安装的)
installFrom stopped the whole set at the first skill whose name was taken,
and returned before change saved library.json. The folders it had already
copied for the skills before that one stayed in the library's folder,
unlisted. Every later try then failed on the first of them with "the
library's folder already has a caveman-commit in it".
Now each skill is installed on its own (InstallSkills, InstallMarketSkill
and AddNewSkills, through installChange):
- the library lists it from the same source: had already (Result.Had),
nothing written, so the user's edits stay;
- a folder in the library's folder holding exactly its files (not a link,
sameTree, which counts a failed read as different): listed where it is,
as had;
- another skill by that name, listed or only a folder, or a folder that
can't be read: left as it is, in Result.Skipped with why;
- the rest are installed (Result.Installed). Only when nothing was
installed or had does the change fail, with the first reason.
The GUI's toast and the TUI's line say how many were installed, the ones
already in the library and each one skipped with why.
Verified (temp HOME, pinned caches):
- go vet -tags nogui ./internal/library ./internal/tui ./internal/gui .;
GOOS=windows go build and GOOS=linux go build -tags nogui of
internal/library and internal/tui: clean
- go test -tags nogui ./internal/library ./internal/tui ./internal/gui: ok
- with the old skills.go, market.go and skillnew.go, TestInstallSetSkipsOneInTheWay,
TestInstallListsItsOwnLeftoverFolder (fails with lc's exact "the library's
folder already has a caveman-commit in it") and
TestInstallSetSkipsAnotherByTheSameName FAIL; TestSkillsFromGitHub's
reinstall now asserts Had
- skill-install-skip.test.cjs (Chromium + WebKit, en and zh at 440px) fails
on the old library.js ("status ok", no names) and passes now; with
gui-ja, gui-de and 11 library GUI files, 99/100 passed, and the one that
failed (by-agent, Chromium, at load average ~150) passes 3 times in a row
in both engines when run alone
- not tried in the real app, or with lc's library
* agent tests: Claude Code's haiku tier follows Claude Haiku 5.5 where a provider serves it beside Haiku 4.5 (Discord: using Haiku 5.5)
magpie keeps no list of Anthropic's models: the Claude subscription's list
is models.dev's "anthropic" provider (catalog.Provider, cached and
refetched daily), other providers' come from their own /v1/models, and the
code that knows Claude models (claudeLight, claudeEffortsFor,
claudeReadsTop, the gateway's adaptiveOnly) reads versions, not ids. So
claude-haiku-5-5 needs no entry: once a provider lists it, claudeLight
picks it over claude-haiku-4-5 and its dated id, and Claude Code is sent
every effort level for it, read from modelSettings only.
TestClaudeLightHaiku55 holds that, on an Anthropic-style provider and on a
router's dotted ids (anthropic/claude-haiku-5.5, as Vercel lists it). With
claudeLight's newest-first compare reversed it fails: claudeLight
("anth/claude-opus-5-5") = "anth/claude-haiku-4-5", want
"anth/claude-haiku-5-5". go vet -tags nogui and go test -tags nogui
./internal/agent pass under a temp HOME.
Not added: models.dev's anthropic provider doesn't list claude-haiku-5-5
yet (only Vercel's gateway does, released 2026-10-07), so the Claude
subscription won't offer it until models.dev does. No Bedrock id was given
or listed, so the Bedrock preset is unchanged. Not tried with a real
account.
* gui, tray, alerts, settings: magpie speaks Traditional Chinese (繁體中文) with Taiwan wording, picked on its own for a zh-TW, zh-HK or zh-Hant system
Settings → Language offers 繁體中文 beside 简体中文. On System, a
browser or OS locale of zh-TW, zh-HK, zh-MO or zh-Hant (zh_TW.UTF-8 too)
shows zh-TW; zh-CN, zh-SG and zh-Hans-HK keep Simplified. The tray menu
and quota alerts follow the same choice. Release notes fall back to
Simplified.
The zh-TW strings are generated from the zh strings with opencc
(cn→twp), then corrected to Taiwan wording: 設定, 檔案, 帳號, 伺服器,
預設, 閘道, 連接埠, 參數, 選單, 透過, 取得, 用戶端, 存取, 終端機, 「」
quotes. A quota window is 時段 and a context window stays 上下文視窗.
Number units use 萬/億, and #unitsRow is shown for zh-TW.
Verified:
- New gui-zh-tw.test.cjs, in Chromium and WebKit:
- key parity, placeholders and tags against zh;
- no Simplified characters;
- the Taiwan words;
- locale detection for each system tag;
- <html lang>, nav labels, fmtN units, the picker;
- Settings Language at 1000px and 440px.
With the sysLocale zh-TW line removed, it fails. On the base assets,
all 3 fail.
- gui-ja, gui-de and i18n-keys pass.
- go vet, and go test -tags nogui for ./internal/gui and
./internal/settings, pass. That includes the new zh-TW cases in
TestTrayLang / TestTrayLabels / quota_alert_test, which fail without
the zh-TW branches.
* gui: zh-TW gets the four strings added beside it — Route by what it asks for, its hint, and the skill install's already-in-the-library and skipped notes
* provider: moved Devin users get the plugin's 0.1.9, which sends a tool whose parameters have a root anyOf, oneOf or allOf as a plain object, so Devin's Claude models no longer answer 502 to Codex desktop's automation_update (#1196)
* gateway: the built-in Devin sends a tool whose parameters have a root anyOf, oneOf or allOf as a plain object, as the plugin's 0.1.9 does, so Devin's Claude models no longer answer 502 to Codex desktop's automation_update for users still on the built-in (#1196); provider.ObjectRoot keeps the root's and allOf's own required fields beside anyOf/oneOf branches, which only add the fields every branch requires (it used to intersect them away)
buildDevin folds every offered tool's parameters through objectSchema
(provider.ObjectRoot, as anthropic.go does for #646); a schema with no root
combinator goes byte for byte as it came.
TestBuildDevinObjectRoot builds from #1196's literal parameters with oneOf,
anyOf and allOf; without the devin.go change it fails ("oneOf: sent {...
oneOf ..."). TestObjectRootKeepsOwnRequired fails on the old ObjectRoot with
"required = [id n]" (allOf's "kind" dropped). Run: go test -tags nogui
./internal/gateway ./internal/provider (temp HOME, pinned caches), both ok;
GOOS=linux and GOOS=windows go vet -tags nogui on both packages, ok. Not
tried against a real Devin account.
* gui: the subscription windows' fit (stacked under the name or beside it) is decided on the next frame, not in its ResizeObserver's own call, so WebKit no longer reports a ResizeObserver loop as a page error (account-mask)
* context: magpie inspects the context window of every request, session and agent
The gateway reads each request's prompt into its parts (system prompt,
tools and MCP servers, memory, files, tool results, chat), measured
against the model's window and calibrated to the tokens the vendor
counted. Routing shows a request's window as a card: a 400-cell grid by
part, a hover naming what is largest in a cell, and the contents by part.
The Usage page's Context tab scores each agent out of 100 (cache reuse,
how lean it starts, how fast it grows, how reliably it answers), tags it
(lean, cache-friendly, mcp-heavy, compacts, near-full…), and draws each
session's growth with its latest request's card.
Older Safari (15) gets media-query and solid-colour fallbacks for the
card's container queries and color-mix.
* provider: Volcengine plans use their preset models instead of /models (xyenon) (#1216)
Coding Plan's /models returns Ark's general catalog, while Agent Plan's
returns 404. Skip their model-list requests and ignore old fetched lists,
including their protocol and key restrictions. Pay-as-you-go and an
explicit list URL still fetch normally; saved model choices stay intact.
Both plan endpoints were checked with real keys. TestVolcengineModelLists
fails without the no-list behavior and passes with it. Vet (gtk3) and
Linux GUI/CLI and Windows builds pass under a temporary HOME. The full
nogui suite passes except TestCodexArchivedSameSizeRewriteInvalidatesDiscovery
in sessions, also reproduced separately with -race -count=20.
Amp-Thread-ID: https://ampcode.com/threads/T-01a11791-db8b-7229-ade8-5e3600b835fc
Co-authored-by: Amp <amp@ampcode.com>
* gui tests: fold-show-grip measures Show and the picker once the fold's rows have slid in, in one read, and reads the grips from the page as drawn now
The fold's rows open with translateY(-10px) scale(.985) over .56s after a
staggered delay. The test read Show's box and the picker's box in two round
trips while the row still moved, so the second read landed lower (Show
355.7 vs picker 361.3): it failed in 3 of 4 runs alone, in either engine.
It now waits for the fold's animations to finish and reads both boxes in one
evaluate. The Usage page's cards are drawn again as its reads come in
(Chromium: three grips within 35ms of the first), so the grip's style and
the card, grip and logo boxes are read from the page in one call rather
than through a handle that may already be replaced. The hover-only grip is
unchanged.
* gui tests: api-key-theme finds where Settings' sharing On is in one read, so a #lanList drawn again in between doesn't read as no box
Settings opened on Network reads the settings twice (show("settings") and
load() after /api/state), and each draws #lanList again, 20-50ms apart.
The test waited for On, then asked its box in a second call; under load the
button it had found was already replaced, boundingBox() was null and the
test threw "Cannot read properties of null (reading 'y')". With #lanList
drawn again every 1ms for 600ms after it first shows, the old test fails
that way in Chromium and WebKit; this one passes.
* usage: a GLM Coding Plan key's card on bigmodel.cn or Z.ai shows its reset cards, five-hour and weekly, counted from customer-package-reset/list (targetType=PERSONAL) as the vendor's usage page counts them: only those still available; a list that can't be read shows none, never 0. Read only, never spent from magpie (#1191)
The list is asked with the plan's key, bare in Authorization, at the host its
term (subscription/list) is asked at, so open.bigmodel.cn and api.z.ai alike;
a ZCode account on the built-in gets the same. The tooltip says "The plan's
resets" for a person's own, "The team plan's" for a team's (ResetCredits.Team).
Tested: the reporter's literal reply (ids and times filled in, same shape)
in TestZhipuPersonalResets, TestPlanQuotas and TestZCodeAccounts, each
failing with the change reverted; reset-each-title in Chromium and WebKit,
en/zh/ja/de; a real Z.ai key through zhipuPersonalResets (1 five-hour,
1 weekly). Not tried: a real bigmodel.cn key.
* ci: the GTK and WebKitGTK install on Linux gives up after 15 minutes, where a stuck apt held main's Test run for 2.5 hours (b0d456c3) and every later push queued and was replaced behind it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* library tests: skillnew_test.go's imports are gofmt'd (Gofmt red on main since bb8a3e30, run 37693090857)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* davsync tests: the slow transfer's line is narrow both ways, so a download on macOS doesn't burst through the tuned-up buffers past the 300ms watchdog (TestSlowTransferIsNotCutShort red on macos-latest in main's run 37693090857: "took nothing for 300ms (GET)"; b1cba654 narrowed only the upload's way)
* plugin: a failed write to the plugin host ends the host before its caller hears of it, so a call freed by the failure finds the host already gone (TestHostWriterFailureWakesWaiters, red on macOS CI) (#1214)
* provider, gateway: a Claude /usage reset with no year is read within its window, so a reading a few days old no longer benches an account for a year (#1212)
* provider: a Claude reset read in the hour the clocks repeat, or the night they skip it, is the coming one, and a Dec 31 with no year read just after New Year is last year's
claudeResetTime turns /usage's reset text into the ResetsAt of the
"5 hours" and "7 days" windows, which smart routing orders accounts by and
rests a full account until.
- Where the clocks go back and read a time twice, time.Date gives either
instant: the first west of UTC, the second east of it. Each reading is
now considered (nextReading), and the one not yet past is taken.
Before, at 2026-11-01 01:20 EST (the second 01:20), "Nov 1, 1:30am
(America/New_York)" was 01:30 EDT, 50 minutes past, so a full window
read as renewed; "1:30am" was 24h10m away instead of 10m. In Berlin at
02:20 CEST on 2026-10-25, "2:30am" was 02:30 CET, 1h10m away instead of
10m (Sydney and Lord Howe alike).
- A date with no year is in the first of last year, this year and next
that is less than a day past, as the doc comment already said. Before,
only this year and next were tried, so at 2027-01-01 00:00:05
"Dec 31, 11:59pm" was 2027-12-31, 8760h away, and a 7-day window that
had just renewed read as renewing in a year.
- A time alone the clocks skip today is the next day that has it,
rebuilt from the clock read rather than from the normalized time.
Before, the evening after New York skipped 02:00-02:59, "2:30am" was
tomorrow's 1:30am; at 23:31 before Santiago skips midnight, "12:30am"
was 23:30 that night, a minute past, instead of 00:30 the day after
tomorrow.
What Claude Code prints, from the /usage formatter in the 2.1.290 binary:
every line of `claude -p /usage` carries a date, with the year only when
it isn't this one, so the New Year case needs /usage run just before
midnight and parsed after, and the repeated hour shows in the dated form.
Whether "at" or a comma joins date and time follows the platform's ICU,
and both are parsed. A time alone is the older form, which the TUI still
prints for the session within a day.
No other parser in internal/provider fills in a missing date or year:
zhipuTime and mimoTime read full dates in UTC+8, and the rest read
RFC3339 or Unix times.
Verified:
- TestClaudeResetTimeAroundClockChanges and TestClaudeResetTimeAtNewYear
are new. With upstream's claudeResetTime put back, 15 rows fail, e.g.
`"Nov 1, 1:30am (America/New_York)" at 2026-11-01 01:20:00 -0500 EST:
2026-11-01 01:30:00 -0400 EDT true (in -50m0s)` and `"Dec 31, 11:59pm
(America/New_York)" at 2027-01-01 00:00:05 -0500 EST: 2027-12-31
23:59:00 -0500 EST true (in 8759h58m55s)`; the 16 controls pass. With
the change, all pass, as does TestParseClaudeUsage, also with
-race -count=20.
- An oracle that lists every instant a zone shows a clock at was swept
over 518 zones, every transition 2025-2030, 85M calls: no deviation in
anything Claude Code can print. What remains is Feb 29 in a non-leap
year and dated times the clocks skip, both unchanged from upstream.
- Under a temporary HOME with GOPATH, GOMODCACHE and GOCACHE pinned:
go vet ./..., go build, GOOS=linux go build -tags nogui,
GOOS=windows go build, go test -tags nogui ./... all pass.
- Not run: claude -p /usage on a real account across a clock change or
New Year.
* provider: a Claude reset further ahead than its window can reach is the one already past, so a /usage reading days old no longer rests an account for a year
claudeResetTime now gets the window's length (5 hours for "Current
session", 7 days for "Current week ...") and never reads a reset further
ahead than that plus claudeResetSlack (2h, for this machine's clock being
behind Anthropic's; Claude Code prints the reset from Anthropic's instant
with seconds dropped, so never later).
- A date with no year is in the first of last year, this year and next
that is less than a day past and within the window. Before, it was the
first less than a day past: read on Oct 5, a week's "Oct 1, 3pm" was
2027-10-01, 8667h away, and routing (candidate.full, Allowance.Full)
rested a full account that long. Now it is 2026-10-01.
- Where no year fits, the latest year where it is already past is taken,
rather than dropping ResetsAt. The date read fine, and the window's
length rules out every later year, so the reset has passed and the
window has renewed, which is what elapsed() does with a past reset.
Dropping it would keep a 100% window with no time to clear it: Pooled
counts a full model window with no reset as out, so Opus on the account
stayed refused until the next read, and the card showed 100% with no
reset.
- A 5-hour window's date 6 days ahead ("Oct 11, 3pm" on Oct 5) is likewise
last year's and reads as renewed; a week's keeps Oct 11.
- A time alone (the older form) is the next one within the window, else
the latest day where it is already past: at noon, "11am" is today's for
the session (was tomorrow's, 23h on) and tomorrow's for the week.
- A fresh /usage read now goes through elapsed() as a kept one does, so a
reset it gives as past reads as renewed at once. Before, only the next
look did, and the switch (NextLogin's usedPast) and quota alerts saw the
fresh 100%.
- Dates with a year are unchanged.
Tests: TestClaudeResetTimeWithinItsWindow covers the cases above, the
inclusive bound (exactly span+slack is kept, a minute more is not, for a
date and a time alone), a week across New York's fall-back, and
claudeWindows' fresh and kept reads of a 4-day-old reset (Used 0, no
reset, Full zero). The DST rows and the other New Year rows pass
unchanged, at the week's length. Changed existing rows, each of which
asserted a reset further ahead than any window reaches: TestParseClaudeUsage's
"Current week (Opus) ... Jan 2 at 2pm" read Oct 1 (now 2026-01-02), the
#631 "Jan 2, 9:05am (UTC)" row (2026-01-02), and the New Year "Dec 30,
11pm" read Jan 1 01:00, 26h past (2026-12-30, not 2027-12-30). The #631
format rows are read on Oct 3 instead of Oct 1, so "Oct 9, 2:59pm" stays
within its week. Fixtures that gave a 5-hour session a reset the next day
(soon(1) at 3:30pm in claude_usage, claude_seats, claude_usage_switched;
claude_made_first in gateway; "Jul 20 at 10am" at midnight in
claude_scoped) now give one 3 hours on, as /usage would.
Fails without: with the bound removed and fresh reads not through
elapsed, TestClaudeResetTimeWithinItsWindow fails ("Oct 1, 3pm (UTC)" for
168h at 2026-10-05: 2027-10-01 15:00 (in 8667h), want 2026-10-01 15:00;
"Oct 11, 3pm (UTC)" for 5h: 2026-10-11 (in 147h), want 2025-10-11;
"read true: held until 2027-10-03 15:00"), as do TestParseClaudeUsage and
TestClaudeResetTimeAtNewYear on the changed rows. With the bound but
fresh reads not through elapsed, it fails with "read true: 5 hours
Used:100 ResetsAt:<4 days ago>".
Verified, under a temp HOME with GOPATH, GOMODCACHE and GOCACHE pinned:
go vet ./..., go build, GOOS=linux go build -tags nogui, GOOS=windows go
build, go test -tags nogui ./... (all pass), and go test -race -count=20
-run 'TestClaudeResetTime|TestParseClaudeUsage' ./internal/provider.
Not run: claude /usage on a real account read days late. Unchanged: a
week's time alone read minutes after its reset still reads as tomorrow's
(at most a day).
* gateway: a subscription account that fails with a window full rests until it renews, but no more than 8 days, so a reset time read a year off no longer benches it for that year
restAfter's default branch (a failure that isn't credit, quota, a rate
limit or verification: a 502, a sign-in error) rests a subscription
account until its fullest window renews, by c.full(now). That time is
the vendor's reading, not the failure's: Claude Code's /usage text, read
by claudeResetTime, takes a date with no year for next year when it reads
it wrong, and the account then sat out ~365 days until the user noticed
and lifted it by hand. The failQuota branch of the same function already
holds every rest, window included, to longestQuota (a week's window and a
day over); the window rest here now takes the same bound:
min(t.Sub(now), longestQuota). A window renewing within 8 days rests
exactly as before.
Why longestQuota and not the full window's own Span: Allowance.Full
returns only the renewal time, so the call site doesn't know which window
was full or how long it runs; Span is zero where a reading doesn't say
(plugin windows may not); and a failure that isn't about quota must not
sit out longer than one that is, which the quota branch already caps at
8 days. Bounding by Span would only shorten a misread 5-hour window from
8 days to 5 hours, and would have to change the quota branch with it, so
it is left for a change of its own.
Siblings: restAfterMarked is the only place that stores a rest
(restingUntil). Its other branches are bounded already: failQuota by
longestQuota (resets in the body, the noted header, the window and
Retry-After alike), failRate by longestWait and longestRateRest, credit
and verify by constants; a key never takes the default branch's window
rest (c.p.Account != nil). Every other use of a window's reset (capHeld,
Allowance.Full/Renewal/Restarts/Pace in weighing, the sink condition) is
computed again from the latest reading on each request and stores
nothing. Not changed here: cappedError's soonest renewal still becomes
the agent's Retry-After uncapped, and a stored window rest isn't lifted
when a later reading corrects the time (only OnRenewed or a manual
Unrest does), so a bad reading can still bench an account for up to 8
days. Claude's parsing is fixed in the commit before this one, and a
Claude account's window rest is lifted by a renewed reading in #1200.
docs/subsystems/gateway-routing.md says so in the runtime path; its
gateway test list already takes the new test by its TestRouting prefix.
Verified under a temporary HOME with GOPATH, GOMODCACHE and GOCACHE
pinned: go vet ./..., go build, GOOS=linux go build -tags nogui and
GOOS=windows go build pass. TestRoutingHoldsAWindowRestToLongestQuota (new): a
Claude account whose window is full with ResetsAt a year out, failing
502, rests at most longestQuota; a control renewing in 3h rests 3h.
Without the change it fails: "window read a year off: rests
8759h59m59.999682292s by window (other), want at most 192h0m0s". It
passes with -race -count=20, with TestOrderedKeepsAnAccountAt98 and
TestQuotaRestsUntilItsReset. The routing reference's gateway test set
passes.
go test -tags nogui ./... ran with the machine at a load average of
~125 on 12 cores, and three packages failed there, none on this path:
internal/davsync timed out at 10m (it passes alone in 353s);
TestSetAnswersWhileDevinAndProvidersHang fails on e95852e3 too (4 of 20
with -race); TestQuietStreamsKeptAliveEveryProtocol/chat_mid-reply (a
keepalive written before the reply already passed) failed 6, then 1,
then 0 of 20 here with -race and 0 of 60 on e95852e3. Its provider is a
key that answers, so restAfter, the only code changed, never runs in it;
it is left for a change of its own, not called a flake.
* gui: a click made while the page is drawn again is kept: a Settings option pressed through a redraw is still picked, and a menu still loading opens at the redrawn button (#1211)
* gui: a Settings option pressed while the page is drawn again is still picked when let go on it
Every save's answer runs renderSettings(), which replaces each option
control. A second option pressed while the first one's save came back was
let go on a new button: down and up were on two nodes, the browser sent the
click to neither, and nothing was saved. Two quick clicks lost the second
one silently.
A capture-phase pointerdown/pointerup pair now clicks the new button when the
pressed one was taken out of the page and the pointer is let go on the same
option of the same control. The same control means the same place under the
nearest element with an id, among as many controls of its kind, in rows
alike by their data-*; a list drawn again with a row gone, come in or moved
above the pressed one is another control, and nothing is picked. Let go
anywhere else (dragged off to another option, or to another control) picks
nothing, as for any button. A click the browser sends after all is not
doubled. A tap is left to the browser, which clicks what is under the finger
as it lifts. It covers every segs() control, so every option on Settings.
segs-press-redraw.test.cjs, Chromium and WebKit, en/zh/ja/de, 900 and 440px
(42 tests): the probe (press Lightweight → On, renderSettings(), let go)
posts once; drag-offs post nothing; a plain click posts once; every option
control on every Settings tab (49 in English) pressed through a redraw
clicks its new self once; a Chromium tap through a redraw posts once; a
list drawn again with its rows changed picks nothing. Without the fix the
probe and the sweep get no click at all in both engines; without the touch
skip a tap posts twice; matching by place alone picks the row below.
* gui: a menu still loading when its button is drawn again opens at the redrawn button
openAgentModels kept the clicked button across its fetch and gave up when
a redraw had taken it off the page, so a click on Pick, Claude Desktop's
model menu or the models line was silently dropped whenever Agents was
drawn again while the list loaded: a periodic refresh, or the /api/state
re-read that closing a changed list queues (agent-models.test.cjs's
Escape-then-Pick was racing it). The loading record now holds the button,
the builders hold it to the button they draw (holdAgentModels, beside the
existing re-anchor of an open list), and the list opens at the current one.
A click on the redrawn button while it loads takes the click back, as one
on the first did.
The same await-then-open gap was in the two other menus that wait on a
fetch before opening:
- a gateway key's models badge dropped the click when the keys were drawn
again during GET /api/caller-keys/models;
- the menu bar's allowances pill, opened while the allowances loaded and
Settings was drawn again (another setting saved), came up in the window's
corner at the dead pill and saved the other setting back as it was.
Both now open from the button drawn last, with what that drawing keeps.
Reproduced on the unfixed code with the new tests, which delay the fetch
and redraw during it: agent-models-redraw, gateway-key-models-redraw and
tray-usage-redraw, in Chromium and WebKit at a wide and a 560px width.
They pass after the fix, as do the agent-models, gateway-key and
tray-usages tests. Full GUI suite in both engines: 3503 of 3518 pass; the
15 failures are in 8 files outside these paths (account-mask,
panel-arrange, session-identity fail on upstream/main too; profile-confirm
is flaky there too; azure-endpoint, lightweight, purpose-filter and
routing-time failed only under the full run's load and pass alone on both).
---------
Co-authored-by: yetone <yetoneful@gmail.com>
Co-authored-by: XYenon <i@xyenon.bid>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
15 files changed
+758
-43
No files matched your search
+5
-1
@@ -859,7 +859,11 @@ refusal (Claude Code's `usage limit reached|<time>`, ChatGPT's
|
||||
`resets_at`), the reset of a window magpie last read as used up (98%, 100%
|
||||
In order), the vendor's `Retry-After` or rate-limit reset header (an hour
|
||||
at most), else 15 minutes. It is never longer than 8 days. The account's
|
||||
windows are read again right away. A 429 that is a short rate limit rests
|
||||
windows are read again right away, and once a reading finds the window it
|
||||
filled started again (for Claude, a new `/usage`; for any subscription,
|
||||
its reset gone by), it is back at once, not at the time the refusal
|
||||
named. A five hours started again while its week is still used up doesn't
|
||||
bring it back. A 429 that is a short rate limit rests
|
||||
the account for as long as the vendor asks (an hour at most), or a
|
||||
minute, doubled each time it comes back right after its rest, up to 30
|
||||
minutes. Out of credit rests half an hour. Any other failure rests a minute, longer each time it fails
|
||||
|
||||
@@ -51,6 +51,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md).
|
||||
- A ChatGPT account that holds credits answers past its windows on them; the vendor spends them. That is the default. Set not to spend them (Usage card's *Use credits*, `magpie quota credits <account> off`), the account is held at 100% of a window till it renews, by magpie's latest reading of its windows (`provider.Allowances`, refreshed about every minute), so the other accounts, groups and fallbacks take the request. A cap set on the account (1–99%) takes precedence. Unlike a cap, the hold is at the vendor's own 100%, so an account allowed to spend its resets spends one there, both when every candidate is held and after the last one is refused. A reset made forgets the account's cached allowance (`renewedNow`), so neither hold outlives the windows it started again.
|
||||
- Different failures are classified separately: out of credit, out of quota, rate limit and other errors rest for different lengths. A fix must not merge them into one bucket (see #873).
|
||||
- A sub2api key out of its own limit answers 429 `api key 7天限额已用完` (or `日限额`, `5小时限额`) as `rate_limit_exceeded`. It is out of quota (`plannedWords`), not rate limited: it rests till the window it filled renews when its windows as last read say so, else `quotaRest`, and its windows are read again (`staleKeyAllowance`). A reading that finds that window full no more (the limit raised in the relay's panel, or the key's usage reset) lifts the rest (`renewed`, told through `provider.OnRenewed`), as a Codex reset spent does an account's; the key's other rests stay. While it rests out of its windows, they are read again each time it is planned (`restingOf`, once a minute at most), so this holds where nothing weighs it by them: an ordered group. The relay out of accounts for everyone (503 `No available accounts`, or a later version's 429 `All available accounts are currently rate-limited`) says nothing of the key's windows: it backs off as any failure or rate limit does, never to the window. A key's windows count only its own spend, so other users draining the relay's accounts show up as those errors, not in the windows.
|
||||
- An account resting out of its window (a Claude account by the time Claude Code's refusal named, `resets`, or any by `window`) is back once magpie's reading of its windows (`provider.Allowances`) finds it full till sooner: the window used below `SpentShareOf`, or its reset passed, with no other window over the same models still full. The reading tells `provider.OnRenewed` as the account's `UsageAgent` and user, and `renewed` lifts that account's rests out of quota or by `window`; its rests for a rate limit, its credit, a verification or a failure stay, as a key's do, and so does every rest of other accounts still out of their windows. Planning reads every account candidate's allowance, ordered groups included, so this needs no step of its own in `restingOf`.
|
||||
- Only an account is held at a usage cap (`capHeld`) or rested out of one model's pool (`pooled`); a key's windows order it and rest it, nothing more.
|
||||
- Codex offers its image tool (the `image_gen` namespace, #870) to every magpie provider. When a provider other than a ChatGPT sign-in or openai.com answers a bare 400, `attempt` asks it once more without the tool (`image_tool.go`). This happens both passed through and translated. If that works, the provider is marked unfit for the tool (`tool:image_gen`) and isn't offered it again. If it doesn't, the tool is restored and the 400 stands (#949).
|
||||
- A translated Responses reply gives Codex reasoning items magpie made: an `rs_` id and a summary, nothing sealed. Codex hands them back on every turn. A request relayed as is to OpenAI's API or Azure OpenAI goes without them (`withoutBareReasoning`) unless `store` is true, because both look the id up among stored items and answer 400 "Item with id 'rs_…' not found" (#1008). Any other Responses upstream gets them, because a DeepSeek model wants its reasoning back (#388). When one answers 400 naming the input (`refusesInput`: `param` is `input`, or the error names an item by id), as a relay in front of OpenAI does with a message of its own, `passthrough` asks once more without them and, once that works, leaves them out for that provider and model from then on (`bareReasoningRefused`, #1044). Codex's compaction offers no tools, so it is relayed even when its turns were translated. When a provider answers its summary request 404, or 400 for an item it didn't find (`itemNotFound`, a relay in front of either), `codexCompact` asks once more with the conversation as text (`plainCompact`), then compacts locally (#866, #1008).
|
||||
@@ -61,8 +62,8 @@ 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|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool|TestCodexNoCredits|TestClaudeGoesOnMessagesWhereTheRelayHasThem|TestAutoRelaysOnTheClientsOwnAPI|TestSearchOfferedStaysOnTheClientsAPI|TestCodexSearchAnsweredOnResponses|TestAgentEffortOnCursorLocal|TestAzureCompactionWithoutMagpiesReasoning|TestCodexCompact|TestNotAnAPIReply|TestAPIReplyUntouched|TestCodexBackendWebPage|TestSub2APIKeyLimitIsQuota|TestKeysWeighedByTheirWindows|TestGroupWeighsKeyWindows|TestKeyOutOfItsWindowRestsUntilReset|TestKeyPoolErrorDoesNotRestForWindow|TestKeyBackOnceItsWindowIsNotFull|TestRestingKeyReadAgainInAnOrderedGroup|TestThinkingStreams|TestHeldThinkingKeepsTheAgentAlive|TestRefusalAfterThinkingFailsOver|TestGeminiEmptyRepliesAskedAgain|TestLocalUnredacted|TestCORS|TestForeignPagesRefused|TestGeminiThinkingOffAtItsLowest|TestGeminiThoughtsMarkedInExtraContent'
|
||||
go test -tags nogui ./internal/provider -run 'TestGroup|TestCodexCreditsSwitch|TestRenewedAccountForgetsItsAllowance|TestNative|TestKeyAllowance|TestPlanKeyAllowance|TestSkipsRedaction'
|
||||
go test -tags nogui ./internal/gateway -run 'TestQuiet|TestSilentHeld|TestRotateSpreadsSessions|TestRouting|TestSmartRouting|TestFallback|TestNoFallbackForOtherErrors|TestLastFallbackErrorReachesTheAgent|TestRateLimitedSinksToTheBack|TestGroupRateLimitedSinks|TestAffinity|TestSeveralKeysOnTakeOverFromEachOther|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool|TestCodexNoCredits|TestClaudeGoesOnMessagesWhereTheRelayHasThem|TestAutoRelaysOnTheClientsOwnAPI|TestSearchOfferedStaysOnTheClientsAPI|TestCodexSearchAnsweredOnResponses|TestAgentEffortOnCursorLocal|TestAzureCompactionWithoutMagpiesReasoning|TestCodexCompact|TestNotAnAPIReply|TestAPIReplyUntouched|TestCodexBackendWebPage|TestSub2APIKeyLimitIsQuota|TestKeysWeighedByTheirWindows|TestGroupWeighsKeyWindows|TestKeyOutOfItsWindowRestsUntilReset|TestKeyPoolErrorDoesNotRestForWindow|TestKeyBackOnceItsWindowIsNotFull|TestRestingKeyReadAgainInAnOrderedGroup|TestClaudeAccountBackOnceItsWindowRenews|TestThinkingStreams|TestHeldThinkingKeepsTheAgentAlive|TestRefusalAfterThinkingFailsOver|TestGeminiEmptyRepliesAskedAgain|TestLocalUnredacted|TestCORS|TestForeignPagesRefused|TestGeminiThinkingOffAtItsLowest|TestGeminiThoughtsMarkedInExtraContent'
|
||||
go test -tags nogui ./internal/provider -run 'TestGroup|TestCodexCreditsSwitch|TestRenewedAccountForgetsItsAllowance|TestNative|TestKeyAllowance|TestPlanKeyAllowance|TestClaudeAccountToldRenewed|TestPluginAccountToldRenewed|TestSkipsRedaction'
|
||||
```
|
||||
|
||||
The gateway's `TestMain` gives the package a home of its own, so these tests
|
||||
|
||||
@@ -37,7 +37,7 @@ keys and accounts per request is in [Gateway routing and fallback](gateway-routi
|
||||
1. **Add.** The Providers page adds a key provider through `Add` or `Save`, or an account through `StartSignIn`. An agent signed in on its own is found by `Accounts` with no step from the user.
|
||||
2. **Resolve.** The gateway calls `provider.Resolve` or `GroupFor` with the model name, then plans candidates over the provider's keys or accounts.
|
||||
3. **Sign.** For each try, `Sign` gives the request its auth. A token about to expire is refreshed first, and the rotated tokens go back to their one holder: the agent's store, or `logins.json` for an account standing behind it.
|
||||
4. **Allowance.** Usage endpoints are rate limited, so `SubscriptionUsage` caches its results: a cached copy comes back at once and a stale one is refreshed in the background. When an account's windows start again, `OnRenewed` tells the gateway so a resting account can come back. A key's reading that finds a window it was full in (at `SpentShareOf` its routing) full no more, or full till sooner, tells it too, as agent `""` and the key's `KeyAllowanceID`, so a key out of its limit is back once the limit is raised or its usage reset; a first reading, a failed one, or one that finds it fuller tells nothing.
|
||||
4. **Allowance.** Usage endpoints are rate limited, so `SubscriptionUsage` caches its results: a cached copy comes back at once and a stale one is refreshed in the background. When an account's windows start again, `OnRenewed` tells the gateway so a resting account can come back: a Codex reset spent (`renewedNow`), or a reading routing takes (`Allowances`, as the agent the account's usage is read under, `claude` or `plugin:<id>`) that finds the account full till sooner than the last did (`renewedFrom`, the rule a key's reading goes by): the whole account, or a pool of some models with the whole account's windows over it. A window used below `SpentShareOf` its routing, or whose reset passed, is full no more; one full with its reset not known is full for good. A Claude account is back as soon as a new `/usage` or the reset passing shows the window it was out of started again, not when the time Claude Code's refusal named comes; its five hours started again in a week used up tells nothing. A first reading, a failed one, one read alike, or one leaving the full window out tells nothing, and a reset spent is told once, by `renewedNow`. A key's reading that finds a window it was full in (at `SpentShareOf` its routing) full no more, or full till sooner, tells it too, as agent `""` and the key's `KeyAllowanceID`, so a key out of its limit is back once the limit is raised or its usage reset; a first reading, a failed one, or one that finds it fuller tells nothing.
|
||||
|
||||
## Constraints and failure behavior
|
||||
|
||||
@@ -52,11 +52,12 @@ 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 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'
|
||||
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 'TestVolcengine|TestQianfan|TestBedrock'
|
||||
go test -tags nogui ./internal/provider
|
||||
```
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// A Claude account resting out of its five hours is back as soon as a
|
||||
// reading of its windows, the one routing goes by, finds them started
|
||||
// again, rather than when the time Claude Code's refusal named comes.
|
||||
// Another account still out of its window stays out.
|
||||
func TestClaudeAccountBackOnceItsWindowRenews(t *testing.T) {
|
||||
fresh(t)
|
||||
old := allowances
|
||||
allowances = provider.Allowances // the accounts' windows as read, here
|
||||
t.Cleanup(func() { allowances = old })
|
||||
// users of their own: Allowances keeps what it read for the next test
|
||||
const a, b = "renewed-a@example.com", "renewed-b@example.com"
|
||||
window := time.Now().Add(2 * time.Hour).Truncate(time.Second)
|
||||
var mu sync.Mutex
|
||||
used := map[string]float64{a: 100, b: 100}
|
||||
provider.LoginUsageVia(func(_ context.Context, agent string) map[string]provider.SubscriptionQuota {
|
||||
out := map[string]provider.SubscriptionQuota{}
|
||||
if agent != "claude" {
|
||||
return out
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for u, n := range used {
|
||||
out[u] = provider.SubscriptionQuota{Provider: "claude", Windows: []provider.QuotaWindow{
|
||||
{Name: "5 hours", Used: n, Span: 5 * time.Hour, ResetsAt: &window},
|
||||
{Name: "7 days", Used: 40, Span: 7 * 24 * time.Hour},
|
||||
}}
|
||||
}
|
||||
return out
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
provider.LoginUsageVia(nil)
|
||||
provider.StaleAllowance("claude", a)
|
||||
})
|
||||
acct := func(user string) candidate {
|
||||
return candidate{p: provider.Provider{ID: "claude", Account: &provider.Account{Agent: "claude", User: user}}, model: "claude-sonnet-4-6", rest: "claude#" + user}
|
||||
}
|
||||
ca, cb := acct(a), acct(b)
|
||||
forget := func() { clearRest(ca.restKey()); clearRest(cb.restKey()) }
|
||||
forget()
|
||||
t.Cleanup(forget)
|
||||
// read before: each out of its five hours (not what a test before left
|
||||
// read of them)
|
||||
full := func(al provider.Allowance) bool {
|
||||
for _, l := range al {
|
||||
if l.Used >= 100 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
provider.StaleAllowance("claude", a)
|
||||
for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(10 * time.Millisecond) {
|
||||
if al := allowances("claude"); full(al[a]) && full(al[b]) {
|
||||
break
|
||||
} else if time.Now().After(deadline) {
|
||||
t.Fatalf("routing reads %v", al)
|
||||
}
|
||||
}
|
||||
// refused, the way the bridge puts Claude Code's limit: resting till
|
||||
// the time it named, an hour past the window
|
||||
s := &Server{}
|
||||
h := http.Header{}
|
||||
h.Set(resetsHeader, strconv.FormatInt(window.Add(time.Hour).Unix(), 10))
|
||||
for _, c := range []candidate{ca, cb} {
|
||||
if r := s.restAfter(c, 429, h, []byte(`{"error":{"type":"rate_limit_error","message":"You've hit your limit"}}`)); r.Why != failQuota || r.By != "resets" {
|
||||
t.Fatalf("%s out of its five hours: rests by %s (%s)", c.p.Account.User, r.By, r.Why)
|
||||
}
|
||||
}
|
||||
|
||||
// a's five hours started again (a new /usage, what Claude Code says
|
||||
// as it answers); routing reads it a minute on
|
||||
mu.Lock()
|
||||
used[a] = 4
|
||||
mu.Unlock()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
if _, ok := restOf(ca.restKey()); !ok {
|
||||
break
|
||||
}
|
||||
// read again till a reading has it at 4%: one in flight as it
|
||||
// changed may have it full still
|
||||
if full(allowances("claude")[a]) {
|
||||
provider.StaleAllowance("claude", a)
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
r, _ := restOf(ca.restKey())
|
||||
t.Fatalf("a, its window started again: still rests by %s till %v", r.By, r.Until)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if r, ok := restOf(cb.restKey()); !ok || r.By != "resets" {
|
||||
t.Fatalf("b, still out of its five hours: %+v, %v", r, ok)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import (
|
||||
// that is, and the trace says so (#147: ChatGPT's resets_at three hours
|
||||
// off was a rest of "59 minutes", the Retry-After magpie made of it cut to
|
||||
// an hour); a rate limit's Retry-After and a failure's backoff stay short,
|
||||
// and a Codex reset spent lifts the rest.
|
||||
// and a Codex reset spent lifts its rest out of quota, not one for a rate
|
||||
// limit or a failure.
|
||||
func TestQuotaRestsUntilItsReset(t *testing.T) {
|
||||
old := allowances
|
||||
defer func() { allowances = old }()
|
||||
@@ -84,4 +85,17 @@ func TestQuotaRestsUntilItsReset(t *testing.T) {
|
||||
if _, ok := restOf(acct("b@x.com").restKey()); !ok {
|
||||
t.Fatal("another account's rest was lifted")
|
||||
}
|
||||
// its windows started again lift a rest out of them, not one for a
|
||||
// rate limit or a failure
|
||||
for _, u := range []string{"c@x.com", "e@x.com", "f@x.com"} {
|
||||
renewed("codex", u)
|
||||
}
|
||||
if r, ok := restOf(acct("c@x.com").restKey()); ok {
|
||||
t.Fatalf("failed with its week full, still resting after it started again: %+v", r)
|
||||
}
|
||||
for _, u := range []string{"e@x.com", "f@x.com"} {
|
||||
if r, ok := restOf(acct(u).restKey()); !ok {
|
||||
t.Fatalf("%s: its rest (%s) lifted by its windows started again", u, r.Why)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,15 +256,18 @@ type Rest struct {
|
||||
}
|
||||
|
||||
// renewed lifts the rests of a subscription account whose windows were
|
||||
// just started again (a Codex reset spent): out of quota no longer. Told
|
||||
// agent "", it is a key (provider.KeyAllowanceID) whose windows a reading
|
||||
// found full no more — its limit raised, or its usage reset — and its
|
||||
// rest out of them is lifted; a key's other rests aren't noted by it.
|
||||
// just started again (a Codex reset spent, or a reading finding a window
|
||||
// it was full in full no more): out of quota no longer. Only its rests out
|
||||
// of quota, or failed with a window full ("window"), are lifted; one for a
|
||||
// rate limit, its credit or a verification stays. Told agent "", it is a
|
||||
// key (provider.KeyAllowanceID) whose windows a reading found full no
|
||||
// more — its limit raised, or its usage reset — and its rest out of them
|
||||
// is lifted; a key's other rests aren't noted by it.
|
||||
func renewed(agent, user string) {
|
||||
restingUntil.Lock()
|
||||
defer restingUntil.Unlock()
|
||||
for k, r := range restingUntil.note {
|
||||
if r.agent == agent && strings.EqualFold(r.user, user) {
|
||||
if r.agent == agent && strings.EqualFold(r.user, user) && (r.Why == failQuota || r.By == "window") {
|
||||
delete(restingUntil.m, k)
|
||||
delete(restingUntil.note, k)
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestCapNeverSpendsACodexReset(t *testing.T) {
|
||||
if weekUsedUp(ws, now) != nil {
|
||||
t.Fatal("an auto-used reset would be spent on a week at 80%")
|
||||
}
|
||||
if usedUp(q) || !BackAt(q, now).IsZero() {
|
||||
if usedUp(q, now) || !BackAt(q, now).IsZero() {
|
||||
t.Fatal("80% read as used up")
|
||||
}
|
||||
if spendExpiringNow(ws, &ResetCredits{Count: 1, Until: &until}, now) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package provider
|
||||
|
||||
// An account resting out of its allowance is back as soon as a reading of
|
||||
// its windows finds the one it was out of started again, rather than when
|
||||
// its rest runs out: a Claude account's rest is as long as Claude Code's
|
||||
// refusal said, and a new /usage, what Claude Code says as it answers, or
|
||||
// the reset passing (elapsed) can show the window renewed before then.
|
||||
// Allowances, which routing reads each account's windows by, tells
|
||||
// OnRenewed's hooks so, for every subscription: the built-ins' and a
|
||||
// plugin's (as "plugin:<id>") alike.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// reading is an account's windows as Allowances last read them, and when.
|
||||
type reading struct {
|
||||
a Allowance
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// renewedFrom says whether a, read at now, finds the account full till
|
||||
// sooner than was, read at wasAt, did (the rule a key's reading is told
|
||||
// renewed by, keyAllowances' keep): for the whole account, or for a pool
|
||||
// of some models only with the whole account's windows over it. A window
|
||||
// used below share, or whose reset passed, is full no more; one full with
|
||||
// its reset not known is full for good. Only the windows both readings
|
||||
// have are weighed, so one not read this time tells nothing; nor does one
|
||||
// read alike, nor one renewed while another over the same models is
|
||||
// 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} }
|
||||
read := map[key]bool{}
|
||||
for _, l := range a {
|
||||
read[keyOf(l)] = true
|
||||
}
|
||||
whole := func(l Limit) bool { return l.Model == "" && l.matches == nil }
|
||||
sooner := func(in func(Limit) bool) bool {
|
||||
in2 := func(l Limit) bool { return read[keyOf(l)] && in(l) }
|
||||
return was.fullTill(in2, share, wasAt).After(a.fullTill(in2, share, now))
|
||||
}
|
||||
if sooner(whole) {
|
||||
return true
|
||||
}
|
||||
for _, w := range was {
|
||||
if k := keyOf(w); !whole(w) && sooner(func(l Limit) bool { return whole(l) || keyOf(l) == k }) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fullTill is when the windows in leaves full at share at now renew: the
|
||||
// last of their resets, zero when none is full, and far off when one is
|
||||
// full with its reset not known.
|
||||
func (a Allowance) fullTill(in func(Limit) bool, share float64, now time.Time) time.Time {
|
||||
var t time.Time
|
||||
for _, l := range a {
|
||||
switch {
|
||||
case !in(l) || l.Used < share:
|
||||
case l.Resets.IsZero():
|
||||
return time.Unix(1<<62, 0)
|
||||
case l.Resets.After(now) && l.Resets.After(t):
|
||||
t = l.Resets
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// renewalShare is the share at which routing counts the windows of
|
||||
// agent's accounts full (SpentShareOf its provider's routing): a plugin's
|
||||
// ("plugin:<id>") by the provider its accounts are listed under.
|
||||
func renewalShare(agent string) float64 {
|
||||
id := agent
|
||||
if p, ok := strings.CutPrefix(agent, "plugin:"); ok {
|
||||
id = PluginID(p)
|
||||
}
|
||||
share, _ := loginSwitching(id)
|
||||
return share
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// toldRenewed registers an OnRenewed hook for the test alone and gives
|
||||
// what it was told so far, as agent/user.
|
||||
func toldRenewed(t *testing.T) func() []string {
|
||||
t.Helper()
|
||||
renewedHooks.Lock()
|
||||
hooks := renewedHooks.fs
|
||||
renewedHooks.Unlock()
|
||||
t.Cleanup(func() {
|
||||
renewedHooks.Lock()
|
||||
renewedHooks.fs = hooks
|
||||
renewedHooks.Unlock()
|
||||
})
|
||||
var mu sync.Mutex
|
||||
var told []string
|
||||
OnRenewed(func(agent, user string) {
|
||||
mu.Lock()
|
||||
told = append(told, agent+"/"+user)
|
||||
mu.Unlock()
|
||||
})
|
||||
return func() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]string(nil), told...)
|
||||
}
|
||||
}
|
||||
|
||||
// startWithNoAllowancesRead starts the test with no allowance read, and leaves
|
||||
// none it read to the next.
|
||||
func startWithNoAllowancesRead(t *testing.T) {
|
||||
t.Helper()
|
||||
clear := func() {
|
||||
usedCache.Lock()
|
||||
usedCache.m, usedCache.at, usedCache.loading = map[string]map[string]Allowance{}, map[string]time.Time{}, map[string]chan struct{}{}
|
||||
usedCache.renewed, usedCache.seen = nil, nil
|
||||
usedCache.Unlock()
|
||||
}
|
||||
clear()
|
||||
t.Cleanup(clear)
|
||||
}
|
||||
|
||||
// readAllowancesAgain has Allowances read agent's accounts again, as it does a
|
||||
// minute after the last reading, and waits for it, its hooks told.
|
||||
func readAllowancesAgain(t *testing.T, agent string) map[string]Allowance {
|
||||
t.Helper()
|
||||
usedCache.Lock()
|
||||
usedCache.at[agent] = time.Time{}
|
||||
usedCache.Unlock()
|
||||
Allowances(agent)
|
||||
usedCache.Lock()
|
||||
done := usedCache.loading[agent]
|
||||
usedCache.Unlock()
|
||||
if done != nil {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the reading never came back")
|
||||
}
|
||||
}
|
||||
usedCache.Lock()
|
||||
defer usedCache.Unlock()
|
||||
return usedCache.m[agent]
|
||||
}
|
||||
|
||||
// A Claude account out of a window is told renewed (OnRenewed, as
|
||||
// "claude", the agent its usage is read under, and the account) once a
|
||||
// reading routing goes by finds that window started again: a new /usage,
|
||||
// or its reset passed in what Claude Code last told of it. Not on its
|
||||
// first reading, nor while the window stays full, nor twice for one
|
||||
// renewal.
|
||||
func TestClaudeAccountToldRenewed(t *testing.T) {
|
||||
home := claudeHome(t)
|
||||
claudeSignIn(t, home, time.Now().Add(time.Hour))
|
||||
writeFile(t, filepath.Join(home, ".claude.json"), map[string]any{"oauthAccount": map[string]any{"emailAddress": "a@example.com", "accountUuid": "u-a"}})
|
||||
rememberLogins(true)
|
||||
var user string
|
||||
for _, l := range Logins("claude") {
|
||||
if l.Active {
|
||||
user = l.User
|
||||
}
|
||||
}
|
||||
if user == "" {
|
||||
t.Fatalf("no Claude account signed in: %+v", Logins("claude"))
|
||||
}
|
||||
key := strings.ToLower(user)
|
||||
var out atomic.Value
|
||||
runs := fakeClaudeUsage(t, &out, nil)
|
||||
startWithNoAllowancesRead(t)
|
||||
told := toldRenewed(t)
|
||||
tellsSo := func(what string, n int) {
|
||||
t.Helper()
|
||||
got := told()
|
||||
if len(got) != n {
|
||||
t.Fatalf("%s: told %v", what, got)
|
||||
}
|
||||
for _, s := range got {
|
||||
if s != "claude/"+user {
|
||||
t.Fatalf("%s: told %v", what, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
// usage is what Claude Code's /usage prints, run as the user asked
|
||||
usage := func(text string) map[string]Allowance {
|
||||
t.Helper()
|
||||
out.Store(text)
|
||||
claudeUsage.Lock()
|
||||
if e, ok := claudeUsage.m[key]; ok {
|
||||
e.tried = e.tried.Add(-time.Minute) // past the least time between two runs
|
||||
claudeUsage.m[key] = e
|
||||
}
|
||||
claudeUsage.Unlock()
|
||||
n := runs.Load()
|
||||
AskClaudeUsage()
|
||||
m := readAllowancesAgain(t, "claude")
|
||||
if runs.Load() != n+1 {
|
||||
t.Fatalf("/usage ran %d times", runs.Load()-n)
|
||||
}
|
||||
return m
|
||||
}
|
||||
// a session's reset as /usage prints it, within its five hours
|
||||
session := func(in time.Duration) string {
|
||||
return time.Now().UTC().Add(in).Format("Jan 2 at 3:04pm") + " (UTC)"
|
||||
}
|
||||
full := "Current session: 100% used · resets " + session(3*time.Hour) + "\nCurrent week (all models): 41% used · resets " + soon(3) + " at 2pm (UTC)\n"
|
||||
if m := usage(full); len(m[user]) != 2 || m[user][0].Used != 100 {
|
||||
t.Fatalf("routing reads %+v", m)
|
||||
}
|
||||
tellsSo("its first reading, out of its five hours", 0)
|
||||
usage(full)
|
||||
tellsSo("its five hours still used up", 0)
|
||||
usage(strings.Replace(full, "Current session: 100% used", "Current session: 99% used", 1))
|
||||
tellsSo("at 99%, still used up as Smart counts it", 0)
|
||||
usage("Current session: 3% used · resets " + session(4*time.Hour+30*time.Minute) + "\nCurrent week (all models): 42% used · resets " + soon(3) + " at 2pm (UTC)\n")
|
||||
tellsSo("its five hours started again", 1)
|
||||
usage("Current session: 9% used · resets " + session(4*time.Hour+30*time.Minute) + "\nCurrent week (all models): 42% used · resets " + soon(3) + " at 2pm (UTC)\n")
|
||||
tellsSo("used a little more", 1)
|
||||
|
||||
// what Claude Code told as it answered: refused, out of its five
|
||||
// hours till back; then back is gone by, with nothing more told of it
|
||||
kept := func(back time.Time) {
|
||||
claudeUsage.Lock()
|
||||
e := claudeUsage.m[key]
|
||||
e.ws = []QuotaWindow{{Name: "5 hours", Used: 100, Span: 5 * time.Hour, ResetsAt: &back}}
|
||||
claudeUsage.m[key] = e
|
||||
claudeUsage.Unlock()
|
||||
StaleAllowance("claude", user)
|
||||
}
|
||||
kept(time.Now().Add(time.Hour))
|
||||
readAllowancesAgain(t, "claude")
|
||||
tellsSo("refused, out of its five hours", 1)
|
||||
kept(time.Now().Add(-time.Second)) // as an hour on
|
||||
if m := readAllowancesAgain(t, "claude"); len(m[user]) != 1 || m[user][0].Used != 0 {
|
||||
t.Fatalf("its reset passed, routing reads %+v", m)
|
||||
}
|
||||
tellsSo("its reset passed", 2)
|
||||
StaleAllowance("claude", user)
|
||||
readAllowancesAgain(t, "claude")
|
||||
tellsSo("read again", 2)
|
||||
if n := runs.Load(); n != 5 {
|
||||
t.Fatalf("/usage ran %d times, unasked", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin's account is told renewed as a built-in's is, by the agent its
|
||||
// usage is read under ("plugin:<id>"). A reading that fails is not one
|
||||
// that finds the window renewed: what was read before stands for the
|
||||
// next. An account whose reset was spent (renewedNow) was told then, and
|
||||
// the reading after tells it no more.
|
||||
func TestPluginAccountToldRenewed(t *testing.T) {
|
||||
isolate(t)
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
startWithNoAllowancesRead(t)
|
||||
told := toldRenewed(t)
|
||||
const agent = "plugin:fakeco"
|
||||
week := time.Now().Add(50 * time.Hour).Truncate(time.Second)
|
||||
var mu sync.Mutex
|
||||
reading := map[string]SubscriptionQuota{}
|
||||
read := func(used map[string]float64, failed ...string) {
|
||||
t.Helper()
|
||||
mu.Lock()
|
||||
reading = map[string]SubscriptionQuota{}
|
||||
for u, n := range used {
|
||||
reading[u] = SubscriptionQuota{Provider: agent, Windows: []QuotaWindow{
|
||||
{Name: "Weekly", Used: n, Span: 7 * 24 * time.Hour, ResetsAt: &week},
|
||||
{Name: "Premium", Used: 100, Span: 7 * 24 * time.Hour, ResetsAt: &week, Model: "premium"},
|
||||
}}
|
||||
}
|
||||
for _, u := range failed {
|
||||
reading[u] = SubscriptionQuota{Provider: agent, Windows: []QuotaWindow{}, Error: "502 Bad Gateway"}
|
||||
}
|
||||
mu.Unlock()
|
||||
readAllowancesAgain(t, agent)
|
||||
}
|
||||
LoginUsageVia(func(_ context.Context, a string) map[string]SubscriptionQuota {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if a != agent {
|
||||
return map[string]SubscriptionQuota{}
|
||||
}
|
||||
return reading
|
||||
})
|
||||
t.Cleanup(func() { LoginUsageVia(nil) })
|
||||
tellsSo := func(what string, want ...string) {
|
||||
t.Helper()
|
||||
if got := told(); strings.Join(got, " ") != strings.Join(want, " ") {
|
||||
t.Fatalf("%s: told %v, not %v", what, got, want)
|
||||
}
|
||||
}
|
||||
read(map[string]float64{"full@fake": 100, "spare@fake": 20})
|
||||
tellsSo("the first reading")
|
||||
read(map[string]float64{"spare@fake": 25}, "full@fake")
|
||||
tellsSo("full@fake's reading failed")
|
||||
read(map[string]float64{"full@fake": 100, "spare@fake": 30})
|
||||
tellsSo("still out of its week")
|
||||
read(map[string]float64{"full@fake": 12, "spare@fake": 30})
|
||||
tellsSo("its week started again", agent+"/full@fake")
|
||||
read(map[string]float64{"full@fake": 12, "spare@fake": 30})
|
||||
tellsSo("read alike", agent+"/full@fake")
|
||||
|
||||
// out again, then a reset spent on it: told by the reset alone
|
||||
read(map[string]float64{"full@fake": 100, "spare@fake": 30})
|
||||
renewedNow(agent, "Full@Fake")
|
||||
read(map[string]float64{"full@fake": 0, "spare@fake": 30})
|
||||
tellsSo("a reset spent", agent+"/full@fake", agent+"/Full@Fake")
|
||||
|
||||
// out of its week again, then read with its reset gone by, the vendor
|
||||
// still counting it full: told once, and not again read alike
|
||||
read(map[string]float64{"full@fake": 100, "spare@fake": 30})
|
||||
passed := time.Now().Add(-time.Second).Truncate(time.Second)
|
||||
mu.Lock()
|
||||
reading = map[string]SubscriptionQuota{
|
||||
"full@fake": {Provider: agent, Windows: []QuotaWindow{{Name: "Weekly", Used: 100, Span: 7 * 24 * time.Hour, ResetsAt: &passed}}},
|
||||
"spare@fake": {Provider: agent, Windows: []QuotaWindow{{Name: "Weekly", Used: 30, Span: 7 * 24 * time.Hour, ResetsAt: &week}}},
|
||||
}
|
||||
mu.Unlock()
|
||||
readAllowancesAgain(t, agent)
|
||||
tellsSo("its reset gone by, still at 100", agent+"/full@fake", agent+"/Full@Fake", agent+"/full@fake")
|
||||
readAllowancesAgain(t, agent)
|
||||
tellsSo("read alike after its reset", agent+"/full@fake", agent+"/Full@Fake", agent+"/full@fake")
|
||||
}
|
||||
|
||||
// A reading is renewed from the last as a key's is (keyAllowances' keep):
|
||||
// the account, or a pool of some models in it, full till sooner. One
|
||||
// window started again while another over the same models is still full
|
||||
// is not; nor are windows of one name read alike, one not read this time,
|
||||
// or one full till later.
|
||||
func TestRenewedFrom(t *testing.T) {
|
||||
now := time.Now()
|
||||
h := func(n int) time.Time { return now.Add(time.Duration(n) * time.Hour) }
|
||||
five := func(used float64, resets time.Time) QuotaWindow {
|
||||
return QuotaWindow{Name: "5 hours", Used: used, Span: 5 * time.Hour, ResetsAt: &resets}
|
||||
}
|
||||
week := func(used float64, resets time.Time) QuotaWindow {
|
||||
return QuotaWindow{Name: "7 days", Used: used, Span: 7 * 24 * time.Hour, ResetsAt: &resets}
|
||||
}
|
||||
opus := func(used float64, resets time.Time) QuotaWindow {
|
||||
w := week(used, resets)
|
||||
w.Name, w.Model = "7 days (Opus)", "opus"
|
||||
return w
|
||||
}
|
||||
credits := func(used float64) QuotaWindow { return QuotaWindow{Name: "Credits", Used: used} }
|
||||
of := func(ws ...QuotaWindow) Allowance { return allowanceOf(ws, now) }
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
was, now Allowance
|
||||
renewed bool
|
||||
}{
|
||||
{"its five hours started again", of(five(100, h(1)), week(40, h(50))), of(five(3, h(6)), week(41, h(50))), true},
|
||||
{"still full", of(five(100, h(1)), week(40, h(50))), of(five(100, h(1)), week(40, h(50))), false},
|
||||
{"its five hours started again in a week used up", of(five(100, h(1)), week(100, h(50))), of(five(0, h(6)), week(100, h(50))), false},
|
||||
{"its week started again", of(five(0, h(6)), week(100, h(50))), of(five(0, h(6)), week(2, h(218))), true},
|
||||
{"full till sooner", of(week(100, h(50))), of(week(100, h(20))), true},
|
||||
{"full till later", of(week(100, h(20))), of(week(100, h(50))), false},
|
||||
{"its Opus week started again", of(week(40, h(50)), opus(100, h(50))), of(week(41, h(50)), opus(0, h(218))), true},
|
||||
{"its Opus week started again in a week used up", of(week(100, h(50)), opus(100, h(50))), of(week(100, h(50)), opus(0, h(218))), false},
|
||||
{"two of one name, one full, read alike", of(credits(100), credits(10)), of(credits(100), credits(10)), false},
|
||||
{"full, its reset not known, then not", of(credits(100)), of(credits(5)), true},
|
||||
{"its full window not read this time", of(five(100, h(1)), week(40, h(50))), of(week(41, h(50))), false},
|
||||
{"a first reading, read alike", of(five(20, h(1))), of(five(25, h(1))), false},
|
||||
} {
|
||||
if got := c.now.renewedFrom(c.was, now, 98, now); got != c.renewed {
|
||||
t.Errorf("%s: renewed %v", c.what, got)
|
||||
}
|
||||
}
|
||||
// read before its reset, read alike after it: started again then
|
||||
was := of(five(100, h(1)))
|
||||
if !was.renewedFrom(was, now, 98, h(2)) {
|
||||
t.Error("its reset gone by since it was read: not renewed")
|
||||
}
|
||||
if was.renewedFrom(was, h(2), 98, h(3)) {
|
||||
t.Error("its reset gone by before either reading: renewed")
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func (e *expiringResets) check(user string, now time.Time, look func() ([]QuotaW
|
||||
return ResetOutcome{}, nil
|
||||
}
|
||||
q := SubscriptionQuota{Windows: windows}
|
||||
at := expiringResetSpent(until, now, usedUp(q), BackAt(q, now))
|
||||
at := expiringResetSpent(until, now, usedUp(q, now), BackAt(q, now))
|
||||
if at.After(now) || !windowsUsed(windows) {
|
||||
// not yet, or nothing to start again yet: the account may be used,
|
||||
// or held up, before it runs out
|
||||
@@ -234,7 +234,7 @@ func spendExpiringNow(windows []QuotaWindow, resets *ResetCredits, now time.Time
|
||||
return false
|
||||
}
|
||||
q := SubscriptionQuota{Windows: windows}
|
||||
at := expiringResetSpent(resets.Until.Round(0), now, usedUp(q), BackAt(q, now))
|
||||
at := expiringResetSpent(resets.Until.Round(0), now, usedUp(q, now), BackAt(q, now))
|
||||
return !at.IsZero() && !at.After(now)
|
||||
}
|
||||
|
||||
|
||||
@@ -80,15 +80,15 @@ const backShare = 90
|
||||
// switchedAgents are the agents whose account magpie moves on.
|
||||
var switchedAgents = []string{"codex", "claude"}
|
||||
|
||||
// usedUp reports whether an account's allowance is used up for now: a
|
||||
// usedUp reports whether an account's allowance is used up at now: a
|
||||
// window that stops the account, for every model, at 100%.
|
||||
func usedUp(q SubscriptionQuota) bool {
|
||||
return usedPast(q, 100)
|
||||
func usedUp(q SubscriptionQuota, now time.Time) bool {
|
||||
return usedPast(q, 100, now)
|
||||
}
|
||||
|
||||
// UsedUp is usedUp for the CLI's magpie quota wait, so what it waits out
|
||||
// is what stops an account here.
|
||||
func UsedUp(q SubscriptionQuota) bool { return usedUp(q) }
|
||||
func UsedUp(q SubscriptionQuota, now time.Time) bool { return usedUp(q, now) }
|
||||
|
||||
// BackAt is when a used-up account has room again: when the last of the
|
||||
// windows that stop it (usedUp's) starts again, a window's seconds to go
|
||||
@@ -96,7 +96,7 @@ func UsedUp(q SubscriptionQuota) bool { return usedUp(q) }
|
||||
func BackAt(q SubscriptionQuota, now time.Time) time.Time {
|
||||
var back time.Time
|
||||
for _, w := range q.Windows {
|
||||
if w.Aside || w.Model != "" || w.Used < 100 {
|
||||
if w.Aside || w.Model != "" || w.Used < 100 || resetPassed(w, now) {
|
||||
continue
|
||||
}
|
||||
var at time.Time
|
||||
@@ -115,9 +115,21 @@ func BackAt(q SubscriptionQuota, now time.Time) time.Time {
|
||||
return back
|
||||
}
|
||||
|
||||
func usedPast(q SubscriptionQuota, share float64) bool {
|
||||
// resetPassed says whether w has started again by now: its reset time has
|
||||
// passed, whatever share it was read at. One that gives no reset time
|
||||
// can't say.
|
||||
func resetPassed(w QuotaWindow, now time.Time) bool {
|
||||
return w.ResetsAt != nil && !now.Before(*w.ResetsAt)
|
||||
}
|
||||
|
||||
// usedPast reports whether one of q's windows that stop the account for
|
||||
// every model is at share or past at now. A window whose reset has passed
|
||||
// has started again, whatever share it was read at: a reading kept from
|
||||
// before, or one the vendor gave late (Claude Code's /usage tells a reset
|
||||
// to the minute), says nothing of now.
|
||||
func usedPast(q SubscriptionQuota, share float64, now time.Time) bool {
|
||||
for _, w := range q.Windows {
|
||||
if !w.Aside && w.Model == "" && w.Used >= share {
|
||||
if !w.Aside && w.Model == "" && w.Used >= share && !resetPassed(w, now) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -174,25 +186,20 @@ func NextLogin(ctx context.Context, agent string) (from, to string, back, ok boo
|
||||
return capReached(q, AccountCapOf(agent, user), now)
|
||||
}
|
||||
if first != nil && first.On && first.Lapsed == "" {
|
||||
if q, known := u[first.User]; known && q.Error == "" && !usedPast(q, backShare) && !capped(first.User, q) {
|
||||
if q, known := u[first.User]; known && q.Error == "" && !usedPast(q, backShare, now) && !capped(first.User, q) {
|
||||
return from, first.User, true, true
|
||||
}
|
||||
}
|
||||
q, known := u[from]
|
||||
if q.Provider == "claude" && q.AsOf != nil {
|
||||
// An expired cached window cannot say whether this account is spent
|
||||
// now. Keep other windows and the stored historical reading intact.
|
||||
q.Windows = slices.DeleteFunc(slices.Clone(q.Windows), func(w QuotaWindow) bool {
|
||||
return w.ResetsAt != nil && !w.ResetsAt.After(now)
|
||||
})
|
||||
}
|
||||
// kept on the first, it stays there however little that has left; and
|
||||
// it moves on at the share its routing counts the account spent at
|
||||
if keep || !known || q.Error != "" || !usedPast(q, share) && !capped(from, q) {
|
||||
// it moves on at the share its routing counts the account spent at.
|
||||
// A window whose reset has passed counts as started again (usedPast),
|
||||
// on every account, however its reading came
|
||||
q, known := u[from]
|
||||
if keep || !known || q.Error != "" || !usedPast(q, share, now) && !capped(from, q) {
|
||||
return "", "", false, false
|
||||
}
|
||||
for _, l := range spares {
|
||||
if q, known := u[l.User]; known && q.Error == "" && !usedPast(q, share) && !capped(l.User, q) {
|
||||
if q, known := u[l.User]; known && q.Error == "" && !usedPast(q, share, now) && !capped(l.User, q) {
|
||||
return from, l.User, false, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,11 +133,14 @@ func (c *lastQuotasT) load() {
|
||||
func (c *lastQuotasT) reading(key string) (SubscriptionQuota, bool) {
|
||||
out, ok := c.reported(key)
|
||||
if ok {
|
||||
wasUp := usedUp(out)
|
||||
out.Windows = elapsed(out.Windows, time.Now())
|
||||
now := time.Now()
|
||||
// as read, before any window started again (none had by the zero
|
||||
// time)
|
||||
wasUp := usedUp(out, time.Time{})
|
||||
out.Windows = elapsed(out.Windows, now)
|
||||
// a hold that came with a window used up ends when that window
|
||||
// starts again; one with no window used up stands till a fresh read
|
||||
if out.Held && wasUp && !usedUp(out) {
|
||||
if out.Held && wasUp && !usedUp(out, now) {
|
||||
out.Held = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,10 @@ var usedCache struct {
|
||||
// Codex reset spent), by agent/user: a reading begun before says
|
||||
// nothing of them
|
||||
renewed map[string]time.Time
|
||||
// seen: each account's windows as last read, and when, by
|
||||
// agent/user, kept through a reading that failed, for the next to be
|
||||
// told renewed by (renewedFrom)
|
||||
seen map[string]reading
|
||||
}
|
||||
|
||||
// firstWait is how long a request waits for an agent's allowances the
|
||||
@@ -116,7 +120,8 @@ type Limit struct {
|
||||
Amount, Of float64
|
||||
Unit string
|
||||
matches func(string) bool
|
||||
partial bool // of a reading that may leave windows out (QuotaWindow.partial)
|
||||
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
|
||||
// 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
|
||||
@@ -404,6 +409,7 @@ func Allowances(agent string) map[string]Allowance {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
share := renewalShare(agent) // read before the lock: it reads providers.json
|
||||
began := time.Now()
|
||||
all := map[string]Allowance{}
|
||||
for user, q := range LoginUsage(ctx, agent) {
|
||||
@@ -414,17 +420,33 @@ func Allowances(agent string) map[string]Allowance {
|
||||
}
|
||||
c.Lock()
|
||||
at := time.Now()
|
||||
for user := range all {
|
||||
if c.renewed[agent+"/"+strings.ToLower(user)].After(began) {
|
||||
now := at
|
||||
if c.seen == nil {
|
||||
c.seen = map[string]reading{}
|
||||
}
|
||||
var renewed []string
|
||||
for user, a := range all {
|
||||
key := agent + "/" + strings.ToLower(user)
|
||||
if c.renewed[key].After(began) {
|
||||
// started again while this was read: not known till
|
||||
// it is read again, at once
|
||||
delete(all, user)
|
||||
at = time.Time{}
|
||||
continue
|
||||
}
|
||||
if was, ok := c.seen[key]; ok && a.renewedFrom(was.a, was.at, share, now) {
|
||||
renewed = append(renewed, user)
|
||||
}
|
||||
c.seen[key] = reading{a, now}
|
||||
}
|
||||
c.m[agent], c.at[agent] = all, at
|
||||
delete(c.loading, agent)
|
||||
c.Unlock()
|
||||
// told once the lock is let go (a hook may ask for these
|
||||
// again), and before those waiting for them are let go
|
||||
for _, user := range renewed {
|
||||
tellRenewed(agent, user)
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
}
|
||||
@@ -447,8 +469,10 @@ func Allowances(agent string) map[string]Allowance {
|
||||
}
|
||||
|
||||
// OnRenewed has f told when an account's usage windows were started again
|
||||
// (a Codex reset spent), so what sat out waiting for them can come back;
|
||||
// and, as agent "" and the key's KeyAllowanceID, when a reading of a key's
|
||||
// (a Codex reset spent), or a reading of them (Allowances) finds one it
|
||||
// was full in full no more, so what sat out waiting for them can come
|
||||
// back; agent is the one its usage is read under (Account.UsageAgent).
|
||||
// And, as agent "" and the key's KeyAllowanceID, when a reading of a key's
|
||||
// own windows finds one it was full in full no more: its limit raised in
|
||||
// its panel, or its usage reset.
|
||||
func OnRenewed(f func(agent, user string)) {
|
||||
@@ -491,6 +515,7 @@ func forgetAllowance(agent, user string) {
|
||||
c.renewed = map[string]time.Time{}
|
||||
}
|
||||
c.renewed[agent+"/"+strings.ToLower(user)] = time.Now()
|
||||
delete(c.seen, agent+"/"+strings.ToLower(user)) // told already, by renewedNow
|
||||
if c.at != nil {
|
||||
c.at[agent] = time.Time{}
|
||||
}
|
||||
@@ -574,7 +599,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}
|
||||
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] }
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A window whose reset has passed has started again, however the reading
|
||||
// came: Claude Code's /usage tells a reset to the minute and can lag it,
|
||||
// so a fresh run can say 100% of a session that reset a minute ago. Such a
|
||||
// reading isn't spent, and Claude Code isn't moved off its account for it;
|
||||
// one whose reset is still to come is.
|
||||
func TestFreshClaudeReadingPastItsResetIsNotSpent(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
reset time.Time
|
||||
spent bool
|
||||
}{
|
||||
{"reset a minute ago", time.Now().Add(-time.Minute), false},
|
||||
{"reset in an hour", time.Now().Add(time.Hour), true},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
at := c.reset.UTC().Format("Jan 2, 2006 at 3:04pm") + " (UTC)"
|
||||
_, card := claudeUsageCards(t, "Current session: 100% used · resets "+at+"\nCurrent week (all models): 10% used · resets "+soon(3)+" at 2pm (UTC)\n", nil)
|
||||
loginsMu.Lock()
|
||||
ls := upsertLogin(readLogins(), savedLogin{Agent: "claude", User: "b@example.com", Plan: "max", On: true, Seen: time.Now().UTC(),
|
||||
Auth: mustJSONRaw(t, map[string]any{"claudeAiOauth": map[string]any{"accessToken": "sk-ant-oat01-b", "refreshToken": "sk-ant-ort01-b",
|
||||
"expiresAt": time.Now().Add(time.Hour).UnixMilli(), "subscriptionType": "max", "scopes": []string{"user:inference", "user:profile"}}}),
|
||||
Profile: mustJSONRaw(t, map[string]any{"emailAddress": "b@example.com", "accountUuid": "u-b"})})
|
||||
err := writeLogins(ls)
|
||||
loginsMu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
NoteClaudeLimits("b@example.com", []ClaudeLimit{{Kind: "five_hour", Used: .1, ResetsAt: time.Now().Add(3 * time.Hour).Unix()}})
|
||||
|
||||
q := card() // a fresh /usage run
|
||||
if q.Error != "" || q.AsOf != nil || len(q.Windows) != 2 {
|
||||
t.Fatalf("not a fresh reading: %+v", q)
|
||||
}
|
||||
if got := q.Windows[0].Used >= 100; got != c.spent {
|
||||
t.Fatalf("the session reads %g%% (reset %v)", q.Windows[0].Used, q.Windows[0].ResetsAt)
|
||||
}
|
||||
if got := usedUp(q, time.Now()); got != c.spent {
|
||||
t.Fatalf("used up %v, want %v: %+v", got, c.spent, q.Windows)
|
||||
}
|
||||
from, to, back, ok := NextLogin(context.Background(), "claude")
|
||||
if ok != c.spent || c.spent && (from != "a@example.com" || to != "b@example.com" || back) {
|
||||
t.Fatalf("got %q→%q back=%v ok=%v, want a move %v", from, to, back, ok, c.spent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NextLogin counts a window whose reset has passed as started again on
|
||||
// every account it looks at — the one the agent is on, the one to go back
|
||||
// to and the spares — for a fresh reading as for one kept from before
|
||||
// (AsOf), Codex's as Claude's.
|
||||
func TestNextLoginTakesAPassedResetAsRenewed(t *testing.T) {
|
||||
past, future := time.Now().Add(-time.Minute), time.Now().Add(time.Hour)
|
||||
full := func(at time.Time) []QuotaWindow {
|
||||
return []QuotaWindow{{Name: "5 hours", Used: 100, ResetsAt: &at, Span: 5 * time.Hour}}
|
||||
}
|
||||
half := []QuotaWindow{{Name: "5 hours", Used: 50, ResetsAt: &future, Span: 5 * time.Hour}}
|
||||
room := []QuotaWindow{{Name: "5 hours", Used: 10, ResetsAt: &future, Span: 5 * time.Hour}}
|
||||
type move struct {
|
||||
ok, back bool
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
on, spare []QuotaWindow
|
||||
returns bool
|
||||
asOf bool
|
||||
want move
|
||||
}{
|
||||
{"on renewed", full(past), room, false, false, move{}},
|
||||
{"on renewed, kept reading", full(past), room, false, true, move{}},
|
||||
{"on still full", full(future), room, false, false, move{ok: true}},
|
||||
{"spare renewed", full(future), full(past), false, false, move{ok: true}},
|
||||
{"spare still full", full(future), full(future), false, false, move{}},
|
||||
{"back to one renewed", half, full(past), true, false, move{ok: true, back: true}},
|
||||
{"back to one still full", half, full(future), true, false, move{}},
|
||||
}
|
||||
for _, agent := range []string{"codex", "claude"} {
|
||||
t.Run(agent, func(t *testing.T) {
|
||||
var on, spare string
|
||||
if agent == "codex" {
|
||||
home := signIn(t) // me@example.com
|
||||
rememberLogins(true)
|
||||
codexSignIn(t, home, "work@example.com", "r-work")
|
||||
rememberLogins(true)
|
||||
on, spare = "work@example.com", "me@example.com"
|
||||
} else {
|
||||
home := claudeHome(t)
|
||||
claudeSignIn(t, home, time.Now().Add(time.Hour))
|
||||
writeFile(t, home+"/.claude.json", map[string]any{"oauthAccount": map[string]any{"emailAddress": "a@example.com", "accountUuid": "u-a"}})
|
||||
rememberLogins(true)
|
||||
loginsMu.Lock()
|
||||
ls := upsertLogin(readLogins(), savedLogin{Agent: "claude", User: "b@example.com", Plan: "max", Seen: time.Now().UTC(),
|
||||
Auth: mustJSONRaw(t, map[string]any{"claudeAiOauth": map[string]any{"accessToken": "sk-ant-oat01-b", "refreshToken": "sk-ant-ort01-b",
|
||||
"expiresAt": time.Now().Add(time.Hour).UnixMilli(), "subscriptionType": "max", "scopes": []string{"user:inference", "user:profile"}}}),
|
||||
Profile: mustJSONRaw(t, map[string]any{"emailAddress": "b@example.com", "accountUuid": "u-b"})})
|
||||
err := writeLogins(ls)
|
||||
loginsMu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
on, spare = "a@example.com", "b@example.com"
|
||||
}
|
||||
if err := SetLoginOn(agent, spare, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { LoginUsageVia(nil) })
|
||||
for _, c := range cases {
|
||||
var asOf *time.Time
|
||||
if c.asOf {
|
||||
at := time.Now().Add(-2 * time.Hour)
|
||||
asOf = &at
|
||||
}
|
||||
LoginUsageVia(func(context.Context, string) map[string]SubscriptionQuota {
|
||||
return map[string]SubscriptionQuota{
|
||||
on: {Provider: agent, User: on, Windows: c.on, AsOf: asOf},
|
||||
spare: {Provider: agent, User: spare, Windows: c.spare},
|
||||
}
|
||||
})
|
||||
r := loginReturn{}
|
||||
if c.returns {
|
||||
r = loginReturn{Back: spare, To: on}
|
||||
}
|
||||
setLoginReturn(agent, r)
|
||||
from, to, back, ok := NextLogin(context.Background(), agent)
|
||||
if ok != c.want.ok || back != c.want.back || ok && (from != on || to != spare) {
|
||||
t.Errorf("%s: got %q→%q back=%v ok=%v, want %+v", c.name, from, to, back, ok, c.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// What stops an account, for magpie quota wait and the Codex resets, is a
|
||||
// full window still to reset: one whose reset has passed doesn't stop it,
|
||||
// nor say when it is back.
|
||||
func TestUsedUpLeavesOutARenewedWindow(t *testing.T) {
|
||||
now := time.Now()
|
||||
past, future := now.Add(-time.Minute), now.Add(time.Hour)
|
||||
renewed := SubscriptionQuota{Provider: "codex", Windows: []QuotaWindow{{Name: "5 hours", Used: 100, ResetsAt: &past}}}
|
||||
if UsedUp(renewed, now) || !BackAt(renewed, now).IsZero() {
|
||||
t.Fatalf("a window reset a minute ago stops the account: back %v", BackAt(renewed, now))
|
||||
}
|
||||
full := SubscriptionQuota{Provider: "codex", Windows: []QuotaWindow{
|
||||
{Name: "5 hours", Used: 100, ResetsAt: &past}, {Name: "7 days", Used: 100, ResetsAt: &future},
|
||||
}}
|
||||
if !UsedUp(full, now) || !BackAt(full, now).Equal(future) {
|
||||
t.Fatalf("a full week doesn't stop it: back %v", BackAt(full, now))
|
||||
}
|
||||
// read as it was, before it reset, it was used up (quota_last's holds)
|
||||
if !usedUp(renewed, time.Time{}) {
|
||||
t.Fatal("the reading as read isn't used up")
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -149,7 +149,7 @@ func (w quotaWait) run(ctx context.Context, t quotaTarget, timeout time.Duration
|
||||
why = cmp.Or(why, q.Error)
|
||||
continue
|
||||
}
|
||||
if !provider.UsedUp(q) {
|
||||
if !provider.UsedUp(q, now) {
|
||||
return q, nil
|
||||
}
|
||||
known++
|
||||
|
||||
Reference in new issue
Block a user