Commit Graph
87 Commits
Author SHA1 Message Date
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
forever fb9d1a4cc6 provider, plugins: a sign-in reads as done only once its account is listed again, and the command line's plugin sign-in brings the account back and takes its lapsed mark off (#1061)
* provider: a sign-in reads as done only once the account it brings back is listed again, so the window opens it rather than saying magpie can't list it (TestClaudeTeamSeatBesidePersonal failed so on macOS in #1046's CI)

Signing in brings back an account removed from magpie (ShowAccount clears
its Hidden mark). Since 440e535b, (*signInFlow).finish recorded the outcome
and let go of the sign-in's lock before it called ShowAccount, so a poll of
SignInStatus could read done while the account was still hidden. The
window polls every 800ms and, seeing done, looks for the account to open;
missing, it says "{user} signed in, but magpie can't list it — please
report this". #1046's macOS job (run 37502933081) hit the same gap:
  signin_team_test.go:52: account after sign-in: false []
WaitSignIn, which the command line uses, waits for the end of finish and
was not affected.

finish now marks the sign-in finished first. Only the first finish records
an outcome, as before: a cancel or the timeout that comes after it is
refused, and so is a callback (claim). A done sign-in then calls
ShowAccount, and only after that reads as done. ShowAccount's error is
ignored, as before: if providers.json can't be written, the account stays
hidden and the sign-in still ends done. Until then the sign-in reads as
waiting; that takes longer only when the account was hidden, since only
then does ShowAccount write providers.json (and sync the agents' catalogs,
as it did inside finish before).

Every built-in and plugin sign-in's done goes through finish, so this one
change covers them all. The other places that show an account again do it
before their result can be seen: PluginAPIKey returns after ShowAccount,
and ImportLogins and ImportGoogleAccounts call it before they return. The
command line's own plugin sign-in never showed the account again; that is
a change of its own. ImportFactoryKeys shows none either, but the window
offers a Factory key's import only for a Factory it lists, and a removed
one is left out of the Add sheet. providers-accounts.md's sign-in row now
says when a sign-in reads as done.

Verified: TestSignInDoneOnceTheAccountIsBack records what the sign-in reads
as at the moment providers.json shows the account again (catalog.Changed),
for magpie's own callback (Codex), Claude Code's login run by magpie
(Claude) and a plugin's (FakeCo, with Bun). With finish from main all three
fail with "the sign-in read as done before its account was shown again:
the window, seeing it done, can't list it"; with this they pass.
TestSignInCanceledWhileTheAccountComesBack cancels the sign-in at that
moment and checks it still ends done, the account listed; with this finish
guarded on the state alone, as main's was, it fails with a "close of
closed channel" panic in the callback. With a 300ms sleep where main's
finish lets go of its lock, TestClaudeTeamSeatBesidePersonal fails 10 of
10 runs, each in a process of its own, with #1046's message; main as it is
passes 10 of 10 here, the gap being that narrow. With the same sleep after
this finish publishes done, or before its ShowAccount, it passes 10 of 10.
Under a temporary HOME with Bun 1.4.2, -race -count=20 on those three
tests and -race -count=2 on the package's sign-in, plugin and login tests
pass, as do go vet and the darwin, GOOS=linux (nogui) and GOOS=windows
builds; this commit alone builds and passes internal/provider.
With the next commit, go test -tags nogui ./... passes on main at 443f1cbc
and at 0bf9d77d. Three earlier full runs failed, each only in
internal/plugin, which is unchanged and imports nothing changed here:
twice TestHostJSCancellation/ready-wait at its 10-second Node deadline
(signal: killed) while other sessions' tests loaded the machine, the
timeout 76dc5aed and #1036 also record, and once, with Bun 1.3.13,
TestPluginHTTP2TakesTheProxy.
The GUI suite was not run: nothing in internal/gui/assets changes, and its
tests answer /api/signin from fixtures. Not tried with a real vendor's
sign-in.

* plugins: magpie plugin login and magpie accounts add finish a plugin's sign-in as the window does, bringing back a provider removed from magpie and taking the account's lapsed mark off

The window's sign-in to a plugin's provider, once the plugin has saved
the account, takes the account's lapsed mark off and shows again the
agent's own account removed in magpie (clearPluginLapse), and brings the
provider back if it was removed from magpie (ShowAccount): pluginDone
through finish, and PluginAPIKey for a key. The command line's
(pluginLogin, which magpie plugin login runs, as magpie accounts add does
for a plugin's provider or a subscription moved onto one) said "✓ signed
in to …" and did none of it:
- a provider removed from magpie stayed out of magpie providers, the
  agents' catalogs and routing until it was added back (in the window, or
  with magpie provider add);
- an account signed in again after its vendor turned the sign-in away
  still said "sign in again" until a request or a usage read went through
  it;
- an agent's own account (Kiro's, Devin's, …) removed in magpie stayed
  hidden after it was signed in to again, though the window's sign-in
  shows it again (#320).

PluginSignedIn now does all three for a sign-in magpie doesn't follow
itself, and PluginAPIKey and pluginLogin call it. providers-accounts.md's
sign-in row says so.

Verified: TestAccountsAddBringsBackARemovedPlugin signs in to FakeCo, the
plugin host's test plugin, with a key, removes it from magpie and runs
magpie accounts add fakeco with another key.
TestPluginLoginTakesTheLapseOff signs in with magpie plugin login's
browser way (open and xdg-open stubbed), has a usage read mark the
account lapsed as FakeCo's answer says, and signs in again. With
plugins_cli.go from main they fail with "signed in again, but not listed:
no provider "fakeco" — magpie providers lists them" and "signed in again,
but still lapsed: [{… Lapsed:gone@fake's FakeCo sign-in has expired —
sign in again …}]"; with this they pass. With PluginSignedIn's
ShowAccount taken out only the first fails, and with its clearPluginLapse
taken out only the second. Neither covers the own account: FakeCo has
none, and no test covers that part of clearPluginLapse on main either.
Under a temporary HOME with Bun 1.4.2, -race -count=5 on the two and on
TestAccountsOfAPlugin pass. Like TestAccountsOfAPlugin, the two are in
the root package, which has no testenv.Offline, so the plugin host they
start asks models.dev for its catalog.
go test -tags nogui ./... passes on main at 443f1cbc and at 0bf9d77d. Two
earlier full runs failed only internal/plugin's
TestHostJSCancellation/ready-wait at its 10-second Node deadline (signal:
killed) while other sessions' tests loaded the machine, the timeout
76dc5aed and #1036 also record; internal/plugin is unchanged and imports
nothing changed here.
Not tried with a real plugin's sign-in.
2026-10-07 06:24:24 +08:00
yetone 54b4e36dcf providers, gui: a custom provider saved as OpenAI Responses opens as OpenAI Responses, not OpenAI compatible (01huadalang on Discord: 我选 response 保存了然后再打开一会就变成 openai 兼容了)
The editor never saved which API the Base URL was picked as; reopening
showed the first API with a URL, chat first. A provider added as OpenAI
compatible keeps its chat URL when Responses is picked (#105), so after
Responses was picked, its URL typed and saved, it opened as OpenAI
compatible again. Picked with no URL of its own, the Save sent nothing
for it and the pick was gone without a word.

Now the pick is saved with the provider (baseAPI), sent back to the
editor and shown while it has a URL; a save that doesn't say keeps it,
and one whose URL is cleared elsewhere drops it. A Save with the picked
API's URL empty while another has one says so in the form. Requests are
unchanged: they go by which URLs are set.

Verified: reproduced on origin/main in a sandbox HOME with the built
binary (magpie web, real GUI via Playwright, fake Responses-only upstream):
a provider added as OpenAI compatible, then saved as Responses with and
without its URL typed, reopened as OpenAI compatible. With the change it
reopens as OpenAI Responses after a reload, a 65s wait and an app
restart, and the empty Save shows the message. A provider added as
Responses from the start was already kept and still is.
TestProviderBaseAPIKept fails without the change ("added as OpenAI
compatible, shown as \"\""); provider-base-api.test.cjs fails on the old
app.js (actual 'OpenAI compatible', expected 'OpenAI Responses') and
passes in Chromium and WebKit, en and zh. gui-ja, gui-de, i18n-keys and
the provider*/system-one/azure/add-sheet/menu-scroll GUI tests pass in
both engines. go vet, GOOS=linux/windows builds, go test -tags nogui ./...
pass under a temp HOME.
2026-10-07 06:14:20 +08:00
yetone 6f65ffb5ff agent: CodeBuddy Code, Cline, Gemini CLI, AtomCode, Goose and Reasonix installed in a WSL distro are found and wired there (lgtm on Discord: magpie 现在无法检测到安装在wsl中的codebuddy呀?)
wslKinds had none of these CLIs, so each was set up only on Windows.
Each now has a place-aware constructor (codebuddyIn, clineIn, geminiIn,
atomcodeIn, gooseIn, reasonixIn). It writes the distro's own config with
the gateway as the distro reaches it (mirrored or NAT) and the key it
takes from there. A distro reads none of Windows' variables
(CODEBUDDY_CONFIG_DIR, CLINE_DIR, APPDATA...). Kinds are found by their
folder or their command. Gemini CLI is found by its command alone,
because Antigravity keeps ~/.gemini too. Goose is found by its folder
alone, because a goose command can be pressly's. Reasonix also needs a
native version (new wslKind.accept). Gemini CLI also counts a .env that
names an old address with magpie's key as magpie's own. Before this, a
WSL restart that moved the distro's address made it read "custom", and
picking the model again stashed magpie's old address as the user's.

Verified:
- On the Windows test box (WSL2 magpie-desktop, NAT), with a sandboxed
  nogui magpie.exe (own USERPROFILE/APPDATA, port 3441), real CodeBuddy
  Code 2.161.4, Gemini CLI 0.63.0 and Cline CLI 3.0.68 were installed in
  the distro. `magpie ls` listed each as "· WSL magpie-desktop".
  Picking relay/glm-4.6 wrote ~/.codebuddy/models.json, ~/.gemini/.env +
  settings.json and ~/.cline/data/settings/providers.json in the distro
  at http://172.17.32.1:3441, and wrote nothing in the sandbox home's
  agent folders. The installs were removed afterwards.
- Not tried for real: AtomCode, Goose and Reasonix in a distro, and a
  chat request from a WSL CLI through the gateway.
- Tests: gofmt; go vet ./...; -race on internal/agent; the full
  `go test -tags nogui ./...`; GOOS=windows and GOOS=linux builds.
  TestWSLProbeFindsMoreAgents and the rest of TestWSLMore* panic with
  "no WSL agent codebuddy" on the old wsl.go.
  TestWSLGeminiAddressMoved fails ("auth custom") without the gemini
  routed change.
2026-10-07 05:37:27 +08:00
yetone d5468283bb gateway: a web page that isn't listed in Settings, on this computer or the gateway's own address is refused 403 on every route
On loopback a call needs no key and the body's Content-Type isn't
checked, so any page the user visited could send a "simple" text/plain
POST to 127.0.0.1:3425 and spend their subscriptions without reading the
reply. corsGuard now refuses a request whose Origin is an http(s) origin
that is not on Settings → Network and sharing → Web pages, not loopback
(localhost, *.localhost, 127.0.0.0/8, [::1], any port) and not the
request's own (host:port equal to Host). usemagpie.ai still reads
/v1/magpie/omarchy. No Origin (agents), "null" and non-http schemes
(app://, file://, tauri://, vscode-webview://, chrome-extension://) pass
as before: those clients could only call before by not enforcing CORS,
and no web page can send those origins. Such a page never read a reply
before (no CORS headers), so nothing that worked is refused.

Verified: TestForeignPagesRefused (text/plain POSTs from https://evil.example
and other foreign origins on every route family: /v1/chat/completions,
/v1/responses, /v1/messages, count_tokens, /v1/systemone, /v1beta,
/v1/embeddings, /v1/rerank, images, videos, the Codex backend path, /mcp,
/_magpie/claude-mcp, /v1/magpie/*, /v1/models, the GET /v1/responses
upgrade, OPTIONS; loopback, *.localhost, listed with a key, same-origin
on a LAN address through lanGuard, no Origin, null, app://, file://,
tauri://, vscode-webview://, chrome-extension:// answered) fails without
the change (120 routes answered, the fake vendor reached 139 times).
go test -tags nogui ./... green; -race -count=10 on the CORS tests;
darwin, linux nogui and windows builds. The built binary served from a
sandbox HOME on 127.0.0.1:3591: curl from the foreign origins got 403 on
every route with the vendor untouched; the allowed origins got 200.
2026-10-07 04:31:03 +08:00
yetone b234507842 gateway, settings, gui: web pages the user lists in Settings › Network may call the gateway from a browser, each request with a gateway key (SkyAerope, #1051)
A page on http://localhost:3000 asking /v1/models got a 404 to its
preflight and no Access-Control-Allow-Origin, so the browser hid every
answer. Settings › Network › Web pages now takes origins (cleaned to
scheme://host[:port]; "*" and anything that isn't an origin are refused,
said in the page's language). For a listed origin corsGuard answers the
preflight (204, the asked header names, Private Network Access) and adds
Access-Control-Allow-Origin; every other request from it needs an enabled
gateway key, even from loopback, or gets a readable 401. Default: none
listed, and a request with any other Origin or none (every agent's) is
unchanged. A save of the rest of the Settings page keeps the list.

Verified: TestCORSOrigins (gateway, loopback requests: preflight, keyed
calls, keyless/managed-token/turned-off key refused, unlisted origins incl.
"null" and port/scheme variants get nothing, agents untouched) fails
without corsGuard in Handler (preflight 404); TestCleanOrigins;
TestCORSOriginsSetting fails without keeping CORSOrigins in POST
/api/settings; TestSettingsSaveKeepsWhatItDoesNotSend. GUI
cors-origins.test.cjs (en/zh, 440px, add by button and Enter, refusals,
remove, no scroll, no left border) in Chromium and WebKit fails without
renderCORS; gui-ja, gui-de, i18n-keys, gateway-port, lan-docker,
redact-rule-row, settings-* pass in both. go test -tags nogui ./... green;
-race -count=5 on the new tests; darwin, linux nogui, windows builds.
click-scroll's two Requests-page subtests fail the same on origin/main.
2026-10-07 03:34:05 +08:00
yetone 0c08b1d5bb agent: Claude Code's tier and subagent models the user set for their own endpoint come back with it when magpie steps out (#1050)
They were stashed nowhere: Connect took ANTHROPIC_DEFAULT_*_MODEL,
ANTHROPIC_SMALL_FAST_MODEL and CLAUDE_CODE_SUBAGENT_MODEL over and
Disconnect or Claude Code's own model deleted them, giving back only the
base URL and token.
2026-10-07 02:25:49 +08:00
yetone c44698bdaa agent: a model given to one of Claude Code's tiers or its subagents stays theirs when the main model moves onto it and on again (#1050)
#1050's sonnet tier and subagents were given Sonnet 5.5 with the main
model on Opus 5.5. Moving the main model to Sonnet 5.5 made the two read
as following it, so its next move took them along: back on Opus 5.5,
ANTHROPIC_DEFAULT_SONNET_MODEL said Opus and CLAUDE_CODE_SUBAGENT_MODEL
was gone, the pick lost without a word. set now marks a tier or the
subagents whose own model the new main model is (ownKey, the mark a tier
already got when the main model was picked for it where it would follow
another), in magpie and through /model's Follow alike. While marked they
read as that model, not "same as model"; "same as model" picked for them
takes the mark away and they follow again.

TestReseatOff expected the sonnet tier given ds/gpt-5 to read empty
after the main model was reseated onto ds/gpt-5; it now keeps ds/gpt-5.
The #1050 drift tests leave the marks out, as a magpie before this left
the state.

TestClaudeOwnTierStaysThroughTheMainModel (magpie and /model) fails
without the change ("on the main model, the tier and subagents read as
their own": sonnet and subagent empty), as does TestReseatOff with the
old expectation flipped. go test -race ./internal/agent ./internal/profile
pass; -race -count=20 on the Claude, WSL and reseat tests passes.
2026-10-07 02:25:49 +08:00
yetone 99f07249e8 agent: Claude Code's tiers and subagents following a main model moved onto the model magpie gave them aren't "changed outside magpie" (#1050, cjyrainbow: Claude Code · WSL Ubuntu 已在 magpie 之外换掉 claude/claude-sonnet-5-5[1m])
With the sonnet tier and the subagents on Sonnet 5.5 and the main model
on Opus 5.5, moving the main model to Sonnet 5.5 in Claude Code's own
/model left the two following it: they read as empty, and Drift took
that for the agent's default replacing the Sonnet 5.5 in applied.json.
Drift now reads an empty field that follows another (Follows) as on that
field's model, so the usual rules apply: a move between magpie's models
isn't drift, the main model taken off magpie still is. The subagents
field now says it follows the main model, and a stopped WSL distro's
agent keeps Follows too.

TestClaudeFollowerOnTheMainModelIsNoDrift and
TestWSLClaudeFollowerOnTheMainModelIsNoDrift (running and stopped
distro) build #1050's record through Apply and the switch through
/model + Follow. Without the change both fail with
"replaced Field:sonnet Now: Want:anth/claude-sonnet-5-5[1m]"; without
the wsl.go line the stopped distro still does. go test -race
./internal/agent ./internal/profile pass.
2026-10-07 02:25:49 +08:00
forever 89b3a20f5d gui tests: a test run leaves no gui.test folder in the real ~/Library/WebKit or ~/Library/Caches (waitingsnow) (#1047)
TestTrayCellClickReleasedPanel shows its panel in the test binary run
again as a child. The package's TestMain gives the tests testenv's HOME,
but WebKit takes the home from the account's record, so every run wrote
the child's WebKit data to ~/Library/WebKit/gui.test and its caches to
~/Library/Caches/gui.test in the real home.

The child now starts through runAppKit (appkit_darwin_test.go). It sets
CFFIXED_USER_HOME, which CoreFoundation reads before the account's
record, to a temporary folder, so WebKit's data and caches go there. It
runs the binary under a name of its own, through a hard link: WebKit
names its folders after the process, whose name comes from the file, not
argv[0]. The test fails when a folder of that name is left in the real
~/Library/WebKit or ~/Library/Caches. The empty folders WebKit makes
under the name in the account's temporary folder (NSTemporaryDirectory,
which TMPDIR doesn't move) are removed. Wails v3 beta.24 offers no
non-persistent WKWebsiteDataStore: its windowNew makes a plain
WKWebViewConfiguration, which uses WebKit's default store.

CFFIXED_USER_HOME doesn't move the account's settings: cfprefsd still
serves the global domain and com.apple.universalaccess, where dark mode
and Increase Contrast are kept. A probe window read the same appearance,
Increase Contrast, Reduce Transparency, layout direction, languages and
non-default values (AppleShowScrollBars, AppleReduceDesktopTinting, the
zoom factor) with and without it, on macOS 26.7.1. Nothing is written
to the temporary home after the child exits.

Without CFFIXED_USER_HOME the test fails:

    trayusage_panel_darwin_test.go:20: the windows' process left /Users/wei/Library/WebKit/gui.test-1791307161567260000 in the account's real home
    trayusage_panel_darwin_test.go:20: the windows' process left /Users/wei/Library/Caches/gui.test-1791307161567260000 in the account's real home
    --- FAIL: TestTrayCellClickReleasedPanel (0.45s)

With it, it passes, and nothing new appears in the real ~/Library or in
the temporary folder.

PR #1042's TestMainWindowCorners and TestMainWindowRightToLeft, moved
onto runAppKit at its head f89991b1, pass and print the same with and
without CFFIXED_USER_HOME: corners 16.00pt (plain 16.00, toolbar
26.00), lights [[26 26] [49 26] [72 26]], title bar 66pt, a sheet 66pt
below the top, right to left lights [[874 26] [851 26] [828 26]].
Without it, both fail the same way.

go vet ./..., go build ./..., GOOS=linux go build -tags nogui ./...,
GOOS=windows go build ./..., go test -tags nogui ./..., go test
-count=1 ./internal/gui/... and gofmt -l pass under a temp HOME with
Go's caches pinned. docs/subsystems/gui-shell.md says it. Tests only.
2026-10-07 02:23:19 +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 de2c84ace7 gateway: a Responses relay that turns away reasoning with nothing sealed in it, naming only the input, is asked once more without it, and isn't sent it again for that model (#1044: Codex's /compact through Anyrouter failed with 400 invalid_request_error param=input on old conversations)
Codex hands back reasoning items with no encrypted_content (an rs_ id and
a summary, as magpie's translated replies give it). #1008 left them out
only for OpenAI's API and Azure OpenAI. A relay in front of OpenAI turns
them away the same way but with its own message ("bad response status
code 400", param "input"), so the compaction, which is relayed as it is,
failed. They are not left out for every other upstream: a DeepSeek model
behind a Responses relay wants its reasoning back (#388), and magpie's own
translator reads the summary as thinking.

passthrough now, on a 400 to a Responses request whose error names the
input (refusesInput: param input / input[…], or an item named by id),
asks once more without the bare reasoning (withoutBareReasoning), sealed
reasoning and every other item kept; once that works the provider and
model are remembered (bareReasoningRefused) and later requests go without
it at once. Any other 400 goes back as it came.

TestBareReasoningLeftOutWhereRefused (the reporter's item kinds: message,
function_call(_output), custom_tool_call(_output), two bare reasoning items
and a sealed one; the relay's 400 word for word) fails without the
gateway.go change for both the compaction and a relayed turn (400 back to
Codex) and passes. TestBareReasoningKeptWhereTaken and
TestBareReasoningKeptOverAnotherRefusal guard the upstreams that take it
and other 400s; TestAzureCompactionWithoutMagpiesReasoning (OpenAI/Azure
unchanged) still passes. -race -count=5 on these, TestCodexCompact* and
TestImageTool*; go test -tags nogui ./internal/gateway ./internal/provider,
go vet ./internal/..., gofmt, linux and windows (nogui) builds pass. Not
tried against Anyrouter itself (no account); the 400 is from the issue's
capture.
2026-10-07 01:34:57 +08:00
yetone 698da36f31 gateway, providers, gui: a model on this computer or the local network can be set to get requests unmasked by Settings' redaction (lc on Discord: 本地模型可以不脱敏)
The provider editor has a Redaction row, "Send requests unmasked", while
every address of the provider is on this computer or the local network
(localhost, *.local, a loopback or private IP): Ollama, LM Studio, a vLLM
box. Ticked (provider.Unredacted; CLI `magpie provider set <id>
unmasked=yes`), a request whose provider and fallbacks, or whose routing
group's models, all skip redaction (provider.SkipsRedaction) goes to them
as the agent wrote it; anything that may reach another provider stays
masked for all of them. An address edited to a vendor's masks it again; a
signed-in account, a plugin's provider and another magpie never skip. The
log and recent calls keep the masked body. count_tokens and embeddings
follow the same rule.

It is a per-provider choice, not automatic for local addresses, because
a local address isn't a local model: a relay run locally (new-api,
CLIProxyAPI) or Ollama's cloud models pass the request on to a vendor.

Verified: TestLocalUnredactedProviderGetsTheRequestWhole (set, unset,
fallback to a vendor, fallback to another local one, a group of it alone,
a group with a vendor; chat and count_tokens) and TestLocalUnredactedEmbeddings
fail without the gateway change ("sent whole false, want true: … my key is
{{API_KEY_…}}"; count_tokens alone: "counted whole false"); TestSkipsRedaction;
TestProviderSaveKeepsUnredacted fails without the preset save line.
provider-unmasked.test.cjs (Chromium+WebKit, en/zh, 900 and 440 wide) 32/32,
3 of 4 fail on the old app.js; gui-ja, gui-de, i18n-keys, provider-search
and the other provider editor suites pass. go test -race on gateway,
provider, redact, gui; -race -count=10 on the new tests; go vet,
GOOS=linux/windows builds. Real: a sandbox gateway in front of the local
Ollama (llama3 8B) through a logging proxy: unset, Ollama was sent
{{API_KEY_…}}; with unmasked=yes it was sent the key as written, and
answered both times.
2026-10-07 00:33:15 +08:00
yetone 92469ee9f9 library, gui: a check for updates lists the skills a repository added beside the library's skills from it, to Add or Ignore (Raven on Discord)
Raven: "check for updates" only updated skills already in the library; when
dontbesilent2025/dbskill or mattpocock/skills adds a skill, magpie never said.
CheckSkills now asks GitHub's git trees API once per repo@ref (ETag-cached, a
304 is free) and only when a skill folder under the installed skills' common
folder (newBase) is neither in the library nor already offered does findNew
fetch the tarball for names and descriptions. library.json's seenSkills keeps,
per repo@ref, the paths the install picker showed and the ones set aside, so
an offered skill is offered once. The Skills page shows them under "Also in
their repositories" with Add (to the agents that have the repo's other skills)
and Ignore, plus Add all / Ignore all (/api/library/skills/{add-new,ignore-new}).

Verified: TestCheckSkillsFindsNewOnes (fake repo: nothing offered after
install, no tarball on a second check; two added skills offered, a nested one
and a .wip one not; Add installs with the repo's agents; Ignore keeps it out
on the next check via 304; remove clears) fails without the change (no
NewSkills / findNew) and passes -race -count=10; TestNewBase. A real run in a
sandbox HOME against GitHub: tdd from mattpocock/skills/skills/engineering
offered its 19 siblings; dbs from dbskill offered nothing (the picker showed
all), 33 with seenSkills wiped, and Add installed dbs-action. GUI:
skill-new-in-repo.test.cjs (Chromium+WebKit, 860/440px, en+zh) fails on
main's library.js and passes; gui-ja, gui-de, click-scroll, library-items,
every-skill, pick-skills, import/remove-all-skills, skill-groups,
skill-repo-groups, github-token, library-all pass. -race on library, gui,
tui; gofmt, vet, darwin/linux/windows builds.
2026-10-07 00:26:54 +08:00
yetone a7e7fe2b0f library: switching a group of skills on or off no longer reads every copy every agent has (#1031, mintonight: 开关一整个技能组时很卡,要10多秒才能完成)
The page already sends one request for a group (agents-some), but its one
change ran a sync that, for every skill given as a copy (every WSL agent,
Windows without the right to link, agents set to copies), hashed the copy
and the library's skill twice, in every agent, on every change, and the
page's read hashed the copies once more. Over a WSL distro's share that is
every file of every skill read through 9P each click.

A copy's mark now keeps the hash of what was copied (markCopy). A copy
whose mark says the library skill's current hash, with nothing in it newer
than the mark, is current without its files being read (copyIsFresh);
takeEdits' walk of a copy is not done a second time in the same sync, and
each library skill is hashed once per change (libHash). Copies with an old
mark, or edited since, are hashed as before. Claude Desktop's copies and
project copies use the same check.

Measured in a sandbox (55 skills x 31 files, copies for 10 agents, a
5-skill group switched on for an 11th): the change went from 1.54s to
0.31s and the page's read from 0.66s to 0.25s on a local SSD; the copies
not touched are no longer read at all, which is the cost over WSL.

TestSyncDoesNotReadUnchangedCopies fails without the change ("codex's
copy of pdf was read and made again"): an unreadable file in an unchanged
copy is left alone now. TestCopyFreshByMark checks a changed library skill
is still copied again, an old mark without a hash still works, and an edit
in a copy is still taken. go test -race ./internal/library ./internal/gui
pass, the new tests pass -race -count=10; vet, gofmt, darwin/linux/windows
builds pass. Not run on Windows or WSL.
2026-10-07 00:14:00 +08:00
yetone 14af0f100b provider, plugins: a plugin's browser sign-in finishes from the address the browser ended on, for magpie on a server or in Docker (Chicring)
The built-in sign-ins that come back to a port on this machine (Codex,
ChatGPT API, Devin, Kiro, Zed, Gemini, Antigravity) already took the
pasted address. Their plugins didn't: a subscription moved onto its plugin
(Devin, Kiro, Zed) or a plugin's own browser sign-in (Trae) showed only
"the account shows up here as soon as you're done", and a browser on
another computer than magpie's ended on a page that won't load with no way
to finish.

Now a plugin's "auto" sign-in whose page names a port on this machine to
come back to (redirect_uri, auth_callback_url, callback on localhost, or
Zed's native_app_port: loopbackPorts) sets pasteCallback, so the GUI shows
the same Callback URL field as the built-ins, and `magpie plugin login` /
`magpie accounts add` ask for it too. SubmitSignInCallback opens the pasted
address on 127.0.0.1 at that port, never through a proxy, so the plugin's
own listener finishes the sign-in as it would for the browser; an address
on another port or host is refused, a page that sends the browser on to a
page that comes back to the same port (Kiro's AWS sign-in) becomes the next
page to open. Command Code's page posts its key from script, which leaves
no address to paste, so it keeps offering the plugin's API key way.

Verified: TestPluginSignInTakesAPastedCallback (fake plugin with a loopback
sign-in, $FAKE_LOOPBACK) fails without the wiring in pluginBegin ("a
sign-in that comes back to a port here takes no pasted address:
{... PasteCallback:false ...}") and passes with it, -race -count=10;
TestLoopbackPorts covers the community plugins' page addresses.
go test -race ./internal/provider ./internal/plugin and the root package's
Plugin|Login|SignIn tests pass; vet, gofmt, darwin/linux nogui/windows
builds pass. With the real community Devin, Zed and Kiro plugins in a
sandbox HOME, each sign-in offered the field and a pasted address reached
the plugin's listener and the vendor (bogus code refused by Devin 401, Zed
"couldn't be decrypted", Kiro 400). signin-paste-plugin.test.cjs (new),
signin-callback and signin-paste-codex pass in Chromium and WebKit, en and
zh, at 360px; no new strings. Not tried: a real successful sign-in from a
browser on another computer.
2026-10-07 00:13:03 +08:00
Zhizhen He dfc66030e8 provider: keep saved credentials when reading or backing them up fails (#1034)
Follow up #965: keepUnreadLogins still ignored read and backup failures,
so a new sign-in could replace credentials that had not been recovered.
Return those errors and stop writeLogins before replacing the original.
A missing logins.json can still be created, and successful backups keep
the existing behavior. Document the save failure contract.

TestUnreadLoginsWriteErrorsKeepCredentials fails on the latest base for
both faults with "original credentials overwritten" and passes with the
fix. The unread-logins tests also pass under -race -count=20.

On 588e51ea, the full nogui suite, go vet, and macOS/Linux(nogui)/Windows
builds pass under a temporary HOME with pinned Go caches. The full suite
uses a temporary, checksum-verified Bun for the CLI's plugin test.
2026-10-06 22:48:34 +08:00
yetone dcbae2ef98 library, gui: the MCP servers page has the skills page's Select, Turn all on, Turn all off and Remove all, under one "In the library" heading (#1027, emo172)
The servers page had only By agent and the order, each with its own "In the library" heading, so with both the heading showed twice. Turning every server on or off, or removing them all, took one chip or one row at a time. The page now has one heading (lib-serverhead, which wraps when narrow) holding the order, Select, Turn all on, Turn all off, By agent and Remove all, as the skills page does. The skills page's helpers now serve both pages rather than being copied. picking names the page ("skills" or "servers"). pickingFor, pickRow, pickBar and groupChips(kind) are shared, and so are everyButtons, confirmEveryOff and everyOf, which replace everySkillButtons and everySkill. removeEveryButton, confirmRemoveEvery and removeEvery replace removeEverySkillButton. The skills page says and does what it did before.

- Turn all on and Turn all off post every agent shown to servers/agents-all (EveryServerAgents, now serversAgents(nil, …)). A hidden agent is left alone. Turn all on is greyed once every server is on for every agent that can reach it. Turn all off asks first, in the page.
- Select puts a box on each row. A row's click ticks it rather than opening the server. The bar's chips post the servers picked to the new servers/agents-some (SomeServersAgents), which gives an agent only the ones it can reach (mcpFile.supports) and leaves unnamed servers and agents as they are. An agent that can reach none of the picks has its chip greyed, with the reason.
- Remove all asks first. It says which agents lose the servers, the projects they are taken from, the sign-ins forgotten, and that the agents' own servers stay. It then posts every name to the new servers/remove-all (RemoveServers). This is one change that takes each server out as RemoveServer does: out of the library, its projects and its icon (removeServer, now shared by both), then out of every agent through syncMCP, which takes only what Applied says magpie wrote, and then forgets its sign-in. A name the library hasn't is listed in Unremoved, and the rest go all the same.
- The pick bar wraps, so its chips and Done fit at 440px in ja and de.

TestRemoveServers checks a server the user put in Claude Code's file and Codex's own "docs" of a library server's name: both stay, while fs, docs and neon leave the library, both agents and the project's .mcp.json, and the project's own "team" stays. neon's sign-in is gone, "nope" is reported, and an empty list or one with only "nope" errors. TestSomeServersAgents checks that Codex gets fs but not the SSE web, that git stays untouched, that a call naming no servers or no agents errors, and that a call naming an unknown server writes nothing. Both pass with -race -count=10, alongside TestEveryServerAgents and TestRemoveServer*.

library-servers-bulk.test.cjs passes on Chromium and WebKit in en, zh, ja and de at 440px. It checks one heading with every button, Turn all on's post without the hidden Copilot CLI, Turn all off's Cancel posting nothing and Confirm keeping Copilot CLI's, the pick bar's codex chip posting agents-some for the two picked and its greyed state for web alone, the All box, Done, and Remove all's lines, Cancel and post. It also checks for no sideways scroll and that each control clicked stays where it was on the screen. The fake check answers every server, since an unchecked one is checked again at each drawing. It fails without the change and passes 6 runs beside pick-skills, every-skill and gui-ja at --test-concurrency=4. by-agent, pick-skills, every-skill, remove-all-skills, group-*, library-*, mcp-signin, project-servers, skill-*, i18n-keys, gui-ja and gui-de pass. skill-how's WebKit case and click-scroll's "Live and a day" fail the same on the unchanged origin/main when run beside each other, and pass alone. go test -tags nogui ./internal/..., go vet, gofmt -l, and the linux (nogui) and windows builds pass. Not tried in the real app. docs/subsystems/library.md says it.
2026-10-06 22:24:04 +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
tsuixl 29f4909262 fonts: list upright styles before slanted faces at the same weight (#1030)
Follow up on the Menlo style ordering noted in PR #1007. Compare normal, italic and oblique explicitly after weight, retaining the existing width and name tie-breakers.

The Menlo regression fails before the change with Italic, Regular, Bold Italic, Bold, and passes with Regular, Italic, Bold, Bold Italic. Group/tie-break tests, fresh Windows native discovery, focused nogui GUI/font tests, go vet and Windows/Linux nogui builds pass.
2026-10-06 21:15:19 +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
TryWorld fde0c38c56 fix(gateway): a refusal that arrives once the reply began rests nobody (#1015)
A member whose reply had begun and whose vendor's safety filter then
refused the turn — Chat's code content_filter, Anthropic's stop_reason
refusal, Responses' bio_policy — was booked as a vendor failure
(Fail other) and rested with a backoff, so later requests skipped a
healthy account. The same refusal, said before any of the reply was, is
booked failRefused and rests nobody, and the request is handed to the
next member (#248). Only the timing differed: a refusal is about the
request's content, not about the account (5353ed54 rested every
WorkBuddy AI account over one system prompt).

The refusal was read only while the stream was still held for its first
content (`scan`): once that content has been said the reply goes through
as it comes, and an event after it is never classified. The holdWriter
now reads the events of a reply that has begun for a refusal in one
(`refusalAfter`), with a word filter before the JSON is read, so an
ordinary stream pays one substring test a write, and the mid-reply
branch books such a refusal as failRefused without resting it.

What the agent gets does not change: the reply and the vendor's own
error event go to it as they came, nobody else is asked (its 200 has
been sent), and the conversation is still booked as unanswered. An
error of another kind — an unreachable vendor, an overloaded one —
still rests as it did (#733), as does a reply too long.

TestRefusalMidReplyRestsNobody is new. On main, with a group of two
Chat members whose first answers half and is then refused:

    after content:  try0: status=200 fail=other rest=other
    before content: try0: status=400 fail=refused rest=<nil>

With this change the after-content case reads fail=refused,
rest=<nil>, as the before-content one does, and the test fails on main
and passes. TestRefusalAfterContentPassesThrough passes unchanged.

go test -race -count=5 -run TestRefusal, go test ./internal/middleware,
go vet (windows, darwin), the darwin, linux and windows builds, and
gofmt -l on the three files pass. One test in internal/gateway fails on
this machine either way — TestSweepBridgeProjectsTakesOnlyThe-
BridgesFolders needs SeCreateSymbolicLink — so it is skipped by name
rather than taken for a baseline. Not tried with a real vendor's
safety filter.
2026-10-06 19:54:53 +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 76321e3b42 gateway: a lone provider's Claude or GPT reasoning streams as it comes, not held for a refusal no one else could answer (iTianbao on X: 请加一个开关,让思考实时透传; through a relay Claude Code showed the thinking only all at once with the text, Codex none until then) — holdWriter held Claude's, GPT's and Gemini's reasoning (refusesAfterThinking, up to holdThinking) on every candidate, so a refusal after it could go to the next one (#248), but a refusal on the last candidate is never asked again (the !last checks in serve), so there it only delayed the thinking. On the last candidate Claude's and GPT's reasoning is now shown at once (thinkingShown), and a refusal after it reaches the agent as the vendor's own stop; Gemini's stays held there, for a reply that only reasoned to be asked again (#667, TestGeminiEmptyRepliesAskedAgain). A group with members after it still holds as before. Before: on a slow fake relay (4 thoughts 300ms apart, then text) every relay shape (Messages, Chat with reasoning_content and reasoning, Responses) for claude-sonnet-4-5 and gpt-5.1 gave Claude Code's thinking_delta and Codex's reasoning_summary_text.delta all at ~1.2–1.5s with the text; after, 0/300/600/900ms. TestThinkingStreamsWithNoOneElse (Messages, Chat for Claude and GPT, Responses; each asked from /v1/messages and /v1/responses) fails without the change in every case and passes; TestHeldThinkingKeepsTheAgentAlive's nobody-left case now expects the thinking and Claude's refusal rather than a held error. -race -count=5 on TestThinking*, TestRefusal*, TestHeld*, TestGeminiEmpty*; go test -tags nogui ./internal/gateway, go vet, gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay: whether it sends GPT's reasoning at all (OpenAI's Chat API doesn't) is unknown, and no switch was added. docs/subsystems/gateway-routing.md says it. 2026-10-06 19:34:24 +08:00
tsuixl bd33e575f1 gui: let users choose installed interface and code fonts (#1007)
* gui: choose installed interface and code fonts and styles

Discover installed styles through DirectWrite, CoreText and Fontconfig. Keep choices per computer, apply them before paint and notify both desktop webviews after saving; defaults retain platform and Omarchy fallbacks.

Verified seven focused Go tests, including real Windows discovery (90 families, 276 styles), 14 font browser cases in Chromium/WebKit, 68 settings/zoom/Omarchy/web cases, go vet, Windows amd64/arm64 and Linux nogui builds. Against the original assets, the new UI test fails with Settings must offer an interface font picker (0 != 1). Full Windows suites have separately reproduced upstream failures; macOS/Linux native discovery is covered by the added CI step.

* gui: retain bundled Mac fonts and variable style precision

Only filter an undownloaded CoreText face when it is explicitly downloadable; bundled fonts are installed without being downloaded. Use resolved font names when descriptors omit them. Preserve fractional weights and nonnegative CSS widths for named variable styles.

* gui: restore the authored font cascade when returning to defaults

Apply family-only trait overrides only while a custom face is active. The browser regression catches code under a bold parent becoming 700 instead of its original 400. Keep the compatibility shim first, and apply boot fonts before visible content.

* gui: keep font picker review separate from CI integration

Leave the native test sources and manual verification command with the feature. Move the added workflow step to a follow-up so the font picker can take the normal review path requested on PR #1007.
2026-10-06 19:32: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 588e51ea4c agent, gui: an agent connected by its config to an address where nothing answers is told, not shown connected (#1013, cjyrainbow: Codex Desktop's WSL environment reads Windows' ~/.codex/config.toml, whose openai_base_url and [model_providers.magpie].base_url the reporter pointed at Windows as WSL sees it, forwarded to 127.0.0.1:3425 by a portproxy of their own; after a reboot the portproxy listened no more and the Agents page still said Codex was connected) — Drift looked only at the config, which was right. Now an agent pointed at an address off loopback (this machine's Codex at an address the user kept, #816, through Agent.reach; every WSL twin under NAT, at Windows as the distro sees it, through Agent.Gateway) has that address tried from here (reach.go: GET /v1/models, any HTTP status an answer, 2 s timeout, in the background, at most every 20 s per address, never waited for), and one that doesn't answer while the gateway answers on loopback is a new drift, "unreachable", with Addr and the advice in Detail: under WSL (or on Windows) magpie with Settings › Share on local network on, or the user's netsh interface portproxy, which may need setting up again after a restart; Windows' firewall allowing the port for the WSL network; on Windows 11, networkingMode=mirrored; elsewhere, the forward to start again or gateway.URL() to point at. An answer not had yet, and a gateway down on loopback too, are no drift. The config is never changed for it, except where the address is one a WSL distro reached Windows at before (distro.Was, the last 4 kept in wsl.json as a re-probe finds Gateway changed) and the distro reaches Windows at another now: Drift's Move names that one, and Reapply moves the config there (Agent.move; Codex's codexMoveGateway rewrites openai_base_url and magpie's own provider's base_url, only where they begin with the old address). An address no distro was seen at is the user's own and is never offered a move. In the GUI the row's line says "{agent} is set up, but nothing answers at the address it reaches magpie by" in red with the advice as its tooltip, and its pill is "How to fix" (an i, not the reapply arrow), which opens the advice in a dialog (too long for the status line; it wraps and can be copied) and sets nothing — or "Use <address>" (the existing "Use {url}" string) when there is a Move, which reapplies; two new t() strings with zh, ja and de. The probe runs from Windows, not from inside WSL, so a firewall keeping WSL out isn't seen; the advice names it. TestKeptAddressNotAnswering (the reporter's case: Codex at 172.28.96.1, unreachable with the address and loopback in the detail; no drift once it answers, none with the gateway down too), TestKeptAddressProbeNeverWaitedFor, TestKeptAddressMovedWithWSL (Move from a Was address, Reapply rewrites both URLs, Check empty and no drift after; a 10.0.0.7 address is told but never moved) and TestWSLAgentAddressNotAnswering (Codex in a NAT distro told, with Share on local network, portproxy and firewall; a mirrored one never probed) fail without the Drift change and pass; TestLoopbackNotProbed, TestReachProbe (httptest: a 401 is an answer, a closed port none) and TestWSLWas pass. TestMain stubs reachProbe so no test dials out. -race -count=10 on them; go test -tags nogui ./internal/... under a temp HOME, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. agent-unreachable.test.cjs (the window at 560px and the tray panel at 440px: the red line and tooltip, How to fix's dialog posting nothing, Use <address> posting the reapply, no sideways scroll) fails on main and passes on Chromium and WebKit in en, zh, ja and de; agent-connect, agent-layout, agent-field-names, aside-native, gui-de, gui-ja and i18n-keys pass on both. Not tried on a real WSL NAT setup (the Windows test box didn't answer ssh). docs/subsystems/agent-wiring.md says it. 2026-10-06 19:17:55 +08:00
yetone c5a54bc74b agent: VSCodium's magpie group is written for the provider its installed Copilot Chat declares, OpenAI Compatible (customoai) on the Marketplace's 0.48.1 (#1014, xybio: VSCodium's Chat picker showed only Auto) — VSCodium has no Copilot Chat of its own, and the last Marketplace Copilot Chat it can install, 0.48.1, declares no Custom Endpoint provider (its contributes.languageModelChatProviders: copilot, copilotcli, claude-code, anthropic, xai, gemini, openrouter, openai, ollama, customoai, azure), so the customendpoint group magpie wrote in chatLanguageModels.json was never read. magpie now reads the package.json of the github.copilot-chat installed under ~/.vscode-oss/extensions (vscodeChatOf: the one extensions.json lists, else the highest github.copilot-chat-<version> folder not in .obsolete) and writes customendpoint when it is declared, customoai when only that is: each model's window as maxInputTokens beside maxOutputTokens (0.48.1 adds them up), and the token as x-api-key, since 0.48.1 drops an Authorization in requestHeaders and its apiKey has to be a ${input:} secret; the gateway already takes a key from x-api-key, past an empty Authorization, so Usage still counts VSCodium. No extension found, or a package.json that can't be read, keeps customendpoint as before; one declaring neither is said in Notice and Check. Writing the group removes magpie's group for the other provider in the default's and each profile's file, and Disconnect removes both; the user's own groups, under either provider, stay. Sync (at magpie's start and on catalog changes) rewrites a group written for the other provider, and Check reports it until then. VS Code and Insiders, whose chat is built in, keep customendpoint unchanged. The Notice says 0.48.1 registers its providers only while signed in to GitHub with a personal Copilot plan (its BYOK registration is gated on the Copilot token's individual/BYOK flag). TestVSCodiumCopilotChatOpenAICompatible (the real 0.48.1 provider list installed in a temp ~/.vscode-oss: Connect writes customoai with x-api-key magpie-vscodium and no Authorization or contextWindow, the user's groups kept, Check empty; a Custom Endpoint group an older magpie wrote is flagged and rewritten by Sync; a Copilot Chat declaring customendpoint switches it back; one declaring neither is reported; Disconnect removes both) and TestVSCodeChatInstalledWithoutExtensionsJSON (folder scan, .obsolete, unreadable package.json) fail on main, which writes customendpoint, and pass; -race -count=10 on the VS Code tests, go test -tags nogui ./internal/..., go vet, gofmt -l, linux and windows (nogui) builds pass. No GUI change. Not tried with a real VSCodium. docs/subsystems/agent-wiring.md and docs/reference.md say it. 2026-10-06 19:09:26 +08:00
forever b0a96cbbd7 gateway, provider, gui: a Sub2API key given 5h/1d/7d limits is weighed and rested by its own windows, like an account (#1004)
* gateway: a Sub2API key out of its 5h/1d/7d limit ("…限额已用完") rests as out of quota, not a 1-minute rate limit

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tried on a real Sub2API v0.1.149 relay, in a sandbox HOME, its key's 7d
limit lowered under its spend for the test and then restored, with
magpie serve on a group ordered: the key, then a local stand-in. The
key answered 429 "api key 7天限额已用完"; it rested by window till its
7d reset, 2026-10-13 00:00 +08:00, and the stand-in served. Planned
again a minute later, still over, it still rested. With the limit back
at $800, the next request was planned with it resting, the stand-in
serving, and read its windows; the one after went to the key, 200.
2026-10-06 18:57:47 +08:00
yetone 87487c5ef0 plugin, provider: a plugin can press its vendor's daily check-in itself (auth.checkin), and magpie runs it as its own check-ins (Lemon on Discord #feedback: provider 每日签到的方法能放到插件里面去做吗; ARNO earlier asked for Qoder's) — the check-ins magpie had (WorkBuddy, Trae CN, MiniMax Code, Qoder) are each written in magpie against the vendor's endpoint, even for a subscription that moved to its plugin, so a plugin author had no way to give their provider one. A plugin's auth hook may now have checkin(getAuth, provider) beside usage: the host lists the provider with checkin: true (plugin.Provider.Checkin) and answers checkin({provider, account}) (plugin.AccountCheckin), refreshing the account as usage does, with {outcome, credit, streak, message}; outcome is claimed, done, ineligible, inactive, captcha or failed, and a throw or an outcome outside these is failed with the reason. internal/provider/plugin_checkin.go runs these through checkin.go's checkiner, as the built-ins are: each signed-in account in use once a Beijing day (first look 2 minutes after start, then every 30), a failure asked again after wbCheckinRetry, every other outcome settled for the day, results kept in plugin-checkin.json by provider|account; a failure is recorded as failed and resets nothing. captcha is new (CheckinCaptcha): the vendor wants a captcha, which magpie never solves, so it is said as "check in in its own app", not as a failure, and not asked again that day. The results mark the account's Usage card (checkinBy "plugin:<provider>", WithCheckins), so the card's check-in row, its Auto check-in and Check in now work for it (POST /api/settings/plugin-checkin {provider, on}, POST /api/usage/plugin-checkin {provider}); Settings → Usage gains a Plugins tab, shown while such a plugin is signed in, a Daily check-in row per provider with Off/On and how each account went; magpie accounts checkin and c on the TUI's Usage page include them (the TUI's "no account" line names check-in plugins). The switch (settings.PluginCheckins, by provider) is off by default; for WorkBuddy, Trae CN, MiniMax Code and Qoder it follows that vendor's existing switch until set on its own, and magpie's own check-in through the plugin's fetch leaves a provider whose plugin checks in to it (pluginChecksIn), so no account is checked in twice. The Settings page's save carries PluginCheckins over (TestSettingsSaveKeepsWhatItDoesNotSend's fixture names it). No plugin declares the hook yet; Qoder's check-in stays magpie's own here. TestPluginCheckinRunsAsTheBuiltinsDo (the fake plugin with FAKE_CHECKIN, five accounts answering claimed, done, captcha, a throw and an unknown outcome: off presses nothing; on, each outcome kept with its vendor; a minute on nothing pressed again; past the retry only the two failures; the next day all five; PluginCheckins and the cards marked; another provider's press asks none) and TestPluginCheckinTakesOverTheVendors (a qoder plugin without the hook leaves magpie's Qoder check-in its account, with it none; PluginCheckinOn's fallbacks) pass -race -count=10, and the latter fails with pluginChecksIn stubbed false; TestTUIChecksPluginsIn passes -race -count=20. plugin-checkin.test.cjs (the card's row with the captcha said, Auto check-in posting {provider, on}, no page move; the Settings Plugins tab at 440px with the row fitting, no sideways scroll, On posting) passes on Chromium and WebKit in en, zh, ja and de; usage-checkin, i18n-keys, gui-ja, gui-de and settings-* pass. go test -tags nogui ./internal/..., go vet, gofmt -l, linux (nogui) and windows builds pass. Not tried with a real plugin or account: no published plugin has the hook yet. docs/subsystems/provider-plugins.md (the hook's contract) and docs/reference.md say it. 2026-10-06 18:40:45 +08:00
yetone b56bfb5a94 gateway: an upstream that answers 200 with a web page, with nothing at all, or with JSON that isn't, has failed as a 502 does, so a group asks its next member (#1012, cth123456: Codex on group/medium, agentrouter/deepseek-v4-flash now and then answered 200 text/html; the log said status=200 done=true in=0 out=0 served=null, codex/gpt-6-luna was never asked, and Codex failed with "stream disconnected before completion: stream closed before response.completed") — the candidate loop judged a try by its status alone, and holdWriter passes a 200 that is neither text/event-stream nor application/json straight to the agent, so the page went to Codex as the reply. notAnAPIReply now reads every 2xx (but 204) an upstream gives, in forwardOnce (every relayed and translated request, every API, plugins included) and in the built-in clients' own requests (the ChatGPT backend's /responses, Kiro, Zed, Qoder, Devin, Cursor, Command Code): a text/html reply, a body that begins as a web page whatever its Content-Type, an empty body, or one sent as JSON that doesn't begin with { or [ is turned into the 502 it stands for, with a JSON error saying what came back ("answered 200 OK with a web page (text/html), not an API reply: “Just a moment...”", the firewall hint for a block page). It then fails over, rests a minute and is logged as any 502 before the reply begins does; a provider with nobody after it hands the agent that 502 rather than the page. Only the first bytes are peeked, which a stream waits for anyway; a read that fails is left to the reader, a compressed body and anything else as it came. TestNotAnAPIReplyFailsOver (Chat, Responses and Messages, streamed and not, a member answering a Cloudflare page as text/html, as JSON and as SSE, an empty body as JSON and as SSE, and plain text as JSON; the next member must answer and the log name why) fails without the change in all 36 cases (the page relayed to the agent, or b never asked, or no reason logged) and passes; TestNotAnAPIReplyAlone (a 502 with the reason, logged 502) and TestCodexBackendWebPage (the ChatGPT backend's page reached Codex as a 200) fail without it and pass; TestAPIReplyUntouched keeps JSON after whitespace, SSE with and without a Content-Type, gzip, XML, a 204 and a 404 as they came. -race -count=10 on them; go test -tags nogui ./internal/gateway ./internal/provider, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay (agentrouter) itself; the page is a Cloudflare challenge's, as the issue has only its Content-Type. docs/subsystems/gateway-routing.md says it. 2026-10-06 18:26:31 +08:00
yetone a87dc1ff2b gateway: a WebSocket upgrade on /v1/responses is answered 426 "magpie relays Responses over HTTP only", not 404 (#1005, Clarence12138) — Pi's Responses WebSocket extension opens GET /v1/responses with Upgrade: websocket, and only POST was routed there, so the catch-all's 404 ("magpie serves …") read as a broken gateway and the extension retried five times a session before falling back to SSE. GET /v1/responses and /responses now tell an upgrade 426 with the reason and the way that works (POST, streamed as SSE) — the answer a client falls back to HTTP on at once, as the Codex backend path already gives (TestCodexWebSocketUpgradeRequired) — and a plain GET 405 with Allow: POST. Relaying Responses over a WebSocket itself is not done here (a long-lived connection whose model is named per response.create, outside the per-request routing, failover, key limits and usage the gateway has). TestResponsesWebSocketUpgradeRequired fails without the change (404) and passes; go test ./internal/gateway, go vet (darwin, windows), gofmt -l and the linux build pass. Not tried with Pi's extension itself. docs/reference.md and gateway-routing.md say it. 2026-10-06 17:55:40 +08:00
yetone 6ba82d2f07 gateway: reasoning magpie made isn't relayed to Azure OpenAI or OpenAI's API, and a summary request refused for an item not found is asked again as text (#1008, zhouxiong100: Codex on an Azure OpenAI deployment failed every compaction with "Azure OpenAI: Item with id 'rs_18dbe693b3dce4800000003c' not found", Reconnecting 5/5, store=true by a middleware no help) — Codex offers web search on every turn, so its turns on Azure went out translated, and the translated replies gave Codex reasoning items magpie made: rs_ and newID (the reporter's id is one, a UnixNano in hex and a counter), a summary and nothing sealed. Codex hands them back on every turn. buildResponses replays no reasoning to Azure, so the turns worked; Codex's compaction offers no tools, so it isn't translated but relayed as it is, and Azure, with store false, looked rs_… up among stored items, found none and answered 400 for the whole request. codexCompact asked again as text only on a 404 (#866), so the 400 went back to Codex. passthrough now drops reasoning with no encrypted_content from a Responses request to OpenAI's API or Azure OpenAI unless store is true (withoutBareReasoning): both look such an item up and can't have it, its summary being the model's notes no model reads back; their own sealed reasoning still goes, and every other host gets the request as before (a relay may read the summary for a vendor that wants reasoning back). The same holds for any turn relayed as it is, Codex's with web search off for one. codexCompact's fallback also takes a 400 naming an item not found (itemNotFound: unreadableItem and "not found"), as a relay in front of either passes on; any other 400 still goes back as it came, and the local summary names the status it got. TestAzureCompactionWithoutMagpiesReasoning (the Azure preset at a fake /openai/v1/responses answering a storeless request with a bare reasoning item Azure's 400 word for word; the compaction and a relayed turn, each asking Azure once with the sealed reasoning kept and magpie's dropped) fails without the passthrough change (the turn 400 with the reporter's exact message, the compaction asking twice) and passes; TestCodexCompactItemNotFoundAsksAgainAsText fails without the codexCompact change (400 to Codex) and passes; TestCodexCompactOther400Stays guards other 400s. -race -count=10 on them, TestCodexCompact* and TestAzureRoutes. go test -tags nogui ./internal/gateway ./internal/provider, go vet, gofmt -l, linux and windows (nogui) builds pass. Not tried with a real Azure OpenAI resource (none on this machine); the 400's text is from the reporter's screenshot and issue. docs/subsystems/gateway-routing.md says it. 2026-10-06 17:53:18 +08:00
yetone 5361a2178e agent, gateway: Cursor Private Inference's row has an effort, which magpie's gateway asks its requests for (#1003, gjf7) — other agents' rows pick a reasoning effort that magpie writes into their config, but Cursor Private Inference reads only its base URL and key from the environment, and its app offers no Reasoning control for most of magpie's models, so there was no way to set one short of a routing group with a member fixed at a level, which the reporter used as a workaround. Its row now has an effort field like the others': the default (as Cursor asks) first, then every level the models it lists have (cursorLocalEfforts: their levels in MemberEfforts order, low/medium/high where a model reasons with levels unknown, none at all when nothing it lists reasons or it isn't connected). The pick is kept in magpie, settings' agentEfforts by agent (provider.AgentEffort / SetAgentEffort, a level of MemberEfforts or "" for none), and the gateway's candidate loop asks a cursor-local request for it with withFixedEffort, fitted to the model's levels and bounded by a group's, on a model that reasons (the new Provider.Thinks: levels, its list's Reasoning, or models.dev's). Precedence: a group member's fixed effort, then a :effort suffix, then a routing rule's turn pick, then the agent's pick, then the request's own; the pick wins over a level Cursor sent, as Cursor sends its own default as often as a choice, and Default hands the choice back to Cursor. A model that doesn't reason and every other agent's requests go as they came. The Settings page's save carries agentEfforts over as it does fastPicks (TestSettingsSaveKeepsWhatItDoesNotSend's fixture names it). In the window the field sits under "Settings" rather than "New sessions" for this agent alone, as it applies to every request from then on, not to what a session starts on; no new t() strings (Settings, effort and default are translated in zh, ja and de already). TestAgentEffortOnCursorLocal (Chat and Responses with and without a level of their own, Claude on Messages made to think, OpenCode's request untouched, a non-reasoning model untouched, a member fixed at low kept, the pick taken off) fails without the gateway change and passes; TestCursorLocalEffort covers the options (none with no reasoning model, the union of levels, the low/medium/high fallback, none while disconnected), Apply and a bad level refused; both pass -race (-count=20 gateway, -count=10 agent). cursor-local-effort.test.cjs (the window at 560px and the tray panel at 440px: the field under Settings, the slider's five stops with Default first, a pick posting the field, no sideways scroll) passes on Chromium and WebKit in en, zh, ja and de; claude-effort, subagent-effort, agent-field-names, agent-connect, agent-layout, agent-tweak-fold, agent-models, cursor-local-launch and i18n pass on both. go test -tags nogui ./internal/..., go vet, gofmt -l, linux (nogui) and windows builds pass. Not tried with the real Cursor Private Inference: whether it sends a reasoning level of its own on models it shows a Reasoning control for is unverified, which the precedence makes moot. docs/subsystems/gateway-routing.md and agent-wiring.md say it. 2026-10-06 17:29:28 +08:00
yetone ec6df1de57 gateway: a request offering web search is translated on the client's own API where the provider serves it, not on Chat (#997, wlj521: Codex on Xiaomi MiMo's pay as you go showed "Responses → Chat" on v0.1.1082 with all three URLs filled) — Codex offers its web_search tool on every turn, and a provider that doesn't search by itself can't be relayed it: magpie searches for it, which takes a translated request. That translation went to usable's first API, Chat for every model but OpenAI's and Claude's, so on any provider serving Responses (MiMo, whose api.xiaomimimo.com/v1/responses answers 401 to a made-up key, a wrong path 404) each Codex turn went out as Chat, and Claude Code's WebSearch on a provider with an Anthropic URL the same. The translation now stays on the client's API when the provider serves it there and neither ResponsesFirst nor MessagesFirst prefers another (firstElsewhere); the OnMessages check moved before the search check so a Claude model still goes to Messages. The search tool itself isn't sent to the provider (forwardTranslated drops it where the provider doesn't search), and magpie's own web_search function is given on Responses as it was on Chat. TestSearchOfferedStaysOnTheClientsAPI (the preset's three URLs; Codex's Responses with a function and web_search, Claude Code's Messages with web_search_20250305, Chat's web_search_options) and TestCodexSearchAnsweredOnResponses (a searcher configured: the model is asked on /v1/responses twice, the search runs once, Codex gets the answer) fail without the change (/v1/chat/completions) and pass; -race -count=5 on them and TestWebSearch*, TestClaudeFromChat*, TestAutoRelays*. go test ./internal/gateway ./internal/provider, go vet, gofmt -l, linux/windows (nogui) builds pass. Not tried with a real MiMo key (none on this machine) or a real Codex. docs/subsystems/gateway-routing.md says it. 2026-10-06 17:16:50 +08:00
zzzpyyy 28c58adcb3 feat(gui): discover local provider configurations with persistent dismissal (#879) 2026-10-06 17:16:39 +08:00
yetone e6b0827179 gateway, provider: a Claude model is asked on Messages from Chat and Responses too, where its provider has an Anthropic URL (ReturnTrue on Discord, after #997/050e629b: OpenCode on supergpt@claude-opus-5-5 had no cache with Chat, Responses and Anthropic URLs filled; Chat → Anthropic with Anthropic alone wrote 16,318 to the cache) — OpenCode speaks Chat to magpie for every model, Claude's too, and a request on an API the provider serves was relayed as it is, so with a Chat URL beside the Anthropic one OpenCode's Chat went on as Chat and the cache breakpoints the translation to Messages adds never reached the relay; Codex's Responses at a relay with a Responses URL the same. MessagesFirst only ordered the APIs a request is translated to. Provider.OnMessages (MessagesFirst, no "Asked on" set for the model, and no vendor list naming its APIs) now stops the pass-through for Chat and Responses when Messages is usable, and the request is translated there. A model the user pinned to Chat stays on Chat; Copilot's Claude, whose list names Chat and Messages, and every non-Claude model are relayed as before; Messages is relayed as it is. The reporter's second setup (Responses and Anthropic URLs, Chat → Responses in the screenshot) already went to Messages on v0.1.1081 — Chat isn't served there and 050e629b put Messages first — so that screenshot was from before it. TestClaudeFromChatIsAskedOnMessages (the reporter's three setups plus Chat+Anthropic, OpenCode's streamed Chat with a system prompt and a tool and Codex's Responses, each asserting /v1/messages upstream with cache_control in the body) fails without the change on the Chat+Responses+Anthropic and Chat+Anthropic setups and passes; TestClaudePinnedToChatStaysOnChat guards the pin; TestClaudeGoesOnMessagesWhereTheRelayHasThem and TestAutoRelaysOnTheClientsOwnAPI now expect Messages for Claude (GLM and GPT unchanged). -race -count=5 on them and the Copilot tests. go test ./internal/gateway ./internal/provider, go vet (darwin, windows), gofmt -l, darwin, linux (nogui) and windows builds pass. Not tried with the real SuperGPT relay or a real OpenCode. OpenCode's config still names @ai-sdk/openai-compatible for every model; a per-model Anthropic SDK was not added, as it isn't tried with the real OpenCode and the gateway now does the same. docs/subsystems/gateway-routing.md says it. 2026-10-06 16:42:07 +08:00
july_lu 51bec20c09 gui: a gateway key's menu puts its accounts beside their provider's models, not one block at the end (a follow-up to #932, July, LCorleone) — the menu the key's badge opens listed every provider's models and then all the accounts and keys together after them, so "which models" and "which plan" of one provider sat in two far-apart places, hard to give someone one vendor's plan alone. Each provider's accounts and keys now follow its own models, inside its block — the order the /api/caller-keys/models comment already described — an account by who is signed in with the plan in its note, a key by its fingerprint; a provider with accounts and no model in the list (one kept unlisted, or serving none now) keeps its accounts after the models; what the CLI kept that no provider has now stays behind every live entry, a model gone before an account signed out. Nothing of the picking changes: the same proto menu, the same opts and literalName, the same accountsSet that splits what comes back into "models-key" and "accounts-key", and no new strings to translate. gateway-key-accounts reads the order off the real menu now — relay's two accounts between its models and OpenAI's, the key behind GPT-5 — and pins the dead tail on a key held to a gone model and a signed-out account; its last badge read waits for the row to be drawn with the answers (the wait gateway-key-models already makes): without it a slow engine read the hold one action behind, a flake this sandbox shows on main's own test 5/6 runs on WebKit. gateway-key-accounts passes Chromium and WebKit, en and zh; gateway-key-models, gateway-key-groups, gateway-caller-keys, gateway-connect-keys, gateway-key-limit, i18n-keys and usage-keys-fold pass on Chromium — gateway-key-models' scroll assertion doesn't survive this machine's frozen WebKit build on main either. No Go change (the assets are embedded; no Go test reads their content). docs/reference.md's sentence on the menu follows the new order. (#988) 2026-10-06 16:39:33 +08:00
TryWorldandh4rK8s efd501ec74 agent: add Reasonix Studio with separate Executor and Plan models (#427)
* feat(agent): add Reasonix Studio 2.x integration

* agent: select Reasonix planner independently from executor

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

* agent: preserve Reasonix planner choices and model controls

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

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

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

* agentenv: isolate Reasonix configuration in test sandboxes

* gui: restore Reasonix roles without disconnecting the other

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: h4rK8s <63350517+h4rk8s@users.noreply.github.com>
2026-10-06 16:14:35 +08:00
forever f109b270c0 gui: the main window opens as it was last left — maximised again if it was, at its last size otherwise (#995)
On a MacBook the window had last been zoomed. magpie skipped a maximised
window when it saved the size, so it reopened at an older, nearly full
size: 1712×1083 at (8,34) on a 1728×1084 work area, neither full nor the
size it was given nor the default.

settings.WindowMaximised (this computer's own, KeepOwn) now records
whether the window was maximised when it settled; Window stays the size
it restores to (settle). A window not shown yet, let go, full screen or
minimised, or on Linux hidden, records nothing.

Reopened maximised: on the Mac and Windows placeMain maximises the made
window just before its first Show (on Windows once its page has come);
Wails' StartState is undone by the Mac's centring and shows a Windows
window before its page. The least size Maximise drops is put back. On
Linux makeMain sets StartState, since GTK can't be asked about a window
not mapped yet.

A size kept on a larger screen: Windows fits the window to the work area
and centres it, title bar in reach, and keeps the larger size for the
larger screen (fitted); the Mac fits and zooms it itself, and magpie
doesn't zoom it again, so it can still be restored.

TestSettle fails with upstream's rule (5 of 9 cases); TestOpenSize,
TestFitRoom, TestWindowMaximisedKeptAndOwn; the Mac's real build under a
sandbox HOME, 11 cases.
2026-10-06 16:09:58 +08:00
forever 8754221203 gateway, usage: when the credits switch holds every account, the record and 429 say so accurately; the switch's text says it follows the latest reading (#999)
* gateway: a request refused as every account is held records why, caps or credits

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

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

Follow-up to #974.

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

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

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

Follow-up to #974.

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

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

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

Follow-up to #974.
2026-10-06 16:09:00 +08:00
yetone 050e629b0a gateway, provider: a Claude model is asked on Anthropic's Messages where its provider has an Anthropic URL, not Chat (#997, wlj521: 自动好像只会走chat; ReturnTrue on Discord: a relay with an OpenAI and an Anthropic URL had 0% cache hits from Claude models until the OpenAI URL was left empty) — every API's order was Chat first. A request on an API the provider serves was already relayed as it is (a fake relay recording each path: Messages → /v1/messages, Responses → /v1/responses, Chat → /v1/chat/completions, for GLM, GPT and Claude alike, with one URL for three APIs and with an OpenAI URL plus an Anthropic one), but two places fell back to Chat for Claude: usable(), which a request on an API the provider lacks is translated to (Codex's Responses for a Claude model at a Chat+Anthropic relay went to /v1/chat/completions, losing the cache breakpoints the translation to Messages adds), and Provider.Native, the API Pi, omp and droid are wired to ask each model on (route.go's magpieModels), which named Chat for every Claude model at such a relay, so those agents spoke Chat and their cache_control never reached the relay. MessagesFirst (a Claude model by name, where the provider has an Anthropic URL) now puts Messages first in both, beside ResponsesFirst for OpenAI's models. A model the user set "Asked on" for, or one the vendor's list names its APIs for, is as before; GPT and other models keep Chat first; Copilot's Claude, which its list serves on Chat and Messages, is now native on Messages (TestNative updated), the API Claude Code already reaches it on. Not reproduced: a Claude Code /v1/messages request translated to Chat — with either relay layout, a list as new-api answers it, and a key per protocol, it went to /v1/messages with cache_control kept; ReturnTrue is asked for the request's Protocol row. TestClaudeGoesOnMessagesWhereTheRelayHasThem and TestNativeClaudeIsMessagesWhereServed fail without the change and pass; TestAutoRelaysOnTheClientsOwnAPI guards the pass-through for the three APIs; -race -count=5 on them. go test ./internal/gateway ./internal/provider ./internal/catalog ./internal/agent, go vet, darwin, linux (nogui) and windows builds pass. docs/subsystems/gateway-routing.md says the order. 2026-10-06 15:33:16 +08:00
yetone a711f428ad agent: CodeBuddy Code (Tencent's CLI, codebuddy/cbc, the China edition too) is an agent that uses magpie's models (#980, yujianbinm) — the CLI (npm @tencent-ai/codebuddy-code 2.161.4, one package for codebuddy.ai and codebuddy.cn, CODEBUDDY_INTERNET_ENVIRONMENT=internal) takes models of one's own from ~/.codebuddy/models.json ($CODEBUDDY_CONFIG_DIR), the format WorkBuddy reads, hot-reloaded, and starts a session on settings.json's "model". magpie now writes its models there as WorkBuddy's are written (vendor magpie, key magpie-codebuddy, url the gateway's /v1/chat/completions), keeping the user's own entries, their order, a model turned off and availableModels (magpie's ids added when the list is there), and sets "model" to the pick, the user's other settings kept; one of CodeBuddy's own models, or default, takes magpie's models out again. A new models.json is {"models":[…]} as CodeBuddy's docs give it. workbuddyWrite becomes buddyWrite(agent), the same writer for both. CODEBUDDY_CONFIG_DIR joins agentenv.Vars; cliupdate knows the npm package. TestCodeBuddyCode (user's model, availableModels and settings kept; magpie pick; sync; back to own; default) and TestCodeBuddyCodeNewFile fail without the agent ("no codebuddy agent") and pass; -race -count=5 on the CodeBuddy/WorkBuddy tests. End to end, the real CLI 2.161.4 under a temp HOME with CODEBUDDY_INTERNET_ENVIRONMENT=internal and no CodeBuddy sign-in: magpie codebuddy magpie/mock/pro against a sandbox gateway on 3462, then codebuddy -p with no --model, answered through the gateway from a mock upstream, and magpie's usage counted it under CodeBuddy Code (the CLI sends "CLI/2.161.4 CodeBuddy/2.161.4", so it is told by its key); magpie codebuddy default took it out. A bare-list models.json is read by the CLI too. Not tried with a real CodeBuddy account or a real model vendor. go test ./internal/agent/ ./internal/agentenv/ ./internal/usage/ ., go vet, darwin, linux (nogui) and windows builds pass. 2026-10-06 15:32:23 +08:00
forever a7c4e5ddde provider: a Codex account the Codex app still sends for keeps Codex signed in — spend cap or overage with credits, limit_reached alone; a hold survives a failed read (#996)
* provider: a Codex account at a spend cap or past its overage with credits left keeps Codex signed in, as the Codex app's composer still sends for it

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

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

Follow-up to #966.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* accounts: tell a ChatGPT account's credits

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

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

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

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

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

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

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

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

The credits switch adds a row after the Auto-use one, so the account after
a Codex account follows the curve, the Auto-use row and then the Use
credits row.
2026-10-06 14:20:09 +08:00
yetone 5839e08189 dsh: the gateway key also goes into dsh's own key store, which the desktop app reads (#969, pan17: in the DeepSeek Harness desktop app every turn failed with llm-pi-ai: no credential for provider route "magpie"; its profile resolves MAGPIE_GATEWAY_KEY, which is not set — store MAGPIE_GATEWAY_KEY through the credentials service (the web Models page writes it) or export it (MISSING_CREDENTIAL), while magpie showed dsh connected) — the route was in the desktop profile (magpie writes every profile; the GUI names only the first, profiles/web, which read as "magpie wrote the npm dsh's config"), but magpie put the key only in ~/.dsh/.env. In dsh 0.2 the .env files are layers the product CLI's boot (dsh-app-boot) loads; dsh-credentials-local resolves inherited environment > $DSH_HOME/.credentials.yaml > project .env > home .env, and "a composition the product CLI did not boot has only the inherited environment as its layer". So the desktop app never saw the key. magpie now also writes refs.MAGPIE_GATEWAY_KEY into ~/.dsh/.credentials.yaml, dsh's own store, which its Models page writes and every dsh reads. Details: a new store is made owner-only, version 1 (dsh refuses a store other users can read); the user's other refs, records and comments are kept; a different key of the user's under magpie's name is left alone (the check already reports it); a store in dsh's pre-release flat layout isn't touched; going back to dsh's own models takes magpie's key out. The check now asks for Apply again when the store lacks the key; a dsh wired by an earlier magpie gets it from the catalog sync magpie runs as it starts. The output of a fresh write and a merge into a store with refs, a grant record and a comment both parse with dsh 0.2.0-rc.2's own parseCredentialsDocument. TestDshDesktopGetsTheKeyFromItsStore (web+desktop profiles as on the reporter's machine) and TestDshStoreOfTheUsersOwn fail without the change and pass; -race -count=5 on the dsh tests. Fixtures that stood for "wired" with .env alone now hold the store too. Not tried with the real desktop app (not installed here; the reporter is on Windows); they're asked to confirm. go test ./internal/agent/, go vet, darwin, linux (nogui) and windows builds pass. 2026-10-06 14:13:53 +08:00