Commit Graph
47 Commits
Author SHA1 Message Date
yetone 5d0c1dcad7 agent, library, sessions: a Claude Desktop installed as an MSIX package on Windows is found, wired, given MCP servers and skills, and its Cowork calls read, in the package's folders (Kilig on Discord, Windows 10 LTSC 21H2)
Desktop from its setup.exe there runs packaged, so Windows keeps what it
writes under %APPDATA% and %LOCALAPPDATA% in
%LOCALAPPDATA%\Packages\Claude_<publisher>\LocalCache\{Roaming,Local}.
Nothing was in %APPDATA%\Claude or %LOCALAPPDATA%\Claude*, so magpie didn't
recognise Desktop at all. The packaged app reads its own copy before the
real folder's, so writing the usual folders wouldn't reach it either.

New internal/desktopdir is the one resolver for Desktop's folders: Data
(its own userData: %APPDATA%\Claude, the package's LocalCache\Roaming\Claude),
Mode (the Claude folder deploymentMode goes into, %LOCALAPPDATA%\Claude as
before) and ThreeP (Claude-3p). The package is found by globbing
Packages\Claude_* (the publisher id isn't assumed) and used unless Desktop
runs in the usual folders: it has run there (Chromium's "Local State"; an
%APPDATA%\Claude holding only another tool's claude_desktop_config.json
doesn't count) and not later in the package's. Every place that built
these paths goes through it:

- agent: desktopDirs (its 3p switch's writes, DesktopConfig3p, Claude
  Code's desktop check in claude.go) and detection, which also looks at
  Data (replacing the separate %APPDATA%\Claude check). windowsDesktopDir
  moved into desktopdir unchanged.
- library: apps() (Desktop's MCP file, was os.UserConfigDir) and
  desktopSkillRoots (Cowork's skills-plugin).
- sessions: desktopDataDirs, a copy of agent's resolver, now desktopdir's
  All(); it also adds %APPDATA%\Claude on a usual Windows install, where
  Desktop keeps 1p Cowork sessions (only %LOCALAPPDATA%'s were read). usage's
  request_page and session_provider read it through sessions.DesktopDataDirs.

Tests: TestFindMSIX/TestFindWindowsClassic/TestFindOthers (desktopdir),
TestClaudeDesktopMSIX (agent: detected, switch writes LocalCache\Local\Claude
and Claude-3p, nothing in the usual folders), TestClaudeDesktopMSIXLibrary
(MCP file and skills-plugin in LocalCache\Roaming\Claude),
TestCallsCoworkMSIX (sessions). With the call sites put back to origin/main
the last three fail: "an MSIX Claude Desktop isn't detected", "no Claude
Desktop target: <nil>", "dirs: [.../Claude .../Claude-3p]".

Run: go test -tags nogui ./... under a temp HOME with pinned caches (all
ok); GOOS=darwin|linux|windows go vet -tags nogui on desktopdir, agent,
library, sessions, usage; GOOS=windows go build, GOOS=linux go build -tags
nogui. On the Windows 11 box (Squirrel install, no MSIX package): the
windows test binaries pass every Desktop/MSIX/Cowork test; the full agent
and sessions binaries fail the same 16 and 76 tests (run outside their
testdata) as origin/main's; a read-only probe of desktopdir.Here() gives
the usual %APPDATA%\Claude, %LOCALAPPDATA%\Claude and Claude-3p, Package "".
Not tried against a real MSIX Desktop: none on the box, and Claude-3p's
place in the package (LocalCache\Local, as Local\Claude is in Kilig's
listing) is inferred, not seen.
2026-10-08 12:47:48 +08:00
Alex be1ce5d4d2 davsync: incomplete S3 listings keep cached usage days (#1243)
Reject truncated listings with missing or repeated tokens and listings that exceed the page limit. On the old code the regression reports successful partial listings and shareWith removes a cached day. Focused tests pass in a temporary HOME.
2026-10-08 11:52:26 +08:00
forever ac142bb1d2 agent, gateway: Claude Code through magpie can keep its claude.ai sign-in (#1218)
* gateway: Claude Code signed in to claude.ai is told a provider's 401 as a 502, not as its own sign-in refused

Claude Code signed in to claude.ai, its base URL magpie's and no key set,
sends its own sign-in as its key (Authorization: Bearer sk-ant-oat…),
which magpie never passes on. A 401 it reads as that sign-in refused: it
renews the sign-in before each of its retries (ten for one request) and
then asks to /login; a 403 saying "OAuth token has been revoked" the
same. Whatever magpie answers such a request with is never about that
sign-in: a provider refused magpie's credential, or one of magpie's own
Claude accounts lapsed (the bridge's 401 or revoked 403). keepsSignIn,
on /v1/messages, /messages and /v1/messages/count_tokens, tells such a
request's 401 as a 502, and a 403 too when its body says the token was
revoked; the body is the provider's message as it was. Recent calls and
the usage log keep the provider's status. Any other 403, and a request
with magpie's key or an API key, is answered as before; no
x-should-retry is added, so Claude Code retries a 502 as it does a 5xx.

Verified: TestClaudeSignInNotToldItsSignInFailed (401 on /v1/messages,
/messages and count_tokens; revoked 403; another 403; magpie's key; an
API key; a reply and a stream untouched; Recent calls' status) fails
without the route change ("401: 401 … want 502", "revoked: 403 … want
502", "401 counting: 401 … want 502"); TestSignInWriterReleasesA403. go
test -tags nogui ./internal/gateway, go vet and gofmt are clean.

* gateway: a provider never gets the oauth beta of Claude Code's claude.ai sign-in

Claude Code signed in to claude.ai asks oauth-2025-04-20 in
anthropic-beta with its sign-in. magpie relayed the agent's betas to
every Anthropic-protocol provider, so that one went too, naming a
credential that never leaves magpie: a provider got a request claiming
an OAuth sign-in it was not sent, beside magpie's key for it. askedBetas
leaves the agent's oauth- betas out of what forwardOnce asks, so a
provider gets the request as Claude Code with magpie's key sends it. A
beta the user set on the provider (header.anthropic-beta, fitUserBetas)
still goes, oauth- or not.

Verified: TestSignInBetaStaysWithMagpie (the oauth beta among others,
alone, and the user's own on a provider) fails without the forwardOnce
change (sent ["claude-code-20250219,oauth-2025-04-20,…"], and
["oauth-2025-04-20"] where none was wanted); TestUserBetas* and
TestClaudeCodeHeadersReachAnthropicRelay pass. go test -tags nogui
./internal/gateway, go vet and gofmt are clean.

* agent, gui: Claude Code through magpie can keep its claude.ai sign-in

Claude Code's new sign-in field is magpie's key (the default) or claude.ai.
claude.ai writes ANTHROPIC_AUTH_TOKEN empty, which Claude Code reads as no
key: it stays signed in to claude.ai (/usage plan limits, connectors, voice,
/teleport) and sends that sign-in to magpie, which never passes it on. Every
later write (a model, a tier, the subagents, a /model pick followed, Reapply,
a port move, Disconnect and Connect) keeps it so, and a key put there by hand
is reported by Check rather than taken for magpie's. A token emptied or taken
out by hand in key mode is reported with the way to keep it so.

It is offered only where the gateway takes any key (loopback, a WSL distro
mirrored), not from a distro under NAT while magpie is shared on the network.
The GUI draws no square for a Claude Code with no other sign-in to take.

Verified: go test -tags nogui ./internal/agent (new claude_login_test.go,
each test fails with its change reverted) and -run 'SignIn|Claude|Beta'
./internal/gateway; go vet; GOOS=linux/windows builds; claude-signin.test.cjs
(fails without the app.js change) plus i18n-keys, agent-field-names,
agent-layout, claude-direct, claude-effort, codex-signin-always,
subagent-effort and menu-scroll, Chromium and WebKit, en/zh/ja/de, 1100 and
440px. Real Claude Code 2.1.292 with the token empty sent Bearer sk-ant-oat
and the oauth beta to the base URL, took a 502 as a server error, and stayed
signed in to claude.ai.

* agent: Claude Code given a magpie model at an older gateway address keeps the endpoint it had before magpie

When the gateway moved while magpie wasn't running to follow it, a magpie
model picked next found Claude Code at the older address and stashed what
was there as the user's own: the dead gateway URL, magpie's key or the
empty one of a claude.ai sign-in kept, and magpie's own tier models. That
overwrote the endpoint and token stashed when magpie was first wired in, so
switching off put the dead address back, and with the claude.ai sign-in
kept "model default" left it and five magpie tiers too.

What's there at an older address is now taken for magpie's (keyOurs: its
key, or an empty token with the claude.ai sign-in kept), and the stash made
when magpie was wired in stays. The same test replaces "model default"'s
guess from the model's name, which could take out a user's own endpoint
with an empty token.

Verified: TestClaudeAtAnOlderGatewayAddress (both sign-ins, Disconnect and
model default; fails on the previous commit, the key-mode case on main
too); go test -tags nogui ./internal/agent; -race -count=5 on the Claude
and WSL Claude tests; go vet; GOOS=windows build.

* gateway: Claude Code signed in to claude.ai is told a provider's refusal in magpie's words, never mentioning x-api-key

Claude Code 2.1.293 tells any error whose message mentions x-api-key as
"Not logged in · Please run /login", whatever its status, so the 502 alone
still signed it out (yetone on #1218). A 401, or a 403 saying the OAuth
token was revoked, now reaches it as an api_error "<provider> refused
magpie's credential (HTTP 401): invalid API key", and a stream's error
after its 200 says "API key" too. Recent calls keep the provider's status
and message.

Checked with Claude Code 2.1.292 signed in to claude.ai, through the
gateway to a provider answering 401 invalid x-api-key: it shows "API
Error: 502 Fake refused magpie's credential (HTTP 401): invalid API key.
This is a server-side issue" and stays signed in.

* agent: an empty token at an endpoint of the user's own stays theirs, though Claude Code once kept its claude.ai sign-in through magpie

keyOurs took any empty ANTHROPIC_AUTH_TOKEN for magpie's once the sign-in
was kept, so after Disconnect a user's own https://corp.example with an
empty token lost both to the model default (yetone on #1218). The empty
token is magpie's only beside the address magpie last wrote, stashed as
claude.wired_at, which Rewire carries to the gateway's new port.

* gateway: no error Claude Code signed in to claude.ai is told mentions x-api-key, whatever its status

Claude Code reads any error naming x-api-key as its own sign-in refused,
so a provider's 403 "invalid x-api-key" or 400 "x-api-key header is
required" still told it "Not logged in · Please run /login" (yetone on
#1218, Claude Code 2.1.293). Every error such a request is told is held
until its body and says "API key" there, at the status it had; a 401 and
a revoked 403 stay the 502 in magpie's words.
2026-10-08 08:58:21 +08:00
forever 202031642a provider: an account refused while its allowance is being read has it read again once that reading is back, and keeps the share it had meanwhile (#1142)
* provider: an account refused while its allowance is being read has it read again once that reading is back, and keeps the share it had meanwhile

StaleAllowance zeroed the time an agent's allowances were read, and
dropped the account's own reading, but a reading Allowances already had
out, asked before the refusal, came back after it, set that time to now
and was trusted for a minute. For that minute the Routing page and the
trace showed the share from before the refusal, the tray's account in
use kept the refused account, and a second refusal of the account found
its window not full: a quota refusal with no reset time rested 15
minutes by "quota" instead of until the window renews, and the account
was tried and refused again after it. Every caller of StaleAllowance had
it: the gateway after a refusal, Claude's limit headers, a Codex reset
spent and both warm-ups.

StaleAllowance now marks the agent stale when a reading is out, as
StaleKeyAllowance does a key (ee2588c2). Once that reading is back what
it read is kept, but as not read, so the next ask reads again at once.
The values are kept rather than dropped because weigh counts an account
it doesn't know as unused, which would put the refused account first.
The tests' resets of the allowances clear the mark.

TestAllowancesStaleMidRead (provider: the reading held until after
StaleAllowance, answered 97%; later readings 100%) fails without the
change, "first ask 97%, 2s later 97%, readings 1", 3 of 3.
TestRefusedMidReadRestsByWindow (gateway: provider.Allowances behind
restAfter, as a plugin's agent is kept) fails without it, "second
refusal rests 15m0s by quota; the share read 97%, readings 2", 3 of 3,
and now rests by "window". Both pass -race -count=20, as do
TestAllowances, TestResetRunsOut, TestKeyAllowance and TestLoginRoom.
The verification commands of gateway-routing.md and
providers-accounts.md, go test -tags nogui ./..., go vet and gofmt -l
pass. docs/reference.md says it.

* docs: providers-accounts says a reading out when StaleAllowance is called is kept and read again (from #1161)

* provider: a built-in Grok account refused while its allowance is being read is read again on the next ask, not served the share read before the refusal for a minute

grokLoginUsage wrote every per-home reading back with a fresh time, so one
out when StaleAllowance("grok", user) dropped the home's entry was kept and
served for up to a minute. Each home's reading now carries a pending
generation that StaleAllowance drops; a reading whose generation no longer
matches goes to its caller but isn't cached, as loginReading does for the
other agents. A Grok moved to its plugin goes through loginReading and was
not affected.
2026-10-08 08:23:20 +08:00
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
yetone c83c0d5fef gateway: sharing keeps the host MAGPIE_ADDR names (127.0.0.1 behind Tailscale Serve, one interface's address) instead of listening on every interface, and still asks what reaches it from elsewhere for a gateway key (#1112)
listenAddr turned sharing into 0.0.0.0:<port> and dropped the host of an
explicit MAGPIE_ADDR, so requiring gateway keys from remote callers also
widened the listener. A host MAGPIE_ADDR names is now kept while shared;
0.0.0.0, :: or no host still means every interface, and an unset
MAGPIE_ADDR still shares on every interface. LANURLs offers the pinned
address alone, or none on loopback (MAGPIE_PUBLIC_URL says the proxy's).

Verified: TestSharedKeepsPinnedAddr fails without the lan.go change
("shared, MAGPIE_ADDR on loopback, listens on 0.0.0.0:4555") and passes
with it, -race -count=20. go test -race ./internal/gateway ./internal/gui
green under a temp HOME.
2026-10-07 16:47:41 +08:00
yetone 6b6113aa7a gateway keys: a key can be created with a value of the user's own, one their clients already send, so moving from another gateway (CLIProxyAPI's api-keys) doesn't mean re-keying every client (love1sbug on X)
Gateway → Gateway keys → Add gateway key has a second field, "Your own key
(optional)"; `magpie gateway-key add <name> --key <value>` (or `--key -`
from stdin) does the same. Left empty, magpie makes a sk-magpie-key- as
before. A value is 8–256 printable ASCII characters with no spaces, can't
start with sk-magpie-, and can't be another key's. The list shows only its
last four characters ("…0042"); rotating it gives a magpie-made key.

From another computer any enabled key was already checked, so an own value
works there as is. From this computer the gateway only identified a request
whose key looked like sk-magpie-…, so an own value would have been let in
unidentified: not counted as the key, nor held to its limit, models or
accounts. managedKey now asks access.Named, which also knows the store's
own values (cached by the file's size and time).

Tested: go test -race . ./internal/access ./internal/gateway ./internal/gui;
-count=20 on the new and LAN guard tests; TestOwnKeyValueIsTheKey fails with
managedKey back on access.Managed. gateway-own-key.test.cjs passes 16/16
(chromium and webkit, en/zh/ja/de, 1000 and 440px), and the gateway-key
suites, i18n-keys, gui-ja and gui-de pass. A built binary in a sandbox HOME
with sharing on: own key from the LAN address 200, a wrong or no key 401,
from 127.0.0.1 200, the old value after rotate 401. GOOS=linux and windows
builds pass.
2026-10-07 14:34:36 +08:00
yetone 05683637d5 plugins: the Bun the plugin host runs on is kept as magpie-bun, so a proxy app's PROCESS-NAME rule can route the requests plugins (Cursor and the other moved subscriptions) make (#1048)
magpie downloads its own Bun into ~/.cache/magpie/bun/<version>/. It is now
written there as magpie-bun (magpie-bun.exe on Windows), and a Bun downloaded
before is renamed in place the first time it is looked for, so nothing is
downloaded again and no second copy takes disk. The host, bun add and bun
update all run it, so all of their traffic carries the one name. A
$MAGPIE_BUN the user set is run as it is.

Verified: TestBunDownloadedAsMagpieBun, TestBunDownloadedBeforeTakesTheName
and TestHostRunsAsMagpieBun fail without the change (the Bun is .../bun, the
host runs .../bun). TestHostRunsAsMagpieBun starts the real host on a copy of
Bun 1.4.2 and `ps -o comm=` names it magpie-bun on macOS. On the Windows box
the three tests pass with the Windows build of Bun 1.4.2, a running bun.exe
renames to magpie-bun.exe, and Get-Process/Win32_Process name it magpie-bun.
Linux not run here (CI's ubuntu job runs the test with Bun on PATH).
go test -tags nogui ./... green; -race -count=10 on the new tests green.
2026-10-07 01:37:31 +08:00
yetone 33e4f165aa docs: how magpie picks an account — the steps a request's accounts go through, what Make first counts under each routing, Smart's and Weekly pace's real rules, rests after running out, gateway vs. on its own, and how often an allowance is read (#1032, Cyb3rN8)
docs/reference.md gets a section "How magpie picks an account" after Routing
groups; docs/subsystems/gateway-routing.md links it and notes the two
points users most often misread. Docs only; no behavior changes.

Every claim in the issue was checked against origin/main (38a0f470):
internal/gateway/routing.go (weighRouted, restAfterMarked, rateRest),
affinity.go (affine), fallback.go (perKeyBarred, plan, restLast),
gateway.go (serve), internal/provider/routing.go (Allowance.For, Renewal,
Pace, Allowances), codex_switch.go (NextLogin, KeepOnAnAccountWithRoom),
subscription_usage.go (claudeWindows), claude_limits.go, login_usage.go,
and app.js's Make first.

Claims in the issue that were wrong or incomplete:
- The order has more than four steps: Sink (when on) after the routing,
  the provider's fallback models after its accounts, then resting ones
  last over all of them, then affinity.
- A Claude account that has to be signed in again is not filtered out: it
  stays a candidate and is passed over at its turn, without resting.
- "Used to about 90% and it switches" holds only for new conversations:
  affinity keeps a conversation on an account until 98% (100% In order).
- Smart: Make first is not "useless once resets differ by an hour". Resets
  are compared window by window, biggest first, each truncated to the clock
  hour, so 13:55 and 14:05 differ while 14:05 and 14:55 tie; the order
  counts only when every window ties (or, at 90%+, shares are equal).
- Weekly pace: Make first counts only when the recent tokens are equal,
  not "similar" (a decayed float: in practice both zero).
- Saved Claude accounts' usage heard from rate_limit_event has no 1-hour
  validity: it is kept until replaced (a window past its reset reads as
  empty). claudeHeard's hour only lets such a reading stand over a failed
  /usage of the signed-in account.
- Claude's /usage also runs once after start and on `magpie accounts`, the
  TUI's Usage page and `magpie quota wait`, not only the GUI refresh.
- The direct-use switch also moves on at a usage cap, uses 100% under In
  order, goes back once the account it moved off has every window under
  90%, and never moves to a saved Claude account whose allowance isn't
  known yet.
- "Response header" in the rest order is magpie's own carrier of the
  refusal's reset time; Retry-After is held to an hour.

No UI hint: the natural spot (Make first's tooltip) would reword existing
strings in four languages; left for a separate change.
2026-10-06 21:52:41 +08:00
yetone dd027bc433 sessions: a Claude Code workflow's agents are read as their session's calls (ksinverse on X: 发现Claude的用量和会话不太准,codex就非常好。是不是sub-agents没有被统计进去?默认开ultracode) — Claude Code writes a workflow's (ultracode) agents a folder deeper than other subagents, <session>/subagents/workflows/<run>/agent-*.jsonl beside the run's journal.jsonl, and ccFiles read only <session>/subagents/*.jsonl, so with ultracode on every workflow agent's calls and tokens were missing from Usage, the Requests page and the session's totals. ccFiles now reads each run's agent-*.jsonl too (the journal isn't a transcript and stays out), and sessionOfPath gives them their session, so they count as the session's like any subagent's; Claude Desktop's and Qoder's readers, which share ccFiles, follow. TestCallsWorkflowAgents (a session, a workflow agent's file and a journal with a line that reads as a call) fails without the change (1 call, the agent's missing) and passes. On this machine's real ~/.claude (read only), 34 workflow-agent calls of one session were unread and are now that session's. go test ./internal/sessions, -race -count=10 on TestCalls*/Subagent*/Trace*, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried against the reporter's own transcripts. docs/reference.md says it. 2026-10-06 20:54:36 +08:00
yetone a334fe091c gateway: a request a proxy or tunnel on this computer forwards to loopback is answered as one from another machine, and needs the gateway key (#1022, znjhahaha: asked for a Cloudflare Tunnel in magpie, with the gateway key checked for requests through it) — local() was the request's address alone, so cloudflared, ngrok, Tailscale serve or funnel, frp's HTTP proxy, or a Caddy or nginx here, all reaching 127.0.0.1:3425 for clients anywhere, were taken for this computer's agents: whoever had the tunnel's address used the user's subscriptions with any token, read /v1/magpie/quotas (the accounts and their balances) and the remote cards, and their usage was counted as local, all without sharing on. reference.md said a proxy here had to sign its clients in itself; a quick tunnel signs in nobody. A request on loopback carrying a forwarding header (Forwarded, X-Forwarded-For, X-Real-IP, Cf-Connecting-IP, True-Client-IP, Tailscale-User-Login), which no agent sends, is now proxied, and local() is false for it, so every check built on local() takes it as from the network: lanGuard refuses it while magpie isn't shared, saying it came through a proxy or tunnel and that Settings → Share on local network and a gateway key let it in; shared, it needs an enabled gateway key and is then sharedWith, as a LAN client is, for the model APIs, quotas, remote cards, concurrency, session route and MCP sign-ins; the Omarchy theme and the Codex title link stay this machine's only, and its usage is no longer marked local. A gateway MAGPIE_ADDR opens to the network stays open to a tunnel as to anyone; one MAGPIE_ADDR keeps on loopback is closed to it. MAGPIE_TRUST_PROXY=1 says a proxy here signs its clients in itself and keeps what it forwards local, as before (listed in agentenv_test's notAnAgent). A tunnel forwarding bare TCP (frp tcp, ssh -R, socat) adds no header and can't be told from an agent: reference.md says to point one at the LAN address with sharing on. Running cloudflared from magpie with a pasted Tunnel Token is not done here: magpie would have to download or run a program it doesn't ship; reference.md says how to serve magpie through a Cloudflare Tunnel today. TestProxiedLoopbackNeedsTheKey (from 127.0.0.1 with cloudflared's, ngrok's, nginx's, RFC 7239's, Akamai's and Tailscale serve's headers: refused unshared, 401 shared without a key, with magpie's token or a wrong key, let in with the key under its identity, a disabled key refused; an agent on loopback let in with any token; MAGPIE_TRUST_PROXY; MAGPIE_ADDR on loopback and open) and TestProxiedLoopbackRoutes (quotas, quotas history, models, Omarchy and /mcp through a tunnel, unshared and shared, with and without the key) fail on main (200 for every tunnel unshared) and pass -race -count=10 with TestLANGuard, TestQuotasOverLAN, TestManagedLANGuard and TestExplicitOpenGatewayAcceptsAllRemoteTokens; go test -race -tags nogui ./internal/gateway (579 s, under -timeout 30m) and go test -tags nogui ./internal/... under a temp HOME pass; go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. No GUI change. Not tried with a real cloudflared (not installed here); its Cf-Connecting-IP and X-Forwarded-For are what Cloudflare documents it sends. 2026-10-06 20:35:18 +08:00
yetone 555f570660 gateway, provider: the Routing page says what a lone key or account has left, and a key bought as a plan (Kimi Code, GLM Coding Plan, MiniMax, OpenCode Go, Command Code) is weighed by its plan's windows (#1016, gphysign: Kimi Code (China) with one key on, Smart routing; the card said only "Kimi Code (China) 只启用了一个 Key,无需选择。" while /v1/magpie/quotas had its 5 hours 49 used, 51 left and its 7 days 45 used) — two gaps. weighRouted returned before reading anyone's allowance when there was one candidate, so a lone key or a lone subscription account was never known in the trace, and the Routing page, which shows a bar and "{n} used · renews in {d}" for a known row, had nothing to show; every single-account subscription said "what's left not known yet" the same way. And KeyAllowance read only a sub2api key's limits, so a key whose plan the Usage page reads (planQuotaSourceOf: Kimi Code, GLM Coding Plan and Z.ai, MiniMax Coding Plan, OpenCode Go, Command Code) was never known to routing, with one key or several. weighRouted now reads what each candidate has left before returning a lone one (one alone is still weighed against nobody). readsKeyWindows takes a plan key too; keyWindows reads it through planKeyWindows (planWindows, with Zhipu's team quota behind a key that has none of its own, as PlanQuotas does), and PlanQuotas' own read of a card is noted for routing at once (noteKeyAllowance), so the Usage page and routing never ask twice; the card kept on disk seeds it after a restart (keyCardTag keys a plan card as plan:, a sub2api one as balance:). A key at such a vendor that turns out to be pay-as-you-go, with no windows, is asked again every 30 minutes (keyNoWindowsAge), not every minute it is used. So, beyond the issue: several plan keys on one provider are now weighed by their windows under Smart, usage and pace, and a plan key the vendor refuses as out of quota rests until its window renews and comes back once read short of full, as a sub2api key does. affine no longer says a lone key or account all but used up "moved" its conversation: there is nobody to move to (at > 0). The GUI needed no change. TestLoneOneTellsWhatItHasLeft (a lone Kimi key known at 49% with both renews, the week first; a lone Codex account known; a plain DeepSeek key not), TestLoneSpentKeyKeepsItsConversation (fails with the at > 0 guard taken out), TestPlanKeyAllowance (the windows read behind the request, asked once; DeepSeek and Moonshot keys never asked), TestPlanKeyAllowanceFromItsCard (PlanQuotas' read known at once without a second ask; after a restart with the vendor down, the card on disk stands in and a failed read keeps it) and TestPayAsYouGoKeyAskedSeldom (a GLM pay-as-you-go key asked once, not two minutes on, again 31 minutes on) all fail without the change and pass -race -count=20 (provider) / -count=10 (gateway). routing-key-windows.test.cjs gains a lone Kimi Code key at 1100px and 440px (the one-key story, the row's bar at 49%, its provider row "49% used · renews in …" fitting, no sideways scroll) and passes in full on Chromium and WebKit in en, zh, ja and de; routing-quota-left and panel-routing pass. go test -tags nogui ./internal/..., go test -race on ./internal/provider and ./internal/gateway in full, go vet, gofmt -l, darwin, linux and windows (nogui) builds pass. Not tried with a real Kimi Code key. StepFun's Step Plan is read through a sign-in, not the key, so it isn't one of these. docs/subsystems/gateway-routing.md, providers-accounts.md and docs/reference.md say it. 2026-10-06 20:25:04 +08:00
yetone e6dd5d20bb usage, gateway, gui: a reply that reasoned is timed by its answer, from its first text, not by all its output from its first token (tony on Discord #general: Usage › Requests read "gpt-6.1-sol 163 token/秒 · 8,036 个请求 · 首字 14 秒" and asked whether subagents were added up) — speed was every output token over the time after the first content (text, reasoning or a tool call). Output counts the reasoning, and a model whose reasoning is hidden (OpenAI's encrypted reasoning, a summary sent when it is done, Claude's omitted thinking) writes it before the stream shows anything, inside the TTFT; so a 2,000-token think and a 200-token answer over the 1.5 s after the first token read 2,200 in 1.5 s, 1,467 tok/s. Concurrency was not the cause: the aggregate is Σ tokens over Σ per-request windows, so subagents running at once never add up. In the owner's own usage.jsonl codex/gpt-6-luna turns read up to 600 tok/s (out 496, reasoning 224, 11,055 ms, ttft 10,228) and its aggregate 133; it is 80 now. usage.DecodeOf is the one rule: a reply with reasoning counts out − reasoning over ms − firstText, one that reasoned and wrote only tool calls tells no speed, one without reasoning is timed from its TTFT as before; DecodeWindow's burst cut (#731) still applies. Record.Decode feeds summarize, the ledger's Totals and its timeline Parts; a route keeps its reply's reasoning (Route.Reasoning, from gateway.go and codex_backend.go), and routing.js's decodeOf/speedOf times its rows, the story and a session's average by it (an older route takes its served usage's reasoning); app.js's ledDecode does the Usage page's rows, whose tooltip says the reasoning left out, and the Output speed help says the rule, in en, zh, ja and de. History is read the same way, as it keeps the reasoning and the first text. Routing never ranks by speed. TestReasoningReplyTimedByItsAnswer (the 2200/2000 turn, a summary 10 ms before the text, a concurrent subagent, tool calls only, reasoning over output, no reasoning, not streamed: 1,200 tokens in 11 s through summarize, addRow, LedgerSeries and Decode) fails without the change and passes -race -count=10; TestUsageKeepsTimeToFirstToken now has its paced replies report reasoning and checks the route keeps it. reasoning-speed.test.cjs (Routing rows 133, 100, none and 100, the story never 1,467, the session 105; the Usage rows, tooltip and KPI the same; no sideways scroll) passes on Chromium and WebKit in en, zh, ja and de, and fails against the old assets (1467 tok/s); routing-request-metrics, routing-burst-speed, usage-speed, usage-ledger-detail, i18n-keys, gui-ja, gui-de, ledger-hscroll and click-scroll pass on both. go test -race -tags nogui ./internal/... under a temp HOME passes but internal/proc's TestUserPath ("the login shell's PATH never came"), which failed under the full run's load, passes 3/3 alone and is not touched here; go vet, gofmt -l, linux and windows (nogui) builds pass. Not tried with tony's data. docs/reference.md says how output speed is counted. 2026-10-06 19:47:56 +08:00
yetone 490d2ea362 gui: the tray panel's Allowances tab hides subscriptions and moves them, from an Arrange at its foot (H20 on Discord: "菜单栏的详情页能不能选择某些不显示,有点额度并不想看到或者可以调整顺序,目前的还不行") — the panel's tab already followed the Usage page's order (usageOrder, byUsageOrder), but it could be set only by dragging a card's logo on the window's Usage page, which nothing in the panel said, and there was no way to leave a subscription out of the panel at all. The tab's foot now has Arrange, and "{n} hidden" beside it when some are: Arrange lists a row a subscription (balances too) in the shared order, its logo the handle (a drag, or Alt+arrows; the grip by it on hover only, as on the Usage page) and a Hide/Show pill, under a note that hiding is the panel's only and the order is the Usage page's too; Done draws the cards again. What's hidden is kept in magpie's settings (settings.PanelUsageHidden, panelUsageHidden), not the browser: routing, caps, the Usage page and the menu bar's cells still have it, and a menu bar cell for a hidden subscription still opens its card (panelPeek) until the panel is put away or the tab left. POST /api/usage/arrange takes order and panelHidden and changes only the one it is sent (both were whole-order before; an order alone still works), and the Settings save keeps panelUsageHidden as it does usageOrder. Saves draw at once and are put back with the error if they fail; no click moves the page (the view's scroll is kept across the redraw); no native select, no left-border stripe. Which allowances the menu bar shows beside its icon stays Settings' pick (trayUsages). New t() strings have zh, ja and de. TestUsageArrangePanelHidden (each field set and cleared on its own, a Settings save between) passes -race -count=20, TestSettingsSaveKeepsWhatItDoesNotSend's fixture names the field. panel-arrange.test.cjs (440px: the hidden card left out and counted, Arrange keeping the reader's scroll, the rows and note, the hover-only grip, no stripe, no sideways overflow, Show/Hide posting panelHidden without a scroll and a failed save put back, Alt+arrow and a drag posting the order, Done's cards in it, a menu bar cell opening a hidden card until the panel hides, the Usage page keeping the hidden card) passes on Chromium and WebKit in en, zh, ja and de; click-scroll, gui-ja, gui-de, i18n-keys, panel-fold, panel-usage, quota-*, routing-quota-left, tray-cell-click, tray-inuse, tray-logos, tray-usages, usage-accounts(-fold), usage-arrange, usage-fold, usage-quota-head, usage-card-refresh, usage-stale-answer, usage-remote-cards, api-key-usage, claude-usage-asked and usage-checkin pass on both. go test -tags nogui ./internal/... under a temp HOME, go vet, gofmt -l, linux (nogui) and windows builds pass. Not tried in the real tray panel on a Mac. docs/reference.md and docs/subsystems/gui-shell.md say it. 2026-10-06 19:18:14 +08:00
yetone c5a54bc74b agent: VSCodium's magpie group is written for the provider its installed Copilot Chat declares, OpenAI Compatible (customoai) on the Marketplace's 0.48.1 (#1014, xybio: VSCodium's Chat picker showed only Auto) — VSCodium has no Copilot Chat of its own, and the last Marketplace Copilot Chat it can install, 0.48.1, declares no Custom Endpoint provider (its contributes.languageModelChatProviders: copilot, copilotcli, claude-code, anthropic, xai, gemini, openrouter, openai, ollama, customoai, azure), so the customendpoint group magpie wrote in chatLanguageModels.json was never read. magpie now reads the package.json of the github.copilot-chat installed under ~/.vscode-oss/extensions (vscodeChatOf: the one extensions.json lists, else the highest github.copilot-chat-<version> folder not in .obsolete) and writes customendpoint when it is declared, customoai when only that is: each model's window as maxInputTokens beside maxOutputTokens (0.48.1 adds them up), and the token as x-api-key, since 0.48.1 drops an Authorization in requestHeaders and its apiKey has to be a ${input:} secret; the gateway already takes a key from x-api-key, past an empty Authorization, so Usage still counts VSCodium. No extension found, or a package.json that can't be read, keeps customendpoint as before; one declaring neither is said in Notice and Check. Writing the group removes magpie's group for the other provider in the default's and each profile's file, and Disconnect removes both; the user's own groups, under either provider, stay. Sync (at magpie's start and on catalog changes) rewrites a group written for the other provider, and Check reports it until then. VS Code and Insiders, whose chat is built in, keep customendpoint unchanged. The Notice says 0.48.1 registers its providers only while signed in to GitHub with a personal Copilot plan (its BYOK registration is gated on the Copilot token's individual/BYOK flag). TestVSCodiumCopilotChatOpenAICompatible (the real 0.48.1 provider list installed in a temp ~/.vscode-oss: Connect writes customoai with x-api-key magpie-vscodium and no Authorization or contextWindow, the user's groups kept, Check empty; a Custom Endpoint group an older magpie wrote is flagged and rewritten by Sync; a Copilot Chat declaring customendpoint switches it back; one declaring neither is reported; Disconnect removes both) and TestVSCodeChatInstalledWithoutExtensionsJSON (folder scan, .obsolete, unreadable package.json) fail on main, which writes customendpoint, and pass; -race -count=10 on the VS Code tests, go test -tags nogui ./internal/..., go vet, gofmt -l, linux and windows (nogui) builds pass. No GUI change. Not tried with a real VSCodium. docs/subsystems/agent-wiring.md and docs/reference.md say it. 2026-10-06 19:09:26 +08:00
forever b0a96cbbd7 gateway, provider, gui: a Sub2API key given 5h/1d/7d limits is weighed and rested by its own windows, like an account (#1004)
* gateway: a Sub2API key out of its 5h/1d/7d limit ("…限额已用完") rests as out of quota, not a 1-minute rate limit

Sub2API refuses a key that has spent the 5-hour, day or 7-day limit its
owner gave it with 429 "api key 7天限额已用完" (or 日限额, 5小时限额),
typed rate_limit_exceeded. It matched rateWords and not plannedWords
("限额" is not "额度"), so failure() said rate: the key rested a minute,
was tried again, refused again, and sank, for the days its 7-day window
had left. plannedWords now has 限额已用完, so it is failQuota.

The relay's other refusals keep their classes, fixed by the test: the
pool out of accounts (v0.1.149's 503 "No available accounts": other;
later versions' 429 "All available accounts are currently
rate-limited": rate), its upstream's rate limit (rate), the key's own
total quota API_KEY_QUOTA_EXHAUSTED (quota) and the wallet's 403
INSUFFICIENT_BALANCE (credit).

Verified: TestSub2APIKeyLimitIsQuota, with Sub2API v0.1.149's message
bodies. Without the change it fails: "429 {…api key 7天限额已用完…}:
rate, want quota", and the same for 日限额 and 5小时限额.

* provider: a Sub2API key's /v1/usage shows its 5 hours / 1 day / 7 days windows instead of nothing at "remaining"

A Sub2API key its owner gave a 5-hour, day or 7-day spending limit
answers /v1/usage in quota_limited mode with rate_limits — each
window's limit, used and reset_at in dollars — and, without a total
quota of its own, no "remaining". With the Balance URL set to it, the
key's card said `nothing at "remaining" in the reply`.

readSub2APIKeyLimits reads rate_limits into the windows the cards
already name 5 hours, 1 day and 7 days, in USD. A window not started
(used 0, no reset_at) is taken to renew a span from now; a window with
a limit of 0 is none. The card shows them, beside a balance field the
user wrote; with none, Check balance and `magpie provider show` give
what the fullest window has left. A wallet key's reply is unchanged.

KeyAllowance gives routing the same windows, per provider and key: read
behind the request, never waited for, at most once a minute (once for
any number of asks at a time); a failed read keeps the last; after a
restart it is seeded from the card's last reading in quotas.json;
StaleKeyAllowance has it read again at the next ask. ForgetBalances (a
key or Balance URL changed) has every key read again, a read begun
before dropped, the last reading standing until then. Only keys whose
Balance URL is a Sub2API /v1/usage are asked: any other key, and every
account, costs nothing here.

Verified with real bytes of a limited key's reply, its key removed
(testdata/sub2api_usage_key_limits*.json): TestReadSub2APIKeyLimits,
TestSub2APIKeyLimitsOnItsCard, TestKeyAllowance*. Each fails without
its part, e.g. the card without the windows: `Error:nothing at
"remaining" in the reply`; KeyAllowance waiting for the read: "waited
30.0s"; asking every time: "asked 11 times"; a failed read asked again
at once: "asked 22 times after failing"; not seeded: "seeded = [],
false"; any key asked: "asked /api/usage/token"; ForgetBalances
dropping what was read: "after ForgetBalances, and a read begun before
it: []"; leaving a read under way loading: "the key's windows were
never read"; a stale-generation read landing: it replaced 95%; a limit
of 0 taken: "a day's limit of 0 = [{… Used:100 …}]"; Balance from the
first window only: "$300.00" for $80 left.

* gateway, gui: keys whose windows magpie reads are weighed and rested by them, like accounts

Smart, least-used and pace routing weighed only subscriptions by their
windows: weighRouted skipped every key (c.p.Account == nil), so a
Sub2API key beside Codex accounts in a group went by its place in the
order, and, refused for its 7-day limit, rested a quarter of an hour at
a time while the window had days to run.

A key whose own windows magpie reads (KeyAllowance: a Sub2API key given
a 5h/1d/7d limit, its Balance URL /v1/usage) is now weighed as an
account is: fine, low (90%) or spent (98%), the one renewing soonest
first, in a provider and in a group alike, and its trace row carries
them. Out of quota it rests till the window it filled renews, by
candidate.full; when its windows as last read aren't full it rests
quotaRest, and either way they are read again (staleKeyAllowance).
Every other failure of a key backs off as before, never to the window:
the relay out of accounts for everyone (503/429) says nothing of the
key's own spend. capHeld and pooled stay accounts' only. Keys magpie
reads no windows of are ordered exactly as before.

The Routing page tells such a key as it does an account: its row's bar,
used share and renewal, why it went first by its allowance, "Every key
and account here is at 90% or more", and the Smart header. A key not
read yet beside one read is told as weighed with it, not "keys go in
their order" or by its tokens. New strings in en, zh, ja and de.

Other users of the relay draining its accounts don't show in a key's
windows, which count only its spend: they show as the pool's 503/429,
which back off and fall over to the next key, account or fallback.

Verified: TestKeysWeighedByTheirWindows, TestGroupWeighsKeyWindows,
TestKeyOutOfItsWindowRestsUntilReset, TestKeyPoolErrorDoesNotRestForWindow;
and routing-key-windows.test.cjs in Chromium and WebKit (en/zh/ja/de).
Without the change they fail: "smart: kw#a kw#b kw#c"; "the key renewing
first: codex sub2api"; "full: rests 14m59.99s by quota"; "read again:
[]"; backing off to the window: "pool empty: rests 71h59m59.99s by
window (other)"; and on the page "relay-a goes first: keys go in their
order, those that suit the request first." where all are at 90% or
more, or a key not read yet beside one read.

* gateway, provider: a Sub2API key out of its limit is back once its windows read short of full, not only when the window renews

A key refused for its 5h/1d/7d limit rests till the window it filled
renews: days, for the week. Its windows are read again behind the next
requests (a minute apart) and by its card, but nothing lifted the rest
when they came back short of full, so the owner raising the key's limit
in the relay's panel, or resetting its usage, left the key out till the
week turned, though the relay would take it at once. Accounts have this
already: a Codex reset spent tells OnRenewed, and the gateway lifts the
account's rests (renewed).

A key's reading, routing's or its card's, now tells OnRenewed the same
way, as agent "" and the key's KeyAllowanceID, when a window it was full
in (Allowance.Full at SpentShareOf its routing, as candidate.full counts
it) is full no more, or full till sooner. Its first reading, a failed
read, or one that finds it fuller tells nothing. The gateway notes the
key's KeyAllowanceID on its rest out of quota only, so renewed lifts
that rest and no other: the relay out of accounts for everyone (503)
still backs off, and another key still out of its week stays out.

Where nothing weighs the key by its windows, nothing read them again
either: an ordered group weighs each member's keys alone, so a member
with one key, like a provider with one key, never asks for them. After
the one read its 429 starts, the key sat out its week on that reading.
restingOf, which plans a resting key behind the rest, now asks for the
windows of a key resting out of them (agent "" and a user on its rest),
so the next request planned past a minute reads them, behind it, and a
reading short of full brings the key back there too. No card is read
for it, and a key resting for any other reason asks nothing.

Verified: TestKeyAllowanceRenewed (provider) and
TestKeyBackOnceItsWindowIsNotFull (gateway, end to end through the key
cards' reads and restAfter), each failing with a piece of the change
undone: never telling, "its week's limit raised to $1,000: told []" and
"a, its limit raised: still rests by window till …"; telling on any
change, "its first reading, out of its week: told [/sub2api#…]"; a
failed read telling, "a read that failed: told [… …]"; the card not
telling, "its usage reset, read on its card: told [/sub2api#…]"; the
rest not noted, "a, its limit raised: still rests by window"; every
rest of a key noted, "c, the relay out of accounts: …, false"; renewed
not matching the key, "b, still out of its week: …, false". And
TestRestingKeyReadAgainInAnOrderedGroup (gateway: an ordered group, its
first member one key on a fake /v1/usage, no card read after the 429),
failing without restingOf's ask: "its limit raised: still rests by
window till …, its windows read 1 times".

Tried on a real Sub2API v0.1.149 relay, in a sandbox HOME, its key's 7d
limit lowered under its spend for the test and then restored, with
magpie serve on a group ordered: the key, then a local stand-in. The
key answered 429 "api key 7天限额已用完"; it rested by window till its
7d reset, 2026-10-13 00:00 +08:00, and the stand-in served. Planned
again a minute later, still over, it still rested. With the limit back
at $800, the next request was planned with it resting, the stand-in
serving, and read its windows; the one after went to the key, 200.
2026-10-06 18:57:47 +08:00
yetone 87487c5ef0 plugin, provider: a plugin can press its vendor's daily check-in itself (auth.checkin), and magpie runs it as its own check-ins (Lemon on Discord #feedback: provider 每日签到的方法能放到插件里面去做吗; ARNO earlier asked for Qoder's) — the check-ins magpie had (WorkBuddy, Trae CN, MiniMax Code, Qoder) are each written in magpie against the vendor's endpoint, even for a subscription that moved to its plugin, so a plugin author had no way to give their provider one. A plugin's auth hook may now have checkin(getAuth, provider) beside usage: the host lists the provider with checkin: true (plugin.Provider.Checkin) and answers checkin({provider, account}) (plugin.AccountCheckin), refreshing the account as usage does, with {outcome, credit, streak, message}; outcome is claimed, done, ineligible, inactive, captcha or failed, and a throw or an outcome outside these is failed with the reason. internal/provider/plugin_checkin.go runs these through checkin.go's checkiner, as the built-ins are: each signed-in account in use once a Beijing day (first look 2 minutes after start, then every 30), a failure asked again after wbCheckinRetry, every other outcome settled for the day, results kept in plugin-checkin.json by provider|account; a failure is recorded as failed and resets nothing. captcha is new (CheckinCaptcha): the vendor wants a captcha, which magpie never solves, so it is said as "check in in its own app", not as a failure, and not asked again that day. The results mark the account's Usage card (checkinBy "plugin:<provider>", WithCheckins), so the card's check-in row, its Auto check-in and Check in now work for it (POST /api/settings/plugin-checkin {provider, on}, POST /api/usage/plugin-checkin {provider}); Settings → Usage gains a Plugins tab, shown while such a plugin is signed in, a Daily check-in row per provider with Off/On and how each account went; magpie accounts checkin and c on the TUI's Usage page include them (the TUI's "no account" line names check-in plugins). The switch (settings.PluginCheckins, by provider) is off by default; for WorkBuddy, Trae CN, MiniMax Code and Qoder it follows that vendor's existing switch until set on its own, and magpie's own check-in through the plugin's fetch leaves a provider whose plugin checks in to it (pluginChecksIn), so no account is checked in twice. The Settings page's save carries PluginCheckins over (TestSettingsSaveKeepsWhatItDoesNotSend's fixture names it). No plugin declares the hook yet; Qoder's check-in stays magpie's own here. TestPluginCheckinRunsAsTheBuiltinsDo (the fake plugin with FAKE_CHECKIN, five accounts answering claimed, done, captcha, a throw and an unknown outcome: off presses nothing; on, each outcome kept with its vendor; a minute on nothing pressed again; past the retry only the two failures; the next day all five; PluginCheckins and the cards marked; another provider's press asks none) and TestPluginCheckinTakesOverTheVendors (a qoder plugin without the hook leaves magpie's Qoder check-in its account, with it none; PluginCheckinOn's fallbacks) pass -race -count=10, and the latter fails with pluginChecksIn stubbed false; TestTUIChecksPluginsIn passes -race -count=20. plugin-checkin.test.cjs (the card's row with the captcha said, Auto check-in posting {provider, on}, no page move; the Settings Plugins tab at 440px with the row fitting, no sideways scroll, On posting) passes on Chromium and WebKit in en, zh, ja and de; usage-checkin, i18n-keys, gui-ja, gui-de and settings-* pass. go test -tags nogui ./internal/..., go vet, gofmt -l, linux (nogui) and windows builds pass. Not tried with a real plugin or account: no published plugin has the hook yet. docs/subsystems/provider-plugins.md (the hook's contract) and docs/reference.md say it. 2026-10-06 18:40:45 +08:00
yetone a87dc1ff2b gateway: a WebSocket upgrade on /v1/responses is answered 426 "magpie relays Responses over HTTP only", not 404 (#1005, Clarence12138) — Pi's Responses WebSocket extension opens GET /v1/responses with Upgrade: websocket, and only POST was routed there, so the catch-all's 404 ("magpie serves …") read as a broken gateway and the extension retried five times a session before falling back to SSE. GET /v1/responses and /responses now tell an upgrade 426 with the reason and the way that works (POST, streamed as SSE) — the answer a client falls back to HTTP on at once, as the Codex backend path already gives (TestCodexWebSocketUpgradeRequired) — and a plain GET 405 with Allow: POST. Relaying Responses over a WebSocket itself is not done here (a long-lived connection whose model is named per response.create, outside the per-request routing, failover, key limits and usage the gateway has). TestResponsesWebSocketUpgradeRequired fails without the change (404) and passes; go test ./internal/gateway, go vet (darwin, windows), gofmt -l and the linux build pass. Not tried with Pi's extension itself. docs/reference.md and gateway-routing.md say it. 2026-10-06 17:55:40 +08:00
zzzpyyy 28c58adcb3 feat(gui): discover local provider configurations with persistent dismissal (#879) 2026-10-06 17:16:39 +08:00
july_lu 51bec20c09 gui: a gateway key's menu puts its accounts beside their provider's models, not one block at the end (a follow-up to #932, July, LCorleone) — the menu the key's badge opens listed every provider's models and then all the accounts and keys together after them, so "which models" and "which plan" of one provider sat in two far-apart places, hard to give someone one vendor's plan alone. Each provider's accounts and keys now follow its own models, inside its block — the order the /api/caller-keys/models comment already described — an account by who is signed in with the plan in its note, a key by its fingerprint; a provider with accounts and no model in the list (one kept unlisted, or serving none now) keeps its accounts after the models; what the CLI kept that no provider has now stays behind every live entry, a model gone before an account signed out. Nothing of the picking changes: the same proto menu, the same opts and literalName, the same accountsSet that splits what comes back into "models-key" and "accounts-key", and no new strings to translate. gateway-key-accounts reads the order off the real menu now — relay's two accounts between its models and OpenAI's, the key behind GPT-5 — and pins the dead tail on a key held to a gone model and a signed-out account; its last badge read waits for the row to be drawn with the answers (the wait gateway-key-models already makes): without it a slow engine read the hold one action behind, a flake this sandbox shows on main's own test 5/6 runs on WebKit. gateway-key-accounts passes Chromium and WebKit, en and zh; gateway-key-models, gateway-key-groups, gateway-caller-keys, gateway-connect-keys, gateway-key-limit, i18n-keys and usage-keys-fold pass on Chromium — gateway-key-models' scroll assertion doesn't survive this machine's frozen WebKit build on main either. No Go change (the assets are embedded; no Go test reads their content). docs/reference.md's sentence on the menu follows the new order. (#988) 2026-10-06 16:39:33 +08:00
TryWorldandh4rK8s efd501ec74 agent: add Reasonix Studio with separate Executor and Plan models (#427)
* feat(agent): add Reasonix Studio 2.x integration

* agent: select Reasonix planner independently from executor

* agent: restore Reasonix when its credential file was initially absent

* agent: preserve Reasonix planner choices and model controls

Keep configured and saved native planner references selectable, and retain full model picker behavior and role labels. Exercise native planner restoration and both browser layouts. Separate temporary CLI first-launch work from version and intentional timeout assertions.

* test(gui): follow the current Reasonix connection flow

* test(gui): open connected Reasonix details before checking roles

* agentenv: isolate Reasonix configuration in test sandboxes

* gui: restore Reasonix roles without disconnecting the other

* gui: Reasonix's role labels read in every language, and its confirm names them

The review found four things on the Reasonix roles' strings.

"executor" and "planner" are field labels like any other, so t() translates
them; they had no i18n entry in any language, so the zh, ja and de windows
showed the English key. They now read 执行模型/规划模型, 実行モデル/計画モデル
and Executor-Modell/Planner-Modell, beside model/effort/thinking in each block.

The confirm said "neither Executor nor Plan" — names that appear nowhere in
the UI — and its zh text said "Executor 和 Plan", while the fields are labelled
executor and planner. It now names the labels, and each language names its own
translation of them (zh 执行模型和规划模型, ja 実行モデルも計画モデル, de vom
Executor- bzw. Planner-Modell).

Both new strings had a zh entry and no ja or de entry, which is what turned
gui-ja and gui-de red: every zh key needs its ja and de with the same
placeholders. "restore the previous {field} selection" also started at column
0 in i18n.js; it is indented with the lines around it now.

reasonix-roles.test.cjs asserts the rendered label, so its zh expectations name
执行模型/规划模型; the en ones stay as they were, the en label being the key.
Its en assertions on the confirm's first sentence are unchanged, and the
reworded second sentence is not asserted.

Verified on this head: gui-ja passes 3/3 and gui-de 3/3 (every zh string has
its ja and de, same placeholders and tags); reasonix-roles.test.cjs passes 8/8
in Chromium and WebKit, en and zh, window (1000px) and panel (440px); go vet
./... and the four builds pass. The rest of internal/gui/tests was run and
compared against a clean main worktree at e0bf3e61: the same assertions fail on
both, and none of them is about i18n or Reasonix (see the PR comment).

* edit: a TOML value is written the way TOML reads it, preserving or not

setTOMLTop spelled every value with strconv.Quote. Go's escapes are not TOML's:
a control byte came out as \x01, and TOML defines no \x escape, so a preserving
edit wrote a config.toml that no parser reads — an agent's own value could not
be written at all. Worse, it only failed at writeTOML, after the edit was
already decided.

The preserving path and the path that adds a key now both use tomlLiteral, as
SetTOMLKey already did, so both write the same encoding. tomlString spells a
basic string from the TOML spec (\b \t \n \f \r \" \\, \uXXXX for the other
control bytes and DEL, everything else as it is, non-ASCII included). A byte
that is not valid UTF-8 has no TOML escape, so it is left as it is and writeTOML
refuses the file, rather than the value being quietly replaced by U+FFFD.

TestTOMLPreservingKeepsAValueTOMLCanParse parses the file back with the TOML
parser and compares the value, and TestTOMLPreservingAndPlainAgreeOnEncoding
puts the same value through both paths and compares the bytes. TestSetTOMLKeepsCRLF's
"top = \"9\"" is "top = 9" now: an int is a TOML integer, the same value
SetTOMLKey writes for it, and no caller but that test passes anything but a
string here.

Verified on this head: go test ./internal/edit/ passes whole; the two new tests
and TestSetTOMLKeepsCRLF pass under -race -count=10; go vet ./... and the four
builds pass. TestTOMLBigFileParsesInLinearTime fails under -race -count=10 on
this head and on a clean main at e0bf3e61 alike (a 1s budget against ~1.2s under
the detector); it passes without -race.

---------

Co-authored-by: h4rK8s <63350517+h4rk8s@users.noreply.github.com>
2026-10-06 16:14:35 +08:00
forever 8754221203 gateway, usage: when the credits switch holds every account, the record and 429 say so accurately; the switch's text says it follows the latest reading (#999)
* gateway: a request refused as every account is held records why, caps or credits

When every account was held, the request's record always read "every
account at its usage cap", though the client's 429 (cappedError) already
told a usage cap, credits set not to be spent, and the two together
apart. The record now comes from the same classification (heldBy): the
cap-only wording is kept, credits-only reads "every account held: set not
to spend its credits", and a mix names both.

TestCodexNoCreditsEveryAccount checks the record for both requests; with
the old fixed string it fails with: recorded 429 "every account at its
usage cap" (twice).

Follow-up to #974.

* gateway: the 429 for accounts held off their credits says to spend them only if one holds some

When every account was held as set not to spend its credits, the 429
told the user to let an account spend them to go on now. An account that
holds no credits is refused by the vendor at 100% all the same, so
following that got one more 429. The hint now reads "if an account holds
credits, let it spend them", in the credits-only and the mixed message.
The gateway's allowance readings carry only the windows, and a Codex
reading's balance is "" for none, unlimited and unreadable alike, so a
known-empty balance isn't available to drop the hint by.

TestCodexNoCreditsEveryAccount checks the new wording in both messages;
with the old one it fails with: no "if an account holds credits, let it
spend them" in the body (both requests).

Follow-up to #974.

* usage, docs: a Codex account set not to spend its credits is held by magpie's latest reading

The Use credits hint, magpie quota credits' help and the docs said that,
turned off, the account's credits aren't spent. The hold goes by magpie's
latest reading of the account's windows, which is read again about once a
minute, so for a minute or two after a window fills, or while nothing is
read yet, a request can still reach the account and spend some credits.
Each now says the hold follows that reading, refreshed about every minute
(Usage card in en/zh/ja/de, quota help, reference.md, gateway-routing.md).

codex-credits (Chromium and WebKit, en/zh/ja/de: 8/8), i18n-keys (1/1),
gui-ja (3/3) and gui-de (3/3) pass; codex-credits checks the reworded
string has its zh, ja and de.

Follow-up to #974.
2026-10-06 16:09:00 +08:00
yetone a711f428ad agent: CodeBuddy Code (Tencent's CLI, codebuddy/cbc, the China edition too) is an agent that uses magpie's models (#980, yujianbinm) — the CLI (npm @tencent-ai/codebuddy-code 2.161.4, one package for codebuddy.ai and codebuddy.cn, CODEBUDDY_INTERNET_ENVIRONMENT=internal) takes models of one's own from ~/.codebuddy/models.json ($CODEBUDDY_CONFIG_DIR), the format WorkBuddy reads, hot-reloaded, and starts a session on settings.json's "model". magpie now writes its models there as WorkBuddy's are written (vendor magpie, key magpie-codebuddy, url the gateway's /v1/chat/completions), keeping the user's own entries, their order, a model turned off and availableModels (magpie's ids added when the list is there), and sets "model" to the pick, the user's other settings kept; one of CodeBuddy's own models, or default, takes magpie's models out again. A new models.json is {"models":[…]} as CodeBuddy's docs give it. workbuddyWrite becomes buddyWrite(agent), the same writer for both. CODEBUDDY_CONFIG_DIR joins agentenv.Vars; cliupdate knows the npm package. TestCodeBuddyCode (user's model, availableModels and settings kept; magpie pick; sync; back to own; default) and TestCodeBuddyCodeNewFile fail without the agent ("no codebuddy agent") and pass; -race -count=5 on the CodeBuddy/WorkBuddy tests. End to end, the real CLI 2.161.4 under a temp HOME with CODEBUDDY_INTERNET_ENVIRONMENT=internal and no CodeBuddy sign-in: magpie codebuddy magpie/mock/pro against a sandbox gateway on 3462, then codebuddy -p with no --model, answered through the gateway from a mock upstream, and magpie's usage counted it under CodeBuddy Code (the CLI sends "CLI/2.161.4 CodeBuddy/2.161.4", so it is told by its key); magpie codebuddy default took it out. A bare-list models.json is read by the CLI too. Not tried with a real CodeBuddy account or a real model vendor. go test ./internal/agent/ ./internal/agentenv/ ./internal/usage/ ., go vet, darwin, linux (nogui) and windows builds pass. 2026-10-06 15:32:23 +08:00
forever a7c4e5ddde provider: a Codex account the Codex app still sends for keeps Codex signed in — spend cap or overage with credits, limit_reached alone; a hold survives a failed read (#996)
* provider: a Codex account at a spend cap or past its overage with credits left keeps Codex signed in, as the Codex app's composer still sends for it

#966 held such an account (and one whose limit_reached alone was true),
following the readiness check Tht in ChatGPT.app 26.930. Tht has one
caller, the work-credit-restriction-recovery poller; it isn't the
composer's gate. The composer's submit is disabled only by hP (search
"n.rate_limit?.allowed!==!1||Jpe(n)||SIe(") and hardBlocked (search
"hardBlocked:r.rate_limit?.allowed===!1"): both read rate_limit.allowed
alone and let credits (has_credits or unlimited) through before any spend
cap or overage. So the app sends for that account, and holding it gains
nothing: CodexUsedUp only moves Codex to magpie as provider while Codex is
on a magpie model, whose turns already reach magpie through
openai_base_url. It only takes the app out of ChatGPT (durable threads,
remote control), the loss #966 set out to stop.

codexHeld is back to that gate: held when allowed is false, no credits,
and not a workspace within its overage (no spend cap, an ordinary
reason). Its comment names the build, both gates and why Tht isn't one,
each with a string to search for. The spend-cap and overage subcases now
expect not held; new subcases pin allowed as the only trigger. Five
subcases fail on the merged codexHeld. agent-wiring.md and reference.md
say the same.

Follow-up to #966.

* provider: a Sci, Law, FinServ or Enterprise trial/view-only workspace within its overage keeps Codex signed in

codexHeld lets a workspace with no credits through while it is within
its overage, as the Codex app's composer does, but the workspace list
came from codex-rs's PlanType alone. The app's own plan_type switch for
its usage banner (ChatGPT.app 26.930.61225, app-initial) also names sci,
law, finserv, enterprise_cbp_trial and enterprise_cbp_view_only, so such
an account read held and magpie took Codex off its ChatGPT sign-in while
the app still sent. They are added; the comment cites that switch. guest
there is a personal plan and stays out, and a plan not listed still
counts as no workspace (held).

Verification: "Law without credits, within its overage" in
TestCodexUsedUpIsWhatTheAppHolds fails without the list change
(CodexUsedUp = true, want false) and passes with it.

* provider: a held Codex reading with no window at 100% stays held through a failed read

A reading kept for when /wham/usage fails dropped Held whenever none of
its windows read 100%. That is right for a hold that came with a used-up
window which has since started again, but a hold with no window used up
(the backend says not allowed, no credits, every window under 100%) has
no reset to wait for: the first 503 after it read not held and put Codex
back on its ChatGPT sign-in for an account its app sends nothing for.
Held is now cleared only when the saved reading had a window used up and
none is after the passed resets are applied; any other hold stands till
a fresh read.

Verification: TestCodexHeldWithNoWindowUpKeptTillAFreshRead (the real Pro
reply, credits spent, its window at 40%: a good read, then a 503) fails
without the change ("CodexUsedUp = false on the kept held reading, no
window used up") and passes with it. TestCodexHeldKeptOnlyTillTheReset
still passes; both share their setup now. -race -count=10 passes.

* agent, gui, docs: Codex's sign-in notes say magpie steps in while the Codex app blocks the account, not whenever it is used up

#981's sign-in notes said magpie becomes Codex's provider "while its
account is used up". Since codexHeld, a ChatGPT account at 100% with
credits left keeps Codex signed in; magpie steps in only while the Codex
app blocks the account (out of allowance, no credits left), and the notes
now say that, Always ChatGPT's to match, in English, Chinese, Japanese
and German. agent-wiring.md's sign-in line and reference.md's Codex
paragraph say when the app holds an account as codexHeld reads it: OpenAI
no longer allows it and it has no credits left, a workspace still within
its overage excepted; at 100% with credits Codex stays signed in.

Verification: codex-signin-always (Chromium and WebKit, en/zh/ja/de, 1100
and 440px) 8/8, i18n-keys 1/1, gui-ja 3/3, gui-de 3/3. git grep finds the
old English notes nowhere under internal/gui/tests or internal/gui/assets.
2026-10-06 15:28:41 +08:00
forever 8005f17551 provider, codex: a ChatGPT account at 100% with credits left keeps Codex signed in (#966)
* provider, codex: a ChatGPT account at 100% with credits left keeps Codex signed in — magpie becomes its provider only while the Codex app itself holds the account

magpie made itself Codex's provider (model_provider = "magpie", #540)
whenever any of the active ChatGPT account's windows read 100%. A Pro
account at 100% on its week with credits left keeps working in the Codex
app: the backend answers every turn (about 490 200s in one day's usage log
at 100%), yet magpie moved Codex off it, and as a provider without
requires_openai_auth the app lost its ChatGPT state ("Sign in to ChatGPT
to start a durable thread", no remote control).

CodexUsedUp now reads codexHeld, which mirrors the Codex app's own
composer block (ChatGPT.app webview bundle, hP and its helpers): the
backend says the account isn't allowed (rate_limit.allowed = false), it
has no credits (has_credits / unlimited), and it isn't a workspace still
within its overage (overage_limit_reached = false, no spend cap, an
ordinary rate_limit_reached). Window percent never decides it. Where the
app goes by what /wham/usage can't show (a statsig gate, the gpt-reserve
fallback, Team's reserve experiment that ignores overage), it counts as
held: a wrong move costs ChatGPT extras, a missed one every turn. A
reading kept through a failed read (keepLast) stops saying held once its
used-up window has started again, as its usage does. NextLogin's account
rotation and its thresholds are unchanged.

A user stuck on the old move (codex.out = 1) is put back beside the
sign-in by the next Sync, as #540 already does once the account has room.

Verification:
- TestCodexUsedUpIsWhatTheAppHolds (real /wham/usage replies, ids
  replaced) fails on the old trigger: 7 subcases, "CodexUsedUp = true,
  want false". Each workspace guard (spend cap, reason, overage, plan
  list, Team reserve) was mutated and fails its own subcase.
- TestCodexHeldKeptOnlyTillTheReset fails without the quota_last change.
- The real Pro account (7-day window 100%, ~28k credits, allowed false):
  CodexUsedUp true before, false after.
- go vet ./...; go build (darwin), GOOS=linux -tags nogui, GOOS=windows;
  go test -tags nogui ./... under a sandbox HOME; -race -count=20 on the
  new and related Codex tests.
- Not run: a live Codex app turn on an account truly held (none at hand),
  Windows/WSL runtime, GUI suites (no GUI change).

* provider: a Codex account at a spend cap or past its overage is held, credits or not; limit_reached counts as not allowed

codexHeld let credits through before the spend cap, so a Team seat at its
cap (spend_control.reached, allowed false) with has_credits read not held
and magpie left Codex on an account the backend refuses. The app's
readiness check (26.930.61225, app-primary) returns limited on
spend_control.reached or credits.overage_limit_reached before it looks at
credits, and treats rate_limit.limit_reached like allowed false; held now
reads the same. A spend cap with the rate limit still allowed (a
workspace's included usage) stays not held.
2026-10-06 14:50:39 +08:00
forever b37ac53bef codex: a switch for whether an account spends its credits (#974)
* tui: show a ChatGPT account's credits beside its windows

The Usage page printed only the balance for an account that had one, so a
ChatGPT account holding credits lost its 5-hour and weekly windows there.
The balance now follows the windows, as on the app's cards and in
magpie quota.

* accounts: tell a ChatGPT account's credits

magpie accounts left out the credits an account holds, which magpie quota
and the Usage page show. Its rows now carry them, in the line and as
balance in --json.

* codex: a switch for whether an account spends its credits

A ChatGPT account holding credits keeps answering once a usage window is
used up: the vendor spends the credits, and a task goes on. That stays the
default. Each Codex account now has a say in it (Usage card and menu bar
panel: Use credits; magpie quota credits <account> on|off; settings'
codexNoCredits). Turned off, the gateway holds the account at 100% of a
window, as a usage cap holds it at its share, so the other accounts,
groups and fallbacks take the request and no credits are spent. With none
left the request gets a 429 naming what holds each account and how to go
on, unless the account spends its resets by itself and its week is used
up: then a reset is spent first, whether every candidate was held or the
last one tried was refused. A cap set on the account takes precedence.

A reset made now forgets the account's cached allowance, so neither a cap
nor this hold outlives the windows it started again. The switch is
routing's only: which account Codex is signed in to is unchanged.

The Routing page tells why such an account was left out, and magpie quota,
magpie accounts and the TUI mark credits not spent.

* usage: an auto-reset switch keeps by its first line

The auto-reset row centred its switch on its words, so the switch moved
when they rewrapped on a click, and it sat lower than the Use credits
switch beneath it. Both now keep the switch by the first line.

* gui tests: quota-curve expects the Use credits row to close a Codex account's section

The credits switch adds a row after the Auto-use one, so the account after
a Codex account follows the curve, the Auto-use row and then the Use
credits row.
2026-10-06 14:20:09 +08:00
yetone 5839e08189 dsh: the gateway key also goes into dsh's own key store, which the desktop app reads (#969, pan17: in the DeepSeek Harness desktop app every turn failed with llm-pi-ai: no credential for provider route "magpie"; its profile resolves MAGPIE_GATEWAY_KEY, which is not set — store MAGPIE_GATEWAY_KEY through the credentials service (the web Models page writes it) or export it (MISSING_CREDENTIAL), while magpie showed dsh connected) — the route was in the desktop profile (magpie writes every profile; the GUI names only the first, profiles/web, which read as "magpie wrote the npm dsh's config"), but magpie put the key only in ~/.dsh/.env. In dsh 0.2 the .env files are layers the product CLI's boot (dsh-app-boot) loads; dsh-credentials-local resolves inherited environment > $DSH_HOME/.credentials.yaml > project .env > home .env, and "a composition the product CLI did not boot has only the inherited environment as its layer". So the desktop app never saw the key. magpie now also writes refs.MAGPIE_GATEWAY_KEY into ~/.dsh/.credentials.yaml, dsh's own store, which its Models page writes and every dsh reads. Details: a new store is made owner-only, version 1 (dsh refuses a store other users can read); the user's other refs, records and comments are kept; a different key of the user's under magpie's name is left alone (the check already reports it); a store in dsh's pre-release flat layout isn't touched; going back to dsh's own models takes magpie's key out. The check now asks for Apply again when the store lacks the key; a dsh wired by an earlier magpie gets it from the catalog sync magpie runs as it starts. The output of a fresh write and a merge into a store with refs, a grant record and a comment both parse with dsh 0.2.0-rc.2's own parseCredentialsDocument. TestDshDesktopGetsTheKeyFromItsStore (web+desktop profiles as on the reporter's machine) and TestDshStoreOfTheUsersOwn fail without the change and pass; -race -count=5 on the dsh tests. Fixtures that stood for "wired" with .env alone now hold the store too. Not tried with the real desktop app (not installed here; the reporter is on Windows); they're asked to confirm. go test ./internal/agent/, go vet, darwin, linux (nogui) and windows builds pass. 2026-10-06 14:13:53 +08:00
yetone e0bf3e61ed catalog: a model the provider's catalog doesn't list is named as the other providers name it (John on Discord #feedback: GLM Coding Plan and ZCode name the same models in different case, in the provider list and the group picker) — the Coding Plan's /models (open.bigmodel.cn and api.z.ai, read with a real key) gives ids alone, glm-5-turbo, glm-5.3-flashx …; catalog.Decorate names only those Zhipu's models.dev entry (zhipuai) lists, so glm-5-turbo kept its id as its name beside ZCode's GLM-5-Turbo. models.dev's load now keeps each model's name as most providers serving it give it (a name that is only the id casts no vote), catalog.NameOf reads it by bare id in any case, and catalog.Named fills it in for a model still named by its id: in Available's live list and for a pick the list doesn't have. Ids, and what is sent upstream, are unchanged; a name the user gave stays theirs (Catalog's Name, with the new one as Default); an Azure provider is left out, its deployments named as their owner named them. TestModelNamedAsElsewhere (the Coding Plan's real ids, a user name, an Azure deployment) fails without the provider change and passes; TestNameOf is new. TestSearcherChosen read whatever catalog an earlier test had left loaded, and now resets it: with none, its lists' models go by their ids, as it expects. go test ./internal/catalog/ ./internal/provider/ ./internal/gateway/ ./internal/gui/ . pass; -race -count=5 for the two new tests; go vet, linux(nogui)/windows builds pass. No GUI change (the GUI and the group picker show name || id). 2026-10-06 13:09:40 +08:00
yetone 8c71cd27f9 quotas: a computer using another's magpie (remote-magpie) shows that magpie's quota cards (莫 on Discord: the second computer had the models and groups but /v1/magpie/quotas was empty, so Usage, the menu bar and magpie quota showed nothing) — each named with the remote ("Codex · Office", id office/codex, from: Office) and kept in its kind (subscription, plan, balance), on the Usage page, the tray, the TUI and magpie quota (which matches by the remote's id or name); their history comes with the local one. Only the computer holding the sign-ins asks vendors: it answers the new GET /v1/magpie/quotas/cards from what it has kept (CachedCards, never a read, whatever the age), asked here at most once in 30s; a card's refresh here POSTs /v1/magpie/quotas/refresh (that card, or every card from the remote's own card), which the remote does at most once in 30s per card. A remote keeps showing its last cards when it can't be reached; with none its one card says why (nothing read there yet, or a magpie too old to share — "update magpie there", zh/ja/de added). Cards from a third magpie aren't passed on, and the refresh path refuses them (400); a Codex reset or check-in isn't offered on a remote card. Same auth as /v1/magpie/quotas (loopback, or LAN with the key).
Verified: provider tests (fake remote: names/kinds, buttons stripped, third-magpie cards dropped, one ask per 30s, refresh paths, unreachable keeps last cards, 404 → update card, cache-only CachedCards, histories) and the gateway test (an in-process gateway with a fake balance vendor counting hits, asked through a remote-magpie provider: nothing read until a refresh, 5 card asks read nothing, two refreshes of a card read once, a remote card's refresh 400, another machine without the key refused) pass with -race -count=20; go test provider/gateway/gui/tui/root, vet, linux and windows builds pass. Two sandbox gateways (3481 with a fake balance vendor, 3482 with it as remote-magpie): magpie quota on the second showed the "nothing read" card with 0 vendor hits, then office/bal "Bal · Office" $12.00 after one refresh (1 hit), 10 more asks 0 hits, two card refreshes 1 hit. usage-remote-cards (names, translated remote errors, refresh ids) passes in Chromium and WebKit, en/zh/ja/de and fails without the app.js change; i18n-keys, gui-ja, gui-de, usage-card-refresh, usage-refresh, quota-reading-again pass. Not tried with a real second computer.
2026-10-06 12:38:52 +08:00
yetone ccf5e5a1cb docs: VSCodium's row in the agent reference (#926) — VS Code Insiders had one, VSCodium (merged in 2f15623e) didn't: its files under VSCodium/User, its own token magpie-vscodium, and the Chat features it needs turned on, as its Notice says. 2026-10-06 11:28:50 +08:00
july_lu 47c2382467 gateway: a gateway key can be held to some accounts (#905, LCorleone) — a key could burn any account a provider had: two coding plans of one vendor, and a key given to someone had them both. A key now keeps an optional list of accounts and keys (caller-keys.json "accounts", none = every account, so older files read as before): "<provider>/<account>", an account by its stable id — the login's, set once it is written and kept through renames (logins.json's savedLogin id, written by every write and stood in for by one made of the name until then, so an entry made before stands), a key by its fingerprint — every one a provider has when the list is set, and one gone later (a key rotated, an account signed out) kept as it is, matching nothing, holding the key closer, never wider. The command line and the menu name an account by who is signed in and keep its id. Routing holds a plan to the key's accounts beside its models: a candidate whose account the key may not use is left out — through a routing group the key names no less, for naming a group is not naming its accounts — the plan's order keeping step and its left-out list marking what the key held out (Weighed's held); magpie's own calls for the key — a web search, a picture described, a Codex title — are of the models the user picked, so the models don't hold them, but their spend lands on an account all the same, and the accounts hold them too. When every account or key a model has is one the key may not use, the request is refused with a 403 in the API's own error shape naming the model and the accounts the key may use, logged as a rejected call: after a usage cap's 429 and before an account barred for the model's, itself before the no-member 404. /v1/models and the Anthropic and Gemini lists show the key only the models an account or key it may use serves (a provider's account set not to serve one, #474, is none of the key's); a provider with no account and no key (a local ollama) is one the key names no account of — its models stay listed and its asks pass — while an entry kept after its provider lost its last account or key serves none, holding the key closer. The embeddings, images and videos endpoints hold their candidate to the key's accounts the same way, and counting tokens counts on those alone (the local estimate where it may use none). System One asks its decision model of an account or key the key may use — and sends on that key, of those in use, not the provider's first, which a key held to a later one was spent on; the videos and images keyed paths send on one the key may use too, held to a later key no longer refused for the first alone. Set it in the app's menu the models are picked in — the accounts after the models, an account by who is signed in with their plan in the note, a key by its fingerprint, what the CLI kept that no provider has now kept as "Not signed in now" — or magpie gateway-key accounts <id> codex/me@example.com openai/<key id> (all takes it off); gateway-key list has an ACCOUNTS column. en/zh/ja/de, the German "{n} accounts" main already had left to it rather than written twice. The images API's fan-out over a subscription's accounts is held to the key's accounts as one: one of the key's whose plan refuses to draw no longer hands the pen to an account it may not use, and the second in the order, when it is the key's, draws what the first may not (drawOnAccounts filters the candidates, the account pinned to with the account header told plainly when the key may not use it). The Routing view's left-out list says an account the key held out for what it is, not that its plan lacks the model, in Chinese, Japanese and German too; the key's badge names its accounts by who is signed in, not by the stable ids it keeps. (#932) 2026-10-06 10:51:50 +08:00
yetone 1330b9660e agent: VS Code Insiders is an agent of its own, its Chat given magpie's models in Code - Insiders/User as VS Code's is in Code/User (wani on Discord)
vscode.go's agent is now one per build (vscodeKind): VS Code as it was, and VS Code Insiders, id vscode-insiders, its User folder Code - Insiders beside Code on the Mac, Windows and Linux, its binary code-insiders. Insiders' chat names itself GitHubCopilotChat as Stable's does, so it has no UA of its own: its models send the token magpie-vscode-insiders, which the gateway reads first, and its requests are its own in Usage while Stable's stay Stable's. Connecting one writes nothing of the other's. TestVSCodeInsiders fails without the change (unknown agent "vscode-insiders"). go vet, go build (GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
2026-10-06 01:42:40 +08:00
yetone fbaf27bce1 agent: dsh's web search goes through magpie while dsh is on one of magpie's models (Packing1 on Discord)
dsh's web search is a plugin of its own, web-search-deepseek, which signs each search to DeepSeek's Anthropic API with DEEPSEEK_API_KEY, so a dsh that reaches its models through magpie alone failed every search asking for that key. While dsh starts on a model of magpie's, magpie writes a marked web-search-deepseek entry in each profile: the gateway's /v1 as baseURL, MAGPIE_GATEWAY_KEY as apiKeyEnv and that model. The gateway answers the plugin's web_search_20250305 request as Anthropic does, by the model's own search or magpie's (web_search_tool_result blocks). A row of the user's, or a DEEPSEEK_API_KEY dsh has in .env or its key store, is left as it is. The entry goes with dsh's own model or Disconnect, and moves with the gateway's address. The row's Notice says when magpie has nothing to search with. TestDshSearchThroughGateway is new and fails without the change. Tested end to end in a sandbox (own HOME and DSH_HOME, gateway on 3493, the real DeepSeek account): real dsh 0.2.0-rc.2 headless called web_search, got eight results through magpie, and answered https://golang.google.cn/blog/go1.26; magpie dsh default took the entry out of all three profiles. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
2026-10-05 22:58:12 +08:00
yetone 3a30ac87c2 catalog, gui: a 1-hour cache write is 2× input for a Claude model only, any other's unset is its 5-minute price and isn't shown (PAMI on Discord)
v0.1.1032 counted a 1-hour cache write left out at 2× input for every
model, so gpt-6-astra priced 10,50,1,12.5 showed "$20 (1 hour) at 2×
input", a write OpenAI has no price for. OneHourFor gives a Claude model's
price, listed or given by the user, its 2× input; any other model's 1-hour
writes count at the 5-minute price, magpie model price shows only its cache
write, and the app shows the 1-hour box only for a Claude model or one
priced with one.
2026-10-05 22:55:30 +08:00
yetone df95ac6bc2 docs, site: Integrating your app or agent with magpie, at docs/integrating.md and usemagpie.ai/docs/integrate (#918, Magixyne)
A developer of an AI app asked how to work with magpie and whether an app can
register itself after one confirmation. The page says what exists: the
gateway (find it via /api/hello and settings.json's port, the OpenAI,
Anthropic, Responses and Gemini endpoints, /v1/models' fields), how a key
magpie-<id> or the User-Agent's first word names the app in Usage, a row on
the Agents page by an adapter PR in internal/agent (config-edit, Cindy-style
own import link confirmed in the app, or a local API) with what the PR needs,
what plugins can and can't do, and what isn't there: no link or API registers
an app or installs a plugin, and magpie://import adds providers only. English
and Chinese pages, linked from every docs page's nav and footer.
TestThirdPartyAppIsCountedAsItself pins the attribution the page promises.
go vet and the gateway tests pass; site node tests pass; pages checked in
headless Chromium (no errors, TOC, no horizontal scroll at 390px).
2026-10-05 22:46:35 +08:00
yetone 11a2d359a1 agent: VS Code's magpie models and default model go in each of its profiles too, so a window on another profile lists them in Chat (TJHHHH on Discord) 2026-10-05 22:14:56 +08:00
yetone 71d2da1f42 catalog, usage, gateway, gui: a price can have a long-context tier and a 1-hour cache write, and the price boxes take decimals (PAMI on Discord)
OpenAI bills gpt-6-astra, gpt-6.1-sol and gpt-6-luna over 272K input at 2× input and cache and 1.5× output, for the whole request. A price now has tiers: `magpie model price <m> 10,50,1,12.5 --tier 272k 20,75,2,25` (--tier can repeat). A tier applies when input + cache reads + cache writes is over its size, so 272,000 is not over and 272,001 is. The built-in prices take tiers only where models.dev lists one (cost.tiers of type "context"), and it does list one for those three. The ledger, each request and OTLP are costed at the tier the call reached. Session and day totals sum many calls, so they stay at the base price.

Anthropic bills a cache write kept for an hour at 2× input and one kept for 5 minutes at 1.25×. Its usage reports which is which in cache_creation.ephemeral_5m/1h_input_tokens. The gateway (API and Claude subscription) and Claude Code's transcripts now keep that split. A price's fifth part is the 1-hour write. When it isn't given, it is 2× input: that is Anthropic's rule, and only Anthropic reports 1-hour writes. Claude list prices carry the 2× too. cache_write_tokens is still the total, and the usage CSV adds cache_write_5m_tokens and cache_write_1h_tokens at the end. The app shows the split on a request's Cache and on the usage tooltips. Old prices and old records, which have no 1-hour part, read as before. The sessions cache (18) and the call cache (calls-v8) are re-read once.

In the GUI, the price boxes were number inputs and dropped what they didn't parse ("0,25"). They are now decimal text boxes that take both "0.25" and "0,25". A model's price row has a 1-hour box (empty means 2× input) and a quiet Long-context price row. That row shows the list's tier greyed in, or sits behind a link when the list has none. A bad size is refused by name. en/zh/ja/de.

Tests:
- New: TestPriceTiers, TestCostSplit, TestPriceJSON, TestModelPriceTiers, TestModelPriceTierCmd, TestAnthropicCacheWriteSplit and TestClaudeCacheWriteSplit. The ledger CSV test expects the new columns.
- model-price-tiers.test.cjs is new; model-price-edit and provider-price-rate are updated. All of them fail against the old assets and pass in WebKit. Chromium couldn't be launched here.
- go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
2026-10-05 21:56:59 +08:00
yetone 9dda306a33 provider, gateway: a provider's header.anthropic-beta is added to the betas the agent asked, not put in their place (PAMI on Discord)
`magpie provider set <id> header.anthropic-beta=…` replaced the betas each request carried: provider.Sign wrote the user's headers over what the gateway had set, so Claude Code's own betas (claude-code-…, interleaved thinking, 1M context's context-1m-2025-08-07 for a [1m] model) and fast mode's were dropped, and a name typed in another case went out as a second anthropic-beta line beside the gateway's. anthropic-beta is now a list header (provider.ListHeader): Sign merges the user's value into the request's (provider.MergeList: the request's items first, then the user's, each once, empty ones left out, one line under the name as typed). Every other header of the user's still replaces magpie's, as before. The gateway then fits the merged list as it fits the agent's (fitUserBetas, after Sign): a beta the provider has refused (400 Unexpected value(s) `x` for the `anthropic-beta` header) is left out from the retry and from then on, the user's own as well as the agent's; one it takes is always sent. magpie adds no 1M beta of its own: Claude Code 2.1.289 itself sends context-1m-2025-08-07 for a [1m] model to any base URL (its long_context beta is asked when the model matches /\[1m\]/ and is among those kept for third-party endpoints), magpie writes 1M models into Claude Code marked [1m], and the gateway passes it on. Per-endpoint header sets were left out: one set of headers rides on every request to a key+URL provider. docs/reference.md says how header.* works and that anthropic-beta adds. TestSignMergesListHeaders (internal/provider: merged in order, deduped, the request's line not kept beside it, another header still replaced) and TestUserBetasMergeWithTheAgents (internal/gateway: a relay that refuses one of the agent's betas and one of the user's gets the agent's then the user's, both refused ones dropped on the retry and not sent again) are new and fail without the change; TestUserBetasAlone (the user's betas sent on a request with none) passes either way. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass (internal/provider failed once under load in the first full run and passed in the second).
2026-10-05 21:48:01 +08:00
yetone 2a57b7bb60 tui: magpie tui serves the gateway while it is open when no other magpie does, so agents it connected reach it in WSL (gnayiab on X)
In WSL, with only magpie tui and Cursor's subscription, Claude Code said ECONNREFUSED. The terminal set agents to the gateway but never served it, and nothing listened at 127.0.0.1:3425 unless the app, magpie web or magpie serve ran.

magpie tui now serves the gateway in its own process while it is open, as the app does, whenever no magpie answers at its address. One that does (the app, magpie web, magpie serve) keeps it. The terminal looks every 15s and takes the gateway up once that one is gone.

The status line says "● gateway <url> · served while magpie tui is open" when it has nothing else to say, or "✗ gateway not running: <why>" when the port couldn't be had. On quitting after serving, it says the gateway stopped with it and what serves it otherwise. The usage text and docs/reference.md say so too.

Tested in a sandbox HOME on port 3486 with a real DeepSeek key: magpie tui alone answered /v1/models; Claude Code (sandbox HOME) set to deepseek/deepseek-flash ran a Bash tool turn through it; the status line showed the gateway; q printed the note and freed the port.

TestTUIServesTheGatewayWhenNoneDoes (serves, status, lets go at quit) and TestTUILeavesTheGatewayToAnotherAndTakesItUp (leaves another magpie's gateway alone, takes it up once it is gone) are new and pass 3x under -race; they don't build without the change.

go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
2026-10-05 21:29:01 +08:00
yetone 528d6c12dc agent, gui: Codex left on model_provider = "magpie" with no [model_providers.magpie] loads its config again, and connecting with nothing in magpie says what to do, in the reader's language (Tystem on Discord) — the Codex app said "无法加载 config.toml … Model provider magpie not found", magpie showed Codex as not connected on "deepseek-flash", and its switch answered only "Codex can't be connected to magpie", in English. Codex looks model_provider up in [model_providers.*] as it loads config.toml (codex-rs: "Model provider {id} not found"), so the key without the table keeps it from loading any config, a new thread too, not just an old one. magpie never writes one without the other: it takes model_provider out whenever it steps out as the provider (disconnect, Unwire, a model of Codex's own, Join) and keeps the table so the threads started on magpie still open. That is unchanged, and TestCodexSteppingOutLeavesNoHalfState now covers it. Another tool or a hand edit had taken the table away. Sync, at magpie's start and whenever the catalog changes, now writes the table back whenever the config names magpie as its provider, at the top or in a profile, and has no table, whether Codex is connected or not. Check reports that state too. A config magpie can't parse is still only reported, never written to. A Connect that finds none of magpie's models because no provider or subscription has been added answers agent.NoModelsError, "Add a provider or subscription in magpie first, then connect Codex". /api answers it with code no_models and the agent's name, which app.js says through t() (en/zh/ja/de). docs/reference.md no longer says the table goes with a native model. TestCodexProviderWithoutTable (top and profile: Check says so, Sync writes the table) and TestConnectWithNoModels in internal/agent, TestAgentConnectNoModels in internal/gui, and agent-connect-no-models.test.cjs (WebKit, en/zh/ja/de: the toast in the reader's language, the switch back off, no scroll, no native dialog) are new and fail without the change; the latter's en case passes either way, as the server's message is English. Chromium wouldn't launch on this machine for this test or the existing agent-connect.test.cjs. TestCodexSyncAddsBaseURLToProvider now expects the table added to a hand-made provider that had none. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass. In the first full run, internal/gui's TestProvidersAnswerWhileListsComeIn and internal/proc's TestUserPath failed under load; both passed alone and in the second full run. 2026-10-05 21:09:13 +08:00
yetone 2f135ed4d5 plugin, gui, docs: Plugins says which plugins are providers and which are middleware, lists the community's ready-made middleware, and edits a middleware's options in its row (the owner) — the Plugins page still read as if every plugin were a subscription, and nothing showed what a middleware does or let you set its options without editing plugins.json. Every Discover card and Installed row now carries a Provider or Middleware chip (both for a plugin that is both), also for one that is off (isMiddleware/middlewareOnly from the server, read from its files); Discover splits into Subscriptions and Gateway middleware ("runs in magpie's gateway on what every agent sends and gets back, whichever provider serves it"), the latter from market entries with "kind": "middleware" and no providers or icon (the built-in market.json lists the five new @magpie-community/middleware-* packages: param-override, model-map, system-prompt, word-guard, think-tags, mostly New API's channel settings with the same JSON). A middleware row has Options: a JSON editor in the row, starting from the package's magpie.options example when none are set, which validates it is an object, saves (POST /api/plugins/options) for the next request, clears, and cancels with Esc. magpie plugin options takes a short name (model-map for @magpie-community/middleware-model-map; plugin.ShortName, also in SetOptions). en/zh/ja/de. Docs: usemagpie.ai/docs/plugins (en/zh/ja) gains Ready-made middleware and Options and the registry's kind; reference.md, both READMEs and docs/subsystems/gateway-middleware.md likewise. Tested with a real DeepSeek account and real Claude Code through magpie web --gateway in a sandbox HOME, with all five installed and options set by short name: a mapped model named back, max_tokens capped, a prompt appended for claude only, word-guard's 400 and param-override's 403 in the agent's shape, <think> stripped from Chat and Responses streams and a whole Anthropic reply, reasoning_content moved into the text; /api/plugins showed the kinds and the example. TestSetOptionsShortName is new; TestBuiltinMarket checks a middleware entry has no providers or icon and no other kind. plugin-middleware.test.cjs (Chromium and WebKit, en and zh: the chips, Discover's two sections, the options editor with its errors, no select, no scroll, no left border) fails without the change; the other plugin tests and click-scroll pass in WebKit (plugin-market's section name updated; plugin-move fails only under parallel load and passes alone). go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass. 2026-10-05 20:50:09 +08:00
yetone eebd0b4222 plugin, gateway, gui: a plugin can be gateway middleware — onRequest, onEvent and onResponse run in magpie's gateway on what agents send and get back (moejs) — a plugin could only be a subscription provider, run in the Bun host, a process away from the gateway. A plugin may now declare middleware (package.json magpie.middleware, or a *.middleware.js / .mjs file), run in-process by moejs (pure-Go JS engine): compiled once, one runtime per request from a pool, so every hook of a request shares ctx.state. onRequest(body, ctx) sees the agent's body in its own API before routing; what it returns is the body the next middleware and the gateway get, undefined leaves the bytes as they were, ctx.reject(status, msg) answers with an error in the agent's API's shape. onEvent(ev, ctx) sees each SSE event's JSON (export const events = [...] filters by name; an object changes it, null drops it, undefined passes the bytes through untouched; data: [DONE] is no JSON and passes by). onResponse(body, ctx) sees a whole JSON reply, with ctx.status. ctx has protocol, model, stream, path, agent, the plugin's options and state. A hook that throws, returns a non-object or runs past its limit (250ms request/response, 50ms an event, 1s load; interrupted) leaves the request or reply as it was and is counted and logged (60 lines a minute); a compressed reply passes as-is. Files are reloaded within a second of a change. Only agents' requests run through it (serveAgent at the gateway's agent entry points and the Codex backend), never magpie's own titles, classifier or stand-ins; it sees bodies before redaction. A middleware-only package is skipped by the Bun host. The Plugins page marks a middleware with a chip, its hooks, calls and µs per call (JSON in and out counted), failures in red with the last error, and a load error. en/zh/ja/de; docs/reference.md and usemagpie.ai/docs/plugins#middleware (en/zh/ja) with an alias example. Tested with a real DeepSeek account through magpie web --gateway in a sandbox HOME: an alias middleware served "fast" and named it in message_start, a reject came back as 403 permission_error, a Chat stream uppercased; real Claude Code (sandbox CLAUDE_CONFIG_DIR) did Read, Bash and Glob tool turns through three middlewares with 0 failures: ≈1–1.6µs an event, 0.2–0.7ms an onRequest that parses and rewrites Claude Code's ≈80KB body. Real use found two bugs fixed here: [DONE] parsed as JSON (SyntaxError on every Chat stream) and stats leaving out the JSON time. internal/middleware tests (chain, undefined keeps bytes, reject, fail-open on throw/spin/string with the runtime reused, async hook, load errors, off and Bun skip, reload, split/CRLF/filtered/dropped stream events, [DONE] untouched, whole reply and gzip, 64 in parallel; BenchmarkEvent ≈0.9µs, 11 allocs) and TestMiddlewareThroughGateway (alias routed, events by protocol, redaction still upstream, reject in Anthropic's shape; fails without the change) are new; plugin-middleware.test.cjs (Chromium and WebKit, en and zh: chip, hooks and stats, failures with last error, broken and off, no left border) fails without it. The other plugin GUI tests and click-scroll pass in WebKit. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass (internal/provider failed once under load and passes alone). 2026-10-05 19:14:31 +08:00
yetone b9d20856ef gateway, gui: a gateway key can be held to routing groups (Magic_zero on Discord) — a key's models menu on the Gateway page listed providers' models only, so a key couldn't be limited to the routing groups an agent was meant to use. The menu now lists the routing groups first, with Every routing group (group/*), and a key may keep group/<id>. A key that names a group may use it with every member in it, the next member too when one fails, but not those members asked for by name, a group it doesn't name, or a fallback outside the group; a group named inside one it doesn't name counts for its own members. A group the key doesn't name still passes only when it may use every member, as before. /v1/models and the Anthropic and Gemini lists show the key its groups. en/zh/ja/de; docs/reference.md. TestGatewayKeyModelsNameAGroup (group/both lists and serves only the group, falls to the next member, members by name and other groups refused; group/* every group; a nested named group; a member's own fallback outside the group refused) fails without the change; TestCallerKeyModelsRoutes lists the groups. gateway-key-groups.test.cjs (WebKit, en and zh: groups first after All models, Every routing group, a group picked sends group/coding, a held key's group ticked, no select, no scroll, no left border) fails without the change; gateway-key-models, gateway-key-limit and click-scroll pass in WebKit. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass. 2026-10-05 18:44:27 +08:00
yetone 1534382ff9 gui, web: gateway mode — magpie web on a server that is only a gateway shows no Agents, Sessions or Library (Player on Discord) — a magpie web run on a server or NAS for other machines' agents showed the Agents, Sessions and Library pages and the settings written into this machine's agents, none of which do anything there. In gateway mode the page has Providers, Gateway, Routing, Usage, Plugins and Settings; an address kept for a page left out opens Providers. Settings leaves out provider in model names, Codex subagents, long conversations, Codex thread titles, the usage/balance alerts and reset reminders, on top of the tray, open at login, Dock, text size and lightweight rows magpie web already hid; Language, theme, currency, proxy, redaction, vision/image models, sync, updates and the rest stay. magpie web is in it by itself when it finds no agents on its machine (the Docker image, a NAS) and with the new magpie web --gateway; Settings › General › Gateway mode (Automatic / On / Off, saying why it is on) wins over both and Off brings every page back at once. The choice is this machine's own (KeepOwn: never synced or restored from another computer), set only by POST /api/settings/gateway-mode, never by the Settings page's save. The app's own windows are never in gateway mode. boot.js tells the page before it paints. en/zh/ja/de; docs/reference.md (Docker › NAS, CLI list). TestGatewayMode (temp HOME: no agents → on, agents → off, --gateway → on, Off wins over --gateway, the Settings save leaves it, On with agents, Automatic, an unknown mode refused, the app window never told, KeepOwn) is new; TestSettingsSaveKeepsWhatItDoesNotSend sets it. gateway-mode.test.cjs (WebKit, en and zh: the tabs, ?view=agents/sessions/library open Providers, the switch and why, the seven rows hidden, Off posts "off" and brings tabs and rows back with Settings kept open and Agents opening, Automatic hides them again, the Settings save never sends it, no scroll, no select, no left border; web with agents and the app window keep every tab, the switch is magpie web's alone) fails without the change. mobile-web's desktop comparison differs from origin/main on Settings only, by the new row. settings-sections, settings-groups, click-scroll, cli-path, plain-names, codex-titles and agent-grip pass in WebKit. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass. 2026-10-05 18:20:28 +08:00
maYandyetone 6fe8169502 gateway, agent: Codex draws with its native image tool through magpie (#870)
* fix(agent): give magpie's provider table the actor-authorization header

Codex offers its built-in image tool (image_gen.imagegen) — and the other
OpenAI-provider tools — only to a provider it reads as the OpenAI actor:
ModelProviderInfo::uses_openai_actor_authorization(), i.e. a provider that
does not require OpenAI auth but carries x-openai-actor-authorization.
magpie writes [model_providers.magpie] but carried neither signal, so a
Codex on magpie was never given the tool and could not draw through a
Codex backend such as sub2api's.

Write http_headers = { "x-openai-actor-authorization" = "magpie" } into
that table. magpie does not forward the header upstream: codexHeader
rejects anything naming "authorization".

Test: TestCodexMagpieProviderCarriesActorAuthorization.

* gateway/provider: let a Responses-only relay (Codex backend, sub2api) draw

A provider added as 'OpenAI Responses' only (what a Codex backend such as
sub2api speaks) has no chat base, so Drawers() returned nil before ever
looking at the vendor's list, draw() refused with 'can't draw', and
drawImages() posted to an empty base. The vendor's own /v1/models does list
its image models (sub2api serves gpt-image-2.5), and they are meant to be
picked in Settings → Images.

- Drawers: a Responses-only provider draws when it has any OpenAI-shaped
  base, not only a chat one.
- draw: refuse only when neither Chat nor Responses is set.
- drawImages: fall back to the Responses base when there is no chat base.
- List: the add form's 'Fetch models' keeps the vendor's image models so
  one can be picked while the provider is added.

Tests: TestResponsesOnlyProviderDraws, TestListOffersImageModels.

* fix: route Codex native images through its turn provider

---------

Co-authored-by: yetone <yetoneful@gmail.com>
2026-10-05 17:19:42 +08:00
yetone 46f03154ff gateway: a gateway key can be held to some models (#882, LCorleone) — a key could reach every model magpie serves. A key now keeps an optional list of models (caller-keys.json "models", none = every model, so older files read as before): <provider>/<model> or <provider>/*, matched against the provider that serves the call, so a bare model name is resolved first and OpenRouter's "anthropic/x" is not Anthropic's. Such a key sees only its models in /v1/models, /v1/models/{id} and the Anthropic (Claude Desktop) and Gemini lists, a routing group only when every member is its; asking for another, by id, bare name or through a group with a member it may not use, is refused before any provider is asked with a 403 in the API's own error shape (permission_error) that names the key and its models, logged as a rejected call like an over-limit key's 429; a fallback it may not use is skipped; embeddings, images and videos it names are held too. magpie's own calls for it (a web search, a picture described, Codex titles with the model Settings name) are marked in their context, not by a User-Agent a client could send, and go on. Set it with the new badge on the key's row in the Gateway page (the app's menu: All models, every model of a provider, each model; sent when the menu closes) or magpie gateway-key models <id> openai/gpt-5 anthropic/* (all takes it off); gateway-key list has a MODELS column. The app's menu, fitting neither below nor above its button, now goes on the roomier side and scrolls instead of running off the window. en/zh/ja/de. TestGatewayKeyModelsHoldAKey (lists, 403s by id, bare name and group, Anthropic shape, fallback skipped, magpie's own call, a free key) fails without the change; TestModelPatterns, TestKeyModelsKept, TestGatewayKeyModelsCLI, TestCallerKeyModelsRoutes pass; gateway-key-models.test.cjs (Chromium and WebKit, en and zh: no native select, menu inside a 420px window, picks sent on close, All models, no scroll, no left border, 560px fits) passes with gateway-connect-keys, gateway-key-limit, gateway-caller-keys and click-scroll. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass. 2026-10-05 16:52:23 +08:00
yetone 9fb51c7742 docs: a new README, in English and Chinese, that shows what magpie does
The old README was the whole manual in one file. Someone who opened the
repo saw a wall of config paths before seeing the app. The new one leads
with the app: a hero screenshot, six highlights, then a section for each
part (picking models, providers, routing groups and intent routing,
subscriptions and plugins, usage, sharing, the library), with the site's
light and dark screenshots in <picture> tags. A supported-agents list, a
three-step quick start and a CLI sample follow. README.zh-CN.md is the
same page in Chinese, with the Chinese screenshots where the site has
them.

The full manual moves, word for word, to docs/reference.md, with its two
relative links fixed. Both READMEs link to it, so no detail is lost.
2026-10-05 15:02:09 +08:00