80 Commits
Author SHA1 Message Date
yetone ddd4f43144 provider: a routing group can be duplicated, from its menu on the Routing page or with magpie group copy (lc on Discord)
provider.CopyGroup(id, name) saves a copy of the group as it is now: its
models and patterns, routing, stays, rules, classifier, effort, context,
levels, and fast and off members. The copy is deep, so its rules' time
windows aren't shared. Its name is the one given, or "<name> copy", with a
number added past a name in use. Its id comes from the name, numbered past
any id in use, removed ones included. It is listed right after the group
it copies. The copy is a group of the user's and switched on, even when
it copies a found group or a switched-off one. The original is unchanged.

Surfaces:
- GUI: a group row's menu (right-click, or a click on its logos) has
  Duplicate under Move up and Move down. It posts /api/groups/copy with
  the id and t("{name} copy"), so the name is in the reader's language.
  It asks before discarding an editor with unsaved changes. The status
  then says "Duplicated {name}". No click scrolls the page.
- CLI: magpie group copy <id> [name]; duplicate, dup and clone work too.
- zh, zh-TW, ja and de strings. docs/reference.md and the site's start
  page (en, zh, ja) list the command.

Run:
- TestGroupCopy (internal/gui): the copy has the models, pattern,
  routing, rule and off member, is on, and is listed after the original.
  The original stays off and unchanged. A second copy is "Fast copy 2" /
  fast-copy-2. An unknown id is refused.
- TestGroupCopyCLI (root): the default name, a given name, a found
  group's copy as the user's, and the errors.
- group-duplicate.test.cjs: Chromium and WebKit, en/zh/zh-TW/ja/de at
  420px, 10/10. It fails 10/10 on the old routing.js. Siblings
  group-arrange, removed-groups-menu-scroll, group-bulk-remove and
  click-scroll pass 36/36.
- Sandbox CLI (sandbox HOME): group copy and duplicate made "DupTest copy"
  and "DupTest copy 2", listed after DupTest.
- gofmt -l clean. go vet darwin/linux/windows on the root, provider and
  gui packages: ok.
2026-10-10 15:08:14 +08:00
yetone a154197bbb accounts: ChatGPT accounts come in from codexbar's config.json (碳碳双键)
碳碳双键 on Discord (#general): several Codex accounts switched with
codexbar on the old computer; after a magpie backup and restore the new
one had only the account Codex itself was signed in to. A backup carries
no sign-in by design (a refresh token rotates, so two computers can't
share one); importing is how accounts move. But codexbar's own store,
~/.codexbar/config.json, wasn't a shape the importer knew: its accounts
sit under providers[].accounts, so the whole file was read as one entry
with no token ("#1: failed").

codexbarEntries reads it as codexbar (lizhelang/codexbar 9f2e0f53)
writes it (CodexBarConfig.swift, CodexBarConfigStore): the accounts of
the provider of kind openai_oauth and openAI.remoteConnectionAccounts,
camelCase tokens, the workspace from openAIAccountId (its id is
codexbar's own). api_key accounts are said to be keys; other providers
(API endpoints of other vendors) are left alone; a sign-in in both places
is read once. codexbar signs in with Codex's own OAuth client, so the
refresh works as for Codex's auth.json. Its own export (Sub2API's shape)
was already read.

The import window's texts (en, zh, zh-TW, ja, de), the CLI's warning and
the docs name codexbar; reference.md says a backup carries no sign-in and
that importing the old computer's file is how accounts move.

Run:
- TestImportCodexbarConfig, fixture in codexbar's encoder output (pretty,
  sorted keys, ISO dates; two Team seats, a duplicate remote account, an
  access-only account, a DeepSeek api_key provider): fails on the old code
  ("got #1:failed"), passes on the new; seats kept apart by
  openAIAccountId with an id token that doesn't name the workspace.
- Built nogui binary, sandbox HOME: `magpie accounts import codex
  config.json --yes` lists the three accounts, ChatGPT refusing the fake
  tokens; no real codexbar install here to try with real accounts.
- login-import.test.cjs (chromium, webkit; en, zh) and i18n-keys.test.cjs
  pass.
- go test -tags nogui ./... (temp HOME, pinned caches): all ok.
- go vet (darwin, GOOS=linux, GOOS=windows) on . and internal/provider;
  gofmt -l clean.
2026-10-10 15:04:23 +08:00
yetone 494dedcf8f desktopdir: Claude Desktop installed from the Microsoft Store (package AnthropicPBC.Claude_*) has its folders found, as the setup.exe's Claude_* package's are
Desktop 2.31226.1's own code knows two MSIX package families, Claude_pzs8sxrjxfjjc
and AnthropicPBC.Claude_fnn82j28hfe8t (hze/_ze in index.chunk-Cyt2WTko.js).
packageDir only globbed Packages\Claude_*, so a Store install's LocalCache
was never found: magpie wrote Claude Desktop's gateway, MCP servers and
read its sessions and skills in the unpackaged folders instead.

Checked while following up v0.1.1156: the same bundle has a
%LOCALAPPDATA%\Claude-Data folder for 1p userData, but in 2.31226.1 it is
dormant. globalThis.userDataMoved is only read, never set, and the
Claude-Data dir is chosen only when %APPDATA% is a network share, while
relocateUncUserData does nothing in index.pre.js. So 1p userData is still
%APPDATA%\Claude (or the package's copy), and desktopdir is left as it is for
that.

Run: go test ./internal/desktopdir ./internal/library ./internal/agent
(macOS); TestFindMSIX fails on the old packageDir for the Store family;
desktopdir's Windows test binary passes on the Windows box; go vet for
linux, windows and darwin.
2026-10-10 14:55:16 +08:00
yetone e04824714d library: a skill's templates and other big files go with a backup and a sync (Crispin)
Crispin on Discord (#help): a skill's template files, a PPT among them,
didn't reach the other computer. Collect read each skill file with a 2 MB
cap and left everything bigger out, so a .pptx template with its pictures
never travelled. Links, copies and WSL copies on one computer already carry
every file (copyDir and hashFiles have no filter); only the backup/sync
carry dropped them.

- A file goes up to 16 MiB, all skills together up to 32 MiB. A sealed
  backup is JSON with the bytes base64'd twice (~x16/9), and a sync reads
  at most 64 MiB of one, so 32 MiB raw is what still fits.
- readSkills fills that budget smallest file first across every skill, so
  one skill's big file never keeps another skill's SKILL.md from coming.
- What is left out (Left) and the computer being put to already has is
  kept (keepLeft): a Put no longer sets the folder aside without it. A Left
  name is checked like a carried one.

Run:
- New tests fail on the old code: TestCarryTemplates (template left),
  TestCarrySmallFirst (zzz's notes.md left), TestCarryKeepsWhatWasLeft
  (the local file went), TestSkillTemplatesTravel (0 bytes of 6291456).
  Pass on the new. TestSkillTemplateReachesEveryAgent covers link, copy
  and WSL copies of a pptx.
- Break check: maxCarriedAll = 48 MiB makes TestSkillTemplatesTravel fail
  ("the backup is 64 MiB, more than a sync reads").
- Real use: built nogui binary, sandbox HOMEs, a real 2.8 MB and a real
  14 MB .pptx in a library skill, `magpie backup` then `restore
  --no-agents` on another HOME: sha256 identical both times.
- go test -tags nogui ./... (temp HOME, pinned caches): all ok.
- go vet (darwin, GOOS=linux, GOOS=windows) on internal/library and
  internal/backup; gofmt -l clean.
2026-10-10 14:52:17 +08:00
yetone 282c9feb23 agent: a packaged Claude Desktop gets magpie's gateway in the real %LOCALAPPDATA%\Claude-3p too, which 2.31226 reads, not only the package's (bg5eau on X)
bg5eau on X: Claude Desktop 2.31226.1, installed as an MSIX package on
Windows, never reached magpie. magpie wrote deploymentMode and its
configLibrary profile only into the package's
LocalCache\Local\Claude-3p, and that build reads the real
%LOCALAPPDATA%\Claude-3p. Copying configLibrary there and setting
deploymentMode 3p fixed it for them.

What the build does: 2.31226.1's asar (index.chunk-Cyt2WTko.js) resolves
the 3p userData with Ou() = path.join(LOCALAPPDATA, "Claude-3p"). 2.7032.0
has the same code (GY()). It reads deploymentMode from
Ou()/claude_desktop_config.json and the gateway from Ou()/configLibrary.
The path didn't change; the package stopped redirecting it into
LocalCache. An install's folders don't say which build it is, so:

- desktopdir.Sets returns every set of folders: Find's choice first, then
  the other one for an MSIX install. Everywhere lists every set's folders.
- Claude Desktop's switch writes and removes deploymentMode, the profile
  and _meta.json in each set. The package's set keeps its own stash keys
  (".package"). A stash that an older magpie wrote for the package's
  folders moves onto those keys, so turning it off still restores them.
- The check runs on each set, names the folder when there are two, and
  checks Claude-3p's deploymentMode as well.
- The siblings: the library's MCP servers go into every Claude-3p file
  that exists, and Cowork skills and Desktop sessions are read from every
  set.

Run:
- go test ./internal/{desktopdir,agent,library,sessions,usage}: green.
- The new tests (TestClaudeDesktopMSIX, TestClaudeDesktopMSIXStashBefore,
  TestClaudeDesktopMSIXBoth3p) and TestCallsCoworkMSIX are red against
  the old code. StashBefore also goes red with desktopAdoptStash disabled.
- gofmt; go vet for darwin, linux and windows.
- On the Windows box, Windows test binaries for the four packages:
  - desktopdir and library pass.
  - agent and sessions fail exactly the set that main's binaries fail
    there (#1388, and newly filed #1468).
  - Every Desktop test passes.
2026-10-10 14:13:05 +08:00
yetone 34f2078db8 site: a partner's website and key links go through usemagpie.ai/go, which counts the click; fetches of the partner list are counted too
The app's own partner counts only come from users who leave the stats on,
so a sponsor's reach and clicks are now also counted where the server sees
them. /api/partners hands out each partner's website and keysUrl (and a
region's) as usemagpie.ai/go/<id>/site|keys[/<region>]; /go sends a
"magpie partner go" event to PostHog (partner, link, region, country) and
302s to partners.js's own URL. One in ten list fetches sends "magpie
partners fetch" with weight 10 while someone is listed. Events carry a
random id each, no install id or address. Ended entries leave the list but
/go still resolves them, since added providers keep the links. Privacy text
updated in README (en, zh), docs/reference.md and the site docs (en, zh, ja).

Run: node --test site/*.test.mjs (27 pass); a scratch copy with a fake
partner served rewritten links, 302'd /go to the real URLs and posted both
events; PostHog's /i/v0/e/ answered 200 to the payload shape.
2026-10-10 13:48:26 +08:00
yetone 9bb2a467e4 agent: Snow CLI is an agent magpie sets up: a magpie profile in ~/.snow, made the active one, that Snow App takes in with "Sync Snow CLI API config" (#1457)
Snow CLI (npm snow-ai, MIT, MayDay-wpf/snow-cli) keeps one profile per
file under ~/.snow/profiles (or $SNOW_CONFIG_DIR), names the active one
in active-profile.json, and copies it over config.json at every start,
headless `snow --ask` included. Each profile is the whole snowcfg: base
URL, key, request method and models. Snow App (MayDay-wpf/snow-app)
reads ~/.snow/profiles/*.json when "Sync Snow CLI API config" is pressed.

magpie writes profiles/magpie.json from the profile the user was on
(their settings kept, Snow's OAuth sign-in left out): baseUrlMode base
at the gateway's /v1, requestMethod chat, the agent's key, the model as
advancedModel and basicModel, and the catalog's window and output
(200000 and 64000 when not known, the output cut to the window). It
makes that profile active and copies it to config.json. The profile the
user was on, whether there was an active-profile.json at all, and a
magpie.json of their own are stashed; Disconnect, `magpie snow
default`, or picking a model of one of the user's profiles puts those
back. A Snow from before profiles (config.json alone) gets the
default.json Snow itself would make, so its next start doesn't take
magpie's config for the user's. Check reports a base URL, key or request
method moved off magpie's; Sync rewrites the profile on a port move. A
WSL distro's ~/.snow is found and written the same way.

Registered: agents.go, the WSL kinds, SNOW_CONFIG_DIR in agentenv, the
npm update spec (snow-ai), the icon (Snow App's own, trimmed to 96px),
README, docs/reference.md, docs/subsystems/agent-wiring.md, the site's
agent list and start.html (en, zh, ja).

Run:
- go test -tags nogui ./... in a scratch HOME: 44 packages ok.
  New: TestSnow, TestSnowOwnProfile, TestSnowNoProfiles,
  TestSnowUsersMagpieProfile, TestSnowCheckSync, TestAgentSnowDefault;
  snow added to TestOwnProviderNamedAsMagpies, the WSL more-agents tests
  and TestMaxTokensWithinContextWindow. All fail before this commit
  (no agent "snow").
- go vet -tags nogui for darwin, linux and windows; gofmt -l clean.
- Real Snow CLI 0.9.2 in a sandbox HOME against a sandbox magpie
  (`magpie serve` on 127.0.0.1:3571, a fake OpenAI upstream on 3561):
  `magpie snow fake/pro` made the magpie profile active, `snow --ask`
  answered through the gateway (POST /v1/chat/completions, model pro,
  logged as agent snow), and `magpie snow default` left profiles,
  active-profile.json and config.json byte-identical to before.
- GUI: agent-layout, agent-connect, agent-models-picker,
  agent-field-names and models-add-and-icons in Chromium and WebKit;
  a Snow CLI row checked at 520-960px in en, zh, zh-TW, ja and de in
  both engines. No new GUI strings.
2026-10-10 11:46:44 +08:00
yetone f2611b7154 accounts: import ChatGPT accounts from Cockpit Tools' and Sub2API's exports, several files at once, from the window or magpie accounts import; the box says the source tool is signed out (#1453)
wlj521 asked for cockpit-tools' import: several auth.json files at once.
magpie's window already imported Codex's auth.json and CLIProxyAPI's
files; this brings it the rest of what Cockpit Tools exports and a command
for it.

What changed:
- login_import.go reads Sub2API's export ({accounts: [{platform, type,
  credentials}]}), so every Cockpit Tools export format comes in (its own,
  auth.json, CPA, Sub2API). An API key, another vendor's account and an
  entry with only an access token (a web session: it can't be renewed and
  would stop within days) are refused with the reason.
- ImportLogins and ImportGoogleAccounts no longer skip one file of several
  that holds no account: it is a failed row with its place
  (ImportedAccount.File), which the window and the command name by file.
- `magpie accounts import <codex|claude|antigravity|factory> <file>...
  [--yes]`: says first what importing does to the tool the file came
  from, imports nothing without a yes, only reads the files, and lists
  each account's outcome.
- The window's import box names Cockpit Tools and Sub2API and says
  plainly that the source tool is signed out of the account and the file
  is only read. Claude's box no longer claims a refresh it doesn't do.
  zh, zh-TW, ja and de for every new string; the retired strings removed.
- docs: reference.md and providers-accounts.md describe the import.

Decision: keep the refresh on import. It checks the file and makes magpie
the token's one holder at a moment the user chose. Storing the token
as-is, as Cockpit Tools does, only delays the rotation conflict and makes
which side gets signed out a race.

Run:
- go test -tags nogui ./... under a sandbox HOME (pinned caches)
- TestImportCockpitExports red on the old login_import.go (Sub2API rows
  unread, the garbage file passed over); TestAccountsImport red with the
  confirmation removed by hand; login-import.test.cjs red on the old app.js
- node --test login-import, factory-key, i18n-keys, gui-ja, gui-de,
  gui-zh-tw (Chromium and WebKit; en and zh, results at 380px)
- gofmt -l; go vet, GOOS=linux and GOOS=windows go vet -tags nogui on .
  and ./internal/provider; darwin, linux and windows builds
- no live ~/.codex: fixtures and a fake token endpoint only
2026-10-10 10:56:55 +08:00
yetone 83c963c466 gateway: a gateway MAGPIE_ADDR puts on the network asks callers from other machines, a container's host among them, for an enabled gateway key, as sharing does
What changed
- lanGuard: a request whose peer isn't loopback (or that carries a
  forwarding header) is remote. While the gateway is shared, or
  MAGPIE_ADDR names a host other than loopback/localhost (OnNetwork:
  0.0.0.0 in the Docker image, a server's address), a remote request is
  answered only with an enabled gateway key, on every route behind the
  guard; otherwise it is refused (403) as before. Before, a MAGPIE_ADDR on
  the network with sharing off took remote requests with any key.
  A remote request let in with a key is marked as shared-mode ones are,
  so quotas, quota cards, refresh and prices answer it.
  callerGuard follows the same condition. OpenToAnyone is replaced by
  OnNetwork.
- Loopback is unchanged: any key still works on the gateway's own
  machine and inside the container (HEALTHCHECK included).
- CLI keyNote and the console's Connect note no longer describe an open
  gateway; on the network they say other machines need a gateway key
  (`magpie gateway-key add <name>`). The console shows the Gateway keys
  block and network addresses when OnNetwork (gatewayJSON.onNetwork
  replaces open); the "Open to the network" strings are removed.
- Docs: docker.html (en/zh/ja) and remote.html (en/zh/ja) say callers
  outside the container need a gateway key, how to make one, that
  sharing isn't needed for it, and what an upgrade means for agents that
  sent `magpie`; docs/reference.md and providers-accounts.md likewise.
  A MAGPIE_ADDR pinned to one interface's address now asks a key of this
  computer's agents calling that address too (documented).

Tests
- New TestNetworkGatewayGuardsEveryRoute: from 172.17.0.1 and
  192.168.1.9 every registered route (model APIs, count_tokens,
  systemone, the Responses websocket GET, MCP, Codex backend and
  catalog, /v1/magpie/*, images, embeddings, rerank, videos, Gemini)
  gets 401 without a valid key; with one, models and quotas answer 200.
- TestNetworkGatewayNeedsAKey (was TestExplicitOpenGatewayAccepts...),
  TestLANGuard, TestProxiedLoopbackNeedsTheKey, TestQuotasOverLAN,
  TestMCPProxyStaysHere, session route and remote prices tests, TestOnNetwork,
  serve_cli and gui public_url tests updated. With the old lanGuard
  restored, TestNetworkGatewayNeedsAKey, TestNetworkGatewayGuardsEveryRoute,
  TestLANGuard and TestProxiedLoopbackNeedsTheKey fail.

Run
- go test -tags nogui -count=1 ./internal/gateway ./internal/gui
  ./internal/provider . under a temp HOME: ok.
- gofmt -l clean; GOOS=linux and GOOS=windows go vet -tags nogui on
  those packages: clean.
- GUI: public-url and gateway-caller-keys tests, Chromium and WebKit: 22/22.
- Locally built image (OrbStack), -p 127.0.0.1:3481:3425: from the host,
  /v1/models with no key, `magpie` and another made-up key -> 401;
  count_tokens and /mcp/x -> 401; a key from `docker exec ... magpie
  gateway-key add Host` -> models 200, quotas 200; inside the container
  wget /v1/models -> 200; healthcheck exits 0. `web --addr 0.0.0.0:3430`:
  /api/state 401 without the web key, 200 with its cookie.
2026-10-10 09:26:23 +08:00
yetone c1c887798b provider, docs: a bare model id several providers serve goes by the Providers order, said and tested (MOMO on Discord)
MOMO's {"model":"glm-5.3"} was answered by opencode-go though a6api
serves glm-5.3 too, and they asked for a 409 or for the response to say
which provider was used. The pick was already deterministic, but
Resolve's comment said "exactly one provider" and no doc said what
happens.

Decision: keep serving bare ids, no 409. magpie writes only
provider/model ids into agents' configs (agent/route.go magpieModels),
so a bare id comes from a user's own config, and a 409 would break every
such client the day a second provider adds the model. The rule is now
written down and pinned:
- found groups on (the default): GroupFor takes the bare id to its found
  group auto-<slug>, members in the Providers order, smart routing and
  fail-over to the next provider; a conversation stays with the member
  that answered it while its cache is worth it (affinity).
- found groups off: Resolve takes the first provider in the Providers
  order that exposes it, alone.
- every reply already carries X-Magpie-Provider / X-Magpie-Model
  (member.go), and the usage log has the provider.

What changed: Resolve's comment; gateway-routing.md model resolution
row; reference.md, the ways a model is named and the headers;
bare_slug_test.go: two providers serving glm-5.3, both modes: the first
in the Providers order answers every new conversation, reordering moves
new ones, an answered one stays in the found group, and a 503 from the
first falls over only in the found group. It passes on the old code
too: it pins behaviour that was already there, now documented.

Run: gofmt -l; go vet -tags nogui on darwin, linux, windows for provider
and gateway; go test -tags nogui ./internal/gateway ./internal/provider
in a sandbox HOME.
2026-10-10 05:56:07 +08:00
yetone 0311c720c1 gateway, provider: a model its vendor answers is deprecated leaves that provider's lists until it is answered again, and the next member is asked (MOMO on Discord)
MOMO's /v1/models listed opencode-zen-free/exo-free, and a request for it
got 410 "Model exo-free has been deprecated.". OpenCode Zen's own
/zen/v1/models still lists exo-free (checked 2026-10-10), and models.dev
gives it no deprecated status, so the zen-free plugin's discovery keeps it
and a refresh of the list would bring it straight back. No preset
hardcodes it.

What changed:
- fallback.go: retiredWords / modelRetired: a 400, 404, 410 or 422 saying
  the model is deprecated/retired (Zen's "ModelDeprecated", OpenAI's
  "The model ... has been deprecated"), without quota words. It is
  retryable (410 wasn't), and modelRefused, so only that model rests.
- gateway.go: such an answer calls provider.Retire(provider, model);
  routing.go: servedCandidate calls Unretire.
- provider/retired.go: retired.json beside providers.json, re-read by
  mtime; Retire/Unretire touch the catalog so agents' lists are written
  again. buildEntries leaves a retired model out, for that provider only,
  for at most a week. provider/model requests are still sent.
- docs: gateway-routing.md failure handling, reference.md.

Tests: retired_test.go with Zen's literal 410 body: the model leaves
/v1/models while the provider's other model stays; a group falls over to
the next member and only the 410's provider is marked; modelRetired
cases. All three fail with the hooks reverted.

Run: gofmt -l; go vet -tags nogui on darwin, linux, windows for provider
and gateway; go test -tags nogui ./internal/provider ./internal/gateway
./internal/agent ./internal/catalog in a sandbox HOME.
2026-10-10 05:40:28 +08:00
a998a36367 provider, gateway: remote magpie serves decision and retrieval APIs (#1282)
* provider, gateway: remote magpie serves decision and retrieval APIs

Configure System One from the remote address and discover explicitly marked
decision models, including Clef and custom names. Keep exposed retrieval
models when importing the remote catalog, without treating decisions as
chat or image generators. Refresh removes stale decisions and the editor
receives an explicit empty decision list when none remain.

Forward caller/session attribution for decision, retrieval and media APIs.
Preserve TUI model-list errors for a remote that cannot be reached. Document
the HTTP API surface and old-peer compatibility.

Verified with the full nogui Go suite, focused race checks, go vet, native
macOS and Linux/Windows cross-builds, and the Remote magpie browser tests in
Chromium and WebKit in English and Chinese. The new System One integration
test fails on the old code with an empty model list, and the retrieval test
fails because the remote embedding model is filtered out. Integration tests
exercise two gateways and simulated upstreams; live vendor accounts were
not used.

* gateway, provider: remote forwards keep official client headers

Claude Code and Codex keep their User-Agent through both gateways, so a
relay behind the remote that admits only official clients accepts their
requests. Only decision, retrieval and media calls set magpie's User-Agent
at their own call sites. The two-hop test covers Messages, Responses, Chat
Completions and a Codex originator with another User-Agent.

Keep entryFor limited to remote decision providers. Test rejection of
unlisted remote decisions and remove the redundant resolveDecideModel
early return: the explicit model marker and list-only Jev already exclude
vendor aliases. Document retrieval entries remaining in the shared agent
catalog and TUI list errors being shown for other mixed providers too.

Verified with the Remote magpie provider/gateway tests. Before the fix,
TestRemoteMagpieClientHeaders fails all four cases with the relay receiving
magpie/dev and returning 403 official client required; all pass after it.
Follow-up to yetone's review of #1282.

* provider, gateway, gui: remote discovery reports only the decisions it lists (#1282)

Show CLI list failures and catalog counts for mixed providers. Derive the
remote decision endpoint from its one editable address, name custom decisions
by their ids, and reuse successful discovery in endpoint tests without an
empty decide OK row. Bare-provider decision selection is documented.

Simplify decision classification around the explicit remote list and reuse
model markers while building Available, Catalog and Deciders, avoiding a
whole-list copy and scan for every model. Other providers keep their rules.

The regression tests failed before the fixes on swallowed CLI errors, blank
labels, extra endpoint fields, empty decide results and repeated discovery.
Deliberately removing retrieval/video User-Agent or System One caller
attribution makes all three receiving-side tests fail with lost agent/via.

Verified with sandboxed Go tests, targeted race checks, Linux GUI/nogui and
Windows vet/build, Chromium/WebKit en/zh browser tests and zh-TW/ja/de checks.
The two-hop tests use real gateway handlers with simulated vendor replies;
real vendor accounts and native Windows/macOS runs were not used.

Co-authored-by: XYenon <register@xyenon.bid>
Amp-Thread-ID: https://ampcode.com/threads/T-01a11cd6-93aa-767c-9d6e-f0db2a9c5bab

* gui, gateway tests: remote decision refresh leaves only its current model choices (#1282)

The empty-decision browser fixture cleared deciders but kept decision
entries in models, so its screenshot still showed the same chips. Remove
those entries and assert the visible choices in both states. Check the
real providerInfo model list after refresh too, with Jev Router retained
as chat, and verify an unnamed remote decision reaches the local picker
with its id and provider label.

No production code changes. Verified all Remote magpie tests in root,
provider, gateway and gui under a sandbox HOME with -race; the new
assertions in gateway/gui pass. Chromium and WebKit pass all six browser
cases in English and Chinese; the corrected screenshot run passes 3/3.
Targeted Linux and Windows nogui vet and gofmt are clean.

Amp-Thread-ID: https://ampcode.com/threads/T-01a11cd6-93aa-767c-9d6e-f0db2a9c5bab
Co-authored-by: XYenon <register@xyenon.bid>

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-10-10 03:55:45 +08:00
yetone 7d0e415e62 plugins: two plugins that sign in to one provider are both listed, each row says which serves it, and Use for picks the other (neiko on Discord)
neiko on Discord installed a third-party plugin beside a plugin of their
own, added by its folder. Only the third-party one could be seen. Both
plugins' auth hooks name the same provider id. The host's auths() keeps
the last plugin in plugins.json for an id, as OpenCode does, so the
user's own plugin silently lost it:
- its row read "Signs in to nothing magpie can use";
- its provider was gone from the Add sheet;
- keepUnloaded could list the id a second time, from the
  plugin-providers.json kept before.
A package-name clash and a display-name clash were tried as well. Both
plugins stay visible in those cases, because a folder plugin's name is
its path.

What changed:
- host.js:
  - init gives each loaded plugin the ids it signs in to (provides) and,
    for each id another plugin serves, that plugin's spec (servedBy).
  - auths() honours plugins.json's new prefer map (id -> spec).
  - Only the serving plugin's provider.models hook runs for the id.
- plugin:
  - Clashes() turns provides and servedBy into {id, by, with} per spec.
  - Prefer() records a pick and restarts the host.
  - Remove drops the removed plugin's picks.
  - Re-adding a plugin under a new spec carries its picks over.
  - remove, off, options and prefer find a plugin by its exact spec
    before its package name (indexOf), so acting on one plugin never
    reaches another of the same package.
  - keepUnloaded no longer re-adds a provider from before when another
    plugin serves its id now.
- GUI:
  - /api/plugins rows carry clashes, with the provider's listed name.
  - POST /api/plugins/prefer {spec, provider}.
  - The serving row says "{other} signs in to {name} too; this one
    serves it".
  - The other row says, in amber, "{name} is served by {other}, which
    signs in to it too", with a "Use for {name}" button.
  - New strings in zh, zh-TW, ja and de.
- CLI: `magpie plugin` prints the same lines, with the command that
  fixes it, and `magpie plugin use <name> <provider>` makes the pick.
- Docs: docs/subsystems/provider-plugins.md, docs/reference.md and
  site/public/docs/{,zh/,ja/}plugins.html.

What was run:
- Reproduced under a sandbox HOME with magpie web on 3562: a fake npm
  plugin and a folder plugin, both on id "acme".
  - Old binary: the local row had providers [] and /api/providers
    listed acme twice.
  - New binary: both rows carry the clash; prefer moves acme to the
    folder plugin; remove clears the pick and leaves the other plugin
    serving acme.
- New Go tests:
  - TestTwoPluginsOneProvider, a real Bun host: Prefer, restart,
    off/on and Remove.
  - TestClashes, TestPluginNamedExactlyFirst and
    TestKeepUnloadedLeavesAServedID.
  - TestPluginsClashRows (gui) and TestPluginListSaysClash (CLI).
  - Each fails when its piece is broken by hand: prefer ignored in
    host.js, the keepUnloaded id check removed, name before exact spec,
    Remove keeping picks, no provides, no clashes in the rows, no
    prefer route.
- plugin-clash.test.cjs, in Chromium and WebKit, in en, zh, zh-TW, ja
  and de, at 1100 and 560px: 22/22 pass. It times out on the old
  plugins.js.
- gui-de, gui-ja, gui-zh-tw, i18n-keys and every plugin-*.test.cjs:
  248 tests.
  - 245 passed in the 4-wide run.
  - plugin-moved-signin (webkit) failed once in that run, then passed
    3/3 alone and 26/26 in a smaller 4-wide rerun.
- gofmt -l; go vet ./...; GOOS=linux and GOOS=windows go vet -tags
  nogui ./...; go build ./...
- The full go test -tags nogui ./... under a temp HOME with the caches
  pinned: 44 packages ok, none failed.
- After rebasing onto 05917100 (plugins.css conflict with a3a8e257's
  chip rules, both kept): plugin, gui and the root plugin tests, vet on
  darwin, linux and windows, and the 248 GUI tests above, all green
  (248/248 this time).

Not tried: a real third-party plugin, neiko's own plugins, and Windows
beyond vet.
2026-10-10 00:55:38 +08:00
yetone a3a8e25792 plugins: a repository tagged magpie-plugin shows the icon its package.json gives (yetone: 非官方的插件能否支持 icon)
magpie.icon, an https URL or a data:image URI, is read with the rest of
package.json and kept through pluginOwnIcon as an installed plugin's is;
the card shows it, else the puzzle piece.

Ran: go test ./internal/plugin ./internal/gui ./internal/provider; the
plugin GUI tests in Chromium and WebKit (en/zh/ja/de, 980 and 440).
2026-10-10 00:42:37 +08:00
yetone 34a598372c providers, gateway, gui, cli: each usage window of an account can have a cap of its own beside the account's (willz on Discord)
willz asked for a cap per window: a friend's account should stop at 50%
of its five hours, while its week has its own 40%. Until now an account
had one usage cap, the same share of every window.

What the user sees: the account's pill still sets the cap for all its
windows and stays the default. Each window's meter on the account's row
is now a button: a click opens the app's own menu headed "Cap for
<window>" with the account's cap, No cap on this window, 50–90%, and
Other… typed in place. A window with its own cap says it beside its
figure ("Cap 50%", or "No cap"), its mark sits there, and the account's
pill says "Caps by window" when only windows have caps. The account is
held while any window is past its own cap, and the row's note, the
gateway's 429 and the Routing trace name that window's cap. CLI:
magpie provider account-cap <id> <account> --window "5 hours" 50
(none: no cap on that window; default: back to the account's), and
`account-cap <id>` lists the overrides.

How: Provider.AccountWindowCaps (providers.json, by account and the
window's capId, its name in lower case; 1–99, or 100 for none) beside
AccountCaps. provider.WindowCaps gives a window its share (its own, else
the account's); Allowance.CapHeld and capReached judge each window by
its own share and report the fullest holding window's cap.
provider.HoldShare becomes HoldCaps, used by gateway capHeld (plan and
group filtering, cappedError, trace) and codex_backend's route-or-relay
choice; codex_switch's NextLogin moves the agent off at per-window caps.
QuotaWindow carries capId; Limit.name is exported as Limit.Name. A
config with no window caps reads and routes exactly as before
(TestSetWindowCap reads an old one). The meter is a span with
role=button, Enter/Space: a <button> as a flex item of the wrapping
meter line kept WebKit's row at its one-line height until the next
layout, so the row grew under the pointer at the first click.

Verified:
- go test -tags nogui ./... in a sandbox HOME: all ok. New
  TestWindowCapHeld and TestSetWindowCap (provider), TestWindowCapsHoldTheAccount
  through the real gateway (5h 60%/cap 50 goes to the spare; 20/30 under
  50/40 is used; 20/45 with the spare past its own cap is a 429 naming
  "past its 40% cap"), TestAccountCapCmd --window cases.
- Break check: with WindowCaps.Of ignoring the window's own share,
  TestWindowCapHeld fails ("5 hours at 60% of 50%: <nil>") and
  TestWindowCapsHoldTheAccount fails (the request is answered 200 by the
  capped account).
- internal/gui/tests/window-cap.test.cjs: Chromium and WebKit, 420 and
  1100 wide, en, zh, zh-TW, ja, de (60 tests): held note per window,
  marks, menu from the meter with no native select, keyboard, posts of
  50, typed 45, 100 and 0, no page scroll on a click, no sideways scroll,
  no left border stripes, every new string translated. With
  windowOwnCapOf returning 0 it fails.
- account-cap, gui-zh-tw, gui-ja, gui-de, i18n-keys pass; full GUI suite
  in both engines (483 files, concurrency 6): 4315 of 4323
  pass; the 8 that failed (library-servers-bulk, purpose-filter,
  routing-proxy, routing-zcode-blocked) touch no account row and all 32
  of their tests pass when run alone
- gofmt -l clean; go vet -tags nogui ./... on darwin, linux and windows,
  and windows without the tag for ., internal/gui, internal/provider,
  internal/gateway; go build ./...
- Not tried in the real window with a live gateway or real accounts.

Docs: gateway-routing.md (HoldCaps, window caps, tests),
providers-accounts.md (AccountWindowCaps, tests), reference.md.
2026-10-09 12:00:26 +08:00
yetone ee657c0969 usage: a Kimi Code membership's k3 is counted at Kimi's API price for kimi-k3, not ≈¥0.000, and kimi-for-coding is unpriced rather than free (#1370, maicent: Kimi Code 的模型计费都是 0)
Why it was 0: magpie already counts a subscription's calls at the API list
price (a Claude or Codex account at its maker's, cf6b82f7). A Kimi Code
provider is looked up in its own models.dev catalog first, and models.dev
lists every model of kimi-code-plan-global / kimi-code-plan-cn at $0 (what
the membership charges per token), so k3, k3-256k, kimi-for-coding and
kimi-for-coding-highspeed were all "priced" at $0. The same $0 catalogs
were also among the maker catalogs, so MakerPrice("k3") was $0 too.

What changed (internal/provider/models.go):
- membershipCatalogs (the two Kimi Code plan catalogs) are never read for a
  price: ListPrice, MakerPrice and PricedName skip them.
- A provider served at a Kimi Code catalog prices its model as the Kimi API
  model it is (kimiCodeAPI), per Kimi Code's docs
  (https://www.kimi.com/code/docs/en/, read 2026-10-09): k3 and k3-256k are
  K3 -> kimi-k3; kimi-for-coding-highspeed is K2.7 Code HighSpeed ->
  kimi-k2.7-code-highspeed. models.dev's canonical_model_id agrees. The
  prices come from moonshotai's catalog, which matches Kimi's pricing page
  (https://platform.kimi.ai/docs/pricing/chat: kimi-k3 $3 in, $15 out,
  $0.30 cached, $3 cache write; kimi-k2.7-code-highspeed $1.90/$8/$0.38).
- kimi-for-coding is K2.8 Preview, which the Kimi API doesn't sell: it has
  no API price, so it is now unpriced (the "—" / "no known price" the GUI
  already shows) instead of ¥0.000. The user can give it a price with
  `magpie model price kimi-code/kimi-for-coding …` or in Names & levels.
- The alias is scoped to Kimi Code providers: a relay's own "k3" keeps its
  own catalog price; Moonshot pay-as-you-go's kimi-k3, an OpenCode free
  model ($0) and Codex's maker price are unchanged.
- PricedNameFor(provider, model): the Requests list's "API price
  reference" names kimi-k3 for a Kimi Code k3 (internal/gui/usage.go).
Docs: reference.md (What a model costs) and providers-accounts.md.

Tests: TestKimiCodeListPrice (internal/provider; fixture
testdata/kimi_code_catalog.json is models.dev's own moonshotai and Kimi Code
plan entries) and TestKimiCodeCost (internal/usage; the reporter's k3 row,
240K in / 78.4K out / 10.0M cached, counted at $4.896 in the ledger and the
summary, kimi-for-coding unpriced, moonshot/kimi-k3 unchanged). Both fail on
the old models.go (every Kimi Code model priced at $0, MakerPrice(k3) $0,
k3 cost=0, unpriced 0); PricedNameFor's check fails with the alias turned
off ("k3-256k").

Run: gofmt -l (empty); go vet -tags nogui, GOOS=linux go vet -tags nogui,
GOOS=windows go vet -tags nogui on internal/provider, internal/usage,
internal/gui; go build, GOOS=linux go build -tags nogui, GOOS=windows go
build; go test -tags nogui -count=1 ./... under a temp HOME with pinned
caches: all ok; -race -count=3 on the new tests ok. No GUI asset changed,
so the Playwright suite wasn't run. Not tried with a real Kimi Code
account or the reporter's usage log.
2026-10-09 11:48:41 +08:00
yetone 5da6c24e80 agents, gui, cli: an agent can be shown only the models picked for it, so a model added later stays out of its list until ticked (#1337, nianlee-official)
nianlee-official asked for an agent whose model list grows only by hand:
today the Agents page's per-agent list hides models one at a time, and
every new model, of a new provider or an old one, shows up in the
agent's picker and config on its own ("New models are shown").

What changed:
- settings: PickedModels, per agent. An agent named in it is shown only
  the catalog entries listed for it (after Visible); HiddenModels is not
  read for it.
- provider: ModelOff(agent) is the one test CatalogFor, the Agents page
  count and Codex's own ChatGPT models (CodexNativeHidden) use, so the
  gateway's /v1/models for that agent's key, the lists magpie writes into
  its config and Codex's /model all narrow the same way hidden models do.
  SetOnlyPicked switches it on or off keeping what is shown as it is
  (on: what is shown becomes the picks; off: the listed models not
  picked become hidden). SetPickedModels keeps a pick whose provider is
  missing for now. renameModelPrefs moves picks with a provider rename.
  A model asked for by name still resolves against the full catalog.
- GUI: the list's foot "New models are shown" note is now a switch
  "Only models I pick", with a tooltip saying what each side does. On,
  a click sends what is ticked ({shown}), not what is hidden, so a model
  that arrived after the list was opened isn't picked by accident; the
  server still reads {hidden} from an older page. The model in use is
  always kept. Hide all / Show all's armed state takes the switch's room.
  zh, zh-TW, ja and de strings; the old note string is gone.
- CLI: magpie visible <agent> --only-picked | --show-new; magpie visible
  lists such an agent as "only models picked"; magpie models <agent>
  says how many are not picked, and what to do when none is.
- /api/settings keeps PickedModels like HiddenModels.
- docs/reference.md: the per-agent list bullet.
Not touched: #1302 (bulk agent visibility) and its files.

Run:
- go test -tags nogui ./... in a temp HOME: 44 packages ok.
- New: provider TestOnlyPickedModels, gui TestAgentModelsOnlyPicked,
  gateway TestOnlyPickedModelsList (opencode's key: a new provider's and
  an old provider's new model are left out of /v1/models, cursor's key
  has them, and a chat request naming fresh/x1 reaches the upstream) and
  TestCodexModelListOnlyPicked. With ModelOff made to ignore picks, all
  four fail.
- GUI: new agent-models-only-picked.test.cjs, Chromium and WebKit, en/zh/de,
  420 and 1100 wide (12/12). With save() sending {hidden} in pick mode it
  fails. agent-model*, agent-models*, codex-model-order, cursor-local-models,
  desktop-model-menu, click-scroll, gui-ja, gui-de, gui-zh-tw: 85/85.
- gofmt -l clean; go vet -tags nogui ./... on darwin, GOOS=linux and
  GOOS=windows; go build, and linux/windows builds.
- The built CLI in a sandbox HOME: visible codex --only-picked, a model
  added after it is left out of `magpie models codex` and kept in claude's;
  --show-new keeps it out and new ones show again.
Not tried: the real window against a live gateway, or a real agent
reading the narrowed config.
2026-10-09 08:50:14 +08:00
yetone 7d663a2769 providers, stats: count how often each partner is shown, opened, has its key page opened and is added, per day and capped, and send the days that have ended with the daily stats as "magpie partner" events; off with the stats or with what magpie is used with (yetone: 能否统计每个合作伙伴的展示数和点击数)
Ran: go test ./internal/provider ./internal/stats ./internal/gui ./internal/tui .;
partners.test.cjs (chromium + webkit), privacy-usage-stats, i18n-keys,
gui-ja, gui-de; GOOS=linux -tags nogui and GOOS=windows builds.
2026-10-09 01:40:02 +08:00
yetone ed31293c0e usage, gui: a reply its vendor let go in a burst over a couple of hundred milliseconds tells no speed, not 2,237 tok/s (John on Discord)
John's Kimi Code rows after v0.1.1119 (OpenCode, group/k3-glm, api.kimi.ai,
Chat, k3-256k): 550 tokens, 22 of them reasoning, in 16 s with the first
content at 5.2 s read 2,237 tok/s, and 395 tokens in 19 s 2,012. 528 answer
tokens at 2,237 tok/s is 236 ms: the burst the vendor let go at the end.
de5455b8 measured how long the content took to come (flow) and took the
window as no longer than it, so a burst sent in one write (flow ~1 ms) fell
under the 100 ms floor and told none, but a burst let go over 236 ms of
many events read its tokens over those 236 ms, under the 10,000 tok/s
ceiling. The time a burst takes to come is not the time it took to write.

usage.DecodeOf now tells no speed for a reply held back and let go at its
end, both ways the burst can show:
- its content came in under a quarter of its window (flow*4 < window):
  the text came early, the tool call it wrote over the rest of the time
  came at the end (window 10.6 s, flow 236 ms);
- it reasoned, and its answer came over 20 times faster than its
  reasoning came between its first content and its first text: the text
  came with the burst, so the window from it is the burst itself (22
  reasoning tokens over 10.5 s, then 528 in 236 ms). Hidden reasoning
  (OpenAI's, Claude's omitted) comes before the first content and leaves
  that time short; streamed reasoning comes at its answer's pace.
Between the two the window is the whole window again, not the flow.
routing.js's decodeOf and app.js's ledDecode do the same; docs/reference.md
says it.

Tests: TestKimiHeldBurstTellsNoSpeed, a fake Kimi Code (Chat: four
reasoning_content deltas, then text and a tool call whose arguments come
as 40 writes 6 ms apart over the last 236 ms; usage 538/550/22), through
the real gateway for a Chat, a Responses and an Anthropic agent, with the
wait shortened (first content 1.3 s, end 4 s): the held call and the held
answer tell none (before: 2,112 to 2,237 tok/s on all three), a steady
50 tok/s answer after the same reasoning keeps ~50. TestKimiHeldBurstAtJohnsTimes
feeds firstToken the same streams at John's times (5.2 s, 16 s): before,
2,138 and 2,237 tok/s. TestJohnsRowsTellNoSpeed: his rows as records
(before: 2,237, 2,237, 2,015). routing-burst-speed and usage-speed gain
his rows and a steady 50 tok/s one; they fail on the old routing.js and
app.js ("2237 tok/s", "2,237 tok/s") and pass now in Chromium and WebKit,
en and zh, with reasoning-speed, routing-request-metrics,
usage-ledger-detail, click-scroll, gui-ja, gui-de and i18n-keys.

Run: go test -tags nogui ./internal/... (golang:1.26.8 in Docker, as a
non-root user: as root, internal/agent's TestAside* write-refusal tests
fail because root ignores the file modes they rely on, unrelated to this);
-race -count=5 of the new and the burst tests; go vet (linux, windows,
nogui) on internal/gateway and internal/usage. Not tried against the real
Kimi Code API: the owner's Kimi Code key answers 403 (subscription has no
access), so how Kimi Code lets the reply go is inferred from John's
numbers; which of the two shapes his rows were is not known, and both are
covered.
2026-10-09 00:10:23 +08:00
易良 ae39bc8ec8 agents: Qwen Code (qwen) as a managed agent (#1311)
* agents: Qwen Code (qwen) as a managed agent (#1279)

Qwen Code (QwenLM/qwen-code, a Gemini CLI fork) keeps custom model
providers in ~/.qwen/settings.json modelProviders.<protocol> entries
({baseUrl, envKey, id, name}, envKey naming a variable in the same
file's env object) and its session model in settings.model
{name, baseUrl}.

magpie writes one modelProviders.openai entry per catalog model —
wire id the gateway takes, name magpie/<provider>/<model>, every one
on env.MAGPIE_API_KEY holding the gateway's key, which is how an entry
of magpie's is told from the user's own — and points settings.model at
a pick. Stash, Check/drift, Sync, port-move Rewire and disconnect work
as for the other JSON-config agents; on disconnect the user's entries,
model and key are restored, the file byte-identical to before.

Verified: go build, GOOS=linux nogui + GOOS=windows builds, go vet
./...; go test -tags nogui ./internal/agent (TestQwen* pass;
TestDriftUnwiredEveryAgent/qwen and TestPortMovesEveryAgent pass after
binding drift identity to the entry, not the address), go test -race
-count=3 -run TestQwen, go test . (TestAgentQwenDefault: set then
'default' restores the settings byte-for-byte). TestQwen fails with
'unknown agent' when the registration is reverted. Real use: a copy of
a live 341-line settings.json in a sandbox HOME — wire, then
'qwen default' left it byte-identical. Not run: GUI suite (no
gui/assets change); TestReasonixCLI* and TestPluginHTTP2TakesTheProxy
fail on main too (fixture/network), unchanged by this.

* agents: qwen honors QWEN_HOME, sets auth type, owns a qwen-specific key name, carries context windows (#1279 review)

- $QWEN_HOME stands for ~/.qwen, resolved as the other agents'
  (via place.getenv, appdir.Getenv; a distro's isn't read), and is in
  agentenv.Vars so the sandbox clears it
- wiring sets security.auth.selectedType openai so qwen skips the
  /auth prompt; the user's own (including a pre-existing "openai",
  told from magpie's by a stash marker) is restored on disengage
- envKey is MAGPIE_QWEN_API_KEY: a shell-exported or ~/.qwen/.env
  value wins over the settings' env, so the magpie-generic name is
  not used
- entries carry generationConfig.contextWindowSize from the catalog
- a settings.json without modelProviders reads as no models of anyone,
  instead of gjson's 'cannot use null as iterable'

Verified: TestQwen* incl. TestQwenAuthOpenAIOfTheUsersOwn and
TestQwenHomeOverride, TestAgentQwenDefault (CLI), -race -count=3;
unwired-drift and port-move generic tests pass. Real end-to-end:
dev gateway on 127.0.0.1:3999 in a sandbox HOME, real qwen binary in
a sandbox QWEN_HOME ran a prompt through the gateway (PONG), and
wire → default left the settings byte-identical. Reasonix fixture
tests fail on main too (environment).

* agents: qwen's requests are attributed by their QwenCode/<version> User-Agent
2026-10-08 20:33:11 +08:00
yetone 2cdae80a10 stats: the day's event no longer names an omp profile or a model the user named; an omp profile goes as omp, and on a key's provider a model id models.dev doesn't list (an Ollama model, a fine-tune with the user's organisation in it) goes as <provider>/other
169c23c1 cut only `@wsl:` from agent ids, and 78d90571 (four minutes
earlier) made each omp profile an agent `omp#<name>`, so the profile
name went to PostHog. modelLabel sent `<preset>/<model>` for any model
on a preset's provider, and on Ollama, LM Studio or a fine-tune that id
is the user's own text. docs/reference.md promised "No names". Model ids
on subscriptions and plugins are the vendor's and are unchanged; known
models on a key's provider still go by id (deepseek/deepseek-v4).

Red before: with the old usage.go, TestAgentLabel fails ("omp#acme-secret",
want "omp") and TestModelLabelKeepsTheUsersOwnModelIDs fails
("openai/ft:gpt-4o:acme-corp::abc123", want "openai/other";
"ollama/acme-internal-llm", want "ollama/other"); both pass with it.

Run in docker golang:1.26.8 (this Mac's syspolicyd is pegged and new
test binaries hang before start): gofmt -l, go vet -tags nogui on
stats/agent/catalog, GOOS=windows go build, go build -tags nogui, and
go test -tags nogui ./... — all pass except internal/agent's aside
chmod-0500 tests, which root ignores; internal/agent passes as uid 1000.
2026-10-08 20:24:57 +08:00
yetone de5455b8fe gateway, usage, gui: a reply whose vendor sent its first words and held the rest to send in one burst tells no speed, not 1,367 tok/s (John on Discord)
John's request (OpenCode, a group served by Kimi Code at api.kimi.ai,
Chat) showed 1,367 token/s: its tokens counted over the time from its
first token to its end, though nothing came in between and the rest came
at once. The 100 ms / 10,000 tok/s bounds catch a burst only when the
first content comes with it.

firstToken now reads on past the first text and marks when each content
event's bytes came (one mark a millisecond, up to 65,536 marks and the
8 MB it reads), and flowMs is how long the content took to come from
where the speed is counted: the span from a tenth of its bytes to nine
tenths, scaled to the whole. A steady stream gets about its whole window;
one let go in a burst about 1 ms. It is kept as Call/Try/Route .flow and
usage.Record.flow_ms (and the Requests page's packed rows), and
usage.DecodeOf, routing.js's decodeOf and app.js's ledDecode count no
longer a window than it. A record without it reads as before.

Verified: TestBurstTellsNoSpeed, a fake Chat vendor that sends a word,
waits 600 ms, then 199 words and the end in one write (1,000 tokens), for
a Chat and a Responses agent: no speed; before, 1636 and 1655 tok/s. Its
steady case (a word every 60 ms) keeps 200 tokens over most of its
window. TestCodexBackendBurstTellsNoSpeed is the same through Codex's own
ChatGPT backend (before: 1,000 tokens in 601 ms). TestFirstTokenKinds now
expects the stream read past its first text. Go tests ran on Linux
(golang:1.26 in Docker; this Mac's syspolicyd held new test binaries at
launch). GUI: routing-burst-speed (a route with John's 5.1 s first token,
6.7 s and a 2 ms flow: was "1367 tok/s", now none) and usage-speed (a row
with flow_ms 2 shows "—") fail without the JS change and pass with it,
with reasoning-speed, routing-request-metrics and usage-ledger-detail, in
Chromium and WebKit (en/zh, ja/de where the tests have them); no strings
changed. Not reproduced with Kimi Code itself: whether it holds the
stream so is inferred from the numbers.
2026-10-08 20:11:05 +08:00
yetone 7611c36e6f agent: wiring Alma also makes the model magpie draws with Alma's image generation model, when Alma has it on Auto (Sorghum on Discord)
Alma has one: Settings › Image Generation, settings.imageGen.model,
"<providerId>:<model>" like chat.defaultModel, "" for Auto (Alma's
src/services/settingsApiService.ts defaults). Its `alma image gen` and
in-chat image requests send an openai-type provider's model to
{baseURL}/images/generations, which the gateway draws with.

Choosing one of magpie's models for Alma now also sets imageGen.model to
magpie's provider and the model magpie draws with (gateway.Drawer: the
Settings' Image generation model, else AutoDrawer's), only when it is
Auto: a model the user picked, theirs or magpie's, is left. Sync leaves
an Auto alone (the user may have gone back to it) and only replaces one of
magpie's that magpie no longer offers. That model is listed on magpie's
provider with capabilityOverrides.imageOutput, as Alma's picker lists only
a provider's models that make images and shows another as "unavailable".
Going back to Alma's own model puts an image model of magpie's back to
Auto with the provider. Nothing changes when magpie draws with nothing.

Verified: TestAlmaImageGen against the fake Alma (settings kept whole,
aspectRatio untouched): Auto filled with p1:img/gpt-image-1 and the model
listed with imageOutput; the user's own pick and a magpie chat model kept
by Apply and Sync; Auto kept by Sync; a stale magpie drawer replaced; Auto
again on going back. It fails without almaSetImage ("image generation:
\"\", want \"p1:img/gpt-image-1\""). TestAlma checks nothing is written
when magpie draws with nothing. Go tests ran on Linux (golang:1.26 in
Docker). Not tried with the real Alma: Alma's settings page reading the
override as an image model, and an image drawn through it, are unverified.
2026-10-08 20:11:05 +08:00
yetone cf10394527 agent: alma-server on Linux is found and wired as Alma, so its requests show as Alma, not "ai-sdk" (Lutra.x on Discord)
alma-server (Alma without a desktop, Linux x64/arm64) keeps its data in
$ALMA_DATA_DIR, else $XDG_DATA_HOME/alma, else ~/.local/share/alma
(Alma's electron/headless/electron-shim.ts getDefaultUserDataPath), and
answers on the same localhost:23001. magpie looked only in the desktop's
folder (os.UserConfigDir()/alma), so on such a machine Alma's Sync never
ran, its magpie provider kept the old shared key, and the gateway read its
requests by their User-Agent alone, the AI SDK's
("ai-sdk/openai/4.0.15 ai-sdk/provider-utils/5.0.10 runtime/node.js/22",
@ai-sdk/openai 4.0.15 and provider-utils 5.0.10 as alma-server bundles
them; Alma sends no header of its own). almaDir now also looks in
alma-server's folders on systems other than macOS and Windows, the
desktop's first; ALMA_DATA_DIR is in agentenv's Vars.

Verified: TestAlmaServerKeyed (a Linux home with only
~/.local/share/alma and a fake Alma whose magpie provider has the old
key): Sync re-keys it to magpie-alma; XDG_DATA_HOME and ALMA_DATA_DIR are
followed, the desktop's folder wins, and macOS/Windows look only in the
desktop's. It fails without the change ("Alma's folder:
.../.config/alma, want alma-server's .../.local/share/alma").
TestAgentOf has alma-server's real User-Agent bytes: Alma with magpie's
Alma key, "ai-sdk" without. Go tests ran on Linux (golang:1.26 in Docker;
this Mac's syspolicyd held every new test binary at launch).
Not tried with a real alma-server, and an alma-server on another
computer, reaching magpie with a gateway key, still reads as the AI SDK:
its User-Agent says nothing of Alma.
2026-10-08 20:11:05 +08:00
yetone 169c23c18e stats: the day's event says which agents, providers and models magpie is used with, by magpie's own ids (a provider of the user's own, and its models, only "custom"), with how many of each and of routing groups: one "magpie uses" event per id in the same PostHog batch, so unique users break down by agent, provider and model. Settings → Privacy → Share the agents, providers and models I use turns it off (on by default; gone while Count me as a user is off); no names, URLs, accounts, keys or usage. go test ./internal/stats (payload, off switch, labels), the Privacy switch in Chromium and WebKit (en, zh), go test -tags nogui ./... in a temp HOME, go vet, GOOS=linux/windows builds pass; read against the real config: 9 agents, 24 providers, 25 groups in 0.75 s 2026-10-08 19:26:13 +08:00
yetone 0d55d9f6c2 provider, gateway: a Codex account at its usage cap is held from the next turn on: each ChatGPT reply's codex.rate_limits event and x-codex-* headers update the account's reading, and routing's reading ages from when it was made, not when routing took it (up to two minutes old before); the cap's tooltip says a turn under way can still pass it (#1295) 2026-10-08 19:14:17 +08:00
yetone 4b8bfd4676 gui: Routing's By session heads a Claude Code (and every other agent's) session with its own name, not its UUID (#1293, luzhuang)
Only Codex sessions were named (from Codex's index). The page now also asks
POST /api/gateway/session-titles for the other agents' sessions it lists,
as agent:id keys, and sessions.Titles reads each from the agent's own files
the way the Sessions page does: the name given in magpie, else the agent's
own title (Claude Code's ai-title / custom-title), else the first prompt.
Each key is asked once; the names are kept on the page, so a live update
that replaces the rows keeps them. A session with no file here keeps its id.

TestSessionTitlesNameOtherAgents fails without the change (titles = map[]),
and routing-sessions' "other agents' sessions show their own names" times
out waiting for the name without the routing.js change.

Run: HOME=<temp> go test -tags nogui ./internal/gui ./internal/sessions;
go vet for darwin, linux and windows; the 72 routing GUI test files in
Chromium and WebKit (491 pass, 0 fail). No new t() strings.
2026-10-08 18:49:16 +08:00
yetone fe405b23e2 gateway: a Codex on another computer reads magpie's models with a gateway key from GET /v1/codex/models, in Codex's own catalog shape, as its model_catalog_url; /v1/models stays OpenAI's list (#1281, jskorlol)
Codex decodes a model_catalog_url as ModelsResponse {models:[ModelInfo]}
(codex-rs model-provider/src/models_endpoint.rs), which /v1/models' OpenAI
list isn't, and /backend-api/codex/models relays to the ChatGPT backend,
which answers a gateway key (replaced by magpie's token) with 401. The new
route lists the models magpie shows Codex, as codexcat writes them for
model_catalog_json, kept to the key's models (keyAllowed, as /v1/models),
the ChatGPT account's own included (provider.CodexCatalog). Codex reads at
most 1 MiB of such a catalog (MAX_MODEL_CATALOG_BYTES); entries past it are
left out and counted in X-Magpie-Left-Out rather than failing the list.

Verified:
- TestCodexCatalogForAGatewayKey through lanGuard(Handler) from a LAN
  address: required ModelInfo fields, unique slugs, held key sees only its
  models, no key / wrong key 401, /v1/models unchanged. Without the route it
  fails "404 ... magpie serves /v1/chat/completions ..."; with keyAllowed
  removed it fails "held key: [plan/m1 spare/m2]".
- TestCodexCatalogFitsCodexsLimit: 300 models -> 127 in 1047147 bytes, 173
  left out. Fails 404 without the route.
- Real Codex CLI 0.161.0 (npm) and 0.160.1 in sandbox CODEX_HOMEs against a
  sandbox `magpie serve` (0.0.0.0:3591, LAN on, two gateway keys) over
  10.0.0.111 with a fake chat upstream: models_cache.json holds the catalog
  (held key: fake/fake-coder only; free key: both), no "Unknown model"
  fallback warning, codex exec answered PONG-1281 through /v1/responses,
  also with the reporter's requires_openai_auth = true. 0.160.1 needs
  [features] api_key_model_discovery = true.
- go test -tags nogui ./internal/gateway ./internal/provider in a temp HOME:
  ok. Full ./...: 44 packages ok (internal/agent had timed out once
  under load; alone it passes in 45s). go vet; GOOS=linux and
  GOOS=windows vet of gateway+provider and nogui builds.
Not verified: Codex's TUI /model picker driven by hand.
2026-10-08 18:46:28 +08:00
yetone 3d5b212f6a cli: magpie provider models <id> +model adds a model to those exposed and -model takes one out, the rest kept; the whole-list form still replaces, and now says so with the count before and after and how to add one instead (MOMO on Discord: 35 exposed became 8 with nothing said)
provider set models= says the same. Taking out a model that isn't
exposed, mixing +/- with plain ids, or leaving none exposed (which would
mean the default list) is an error that saves nothing.

Verified: TestProviderModelsAddsRemovesAndSaysWhatItReplaced fails on
the old code ("+deepseek-flash left [+deepseek-flash]") and passes with it.
2026-10-08 18:13:11 +08:00
yetone d2241a5798 agent: Copilot in JetBrains IDEs takes magpie's models as a Bring Your Own Key custom endpoint provider, written into the Copilot language server's byok.json beside the user's own (KamToHung on Discord) 2026-10-08 18:12:10 +08:00
wizzy-yangandwizzy-yang e5e87d980a 支持 Ante:把 magpie 作为 provider 写进 ~/.ante/catalog.json (#1210)
* agent: Ante takes magpie as a provider in ~/.ante/catalog.json

Ante (antigma.ai, AntigmaLabs/ante) is asked for in #1179. Its catalog is
~/.ante/catalog.json ($ANTE_HOME moves the folder), merged on top of the
providers it ships: a providers map keyed by the id --provider takes, each
with base_url, wire_style (AnthropicMessage, OpenAiCompatible, OpenAiResponse
or Gemini), an optional auth, and preferred_models, the ids its model picker
shows (docs.antigma.ai, reference/catalog-reference). magpie adds the entry
"magpie" at the gateway's /v1 on OpenAiCompatible, which is the wire style
Ante speaks Chat Completions with and what the gateway takes every model on,
and lists magpie's models in its preferred_models. The user's own providers
and the models section are left as they are, and magpie's entry goes again
when magpie steps out; a file magpie made goes with it.

No auth block is written. The gateway listens on loopback and lets any token
in from this machine (gateway.Token's own comment, lan.go's identifyCaller:
"Loopback takes any token"), and Ante counts a provider with no auth as
authenticated. A bearer env_key would mean putting the gateway's key into
Ante's environment, which the user would have to do themselves -- the same
decision Empryo's entry makes, which sends no key of the user's either. A
gateway shared on the local network is another matter, and the same one every
agent magpie wires has.

Two things are deliberately left off a model's entry. A routing group is not
told what it reasons at: which member answers, and what that member takes, is
the group's own to decide per turn, and magpieModels sets no APIs for a group
for the same reason. And support_vision is never written: Ante takes a model
as seeing images unless told otherwise, and what magpie knows is only as good
as a provider's own list -- a relay's models are often not described at all,
so writing false would have Ante drop the reader's images before the gateway,
which describes them to a model that can't see them itself
(provider.Described). No other agent magpie wires declares it either.

Ante ships macOS and Linux builds alone and suggests WSL on Windows, so the
Windows machine's Ante is usually the one in a distro: wslKinds gains it
beside Empryo, which also means the gateway address Ante is pointed at is the
one that distro reaches magpie on (place.base), and a distro not known to be
in mirrored networking gets the notice it already gets for the others.

TestAnte covers a user's own providers and models section kept as they are,
magpie's entry added and taken away, Sync repointing it, and a file magpie
made going again. TestAnteKeepsTheUsersFile covers a catalog magpie can't
parse costing magpie's provider and not the user's. TestAnteGroupSaysNoCapab
ilities covers a group declaring neither effort nor support_vision.
TestAnteEfforts covers the ladder: Ante's has no "none" or "minimal", its two
lowest, so both are its "min", and the rungs come out in Ante's order
whatever order magpie lists them in.

Tried with a real Ante 0.2.9 in WSL Ubuntu: magpie wrote the catalog, and
`ante catalog` then read 16 providers with magpie's among them, 39 models,
and Ante filled in support_vision: true for each itself. Ante then ran a real
turn through the gateway — `ante --provider magpie --model
group/auto-deepseek-v4-1-flash -p …` answered, and magpie's own usage log has
the request (ep /v1/chat/completions, req the group's id, served by one of its
members, out 10) — and so did a second model on another provider. The gateway
was reached over the Windows host's address from the distro (172.17.128.1),
this machine's own being on 127.0.0.1 alone, which is what notMirrored tells
the user about.

go vet, gofmt -l and go build (linux nogui, windows) pass. go test -tags
nogui ./internal/agent: 12 failures here, the same 12 on unmodified main (the
Windows ones AGENTS.md lists). ./internal/gui and ./internal/provider:
TestGatewayFixQuitsOnlyAMagpie, TestPurposeAssetMatchesUsage,
TestUpdateThatWontStart and TestLoginUsageClaudeAskedMeanwhile, all four on
unmodified main here too. docs/reference.md's adapter table gains Ante.

* agent: Ante keeps the user's own keys, reads ANTE_HOME, sends no effort it wasn't told to, and starts Ante on magpie (#1210)

Seven points from the review, each with a test that fails without it.

1. ANTE_HOME was in the file's own comment and in docs/reference.md but never read:
the path was fixed to $HOME/.ante, so a user who set it had magpie write a catalog
Ante does not look at. anteDir reads it through place.getenv, the way MiniMax Code
reads MINIMAX_DATA_DIR, so a distro's own ANTE_HOME - which magpie can't read -
still means ~/.ante there. "ANTE_HOME" joins agentenv.Vars so a sandbox clears it.

2. providers.magpie was written whole, so every key of the user's that magpie
doesn't write - the auth block a gateway shared on the LAN or reached from WSL
needs to send a key, http_headers, extra_body, stream_idle_timeout_secs,
thinking_display - went at the next sync, and an unchanged catalog was rewritten
every round. It goes through theirsKept and syncJSON now, as #1103 did for the
others: the user's keys in magpie's entry stay, preferred_models is magpie's list
alone, and a file that says the same as it does is left as it is.

3. The two files were written in separate steps, so a change that failed halfway
left Ante's catalog without magpie's entry and its settings still naming magpie.
Each field's Set and its Sync are wrapped in atomic(), as empryo, minimax and kimi
do.

4. The comment said Ante's "min" was the rung it sends no effort at, and folded
both magpie's "none" and its "minimal" into it. Ante's reference says what min is:
"OpenAI-compatible providers send the selected level as the same-named
reasoning_effort (min is sent as minimal)" - so min is a sent level, and magpie's
"none" has no rung of Ante's to fold into. Ante's "min" is magpie's "minimal"; a
model magpie knows only "none" of comes out as the empty list, which is Ante's own
way of saying it takes no effort setting at all.

5. The default was the highest rung the model takes, so every model magpie listed
ran at Ante's slowest and dearest level. No effort is written: "Omit for models
with no reasoning knob (Ante then sends no effort parameter and hides the effort
slider)".

6. Having magpie in the catalog never meant Ante used it. What a session starts on
is the provider and the model of ~/.ante/settings.json; with neither set Ante
"uses the first authenticated provider in catalog order", and Ante's own presets
come before magpie's entry - so while the user has any of those signed in, that is
the one picked and magpie is not. (Measured: nothing named in settings, two keyless
providers ordered aaa_other then magpie, and the request went to aaa_other's port;
settings provider=magpie and it went to magpie's.) magpie sets that pair now and
saves what it found there, so Disconnect hands it back; a provider the user picks in
Ante's own /providers while magpie is wired is theirs and stands, and Check says so
instead of keeping quiet. Ante's settings name the model apart from the provider, so
magpie writes the catalog id it put in preferred_models, un-prefixed, and reads a
bare id as magpie's only beside magpie's provider.

Tests lean on the package's own testenv, not a real ~/.ante.
TestAnteSyncKeepsTheUsersKeys is modelled on ad3b7fe5's
TestAirProviderWritesKeepCustomFields and goes both ways - sync and pick - and
wants an unchanged file to keep its mtime.

Tried with a real Ante 0.2.9 in WSL Ubuntu on this machine (Ante ships macOS and
Linux builds alone; there is no Windows Ante here). The strongest of these is the
whole of it: magpie's own Go code - ante(home).Connect(), then the model field -
wrote a catalog.json and settings.json into a temp folder, that folder was handed
to the real Ante as ANTE_HOME, and Ante ran a turn through it with no --provider,
no --model and nothing typed by hand.

- `ante doctor` read `home ok /tmp/ante-e2e` and `16 providers (1 authed), 108
  models` from ANTE_HOME, and magpie's entry came back with the base_url it was
  given: ANTE_HOME really moves the folder, and a base_url already ending in /v1
  is not doubled (the request went to /v1/chat/completions).
- What a session starts on is settings.json's provider, measured against two
  keyless providers each answering on its own port, so the logged port names the
  one Ante asked. Catalog ordered aaa_other then magpie, nothing named in
  settings: Ante asked 44331, aaa_other - the first authenticated in catalog
  order, exactly the review's "很可能选不到 magpie". settings provider=magpie: it
  asked 44220, magpie. settings provider=aaa_other: 44331 again, so a pick the
  user makes in Ante's own /providers overrules what magpie wrote, which is why
  stepping out leaves it rather than handing back the one magpie found. And with
  magpie the only authenticated provider, auto-detection does reach it - so the
  catalog entry is not useless, it is simply not enough while the user has any
  other provider signed in, which is every real install.
- What Ante sends for effort, from the same files: no reasoning_effort at all for
  a model with no effort written (item 5, as its reference says omitting it sends
  none); `--effort min` on a model sent `reasoning_effort: "minimal"` - Ante's min
  is magpie's minimal, exactly item 4; and a model Ante was told
  `"supported_efforts": []` for sent no reasoning_effort even when asked `--effort
  low`, which is what a none-only model now gets instead of a level that refuses
  it.
- The user's own keys inside magpie's entry are ones Ante reads, not decoration:
  `ante catalog` hands back the entry's auth, http_headers, extra_body,
  stream_idle_timeout_secs and thinking_display unchanged, and a bearer env_key
  there makes `ante doctor` count the provider as the one authed. That is item 2.
- The no-auth decision holds on Ante's side, isolated by doctor's counts: Ante's
  own presets alone read `15 providers (0 authed)`; a magpie with no auth block
  reads `(1 authed)`; the same magpie given a bearer env_key that isn't set drops
  back to `(0 authed)`; with the key set, `(1 authed)`. So an entry with no auth is
  authenticated and one that asks for a key the user doesn't have is not - writing
  an env_key, which the first version's comment considered, would have made magpie
  the one provider Ante refuses to auto-detect.
- Ante fills `support_vision: true` in for itself, as the PR's own note said, and
  a routing group declared with no capabilities at all still worked.

Not covered by that run: the stand-in server answered inside the distro, so this
proves Ante's config handling end to end, not a live magpie gateway relay (the
first commit's message records a real turn through one). Every probe used
ANTE_HOME into a temp folder; this machine's real ~/.ante gained no catalog.json
or settings.json.

What each fix failed without it, one test each: taking ANTE_HOME out ->
TestAnteHome "$ANTE_HOME ignored"; theirsKept out -> "the user's auth lost or
changed"; syncJSON out -> "an unchanged catalog was rewritten"; atomic out ->
"settings.json kept half of a change that failed"; the old min mapping ->
"anteEfforts([minimal high]) = [high], want [min high]"; a written default ->
"deepseek/pro: effort written"; settings never pointed at magpie -> TestAnte; a
model never written -> TestAnteModelField; Check left quiet -> "moved off magpie:
\"\""; handing magpie back over a pick the user made while wired ->
TestAnteKeepsWhatTheUserPickedWhileWired; never restoring the model ->
TestAnteConnectDisconnect "not put back"; Connect picking the model field instead
of magpie as provider -> "Connect left the user's model beside magpie's provider";
ANTE_HOME kept out of agentenv.Vars -> TestFolderVarsAreListed. Taking
preferred_models out of the whole list is the one mutation no test turns red on:
mergeTheirs cannot merge a JSON array (sync.go returns want for it), so the file
behaves the same either way and the argument is there for intent and for the other
keys, not for behaviour.

go vet ./... and go build ./... pass, as do GOOS=linux go build -tags nogui ./...
and GOOS=windows go build ./..., and gofmt is clean on LF content (this clone has
core.autocrlf and no .gitattributes, so gofmt -l reports every file).
go test -tags nogui ./internal/agent fails twelve tests, and an untouched worktree
of origin/main run the same command on this machine fails the same twelve, test for
test; ./internal/agentenv, ./internal/appdir and ./internal/edit pass, and every
Ante test passes at -count=5. The whole ./... was run on both: the failing sets are
alike, origin/main having one more, TestLoginUsageClaudeAskedMeanwhile, already
filed as #1228. None of those twelve is this change's, and none is fixed here.

* docs: agent-wiring records that Ante keeps the user's keys in magpie's entry and starts on settings.json's provider (#1210)

The two rules the review asked for, written where the repo keeps them - the same lines ad3b7fe5 (#1239) added for Air and 2d9db1b6 (#1242) for malformed configs. The Verification block gains the package's own Ante run, as #1239 added Air's.

---------

Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
2026-10-08 16:58:41 +08:00
yetone e52d1cb820 docs: README rewritten for what magpie does now — stat line, flow diagram, warm-up, context, image MCP, keep awake, agent updates, all 47 agents; "coding agent" is now just "agent"; per-agent notes moved to the reference 2026-10-08 16:36:33 +08:00
如是 1b8c33b5e2 provider: a routing group reasons when any member does, not only when all of them do (#950)
* provider: a routing group reasons when any member does, not only when all of them do

groupEntries started a group at Reasoning: true and intersected it with
each member's (e.Reasoning = e.Reasoning && thinks), so one member that
doesn't think made the whole group 不支持推理. Pi reads that off /v1/models
and turns /thinking off for the model: a routing group of
deepseek-v4-flash-0731-max (a Trae plugin model magpie read as one that
doesn't reason) with a DeepSeek that does lost reasoning for both.

The group now reasons when any member does. Images already worked this way
(#756: a text-only member doesn't turn every image away), and the gateway
fits a request's effort to the member that gets it (fitFor), failing over
when a member refuses the level (failEffort). What the group offers as
levels is not changed: a member known to take none still leaves the group
none (#597), so such a group is Levelless — a shape magpie serves — rather
than one that doesn't reason.

group_reasoning_test.go (6 tests) fails without the change:
TestGroupReasoningFromAMemberThatThinks, TestGroupReasoningLevels and
TestGroupReasoningQuietMemberKeepsTheRest report reasoning off.
TestModelsListReasoning, the /v1/models contract test, fails the same way
and is updated to the new semantics. go vet, go build, GOOS=linux
GOARCH=amd64 go build -tags nogui and GOOS=windows go build pass; go test
-tags nogui ./internal/provider ./internal/plugin pass under a temp HOME.
internal/gateway passes except TestPluginStreamStalledClientKeepsOther
RequestsMoving, which fails on this machine with and without the change.

* gateway, provider: a group's member that can't think is sent no reasoning, rather than a 400

A routing group reasons when any member does (#950, 533887c2), so an agent
asks a group for reasoning that a member of it may not think at all. The
effort reached every member, and some vendors turn a reasoning ask away
with a 400 on a model that can't think — the review's second point: the
comment said the gateway routes a reasoning request to the members that
take it, and nothing did.

The gateway now sends such a member no reasoning ask, in the agent's own
protocol: Chat's reasoning_effort and reasoning, Responses' reasoning (with
reasoning_effort, which some relays take), Anthropic's thinking when it
asks to think — thinking disabled is no ask and stays — and output_config's
effort alone, the rest kept. Everything else goes as the agent sent it, the
Responses effort-update history is skipped for it, and the trace's Effort is
"" (nothing was sent) with Fixed kept, so the user's own pin is still read.
Which member goes first is unchanged: the group routes as it did, and only
the body changes.

provider.Entry.Quiet is the judgment: magpie has a word on the model — its
vendor's or its maker's list, or its provider's own account list — and that
word says no reasoning. A model nothing speaks for isn't quiet: it is sent
the effort the agent asked for, as #597 leaves it. Nor is one that takes
levels, which includes a model the user gave levels of their own: those
take the effort the gateway fits to them. Direct requests outside a group
are untouched, as TestPluginThinkingOff pins.

TestModelsReasoningWithoutLevels (thinks_test.go) still expected
group/mixed false; the review's first point. It is updated with a comment
saying why the #402 expectation changes, and a group of two quiet members
keeps the "none of them thinks" case.

TestGroupNonThinkingMemberNoEffort (group_reasoning_test.go) fails without
the change: with the withoutReasoningAsk call disabled it reports "B: the
quiet member was sent map[... reasoning_effort:high]". TestEntryQuiet
(provider) pins the judgment, and TestWithoutReasoningAsk the field set per
protocol.

go vet ./..., go build ./..., GOOS=linux go build -tags nogui ./... and
GOOS=windows go build ./... pass. go test ./internal/provider,
./internal/catalog and ./internal/plugin pass whole; internal/gateway
passes in three chunks covering every test (TestGroup|TestFixed|TestFast|
TestModels|TestPluginThink|TestWithout|TestMember, TestEffort|TestResponse|
TestReasoning|TestQuiet|TestTrace|TestRoute|TestRules|TestRule, and the
rest), 43s + 36s + 790s. The whole suite in one process times out on this
machine in an unrelated test (TestRuleDecidesAtTurnStartAndHolds here,
TestRequestBodyProgressRenewsIdleDeadline on another run) — the base commit
533887c2 times out the same way, at 800s — so the chunks are what was run.
The GUI is untouched, so no Playwright run.

* gateway, provider: a member the user fixed at an effort keeps it, quiet or not

The review's remaining point: Quiet() rests on magpie's catalog or an
account's own model list, and what this PR is about is that word being
wrong — the Trae plugin gave no capabilities, and magpie read a thinking
model as one that doesn't. A member the user fixed at an effort
("provider/model:high", #189) is the user saying this model does think, so
magpie's word doesn't override theirs: the gateway sends the fixed member
its effort and strips the reasoning ask from the others as before.

quiet := isGroup && c.effort == "" && quietTo(c.p.ID, c.model). c.effort is
only ever the member's own fixed effort (Member.Effort, planLevel), never
the agent's, a group's Group.Levels, the turn's pick or the Settings' — so
those are stripped as they were.

TestGroupNonThinkingMemberNoEffort's E is now the other way: the fixed quiet
member is sent reasoning_effort "high", and the trace's Effort is "high"
with Fixed "high". Dropping the c.effort == "" guard makes it fail with
"E: the fixed member was sent map[... model:plain]". quiet.go and
Entry.Quiet say why.

gofmt, go vet ./..., go build ./..., GOOS=linux go build -tags nogui ./...
and GOOS=windows go build ./... pass. The review's own set passes —
go test -count=1 -run 'TestGroup|TestWithoutReasoningAsk|TestEntryQuiet|
TestModelsReasoning|TestModelsListReasoning|Thinks|TestPluginThinking'
./internal/provider ./internal/gateway — and so does internal/provider whole
(208s). internal/gateway passes in chunks (Test[A-D] 340s, Test[S-Z] 216s,
Test[^A-DS-Z] split over TestEffort|TestResponse|TestReasoning|TestQuiet|
TestTrace|TestRoute|TestRules|TestRule|TestFixed|TestFast 38s and the rest
earlier): the whole suite in one process still hangs here on an unrelated
test (TestCodexBodyReadsPastOldLimit this time, TestRuleDecidesAtTurnStart
AndHolds and TestRequestBodyProgressRenewsIdleDeadline before), which the
base commit does too and which passes alone in 0.9s.
2026-10-08 13:47:13 +08:00
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