2515 Commits
Author SHA1 Message Date
ggbdpqandfactory-droid[bot] d81706f974 gateway: a Chat or Responses client's xhigh on Claude Opus 4.7 and later, Sonnet 5 and later or Haiku 5.5 goes as xhigh, not max
buildAnthropic asked every Claude that thinks only adaptively for max
when the client asked xhigh, on the grounds that 4.6 has no xhigh. Only
Opus and Sonnet 4.6 lack it: Anthropic's effort docs list xhigh on
Fable 5 and 5.1, Mythos 5 and 5.1, Opus 5.5, 5, 4.8 and 4.7, Sonnet 5.5
and 5, and Haiku 5.5, and describe max as no constraint on token
spending. So Codex, Cursor or any Chat client picking xhigh on
claude-opus-5-5 was billed for max. Claude Code's own levels in magpie
(claudeEffortsFor) already say only Opus and Sonnet 4.6 skip xhigh.

Now xhigh becomes max only on 4.6 (noXhigh, which reads the version
through claudeVersionOf, shared with adaptiveOnly). A relayed thinking
budget is unchanged: budgetOf gives xhigh and max the same budget, and
31999 is Claude Code's old ultrathink, so a budget above high still
goes as max. docs/subsystems/gateway-routing.md now says which models
are asked adaptively (#1470) and where xhigh stays xhigh.

What was run: TestXhighStaysXhighWhereClaudeTakesIt (Chat and Responses
through the server stack to a fake Anthropic upstream) fails on the old
main with output_config = map[effort:max], want "xhigh" for Opus 5.5
(Chat and Responses), Opus 4.7, Sonnet 5 and Haiku 5.5, and passes with
the change; Opus and Sonnet 4.6 get max before and after, and a Fable
5.1 row asserts xhigh. TestAdaptiveThinking's Opus 5.5 xhigh case now
asserts xhigh, beside a 4.6 case asserting max. Broken by hand (noXhigh
always false) the 4.6 cases fail. gofmt -l prints nothing; go vet -tags
nogui ./... passes for darwin, linux and windows; go test -tags nogui
-count=1 ./... under a temp HOME passes. Through the aihubmix relay,
claude-opus-5-5 with adaptive thinking and effort xhigh answers 200.
Not tried against Anthropic directly (no credential outside the
keychain); a relay that checks effort against an older list could still
refuse xhigh.

Reported and fixed by ggbdpq (#1471).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-10-10 15:17:13 +08:00
ggbdpqandfactory-droid[bot] 55ac9ec5d2 gateway: Claude Fable and Mythos are never sent a thinking budget, so a Chat client's reasoning_effort or a relayed budget no longer gets their 400
adaptiveOnly read a Claude's version with claudeVersion, which knew
opus, sonnet and haiku only, while claudeLine (thinkingOffOf,
samplingRefused) already knew fable and mythos. So claude-fable-5,
claude-fable-5-1, claude-mythos-5 and claude-mythos-5-1 were taken for
models that take thinking.type=enabled with budget_tokens:
buildAnthropic turned a Chat or Responses client's reasoning_effort into
a budget, forwardOnce's adaptiveThinking relayed an agent's budget as it
came, and AdaptiveThinking didn't tell Pi (forceAdaptiveThinking), dsh
or omp (anthropic-adaptive) to ask adaptively. Anthropic documents that
Fable 5 and 5.1 and Mythos 5 and 5.1 answer thinking.type=enabled with
budget_tokens with a 400 and take only {"type": "adaptive"} with the
effort in output_config.

claudeVersion now reads fable and mythos too, so all three paths treat
these models as Opus 5.5 is treated. claude-mythos-preview has no
version in its id and is left as it was.

What was run: TestFableAndMythosNeverGetABudget (a fake Anthropic
upstream answering 400 to enabled) fails on the old main: adaptiveOnly
false for all five ids, and status 400 for a relayed Fable budget, a
Chat client's reasoning_effort on Fable and a relayed Mythos budget. It
passes with the change, as do TestAdaptive*, TestThinkingOff*,
TestSampling*, TestClaudeFiveFive* and TestHaikuFiveFive*. Broken by
hand (mythos dropped from the regex) the Mythos cases fail again.
gofmt -l prints nothing; go vet -tags nogui ./... passes for darwin,
linux and windows; go test -tags nogui -count=1 ./... under a temp HOME
passes.

Not tried against Anthropic itself: the owner's Claude sign-in keeps
its token in the keychain, which a sandbox doesn't read (and must not
refresh), and the one relay with Fable listed (aihubmix) has no balance
for it; that relay also takes a budget on Opus 5.5, so it doesn't show
Anthropic's refusal either.

Reported and fixed by ggbdpq (#1470).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-10-10 15:17:11 +08:00
ggbdpqandfactory-droid[bot] 8714abd45d agent: TestUpdateWithBunBridgesSOCKS reads only the proxy variables it sets, so a machine with NO_PROXY or NODE_USE_ENV_PROXY set no longer fails it
The test collected every variable whose name ends in _PROXY from
updateEnv's environment and required each to be a bridged
http://127.0.0.1 URL for bun. updateEnv passes the rest of the
environment through, so a NO_PROXY of the machine running the test
("bun is given localhost,127.0.0.1") or Node's NODE_USE_ENV_PROXY=1
("bun is given 1") made it red, though neither names a proxy. It now
reads the six proxy variables it sets (the ones netproxy.EnvForBun
bridges, in either case). Test-only; updateEnv is unchanged.

What was run: on the old main the test is red with
NO_PROXY=localhost,127.0.0.1 and, separately, with NODE_USE_ENV_PROXY=1
in the environment, green with neither. With this change it is green
with both set. Broken by hand (updateEnv's netproxy.EnvForBun call
removed) it fails with "bun is given socks5://127.0.0.1:4401". gofmt -l
prints nothing; go vet -tags nogui ./... passes for darwin, linux and
windows; go test -tags nogui -count=1 ./... under a temp HOME passes.
Not run on the Windows box.

Reported and fixed by ggbdpq (#1472).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-10-10 15:17:08 +08:00
yetone ddd4f43144 provider: a routing group can be duplicated, from its menu on the Routing page or with magpie group copy (lc on Discord)
provider.CopyGroup(id, name) saves a copy of the group as it is now: its
models and patterns, routing, stays, rules, classifier, effort, context,
levels, and fast and off members. The copy is deep, so its rules' time
windows aren't shared. Its name is the one given, or "<name> copy", with a
number added past a name in use. Its id comes from the name, numbered past
any id in use, removed ones included. It is listed right after the group
it copies. The copy is a group of the user's and switched on, even when
it copies a found group or a switched-off one. The original is unchanged.

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

Run:
- TestGroupCopy (internal/gui): the copy has the models, pattern,
  routing, rule and off member, is on, and is listed after the original.
  The original stays off and unchanged. A second copy is "Fast copy 2" /
  fast-copy-2. An unknown id is refused.
- TestGroupCopyCLI (root): the default name, a given name, a found
  group's copy as the user's, and the errors.
- group-duplicate.test.cjs: Chromium and WebKit, en/zh/zh-TW/ja/de at
  420px, 10/10. It fails 10/10 on the old routing.js. Siblings
  group-arrange, removed-groups-menu-scroll, group-bulk-remove and
  click-scroll pass 36/36.
- Sandbox CLI (sandbox HOME): group copy and duplicate made "DupTest copy"
  and "DupTest copy 2", listed after DupTest.
- gofmt -l clean. go vet darwin/linux/windows on the root, provider and
  gui packages: ok.
2026-10-10 15:08:14 +08:00
yetone 8b56cf2d57 provider: an Antigravity or Gemini CLI sign-in's models can be tested, asked through the gateway's translator (lc on Discord)
lc: "antigravity的测试按钮不可用,别的供应商正常" — even after restarting
magpie and signing in again. A Google sign-in speaks only Code Assist, an
API whose envelope (project, model variant, request wrapper) the gateway
builds for each agent request. The provider test only sends its own tiny
request to a Chat/Responses/Anthropic/Gemini URL, so ModelTest() returned
"own-api" and the GUI turned Test models and each model's test off.

Now the gateway registers provider.ProbeTranslatedVia, the same pattern as
ProbeClaudeVia. For a Google Code Assist account, Test and TestModels ask
the model a "hi" (max 16 tokens) through askTranslated, the same path an
agent's request takes. The reply is read up to the first word, thought or
tool call, or until the vendor's error, which is reported with its status.
ModelTest() is "" for such an account, so the GUI's buttons are on. Plugin
providers and other own-API sign-ins (Kiro, Cursor…) are unchanged.

Run:
- TestAntigravityModelTest (internal/gateway): an Antigravity login and a
  fake Code Assist upstream. ModelTest is "". TestModels is OK on
  CodeAssist with the account named, and sent one :streamGenerateContent
  with project p1 and "hi". Provider Test is OK. A 403 PERMISSION_DENIED
  comes back as status 403 with its message. With testsTranslated forced
  false by hand (the old behaviour), it fails: "own-api".
- go test -tags nogui ./internal/{provider,gateway,gui} under a temp HOME
  with pinned caches: ok. gofmt -l clean. go vet darwin/linux/windows on
  those packages: ok.
- Not tried against a real Antigravity account. Live state was read only.
2026-10-10 15:08:14 +08:00
yetone df2b44ef6c provider: a model's test can be asked as Codex or Claude Code asks, for a relay that serves only them (耍赖天都爱 on Discord)
A relay that serves only Codex or Claude Code tells them by their headers
("This account only allows Codex official clients"). The gateway already
lets those headers go on to a provider reached with a key, so the agents'
own requests were served, but the provider/model test is magpie's own
tiny request (Go's User-Agent), so it was turned away and said the
provider didn't work.

provider.TestAs(ctx, "codex"|"claude-code") asks Test and TestModels as
that agent asks. It goes to that agent's API (Codex: Responses, else Chat;
Claude Code: Anthropic Messages), streamed, store:false on Responses, and
with the headers the gateway last let through for it. The gateway notes
them with provider.SawClient beside the existing pass-through, without the
session, thread, turn, request id, subagent kind, anthropic-beta or
anything that signs. They are kept in client-shapes.json so the CLI has
them too.

Claude Code's headers are never made up. Until a Claude Code request has
come through magpie, the test says so. They are sent only to a relay,
never to anthropic.com, googleapis.com, Bedrock or Azure. A signed-in
account, a decision-only provider, and OpenCode/Cline/Kilo (which already
ask as their own client) are not offered it. Codex's fall back to the
headers a ChatGPT account's requests use (codexSign) until Codex has come
through.

Surfaces:
- CLI: magpie provider test <id> [--as codex|claude-code] [model…]
- GUI: a model chip's right-click has "Test as Codex" and "Test as Claude
  Code" where the provider offers them (providerJSON.testsAs). An item that
  can't run yet is off, with the reason shown. zh, zh-TW, ja, de strings.

Run:
- TestModelTestAsClient (internal/gateway). A fake relay 403s anything but
  codex_*/claude-cli/. The plain test gives 403. As Codex it is OK on
  /v1/responses with stream+store:false and codex_cli_rs headers. As
  Claude Code it is refused before Claude Code came, then OK after one
  Claude Code request through the gateway, with its UA/x-app/x-stainless
  but no session id or beta. Anthropic's API is refused. With the beta
  filter removed by hand the test goes red.
- model-test-as.test.cjs, Chromium and WebKit, en/zh/zh-TW/ja/de at
  420px: 10/10. On the old app.js it fails (no such items). Siblings
  model-test-one, model-copy-id, model-test-why, provider-typed: 28/28.
- Sandbox CLI (fake chat relay on :3571, sandbox HOME):
  - provider test relaya --as codex deepseek-chat sent
    codex_cli_rs/0.162.0 … with originator and version, stream:true.
  - --as claude-code said the relay has no Anthropic endpoint.
- go test -tags nogui ./internal/{provider,gateway,gui} and the root
  package under a temp HOME with pinned caches: ok. gofmt -l clean.
  go vet darwin/linux/windows on those packages: ok.
2026-10-10 15:08:14 +08:00
yetone 4df1402d10 provider: Provider in model names Off names a remote magpie's models alone, not with the other magpie's provider or "routing group" after them (ARNO on Discord)
A remote magpie's list names each model by its label there (magpie_label),
which carries that magpie's provider, or "· routing group", by that
magpie's own setting. Here it was taken as the model's name, so turning
Provider in model names Off on this computer still left "DeepSeek Chat ·
Relay A" and "Fast · routing group" in every agent's list.

The list's display_name, the name there alone, is now kept beside it
(catalog.Model.Plain, Entry.Plain). provider.Labels names an entry by it
under Off, and under own for a name the user gave; two that would read the
same keep their labels as before. On is unchanged ("DeepSeek Chat · Relay A
· Remote magpie"). /v1/models' display_name is that name alone too, as it
is for every other model, so a magpie chained after this one gets it. The
provider editor's Names & levels line says the same ("plain" in the
provider's model JSON).

Reproduced in a sandbox: fake vendor on :3571, magpie A on :3572 with it
and a group, magpie B on :3573 with remote-magpie → A and `model suffix
off`: B's /v1/models said "DeepSeek Chat · Relay A", "Fast · routing
group". With the fix: "DeepSeek Chat", "Fast"; on: "DeepSeek Chat · Relay
A · Remote magpie".

Run:
- TestRemoteMagpieSuffixOff (internal/gateway, A's list bytes as served)
  fails on the old code (off: "DeepSeek Chat · Relay A", "Fast · routing
  group"), passes with the fix.
- name-suffix-remote.test.cjs, Chromium and WebKit, en/zh/zh-TW/ja/de at
  420px: 10/10; on the old app.js it fails ("… · Relay A"). Sibling
  name-suffix-*.test.cjs and names-fold: 86/86.
- go test -tags nogui ./internal/{catalog,provider,gui,agent,gateway}
  under a temp HOME with pinned caches: ok. gofmt -l clean; go vet
  darwin/linux/windows on the touched packages: ok. Full suite before push.
2026-10-10 15:08:14 +08:00
yetone 9e665491ec context: Tuned for you — each agent's settings worked out from the user's own calls, for the fewest tokens, put in on a click and undone on another
The Context tab now replays the period's calls of each agent against the
settings that decide what they cost, and shows, per agent and per model,
the value that would have used the fewest tokens for how this user works:
Claude Code's auto-compact window per model (modelSettings.<model>.
autoCompactWindow), its main and subagent prompt cache lifetimes
(promptCacheTtl, subagentPromptCacheTtl: 5m or 1h), and Codex's
model_auto_compact_token_limit. Each row says where the value comes from
now (the agent's default, the user's own setting, one magpie set, or a
variable that comes first and locks it), why (how fast conversations grow,
what a compaction leaves, how long the user pauses), the cost curve, and
the saving. Nothing is written until "Use …" is clicked; what was there is
stashed and Undo puts it back, unless the user changed it since. A window
the user set past the model's own is shown capped, as Claude Code caps it
(min(setting, model window)), so no saving is claimed that wouldn't happen.
Advice under a 3% saving is "already the leanest".

On the owner's 7 days (88K Claude Code calls, 956 Codex): opus keeps 200K
(their 500K is capped to it), fable 5.1 200K → 160K saves 5% (2.56M
tokens), both cache lifetimes and Codex's 272K are already the leanest.

Run: go vet ./... and go test ./... pass (internal/tune, internal/agent
TestTune*: user-set values kept, env locks, undo after a user edit, TOML
key order). context-tune.test.cjs passes in Chromium and WebKit, en/zh/
zh-TW/ja/de, at 360 and 1100px (22); context-window, context-scroll-end
pass. Full GUI suite: library-servers-bulk and market-have failed once
under load and pass alone; market-have fails on origin/main 494dedcf too
(1 of 8 runs). Claude Code 2.1.296 in a sandbox config dir shows
"Auto-compact window for Fable 5.1: 160k tokens (from settings)" for the
written modelSettings key. The real-data advice was read from the owner's
own ~/.claude and ~/.codex, nothing written.
2026-10-10 15:05:53 +08:00
yetone a154197bbb accounts: ChatGPT accounts come in from codexbar's config.json (碳碳双键)
碳碳双键 on Discord (#general): several Codex accounts switched with
codexbar on the old computer; after a magpie backup and restore the new
one had only the account Codex itself was signed in to. A backup carries
no sign-in by design (a refresh token rotates, so two computers can't
share one); importing is how accounts move. But codexbar's own store,
~/.codexbar/config.json, wasn't a shape the importer knew: its accounts
sit under providers[].accounts, so the whole file was read as one entry
with no token ("#1: failed").

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

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

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

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

Run: go test ./internal/desktopdir ./internal/library ./internal/agent
(macOS); TestFindMSIX fails on the old packageDir for the Store family;
desktopdir's Windows test binary passes on the Windows box; go vet for
linux, windows and darwin.
2026-10-10 14:55:16 +08:00
yetone 9e35eef336 gateway: TestKimiHeldBurstTellsNoSpeed waits long enough that one pause of the runner mid-burst no longer reads the held call as a speed (#1469)
The test timed its replies over a wait shortened from John's (first
content at 1.3 s, the end at 4 s). The held call is told held when its
flow (~245 ms for the 236 ms burst) is under a quarter of its window
from its first text (usage.HeldShare); a 2.5 s window put that bound at
~620 ms, so a single pause of ~400 ms that split the burst crossed it.
macOS CI under -race read flow 856 ms, ms 4517 (517 ms late) and
176 tok/s: one ~520 ms pause mid-burst.

Reproduced by injecting the pause (not rerunning): a 520 ms sleep in
the fake before the 25th write of the chat held call read 182 tok/s,
flow 773, ms 4405, as CI did. The classifier is right about such a
stream; the fixture sat too near its threshold. The wait is now first
content at 0.5 s and the end at 7 s: a 6.3 s window bounds flow at
~1.6 s, and the steady 50 tok/s reply stays within its 20% with a pause
as long.

Run:
- injected 1 s pause in the chat held call, held answer, steady, and
  in all nine replies: ok each; a 2 s pause still fails the held call
  (flow 2621), so the bound is where the comment says.
- break by hand: HeldShare check off -> the three held calls read
  84 tok/s, red; ThinkPace check off -> the three held answers read
  ~2,237 tok/s, red.
- go test -race -count=10 -run '^TestKimiHeldBurst' ./internal/gateway/ ok.
- gofmt -l clean; go vet darwin/linux/windows ./internal/gateway/ ok.
2026-10-10 14:52:56 +08:00
yetone 82990126db sessions: a gateway turn cut off mid-write no longer makes every later turn of its session fail on Windows (#1468)
turns.jsonl was opened with O_APPEND. On Windows Go opens such a file
with FILE_APPEND_DATA and without write access, so the Truncate that
drops a cut-off last line was refused ("Access is denied"), and
SaveGatewayTurn failed for every later turn of that session. The file
is now opened read-write without O_APPEND and the line is written with
WriteAt at the end found (gatewayMu already keeps another save from
writing in between). No other file in the repo is both opened with
O_APPEND and truncated.

Run:
- Windows box (xiaomi-gpu), GOOS=windows test binaries of
  internal/sessions under a scratch USERPROFILE/APPDATA/TEMP:
  origin/main (f9113626): TestGatewayConversation* -count=20 fails
  TestGatewayConversationInterruptedAppend every time with "truncate
  ...turns.jsonl: Access is denied"; this commit: -count=20 PASS, whole
  package PASS.
- macOS: go test -race -count=5 ./internal/sessions/ ok.
- gofmt -l clean; go vet darwin/linux/windows ./internal/sessions/ ok.
2026-10-10 14:52:56 +08:00
yetone e04824714d library: a skill's templates and other big files go with a backup and a sync (Crispin)
Crispin on Discord (#help): a skill's template files, a PPT among them,
didn't reach the other computer. Collect read each skill file with a 2 MB
cap and left everything bigger out, so a .pptx template with its pictures
never travelled. Links, copies and WSL copies on one computer already carry
every file (copyDir and hashFiles have no filter); only the backup/sync
carry dropped them.

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

Run:
- New tests fail on the old code: TestCarryTemplates (template left),
  TestCarrySmallFirst (zzz's notes.md left), TestCarryKeepsWhatWasLeft
  (the local file went), TestSkillTemplatesTravel (0 bytes of 6291456).
  Pass on the new. TestSkillTemplateReachesEveryAgent covers link, copy
  and WSL copies of a pptx.
- Break check: maxCarriedAll = 48 MiB makes TestSkillTemplatesTravel fail
  ("the backup is 64 MiB, more than a sync reads").
- Real use: built nogui binary, sandbox HOMEs, a real 2.8 MB and a real
  14 MB .pptx in a library skill, `magpie backup` then `restore
  --no-agents` on another HOME: sha256 identical both times.
- go test -tags nogui ./... (temp HOME, pinned caches): all ok.
- go vet (darwin, GOOS=linux, GOOS=windows) on internal/library and
  internal/backup; gofmt -l clean.
2026-10-10 14:52:17 +08:00
yetone 8424a004e2 gateway: a reply whose reasoning loops before it says anything is asked again once in place, not ended (cdredfox on X)
cdredfox (X, 2108799237324701897): WorkBuddy AI's DeepSeek v4 flash through
magpie often stops half way with "magpie ended this reply: the model's
reasoning was stuck in a loop ... ("OK." "Writing." "Now." "Producing."
"Let me output." ...)". That is the #1359 loop, found correctly; nothing
magpie sends causes it (12 direct runs on the real account, effort
high/max, temperature 0/1/unset, a 900-line file: no loop, the same
"OK. Writing." precursors at the tails), so the model does it on its own
now and then, and the agent's turn ended on it.

When magpie translates the reply into the agent's protocol
(streamTranslated: Claude Code, Codex, Gemini clients) and the loop is in
the reasoning, before any text or call and not on a continuation, the
try is let go and the same request asked again once (loopReasks), the
way an empty reply is (#667): the agent keeps the reasoning it has, the
next try's follows in the same stream. A loop on that try ends the reply
with the error as before. holdWriter.onLoop lets streamTranslated take a
trip before cutLoop ends the stream. A text loop, a loop after a text or
call, and a Chat stream passed through stay ended at once.

Run: go test ./internal/gateway (ok); -race on the loop and empty-reply
tests -count=3 (ok); gofmt; GOOS=linux,windows go vet. On old code
TestReasoningLoopIsAskedAgain and the updated
TestLoopEndsTheReplyOnEveryProtocol/TestLoopEndsWorkBuddysReply fail
(the vendor is asked once). Real account: a sandbox gateway in front of
a proxy signing for WorkBuddy AI (deepseek-v4.1-flash); the proxy
injected the reporter's units into the first real stream, the guard let
it go after 257 lines, the second real request answered, and the agent
got one whole message (Anthropic: message_stop, no error; Responses:
response.completed).
2026-10-10 14:51:42 +08:00
yetone a6c34c86b8 tui: a provider of your own (OpenAI, Responses, Anthropic or Gemini compatible) is added from the Providers page, at its base URL, as the app's editor adds it (hezz1891)
The TUI's `a` listed only vendors magpie knows and pointed to `magpie
provider add <name> url=…` for any other, so on a server the TUI couldn't
add one's own relay (hezz1891 on Discord).

`a` now also offers custom-openai, custom-responses, custom-anthropic and
custom-gemini (the line says "or custom for your own API"). Picking one
asks for its base URL (an http(s) address, else said so), then its name
(enter for the host's), then its key, then adds it with provider.Add
and fetches its list, as a preset is added. The API sets the same URL
field as the CLI's url=/responses=/anthropic=/gemini=.

Run:
- TestTUIAddsACustomProvider: an OpenAI compatible provider on httptest
  is added and lists its 2 models; a non-URL is refused; Responses,
  Anthropic and Gemini each set their own URL; an empty name is the host.
  It fails with the custom items taken out of the list.
- go test -tags nogui ./internal/tui (temp HOME, pinned caches): ok;
  the tui tests built for linux/arm64 pass in debian:12-slim
- gofmt -l: none; go vet (darwin), GOOS=linux and GOOS=windows go vet
  -tags nogui ./...: clean; darwin, linux, windows builds
2026-10-10 14:45:47 +08:00
yetone 3ee97def30 claude: a server or container without Claude Code can have magpie download Anthropic's own build, asked first and checked against its manifest (Jorben)
A Claude subscription signs in and answers through the local `claude`, so
on a server or in the Docker image, which has none, it couldn't be used
(Jorben on Discord: could it be downloaded on first use, as Bun is).

- internal/claudecode: Anthropic's native build, as claude.ai/install.sh
  takes it: downloads.claude.ai/claude-code-releases/latest names the
  version, its manifest.json the platform's SHA-256 and size (linux
  glibc/musl, darwin, win32). A build that doesn't match both is not kept.
  It goes to <cache>/claude-code/<version>/claude, one version kept. Find
  takes the machine's own claude first, then the download.
- magpie claude-code [install [--yes]|remove]: says the version, platform,
  size, the URL it comes from, where it goes, that its checksum is checked
  and that it is Anthropic's software under Anthropic's terms, then asks
  [y/N]. magpie accounts add claude offers it when there is no Claude Code
  (not on Windows, where one in WSL may be the one).
- the gateway's claudeBinary and the sign-in's claudeExecutable take
  claudecode.Find; their "not installed" errors name the command.
- Docker docs (en, zh, ja): Claude sign-in needs `docker exec -it magpie
  magpie claude-code install` first; kept in /config/cache.
  The Dockerfile is unchanged: distroless cc-debian12 has glibc.

Run:
- go test -tags nogui ./... under a temp HOME with pinned caches: all ok
- gofmt -l: none; go vet (darwin), GOOS=linux and GOOS=windows go vet
  -tags nogui ./...: clean; darwin, linux, windows builds
- TestClaudeSubscriptionRunsTheDownloadedClaudeCode fails on the old
  proc.FindTool lookup ("Claude Code is not installed"); taking the
  checksum compare out fails TestInstallRefusesABuildTheManifestDoesntName
- real: magpie claude-code install --yes downloaded Claude Code 2.1.296
  darwin-arm64 (230 MB) into a sandbox HOME, checked; it ran --version
- real, in gcr.io/distroless/cc-debian12:nonroot with the image's /config
  layout: install downloaded 2.1.296 linux-arm64 (245 MB) to
  /config/cache/magpie/claude-code, it ran `2.1.296 (Claude Code)` there,
  and magpie accounts add claude went on to print the claude.ai sign-in
  link and ask for the pasted address
- the claudecode tests built for linux/arm64 pass in debian:12-slim
2026-10-10 14:44:57 +08:00
yetone f9113626c7 docs: LESSONS.md gets the review of 10-09 and 10-10 — exemptions scoped by protocol position, a documented security gap is a bug, two sessions never share a checkout; user-set values, error cause and timing tests seen a second day
Reviewed 68aab298..c98160c0 (92 commits) plus the 10-09 ticks: security/privacy probe of every auth, network, telemetry and credential change, fix-again chains, tags vs green runs (all v0.1.1137-1154 on green, 16 assets), commit bodies and community merges. No lesson reached a third day, so nothing moves to the code standards. Filed #1469 for TestKimiHeldBurstTellsNoSpeed.
2026-10-10 14:24:31 +08:00
yetone c431225a08 gateway: a browser that reaches loopback under a web site's name (DNS rebinding) is refused unless it sends an enabled gateway key
What changed
- lanGuard refuses (403) a loopback request that carries a browser's
  marks (Origin, Sec-Fetch-Site/Mode, a Mozilla/ user agent) when its Host
  isn't this computer's: an IP, localhost, *.localhost, a container
  runtime's name for the host (host.docker.internal and kin),
  MAGPIE_PUBLIC_URL's or MAGPIE_ADDR's host, or the machine's own name.
  An enabled gateway key still lets it in.
- Why: a page whose DNS answers 127.0.0.1 after it loads is same-origin
  with the gateway. Its GET sends no Origin and its POST one equal to the
  Host, so corsGuard's foreignPage let it by, and on loopback no key is
  asked. The nightly security review reproduced it on origin/main: Host
  and Origin rebind.attacker.example:3425 from 127.0.0.1 got 200 on
  GET /v1/models, POST /v1/chat/completions (spends subscriptions) and
  GET /v1/magpie/quotas (account names, balances). The subsystem doc
  listed it as a known gap.
- Agents send none of a browser's marks and pass under any name; browsers
  and webviews calling localhost or an IP are unchanged.
- Tests: TestCORSKeyThroughTheServer and the Omarchy test now set a real
  Host (httptest's default is example.com). TestForeignPagesRefused's
  "own origin behind a port-443 name" row from loopback with no forwarding
  header now sends a key: without one it is the rebound page.
- docs/subsystems/gateway-routing.md describes the check in place of the gap.

Ran
- TestReboundPageRefused: fails on origin/main ("a rebound page's GET:
  200"), passes with the change.
- go test -count=1 ./internal/gateway/ ./internal/gui/ ./internal/access/
  (macOS, go1.27): ok. gofmt -l clean; GOOS=linux and GOOS=windows go vet
  ./internal/gateway/ clean.

Not tried: a real browser against a real rebinding DNS name.
2026-10-10 14:21:23 +08:00
yetone 4225ba1efb site: a partner's /go link and the home page's sponsor card say they came from magpie, as ref=magpie (yetone)
goTarget adds ref=magpie to the partner's key page or website, keeping the
address's own query and fragment; an address that names its own ref (a
referral code) keeps it as it is. The home page's sponsor card links to
yylx.io/?ref=magpie.

Run: node --test site/*.test.mjs (27 pass), with new cases for the query,
the fragment and a partner's own ref.
2026-10-10 14:18:05 +08:00
yetone a21a2b54f1 gateway: a Codex title the model was cut short on before any text goes back as response.incomplete with its reason and usage, not as a completed reply with nothing in it (#1466, Edward4226)
codexTitle read the title model's reply with compactReply, which kept
the output and usage of response.completed and response.incomplete alike
and dropped how it ended, and writeTitleReply always wrote status
"completed". A title cut short at max_output_tokens (all of it spent
reasoning) reached Codex as a finished reply with output [].

What changed:
- compactResult keeps the response's status and incomplete_details
  (from the terminal event's response, or its type).
- codexTitle: when no title can be read and the reply was incomplete,
  writeTitleEnd streams response.created then one response.incomplete,
  status "incomplete", the vendor's incomplete_details and usage. A
  title read from the reply still completes with it; Titles off still
  completes with nothing (writeTitleReply is writeTitleEnd "completed").
- Compaction (codexCompact) is left as it was: a summary cut short with
  text in it is still used.

Codex: codex-rs/codex-api/src/sse/responses.rs takes response.incomplete
with a reason other than interrupted/content_filter as a stream error
("Incomplete response returned, reason: max_output_tokens"), which
core/src/responses_retry.rs retries up to the provider's
stream_max_retries; then the hidden title turn fails and
tui/src/temporary_structured_request.rs sets no title, as it did for
the empty completed reply. This is what Codex does when its own backend
answers so.

Run:
- TestCodexTitleIncompleteStaysIncomplete (Responses upstream, #1466's
  two-field schema) and TestCodexTitleIncompleteFromChat (Chat upstream,
  finish_reason length) fail on the old code (one response.completed,
  status completed) and pass now; the same test's whole title under the
  same schema completes. Existing TestCodexTitle*/TestTitle* pass.
- gofmt -l clean; go vet -tags nogui ./internal/gateway on darwin,
  GOOS=linux, GOOS=windows.
- go test -tags nogui -count=1 ./... under a temp HOME: all ok (with
  ./internal/gateway and ./ rerun after this change).
- Real use: the built binary's gateway in a sandbox HOME with only the
  owner's DeepSeek provider, the issue's title request on
  deepseek-flash. max_output_tokens 16: old response.completed,
  output [], status completed; new one response.incomplete, reason
  max_output_tokens, usage 16 reasoning tokens. 2048: a title and
  description, response.completed, before and after.
2026-10-10 14:14:47 +08:00
yetone ea84c99c78 gateway: a DeepSeek model's tool call the client kept no thinking for goes with reasoning_content, so DeepSeek no longer turns the request away (#1462, Clear8Heart)
DeepSeek's Chat API in thinking mode refuses a whole request with 400
"The `reasoning_content` in the thinking mode must be passed back to the
API" when an assistant tool-call message since the user's last message
comes back without reasoning_content. OpenCode (and any client that
continues a turn another model made, or that dropped the thinking) sends
such turns, and magpie relayed them as they came.

What changed:
- chatReplaysReasoning(host, model) names the Chat upstreams that want
  reasoning back: a DeepSeek host, a model named deepseek wherever it is
  served (OpenCode Go's deepseek-v4.1-flash, OpenRouter), and Command
  Code's plugin. buildChat uses it as before.
- Passthrough (gateway.go, provider.Chat): for such an upstream, not
  Mistral (#494), withReasoningOnToolTurns gives every assistant
  tool-call message without reasoning_content one: its thinking from
  another field (reasoning, OpenRouter's) when it has some, else " ".
  The gate takes the member's model, so a group reaching a DeepSeek
  member is covered.
- Translated (buildChat, Responses/Anthropic -> Chat): a tool-call turn
  with no thinking gets " " too.
- Every tool turn, not only the last: DeepSeek also refuses an earlier
  tool turn of the current user turn missing it.
- " ", not "": DeepSeek's Chat takes "", but relays that omit empty
  fields drop it, and DeepSeek's Responses API refuses an empty
  reasoning_text. A vendor that refuses the field still gets the #494
  retry without it.

Checked against api.deepseek.com (owner's key, deepseek-flash, Chat):
last tool turn missing -> 400; "" -> 200; " " -> 200; real -> 200; an
earlier tool turn of the current user turn missing -> 400; a tool turn
before the latest user message missing -> 200; answers missing -> 200.

Not changed, found on the way: DeepSeek's Responses API refuses a
function_call with no reasoning item (and empty reasoning_text), and its
Anthropic API a tool_use with no thinking block; magpie relays those
natively as they come. OpenCode Go's deepseek-v4.1-flash could not be
tried for real: the owner's workspace answers "requires Global regions".

Run:
- New tests fail on the old code (TestChatToolTurnWithoutReasoningRelayed:
  400 "reasoning_content ... must be passed back"; TestTranslatedToolTurn
  WithoutReasoning: 400), pass now; TestChatToolTurnWithoutReasoning
  NotForMistral and the Mistral/MessageReasoning tests pass.
- gofmt -l clean; go vet -tags nogui ./internal/gateway on darwin,
  GOOS=linux, GOOS=windows.
- go test -tags nogui -count=1 ./... under a temp HOME: all ok.
- Real use: the built binary's gateway in a sandbox HOME with only the
  DeepSeek provider. Old: last tool turn missing 400, earlier one
  missing (stream) 400, plain answer 200. New: all three 200.
2026-10-10 14:14:47 +08:00
yetone 282c9feb23 agent: a packaged Claude Desktop gets magpie's gateway in the real %LOCALAPPDATA%\Claude-3p too, which 2.31226 reads, not only the package's (bg5eau on X)
bg5eau on X: Claude Desktop 2.31226.1, installed as an MSIX package on
Windows, never reached magpie. magpie wrote deploymentMode and its
configLibrary profile only into the package's
LocalCache\Local\Claude-3p, and that build reads the real
%LOCALAPPDATA%\Claude-3p. Copying configLibrary there and setting
deploymentMode 3p fixed it for them.

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

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

Run:
- go test ./internal/{desktopdir,agent,library,sessions,usage}: green.
- The new tests (TestClaudeDesktopMSIX, TestClaudeDesktopMSIXStashBefore,
  TestClaudeDesktopMSIXBoth3p) and TestCallsCoworkMSIX are red against
  the old code. StashBefore also goes red with desktopAdoptStash disabled.
- gofmt; go vet for darwin, linux and windows.
- On the Windows box, Windows test binaries for the four packages:
  - desktopdir and library pass.
  - agent and sessions fail exactly the set that main's binaries fail
    there (#1388, and newly filed #1468).
  - Every Desktop test passes.
2026-10-10 14:13:05 +08:00
ggbdpq 74c48ac060 agent: the managed daemon's subcommand helpers are no stale Codex copy (#1461)
Codex keeps a long-lived helper next to the managed app-server (codex
app-server daemon pid-update-loop). It matches the codex startsWith
pattern, carries no --managed-daemon, sits under no app bundle or
Application Support folder, so codexCopyKind called it "cli": a helper
that started before magpie's last change to what Codex reads counted as
"A Codex in a terminal" forever, a daemon restart didn't end it, and the
only way to clear the row was killing a process the user can't name.

codexCopyKind now answers "helper" for an app-server daemon <sub>
command line, and StaleCopies skips it: it reads no list and there is no
Codex to quit. The daemon itself (--managed-daemon) is told apart
before, and a terminal Codex still counts.

Red/green: TestCodexDaemonHelperIsNoStaleCopy runs the helper alone from
before magpie's change — FAIL on the old code (one stale "cli" copy),
passes now; a terminal codex from before still counts 1.

Generated-by: GLM-5.3-Flash (ZCode)

Reviewed and landed by the maintainer (cherry-pick of b40a1dd0 from
ggbdpq:fix/codex-daemon-helper-1461, lychjck's report). Run on main at
c98160c0 with this commit on top, macOS arm64:
- On this Mac (ps, read only): the installed codex-cli 0.162.0 binary has
  the subcommand `app-server daemon pid-update-loop` ("[internal] Run the
  detached pid-backed standalone updater loop"): an updater, it reads no
  model list. The managed daemons running here are
  `codex app-server --listen unix:// --managed-daemon` and stay "daemon".
  No pid-update-loop helper was running on this Mac at review time, so the
  reporter's live row was not reproduced here; the fixture is the command
  line from the report. provider's isManagedDaemon already names the same
  helper as no daemon.
- TestCodexDaemonHelperIsNoStaleCopy with main's connectinfo.go: FAIL,
  "the daemon's helper counted a stale copy: [{Kind:cli ...}], want none";
  passes with the change.
- gofmt -l: clean. go vet -tags nogui ./internal/agent for darwin, linux
  and windows; GOOS=linux go vet -tags nogui ./... and GOOS=windows go vet
  ./...: clean. go build, GOOS=linux go build -tags nogui, GOOS=windows
  go build: ok.
- go test -tags nogui -count=1 ./... under a scratch HOME with GOPATH,
  GOMODCACHE and GOCACHE pinned: 44 packages ok, none failed.
- Not run: GUI tests (no GUI change), Windows or Linux hardware (the
  stale-copy check doesn't run on Windows; Linux uses the same ps path).
2026-10-10 14:07:55 +08:00
yetone c98160c045 gateway: a group's rule can pause one of its models at set hours, days or agents, leaving it out of the group then, not first, not a fallback, not kept by affinity (John on Discord)
John asked for a pause in a group's Rules, e.g. a model paused in its
vendor's peak hours. A time rule could only send those turns to another
member first: the paused-for model stayed behind it for failover, went
first when the other was unready, and was kept by affinity mid-turn.

What changed:
- provider: Rule.Pause (`"pause": true`, typed `pause=<model>`). It holds
  by time/days and agents only; cleanRules and ParseRule refuse any other
  condition, and refuse a pause with none. Pause rules are not send-to rules:
  MatchesBesidesIntent, ruledEntry and Ruled() leave them out.
  PausedOut(g, ms, agent, at) drops members a pause holds for, from the
  group's own rules and each nested group's (a group named = all its
  models; a model named = wherever a nested group has it, #625).
- gateway: serve and embeddings run PausedOut on ruleClock before planning,
  on every request. Every member paused -> 503 naming each rule
  (pausedError). The trace's GroupRef carries Paused.
- GUI: the rule editor's "send to" is now an app-menu action button (send
  to / pause); a pause hides the request conditions (clearing them),
  keeps hours, days and agents, and isn't saved without one. The group
  card's rule reads "→ pause <model>", and the Routing trace says which
  model was paused by which rule. Strings in zh, zh-TW, ja, de.
- CLI/TUI: `magpie group rule add <g> pause=<model> time=… days=…`.
- docs: gateway-routing.md step 2; start.html en/zh/ja "Hours and days".

Run:
- gofmt -l clean; go vet -tags nogui ./...; GOOS=linux and GOOS=windows
  go vet -tags nogui on gateway, provider, tui, root; go build ./...
- go test -tags nogui ./... under a temp HOME with pinned caches: all ok.
- rules_pause_test.go (3 tests) fails with the PausedOut call in serve
  removed (the paused model answers mid-turn, as a fallback, and the
  all-paused case returns 200). grouprule_pause_test.go covers clean,
  parse/Line, Ruled, Pauses and nested PausedOut.
- GUI: routing-pause.test.cjs in Chromium and WebKit, en/zh at 1100px
  and en/zh/zh-TW/ja/de at 420px: 14/14; all 7 Chromium cases fail on
  the old routing.js. routing-*, group-*, i18n-keys, gui-ja, gui-de,
  click-scroll: 618 pass, 4 fail under --test-concurrency=4
  (routing-blocked, -idle-frame, -side-calls, -wb-refused), all of which
  pass run alone; routing-side-calls failed 1 of 6 alone and passes
  with the old routing.js too, so it is a load flake not touched here.
2026-10-10 13:51:09 +08:00
yetone 34f2078db8 site: a partner's website and key links go through usemagpie.ai/go, which counts the click; fetches of the partner list are counted too
The app's own partner counts only come from users who leave the stats on,
so a sponsor's reach and clicks are now also counted where the server sees
them. /api/partners hands out each partner's website and keysUrl (and a
region's) as usemagpie.ai/go/<id>/site|keys[/<region>]; /go sends a
"magpie partner go" event to PostHog (partner, link, region, country) and
302s to partners.js's own URL. One in ten list fetches sends "magpie
partners fetch" with weight 10 while someone is listed. Events carry a
random id each, no install id or address. Ended entries leave the list but
/go still resolves them, since added providers keep the links. Privacy text
updated in README (en, zh), docs/reference.md and the site docs (en, zh, ja).

Run: node --test site/*.test.mjs (27 pass); a scratch copy with a fake
partner served rewritten links, 302'd /go to the real URLs and posted both
events; PostHog's /i/v0/e/ answered 200 to the payload shape.
2026-10-10 13:48:26 +08:00
yetone 4eb90b69f4 provider: after magpie switches Codex's account, Codex's accounts say the Codex app is still on the one before and to quit and reopen it (CavillZhang on X)
The Codex desktop app (ChatGPT.app 26.1007, bundled codex 0.162) reads
auth.json once, as its app-server starts. codex-rs AuthManager only
reloads it for the same account (reload_if_account_id_matches skips a
different one; a full reload is the app's own login/logout). So after
magpie's switch the running app keeps the spent account's token, its
webview reads /wham/usage with it (straight to chatgpt.com, which magpie
can't intercept), gets rate_limit.allowed=false, and hardBlocked stops
the composer in every thread with "You're out of Codex and Work usage".
Only quitting and reopening the app moves it.

magpie now notes, like the managed daemon, the app's own app-server
(…/CodexCLI.app/Contents/MacOS/codex … app-server, or the Windows Store
app's codex.exe) running at a switch and the account it holds, until that
process changes. Codex's accounts show "The Codex app is still signed in
as …; quit it and open it again" with Got it; `magpie accounts` and the
gateway log say the same.

Run: go test ./internal/provider ./internal/gui (TestCodexAppStale goes
red with the app not noted), the new GUI test in Chromium and WebKit, en
zh zh-TW ja de, 900 and 480px; findCodexApp against the owner's running
ChatGPT.app found its app-server; go vet darwin/linux(nogui)/windows,
go build.
2026-10-10 13:41:59 +08:00
yetone 61e296688e gateway: Codex's model list keeps codex-auto-review when the user picked models on the ChatGPT account, so auto-review runs on it again, not on the conversation's model at low (#1460)
10086gg reported that Codex's auto-review through magpie almost never
used codex-auto-review. It ran on the conversation's own model at low
effort, which costs more.

Cause: codexModels narrows the ChatGPT backend's list to the models the
user picked on the codex provider (CodexNativePicked). Hidden entries
(visibility "hide") never reach magpie's catalog (parseCodexModels), so
no one can pick them, and the narrowing always dropped them.
codex-auto-review is one of those hidden entries. Codex 0.162 picks the
reviewer this way (codex-rs/ext/guardian-reviewer/src/model.rs,
select_review_model):
1. the entry's auto_review_model_override;
2. else codex-auto-review, if the list has it;
3. else the conversation's model, at low effort.
So once the list was narrowed, every review fell to step 3. On a relay
model it was charged to the relay; on a ChatGPT model it ran on that
model. OpenCodex had the same bug: lidge-jun/opencodex#6712 recorded
direct ChatGPT login as codex-auto-review/low and their proxy as Sol/low.

Fix: the narrowing keeps hidden entries. No picker shows them, so the
user's picks still decide what Codex's /model lists. A user who wants
reviews on a relay model can still set Codex's auto-review model
(#938), and that setting still wins (step 1).

Which account pays changes for a user with picks whose conversation
runs on a relay model: auto-review moves from the relay to the ChatGPT
account's codex-auto-review. That is what Codex does without magpie.
OpenAI's help article "Using Codex with your ChatGPT plan" says:
"When you're signed in with a ChatGPT account, auto-review safety
checks are free and don't count toward your plan's usage limits."

New test TestCodexModelListPicksKeepHidden. It fails on the old code:
after the picks only gpt-6-sol is listed, with no codex-auto-review.
docs/subsystems/gateway-routing.md says this.

Run:
- gofmt -l internal/gateway
- go vet -tags nogui ./internal/gateway, on darwin, linux and windows
- go test -tags nogui ./... with a temp HOME and pinned caches: green
Not run: a real Codex signed in to ChatGPT through the gateway. The
reviewer choice was read from the Codex source.
2026-10-10 13:30:43 +08:00
yetone 259c71078c docs: gateway routing says reasoning state goes through same-protocol translation as the vendor wrote it (#1445, XYenon)
What changed: gateway-routing.md gains the #1445 contract (sealed parts,
the new IR events, each vendor's case, no cross-API envelope) and no
longer says a translated Responses reply never carries sealed reasoning.

Run: read against the code in the three commits before this one.
2026-10-10 13:29:45 +08:00
yetone d7186e51b9 gateway: Gemini's thoughtSignature stays on the call and text it signed through translation, and goes back instead of the skip value (#1445, XYenon)
What changed: parseGemini keeps a functionCall's and a text part's
thoughtSignature. geminiParts and buildCodeAssistSent write them back; a
later call of a signed step goes without one, and only an unsigned step
or Claude on Antigravity gets skip_thought_signature_validator. The Code
Assist decoder puts a call's signature in its id (signedID, as chat does
since #687) and a text's in KTextSig; the Gemini encoder and renderer
give them to a Gemini client where Gemini put them, with a clean id.
Chat, Messages and Responses renderers leave a signature-only text part
out.

Why: a Gemini client on a Code Assist or translated path had every call's
signature replaced by the skip value and text signatures dropped.

Run: the new tests in reasoning_state_gemini_test.go (Gemini's
documented thought-signature shapes) fail on the old code and pass now; a
Chat client's call id carries the signature back to Google, and no other
API's client gets the text's. gofmt -l; go vet -tags nogui on darwin,
linux and windows; the full internal/gateway tests in a scratch HOME: ok.
Not tried with a real account.
2026-10-10 13:29:45 +08:00
yetone 13356b9da7 gateway: a Responses reasoning item goes through translation to Responses with its id, summary and encrypted_content, both ways (#1445, XYenon)
What changed: parseResponses keeps a reasoning item that has
encrypted_content raw (Part.Sealed), and buildResponses sends it back as it
came when it has no reasoning text and DeepSeek replay is off
(sealedReasoning). The decoder says where an item begins (KThinkStart with
its id) and hands over the done item (KSealed). The encoder streams the
summary under the upstream's own id and gives the done item and the
completed response's output as the upstream wrote them; renderResponses
does the same for a reply that wasn't streamed.

Why: the old code made its own rs_ id, dropped encrypted_content and
left reasoning out of the next request, so a store:false conversation
through a stream-only account or web search lost its reasoning each turn.
Reasoning with text in it (another vendor's) is still left out for
OpenAI (#1411); withoutBareReasoning and the foreign-reasoning retry still
act on the built body.

Run: the new tests in reasoning_state_responses_test.go (OpenAI's
documented item and stream shapes) fail on the old code and pass now;
the seal isn't handed to Messages, Chat or Gemini clients. gofmt -l; go
vet -tags nogui on darwin, linux and windows; the full internal/gateway
tests in a scratch HOME: ok. Not tried with a real account.
2026-10-10 13:29:45 +08:00
yetone 64179e7383 gateway: Anthropic's redacted_thinking and a thinking block signed with no text go through translation to Messages as they came, each block apart (#1445, XYenon)
What changed: the IR carries a vendor's sealed reasoning (Part.Sealed,
SealedBy) and says where a thinking block begins (KThinkStart) and where a
sealed one is (KSealed). parseAnthropic keeps redacted_thinking raw and
buildAnthropic writes it back raw. decodeAnthropic, the Anthropic encoder,
the collector and renderAnthropic keep each thinking block apart, with its
own signature, including one signed with no text (aBlock still writes
"thinking": "", 12c0550a). Claude's subscription (Claude Code's stream,
whole and streamed) emits the same events. A resumed reply suppresses the
new kinds with the rest of the reasoning.

Why: on a same-protocol translated path (a non-streaming request to a
stream-only account, web search on the agent's own API, a plugin,
failover), the old code dropped redacted_thinking, dropped a signature
that came with no text, and merged two adjacent thinking blocks into one
with both signatures concatenated. Anthropic refuses the next turn for
each of these.

Run: the new tests in reasoning_state_anthropic_test.go (fixtures are
Anthropic's documented block and stream shapes; no captured bytes exist)
fail on the old code and pass now. A sealed block isn't handed to a
Responses client. gofmt -l; go vet -tags nogui on darwin, GOOS=linux and
GOOS=windows; go test -tags nogui ./internal/gateway/ with a scratch HOME
and pinned GOMODCACHE/GOCACHE: ok. Not tried with a real account.
2026-10-10 13:29:45 +08:00
yetone e85be9d6f1 readme: no stats strip under the hero screenshot (45+ agents, 4 wire APIs, 5 routing modes, 0 config files), in en and zh (the owner) 2026-10-10 13:22:39 +08:00
yetone f3d5d12ba3 readme: the intent-routing and nested-routing screenshots are back under the Routing view, in en and zh (the owner wants them) 2026-10-10 13:21:50 +08:00
yetone 793af76242 gui: Codex's subagents setting, unset, says the lead picks the model and only falls back to the main model, not just "same as model" (willz on Discord)
willz set Codex's "subagents" to 同主模型 (same as model), and a subagent
still ran on another model. Codex 0.162 lets the spawn_agent call's own
`model` argument win over `[agents] default_subagent_model`
(core/src/agent/child_config.rs:
requested_model.or(agent_default_subagent_model)). The default only
applies to a spawn that names no model, and the spawn_agent tool lists
up to 5 models the lead may name. So "same as model" was false whenever
the lead named a model.

This was reproduced with the real codex-cli 0.162.0 in a sandbox
HOME/CODEX_HOME, against a fake Responses server on port 3683 that logs
each request's model and x-openai-subagent header. The lead ran on
gpt-6.1-sol in every case:
  a. lead names astra, default unset  -> subagent on gpt-6-astra
  b. lead names none,  default unset  -> subagent on gpt-6.1-sol
  c. lead names astra, default sol    -> subagent on gpt-6-astra
  d. lead names none,  default astra  -> subagent on gpt-6-astra

magpie already holds every subagent to one model with the "subagent
model" setting (the gateway's codexSubagentPick, 18958661). So this
relabels Codex's unset "subagents" field rather than changing it:
- The field reads "the lead's pick, else same as model".
- Its picker opens on "Lead's pick", noted "{model} unless the lead
  names another".
- Its title points to the subagent model as the way to make every
  subagent use one model.
The local row and WSL rows both get this. zh, zh-TW, ja and de are
translated. Claude Code's and omp's "subagents" keep "same as model".
Claude Code's case was not checked here: its Explore runs on the haiku
tier, so its label may not be exact either.

The new codex-subagents-lead.test.cjs fails on the old app.js ("subagents:
same as model"). It passes in Chromium and WebKit, in en, zh, zh-TW, ja
and de, at 1100px and 560px.

Run:
- gofmt -l internal/agent
- go vet -tags nogui ./internal/agent, on darwin, linux and windows
- go test -tags nogui ./... with a temp HOME and pinned caches: green
- GUI, in Chromium and WebKit: codex-subagents-lead, i18n-keys, gui-de,
  gui-ja, gui-zh-tw, click-scroll, codex-subagent-model, omp-roles,
  subagent-effort, profile-details and desktop-tiers: 54/54 in each
2026-10-10 11:58:51 +08:00
yetone 338ea97909 provider: moved Cursor users get plugin 0.2.5: an account with a Grok Bot allowance shows it on its Usage card, and a model asked for by its bare name counts in its sized ids' pool (magpie-community/plugins#64, Morganwan90; Will on Discord)
Cursor's Grok Bot has an allowance apart from Cursor's monthly usage that
only cursor.com's dashboard showed. Plugin 0.2.5 reads it from
cursor.com/api/dashboard/get-sand-usage-status (the sign-in sent as the
dashboard's WorkosCursorSessionToken cookie, as CodexBar does) and adds a
"Grok Bot" window, or "Grok Bot (trial)": aside, so a spent Bot never stops
Cursor requests, placed before Total so it is among the card's three rings.
A missing or failed reading leaves Cursor's own windows as they were.
0.2.4 (not shipped to moved users until now) counts a model asked for by
its bare name (grok-4.7) in the pool its sized ids (grok-4.7@256k) are in.

The cursor mover's min goes from 0.2.3 to 0.2.5, so keepMovedCurrent
updates every moved Cursor user.

0.2.5 on npm (registry.npmjs.org, latest) has an index.mjs byte-identical
to plugins main 6bed2c1. The plugin PR was reviewed at 40d4df6 and merged:
- bun test in packages/cursor: 73 pass, 0 fail; whole repo 1033 pass,
  0 fail; bun scripts/check.mjs all 15 pass
- botUsage broken 16 ways: 12 turn usage.test.mjs red; no timeout, no
  Origin, no res.ok check (the catch covers it) and an added bearer stay
  green
- the request and parsing match CodexBar's CursorSandUsage.swift
- not tried with a real Cursor account on the review side (the author saw
  the Bot ring on a Free account linked to SuperGrok)

go test -tags nogui ./internal/provider passes (temp HOME, pinned caches).
go vet -tags nogui ./internal/provider passes for darwin, linux and windows.
gofmt -l is clean. No test pins a mover's min, as for 0.2.3.
2026-10-10 11:52:49 +08:00
yetone 9bb2a467e4 agent: Snow CLI is an agent magpie sets up: a magpie profile in ~/.snow, made the active one, that Snow App takes in with "Sync Snow CLI API config" (#1457)
Snow CLI (npm snow-ai, MIT, MayDay-wpf/snow-cli) keeps one profile per
file under ~/.snow/profiles (or $SNOW_CONFIG_DIR), names the active one
in active-profile.json, and copies it over config.json at every start,
headless `snow --ask` included. Each profile is the whole snowcfg: base
URL, key, request method and models. Snow App (MayDay-wpf/snow-app)
reads ~/.snow/profiles/*.json when "Sync Snow CLI API config" is pressed.

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

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

Run:
- go test -tags nogui ./... in a scratch HOME: 44 packages ok.
  New: TestSnow, TestSnowOwnProfile, TestSnowNoProfiles,
  TestSnowUsersMagpieProfile, TestSnowCheckSync, TestAgentSnowDefault;
  snow added to TestOwnProviderNamedAsMagpies, the WSL more-agents tests
  and TestMaxTokensWithinContextWindow. All fail before this commit
  (no agent "snow").
- go vet -tags nogui for darwin, linux and windows; gofmt -l clean.
- Real Snow CLI 0.9.2 in a sandbox HOME against a sandbox magpie
  (`magpie serve` on 127.0.0.1:3571, a fake OpenAI upstream on 3561):
  `magpie snow fake/pro` made the magpie profile active, `snow --ask`
  answered through the gateway (POST /v1/chat/completions, model pro,
  logged as agent snow), and `magpie snow default` left profiles,
  active-profile.json and config.json byte-identical to before.
- GUI: agent-layout, agent-connect, agent-models-picker,
  agent-field-names and models-add-and-icons in Chromium and WebKit;
  a Snow CLI row checked at 520-960px in en, zh, zh-TW, ja and de in
  both engines. No new GUI strings.
2026-10-10 11:46:44 +08:00
yetone 43cb6fe34e agent: Claude Code is told the window of Claude Desktop's models while Desktop is on magpie, so its Code tab no longer shows GPT 272K and Grok 256K as 200K (#1458)
Claude Desktop's Code tab runs Claude Code on ~/.claude/settings.json
and hands it the id Desktop lists a model by (mythos-magpie-<n>,
anthropic/magpie-<n>), with nothing of the model's window. Claude Code
2.1.293 (the one Desktop 2.31226.1 ships) gives an id it doesn't know
200000 unless the id has [1m] or CLAUDE_CODE_MAX_CONTEXT_TOKENS is set:
its catalog rows come from Anthropic's served catalog alone, and gateway
discovery keeps only id, name and description. Desktop sets that env
only for Cowork (yukon-silver-config contextWindowByModel), never for a
Code session, and reads the gateway's max_input_tokens only to add a
1M entry. magpie wrote the env from Claude Code's own main model alone,
and not at all on a [1m] or Claude main model, so GPT-6-Astra and Grok
4.7 ran as 200K. GLM 5.3 Flash is 1M, listed as [1m], so it ran at 1M
and showed magpie's auto-compact window, 300K.

While Claude Desktop is on magpie, the window written is now the least
of Claude Code's own model's and of the models Desktop is shown, leaving
out one listed by its 1M id (1M whatever the env says) and one named as a
Claude model Claude Code knows. Claude Code takes one value for every
model it doesn't know, so it is the least: never more than any of them
takes (an overflow is still turned into "prompt is too long" by the
gateway). A value the user set is still left alone. Sync writes it too,
so a model added or hidden for Desktop moves it, and turning
DesktopLongest on or off tells the agents again (catalog.Touched).
gateway.DesktopListed1M is the one rule desktopModels lists [1m] by, so
bb3b3f14's 1M label is unchanged.

Standalone Claude Code has the same limit: a magpie model picked in
/model runs on the one value; that is not changed beyond the Desktop
case.

Run:
- TestClaudeDesktopContextWindow, TestClaudeDesktopContextWindow1M fail
  on the old claude.go/desktop.go ("" want 256000; "" want 1000000), and
  each exclusion broken by hand turns them red.
- Real Claude Code 2.1.293 (from Desktop 2.31226.1's download), sandbox
  HOME/CLAUDE_CONFIG_DIR, against a fake gateway on 3611, with the
  settings.json magpie's agent code wrote (GPT 272K, Grok 256K, GLM 1M
  under DesktopLongest, main on GLM [1m]): /context shows 256k for
  GPT's and Grok's mythos-magpie ids (200k before), 272k for GLM's
  [1m] id (the compact window).
- gofmt -l; go vet, GOOS=linux and GOOS=windows go vet -tags nogui on
  internal/agent, internal/gateway, internal/gui; go test -tags nogui
  ./... under a temp HOME.
Not run: the Claude Desktop GUI itself.
2026-10-10 11:42:03 +08:00
yetone d7a1b02032 redact: more kinds of personal data, each with its own switch: SSNs, passports, IBANs, birthdays, MAC addresses and serial numbers on by default; home-folder names, user@host, public IPs and cloud buckets off (lc on Discord)
lc asked in #feedback (message 1558300896142696549) for magpie's masking
to cover more of what identifies a person or a machine. Under Settings >
Privacy > Mask personal data, each kind now has its own Off/On
(settings.redactKinds, redact.Options.Kinds). What each one masks:

- US SSNs: dashed numbers, except ones never issued (000, 666 and 9xx
  areas) and well-known examples. Undashed ones only next to "SSN" or
  "social security".
- Passport numbers, next to passport, Reisepass, 护照 or パスポート.
- IBANs: compact or in groups of four, with the country's length and the
  mod-97 check.
- Birthdays: a date only next to birthday, DOB, born, 生日 and the like.
  A date on its own stays.
- MAC addresses, but not documentation examples or all-decimal groups.
- Serial numbers, next to S/N, serial or 序列号.
- The user name in /Users/x, /home/x and C:\Users\x. The name is masked
  and the path is kept.
- User and host in a shell prompt or an ssh/scp/rsync target (user@host
  goes out as two placeholders, not as an email address), and on
  hostname/user lines whose value ends the line. Code such as
  `hostname = socket.gethostname()` is left alone.
- Public IPv4 and IPv6 addresses. Private, loopback, link-local, CGNAT,
  documentation, multicast and well-known resolver addresses stay, and so
  do version numbers.
- Cloud bucket names in s3://, gs://, oss://, cos://, abfss:// and
  storage URLs. The object path is kept.

The last four are off by default. Agents send their working folder, the
prompt and log lines in nearly every request, and the model then has to
copy the placeholder exactly inside a path or URL. The first six are
rare and sensitive, so they are on. Each placeholder is restored in
replies, in streamed text and in tool-call JSON. Context rules run only
in a window around their words (rule.matches), so masking with the
defaults on 400KB of app.js takes 136ms (112ms before; 222ms with every
kind on).

Tests, run at this head:
- gofmt -l is clean. go vet passes on darwin, GOOS=linux and
  GOOS=windows (-tags nogui) for internal/redact, gateway, gui and
  settings.
- go test -tags nogui ./... under a temp HOME with pinned caches passed
  everywhere except internal/proc's TestNoCommandBypassesProc. That
  failure came from 2da8fbe4, and b934d64a fixed it on main. After the
  rebase, the changed packages and internal/proc were run again and
  passed. New tests:
  - internal/redact TestMoreKinds, TestMoreKindsLeaveAlone (versions,
    timestamps, hashes, UUIDs, model ids, code, ordinary dates, URLs in
    code), TestCategoryDefaults, TestMoreKindsKnown, TestMoreKindsJSON.
  - internal/gateway TestRedactedKinds.
  - internal/gui TestRedactKindsAreRedacts.
- I broke the IBAN checksum by hand and TestMoreKindsLeaveAlone went
  red.
- GUI tests pass in Chromium and WebKit:
  - the new redact-kinds.test.cjs (en, zh, zh-TW, ja and de, at 1100
    and 640 px);
  - i18n-keys, gui-de, gui-ja, gui-zh-tw, redact-rule-row,
    privacy-hides-accounts, privacy-usage-stats, settings-groups,
    settings-sections and mobile-web;
  - 29 more files that open the Privacy page (252 tests).
  privacy-usage-stats now scrolls with the wheel to reach Count me as a
  user, which the new rows push below its 760px window. WebKit doesn't
  let a script scroll Settings.
- Real gateway check: a `magpie serve` built from this tree, under a
  sandbox HOME, on 127.0.0.1:3492, with a fake OpenAI upstream on 3491.
  An Anthropic /v1/messages request went to the upstream with each kind
  as a placeholder. Turning SSNs off and home folders on changed what
  was sent the same way. The streamed reply came back with every value
  restored.
2026-10-10 11:29:03 +08:00
yetone 5b008f5a55 i18n: Chinese says 子代理 for subagent everywhere, not 子 agent in some places and 子代理 in others (willz on Discord)
willz asked what the difference between 子 agent and 子代理 is. There is
none: the zh and zh-TW GUI strings used 子 agent for the Agents page's
subagent row and subagent effort ("Subagents", "subagents",
"subagent effort" and its hint), and 子代理 for every other subagent
string (Codex subagents, routing's sealed subagent task, subagent model).
Both locales now say 子代理 throughout; zh-TW already used 子代理 for the
rest, so it takes the same term. The zh site docs (intent.html,
start.html) said 子 Agent / 主 Agent and now say 子代理 / 主代理, matching
the GUI's 主代理. ja already says サブエージェント everywhere; the READMEs
don't name subagents. The four GUI tests that expected 子 agent are
updated.

Run: omp-roles, profile-details, subagent-effort, tier-effort,
codex-subagent-model, routing-sealed-task, agent-tweak-fold,
claude-signin, codex-memories(-settings), gateway-mode, mobile-web,
usage-ledger-detail, gui-zh-tw, gui-ja, gui-de and i18n-keys in Chromium
(148/148) and WebKit (79/79); subagent-effort also at 420px wide in both
engines, with the zh screenshot checked (子代理推理强度).
2026-10-10 11:24:31 +08:00
yetone 9db2ed3dc6 gateway: temperature, top_p and top_k are left out for the Claudes that refuse them, so Haiku 5.5 no longer answers auto mode's classifier "temperature is deprecated for this model" (#1454)
Claude Code's auto mode classifier asks at temperature 0, and magpie's
own classifiers do too. Through the aihubmix relay, claude-haiku-5-5
answered 400 "`temperature` is deprecated for this model." (and the same
for `top_p` and `top_k`). magpie relayed sampling parameters as they
came, and built them from a chat request's temperature or top_p.

samplingAsTaken, beside thinkingOffAsTaken in forwardOnce, so relayed and
built requests and retries all go through it, leaves the three out for
the models Anthropic documents as refusing them:
- Opus 4.7, 4.8, 5 and 5.5, Fable 5 and Mythos 5 refuse them at any
  value.
- Sonnet 5 and 5.5 refuse anything but the default.
- Haiku 5.5 takes only temperature 1 and top_p 0.99, never both, and no
  top_k.
Left out, each model samples at its default. The model is read as the
vendor names it, from the body or, for Bedrock and Vertex, the path.
Older Claudes (Haiku 4.5, Sonnet 4.6, Opus 4.6 and before), other
vendors' models and later lines go byte for byte as they came. A Claude
under a name these rules can't read, answering that a sampling parameter
is deprecated or not supported (samplingDeprecated), is asked again
without them, once.

aihubmix itself took every value for the other models and refused it
only on Haiku 5.5. The other models are left out by the docs' rule, for
direct keys and relays that pass the vendor's 400 on.

Verified:
- Tests:
  - TestSamplingAsTheModelTakesIt covers each refusing model's
    spellings, a Bedrock path, and byte-identical bodies for Haiku 4.5,
    Sonnet 4.6/4.5, Opus 4.6/4.1, Claude 3.7, Sonnet/Haiku 6, deepseek,
    glm, kimi and gpt.
  - TestHaikuFiveFiveSamplingOnTheWire uses a fake vendor answering
    with the relay's literal 400s. It covers the classifier relayed,
    top_p/top_k, magpie's own classifier built from chat, Haiku 4.5
    keeping temperature 0, and an unreadable name retried once.
- Without the forwardOnce call, three wire cases FAIL. Without the
  retry, the unreadable-name case FAILs.
- Live, through this build's `magpie serve` under a sandbox HOME with
  the aihubmix key, in the classifier's shape (temperature 0, stop
  </block>, thinking disabled), all 200:
  - claude-haiku-5-5 at default and max effort and with thinking
    omitted;
  - claude-haiku-4-5, claude-sonnet-5-5, claude-opus-5-5 and
    claude-sonnet-5;
  - chat to claude-haiku-5-5 at temperature 0 and at top_p 0.5.
  Straight to aihubmix, Haiku 5.5 at temperature 0, top_p 0.5 or
  top_k 5 gives the 400.
- gofmt -l, GOOS=linux and GOOS=windows go vet -tags nogui
  ./internal/gateway, and go test -tags nogui ./internal/gateway under a
  temp HOME with pinned caches.
2026-10-10 11:13:42 +08:00
yetone 4a3d7c6f04 gateway: an agent's thinking between_tools, Sonnet 5.5's thinking off, is read as thinking off on a model magpie translates for (#1454)
Sonnet 5.5 takes thinking turned off as {"type": "between_tools"}.
parseAnthropic read only "disabled" as off, so an agent that already
sends between_tools, routed to a model on another API (a group's
member, a fallback), had thinking neither off nor on, and was left to
that model's default. It is now read as ThinkOff, the same as disabled.

Verified: TestBetweenToolsReadAsThinkingOff fails without the change
("between_tools: ThinkOff = false"). gofmt -l, GOOS=linux and
GOOS=windows go vet -tags nogui ./internal/gateway, and go test -tags
nogui ./internal/gateway ./internal/agent all pass under a temp HOME
with pinned caches.
2026-10-10 11:02:38 +08:00
yetone a9cb0ef988 gateway: Claude Sonnet 5.5 is sent thinking between_tools in place of disabled, and Opus 5.5, Fable and Mythos no thinking, so Claude Code's auto mode classifier no longer gets a 400 (#1454)
911Liam's auto mode, on claude-sonnet-5.5 through a relay, got 400 "To
turn thinking off on this model, send "thinking": {"type":
"between_tools"} instead of {"type": "disabled"}". Claude Code turns
thinking off for its classifier and side queries unless it knows the
model refuses that, and a model reached through a gateway can fall
outside what it knows. magpie forwarded the disabled as it came, and
turns thinking off itself too (an effort of none fitted, a budget that
doesn't fit, thinkingOffUnlessAsked for other vendors' models).

thinkingOffAsTaken, in forwardOnce next to adaptiveThinking, so that
relayed requests, count_tokens, magpie's own thinking-off and retries
all go through it, follows Anthropic's rules for each model. It reads
the model's name as the vendor gives it, from the body, or from the path
for Bedrock and Vertex.
- Sonnet 5.5: between_tools with no other field, and xhigh or max asked
  as high (between_tools is a 400 above high).
- Opus 5.5, Fable 5.x and Mythos 5.x refuse disabled at every effort.
  Thinking is left out, effort is low unless one was asked, and
  classifierRoom is added to a reply limit below it, as Claude Code
  itself does for these models.
- Opus 5 and Haiku 5.5: disabled is kept, with xhigh or max asked as
  high.
- between_tools sent to any other Claude goes as disabled, or with no
  thinking where disabled is refused too.
Other vendors' models, Sonnet 5, Opus 4.8 and older, and lines later than
these go byte for byte as they came. A Sonnet 5.5 under a name these
rules can't read is asked again with between_tools, once, on the 400
that asks for it (asksBetweenTools). A refusal of disabled at any effort
(thinkingOffRefused) is asked again with thinking left to the model,
beside alwaysThinks.

TestAdaptiveOnlyModelsNeverGetABudget's "disabled stays" case pinned
disabled for claude-opus-5.5, which the vendor refuses. It now checks an
older Claude keeps disabled and Opus 5.5 is sent none.

Verified:
- Tests:
  - TestThinkingOffAsTheModelTakesIt and
    TestThinkingOffLeavesOtherModelsAlone (deepseek, glm-5.3, kimi,
    Sonnet 5, Opus 5 at high, Haiku 5.5, Opus 4.8 at max, Sonnet 4.6,
    Haiku 4.5, Claude 3.7, dated Sonnet 5 and Sonnet 6 unchanged).
  - TestClaudeFiveFiveThinkingOffOnTheWire: a fake vendor answers with
    the reporter's literal 400, covering the classifier body,
    count_tokens and the retry.
- Without the forwardOnce call, the wire test's Sonnet 5.5, Opus 5.5 and
  count_tokens cases and the Opus 5.5 adaptive case FAIL. Without the
  retry, its unreadable-name case FAILs.
- Live, through this build's `magpie serve` under a sandbox HOME with
  the aihubmix relay key, in the classifier's shape (max_tokens 64,
  temperature 0, stop </block>, thinking disabled):
  - claude-sonnet-5-5 answers 200 at default and xhigh effort (sent
    straight to aihubmix with disabled it gives the reporter's 400);
  - count_tokens 200;
  - claude-opus-5-5 200;
  - claude-haiku-5-5 at max 200;
  - claude-sonnet-5 200.
- gofmt -l, GOOS=linux and GOOS=windows go vet -tags nogui
  ./internal/gateway, and go test -tags nogui ./internal/gateway under a
  temp HOME with pinned caches.
2026-10-10 11:02:38 +08:00
yetone 16539c2ea9 imagemcp: generate_video and the Magpie Image card name every video model the gateway makes videos with, not a Grok subscription alone (#1399)
shen-joe (#1399) wants images and videos from magpie in PI-Desktop and
WorkBuddy. Neither app sends video generation to a custom provider: PI-Desktop
has no video generation at all, and WorkBuddy's VideoGen/ImageGen post to
copilot.tencent.com's /v2/videos|images/generations with the account token.
So the way those agents get videos is Library → MCP → Magpie Image
(`magpie mcp image`). Its generate_video tool told the agent it works only
"with a Grok subscription signed in to Magpie (it has no other video model
yet)", and the Library card said the same, while gateway.Videomakers also
makes videos with a preset plan's videos (Volcengine Ark Agent Plan's
Seedance), another magpie, and a custom provider whose model list marks video
models (#1446). An agent reading that can tell a Seedance user they need Grok.

- imagemcp.go: generate_video's description and the package doc name each
  kind of video model.
- library/market.go: the Magpie Image card says "a video model set up in
  Magpie (Grok Imagine, Seedance)"; zh, zh-TW, ja and de follow.
- Tests: TestVideoToolSaysEveryVideoModel and TestMagpieImageSaysVideoModels
  fail on the old text and pass now.

Run: go test -tags nogui ./internal/imagemcp ./internal/library ./internal/gui
./internal/gateway (ok) and ./... (only TestNoCommandBypassesProc red, from
2da8fbe4, fixed on main by b934d64a); gofmt -l; GOOS=linux and GOOS=windows
go vet of imagemcp and library. A built magpie's `mcp image` answered
tools/list with the new description. A sandbox gateway (scratch HOME,
127.0.0.1:3591, a real OpenRouter key) answered PI-Desktop's exact request,
POST /v1/images/generations {"model":"openrouter/google/gemini-2.5-flash-image",
"prompt":…,"n":1}, with 200 and data[0].b64_json, a PNG.
2026-10-10 10:58:31 +08:00
yetone f2611b7154 accounts: import ChatGPT accounts from Cockpit Tools' and Sub2API's exports, several files at once, from the window or magpie accounts import; the box says the source tool is signed out (#1453)
wlj521 asked for cockpit-tools' import: several auth.json files at once.
magpie's window already imported Codex's auth.json and CLIProxyAPI's
files; this brings it the rest of what Cockpit Tools exports and a command
for it.

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

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

Run:
- go test -tags nogui ./... under a sandbox HOME (pinned caches)
- TestImportCockpitExports red on the old login_import.go (Sub2API rows
  unread, the garbage file passed over); TestAccountsImport red with the
  confirmation removed by hand; login-import.test.cjs red on the old app.js
- node --test login-import, factory-key, i18n-keys, gui-ja, gui-de,
  gui-zh-tw (Chromium and WebKit; en and zh, results at 380px)
- gofmt -l; go vet, GOOS=linux and GOOS=windows go vet -tags nogui on .
  and ./internal/provider; darwin, linux and windows builds
- no live ~/.codex: fixtures and a fake token endpoint only
2026-10-10 10:56:55 +08:00
yetone 99009b9333 provider: a plugin's model pool counts a bare id with its @sizes, so a routing group's cursor/grok-4.7 is weighed on Cursor Models, not a used-up Other Models (Will on Discord)
Will's routing group keeps cursor/grok-4.7. Cursor's plugin (0.2.2/0.2.3)
lists Grok 4.7 only at its context sizes, grok-4.7@256k and
grok-4.7@500k, and its usage() builds the Cursor Models window's models
and Other Models' notModels from those ids. quotaOfPlugin matched a model
against those lists by the whole string, so bare grok-4.7 (which the
plugin still runs, at its default size) fell into Other Models: used up,
so the Routing page showed it 0% left and weighRouted ranked it last,
while composer-2.5 showed ~84%.

The window's lists are now an idSet: a bare id is in it when one of its
id@size is, and an id@size when its bare id is. Only the trailing @size
is taken off, so distinct models stay apart: another @size of a model,
another family member (grok-4), and other suffixes (ZCode's -Trial) still
match by the whole id. Every route that asks a plugin window whether it
counts a model goes through w.matches -> Limit.applies (routing's For,
Pace, Count, Renewal, fallback's Pooled, CapHeld, QuotaWindow.Counts for
the usage page's holds), so the one change reaches them all. host.js's
contract comment says so for plugin authors.

The plugin's own lists are also fixed in magpie-community/plugins (cursor
0.2.4 adds each listed id's bare name to its pool), for magpie versions
before this one.

Verified:
- TestPluginPoolCountsBareIDOfSizes (the reporter's literal ids) fails on
  the old code: "grok-4.7: used 100, want 16", "the usage page's holds
  count grok-4.7 in Other Models", "grok-4.8@1m: used 100, want 16";
  passes with the change.
- gofmt -l clean; go vet, GOOS=linux and GOOS=windows go vet -tags nogui
  on internal/provider and internal/plugin; go build for darwin, linux,
  windows.
- go test -tags nogui ./... green but TestNoCommandBypassesProc, red on
  main since 2da8fbe4 and fixed by b934d64a, which this is rebased on.
Not verified: against Will's real Cursor account.
2026-10-10 10:54:22 +08:00
yetone b934d64a4e tests: TestNoCommandBypassesProc leaves out internal/testenv, which only tests import and which can't import proc (its tests import testenv); main was red since 2da8fbe4's testenv.Bun ran bun with os/exec 2026-10-10 10:51:48 +08:00
yetone 54675767f1 sessions: a folder draws its latest 100 sessions and a Show more for the next page, and opening a session draws only that session again (ChildhoodAndy on X)
With thousands of sessions the Sessions page drew every row of the open
folder, 17,000 elements for 1,000 sessions, and drew them all again on
switching to the page or to another agent, on each keystroke in the filter
(every folder opens while filtering: 51,000 elements for 3,000) and on
opening a session. Switching and scrolling stuttered.

Now an open folder draws a page of 100 and a "Show N more" (data-unrolls)
that adds the next page under the rows already drawn. A filter or another
agent starts again at a page. A session opened from the Usage page is drawn
however far down its folder it is. Opening a session replaces only that
item and the one it closes. The folder box and the bar still pick every
session, drawn or not.

Measured with 3,000/2,500 sessions, before → after:
- open the page: WebKit 449 → 159 ms, Chromium 312 → 165 ms
- open a session: WebKit 287 → 67 ms
- filter keystroke: WebKit 546 → 64 ms
- elements drawn: 17,073 → 1,774

Run: sessions-manage-paging.test.cjs (new; Chromium + WebKit, en + zh,
1000px + 380px) and every sessions-*/session-* test, click-scroll and
old-webkit, all green.
2026-10-10 10:50:09 +08:00
yetone ca3394ff80 docs: the community's plugins update by themselves every hour, not every six hours (en, zh, ja)
internal/plugin/updates.go's KeepUpdated first runs 30 seconds after
magpie starts, then every updateEvery, which is time.Hour. #681 cut it
from six hours. The Plugins page (plugins.js) already says "within the
hour". site/public/docs/plugins.html, zh/ and ja/ still said six hours.

Other "six hours" mentions were checked and left alone, since the code
agrees with them:
- the registry.json fetch on the same pages (market.go: 6*time.Hour);
- the move retry in docs/subsystems/provider-plugins.md
  (migrate.go: moveRetry = 6*time.Hour).

Bun's own check (bunEvery) is six hours, but no doc states it. No README
or docs/ page states the plugin update interval.

Run: grep across README*.md, docs/ and site/ for six hours / 6 hours /
六小时 / 6 小时 / 6 時間. No code changed.
2026-10-10 10:28:49 +08:00
yetone e5f04f38e8 plugin: a plugin on a pre-release whose release is out shows the update on its row, as the Plugins dot already did; versions are ordered as semver orders pre-releases, in Go and in the page
The Plugins dot comes from update.Newer, and the Installed rows (chip,
Update, Update all) from plugins.js's own newer(). They disagreed:

- newer() read only x.y.z, so 2.0.0-beta.1 with 2.0.0 out had the dot
  and no row to update it from. Real case: opencode-antigravity-auth,
  whose latest is 1.6.0 after 1.6.0-beta.0..2.
- update.Newer compared pre-releases as strings, so 1.0.0-beta.10 came
  before 1.0.0-beta.9 (and 1.6.0-10 before 1.6.0-9): a missed update,
  and the other way round, an "update" to an older beta.
- update.Newer didn't parse build metadata, so 1.0.1+build.1 was never
  newer than anything.

Both now order as semver §11: no pre-release comes after any;
identifiers compared one by one, numbers as numbers, a number before a
word, and a shorter list before a longer one it starts. Build metadata
is ignored. A version that isn't x.y.z is never newer, as before.

Tests:
- internal/update TestNewer gains the pairs. On the old Newer 4 fail
  (beta.10/beta.9 both ways, 1.6.0-10/-9, +build).
- internal/gui/tests/plugin-prerelease.test.cjs: seven installed
  plugins, the dot's waiting list as update.Newer gives it, and each
  row's chip and Update button, plus the Update all count, must match.
  On the old plugins.js it fails at "v2.0.0 out" in both engines. No
  strings change, so it's English only.

Reproduced in a sandbox HOME with the real package:
`magpie plugin add opencode-antigravity-auth@1.6.0-beta.2`, then Check
for updates. Before: /api/plugins/updates listed it, and the Plugins
nav had the dot, but the row had no chip and no Update, and there was no
Update all. After: the row shows "v1.6.0 out", Update, and Update all (1).
Update all then installed 1.6.0 and the waiting list emptied.

Run: gofmt; go vet -tags nogui for darwin, linux and windows on
internal/update, internal/plugin, internal/gui and the root package; go
test -tags nogui of the root package, internal/{gui,plugin,provider,
stats,update} under a temp HOME; GUI tests plugin-prerelease,
plugin-updates, plugin-card-update, plugin-check-updates, plugin-market,
plugin-release, plugin-git and plugin-local in Chromium and WebKit.
2026-10-10 10:28:49 +08:00
yetone 2da8fbe488 tests: every test that runs plugins on the bun on PATH runs it once first, through testenv.Bun, so a bun that has never run here doesn't time out internal/provider's and internal/plugin's plugin tests (#1306)
65dcc573 (ggbdpq's patch 1) warmed the bun on PATH before the root
package's one-minute plugin contexts. The same lookup of the bun on PATH
is in 40 more places in internal/provider (28), internal/gateway (8),
internal/plugin (3) and internal/gui (1), and in the root package's
TestPluginListSaysClash. Many of them then open a one- or two-minute
context (internal/provider's sign-in, fetch and move tests,
internal/plugin's sandbox), and there macOS's first-exec check of a
just-installed or upgraded bun landed inside the deadline. The rest
are covered the same way, so no test has to remember which is which.

testenv.Bun now does what the root package's pathBun did: it finds the
bun on PATH (skipping the test when there is none) and runs
`bun --version` before the test opens its context, once per bun per test
binary. pathBun is gone; every `exec.LookPath("bun")` + skip block in
the tests uses testenv.Bun instead. Three tests reach for the bun some
other way and are left as they were: TestHostRunsAsMagpieBun (7ddea2c2
covers its copy), the plugin_git test's realBun and netproxy's
TestBunThroughBridge, none of which runs the plugin host under a
deadline on the PATH bun. migrate_test.go needed two `err` declarations
that the removed block used to provide.

What was run (macOS arm64, bun 1.4.2), with a wrapper bun first on PATH
whose first exec sleeps 61s (marker-gated, then execs the real bun):
- Red, old code:
  - internal/provider TestPluginSignInTakesAPastedCallback: FAIL at
    61.1s, "pluginsignin_paste_test.go:36: starting plugins: context
    deadline exceeded".
  - internal/plugin TestFakePlugin: FAIL at 62.0s, "plugin_test.go:66:
    Plugins = [], starting plugins: context deadline exceeded".
  - internal/gateway TestPluginFailover passed on the old code too (its
    calls have no deadline that short), so the gateway is not shown red.
- Green, this change, same cold wrapper: the three tests passed (61.1s,
  62.9s, 61.2s).
- go test -tags nogui -count=1 for the root package and for
  internal/testenv, gateway, gui, plugin and provider, under a temp HOME
  with GOMODCACHE, GOCACHE and GOPATH pinned: all ok.
- gofmt -l on the changed files: clean. go vet of those packages for
  darwin (-tags nogui), GOOS=linux and GOOS=windows (-tags nogui), and
  GOOS=windows without the tag: clean.

Not run: a really cold bun on a loaded machine; the sleep stands in for
the check. Not run on Linux or Windows, where the warm-up is one extra
`bun --version` per test binary.
2026-10-10 10:22:59 +08:00