Commit Graph
150 Commits
Author SHA1 Message Date
yetone ed31293c0e usage, gui: a reply its vendor let go in a burst over a couple of hundred milliseconds tells no speed, not 2,237 tok/s (John on Discord)
John's Kimi Code rows after v0.1.1119 (OpenCode, group/k3-glm, api.kimi.ai,
Chat, k3-256k): 550 tokens, 22 of them reasoning, in 16 s with the first
content at 5.2 s read 2,237 tok/s, and 395 tokens in 19 s 2,012. 528 answer
tokens at 2,237 tok/s is 236 ms: the burst the vendor let go at the end.
de5455b8 measured how long the content took to come (flow) and took the
window as no longer than it, so a burst sent in one write (flow ~1 ms) fell
under the 100 ms floor and told none, but a burst let go over 236 ms of
many events read its tokens over those 236 ms, under the 10,000 tok/s
ceiling. The time a burst takes to come is not the time it took to write.

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

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

Run: go test -tags nogui ./internal/... (golang:1.26.8 in Docker, as a
non-root user: as root, internal/agent's TestAside* write-refusal tests
fail because root ignores the file modes they rely on, unrelated to this);
-race -count=5 of the new and the burst tests; go vet (linux, windows,
nogui) on internal/gateway and internal/usage. Not tried against the real
Kimi Code API: the owner's Kimi Code key answers 403 (subscription has no
access), so how Kimi Code lets the reply go is inferred from John's
numbers; which of the two shapes his rows were is not known, and both are
covered.
2026-10-09 00:10:23 +08:00
yetone 2b5f19fc73 gateway: a request whose tool results another request already answered (Claude Code's fork sub-agents and their lead, a turn sent again) gets a Claude Code run of its own instead of a 409 "the agent's turn is already being resumed" (ylorn on Discord)
A Claude Code fork sub-agent's first request is its lead's conversation
with a result for each of the lead's in-flight calls ("Fork started —
processing in background") and its directive after. The lead's own resume
and every fork it started therefore match the same run at once; the first
to claim it won and the rest got a terminal 409 that Claude Code does not
retry, so the forks died. The 409 dates from e8cd4bac (#794); nothing
between v0.1.1108 and main changed it. A request that loses the claim now
gets a run of its own, told the conversation whole.

Verified: TestClaudeForksAnsweringTheLeadsCallsEachGetAnAnswer drives the
real server stack (httptest Handler, fake claude whose MCP calls go back
through /_magpie/claude-mcp) with a lead and 8 forks resuming at once.
Before the change it failed 7/20 plain and 5/10 under -race (409 for forks
and the lead); after, -count=30, -race -count=20 (macOS) and -race
-count=10 (Linux Docker) pass. TestClaudeBridgeSafeguardConcurrentHandoff
now expects the second caller to get its own run. go test -race
./internal/gateway is green on macOS and in Linux Docker; go vet for
darwin/linux/windows and gofmt clean. Not tried with a real Claude
subscription.
2026-10-08 23:59:50 +08:00
yetone 24bef0f9be gateway: a streamed request waiting for its key's slot (Concurrency) or for room in its minute (Requests per minute) has its agent sent the stream's 200 and SSE keepalives after 15s, as a held try's stream is, instead of nothing until it is sent (coeo91 on Discord: WorkBuddy, Trae and Qoder said it timed out)
serve's try took its lane slot and waited for the minute (04704111)
before holdWriter.watch started, so for up to 2 minutes (or QueueWait,
unbounded by default) the agent got no status, no headers, no byte.
keepQueued runs beside lanes.take and rpms.wait: past keepHeldAfter it
sends the stream's 200 and ": keepalive" comments every keepaliveEvery
(keepAlive, the mechanism held tries already use; none for Gemini, #934).
Once they are out the try is held, so the vendor's error, or the queue
turning it away, reaches the agent as its protocol's stream error
(failTo: Chat's error chunk, Responses' response.failed, Messages'
event: error), and the next candidate still answers in the same stream.
A wait shorter than keepHeldAfter sends nothing: its 429 keeps its
Retry-After. Non-streamed requests are unchanged.

What the agents do with it (read from their app bundles, not tried live):
- WorkBuddy 5.5.6 / WorkBuddy AI 5.6.2 (codebuddy-headless.js): no
  header timeout, first-token timer 20 min, so a 2-minute queue alone
  doesn't time it out there.
- Qoder 0.4.3: 60s to headers, then 60s more after them; its SSE parser
  drops comment lines, so keepalives don't extend that. Headers at 15s
  give it 75s instead of 60s; longer waits need firstPayloadTimeoutMs.
- Trae: its agent is downloaded at runtime and wasn't read.

Verified (Linux, golang:1.26.8 in Docker):
- queue_keepalive_test.go through lanGuard(New().Handler()), with an
  agent whose connection gives up after 400ms of silence: a request
  queued 1.2s for the minute (chat, responses, messages) and one queued
  for a Concurrency slot get 200, keepalives, then the reply; a vendor
  500 after keepalives is each protocol's stream error; a QueueWait
  waited out is the stream's error naming the limit; a short wait is
  still a plain 429. With keepQueued taken out, every one but the 429
  test fails with "i/o timeout". TestQueuedUnheldTryHeldOnceKeptAlive
  fails without the hold (": keepalive\n{\"error\":...}" in the stream).
- go test -tags nogui ./internal/gateway: ok (122s).
- go test -race on internal/gateway the way CI runs it (go test -race -c,
  build/race-shards.sh over 14 processes, -test.timeout=20m): 1258 tests,
  every shard ok. A single serial -race process passes 10m's default
  timeout, which is why CI shards it (#923).
- go vet -tags nogui ./internal/gateway for linux, windows and darwin;
  gofmt -l clean. After rebasing on 5e17a183: TestQueued, TestBurst,
  RPM, TestQuiet and TestSilentHeld under -race, and linux vet, pass.
2026-10-08 23:13:12 +08:00
yetoneandClaude d9caa3dd13 library: Hermes Agent in a WSL distro takes skills and MCP servers there, and so does every other WSL agent that keeps them in its own folder (omp, OpenCode, MiMo Code, Droid, Grok Build, Command Code, AtomCode, Qoder, MiniMax Code, fx, dsh) (#1323, Kevinzz224)
wslTargetOf knew only Claude Code, Codex, Pi and OmO, so hermes@wsl:Ubuntu
was never a Library target and a skill meant for it could only go to
Windows' ~/.hermes. An agent whose places are all its own folder (a.Dir,
or beside a.Path) now gets the target targetOf makes on this machine, at
the distro's home the agent was found with: copies for skills, its MCP
file marked as WSL's (a Windows program is refused, magpie's own image
server goes by its WSL path), and the skills it reads besides its own are
the same distro's agents'. Agents whose places depend on this machine's
variables or home (Kimi Code, Crush, Cline, CodeBuddy, Goose) stay out.

Run: TestWSLOwnFolderTargets fails without the change (hermes@wsl,
omp@wsl, droid@wsl, opencode@wsl aren't targets; no copy of the skill),
passes with it. go test -tags nogui ./internal/library ./internal/agent
under a temp HOME on macOS (library ok; agent's TestFolderVarsAreListed is
red on main since 10578ce6, fixed in its own commit next), and
./internal/library in golang:1.26.8 Docker (Linux) ok; go vet -tags nogui
for darwin, linux and windows. Not run: a real Windows box with Hermes in
WSL.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-08 23:03:19 +08:00
yetone 10578ce683 providers: the add sheet lists Partners first, above Subscriptions, under a heading that says they sponsor magpie; who they are comes from usemagpie.ai/api/partners (site/partners.js), so a paid listing changes with a deploy, not a release (yetone)
The app fetches the list behind the call every 6 hours (10 minutes after a
failure), never waiting, and keeps it in the cache folder. Each entry is
checked: its own id, never a built-in preset's; a name and an endpoint;
https only; at most 12; no decision API, no-key or user endpoint. Its logo
goes through FetchIcon. A partner is a preset to the rest of magpie, and
one that has left still is to the providers added from it, unsponsored.
The window lists a partner in the languages it names only, with its tagline
in the page's language; `magpie presets` and the TUI list them first too.
MAGPIE_PARTNERS moves the list, off lists none.

Run: go test -tags nogui -race ./internal/provider ./internal/gui
./internal/tui . (ok); TestPartners* -race -count=3; node --test
internal/gui/tests/partners.test.cjs add-sheet gui-ja gui-de i18n-keys in
Chromium and WebKit (en/zh/ja/de, 900 and 440px); node --test
site/worker.test.mjs site/install.test.mjs; the built binary in a sandbox
HOME against a local feed: listed, an http entry left out, listed from the
cache with the site down, `provider add` from it.
2026-10-08 22:52:30 +08:00
yetone 8db24fd62f gui: Routing folds three or more of a provider's keys for one model into one row that shows the count and their state, and opens in place to each key (#1319, tkhs101)
53 agnes-3.0-flash keys were 53 near-identical stage rows and 53 rows
under Accounts and keys. Now three or more keys of one provider standing
together for one model (same fixed effort, fallback and group-in-group
heading) fold into one stage row: "Agnes 53 keys agnes-3.0-flash", then
the lit key and what it does, "51 available · 2 resting". A request on a
folded key flies to the fold; a key failing and the next answering show
there (its 429 tag too). A click, Enter or Space opens it in place: each
key keeps its own row, state and wire, drawn from the fold. Accounts and
keys folds the provider's keys into one row with their summed tally and
models, and opens the same way. Open folds are kept per reader in
localStorage magpie.routingKeysOpen. Two keys stay two rows; one key is
as before. Display only: the gateway's order and choice don't change.
New strings in zh, zh-TW, ja and de. docs/subsystems/gui-shell.md says how.

routing-account-order's relay has three keys, so it now opens their fold
to read their order.

Run: routing-keys-fold.test.cjs (32: folded, open/persist/Enter, live
updates folded and open, Accounts and keys, 440px; Chromium and WebKit;
en, zh, ja, de) — fails on main (no fold). All routing-*.test.cjs,
panel-routing, click-scroll, fold-show-grip, gui-ja, gui-de, gui-zh-tw,
i18n-keys: 409 pass. Every other test that opens Routing (29 files, 152
tests) passes but purpose-filter's WebKit "returning focus does not scroll
the page", which fails the same alone on origin/main (off by 1px).
go vet -tags nogui ./internal/gui (darwin, GOOS=linux, GOOS=windows).
2026-10-08 22:20:22 +08:00
yetone 137ebee82f provider: a remote magpie's cards carry what was read behind the requests — the account switching's, the Codex warm-up's and routing's readings of each account (LoginUsage), routing's of each key (KeyAllowance) — and after a restart the readings kept in quotas.json, not only what the Usage page last read (#1313, jorben)
A magpie serving only other magpies (`magpie serve` in a container) has nobody on its Usage page, so GET /v1/magpie/quotas/cards, answered by CachedCards from the page's caches (subscriptionUsageCache, planQuotaCache, keyBalanceCache), said "nothing read on that magpie yet" while its Codex account's usage was read every few minutes behind the requests: those readings land in loginUsageCache (and a key's in keyAllowances), which CachedCards never looked at; after a restart even a page-read card was gone until the page was opened, though quotas.json held it.

CachedCards still asks nobody. It now lays each account's latest LoginUsage reading over its card when it is newer by readSeq (perLogin's cards are that same reading, so the page's own later read stands), adds a card for an account read that way and never on the page, named and drawn as the page names it (accountCard: perLogin now takes that face), and falls back to the reading kept on disk, dated (AsOf). Keys the same way: routing's KeyAllowance reading now keeps what was read with the windows (plan, balance, parts, readSeq) and is laid over the key's plan or balance card, or makes one; with no card read since start, the plan and balance cards kept in quotas.json are told. Cursor (known by asking its CLI), Grok (read by home) and a Kiro key keep the page's cards only. The local Usage page is unchanged.

Not fixed here: the quota history (/v1/magpie/quotas/history) is still written only by the page's reads.

Red before: with CachedCards as on origin/main and everything else in place, TestRemoteMagpieShowsBackgroundReadings (gateway, through lanGuard(New().Handler()) and a remote-magpie provider: CodexUsedUp, then RemoteCards) fails with the reporter's card ("nothing read on that magpie yet; refresh to have it read"), and TestCachedCardsTellBackgroundReadings, TestCachedCardsKeepALaterPageRead and TestCachedCardsTellKeyReadings fail; all pass with it. ForgetKeptCardsForTest is called from the gateway's fresh() so TestRemoteMagpieQuotas's "nothing read" holds under -count.

Run (Docker golang:1.26.8, the Mac's syspolicyd pegged): go test -tags nogui ./internal/... (internal/agent as uid 1000), go test -race ./internal/provider ./internal/gateway, go test -count=2 -run TestRemoteMagpie ./internal/gateway; go vet -tags nogui for darwin, linux and windows on internal/provider and internal/gateway; gofmt -l. No app.js change.
2026-10-08 20:48:49 +08:00
易良 ae39bc8ec8 agents: Qwen Code (qwen) as a managed agent (#1311)
* agents: Qwen Code (qwen) as a managed agent (#1279)

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

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

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

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

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

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

* agents: qwen's requests are attributed by their QwenCode/<version> User-Agent
2026-10-08 20:33:11 +08:00
mac0923 b56476b970 fix(provider): omit Anthropic version headers from OpenAI probes (#1312)
Reported by mac0923: Agent Space rejects Responses probes with anthropic-version as 400 invalid_headers. Add the default version only for Anthropic Messages while preserving custom headers through Sign.

TestProviderProbesUseProtocolHeaders fails before the fix across endpoint tests, model tests and both detection paths, then passes with the fix, including 20 race-detector runs. go vet and macOS, Linux and Windows builds pass.
2026-10-08 20:32:07 +08:00
yetone 2cdae80a10 stats: the day's event no longer names an omp profile or a model the user named; an omp profile goes as omp, and on a key's provider a model id models.dev doesn't list (an Ollama model, a fine-tune with the user's organisation in it) goes as <provider>/other
169c23c1 cut only `@wsl:` from agent ids, and 78d90571 (four minutes
earlier) made each omp profile an agent `omp#<name>`, so the profile
name went to PostHog. modelLabel sent `<preset>/<model>` for any model
on a preset's provider, and on Ollama, LM Studio or a fine-tune that id
is the user's own text. docs/reference.md promised "No names". Model ids
on subscriptions and plugins are the vendor's and are unchanged; known
models on a key's provider still go by id (deepseek/deepseek-v4).

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

Run in docker golang:1.26.8 (this Mac's syspolicyd is pegged and new
test binaries hang before start): gofmt -l, go vet -tags nogui on
stats/agent/catalog, GOOS=windows go build, go build -tags nogui, and
go test -tags nogui ./... — all pass except internal/agent's aside
chmod-0500 tests, which root ignores; internal/agent passes as uid 1000.
2026-10-08 20:24:57 +08:00
yetone 0470411122 providers: a provider has a Requests per minute limit beside Concurrency: each key or account sends the vendor at most that many requests in any rolling minute, one more waits for room (coeo91 on Discord: OpenRouter's free models take 20 a minute, which Concurrency 1 can't keep to)
Set in the provider editor (under Concurrency; empty or 0 is no limit) or
with `magpie provider rpm <id> [n|off]`; kept per provider as MaxRPM, a
subscription's with its picks. Over the limit the gateway waits until the
oldest request of the minute is a minute old, in the order they came,
after the lane's slot and right before sending. A wait longer than 2
minutes (or the provider's Queue wait, when shorter) is turned away at once
with a 429 and a Retry-After of when the minute has room; nothing rests for
it. An agent gone while waiting gives its place back and is never sent.

Every request that reaches the vendor counts: each try of the forward
loop (retries, the next key, fallbacks) waits in serve, and requests a try
makes on the way plus the gateway's own (count_tokens, System One
decisions, embeddings, drawing/video, Kimi search) are counted by an
rpmTransport on s.client. count_tokens never waits: a key with no room is
skipped and the local estimate answers. A key or account with no room in
its minute gives way to a ready one of the provider or the group, as a
full lane does (laneMate). No wait happens under rpms.mu. Not counted: a
plugin account's own extra requests (its own transport; its tries are).

Verified (Go tests on Linux in Docker, golang:1.26.8, since new binaries
hang at launch on this Mac while syspolicyd is pegged):
- internal/gateway rpm_test.go through the real server stack
  (lanGuard(s.Handler())): limit 2, third request reaches the vendor a
  window after the first, all 200; past the bound a 429 with Retry-After
  and no rest; a cancelled waiter is never sent and gives its place back;
  a 503 retry and a count_tokens count, and a count with no room is
  estimated; a group member with no room gives way. With RPMLimit forced
  to 0, those five gateway tests fail. -race -count=5 passes.
- internal/provider TestSetRPM (clamp, subscription picks reach each
  account), TestRPMCmd, internal/gui TestRPMSave.
- go test -tags nogui ./... on Linux: all pass except internal/agent's
  TestAside* write-refusal tests, which fail only as root in Docker and
  pass as uid 1000.
- go vet -tags nogui for linux, windows and darwin; gofmt -l clean.
- GUI: new provider-rpm.test.cjs (en/zh/ja/de, 360 and 440px, Chromium
  and WebKit; 32 pass, and all 16 Chromium cases fail on the old app.js),
  plus gui-ja, gui-de, gui-zh-tw, i18n-keys, provider-concurrency,
  lane-limit, provider-price-rate and clinepass-pin in both engines.
Not tried against the real OpenRouter.
2026-10-08 20:13:26 +08:00
yetone de5455b8fe gateway, usage, gui: a reply whose vendor sent its first words and held the rest to send in one burst tells no speed, not 1,367 tok/s (John on Discord)
John's request (OpenCode, a group served by Kimi Code at api.kimi.ai,
Chat) showed 1,367 token/s: its tokens counted over the time from its
first token to its end, though nothing came in between and the rest came
at once. The 100 ms / 10,000 tok/s bounds catch a burst only when the
first content comes with it.

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

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

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

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

Verified: TestAlmaServerKeyed (a Linux home with only
~/.local/share/alma and a fake Alma whose magpie provider has the old
key): Sync re-keys it to magpie-alma; XDG_DATA_HOME and ALMA_DATA_DIR are
followed, the desktop's folder wins, and macOS/Windows look only in the
desktop's. It fails without the change ("Alma's folder:
.../.config/alma, want alma-server's .../.local/share/alma").
TestAgentOf has alma-server's real User-Agent bytes: Alma with magpie's
Alma key, "ai-sdk" without. Go tests ran on Linux (golang:1.26 in Docker;
this Mac's syspolicyd held every new test binary at launch).
Not tried with a real alma-server, and an alma-server on another
computer, reaching magpie with a gateway key, still reads as the AI SDK:
its User-Agent says nothing of Alma.
2026-10-08 20:11:05 +08:00
yetone 56d1665251 library: a skill an agent has of its own is removed from every agent that has it, from its row under "In your agents" (trash) or with d twice in the TUI, without bringing it into the library first (#1303, sxwedo)
Until now the only action on such a row was Bring in: removing it meant
bringing it in and then removing it from the library. RemoveFoundSkill
(skills/remove-found) takes out of each agent that has it its folder, or
its copy of the very same files, and its links; its entry in the shared
~/.agents/skills, which agents read too, and a folder of it in the
library's own folder go as well. Nothing is deleted: folders go to the
backups (setAside), links are taken away, and a folder elsewhere that the
agents only linked to stays where it is. Another skill by that name
(Others) is a different skill and stays. The page asks first and says
which agents it leaves and what stays.

TestRemoveFoundSkill (library), TestLibraryPage's new part (TUI) and
remove-found-skill.test.cjs (GUI) fail without the change. The GUI test
fails 4 of 4 with library.js from before it, and passes in Chromium and
WebKit, in en, zh, ja and de, and at 440px wide.

Run: go test -tags nogui ./internal/library ./internal/tui ./internal/gui,
and go vet for linux, windows and darwin with -tags nogui, in Docker
(golang:1.26.8, a temporary HOME). The Mac's syspolicyd held test binaries
at launch. On the Mac, in Chromium and WebKit: remove-found-skill, gui-ja,
gui-de, i18n-keys, import-all-skills, own-skill, shared-skills,
library-own-folder, library-items, remove-all-skills,
library-desktop-skills, skill-name-many-agents and mobile-web, 133 of 133.
In one earlier run of that set at load ~40, mobile-web's WebKit "idle
scripts cannot scroll" failed once. It passed 3 of 3 alone with this
change, and passed in the full rerun. Not tried: the reporter's own agents,
and a WSL agent's folder.
2026-10-08 20:00:49 +08:00
yetone 169c23c18e stats: the day's event says which agents, providers and models magpie is used with, by magpie's own ids (a provider of the user's own, and its models, only "custom"), with how many of each and of routing groups: one "magpie uses" event per id in the same PostHog batch, so unique users break down by agent, provider and model. Settings → Privacy → Share the agents, providers and models I use turns it off (on by default; gone while Count me as a user is off); no names, URLs, accounts, keys or usage. go test ./internal/stats (payload, off switch, labels), the Privacy switch in Chromium and WebKit (en, zh), go test -tags nogui ./... in a temp HOME, go vet, GOOS=linux/windows builds pass; read against the real config: 9 agents, 24 providers, 25 groups in 0.75 s 2026-10-08 19:26:13 +08:00
Junhao Bai 78d90571cf agent: each named omp profile is an agent of its own, on Windows and in each WSL distro (#1233)
* agent: each named omp profile is an agent of its own, on Windows and in each WSL distro (#1187)

The Agents tab's omp row only ever wrote the one folder ompDir picks, so a profile started with omp --profile kept the model it had. ~/.omp/profiles/* is now listed per location: omp#<name> on Windows, omp#<name>@wsl:<distro> in a distro, each with the omp row's model, role and thinking controls writing that profile's own config.yml and models.yml. The profile the default row already points at (OMP_PROFILE) is not listed twice, and a stopped distro is not started to list its profiles: the names come from the probe and stay in wsl.json.

* gui: an omp profile's id is encoded in the Agents page's requests (#1187)

omp#<name> went into /api/agents/connect and the rest raw, so a browser read #work as the fragment and connected, disconnected or previewed the default omp row instead. api() now encodes each path segment, which is what agent-models and agents/cli already did for the id alone.

* agent, gui: an omp profile name is one omp takes, and only its # is encoded (#1187)

api() encoded every character of a path, so a query's ? = & became part of the path and Usage, quotas and Sessions 404ed. Only # is encoded now, which is what omp#<name> needs; a query stays a query. ompProfileOK also refuses the Windows device names omp refuses on every platform (aux, nul, com1, lpt9.x, con.work), and work. is checked through that rule rather than by making the folder, which Windows would turn into work.
2026-10-08 19:22:14 +08:00
yetone 9275abcddb gateway: Codex's compaction on a CC Switch relay table that magpie points at /v1 completes, with a summary magpie makes, instead of failing "remote compaction v2 expected exactly one compaction output item, got 0 from 1 output items" (#1301, ylacmilan)
Codex compacts remotely (compaction v2: a compaction_trigger item on
/responses, and exactly one compaction item back) for any provider table
named "OpenAI" (codex-rs ProviderCapabilities::from_config, is_openai), and
CC Switch names its relay tables so. While a magpie model is on, magpie
points those tables at /v1 (takeTables). /v1/responses relayed the trigger
as it came; the relay answered with a message, and Codex counted 0
compaction items out of 1. Only CodexPath turned the trigger into a summary
request (codexInput, codexCompact).

handle now does the same for a Responses request on /v1 that carries a
compaction_trigger: the model summarises, and the summary goes back as
magpie's compaction item. A later turn's magpie1: compaction item reaches
the model as the summary's text, not as an item the relay can't open.

TestCodexCompactionTriggerOnV1 fails without the change (one
output_item.done of type "message", the relay's reply, where Codex needs one
"compaction") and passes with it.

Real use: codex-cli 0.160.1 in a sandbox CODEX_HOME with
[model_providers.custom] name = "OpenAI", base_url = magpie's /v1 (built
from this tree, `magpie serve` in a sandbox HOME on 127.0.0.1:3592), model
token/gpt-6.1-sol on a fake Responses relay that reports 90K tokens and
model_auto_compact_token_limit = 1000. Old build: `codex exec resume`
printed the reporter's exact error, and the relay got the
compaction_trigger. New build: "context compacted", the relay got Codex's
compaction prompt, and the next two turns went on with the summary as a
message. go test ./internal/gateway, go vet (darwin, GOOS=linux,
GOOS=windows) pass. Not tried: the reporter's own relay and CC Switch
version.
2026-10-08 19:16:29 +08:00
yetone 0d55d9f6c2 provider, gateway: a Codex account at its usage cap is held from the next turn on: each ChatGPT reply's codex.rate_limits event and x-codex-* headers update the account's reading, and routing's reading ages from when it was made, not when routing took it (up to two minutes old before); the cap's tooltip says a turn under way can still pass it (#1295) 2026-10-08 19:14:17 +08:00
yetone 4b8bfd4676 gui: Routing's By session heads a Claude Code (and every other agent's) session with its own name, not its UUID (#1293, luzhuang)
Only Codex sessions were named (from Codex's index). The page now also asks
POST /api/gateway/session-titles for the other agents' sessions it lists,
as agent:id keys, and sessions.Titles reads each from the agent's own files
the way the Sessions page does: the name given in magpie, else the agent's
own title (Claude Code's ai-title / custom-title), else the first prompt.
Each key is asked once; the names are kept on the page, so a live update
that replaces the rows keeps them. A session with no file here keeps its id.

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

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

Verified:
- TestCodexCatalogForAGatewayKey through lanGuard(Handler) from a LAN
  address: required ModelInfo fields, unique slugs, held key sees only its
  models, no key / wrong key 401, /v1/models unchanged. Without the route it
  fails "404 ... magpie serves /v1/chat/completions ..."; with keyAllowed
  removed it fails "held key: [plan/m1 spare/m2]".
- TestCodexCatalogFitsCodexsLimit: 300 models -> 127 in 1047147 bytes, 173
  left out. Fails 404 without the route.
- Real Codex CLI 0.161.0 (npm) and 0.160.1 in sandbox CODEX_HOMEs against a
  sandbox `magpie serve` (0.0.0.0:3591, LAN on, two gateway keys) over
  10.0.0.111 with a fake chat upstream: models_cache.json holds the catalog
  (held key: fake/fake-coder only; free key: both), no "Unknown model"
  fallback warning, codex exec answered PONG-1281 through /v1/responses,
  also with the reporter's requires_openai_auth = true. 0.160.1 needs
  [features] api_key_model_discovery = true.
- go test -tags nogui ./internal/gateway ./internal/provider in a temp HOME:
  ok. Full ./...: 44 packages ok (internal/agent had timed out once
  under load; alone it passes in 45s). go vet; GOOS=linux and
  GOOS=windows vet of gateway+provider and nogui builds.
Not verified: Codex's TUI /model picker driven by hand.
2026-10-08 18:46:28 +08:00
yetone e387c5a2ed library: Alma takes the Library's MCP servers, in ~/.config/alma/mcp.json as its own MCP settings write them (#1292)
Alma 0.4.164 (read from a copy of its app.asar, out/main/index.js) reads
its servers from <home>/.config/alma/mcp.json on every system, as
{mcpServers:{name:{command,args,env} | {url,headers,transport?}}}: a
server with a url is streamable HTTP falling back to SSE, and SSE only
when transport is "sse". It has no ${VAR} expansion, so a server naming
a variable is refused for Alma as for Kimi or Devin. Alma reads the file
as it starts and when its MCP settings refresh; there is no watcher.

The user's own servers (the real file's computer-use entry with
ELECTRON_RUN_AS_NODE) are kept: TestAgentMCP's alma case writes one
first. Without the change TestAgentMCP/alma fails ("alma has no MCP
target") and TestTakesRefusesAlma refuses mcp.

Run: HOME=<temp> go test -tags nogui ./internal/library ./internal/agent;
go vet for darwin, linux and windows. Not tried with a running Alma.
2026-10-08 18:45:45 +08:00
yetone d226f87e4c gui: on KDE Plasma's Wayland session the quick panel has no KWin title bar or frame, as the main window has none (#1283)
GTK 3, which the Linux builds link, announces server-side decorations
over org_kde_kwin_server_decoration for every window that isn't
client-decorated, an undecorated (frameless) one too (gtkwindow.c
realize: announce_ssd), and KWin then draws its title bar on it. The main
window escapes it through plainTitlebar's hidden titlebar widget. The
panel now announces client-side decorations after each realize
(ownFrame: gdk_wayland_window_announce_csd), at start and when
lightweight mode makes it again. GTK 4 already asks for none, X11 and
compositors without the protocol never see it.

Run: on real KWin 6.3.6 Wayland (kwin_wayland --virtual with Breeze,
Debian trixie arm64 container, -tags gtk3 test binary): WAYLAND_DEBUG
shows request_mode(1) and KWin answers mode(1); a KWin script reads the
panel's frame 440x520 = its client 440x520. With ownFrame skipped:
request_mode(2), frame 442x557 over client 440x520 (KWin's title bar
and borders, the reporter's picture). go vet -tags gtk3 ./internal/gui
ok there; macOS go vet/go build ok; GOOS=linux/windows go vet -tags nogui
./internal/gui ok. Not tried: the reporter's Manjaro Plasma session, a
GTK 4 build, GNOME/Hyprland (no KDE protocol there, so unchanged).
2026-10-08 18:16:03 +08:00
yetone 23cd1bb609 gui: closing the quick panel from its title bar (or Alt+F4, a window manager's close key) hides it as Escape does, so the tray icon opens it again; it was closed for good and the icon did nothing until a restart (#1283, KDE Wayland)
The system's close reaches Wails as WindowClosing, which destroyed the
panel while h.panel and the tray still held it. makePanel now hooks
WindowClosing: the panel cancels and hides; one lightweight mode let go
(h.panel no longer it) still closes.

Run: TestPanelCloseHidesIt (AppKit, new) passes; with the hook's check
forced to let the close through it fails "panel close: the panel closed
for good; the tray icon can't open it again". On real KWin 6.3.6 Wayland
(kwin_wayland --virtual, Debian trixie container, GTK 3 build) a KWin
script closeWindow() on the panel (what its title bar X does): with the
hook alive=true and a second Show is visible; without it alive=false and
Show shows nothing. go test ./internal/gui ok (darwin, GUI tag); go vet,
go build ok; GOOS=linux/windows go vet -tags nogui ./internal/gui ok,
GOOS=windows go vet ./internal/gui ok. go test -tags nogui ./...: all ok
but internal/agent (11m timeout) and internal/plugin
TestHostRunsAsMagpieBun under load average ~110; neither imports
internal/gui, and TestHostRunsAsMagpieBun passes alone. Not tried: the
reporter's own Manjaro Plasma session; Windows' Alt+F4 on the box.
2026-10-08 18:16:03 +08:00
XYenon b66481a953 provider, gateway: Volcengine plans expose their own models by kind (#1265) 2026-10-08 18:15:54 +08:00
yetone c28a819a8e library: an agent that can't be given instructions, skills or MCP servers says where they go instead and lists the agents here that take them: OpenChamber has OpenCode's (give them to opencode), Claude Desktop keeps its instructions in its own settings and its Code tab reads Claude Code's (MOMO on Discord)
OpenChamber's server reads the global AGENTS.md, skills folder and
opencode.json from OpenCode's config folder (openchamber/openchamber
packages/web/server/lib/opencode/shared.js at 866dffd), so no path of its
own is added; a second target on the same files would write them twice.

Verified: TestTakesSaysWhereInstead fails on the old Takes with "Claude
Desktop has no user-wide place for instructions that magpie knows of";
internal/library passes in a sandbox HOME.
2026-10-08 18:13:11 +08:00
yetone 7712ef8e7c gateway: a routing group is served by its name too, so a client the user typed a renamed group's name into (ZCode) reaches it rather than 404 "magpie knows no model"; magpie group set <id> name=… says the id stays and what agents ask for (MOMO on Discord)
provider.GroupFor tries the group's id and the model's spellings first, as
before, then the one group whose name (by its slug) is the id asked. A
name two groups share names neither, and a model a provider serves by
that id stays the provider's.

Verified: TestGroupAskedByItsName fails on the old GroupFor with 404
"magpie knows no model \"DS Flash\""; TestGroupRenameSaysTheIDStays fails
without the message. internal/provider and internal/gateway pass in a
sandbox HOME.
2026-10-08 18:13:11 +08:00
yetone 3d5b212f6a cli: magpie provider models <id> +model adds a model to those exposed and -model takes one out, the rest kept; the whole-list form still replaces, and now says so with the count before and after and how to add one instead (MOMO on Discord: 35 exposed became 8 with nothing said)
provider set models= says the same. Taking out a model that isn't
exposed, mixing +/- with plain ids, or leaving none exposed (which would
mean the default list) is an error that saves nothing.

Verified: TestProviderModelsAddsRemovesAndSaysWhatItReplaced fails on
the old code ("+deepseek-flash left [+deepseek-flash]") and passes with it.
2026-10-08 18:13:11 +08:00
yetone d2241a5798 agent: Copilot in JetBrains IDEs takes magpie's models as a Bring Your Own Key custom endpoint provider, written into the Copilot language server's byok.json beside the user's own (KamToHung on Discord) 2026-10-08 18:12:10 +08:00
yetone d1fca72f69 library: the Library's MCP servers go to WorkBuddy and CodeBuddy Code (#1266)
WorkBuddy had skills only, and CodeBuddy Code had no library target at all,
so their MCP chips could not be switched on.

WorkBuddy 5.5.6 (app.asar) reads ConnectorService.customMcpConfigPath:
$WORKBUDDY_CONFIG_DIR, then $CODEBUDDY_CONFIG_DIR, then ~/.workbuddy, plus
mcp.json, as {"mcpServers": {...}} in Claude's shape. It connects a server
only once the user has switched it on in its MCP settings
(mcp-approvals.json, module.app-server.js). magpie doesn't write that
approval. Instead, the servers page tells the user to switch it on once,
in en/zh/zh-TW/ja/de.

CodeBuddy Code 2.162.0 (PathUtils.resolveMcpFilePath) reads the first of
<dir>/.mcp.json, <dir>/mcp.json and <base>/.codebuddy.json that exists.
<dir> is $CODEBUDDY_CONFIG_DIR or ~/.codebuddy. The file holds mcpServers
in Claude's shape, and ${NAME} is expanded (McpConfigEnvExpandService).
User scope needs no approval. Skills go in <dir>/skills.

Verified: the real CodeBuddy Code 2.162.0 CLI, in a sandbox HOME, read
SaveServer's file back with `mcp get` and `mcp list`. That covered stdio
with ${MY_TOKEN} expanded, http and sse, and the user's own entry and
disabledMcpServers were kept. WorkBuddy.app was not run; its side is
from its code.

Tests: TestAgentMCPFiles (4 new cases) and TestCodeBuddyMCPFile fail
without targets.go. library-workbuddy-trust fails without library.js.
Run: go test ./internal/library ./internal/gui; go vet for darwin, linux
and windows; in Chromium and WebKit: gui-ja, gui-de, gui-zh-tw, i18n-keys,
click-scroll and all library-* GUI tests.
2026-10-08 17:12:07 +08:00
wizzy-yangandwizzy-yang e5e87d980a 支持 Ante:把 magpie 作为 provider 写进 ~/.ante/catalog.json (#1210)
* agent: Ante takes magpie as a provider in ~/.ante/catalog.json

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
2026-10-08 16:58:41 +08:00
yetone e52d1cb820 docs: README rewritten for what magpie does now — stat line, flow diagram, warm-up, context, image MCP, keep awake, agent updates, all 47 agents; "coding agent" is now just "agent"; per-agent notes moved to the reference 2026-10-08 16:36:33 +08:00
yetone b6d88ce66e provider, gui: each of a provider's keys can go through a proxy of its own, as a subscription's accounts can (Beyfish_Wang on X: 不同 key 走不同的代理)
A provider of keys kept one proxy for all of them: Save dropped
AccountProxies for it. Now AccountProxies holds a key's proxy by its
KeyID, ProxyChoice reads it for the key a try uses (WithKey), and Save
keeps only the keys the provider still holds. The key editor's Proxy
row has a line per key (Provider's proxy | Direct | Custom), named as
the Accounts list names it, when the provider has two or more.

Run:
- go test ./internal/provider ./internal/gui ./internal/gateway: ok;
  TestKeyProxy fails without the provider change ("kept map[]").
- account-proxy.test.cjs, Chromium + WebKit, en + zh: 16/16; the new
  key cases fail without the app.js change. Also at 440px: 16/16.
- gui-ja, gui-de, i18n-keys, account-models, provider-many-keys,
  provider-typed, provider-proxy: all pass.
- Built binary, `magpie serve` in a sandbox HOME, three keys, four
  local proxies: k-1 (429) through its own proxy, k-2 (429) through its
  own, k-3 through the provider's; the global proxy got nothing.
2026-10-08 16:17:23 +08:00
Zhu Wang f00ed2901c routing: filter by several purposes with checkboxes (#1247)
* routing: filter by several purposes with checkboxes

Ticking a purpose immediately shows the union of the selected purposes while keeping the menu open. All purposes, Clear filter and removing the last selection restore every purpose. Counts, the story and history follow the same list.

Live menus ignore renderer-induced scrolling while deliberate outside scrolling still dismisses them. Update the GUI shell reference and browser coverage for multi-purpose counts, keyboard toggling, history, aliases and narrow windows in five languages.

Verified 115 isolated Playwright checks in Chromium and WebKit, JavaScript syntax and git diff --check. The new checkbox case fails against the old assets with 0 checkbox choices instead of 5; restoring the old menu scroll handler separately detaches the menu during selection in WebKit.

* routing: All purposes returns keyboard focus to its button

Address the keyboard-focus regression reported in PR #1247. Choosing All
purposes closes its menu and focuses the purpose button without scrolling.
Unticking the last purpose keeps the live menu and its checkbox focused.

The expanded checkbox test fails without the focus restoration in Chromium
and WebKit: All purposes returns keyboard focus to the purpose button
(false !== true). With the fix, 162 isolated Playwright checks pass across
both engines, including five locales, narrow widths, purpose state,
history, request/session grouping, Usage and scroll/menu regressions.
JavaScript syntax and git diff --check pass. The full GUI/Go suites and the
packaged desktop app were not run.
2026-10-08 16:02:17 +08:00
yetone 01e66e0f4a gateway: a reply a Claude subscription account answered carries its anthropic-ratelimit-unified-5h/7d headers, so a Claude Code signed in to claude.ai shows the 5-hour and weekly limits in its status line (claude-hud, #1257)
Claude Code tells the allowance in its rate_limit_event, not as headers, so
magpie says what it keeps of the account again as Anthropic does: a share
0-1 and a Unix-seconds reset per window, set as the request comes in and
again as the reply begins, after the turn's own event.

Claude Code 2.1.293 reads them only when it is signed in to claude.ai (the
sign-in field's claudeai); with magpie's key it ignores them.

Run: go test -tags nogui ./... (temp HOME); the two new tests fail without
limitHeaders; GOOS=linux/windows go vet of gateway and provider; real
Claude Code 2.1.293 in a sandbox HOME against a fake server sending these
headers: its statusline stdin had rate_limits.five_hour/seven_day with
used_percentage 42/17 when OAuth-signed in, none with ANTHROPIC_AUTH_TOKEN.
Not run: a real Claude account through a running magpie.
2026-10-08 15:28:52 +08:00
yetone db0feb3615 library, gui: a remote MCP server's headers and a command's env can name ${NAME}: the library keeps the placeholder and each agent gets its own syntax, so the token is never written into an agent's settings (#1250)
The library keeps `${NAME}` as typed. refsOf (internal/library/envref.go)
gives each MCP format the agent's own syntax, with the source beside each:
${NAME} for Claude Code, Droid, Grok, Gemini, Crush, Copilot, Pi, OmO, omp,
Qoder, AtomCode, Hermes and Command Code; {env:NAME} for OpenCode and MiMo;
${env:NAME} for Cursor; Codex's bearer_token_env_var, env_http_headers and
env_vars; Goose's ${NAME} plus env_keys. An agent with no syntax (Claude
Desktop, Antigravity, Kimi, Cline, ZCode, dsh, Devin, Pi with
pi-mcp-extension) is never given such a server: supports() refuses it with
the reason, an entry magpie wrote before is taken out, and its chip is
greyed with the reason on the row and in the editor. Codex's and Goose's
extra keys are magpie's only while the server names a variable, so the
user's own env_vars / env_keys stay. The editor says how to write one under
Headers and Environment; a check whose variable isn't set where magpie runs
says which one ("GH_TOKEN not set").

Verified:
- go test -tags nogui ./... (sandbox HOME): all ok.
- New TestEnvRefs* (per-agent encode/read-back, left-out agents, library
  keeps the placeholder, user values kept, health check novar) fail with
  the behaviour turned off (refsOf -> ${NAME} for all, no refsProblem /
  refKeys, no expansion).
- library-env-refs.test.cjs (Chromium + WebKit, en/zh/ja/de at 440px)
  fails on the old library.js (4/4 subtests); gui-ja, gui-de, i18n-keys,
  library-servers-bulk, library-all, by-agent, project-servers,
  library-mcp-health, library-null-agents, library-items: 109/109 pass.
- Real: magpie built from this tree synced a library server with
  Authorization: Bearer ${FAKE_MCP_TOKEN} and X-Api-Key: ${FAKE_KEY} into a
  sandbox HOME, then against a fake MCP on 127.0.0.1:3561 that answers only
  the right bearer: Claude Code 2.1.293 `mcp list` Connected, Gemini CLI
  0.62.0 Connected, Codex 0.160.1 app-server listed the tool (authStatus
  bearerToken), cursor-agent sent both headers expanded; no agent file held
  the token. Antigravity got the Problem and no entry. Not run for real:
  OpenCode (its mcp list goes through the user's background service),
  Goose, Crush, Copilot and the rest; their syntax is from their source.
- go vet (darwin, GOOS=linux, GOOS=windows) for internal/library and
  internal/gui.
2026-10-08 14:04:21 +08:00
如是 1b8c33b5e2 provider: a routing group reasons when any member does, not only when all of them do (#950)
* provider: a routing group reasons when any member does, not only when all of them do

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

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

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

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

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

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

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

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

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

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

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

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

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

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

gofmt, go vet ./..., go build ./..., GOOS=linux go build -tags nogui ./...
and GOOS=windows go build ./... pass. The review's own set passes —
go test -count=1 -run 'TestGroup|TestWithoutReasoningAsk|TestEntryQuiet|
TestModelsReasoning|TestModelsListReasoning|Thinks|TestPluginThinking'
./internal/provider ./internal/gateway — and so does internal/provider whole
(208s). internal/gateway passes in chunks (Test[A-D] 340s, Test[S-Z] 216s,
Test[^A-DS-Z] split over TestEffort|TestResponse|TestReasoning|TestQuiet|
TestTrace|TestRoute|TestRules|TestRule|TestFixed|TestFast 38s and the rest
earlier): the whole suite in one process still hangs here on an unrelated
test (TestCodexBodyReadsPastOldLimit this time, TestRuleDecidesAtTurnStart
AndHolds and TestRequestBodyProgressRenewsIdleDeadline before), which the
base commit does too and which passes alone in 0.9s.
2026-10-08 13:47:13 +08:00
yetone 5d0c1dcad7 agent, library, sessions: a Claude Desktop installed as an MSIX package on Windows is found, wired, given MCP servers and skills, and its Cowork calls read, in the package's folders (Kilig on Discord, Windows 10 LTSC 21H2)
Desktop from its setup.exe there runs packaged, so Windows keeps what it
writes under %APPDATA% and %LOCALAPPDATA% in
%LOCALAPPDATA%\Packages\Claude_<publisher>\LocalCache\{Roaming,Local}.
Nothing was in %APPDATA%\Claude or %LOCALAPPDATA%\Claude*, so magpie didn't
recognise Desktop at all. The packaged app reads its own copy before the
real folder's, so writing the usual folders wouldn't reach it either.

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

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

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

Run: go test -tags nogui ./... under a temp HOME with pinned caches (all
ok); GOOS=darwin|linux|windows go vet -tags nogui on desktopdir, agent,
library, sessions, usage; GOOS=windows go build, GOOS=linux go build -tags
nogui. On the Windows 11 box (Squirrel install, no MSIX package): the
windows test binaries pass every Desktop/MSIX/Cowork test; the full agent
and sessions binaries fail the same 16 and 76 tests (run outside their
testdata) as origin/main's; a read-only probe of desktopdir.Here() gives
the usual %APPDATA%\Claude, %LOCALAPPDATA%\Claude and Claude-3p, Package "".
Not tried against a real MSIX Desktop: none on the box, and Claude-3p's
place in the package (LocalCache\Local, as Local\Claude is in Kilig's
listing) is inferred, not seen.
2026-10-08 12:47:48 +08:00
Strelizia 69b6772794 provider: Qoder check-ins follow the campaign window, so a campaign open past Beijing midnight is claimed and the next one isn't missed (#1173)
Qoder and Qoder CN campaigns run on [startAt, endAt) windows that cross Beijing midnight, so a campaign claimed yesterday could stand in for the whole new day and the next campaign was never claimed.

- A campaign is active by its upstream window, so one still open after Beijing midnight can be claimed.
- A saved result records the campaign's end in `until`, and is reused only on the same Beijing day, within 30 minutes and before that end; after that the list is read again (about 48 list reads per account per day, `inactive` included).
- Manual check-in always verifies upstream; failures and older records without `until` keep the old retry behaviour.
- Only Qoder sets `recheck`; WorkBuddy, Trae CN and MiniMax keep their once-a-day settlement.
2026-10-08 12:46:41 +08:00
yetone 3aeb7d1bf8 gateway: a model a key's plan doesn't serve rests that model alone, not the key (#1235)
SenseNova answers a Token Plan key asked for deepseek-v4.1-flash with
403 "model is not available in the current token plan" while the same
key serves deepseek-v4-flash. That refusal was a plain failure: it
rested the whole key, its backoff counted per key and doubling up to
10 minutes, so an agent that kept asking for the model left every key
of the provider resting for every model (Sun1090: both keys "失败 · 9
分钟后恢复").

A 400/403/404/422 whose body says the model isn't served
(unservedWords), with no quota, credit, rate-limit or channel words,
now rests only that key's or account's model (modelRefused, keyed by
restID, which is now restKey/model for every candidate), its backoff
counted by the model and cleared when the model answers. The key's
other models stay first in order, and its row shows no rest.

TestModelNotInPlanRestsTheModelNotTheKey (two keys, four requests for
the refused model, SenseNova's error shape) fails without the change:
"key One rests for a model its plan lacks: {Why:other Status:403 ...
By:backoff Failures:3}" and "deepseek-v4.1-flash doesn't rest". With
it: go test -tags nogui ./... under a temp HOME passes; -race -count=5
over TestModelNotInPlan|TestSeveralKeys|TestFallback|TestRouting|
TestPool|TestGroup passes; go vet (darwin, GOOS=linux, GOOS=windows,
nogui) passes. Not tried against a real SenseNova key: the body's
"code" field in the fixture is a guess; the message is the reporter's.
2026-10-08 12:26:53 +08:00
Alex be1ce5d4d2 davsync: incomplete S3 listings keep cached usage days (#1243)
Reject truncated listings with missing or repeated tokens and listings that exceed the page limit. On the old code the regression reports successful partial listings and shareWith removes a cached day. Focused tests pass in a temporary HOME.
2026-10-08 11:52:26 +08:00
Alex 2d9db1b60d edit: malformed JSON configs stay untouched when agents connect (#1242)
Validate the complete comment-stripped document before object and array edits, including previews. Regression tests fail on the old implementation because edits and VS Code Connect succeed and alter malformed files. Targeted edit and VS Code tests pass in an isolated HOME.
2026-10-08 11:52:20 +08:00
yetone 814dca9390 gateway: the ChatGPT backend's 502 "response protection is unavailable" is the request's, not the account's: asked once, no account rests, and a magpie-model compaction refused so is asked again as text (vs on Discord)
A long Codex conversation's compaction refused so went to every ChatGPT
account in turn, each rested on a growing backoff, then the last was asked
twice more: 5 upstream tries per Codex request with 3 accounts, times
Codex's own retries. Now the provider's other accounts and models are
not asked it, nor the same one after a moment; another vendor left in a
group is asked once. A reply broken off with it rests nobody either.
codexCompact falls back to plainCompact on it, as vs's control experiment
(the same history as text) was answered.

TestProtectionRefusalAskedOnceNobodyRests fails on the old code with
"asked 5 times ([acct-1 acct-3 acct-2 acct-2 acct-2]), not once" and two
accounts resting; TestProtectionRefusedCompactionAsText with status 502.
The error body's exact bytes are not the reporter's; no real ChatGPT
account was tried.
2026-10-08 11:34:33 +08:00
Kenoia ad3b7fe5c2 agents: keep Air provider fields on sync and model picks (#1239) 2026-10-08 11:20:35 +08:00
yetone 70ec514872 provider: a Copilot editor token GitHub refuses gives way to the Copilot CLI's sign-in of the same account, and a sign-in from magpie of that account is kept (#1238, eightHundreds)
A stale oauth_token for octocat left in ~/.config/github-copilot/apps.json
was read before everything else, so its 401 Bad credentials left Copilot
with no models although the Copilot CLI was signed in to octocat and
worked; signing in again from magpie didn't help, since the new token was
let go as the editors' own account.

- copilotToken remembers an editor token whose trade GitHub refuses (401 or
  403; tried again after an hour, or as soon as one trade works).
- copilotApp.session then uses the CLI's sign-in of the same login on the
  same host, sent as the CLI sends it, and copilotLogin reads that sign-in
  for the account from then on. A CLI signed in to another account is never
  used in its place; the error names the editors' file, and that the CLI is
  another account.
- The Usage card (loginQuota) reads the CLI's sign-in the same way when the
  editor token gets 401.
- The device sign-in asks GitHub about the editors' token of the same account
  first (copilotProbeEditor); refused, addCopilotLogin keeps magpie's token,
  standing for the account. With a working editor token it is let go as
  before.
- TestMain stubs copilotCLISecret, so no test reads the runner's keychain.

Tests: copilot_stale_editor_test.go (fake exchange, user and API servers,
temp HOME). Without the fix TestCopilotStaleEditorFallsBackToCLI and
TestCopilotStaleEditorNotAnotherAccount fail "Copilot is signed out (Bad
credentials); sign in to Copilot again", TestCopilotSignInKeptOverStaleEditor
lists octocat with the editors' gho_x, TestCopilotStaleEditorUsageReadsCLI
reads "Unauthorized". Not tried with a real stale apps.json or a real
Copilot CLI login.
2026-10-08 11:17:39 +08:00
yetone 1efa4c642f gateway: a Codex lead's turn carrying its subagent's sealed reply goes back to the Responses provider that answered the lead, instead of being refused as a subagent task "sealed by the ChatGPT backend" (#1237)
SuperrNaruto (#1237), and PennyVibe on #1109 with v0.1.1108: Codex
with the lead and its subagents all on one custom Responses provider
serving gpt-6-astra. #1109's fix sends the subagent's sealed task back
to that provider, and the subagents ran. But when a subagent answers
the lead with send_message, Codex hands the lead an agent_message to
/root whose payload the provider sealed, and hasSealedAgentMessage
marks the lead's next turn as a sealed task. The lead has no parent
thread, so leadProvider found nobody, sealedReader accepted only a
ChatGPT account, and the lead was refused before anyone was asked with
"This subagent's task was sealed by the ChatGPT backend that answered
its lead ... is neither". The main conversation stopped while the
subagents finished.

The providers that can read a sealed message are now those that
answered the lead (the parent thread, as before) and the one that
answered the request's own conversation (leadProvider of its
conversationID): a subagent sent a task sealed by the lead's provider
had to be on that provider, so its reply was sealed there too. A lead
moved to another provider, one magpie never answered, and a subagent
whose lead a ChatGPT account answered are still refused before anyone
is asked, with the same messages.

Verified: TestSealedReplyGoesBackToTheProviderThatAnsweredTheLead
(lead, subagent, then the lead with the subagent's sealed FINAL_ANSWER
reply, all on third/gpt-6-astra with Codex's web_search tool) fails on
origin/main with the reporter's exact 400 and passes with the change;
the reply's ciphertext reaches the provider as it came.
TestSealedReplyRefusedWhereTheLeadWasNotAnswered and
TestSealedTaskFromChatGPTLeadStillRefused (lead on gpt-5.5 through the
ChatGPT sign-in, subagent on the custom provider) pass on both. go test
-tags nogui ./internal/gateway passes; the sealed tests pass under
-race -count=5; go vet -tags nogui ./internal/gateway passes for
darwin, linux and windows. Not tried with real Codex and a real relay
of the ChatGPT backend: the reply's item is built from the
"Message Type: FINAL_ANSWER / Task name / Sender / Payload" layout in
the codex 0.160.1 binary and PennyVibe's description, not from a
captured request.
2026-10-08 11:13:11 +08:00
yetone 40c5b92a93 gateway: structured output crosses every translation: a Responses text.format or a Chat response_format reaches a Chat, Responses, Messages or Gemini upstream in its own words (frostming on Discord: responses.parse through magpie to OpenRouter came back as plain text)
The IR held a schema only from Anthropic's output_config.format, and only
Messages (and Claude Code's --json-schema) were asked for one. A Responses
request to a provider magpie speaks Chat or Messages to (OpenRouter has no
Responses URL) lost text.format; a Chat request translated to Responses or
Messages lost response_format; an Anthropic schema translated to Chat or
Responses was lost too.

Format (format.go) now carries json_schema (name, description, schema,
strict) or json_object from Responses, Chat, Anthropic and Gemini, and each
builder asks for it in its upstream's field. Where a target can't say it
(json_object on Messages or Claude Code; the vendor bridges Cursor, Devin,
Kiro, Qoder, Command Code, Zed) it is told in the system prompt. An upstream
answering 400/422 naming the field (DeepSeek's "This response_format type is
unavailable now") is asked again with it told in words, and so from then on.
Relayed requests are unchanged.
2026-10-08 10:37:02 +08:00
forever ac142bb1d2 agent, gateway: Claude Code through magpie can keep its claude.ai sign-in (#1218)
* gateway: Claude Code signed in to claude.ai is told a provider's 401 as a 502, not as its own sign-in refused

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

grokLoginUsage wrote every per-home reading back with a fresh time, so one
out when StaleAllowance("grok", user) dropped the home's entry was kept and
served for up to a minute. Each home's reading now carries a pending
generation that StaleAllowance drops; a reading whose generation no longer
matches goes to its caller but isn't cached, as loginReading does for the
other agents. A Grok moved to its plugin goes through loginReading and was
not affected.
2026-10-08 08:23:20 +08:00
yetone 84f37ac977 provider: a Copilot account renamed with its host keeps its proxy, models, cap and concurrency, moved from its bare login before the new name is written (#1220)
Before #1220 a bare Copilot login named one account (a same-named one
wrote over it), so the per-account settings in providers.json kept under
it are that account's. When an account is renamed login -> login@host
(an old enterprise record read by nameAlike, or the editors' own
enterprise account renamed in sideLogins), renameTo remembers the old
name (savedLogin.was, never written) and pins the account's stable id
from it. writeLogins then moves accountProxies, accountModels,
accountCaps and accountConcurrency of the copilot provider from the old
key to the new (moveAccountSettings), and writes the new name only once
that has worked (renameSettled):
- a setting the new name has already stays, and the old with it;
- an old name another account still bears is left to that account;
- providers.json that can't be read or written stops the move, and the
  account is written under its old name, its settings with it, retried
  a minute later (renameDue) rather than at every listing.
sideLogins writes a pending rename the first time copilot is listed,
on a copy so the listing keeps its order.

Not moved: usage history, quota history, usage-alert marks and the
GUI's expanded/brief card state (localStorage) - records and view
state, not settings. Gateway keys' account lists go by the stable
login id, which is kept.

Tests (each FAILs with moveAccountSettings stubbed to do nothing):
- TestCopilotRenameKeepsSettings: proxy, cap, models, concurrency under
  "mona" are mona@a.ghe.com's after the upgrade; a github.com mona added
  after has neither proxy nor cap.
- TestCopilotOwnGHERenameKeepsSettings: the editors' own GHE account
  keeps cap and proxy.
- TestCopilotRenameWaitsForSettings: an unreadable providers.json is
  left as it is and logins.json keeps "mona"; once readable, the
  settings move and the new name is written.
- TestCopilotRenameKeepsNewNamesSetting.

Ran: rebased onto origin/main (b6f33753); gofmt -l clean; go vet -tags
nogui . ./internal/provider ./internal/gui ./internal/gateway for
GOOS=darwin, linux, windows; go build ./...; GOOS=linux/windows go
build -tags nogui .; go test -tags nogui -count=1 ./internal/provider
./internal/gui ./internal/gateway . under a temp HOME with pinned
caches and pipefail, all ok; -race -count=5 -run TestCopilot
./internal/provider ok.
2026-10-08 07:27:03 +08:00