Files
magpie/quota_wait.go
a2c3905368 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>
2026-10-08 06:24:44 +08:00

335 lines
10 KiB
Go

package main
import (
"cmp"
"context"
"errors"
"fmt"
"os"
"slices"
"strings"
"time"
"github.com/yetone/magpie/internal/provider"
)
// How often magpie quota wait reads the allowance again: shortly after the
// soonest reset it knows, but never sooner than a minute (the readings are
// kept that long anyway, and the vendors' usage endpoints are rate limited)
// nor later than ten, as a reset can come early (a Codex reset spent, a plan
// topped up) or not be told at all.
const (
quotaWaitMin = time.Minute
quotaWaitMax = 10 * time.Minute
quotaWaitMargin = 30 * time.Second
)
// exitError is an error that ends magpie with its own exit code rather
// than 1; with no err, nothing is printed.
type exitError struct {
code int
err error
}
func (e exitError) Error() string {
if e.err == nil {
return ""
}
return e.err.Error()
}
func (e exitError) Unwrap() error { return e.err }
// errWaitTimeout is quota wait's --timeout running out, exit code 1.
var errWaitTimeout = errors.New("timed out")
// quotaTarget is what magpie quota wait waits on: every account of a
// provider (user ""), or one account.
type quotaTarget struct {
provider string
user string
label string
}
func (t quotaTarget) has(q provider.SubscriptionQuota) bool {
return strings.EqualFold(q.Provider, t.provider) && (t.user == "" || strings.EqualFold(q.User, t.user))
}
// resolveQuotaTarget finds what arg names among the readings, as magpie
// quota matches a provider (its id or name), or one account by its user,
// or <provider>/<user> where two providers have the same user. find says
// whether arg is a provider at all, for an error that tells the two apart.
func resolveQuotaTarget(arg string, qs []provider.SubscriptionQuota, find func(string) bool) (quotaTarget, error) {
byProvider := func(name string) (string, bool) {
for _, q := range qs {
if strings.EqualFold(q.Provider, name) || strings.EqualFold(q.Name, name) {
return q.Provider, true
}
}
return "", false
}
if id, ok := byProvider(arg); ok {
return quotaTarget{provider: id, label: id}, nil
}
if p, u, ok := strings.Cut(arg, "/"); ok {
if id, ok := byProvider(p); ok {
t := quotaTarget{provider: id, user: u, label: id + " · " + u}
if slices.ContainsFunc(qs, t.has) {
return t, nil
}
return quotaTarget{}, fmt.Errorf("%s has no account %s · magpie quota %s lists its accounts", id, u, id)
}
}
var hits []quotaTarget
for _, q := range qs {
if q.User != "" && strings.EqualFold(q.User, arg) && !slices.ContainsFunc(hits, func(t quotaTarget) bool { return t.provider == q.Provider }) {
hits = append(hits, quotaTarget{provider: q.Provider, user: q.User, label: q.Provider + " · " + q.User})
}
}
switch {
case len(hits) == 1:
return hits[0], nil
case len(hits) > 1:
var names []string
for _, t := range hits {
names = append(names, t.provider+"/"+t.user)
}
return quotaTarget{}, fmt.Errorf("%s is an account of more than one subscription: name one of %s", arg, strings.Join(names, ", "))
}
if find != nil && find(arg) {
return quotaTarget{}, fmt.Errorf("%s has no subscription or plan whose allowance magpie reads: sign in to it first, or magpie quota lists what there is", arg)
}
return quotaTarget{}, fmt.Errorf("no subscription, plan or account %s · magpie quota lists them", arg)
}
// quotaWait is magpie quota wait's loop, with what it reads, the clock and
// how it sleeps given, so tests run it on a fake clock.
type quotaWait struct {
// read is every account's allowance now; readable ones say Error "".
read func(ctx context.Context) []provider.SubscriptionQuota
// skip, when set, leaves out an account a provider's wait shouldn't
// count: one switched off or signed out in magpie, whose room the agent
// would never be moved to.
skip func(q provider.SubscriptionQuota) bool
now func() time.Time
sleep func(ctx context.Context, d time.Duration) error
say func(string)
}
// readable is a reading made now that says the account's windows: not an
// error, nor the last one kept in its place (AsOf), which may be hours old.
func readable(q provider.SubscriptionQuota) bool {
return q.Error == "" && q.AsOf == nil && len(q.Windows) > 0
}
// run waits until an account of t is not used up, and returns it. The
// error is errWaitTimeout once timeout (when over 0) has passed, or ctx's
// once it is cancelled.
func (w quotaWait) run(ctx context.Context, t quotaTarget, timeout time.Duration) (provider.SubscriptionQuota, error) {
var deadline time.Time
if timeout > 0 {
deadline = w.now().Add(timeout)
}
backoff := quotaWaitMin
for {
qs := w.read(ctx)
if err := ctx.Err(); err != nil {
return provider.SubscriptionQuota{}, err
}
now := w.now()
known, unread, why := 0, 0, ""
var back time.Time
backWho := ""
for _, q := range qs {
if !t.has(q) || (t.user == "" && w.skip != nil && w.skip(q)) {
continue
}
if !readable(q) {
unread++
why = cmp.Or(why, q.Error)
continue
}
if !provider.UsedUp(q, now) {
return q, nil
}
known++
// the soonest any account is back; one that doesn't say when
// leaves it to the next look
if at := provider.BackAt(q, now); !at.IsZero() && (back.IsZero() || at.Before(back)) {
back, backWho = at, q.User
}
}
var d time.Duration
var line string
switch {
case known == 0:
// nothing read: try again, less often each time, as a vendor
// that is down or refusing isn't helped by asking every minute
d, backoff = backoff, min(backoff*2, quotaWaitMax)
line = fmt.Sprintf("%s: its allowance couldn't be read", t.label)
if why != "" {
line += " (" + why + ")"
}
line += fmt.Sprintf(" · trying again in %s", roundWait(d))
case back.IsZero():
backoff = quotaWaitMin
d = quotaWaitMax
line = fmt.Sprintf("%s is used up, and when it starts again isn't told · checking again in %s", t.label, roundWait(d))
default:
backoff = quotaWaitMin
d = min(max(back.Sub(now)+quotaWaitMargin, quotaWaitMin), quotaWaitMax)
who := t.label
if t.user == "" && backWho != "" && known > 1 {
who += " (" + backWho + " first)"
} else if t.user == "" && known > 1 {
who += " (every account)"
}
line = fmt.Sprintf("%s is used up until %s · checking again at %s", who, provider.ResetClock(back, now), now.Add(d).Local().Format("15:04"))
}
if known > 0 && unread > 0 {
line += fmt.Sprintf(" · %d not read", unread)
}
if !deadline.IsZero() {
if !now.Before(deadline) {
return provider.SubscriptionQuota{}, errWaitTimeout
}
d = min(d, deadline.Sub(now))
}
if w.say != nil {
w.say(line)
}
if err := w.sleep(ctx, d); err != nil {
return provider.SubscriptionQuota{}, err
}
}
}
// roundWait is a wait in words, to the minute: "2m0s" reads as "2m".
func roundWait(d time.Duration) string {
s := d.Round(time.Minute).String()
s = strings.TrimSuffix(s, "0s")
return strings.TrimSuffix(s, "h0m")
}
// sleepCtx sleeps d, or until ctx ends.
func sleepCtx(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// quotaWaitCmd: magpie quota wait <provider|account> [--timeout <d>]
// [--quiet] — blocks until the subscription (any of its accounts) or the
// account has allowance again, for a script to go on with the work it
// stopped (#720): until codex exec …; do magpie quota wait codex; done.
// Exits 0 then, 1 on --timeout, 2 on a name it doesn't know, 130 on Ctrl+C.
func quotaWaitCmd(args []string) error {
var arg string
var timeout time.Duration
quiet := false
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case a == "help" || a == "-h" || a == "--help":
fmt.Println(quotaUsage)
return nil
case a == "--quiet" || a == "-q":
quiet = true
case a == "--timeout" || strings.HasPrefix(a, "--timeout="):
v, ok := strings.CutPrefix(a, "--timeout=")
if !ok {
if i+1 >= len(args) {
return exitError{2, fmt.Errorf("--timeout needs a duration, such as 30m or 6h")}
}
i++
v = args[i]
}
d, err := time.ParseDuration(v)
if err != nil || d <= 0 {
return exitError{2, fmt.Errorf("--timeout is a duration such as 30m or 6h, not %q", v)}
}
timeout = d
case strings.HasPrefix(a, "-"):
return exitError{2, fmt.Errorf("unknown flag %s · %s", a, "magpie quota wait <provider|account> [--timeout <duration>] [--quiet]")}
default:
if arg != "" {
return exitError{2, fmt.Errorf("one subscription or account at a time: %s or %s", arg, a)}
}
arg = a
}
}
if arg == "" {
return exitError{2, fmt.Errorf("wait for what? magpie quota wait <provider|account>, such as codex")}
}
ctx, stop := interruptContext()
defer stop()
claude := strings.HasPrefix(strings.ToLower(arg), "claude")
read := func(ctx context.Context) []provider.SubscriptionQuota {
// a new reading each time: the cached one is what the last look saw
if claude {
provider.AskClaudeUsage()
} else {
provider.AskUsage()
}
rctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return provider.Allotted(rctx)
}
qs := read(ctx)
if ctx.Err() != nil {
return exitError{130, nil}
}
t, err := resolveQuotaTarget(arg, qs, func(s string) bool { _, err := provider.Find(s); return err == nil })
if err != nil {
return exitError{2, err}
}
claude = strings.EqualFold(t.provider, "claude")
skip := func(q provider.SubscriptionQuota) bool {
for _, l := range provider.Logins(q.Provider) {
if q.User != "" && strings.EqualFold(l.User, q.User) {
return !l.On || l.Lapsed != ""
}
}
return false
}
if t.user == "" && !slices.ContainsFunc(qs, func(q provider.SubscriptionQuota) bool { return t.has(q) && !skip(q) }) {
return exitError{2, fmt.Errorf("none of %s's accounts is on in magpie · turn one on, or name the account", t.provider)}
}
first := true
w := quotaWait{
read: func(ctx context.Context) []provider.SubscriptionQuota {
if first { // what resolving read is new enough
first = false
return qs
}
return read(ctx)
},
skip: skip,
now: time.Now,
sleep: sleepCtx,
}
if !quiet {
w.say = func(s string) { fmt.Fprintln(os.Stderr, muted.Render("…"), s) }
}
q, err := w.run(ctx, t, timeout)
switch {
case errors.Is(err, errWaitTimeout):
return fmt.Errorf("%s still has no allowance after %s", t.label, timeout)
case err != nil:
return exitError{130, nil}
}
if !quiet {
who := q.Provider
if q.User != "" {
who += " · " + q.User
}
fmt.Println(green.Render("✓"), who, "has allowance again")
}
return nil
}