Commit Graph
206 Commits
Author SHA1 Message Date
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 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 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 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 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 eb035e29e1 sessions: a session seen only through the gateway can be deleted: its row and recorded conversation go to magpie's trash, and Restore brings them back (lc on Discord)
lc ("只可列出?不可删除?agent claude code") saw Claude Code sessions named by
uuid under "No folder" at "0 B", with "These Claude Code sessions are read
only; magpie can list them, but cannot resume or delete them." Those rows are
gateway projections from the usage ledger: Claude Code ran with its own files
somewhere magpie can't see (another computer, a container, another config
folder), so nothing native won on the same agent+id. An agent with only such
rows was sent as not deletable, and every row was read_only, so the page had
no Delete at all.

What changed:
- sessions.DeleteGateway moves the session's recorded text folders
  (gateway-conversations/<date>/<identity hash>) into
  trash/sessions/gateway/<ts>-<hash>/files/<date> with a note marked gateway,
  and records {agent, id, last} in gateway-sessions-hidden.json.
  /api/sessions and /api/sessions/manage leave it out up to that last
  request; a newer request lists it again. The usage ledger is untouched, so
  Usage, /api/sessions/one and stats keep what it spent. A session with a
  request in the last minute is refused as active.
- Restore moves the text back (skipping what expired or was cleared, refusing
  rather than overwriting a same-day re-recording), checks each item is this
  session's identity folder inside the store, and unhides it. Delete forever
  keeps it hidden.
- The recording's promises hold in the trash: the hourly prune erases
  trashed gateway text past 7 days, "Delete saved conversations…" erases it
  too, and HasGatewayConversations counts it.
- The manage route marks an agent with gateway rows deletable and sends
  those rows deletable, with gateway: true and the size of their recorded
  text; the delete route sends gateway ids to DeleteGateway and native ones
  to Delete as before.
- Sessions page: gateway rows group under "Seen through the gateway"
  instead of "No folder", show "{size} recorded" only when something was
  recorded (no "0 B"), and the note says why they can't be resumed and what
  Delete takes and keeps. The delete dialog says the same for gateway rows
  instead of "Their files are moved…"; the Trash marks them and notes the
  7-day expiry. New strings in zh, zh-TW, ja and de.
- docs/subsystems/gateway-sessions.md describes the delete, hidden list,
  restore, expiry and clear.

Verified:
- go test -tags nogui ./... (sandbox HOME) green; gofmt -l clean; go vet
  ./...; GOOS=linux and GOOS=windows go vet -tags nogui ./internal/sessions
  ./internal/gui; go build, GOOS=linux -tags nogui and GOOS=windows builds.
- TestSessionsDeleteGatewaySession fails on the old code with "Claude Code
  with only gateway sessions isn't deletable: [{Agent:claude
  Deletable:false}]"; sessions-gateway-delete.test.cjs fails there too.
  TestGatewayDeleteTrashExpires, TestGatewayPurgeKeepsItHidden,
  TestGatewayDeleteNothingRecorded and TestGatewayRestoreRefusesForeignItems
  cover expiry, purge, an unrecorded session and a forged note.
- GUI: sessions-gateway-delete.test.cjs in Chromium and WebKit, en, zh,
  zh-TW, ja, de, at 1000 and 390px (in-app dialog, Cancel sends nothing,
  click doesn't scroll, no left-border accents, no page overflow); all
  sessions-*, gateway-conversations, checkbox-look, old-webkit,
  sess-agent-strip, i18n-keys, gui-ja, gui-de and gui-zh-tw tests pass.
- Real try: magpie web from this tree on a sandbox HOME seeded with three
  gateway-only Claude Code sessions (two recorded): deleted a recorded and an
  unrecorded one, checked the store, trash and hidden list on disk, restored
  the recorded one (its transcript reads again), purged the other (stays
  hidden). That try found the Trash page breaking on an unrecorded session's
  null items; DeleteGateway now writes [] (TestGatewayDeleteNothingRecorded
  fails without it) and the page reads a missing list as empty.
2026-10-10 09:53:38 +08:00
yetone 83c963c466 gateway: a gateway MAGPIE_ADDR puts on the network asks callers from other machines, a container's host among them, for an enabled gateway key, as sharing does
What changed
- lanGuard: a request whose peer isn't loopback (or that carries a
  forwarding header) is remote. While the gateway is shared, or
  MAGPIE_ADDR names a host other than loopback/localhost (OnNetwork:
  0.0.0.0 in the Docker image, a server's address), a remote request is
  answered only with an enabled gateway key, on every route behind the
  guard; otherwise it is refused (403) as before. Before, a MAGPIE_ADDR on
  the network with sharing off took remote requests with any key.
  A remote request let in with a key is marked as shared-mode ones are,
  so quotas, quota cards, refresh and prices answer it.
  callerGuard follows the same condition. OpenToAnyone is replaced by
  OnNetwork.
- Loopback is unchanged: any key still works on the gateway's own
  machine and inside the container (HEALTHCHECK included).
- CLI keyNote and the console's Connect note no longer describe an open
  gateway; on the network they say other machines need a gateway key
  (`magpie gateway-key add <name>`). The console shows the Gateway keys
  block and network addresses when OnNetwork (gatewayJSON.onNetwork
  replaces open); the "Open to the network" strings are removed.
- Docs: docker.html (en/zh/ja) and remote.html (en/zh/ja) say callers
  outside the container need a gateway key, how to make one, that
  sharing isn't needed for it, and what an upgrade means for agents that
  sent `magpie`; docs/reference.md and providers-accounts.md likewise.
  A MAGPIE_ADDR pinned to one interface's address now asks a key of this
  computer's agents calling that address too (documented).

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

Run
- go test -tags nogui -count=1 ./internal/gateway ./internal/gui
  ./internal/provider . under a temp HOME: ok.
- gofmt -l clean; GOOS=linux and GOOS=windows go vet -tags nogui on
  those packages: clean.
- GUI: public-url and gateway-caller-keys tests, Chromium and WebKit: 22/22.
- Locally built image (OrbStack), -p 127.0.0.1:3481:3425: from the host,
  /v1/models with no key, `magpie` and another made-up key -> 401;
  count_tokens and /mcp/x -> 401; a key from `docker exec ... magpie
  gateway-key add Host` -> models 200, quotas 200; inside the container
  wget /v1/models -> 200; healthcheck exits 0. `web --addr 0.0.0.0:3430`:
  /api/state 401 without the web key, 200 with its cookie.
2026-10-10 09:26:23 +08:00
yetone c4476d5658 library: skills from GitHub are checked as the skills page opens, and one changed here is marked and asked about before an update replaces it (#1449)
YangZhiqiang98 asked (#1449) that the Library find which skills have
updates and update them. magpie already checked skills from GitHub (a
market install among them) by hand and could update them, but an update
deleted the library's folder, so a skill the user had changed (by hand,
through an agent's link, or in an agent's copy not yet taken in) was lost
without a word, and nothing checked unless the user pressed Check.

- A skill is "changed here" when its library folder no longer hashes to
  what was fetched (Finder's .DS_Store aside), or an agent's copy was
  edited since its mark and not yet taken in (editState, skill_changed.go).
  The view says so (SkillView.edited) and the row shows "Changed here".
- UpdateSkill refuses such a skill with *EditedError unless told to
  replace it. The row's Update asks in the page first (Keep mine / Update;
  Update sends replace). Update N, the group's Update N and the CLI's
  update-all leave it as it is and say so. The CLI takes
  `magpie library skill update <name> --replace-edits`.
- The version an update replaces is moved to the backups when it was
  changed, or when it can't be told (a skill with no hash from before).
- View.checkDue: no check since magpie started, or none for 12 hours. The
  page then runs the check once by itself as the skills open, and says
  only what there is to update or add.

Not checked, by design: skills the skills CLI manages (`npx skills
update` updates them; magpie only groups them), linked local folders,
skills imported from an agent with no source, and From-only skills have
no remote; CC Switch-installed skills can still be updated by hand.

Verified:
- go test -tags nogui ./... (sandbox HOME); new TestUpdateKeepsASkillChangedHere,
  TestUpdateKeepsACopyEditedInAnAgent, TestUpdateKeepsWhatItCantTell,
  TestCheckDue, TestCheckSkillsMarksItDone, all from httptest. With
  editState forced to "not changed" the first three fail.
- gofmt -l, go vet, GOOS=linux and GOOS=windows go vet -tags nogui on
  ./internal/library ./internal/gui ./internal/tui .
- GUI: new skill-update-edited.test.cjs (Chromium and WebKit, 860 and
  400px, en and zh; no scroll on click; fails with the row's ask removed),
  plus the 22 other library skill tests, gui-ja, gui-de, gui-zh-tw,
  i18n-keys and click-scroll: 262 pass.
- Real use in a sandbox HOME with github.com/anthropics/skills: installed
  pdf and docx, the real check said both current; an edit through Claude
  Code's link was refused by `skill update pdf`, skipped by `skill update`
  (docx updated), replaced by `--replace-edits` with the edit in
  backups/<time>/library/skills/pdf; docx edited later shows edited, a
  .DS_Store doesn't.
2026-10-10 09:14:35 +08:00
yetone 62d7cd7be3 sessions: recording gateway conversations is a setting card over the list, with what it keeps and where, and a labelled delete that shows only when text is kept (lee on Discord)
What changed: c9b82633 (#1355) put a bare "Record gateway conversations"
checkbox and an unlabelled trash icon on lines of their own under the agent
tabs, on every agent. They are now one card in the list's column, as wide
as the list: a switch (the app's lib-switch), its title, and a line that
says what is kept and where ("on this computer only, for up to 7 days
(256 MiB)", worded for on and off). "Delete saved conversations…" sits in
the card, says what it deletes, and asks in magpie's own dialog as before.
The card shows only where it applies: on an agent whose list has gateway
sessions, in gateway mode, or while recording is on or text is kept; not on
an agent without gateway sessions, nor in the Trash. The delete shows only
while some text is kept.

To know that, /api/sessions/manage marks gateway-only rows `gateway` and
says `recorded` (sessions.HasGatewayConversations: any file under
gateway-conversations; a folder that can't be read counts as holding some,
so it can still be cleared). New strings in zh, zh-TW, ja and de.
docs/subsystems/gateway-sessions.md says where the switch is and when it
shows.

Why: lee (Discord feedback, 1558248607751344220) said the controls' look
and place need a redesign: they read as stray, unaligned controls, and the
icon didn't say what it deletes.

Run: gofmt -l (nothing); go vet, GOOS=linux go vet -tags nogui,
GOOS=windows go vet (with and without -tags nogui) on ./internal/gui/...
./internal/sessions/...; go test ./internal/gui/... and go test -tags nogui
./internal/gui/... ./internal/sessions/... under a sandbox HOME: ok.
GUI: gateway-conversations (both tests, en/zh/zh-TW/ja/de, 1000/1400 and
390px) plus checkbox-look, click-scroll, gateway-mode, i18n-keys,
old-webkit, sess-agent-strip, every sessions-* test and
usage-sessions-agents-fit: 221/221 (default run) and 114/114 with
BROWSER=webkit. Not run: the whole GUI suite.
Fails without the change: with origin/main's sessions_manage.go,
TestGatewayConversationAPI fails "listing: …" (no gateway/recorded); with
origin/main's sessions.js/.css, every gateway-conversations case fails
(no .sm-record switch; "no bare checkbox bar under the tabs 2 !== 0").
2026-10-10 07:40:03 +08:00
yetone c1c887798b provider, docs: a bare model id several providers serve goes by the Providers order, said and tested (MOMO on Discord)
MOMO's {"model":"glm-5.3"} was answered by opencode-go though a6api
serves glm-5.3 too, and they asked for a 409 or for the response to say
which provider was used. The pick was already deterministic, but
Resolve's comment said "exactly one provider" and no doc said what
happens.

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

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

Run: gofmt -l; go vet -tags nogui on darwin, linux, windows for provider
and gateway; go test -tags nogui ./internal/gateway ./internal/provider
in a sandbox HOME.
2026-10-10 05:56:07 +08:00
yetone e50a958014 gateway: an unknown model's 404 names the nearest ids, display names first, and how a model is named (MOMO on Discord)
MOMO asked for "Zen-Space-Bunny", the model's name as a picker shows
it, and the 404 said only "magpie knows no model" and then every id. It
named neither the id meant (opencode-zen-free/space-bunny-free, "Space
Bunny Free") nor the forms a model id can take.

What changed:
- provider/closest.go: Closest(asked, n) splits asked into lowercase
  letter and digit words and scores every catalog entry: a word in the
  display name (the user's or the vendor's) counts 4, in the model id 3,
  the provider's id or name 1 and only alongside one of the others; the
  whole of asked found in the name or id adds 4.
- gateway/unknown_model.go: unknownModel puts "did you mean …?" (up to
  3), then the forms (provider/model, a routing group's id or name, a
  bare model id and where an ambiguous one goes), then the ids as
  before. Used by the chat 404 and the embeddings 404. Switched-off,
  disabled-group and empty-group 404s are unchanged. Draw and video keep
  their own 404: their models are a different list, and the chat
  catalog's nearest ids would point at models that can't draw.
- docs: gateway-routing.md model resolution row.

Tests: unknown_model_test.go: "Zen-Space-Bunny" against
opencode-zen-free serving space-bunny-free named "Space Bunny Free" is
told that id first and the forms; a provider's name alone suggests
nothing; a display name ranks before an id with the same words. The
404 test fails with the gateway hooks reverted.

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

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

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

Run: gofmt -l; go vet -tags nogui on darwin, linux, windows for provider
and gateway; go test -tags nogui ./internal/provider ./internal/gateway
./internal/agent ./internal/catalog in a sandbox HOME.
2026-10-10 05:40:28 +08:00
yetone 3e5bc3d8fe gateway, usage: conversation text is kept only under a session the client named, and a client's "request-…" session counts like any other (#1355)
What changed: with recording on, a request that names no session used to
get a made-up "request-…" id and its text saved under it. Nothing listed
that id and GatewaySessionByID turned it away, so the text could never be
opened. magpie now makes up no id: such a request isn't recorded, and the
X-Magpie-Session reply header only echoes the client's own session.

With no made-up ids left, the "request-" prefix checks in appendUsage,
OTel, Usage's sessions, Routing's vias and the gateway Sessions projection
only dropped sessions clients named themselves (appendUsage wiped them from
the ledger). They are gone. The tests that held the old rule now say the
new one, and gateway-sessions.md says it.

Run: TestGatewayConversationNeedsTheClientsSession and
TestGatewayConversationClientRequestPrefixedSession fail on the PR's code
(a made-up id is returned; the ledger keeps 0 of 2 calls) and pass here;
putting appendUsage's prefix check back turns the second red again.
gofmt -l clean; go vet; GOOS=linux and GOOS=windows go vet -tags nogui on
gateway, gui, sessions, settings, usage; go test -race -timeout 900s on
those five: ok (gateway's TestCORSKeyThroughTheServer rerun after its fix).
GUI: gateway-conversations, gateway-mode and all sessions-* tests, 216
cases in Chromium and WebKit: pass.
2026-10-10 04:24:18 +08:00
木子不是木子狸 c9b8263378 sessions, gateway: Sessions lists gateway sessions, and can keep their text on this computer when turned on (#1355, mu-zi-lee)
What changed: the Sessions page lists sessions seen only through the
gateway (from the usage ledger, read-only, a native session of the same id
wins), and shows in gateway mode. A Sessions switch, off by default and
confirmed before it turns on, records each gateway exchange's text under
the config dir (scrubbed, at most 7 days and 256 MiB, cleaned hourly).
Settings sync and restore can't turn it on; POST /api/sessions/recording
turns it on or off and can clear what was kept.

Why: users of magpie as a gateway (gateway mode, LAN clients) had no way
to see their conversations; the owner chose this design on the PR.

Squashed from PR head 6a343ba1 onto origin/main. Conflicts: gui-shell.md
(kept main's placeMenu text, took the PR's Sessions in gateway mode) and
settings_keep_test.go (main's fields plus GatewayConversations).

Run: go build ./..., go vet on gateway, gui, sessions, settings, usage;
go test -race -timeout 900s on those five packages: all ok.
2026-10-10 04:24:18 +08:00
yetone 916c1cc7ea gateway: a custom provider's video models, marked "kind": "video" by its own model list, are started, polled and fetched at its OpenAI Videos API (#1446, Edward4226)
What changed
- Videomakers: a custom provider (no account, not a remote magpie, no
  preset plan's videos, a Chat or Responses base) makes videos with the
  models its fetched /v1/models marks "kind": "video"
  (catalog.LiveVideomakers). Nothing is guessed from a name: sora-like-2
  without the kind, a chat model and grok-imagine-video named on it all
  still get the 400 and reach no upstream video route.
- /v1/videos, /v1/videos/{id} and /v1/videos/{id}/content forward to
  {base}/videos the way Remote magpie's already do (filmsOpenAI), where
  base is the Chat base, else the Responses base (videoBase). The vendor
  gets its own User-Agent and no caller headers; passOnCaller stays
  remote-magpie only.
- Custom video ids base64 the vendor's id (its own may hold dots) and end
  with the key's fingerprint (customVideoID, markedKey), so a poll and its
  download use the key that started the video, and a key turned off since
  answers 404 "no longer on". Remote magpie's and Volcengine's id formats
  are unchanged.
- Gateway key holds on all three routes: account/key holds before any
  upstream call (videoAsker), model holds once the model is known
  (videoModelHeld). Grok's and Volcengine's polls and downloads ignored a
  key's model holds before; they now apply them too.
- videoObject's done/succeeded mapping is untouched: the custom path passes
  the vendor's OpenAI object through (asOurs), so its "completed" reaches
  the client as sent.
- docs/subsystems/gateway-routing.md says so under Remote magpie APIs.

Why
- Edward4226's provider serves OpenAI's Videos API, and magpie answered
  400 "can't make videos" because only Grok, Volcengine and Remote magpie
  were allowed.

What was run
- New tests in video_custom_test.go (httptest OpenAI Videos fake whose id
  holds a dot; start, two polls, content bytes; exact upstream request log;
  Responses-only base; two keys with holds; Grok poll/content held by key
  models). On the old video.go 4 of 5 fail (videomakers [], 400 "can't
  make videos", held Grok poll 200); the name-guard test passes on both,
  as it should. Breaking markedKey by hand, or the content model check,
  turns TestCustomProviderVideoKeys red.
- go test -tags nogui -count=1 ./internal/gateway/ ./internal/catalog/
  ./internal/provider/: ok. gofmt -l clean; go vet -tags nogui
  ./internal/gateway/ on darwin, GOOS=linux and GOOS=windows: clean.
- End to end: the built magpie (nogui) under a scratchpad HOME, `magpie
  provider add Studio url=http://127.0.0.1:3561/v1 key=…`, `magpie serve`
  on 127.0.0.1:3562, against a Go fake of the Videos API on 3561. curl
  start -> queued, poll -> in_progress, poll -> completed, content ->
  video/mp4 whose 200000 bytes cmp equal to what the fake served. The fake
  saw POST /v1/videos with model vid-1, the polls and /content, each with
  the provider's key and no X-Magpie-Agent.
- No real video vendor was used. Multipart uploads to a real OpenAI
  upstream weren't tried. The GUI lists no video models per provider, so
  it has nothing to check.
2026-10-10 04:15:28 +08:00
yetone af1e477d83 gateway: a resumed reply to a model that takes no prefill is answered after it, not refused (#1447, Edward4226)
OpenCode Go's Claude models (over Anthropic Messages) refuse a
conversation ending with the assistant's message, "This model does not
support assistant message prefill. The conversation must end with a user
message.", and Volcengine's Agent Plan does too, "The last message cannot
be from the assistant for a model that does not support prefill".
magpie took every Anthropic upstream to go on from a prefill, so both a
client's own resume (Codex sends what it has of a cut reply as the last
assistant message) and magpie's own continuation of a cut reply (#853)
ended with that refusal.

The refusal is about the model, so it is remembered for that provider,
model and API in unfit (prefillRefused); the provider's other models,
and every upstream that takes a prefill, are unchanged.

- A Chat or Responses client's conversation ending with the assistant's
  message, which those APIs read as a turn already said, is asked again
  with a user turn after it ("Continue."), the conversation the refusal
  asks for, and so from then on: translated (forwardTranslated, whole
  and streamed) and relayed as is (passthrough, Chat and Responses: the
  Volcengine case).
- An Anthropic client's prefill is its own ask and still gets the
  refusal as the vendor sent it.
- magpie's own continuation is not asked of that model again, and the
  try it refused ends the reply with what cut it, as a reply that can't
  go on did before #853, not with the refusal of an ask the client never
  made. No user-turn continuation there: a re-answer spliced onto the
  part the client has is the garble continuation.go avoids.

The thinking-block refusal in the same report (a signed thinking block
with no text) is a separate fix, in its own commit.

Tests (prefill_test.go), from the report's literal messages: the
issue's Responses repro and a streamed Chat request to an Anthropic
upstream (TestTrailingAssistantToNoPrefillModel, also checks another
model keeps its prefill), Volcengine's refusal relayed on Responses and
Chat (TestTrailingAssistantRelayedToNoPrefillModel), a cut reply
(TestStreamCutOnNoPrefillModel), an Anthropic client's prefill
(TestAnthropicPrefillToNoPrefillModel), userLastBody. With the refusal
unrecognised (the old code's behaviour) the first three fail with the
report's "UP: Upstream request failed: [invalid_request_error] This
model does not support assistant message prefill…" / "…the reply didn't
end with what cut it"; with the remembering removed they fail with
"calls 4 … asked one again" and "calls = 2 … asked to go on again".

Run: gofmt -l (clean), go vet -tags nogui ./internal/gateway on darwin,
GOOS=linux and GOOS=windows, go test -tags nogui ./... under a scratch
HOME with pinned caches (all ok). Not run for real: the owner's OpenCode
Go key answers 403 "An active OpenCode Go subscription is required", and
there is no Volcengine Agent Plan account.
2026-10-10 04:14:30 +08:00
a998a36367 provider, gateway: remote magpie serves decision and retrieval APIs (#1282)
* provider, gateway: remote magpie serves decision and retrieval APIs

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

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

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

* gateway, provider: remote forwards keep official client headers

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-10-10 03:55:45 +08:00
yetone 599d784f59 library: magpie-image runs this computer's magpie, whatever path another computer or an old install saved (#1439, dawn1095)
magpie-image was kept in library.json with one computer's magpie by full
path (os.Executable at install). A WebDAV sync carried
C:\Users\...\Downloads\magpie-windows-amd64.exe to a Mac (and a Mac's app
to Windows), and a magpie moved out of Downloads left its old path behind:
the Library page said it couldn't start, and agents were given a program
that isn't there.

- heal (self_here.go): a server that is magpie's own -- `mcp ...` run by a
  program named magpie (magpie, magpie.exe, magpie-<os>-<arch>[.exe]) --
  whose program isn't an existing absolute path here is pointed at this
  magpie, as the library is read (load) and after every change (so a
  bundle Put from another computer is healed before it is written into
  the agents). Every agent writer, project MCP files, the WSL side and
  CheckServers read through load, so all of them get it. A magpie program
  that does exist here (another build) and any server run by another
  program are left as they are.
- Collect carries magpie's own server as plain "magpie": two computers
  with the same library carry the same bundle, so davsync's library hash
  matches and a sync doesn't write one's path over the other's.
- The path given is update.Program (exported cliProgram): the AppImage
  rather than its mount, Homebrew's opt link rather than the Cellar.
- TestWSLMagpieImage now says which magpie this (simulated Windows)
  computer runs.

Verified:
- New tests: TestOwnServerFromElsewhereRunsThisMagpie (Windows path read
  on macOS, a Mac path, an exe moved out of Downloads, a bare "magpie"),
  TestUsersOwnServerStaysAsItIs, TestCarriedOwnServerIsEachComputersMagpie.
  Broken by hand five ways (heal off; no heal in change; no marker in
  Collect; ownServer matching every server; existing magpie builds not
  kept) -- each went red with a FAIL naming the behaviour, green restored.
- gofmt -l clean; go vet -tags nogui ./...; GOOS=linux and GOOS=windows
  go vet (library, update, davsync); go build, GOOS=linux -tags nogui and
  GOOS=windows builds; go test -tags nogui ./... green in a sandbox HOME.
- Sandbox app (own HOME, ports 3561/3562) seeded with a library.json
  holding the reporter's Windows path for magpie-image plus a user's own
  missing Windows tool: on start library.json, Codex's config.toml and
  Claude Code's .claude.json got the running binary; /api/library/mcp/check
  answered magpie-image ok with 3 tools, the user's server unchanged and
  notfound.
Not verified: on the Windows box, or a real two-computer WebDAV sync.
2026-10-10 01:30:40 +08:00
yetone 7f62dbf3ed gateway, agents, gui: a model's reply limit is published within the window beside it, on /v1/models, Cursor's and Muse's lists, the Gateway page and magpie model output (#1438, yhong91)
#1438 (yhong91): GET /v1/models listed grok/grok-4.7 with context_window
256000 (the Grok backend's own) and max_output_tokens 500000 (models.dev's
x-ai entry, which catalog.Decorate fills in when the backend gives no
output). A client that reads both sizes its replies past what the window
holds. The agents' files already kept output within the window (maxTokens,
#338/#347), but the gateway's lists, the GUI and the CLI did not.

What changed:
- provider.OutputWithin(window, output) and Entry.PublishedOutput(): the
  output is kept within the window; an unknown window or output (0) is left
  as it is.
- Every place that publishes the pair uses it: modelObject (/v1/models),
  cursorLocalModel (Cursor Private Inference), Muse Code's list, the
  Gateway page's models and groups (providerInfo, providersState), the
  agents' maxTokens, and `magpie model output` (which also says
  "500k, kept within its 200k window" when it lowers one).
- Entry.Output stays the model's own limit. The request-path cap
  (outputLimit/withMaxOutput) and the user's Max output setting are
  unchanged, so no request is lowered further than before. This is why the
  clamp is not in replyLimit/entryFor as the issue suggests, and why the
  commenter's patch (modelObject only) was not taken as is: it was right
  but missed Cursor, Muse, the GUI and the CLI.
- docs/subsystems/gateway-routing.md: the rule and its routes.

Tests (each fails without the change):
- gateway TestModelListsKeepOutputWithinTheWindow: models.dev 500000 over a
  backend 256000 window, plus output within the window, unknown window and
  unknown output, on /v1/models, Cursor's capabilities and Muse's limit; a
  group request with max_tokens 300000 still reaches upstream as 300000.
- provider TestOutputWithin, gui TestProviderModelOutputWithinTheWindow,
  TestModelOutputCmdWithinTheWindow.
Breaking it by hand: OutputWithin returning output unclamped turns all of
them red, plus agent TestMaxTokensWithinContextWindow; gateway sites back
on e.Output turn the gateway test red on all three lists; moving the clamp
into Entry.Output turns the fixture and the group-request guard red.

Real data: an offline dump of the owner's catalog (copies of
~/.config/magpie and its caches, 203 entries) has 3 models with output
over their window, grok/grok-4.5, 4.6 and 4.7 (256000 vs 500000), now
published as 256000; 0 are not Grok. The live gateway on 3425 was not
running, so /v1/models itself was not read, and no real Grok request was
made.

Run: gofmt -l; go vet -tags nogui ./... on darwin, linux and windows;
go test -tags nogui ./... -count=1: all green except
TestLoopEndsWorkBuddysReply/plugin/anthropic ("let go only after 3207
lines", bound 2000) at load average ~35 with other suites running; it
passes 5 of 5 alone and does not touch the output limit or request path.
2026-10-10 01:30:29 +08:00
yetone b4bdd59f72 gui: a long app menu stays in the window and scrolls inside itself, so the last removed group can be brought back (#1437, Hipple)
With 28 found groups removed, Routing's "28 found groups removed" menu ran
off the bottom of a ~717px WebView2 window and couldn't be scrolled.
openRowMenu had no height cap (max-height: none, and .pop clips with
overflow: hidden), it only flipped up when the whole menu fit above, and any
scroll, even one inside the menu, closed it.

What changed:
- placeMenu (app.js) places every app menu: under its anchor, over it when
  the window runs out below, and one that fits neither way takes the larger
  room with its height held to it. openProtoMenu's placement moved into it
  unchanged; openRowMenu (agent rows, model chips, Routing's removed groups
  and its other row menus) and openSessCombo (the Sessions/Usage filters,
  which could run off the bottom the same way) now use it too.
- .row-menu scrolls inside itself (overflow-y: auto, overscroll-behavior:
  contain), and openRowMenu closes only on a scroll outside the menu, as
  openProtoMenu and openSessCombo already did.
- docs/subsystems/gui-shell.md names placeMenu.

Verified:
- New removed-groups-menu-scroll.test.cjs: 30 removed groups, 1100x700 and
  560x700, en and zh, Chromium and WebKit, 8/8 pass. It wheels the page to
  the line, clicks it where it is, checks the menu is inside the window and
  scrolls, wheels the menu until the last entry shows inside it, clicks it
  (posts groups/show auto-gone-30), and checks the page never scrolled and
  has no sideways scroll.
- Broken by hand three ways, each red in every case: the old app.js/app.css
  ("the menu stays in the window": bottom 1494 of 700), the old
  scroll-closes-menu handler ("scrolling the menu doesn't close it"), and no
  overflow-y on .row-menu ("the last entry is scrolled into view inside the
  menu").
- The 63 GUI test files that open app menus or guard click-scroll, both
  engines: 595 pass, 1 fail: search-api.test.cjs webkit zh, at page load
  (the row is redrawn between waitFor and scrollIntoViewIfNeeded) before any
  menu opens; it passes 5/5 alone. Not run: the rest of the GUI suite, and
  gui-zh-tw/ja/de (no new strings).
- gofmt (no Go changed), go vet ./internal/gui (darwin, GOOS=windows),
  GOOS=linux and GOOS=windows go vet -tags nogui ./internal/gui, and
  go test -tags nogui ./... under a sandbox HOME: all pass.
- Not tried in a real Windows WebView2 window.
2026-10-10 01:26:57 +08:00
yetoneandtimonwong ca0867e412 gateway: a Grok model's 2500.0 in a call's arguments reaches the agent as 2500, on every route and protocol, deltas and done alike (#1431, timonwong)
Grok's models (grok-4.6, grok-4.7) write a whole number in a tool call's
arguments with a fraction of zeros. Codex reads exec_command's
yield_time_ms and max_output_tokens as u64/usize and write_stdin's
session_id as i32, though their schemas say "number", and serde turned the
whole call away ("failed to parse function arguments: invalid type:
floating point `2500.0`, expected u64 at line 1 column 77"; `17277.0`,
expected i32), so the turn stopped at its first command. Reported 11 of 11
turns through a sub2api relay in front of grok-4.6 and grok-4.7.

What changed (grok_integral.go):
- grokModel/grokServed: a Grok model is one whose id, as magpie names it
  or as it is sent upstream, has a last `/` segment beginning "grok"
  (relay/grok-4.6, x-ai/grok-4, the plugin's grok-4.7).
- wholeNums: a streaming scanner over the arguments' JSON text. A number
  written with a fraction of zeros and no exponent loses the fraction
  (2500.0 -> 2500, -3.00 -> -3, [1.0,2.0] -> [1,2]). Strings, keys, other
  fractions (2.5, 2.50) and exponents (1.0e3) stay as written. It holds
  only a run of ".0…" until the next byte, so arguments cut into deltas
  anywhere come out the same as whole, and the deltas add up to what the
  done events say.
- Relayed (passthrough): intTidy, beside nsTidy and built like it, on
  Responses (function_call items, argument deltas and done, the completed
  response, a whole reply), Chat (tool_calls deltas and messages) and
  Messages (tool_use input and input_json_delta), streamed or whole. A
  line that carries no call passes as it came. Plugin-served Grok and the
  built-in subscription go through the same passthrough.
- Translated: translate sets Request.Grok; encoder() wraps the client's
  encoder in wholeEncoder (outside usageEncoder) and render() rewrites the
  Result's calls (wholeResult), so every client protocol is covered at
  those two choke points.
- Any other model's reply is relayed byte for byte, as before.
- gateway-routing.md says so, and lists the tests.

Scope, against the reporter's 9fd6855b: that commit rewrote only fields a
tool schema typed "integer" or a fixed list of Codex's (codexInts), only
on Responses, and left deltas as they came, so the deltas disagreed with
the done events, and the list missed Codex's other integers (shell's
timeout_ms, sleep's duration_ms, tool_search's limit, the multi-agent
tools' timeout_ms). Codex's own schemas say "number", and JSON has one number type, so 2500 is the same value as
2500.0 for every client; this drops the zero fraction everywhere in a
Grok model's call arguments instead. Its rollout fixtures are kept
verbatim (testdata/grok_integral_args) and its test helpers adapted.

Tests: TestWholeNumsAnyCut (every split point and byte by byte),
TestGrokCallsGiveCodexIntegers (relayed Responses, streamed with deltas
cut whole, byte by byte and at every '.'/zero, and whole),
TestGrokCallsGiveCodexIntegersTranslated (Chat upstream -> Codex),
TestGrokCallsGiveIntegersOnEveryProtocol (Chat relayed, Messages relayed,
Messages translated from Chat), TestGrokCallsByUpstreamName,
TestGrokPluginCallsGiveCodexIntegers (fake plugin, $FAKE_GROK),
TestOnlyGrokCallsGetIntegers (gpt-5.5 byte for byte on Responses, Chat
and Messages relayed; translated keeps 2500.0), TestGrokModel. Each
recorded case checks that the field Codex's own error names is now an
integer of the Rust type it named, wherever Codex reads the call.
With grokModel forced false, every Grok test fails; with the delta
rewriting turned off (intTidy and wholeEncoder), every streamed case
fails on deltas != done.

Run: gofmt -l (clean); go vet -tags nogui ./... on darwin, GOOS=linux
and GOOS=windows; go build ./... and GOOS=linux/windows -tags nogui
builds; go test -tags nogui ./... under a temp HOME; -race -count=3 on
the new tests. End to end: the built binary (`serve`, sandbox HOME, port
47812) in front of a fake upstream streaming 2500.0 a byte per delta on
Responses and Chat; a client reading /v1/responses and
/v1/chat/completions, streamed and whole, got integers in deltas,
arguments.done, output_item.done, completed and output for grok-4.6
(relayed) and grok-4.7 (translated from Chat), and 2500.0 unchanged for
gpt-5.5. The same client against a binary of the parent commit failed
all 12 Grok checks.

Not verified: a real Grok model or a real Codex turn; Messages and Chat
Grok replies have not been seen in the field; a relayed Messages
tool_use input that is rewritten has its keys re-marshalled in sorted
order; exponent forms (2.5e3) are left as written.

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

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

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

Not tried: a real third-party plugin, neiko's own plugins, and Windows
beyond vet.
2026-10-10 00:55:38 +08:00
yetone 059171006b library: DeepSeek Harness is given a server's ${NAME} as a !!js expression it reads from its environment (#1435, CnsMaple)
A Library MCP server whose headers or env named a variable as ${NAME}
(#1250) was refused for dsh, because dsh had no reference syntax in
refsOf. dsh does have one: a YAML !!js value is evaluated as the row
activates (dsh 0.2.0-rc.2: cordis-plugin-include's entryListSchema
reads the tag, quoted or not; cordis-plugin-loader's interpolate
evaluates it across the whole config), and dsh-mcp-client's README uses
GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN. A stdio server needs it so:
it inherits dsh's environment without credential-shaped names
(scrubbedParentEnv).

What magpie writes now (refsOf fmtDsh -> refDsh, dshRef):
- a whole ${NAME}: !!js process.env.NAME (bare, as the README has it)
- text mixed with variables: the text as JSON string literals joined
  with (process.env.NAME ?? ""), YAML-quoted. The user's text is only
  ever inside a JSON string literal, so nothing in it runs as code, and
  NAME is the envRef pattern [A-Za-z_][A-Za-z0-9_]*. An unset variable
  inside a mixed value is "", not "undefined".
dshUnref reads exactly those forms back as ${NAME}, so an entry magpie
wrote reads as same and a sync changes nothing; any other !!js still
keeps the entry the user's (hasJS), as before.

The reporter asked for the value to be resolved into a literal when the
file is written. This writes a reference instead, keeping #1250's rule
that the token never lands in an agent's file as plain text. dsh
evaluates it from its own environment at load time. Their side note
that a quoted !!js isn't evaluated is not what dsh 0.2.0-rc.2 does: its
own --dump-config prints !!js quoted, and the quoted forms evaluated in
the run below.

Since AgentView.NoEnvRefs comes from refsOf, dsh's chip is no longer
greyed and the GUI notice drops it with no string change.
docs/subsystems/library.md says what dsh gets.

Run:
- real dsh 0.2.0-rc.2 `dsh headless` in a sandbox (HOME and DSH_HOME
  under a scratch dir, never ~/.dsh), on a cordis.patch.yml written by
  magpie's mcpFile: a stdio fixture got T1435_A=<token> (whole) and
  "Bearer <token>" (mixed); an injection string with quotes, backticks,
  ${, backslashes and "'); process.exit(3); ('" arrived verbatim; an
  unset variable in a mixed value gave "pre--post"; a streamable-http
  fixture received Authorization: Bearer <token>, the whole-variable
  header and the literal header. magpie read every entry back as same.
  A whole !!js process.env.UNSET makes dsh leave the server out as an
  invalid config.
- go test -tags nogui ./... (temp HOME, pinned caches): green.
- break by hand: refsOf without fmtDsh, encode writing raw values, and
  decode not unref'ing each make TestEnvRefsDsh fail (decode also
  TestDshImportKeepsJS).
- gofmt -l; go vet ./...; GOOS=linux and GOOS=windows go vet -tags nogui
  ./internal/library; go build for darwin, linux and windows (nogui).

Not run: dsh on Windows (the reporter's system), and a real LLM turn
calling a tool through the server (no dsh account in the sandbox; the
MCP servers had connected before dsh stopped for its missing LLM
credential). No GUI test was run, as no GUI file changed.
2026-10-10 00:43:26 +08:00
yetone a3a8e25792 plugins: a repository tagged magpie-plugin shows the icon its package.json gives (yetone: 非官方的插件能否支持 icon)
magpie.icon, an https URL or a data:image URI, is read with the rest of
package.json and kept through pluginOwnIcon as an installed plugin's is;
the card shows it, else the puzzle piece.

Ran: go test ./internal/plugin ./internal/gui ./internal/provider; the
plugin GUI tests in Chromium and WebKit (en/zh/ja/de, 980 and 440).
2026-10-10 00:42:37 +08:00
yetone 2c1b593675 library: Zed takes MCP servers, in its settings.json context_servers (lc on Discord)
lc on Discord ("agent未识别到zed"): the MCP server editor's agent chips
had no Zed. magpie already edits Zed's settings.json for its model, and
Zed reads its MCP servers from context_servers in that same file, so Zed
is now an MCP target like the other agents.

What Zed takes, from its own source (zed-industries/zed 2c99f547):
- crates/settings_content/src/project.rs ContextServerSettingsContent is
  untagged: a command as command/args/env (enabled, remote, timeout
  beside it), a url as url/headers (enabled, timeout, oauth), or an
  extension's entry as settings. docs/src/ai/mcp.md writes the same.
- crates/context_server/src/transport/http.rs only POSTs (and DELETEs):
  streamable HTTP, no SSE. So an SSE server is refused for Zed and its
  chip is greyed with the existing reason (AgentView.NoSSE).
- stdio_transport.rs hands command.envs over as written and http.rs sends
  headers as written: no ${NAME}. Zed is refNone, so a server naming a
  variable is refused and its chip greyed (AgentView.NoEnvRefs).

Changes:
- targets.go: "zed" gets mcpFile{agent.zed's settings.json, fmtZed}; the
  path is agent.zed's for every OS (~/.config/zed, $XDG_CONFIG_HOME/zed,
  %APPDATA%\Zed, $MAGPIE_ZED_CONFIG_DIR).
- mcp.go: fmtZed under "context_servers", encoded as Cursor/Desktop are;
  magpie owns url/headers/command/args/env only, so enabled, remote,
  timeout and oauth the user sets on magpie's entry stay. The file is
  JSONC and edited in place (edit.SetJSON/DelJSON), so comments, other
  settings and the user's own servers are kept.
- mcp.go, sync.go: an entry Zed's extensions configure (settings, no
  command or url) is never written over: a library server by that name
  is a Problem for Zed (errZedExtension), asking for another name.
- envref.go: a comment citing why Zed has no syntax.
- docs/subsystems/library.md: Zed's file, format, no SSE, no ${NAME},
  the extension guard, and the new tests.

Tests:
- internal/library/zed_mcp_test.go TestZedMCP, on a Zed-style JSONC
  settings.json with comments, trailing commas, a user stdio server
  (enabled:false, timeout), a user remote server and an extension's
  entry, at the default path and under MAGPIE_ZED_CONFIG_DIR: found
  servers, the page's chips, written entries, SSE/${NAME}/extension
  refusals, user keys kept through an edit, uncheck and remove leaving
  only the user's, comments kept, a Sync rewriting nothing.
- internal/gui/tests/library-zed-mcp.test.cjs: Zed's chip on every row
  and in the editor, a click gives it the server, greyed with the reason
  for SSE and ${NAME}; Chromium and WebKit, en/zh/zh-TW/ja/de, 1100 and
  560px, no sideways scroll. No new strings.

Broken by hand, each went red and was restored: no zed target, no
extension guard, SSE allowed, enabled owned by magpie, wrong key (Go);
Zed without noSSE (GUI).

Run (temp HOME, Go caches pinned): gofmt -l internal/ (clean); go vet
-tags nogui ./... on darwin, GOOS=linux and GOOS=windows; go vet
./internal/library ./internal/agent; go build ./..., GOOS=linux go build
-tags nogui ./..., GOOS=windows go build ./...; go test -tags nogui ./...
(all ok); node --test library-zed-mcp.test.cjs (22/22, both engines).

Not verified: a real Zed reading the written file (the live
~/.config/zed was never touched); Linux and Windows beyond vet/build;
Zed found in WSL (not in ownFolder, so no MCP target there); a
project's .zed/settings.json (not added).
2026-10-10 00:37:32 +08:00
yetone 189586612e agents, gateway: Codex's subagents go on a model set in its row, a ChatGPT account's (willz on Discord)
Codex's lead names each subagent's model itself in spawn_agent, and
Codex's [agents] config can't overrule it. The Agents page's Codex row
now has a "subagent model" square (the app's model picker, Default
first). Its options are only models a ChatGPT account in magpie serves,
because a subagent's task is sealed for ChatGPT accounts.

- settings.CodexSubagentModel holds it. It is an agent Field
  (subagent_model), so the TUI/CLI, profiles, davsync's "agents" part
  and WSL twins carry it too. The setter refuses another provider's
  model. It keeps a ChatGPT account's model that isn't served now, so a
  backup restored on another computer still applies (ApplyFields stops
  at the first failed Set). POST /api/settings keeps it.
- The gateway (codexSubagentPick) moves a collab_spawn request onto it.
  codexBackend does this for Codex's own bare models; serve() does it
  for any other model. When no ChatGPT account on in magpie serves it,
  or a held gateway key may not use it, the request stays on the model
  asked for. Route.Subagent records asked/to/kept. The lead's turns,
  titles, guardian and the rest are never moved. Off (the default)
  changes nothing.
- Routing page: kindWhy says the subagent was put on the model set, or
  stayed on its own and why. "only account on for" names the model it
  went on. Strings added in zh, zh-TW, ja and de.
- docs/subsystems: gateway-routing.md and agent-wiring.md describe it.

Run (darwin, rebased on origin/main):
- gofmt -l internal/: clean.
- go vet -tags nogui on gateway, provider, agent, gui and settings:
  darwin, GOOS=linux and GOOS=windows.
- go build ./...
- HOME=<temp> go test -tags nogui ./...: all ok.
  TestSettingsSaveKeepsWhatItDoesNotSend flagged the new field; it is
  now set there.
- GUI tests, Chromium + WebKit:
  - codex-subagent-model.test.cjs: 20/20 (en, zh, zh-TW, ja, de at
    1100 and 560 px).
  - Also green: routing-kind, subagent-effort, codex-memories,
    codex-memories-settings, i18n-keys, gui-ja, gui-de, gui-zh-tw and
    click-scroll.

Failing without the change (codexSubagentPick returning nil):
- TestCodexSubagentPutOnTheModelSet
- TestCodexSubagentOnAnotherProviderPutOnTheModelSet
- TestCodexSubagentModelNotServedKeepsTheAsked
- TestCodexSubagentModelHeldKeyKeepsTheAsked
TestCodexSubagentModelLeavesOtherRequests guards the other side: the
lead's turns, titles and guardian stay untouched.

Not tried with a real Codex CLI and ChatGPT account. A subagent spawn
needs a signed-in Codex home, and this session may not touch the live
~/.codex. The fake ChatGPT backend tests cover both routes.
2026-10-09 23:38:50 +08:00
YAYOI27andClaude aa82288fb6 usage: Volcengine Ark's Coding Plan and Agent Plan windows, read with the account's access key (#1427, yayoinoyume)
Ark tells a Coding Plan's or Agent Plan's 5-hour, weekly and monthly
windows only to the account's control plane (GetCodingPlanUsage,
GetAFPUsage on open.volcengineapi.com), signed with the account's
AccessKey ID and Secret, never to the plan's inference key. An Ark
provider's editor now takes that access key, and the Usage page shows the
plan's windows as a card.

From yayoinoyume's feat/volcengine-usage, reviewed and reworked:
- Signing is volc-sdk-golang's (base/sign.go GetSignRequest, 06f9ef66):
  signed headers sorted (content-type;host;x-content-sha256;x-date), only
  Action and Version in the query. The branch signed them unsorted and put
  Region in the query. TestVolcSignMatchesSDK pins two signatures the SDK
  made.
- The card is in PlanQuotas beside the Step Plan's (its cache, last
  reading, refresh and `magpie quota wait`), not in the subscription
  cards. One per access key and plan.
- The plan is the endpoint's: /api/coding asks GetCodingPlanUsage,
  /api/plan GetAFPUsage, pay-as-you-go none, in the host's region. The
  branch asked both and showed whichever answered.
- Dropped guessed reply fields; the Coding Plan reply is the one
  pi-ark-usage, dsh-ark-quota and ArkCodingPlanUsage read. AFP buckets are
  read in the forms dsh-ark-quota and arkcli read (Used of Quota or Total,
  or Percent), plus Tier; none was seen from a real account.
- An Ark provider is known by its host, not the preset id, so a copy or a
  custom provider takes the key too.
- The Secret is never sent to the page (accessKey {id, secretSet}); a
  blank Secret keeps it, a save without the ID keeps both, Remove
  (clearAccessKey) drops both, an ID with no Secret is refused.
- The pair goes where keys go: AddCopy, Restore, Mirror, backups and
  davsync carry it only with keys (withoutKeys), and a keyless restore or
  sync keeps this machine's. CLI: access.key, access.secret.
- A changed access key drops the cached cards (ForgetPlanQuotas).
- UI strings in zh, zh-TW, ja and de.

Nothing was tried against the real Volcengine API: there was no access
key. A local fake checked the real request PlanQuotas makes against
volc-sdk-golang's own GetSignRequest (both actions, two regions); it
lives outside the repo, so go.mod is unchanged.

Run: gofmt -l; go vet (darwin), GOOS=linux and GOOS=windows go vet
-tags nogui on provider, gui, backup, davsync and the root; go test
-tags nogui ./internal/provider ./internal/gui ./internal/backup
./internal/davsync . under a temp HOME; node --test
volc-access-key, zhipu-team, gui-ja, gui-de, i18n-keys in Chromium and
WebKit. Fail without the change: TestVolcSignMatchesSDK (unsorted
headers), TestAddCopy, TestBackupBalanceToken, TestNoKeys,
TestTakeBalanceToken, TestSyncBalanceToken (pair not carried),
TestProviderSaveKeepsVolcengineSecret (blank Secret dropped),
volc-access-key (Remove not posted).

Co-authored-by: YAYOI27 <111715843+yayoinoyume@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 23:25:04 +08:00
yetone c3e673b061 codex: the account Codex is signed in to goes by the name it is saved under, so two Team seats of one email are weighed apart (#1424, lingqiqi5211)
One email in two Team workspaces is saved as "me@example.com · Team" and
"me@example.com · Team · <workspace>" (codexName). codexAccount still named
the account Codex is on by codexUser alone, "me@example.com · Team". With
Codex signed in to the second seat, its candidate used the second seat's
credentials under the first seat's name. Everything keyed on Account.User
then read the first seat's records: Smart's allowance (98% used, so the
seat with 2% left was ranked last), rests and sticks (restKey/who), the
usage cap, per-account proxy and model list, a gateway key's account list
(LoginID), OwnPaused, and the Routing page's name.

- codexLiveName (logins.go): codexName over the saved logins, shared by
  liveLogin and codexAccount, so the provider's Account.User is the saved
  name.
- SessionIdentities: a saved Codex identity goes by its saved name, and the
  one from auth.json by that of the saved one of its workspace and member,
  so a session of the second seat is told as that seat's.
- providers-accounts.md: the Codex naming rule.

Stored records: nothing keyed under a name that goes away. The first seat
keeps "email · Team"; the second seat's rests, caps, proxy, model list and
usage are already under its saved name, written while it wasn't the one in
use. Records written under the first seat's name while the second was in
use can't be told apart now. Rests run out and readings are read again, so
none is migrated.

Tests: TestCodexTeamSeatsOfOneEmailWeighedApart (gateway): Team A 98%,
Team B (Codex on it) 2% with its week renewing first, another email 20%.
On the old code it fails with "sent to ws-other; want the seat Codex is on"
(and, with its name check, "goes by \"me@example.com · Team\""). With
session_identity.go reverted, TestCodexWorkspaceSessionIdentities (provider)
fails with "the seat Codex is on: [me@example.com · Team]".

Run: gofmt -l (clean); go vet, GOOS=linux and GOOS=windows go vet -tags
nogui on provider, gateway, usage; go build -tags nogui ./... on darwin,
linux, windows; go test -tags nogui ./internal/provider ./internal/gateway
./internal/usage under a temp HOME with pinned caches: all ok. Not tried
with real accounts: no two Team seats of one email are available here.
2026-10-09 23:16:53 +08:00
yetone acdaa28aa1 codex: a conversation that searched the web declares cached search to the ChatGPT backend, so its compaction and turns aren't refused "response protection is unavailable" (#1270, #1099)
The ChatGPT backend answers HTTP 200 and only `event: error` "response
protection is unavailable" to any request whose input replays one of its
own web_search_call items while the request doesn't declare the web_search
tool. Probed on a real ChatGPT Pro account (gpt-6.1-sol, 2026-10-09) with
a real searched turn: refused with tools [], with function tools only and
with a compaction_trigger; answered once {"type":"web_search",
"external_web_access":false} is declared. A Responses Lite request refuses
a top-level web_search (400) but takes it in an additional_tools item. So
xjdata's case (Codex compacting a custom provider's conversation itself, a
plain /responses with tools [] that magpie's codexCompact never sees) failed
every time once the session had searched, and congee949's thread
descriptions too.

What changed:
- provider.DeclareSearch / declareSearch: when the input holds a
  web_search_call and nothing declares search, add cached-only web_search to
  the tools, or for Lite (header, reasoning.context all_turns, or an
  additional_tools item present) to the additional_tools item (prepended
  when there is none). With no tools offered, tool_choice becomes "none" so
  the model can't search; a forced choice is kept. Otherwise the body is
  unchanged (bytes untouched when no web_search_call is in it).
- codexBody (every ChatGPT account route: serveAgent, codexCompact, warmup,
  magpie's codex/* models) calls it; codexUpstreamOn calls it for Codex's
  own sign-in relayed to the backend's /responses.
- Not changed: siwc, API-key passthrough to api.openai.com, the legacy
  /responses/compact endpoint (no evidence they are refused so; not probed).
- docs/subsystems/gateway-routing.md names the cause and the fix.

Tests: internal/gateway/search_history_test.go replays the backend's real
items from that probe against a fake that refuses as the backend did.
TestSearchedConversationCompactsOnAnAccount, ...FunctionTools and
...OnCodexSignIn fail on the old code with "event: error"; ...CompactionTrigger
fails with "asked 2 times, not once". TestUnsearchedConversationUnchanged
and provider TestDeclareSearch cover what's left alone.

Run: gofmt -l (clean); go vet, GOOS=linux go vet -tags nogui, GOOS=windows
go vet -tags nogui on internal/provider and internal/gateway; HOME=<temp>
go test -tags nogui ./internal/provider ./internal/gateway (ok). Real
account, sandbox magpie on 127.0.0.1:3491 under a scratch HOME, replaying
the real searched history: old binary -> /v1/responses codex/gpt-6.1-sol
text compaction with tools [] and /backend-api/codex/responses (standard
and Lite compaction_trigger) all "response protection is unavailable";
new binary -> all response.completed, Lite with a compaction item.
Codex CLI 0.161.0 end to end was not done: on a custom provider it
offered no web_search tool, so no search could get into its history.
2026-10-09 22:40:53 +08:00
yetone 69c21cdd77 gateway, settings, gui: a reply whose reasoning or text has become a loop of the same few lines is ended with an error the agent can act on, instead of running on to its output limit (#1359, Moody-Sin: WorkBuddy's DeepSeek v4.1 flash at max effort reasoned "Now." "Writing." "Go." "Let me write." "OK." "Producing." … until max tokens, while dsh waited and Pi hung)
holdWriter.Write, which every streamed try goes through (passed through or translated; a key's, a built-in subscription's, or a plugin's fetch, as a moved WorkBuddy's), feeds a loopGuard (loop_guard.go). It reads the reasoning and the text of each protocol's deltas (Anthropic, Chat, Responses, Gemini) and cuts each into units: lines and sentences, compared without case or a closing stop. A list's "1." or a decimal doesn't end a sentence. A unit with no word in it (braces, rules, 0x00, hashes, rows of numbers) isn't counted. When the last 256 counted units of either are among 24 or fewer distinct ones, the try is cut. What came with that write goes out, then the protocol's stream error (Anthropic error event, Chat error chunk, Responses response.failed server_error, Gemini 502 UNAVAILABLE), naming the loop and quoting its units, and the vendor's request is cancelled. The cycle needn't be exact; the reporter's has 9 units in two variants. It falls back to nobody and rests nobody (fail "loop"), and the usage record's err_type is reply_loop. A reply sent whole (no stream, or Gemini without alt=sse) isn't read. Settings › Models › Replies › Stop looping replies (noLoopGuard, on by default) turns it off; new strings in zh, zh-TW, ja and de.

Tested:
- loop_guard_test.go, with the screenshot's 28 lines cut into random 1–7 byte pieces, as one line, and in Chinese. It trips within the window. It reads every protocol's shapes, and tool-call arguments never trip it.
- No trip on magpie's own sources fed as both reasoning and text: gateway.go, fallback.go, anthropic.go, app.js, i18n.js, app.css, routing.js, sessions.js, index.html, docs, LESSONS.md and go.sum. Nor on generated numbered lists, logs, CSV, hex, rules and base64.
- Through the gateway on all 4 protocols with a fake DeepSeek, and on WorkBuddy both built-in and through the community plugin's fetch in the plugin host (TestLoopEndsWorkBuddysReply). In each case the agent gets the reasoning and then the error, the vendor is let go within 2000 lines, nobody rests, and usage keeps reply_loop.
- gateway.go as 4,900 lines of reasoning passes whole on every protocol. With the setting off, a loop runs to its end.
- With the guard removed, the end-to-end and WorkBuddy tests fail on every route.
- The built binary (magpie serve on 127.0.0.1:3592, temp HOME, fake DeepSeek upstream), checked with curl on chat, messages, responses and gemini alt=sse. Each loop ended with that protocol's error, the upstream was let go after 256–271 lines, and the usage log had err_type reply_loop and routing fail "loop". The long gateway.go reasoning came through to [DONE], message_stop, response.completed and STOP. With noLoopGuard, all 100,000 lines passed.
- GUI: loop-guard-setting.test.cjs in Chromium and WebKit, en/zh/ja/de, at 1100 and 420 wide. gui-ja, gui-de, gui-zh-tw, i18n-keys, member-model-setting, click-scroll and settings-sections/groups pass.
- go vet, GOOS=linux/windows vet and builds, go test -race ./internal/gateway ./internal/settings, and the full go test -tags nogui ./... all pass.

Not tried with the real WorkBuddy or DeepSeek model.
2026-10-09 22:32:31 +08:00
yetone ab3fe0fe41 gateway: a Claude subscription turn whose saved session Claude Code can't load goes on in a new Claude Code instead of 502 "Claude Code ended", and a Claude Code that ends early says why (FrierenF on Discord)
What: a conversation let go past idleMost is resumed from its saved
session (unshelve, `claude --resume <sid>`). Claude Code 2.1.295 that
can't load the session prints "No conversation found with session ID"
and exits 1 before it reads anything, without answering a control
request (tried with the real binary in a scratch CLAUDE_CONFIG_DIR: a
missing and an empty session file both do this; a loadable one answers
initialize). With the safety context set (Claude Code's auto mode),
start's initialize got its reply channel closed and the turn was
answered 502 "Claude Code: Claude Code ended", with no reason; without
it, the turn got an empty 200.

Now:
- start sends initialize to a run resumed from a saved session too, as
  the probe that Claude Code loaded it; when that run fails, the session
  is let go and a new Claude Code is told the whole conversation in the
  same request (start / startRun).
- cliControl reports a run that ended before answering with its exit
  status and stderr tail (endedBefore, shared with whyEnded), and its
  timeout no longer says "safety context" for every request.
- The other paths that can meet an ended run already start a new one:
  resume (setSafeguards / tell failing -> abort, fall through) and the
  tool-results path (claimResume / continueWith -> runExpired).
- The resume harness's fake Claude Code now acknowledges control
  requests as Claude Code does.

Not known: why FrierenF's saved session couldn't be loaded, or whether
their run ended for another reason; their next failure will now name it.

Run:
- TestClaudeUnloadableSessionStartsAnew (both with and without
  safeguards) and TestClaudeEndedRunSaysWhy fail on origin/main:
  "502 Claude Code ended"; no run started anew (empty 200);
  "Claude Code: Claude Code ended" lacks exit status 3 and stderr.
  They pass with the change, -race -count=10.
- go test -tags nogui ./internal/gateway/ under a temp HOME, pinned
  caches: ok (174s).
- gofmt -l clean; go vet (darwin), GOOS=linux go vet -tags nogui,
  GOOS=windows go vet (with and without nogui) for internal/gateway;
  go build, GOOS=linux go build -tags nogui, GOOS=windows go build: ok.
- Not run: a real signed-in Claude account (no credentials copied), the
  Windows box, the rest of the repo's packages.
2026-10-09 22:29:41 +08:00
yetone 3691c2047d plugin, agent: bun is given a SOCKS proxy through the bridge, so a plugin installs with only a SOCKS system proxy (#1409)
With only a SOCKS system proxy on macOS (127.0.0.1:4401), every plugin
install failed: netproxy.Env put socks5://… in bun add's *_PROXY, and Bun
takes only http:// and https:// proxies, so it stopped with
"UnsupportedProxyProtocol downloading package manifest". The plugin
host's own fetches already went through netproxy.Bridge (hostEnv), but
bunCommand, which runs bun add, update and remove, did not.

- netproxy.EnvForBun rewrites a SOCKS5 HTTPS_PROXY/HTTP_PROXY/ALL_PROXY
  (any case) to the loopback HTTP bridge in front of it; others as they are.
- bunCommand uses it, so add, update, remove and the host's run all get it.
- Sibling: an agent CLI installed with bun is updated by `bun add -g`
  with the same env (updateEnv in cliupdate.go); npm is left as it is.
- provider-plugins.md says so.

Run:
- TestInstallThroughSOCKSProxy (real bun 1.4.2, fake registry, SOCKS
  proxy only): on the old bunCommand it fails with the reporter's exact
  "error: UnsupportedProxyProtocol downloading package manifest"; passes
  with the fix, through the SOCKS proxy.
- TestEnvForBunBridgesSystemSOCKS (system SOCKS proxy via Env) and
  TestUpdateWithBunBridgesSOCKS fail with EnvForBun / the bun branch
  broken ("bun is given …socks5://…"), pass with it.
- Real use: a throwaway test with a real SOCKS5 server and only
  *_PROXY=socks5://… installed @magpie-community/opencode-cursor-auth
  0.2.2 from registry.npmjs.org through it; on the old code the same
  failed as UnsupportedProxyProtocol. Scratch HOME, not committed.
- gofmt -l clean; go vet ./...; GOOS=linux go vet -tags nogui ./...;
  GOOS=windows go vet ./...; go build; GOOS=linux go build -tags nogui;
  GOOS=windows go build; go test -tags nogui ./... (scratch HOME) all ok.
2026-10-09 19:35:35 +08:00
yetone 0b5b10da2a agent, gateway, gui, tui: a Codex that goes through magpie only to fail over to the user's other ChatGPT accounts says so on the Agents row and keeps to its own models (#1385, shenghsi)
With several ChatGPT accounts on, failover writes openai_base_url into
~/.codex/config.toml so a turn the signed-in account can't pay for goes on
to the next account. With Codex's switch off, the row said only "not
connected", but every request still went through the gateway, and the
gateway's /backend-api/codex/models answer added all of magpie's models
(62 for the reporter).

- agent: Agent.FailingOver means not wired, while the stash's codex.failover
  (or a CC Switch mirror's codex.mirror_failover) base URL points at magpie.
  FailoverSaid is the line the CLI list and the TUI show. CodexOwnOnly is
  true for this machine's Codex when it is failing over and no WSL Codex is
  connected, since a WSL Codex shares the gateway path. Sync ages a
  models_cache.json that isn't tagged with the own-only list.
- gateway: codexModels leaves out provider.CodexListed when
  gateway.CodexOwnOnly (set by main to agent.CodexOwnOnly) says so. The
  ETag and X-Models-Etag carry provider.CodexOwnListTag. Neither tag
  contains the other, so Codex fetches the list again when it is connected
  or disconnected.
- gui: /api/state carries failover. The row says "Not connected · goes
  through magpie only to fail over to your other ChatGPT accounts". Its
  title says what turns it off. A "ChatGPT accounts" button opens
  Providers › Codex. Strings are in zh, zh-TW, ja and de. openProvider no
  longer draws the Providers list before it has loaded. It was only safe
  once the tab had been opened, and this is its first caller from another
  page.
- docs: agent-wiring and gateway-routing describe the failover-only Codex
  and list the new tests.

A user's hand-written openai_base_url (#856) isn't failover's, so it stays a
plain not-connected row.

Verified:
- TestCodexFailoverOnlyKeepsOwnModels (agent) builds the reporter's state in
  a temp HOME: Codex signed in, two more ChatGPT logins on, Sync, so the
  stash has codex.failover=1 and no joined/beside. It checks that the row is
  not wired and is FailingOver, that FailoverSaid names Providers › Codex,
  and that the real gateway handler's /backend-api/codex/models lists only
  the backend's gpt-5.5. It also checks that the cached full list is aged,
  and that after Join magpie's fake/m1 is back.
- TestCodexModelListOwnOnlyForFailover (gateway) checks the list and both
  ETags with the hook on and off.
- agent-failover.test.cjs checks the row text, title and button in en, zh,
  zh-TW, ja and de, at 980px and 420px, in Chromium and WebKit (20 pass).
  It also checks that the button opens Codex's editor, that the page
  doesn't scroll sideways, and that no click moves it.
- Broken by hand, each test goes red:
  - codexModels ignoring the hook fails both Go tests.
  - failingOver returning false fails the agent test.
  - dropping the Sync cache case fails the agent test.
  - removing the GUI branch fails the GUI test.
  - drawing the list before it has loaded (the old openProvider) fails the
    GUI test.
- go test -tags nogui ./internal/agent ./internal/gateway ./internal/provider
  ./internal/gui ./internal/tui ./internal/codexcat . in a temp HOME: all ok.
- node --test agent-*.test.cjs (254 pass) and i18n-keys, gui-ja, gui-de,
  click-scroll: all pass.
- gofmt -l is clean.
- go vet -tags nogui on darwin, linux and windows: clean.
- go build, and GOOS=windows go build: ok.
2026-10-09 14:10:13 +08:00
yetone 1bc0ed404a minimax-code: magpie's models have one slash in the whole ref, so SubAgents take them (#1387, worksyang)
MiniMax Code 3.1.0 checks a SubAgent's model against /^[^/\s]+\/[^/\s]+$/
(uQn in @minimax-ai/code's chunk-2XPMNZVR.js), and magpie wrote its models
under custom_provider.magpie by catalog id, so
custom_provider:magpie/bb-codex/gpt-6.1-sol was refused
(AGENT_CONFIG_INVALID).

- The writer keys each model by its catalog id with "~" for each slash
  (bb-codex~gpt-6.1-sol, provider.FlatID), when exactly one served id
  spells that way (provider.Flattened); else it keeps the slashed id.
- The gateway takes such an id back to that one provider's model or group
  in validateModel and unprefixed (unflat / provider.Unflat): only an id
  with "~", no slash, that resolves as nothing else; two ids spelled alike
  are neither. Never a bare-name first hit. Key limits, middleware and
  usage see the catalog id, so a key held to bb-codex/* is refused
  other~gpt-6.1-sol on every route, count_tokens too.
- A config an older magpie wrote moves on the next sync: the user's keys on
  a model go to its new key, defaultModel, defaultLightModel and
  memory.captureModel/consolidationModel follow, and the slashed key stays
  with enabled: false, so a session begun on it still resumes (checked
  with mcode exec --continue). Get still reads magpie/<catalog id>, and
  Disconnect still puts back the stashed default and variant.

Other agents: no installed agent bundle here (cline, command-code,
qwen-code, deepseek, pi, omp, codebuff, openclaw) has this regex; native
agents were not checked.

Verified:
- go test -tags nogui ./internal/agent ./internal/gateway ./internal/provider
  (temp HOME), gofmt -l, go vet darwin/linux/windows.
- New TestMiniMaxCodeOneSlash uses the reporter's regex and ids;
  TestFlatModelIDsReachTheirProvider has two providers serving
  gpt-6.1-sol. Breaking unflat, mcodeKey, the key carry-over, the legacy
  enabled:false and the ref rewrite each turned the tests red.
- Real run: built magpie serve in a temp HOME on 47812, two fake upstreams;
  `magpie mcode magpie/other/gpt-6.1-sol` wrote other~gpt-6.1-sol; real
  mcode 0.6.5 exec reached other's upstream with gpt-6.1-sol (usage:
  other/gpt-6.1-sol); its agent loader accepted an agent.md with
  custom_provider:magpie/other~gpt-6.1-sol and refused the two-slash one;
  `magpie mcode default` put minimax/MiniMax-M2.7 back.
2026-10-09 14:08:07 +08:00
yetone 71ee5e3519 gateway: a Claude subscription conversation whose client took its images out goes on in its run (#1382, Songxiaor)
Hana's pre-step hook replaces each image before the last assistant message
with "[图片已省略:历史图片保留为文件引用,避免重复发送原始 base64]" in the
tool result or user message that held it. Over pi-ai's openai-completions
the user message of the results' images ("Attached image(s) from tool
result:" and the images) is gone and each result's text has the line added
to its end. The step after one that read an image missed its run both
ways: tool results failed follows() (the hash of what the run heard), and a
new turn failed turnKey and looseTurn.rewrote (message count changed). A
new claude -p was told the whole conversation: 10 of 67 requests, 79% of
the session's cache writes.

lostMedia (claude_lost_media.go) tells this rewrite by its shape, not by
Hana's string: comparing the conversation a run had (each part kept as the
hash and length of its words, heardRuns) with the request's, a role's run
of messages at a time, it holds when at least one image or file was taken
out and nothing else changed: each image/file is still there, gone, or has
text in its place; a tool result may have text added to its end only when
it carried images or the images after the results went; the caption
before those images goes with them only when a result before it grew.
follows() checks it when the hash misses (toldRuns), and looseTurn.rewrote
when the #912 cut rule doesn't match; the looseTurn key drops the message
count, which the per-turn comparison covers. messageWords is now built from
messageParts, the same words, so the hash and the comparison can't drift.
The shape over the string: any client that strips media the same way is
covered, and a compaction or edit (#459) changes words lostMedia never
accepts. Not covered: a tool result of an image alone, whose pi-ai text
"(see attached image)" is replaced rather than added to.

claude_lost_media_test.go builds its fixtures from pi-ai's literal Chat
Completions bodies (read from the installed HanaAgent server seed, 1.0.0-beta: pi-ai
openai-completions.js and Hana's media-strip hook): a table of 18 shapes
for lostMedia, an end-to-end new turn through serveClaudeSubscription with
the script Claude Code (a tool's image, a user's image), and tool results
after the image step through serveSubscription (goes on; a changed user
message or a changed result still starts anew). The two end-to-end tests
fail with lostMedia unhooked from follows/rewrote; each guard in
lostFromRun was broken by hand and turned a table case red.
docs/subsystems/claude-subscription-bridge.md has the rule.

Run: gofmt -l; go vet -tags nogui ./internal/gateway/ (darwin, GOOS=linux,
GOOS=windows); go test -tags nogui ./internal/gateway/ -count=1 under a
temp HOME (ok); -race -count=5 on the lost-media, rewritten, #459, letGo,
given-up and fork tests (ok). Not tried against a real Hana or a sandbox
magpie with a real claude: the harness's script stands in for Claude Code.
2026-10-09 13:45:42 +08:00
yetone 3cd0b5f78d gui: lightweight mode no longer lets go of a main window that is open under another app's, so Command-Tab back to magpie finds it there (#1381, ttmouse)
Cause: lightweight mode (#580) let go of the main window once two of its
30 s looks found it "not shown", and it asked Wails' IsVisible, which on
the Mac is the window's occlusion state. A window the user had
Command-Tabbed away from, covered by the other app's window, counted as
closed: 30-60 s later its webview was dropped and the window closed, and
Command-Tab back to magpie found a blank shell or no window at all. Hidden
with magpie (Command-H) it went the same way, since hiding orders every
window out.

Change: windowUp says whether a window is up as the user sees it:
ordered in on any Space, covered or not, minimised, or open as magpie was
hidden (watchHide records those on NSApplicationWillHideNotification).
Only a window magpie closed itself is down. lighten (through lightShown)
and mainShown use it, so the Dock-while-open setting and a restart to
update also keep a covered window as open. Elsewhere windowUp is
IsVisible as before (visible there covers covered and minimised).

Run:
- Reproduced with a sandbox build of origin/main (own HOME and
  CFFIXED_USER_HOME, port 3481, dock+lightweight on): with the open
  window covered by a full-screen window of a helper process for 70 s the
  log said "the main window's webview let go" and the window was a blank
  white shell. With this change, the same run let go only the closed
  panel and the window kept its page (screenshot of the window).
- Activation itself was checked by handing activation from the helper to
  magpie (NSRunningApplication activateFromApplication): with the window
  there it came forward. A real Command-Tab keystroke was not sent.
- New TestCoveredWindowStaysUp (AppKit, own process): open, covered by
  another window (until IsVisible says false), hidden with the app,
  minimised -> up; closed by magpie, also with the app hidden -> down.
  With windowUp put back to IsVisible it fails: "open: the window counts
  as up=false, want true"; without the hidden clause: "hidden with
  magpie: the window counts as up=false, want true". Passes -count=10.
- gofmt -l; go vet ./internal/gui; GOOS=linux go vet -tags nogui and
  GOOS=windows go vet (with and without nogui) ./internal/gui; go build,
  GOOS=linux go build -tags nogui, GOOS=windows go build;
  go test ./internal/gui and go test -tags nogui ./internal/gui under a
  temp HOME: ok.
2026-10-09 12:59:29 +08:00
yetone 2018882e47 davsync, gui: a server file that isn't a backup is said as what it is, and can be replaced by this computer's setup (JasonLeeForOnly on Discord)
JasonLeeForOnly's WebDAV sync failed with only "not a magpie backup" for
several versions, and nothing on the page got past it. A WebDAV GET of the
backup can come back as a body that isn't the file in several ways; each is
now reproduced with an httptest server that answers the way the real one
does:

Unpacked (WebDAV and S3): gzip with or without Content-Encoding (a proxy
that drops the header), x-gzip, zstd with or without the header, deflate
(zlib or raw), and a header that says gzip over a plain body. Bodies are
told by magic number; a backup is JSON, so either magic is never the file.
Brotli (br) can't be unpacked and is said as itself, with what to turn off.

Said, with no upload offered: an HTML page answered with 200 (Nextcloud
login after a redirect, Cloudflare Access, an AList/OpenList web app, an
rclone directory listing, a captive portal), with its <title> and the
host/path a redirect ended at (query, fragment and user dropped), and
where the WebDAV address is for 坚果云 and Nextcloud; an XML answer, by its
root element.

Said, with Upload as the way out: an empty (0-byte) file, a backup cut
short or damaged (the automatic rebuild is unchanged and still goes first
when its rules allow), another app's file ("format": "…"), other JSON,
and any other file by its content type. Messages carry the size, kind and a
page title only, never the file's contents.

backup.Open now tells ErrNotBackup (doesn't begin like a backup) from
ErrCorrupt (begins like one, cut short). The status carries the kind as
serverFile; Settings › Sync and backup says it in the reader's language,
wraps it whole, and shows "Upload this computer's setup" only when the file
may be replaced. davsync.Upload (POST /api/davsync/upload, `magpie webdav
upload`, `magpie s3 upload`) asks first in the GUI, re-reads the file,
refuses a backup this computer opens, one sealed with another passphrase,
a newer magpie's, a page and an XML answer, keeps the server's copy in the
sync folder (0600, newest 5) and writes nothing if that fails, then writes
with If-Match on the version read. New strings in en, zh, zh-TW, ja, de.

Verified: internal/davsync/not_backup_test.go (TestReadCompressedBackup,
TestReadBrotliSaid, TestServerAnswerSaid, TestCutBackupSaidAndUploaded,
TestS3ReadCompressedObject; Upload-only parts stripped to compile) fails
on e4a90aa6 for every compressed case but "gzip said", Brotli, every
server-answer case, the cut case and S3 gzip, and passes here. Dropping
the Replace check in Upload turns the page/XML cases red ("Upload wrote
over a page"). gofmt -l clean; go vet darwin, GOOS=linux and GOOS=windows
-tags nogui on ., davsync, backup, gui; full go test -tags nogui ./... ok;
-race on davsync and backup ok. GUI: sync-not-backup.test.cjs (en, zh,
zh-TW, ja, de; 900 and 440px), sync-*, s3-sync, gui-de, gui-ja, gui-zh-tw,
click-scroll in Chromium and WebKit, 81 pass. Not tried against a real
WebDAV server or on the Windows box; the reporter's server is unknown.
2026-10-09 12:10:04 +08:00
yetone 34a598372c providers, gateway, gui, cli: each usage window of an account can have a cap of its own beside the account's (willz on Discord)
willz asked for a cap per window: a friend's account should stop at 50%
of its five hours, while its week has its own 40%. Until now an account
had one usage cap, the same share of every window.

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

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

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

Docs: gateway-routing.md (HoldCaps, window caps, tests),
providers-accounts.md (AccountWindowCaps, tests), reference.md.
2026-10-09 12:00:26 +08:00
yetone 3c7473eb3f redact: a value masked once is masked again wherever a request has it, whatever words are around it
The masking rules find a value by what is around it: DB_PASSWORD= before
a password, a bound an email may not touch (so an email that ends a
sentence isn't one). The vendor writes about a placeholder in words of
its own ("your password is {{SECRET_…}}", "I will write to {{EMAIL_…}}."),
the agent reads the value there (Restore), and on the next turn that text
is in the history, where no rule matches it, so it went out as written.

Now every value a placeholder was made for (redact's in-memory values,
bounded by maxValues, never written anywhere) is also matched as an exact
string in each string Mask and MaskJSON look at, and goes as the same
placeholder it had (same kind, same keyed hash), so history and vendor
caches stay stable:

- only from 8 characters (the assignment rule's minimum): the 3-character
  passwords the user:password@ rule takes, and the user's own words
  (which Options.Words matches everywhere already), are not;
- personal values only while personal masking is on, secrets only while
  secret masking is on;
- not as a piece of a longer value: a phone or card number with a digit
  beside it, an email inside a longer address (a full stop after it may
  end the sentence), a secret with a letter or digit against it;
- where a rule finds the same span, the rule's kind still wins, so
  nothing masked before changes placeholder;
- in what a vendor signed (7137feb4), the values masked elsewhere in the
  request go back as their placeholders as before, and so do known
  secrets, for a turn whose other copies were compacted away; known
  personal values don't, since a number the vendor wrote of its own
  (13800138000) may be one masked in another conversation, and masking
  it would break the signature.

A prefix index and a 4-byte bitset keep the lookup cheap: a ~1 MB body
masked in ~79 ms with 0, 1k or 200k known values alike (rules dominate).
Every route goes through redact.MaskJSON / Mask (chat, Responses,
Anthropic, Gemini, count_tokens, embeddings/rerank, the Codex backend,
image and video prompts), so each gets it; Scrub, the archive's, gets the
known secrets too.

Tests: TestEchoedValueMaskedNextTurn (gateway: two chat turns where the
fake vendor echoes the placeholders in other words; the second request
must not hold the password or the email, and keeps the first turn's
placeholders; then the echoed text alone through /v1/responses,
/v1/messages, count_tokens, Gemini generateContent, embeddings and the
Codex backend) fails on origin/main with the second turn sending
"…password is Mv4kestrelPass28, and I will write to
tomas.ng@quietfield.dev.". TestKnownValueMaskedInOtherWords and
TestKnownInSigned fail there too; TestShortValueNotMaskedEverywhere and
TestKnownReindexed pin the bounds and the eviction rebuild.

Run: gofmt -l (nothing); go vet -tags nogui ./... on darwin,
GOOS=linux and GOOS=windows; go build -tags nogui for darwin, linux,
windows; go test -tags nogui ./... under a temp HOME with pinned caches
(all ok); internal/redact -count=3 and -race. Not tried against a real
vendor.
2026-10-09 11:56:10 +08:00
yetone ee657c0969 usage: a Kimi Code membership's k3 is counted at Kimi's API price for kimi-k3, not ≈¥0.000, and kimi-for-coding is unpriced rather than free (#1370, maicent: Kimi Code 的模型计费都是 0)
Why it was 0: magpie already counts a subscription's calls at the API list
price (a Claude or Codex account at its maker's, cf6b82f7). A Kimi Code
provider is looked up in its own models.dev catalog first, and models.dev
lists every model of kimi-code-plan-global / kimi-code-plan-cn at $0 (what
the membership charges per token), so k3, k3-256k, kimi-for-coding and
kimi-for-coding-highspeed were all "priced" at $0. The same $0 catalogs
were also among the maker catalogs, so MakerPrice("k3") was $0 too.

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

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

Run: gofmt -l (empty); go vet -tags nogui, GOOS=linux go vet -tags nogui,
GOOS=windows go vet -tags nogui on internal/provider, internal/usage,
internal/gui; go build, GOOS=linux go build -tags nogui, GOOS=windows go
build; go test -tags nogui -count=1 ./... under a temp HOME with pinned
caches: all ok; -race -count=3 on the new tests ok. No GUI asset changed,
so the Playwright suite wasn't run. Not tried with a real Kimi Code
account or the reporter's usage log.
2026-10-09 11:48:41 +08:00
yetone e4a90aa62f groups, routing: a found group the user changed drops a removed provider's members, and a switched-off provider's members are said as switched off (Discord)
A user on Discord: in magpie's found (auto-…) routing groups, a provider
removed or switched off stayed in the group and had to be taken out by
hand; one switched on again should be back by itself.

A found group magpie derives already followed its providers (built from
the providers that are on). The gap was a found group the user had changed
(any edit on its card stores it under its auto-… id with its members):
its members stayed as they were.

Now:
- provider: groupsIn reads a stored auto-… group without the members of
  providers magpie no longer has (withoutRemoved). This is read-time only,
  never written back, so a provider back under its id (an account shown
  again) is back in the group. A manual group's pick and a model a rule
  sends to are kept, since taking them out would change where requests go
  without asking, or stop the group from saving. If no member would be
  left, all of them stay. The user's own groups (other ids) keep their
  members as they were.
- A switched-off provider's members stay in any group. The gateway
  already skips them (membersIn), and they are routed to again once the
  provider is on.
- gui: groupsState marks such a member providerOff, with its provider's
  name, icon and model. The Routing page says it as "<provider> · <model>
  (switched off)" on the card. A manual group's pick and the editor's row
  say why: "{name} is switched off: the group skips it until it is on
  again" (new string in zh, zh-TW, ja, de). Before, it was a bare id.

Run:
- go test ./internal/provider TestFoundGroupFollowsItsProviders. It fails
  on the old group.go: "c removed, auto-x's members: [a/x b/x c/x]".
- go test ./internal/gui TestGroupMemberOfASwitchedOffProvider.
- New internal/gui/tests/routing-provider-off.test.cjs in Chromium and
  WebKit, en/zh/zh-TW/ja/de, at 1100 and 420: 10/10 each. On the previous
  assets it fails: the card said "B Cloud · sol" with no "(switched off)".
- The full ./internal/provider and ./internal/gui go test suites, in a
  sandbox HOME: ok.
- ./internal/gateway -run 'TestGroup|TestAuto|TestRouting': ok.
- gofmt -l clean. go vet -tags nogui on darwin, linux and windows: ok.
- All routing-*, panel-routing, click-scroll, gui-ja, gui-de, gui-zh-tw
  and i18n-keys GUI tests: 265/265 in Chromium and 265/265 in WebKit.

Not tried with real providers in the live app.
2026-10-09 11:10:12 +08:00
yetone 55544f09f6 routing, gui: a provider's accounts and a group in the group fold on the Routing page, long ones folded at first, what the reader picks kept (Aiirobyte on Discord)
Aiirobyte: a routing group of many accounts took so much of the page that
editing the groups below it was a long scroll. The stage folded only runs
of three or more API keys (#1319).

Now:
- Three or more accounts of one provider side by side for one model fold
  into one row the way keys do: the count, the lit one and what it does,
  how many are available and resting. A request to a folded account flies
  to the fold.
- A group in the group's heading folds from a click, Enter or Space (it is
  a button now, with a chevron). Folded, it says "{n} on" and what its
  seats do together; requests under it fly to the heading and a failure
  tags it.
- Accounts and keys sums a provider's accounts up in one row too.
- Keys start folded as before. Accounts and group headings start open
  under six, folded from six. What the reader opens is kept in
  magpie.routingKeysOpen as before, what they fold in the new
  magpie.routingFolded, so both directions survive a reload.
- Redraws go through steady(), so a click keeps the page where it is.
- Strings in en, zh, zh-TW, ja, de. Display only: the gateway's order and
  choice don't change.

routing-account-order now opens the relay's key fold by :not(.open),
since the account folds there start open.

Run: new internal/gui/tests/routing-group-fold.test.cjs (defaults, click
and Enter in place with the page not moving, kept across reloads both
ways, a live request to a folded group heading with its tag, Accounts and
keys folds, 420px fit) in Chromium and WebKit, en/zh/zh-TW/ja/de, 40/40.
On origin/main's assets it fails (no account fold, heading not foldable).
All routing-* tests, panel-routing, click-scroll, gui-ja, gui-de,
gui-zh-tw and i18n-keys: 255/255 in Chromium and 255/255 in WebKit. Not
tried in the real window with a live gateway.
2026-10-09 11:10:11 +08:00
yetone 7137feb4fc redact: what a vendor wrote and checks goes back as it wrote it — signed thinking and tool call ids (Jeremy.Zhou on Discord: OpenCode Zen answered 400 "invalid …" until 脱敏密钥 and 脱敏个人信息 were off)
Masking ran its rules over every string of the request except a fixed list
of keys, including two kinds of text the vendor itself wrote and checks
when it comes back:

- A thinking block (Anthropic's signature; also Gemini's thoughtSignature
  and Responses' encrypted reasoning). The vendor thinks about the
  placeholders it was given, the agent gets the values back (Restore), and
  on the next turn the rules masked that text again. Rules depend on
  what is around a value: "the password is {{SECRET_…}}" came back as
  "the password is hunter2abc1", which no rule matches without its
  DB_PASSWORD=, an email ending a sentence fails the email rule's bound,
  and a number the vendor wrote of its own ("13800138000") turned into a
  {{PHONE_…}}. The text no longer matched its signature, and Anthropic
  answers 400 "messages.1.content.0: Invalid `signature` in `thinking`
  block" — and the value itself went to the vendor in the clear.
  Now an object that carries a non-empty signature, thoughtSignature,
  thought_signature or encrypted_content is masked only by putting back
  the placeholders of the values masked elsewhere in the same request
  (longest first): what the vendor wrote, its own words untouched.
- A chat tool result's tool_call_id. "id", "tool_use_id" and "call_id"
  were kept, "tool_call_id" wasn't. GLM's ids are call_ and 19 digits,
  and ~5% of them pass Luhn as a bank card number, so the tool message's
  id became call_-{{BANK_CARD_…}} while the assistant's tool_calls id
  stayed, and the upstream refused the orphaned tool result ("messages
  with role 'tool' must be a response to a preceeding message with
  'tool_calls'"). Now every key named id, …_id, …Id (and their plurals)
  is left alone.

Account masking (账号打码) is display only and doesn't touch requests.

Tests: TestSignedThinkingGoesBackAsSigned (a fake Anthropic upstream that
signs its thinking with an HMAC and checks it, two turns through the
gateway) and TestToolCallIDsGoBackAsGiven (a fake chat upstream that
refuses a tool result with no matching call) fail on origin/main with
exactly those 400s; TestMaskJSONSignedGoesBackAsWritten (Anthropic,
Gemini thought and functionCall, Responses reasoning shapes, plus an
unsigned thinking block still masked) and TestMaskJSONKeepsIDs fail there
too. All pass with the change.

Run: gofmt -l (nothing), go vet -tags nogui ./... on darwin, GOOS=linux
and GOOS=windows, go build -tags nogui (darwin, linux, windows),
go test -tags nogui ./... under a temp HOME: all ok. Not tried against the
real OpenCode Zen, and the reporter's exact error text and model weren't
seen; both upstream checks are modeled on Anthropic's and OpenAI's
documented errors.
2026-10-09 11:09:22 +08:00
yetone 16dc154589 claude: a new Claude Code told a whole conversation is told which turns were answered already and which turn to answer, and the gateway logs that it happened (#1365)
What changed: renderClaudePrompt told a run started anew the conversation
as one stretch of Human:/Assistant: text, every image of every turn in it,
and the model in #1365 took the earlier images for new ones. When the
conversation has a reply that calls no tool, the turns up to it now go in
<conversation_history>, with a note that the images and files in them were
sent with those earlier messages and need no answer again, and the rest
(from the user message after that reply, as nextTurn splits it,
historyEnd) in <current_turn>. Images stay in place inside their section;
none is dropped. A first turn, a conversation with no reply in it (one-off
asks, the auto mode classifier's transcript), or messages ending in a
reply are told as before. start logs "a new Claude Code is told the whole
conversation" with the message and image counts, after the "tool results
<why>" line that says why no run took it.

Why: a rebuild can't always be avoided (a run past its idle time with no
saved session, a run started anew for the turn and given up on, a
conversation the client changed). Its replay should not read as the user
sending everything again. Only the text of the one message a new run is
told changes, no header or other request field.

What was run:
- New TestClaudePromptMarksEarlierTurns: the earlier turn and its image are
  inside <conversation_history>, the current turn's text, tool round and
  two images inside <current_turn>, and a first turn or reply-less
  conversation has no markers. With historyEnd's split turned off it fails.
- go test -tags nogui ./internal/gateway/ (full package) under a temp HOME,
  including the existing renderClaudePrompt tests (prompt cache, foreign
  calls, images); -race -count=20 on the new tests; gofmt -l; go vet for
  darwin, linux and windows (-tags nogui); GOOS=linux/windows go build.
Not tried against a real Claude model: whether it now leaves earlier
images alone is untested.
2026-10-09 11:05:33 +08:00
yetone 7ea1cb6743 claude: a client that goes away while the run answers its tool results takes the turn back, not the whole conversation to a new Claude Code (#1365)
What changed: ended() kept a turn's id (turnUUID, backKey) only until the
reply that called a tool. From then on, a client that went away while the
run answered the turn's tool results (a cut connection, or the user
interrupting to send a message queued during the tool call) could not be
rewound: letGo ended the run. The client's next request, its results sent
again or the next turn, logged "tool results no run waiting", and a new
Claude Code was told the whole conversation in one message, with every
image in it, which the model in #1365 took for new. Now the turn's id lasts
across its tool rounds, until a reply ends without calling a tool. letGo
rewinds the run to the turn's user message, its tool rounds with it, and
the run waits at the conversation before the turn. The next request is
resumed there (nextTurn's key is backKey) and told the turn since its user
message. resume logs "a turn taken back goes on in its run" after the
"no run waiting" line. A run started anew for the turn, or one with a call
still in the client's hands, is ended as before. Nothing else sent to
Anthropic changes.

Why: the issue's log showed "no run waiting" four times, then a whole-
conversation replay. On current main a queued message with an image is
sent beside the tool_result (checked with Claude Code 2.1.295 against a
local mock) and match keeps it with the result, so it does not break
matching by itself. The MCP call reaches magpie ~3ms after the tool
reply's message_stop, well inside match's 500ms grace. What drops the run
is a client going away during a tool-result continuation. The exact client
trigger in the report is not confirmed.

What was run:
- New TestClaudeToolResultsGivenUpOnAreTakenBack (fake Claude Code that
  makes its call through magpie's MCP callback and answers
  rewind_conversation): the turn's results are given up on mid-reply, the
  run is rewound to the turn's message, and the resend goes on in the same
  process, told the turn and not turn 1's words. With ended() clearing the
  id as before, it fails: no rewind is asked.
- Real Claude Code 2.1.295 against a local mock of Anthropic:
  rewind_conversation to a user message, sent while the reply to that
  turn's tool result is streaming, answers rewound:true, and the next
  request's messages drop the turn and its tool round.
- go test -tags nogui ./internal/gateway/ (full package) under a temp HOME;
  -race -count=20 on the new test and the TestClaudeTurnGivenUpOn* /
  TestClaudeFirstTurnGivenUpOnEnds tests; gofmt -l; go vet for darwin,
  linux and windows (-tags nogui); GOOS=linux/windows go build.
Not tried with a real Claude account or Claude Desktop.
2026-10-09 11:05:33 +08:00
yetone 3e9bbdaae9 gateway: a paused Codex sign-in isn't spent by native compaction either (JasonLeeForOnly on Discord: a ChatGPT account turned off in magpie still had its quota used)
The account Codex is signed in to can be paused in magpie while another
of its accounts is on (#263: the user's own ChatGPT account kept for
Codex's remote control and Computer Use, another doing the work). Turns
on Codex's own models already passed over it (codexAccounts, routing and
OwnPaused), but /responses/compact for one of those models still relayed
as it came, on the paused account's own token, and on history the
others had sealed.

compactOn now picks, for that case only, the first of the other accounts
on that isn't resting, and codexUpstreamOn relays the compaction signed
as that account (its token and chatgpt-account-id, through its proxy),
keeping the Accept Codex sent. A gateway key held to some accounts (#905)
is asked of the account it goes to, not the paused sign-in. Not paused,
nothing else on, an API-key Codex, or a request naming an account: the
relay is as before.

Not changed: a Codex provider switched off (Off) still relays Codex's own
model requests (the Codex app's gpt-5.6-luna/terra side generations,
memories, codex-auto-review, native compaction) on the sign-in as they
came; that is a design question, reported separately.

Tested: TestCodexPausedOwnAccountNotCompactedOn (in use: on the sign-in;
paused: acct-2 with its own token; key held to the paused sign-in: 403
with nothing asked; held to the spare: acct-2; the spare off: the sign-in
again) fails on origin/main at the paused step (tried [acct-1]) and
passes. gofmt; go vet -tags nogui ./internal/gateway on darwin, linux,
windows; go test -tags nogui ./internal/gateway ./internal/provider
./internal/agent under a temp HOME pass. Not tried with real accounts.
2026-10-09 10:38:40 +08:00
yetone bf4d55acdd gateway: a Codex task sealed by a custom Responses provider still goes back to it after magpie forgets who answered the lead — a day later, after a restart, or after the lead's reply broke off — and the refusal says how to go on (#1367)
Autsunset (#1367): Codex with the lead and its subagents all on
xxxx/gpt-6-astra, a relay of ChatGPT Pro behind new-api. After quota
and overload errors, continuing the task was refused before xxxx was
asked: "This subagent's task was sealed by the ChatGPT backend that
answered its lead ... xxxx/gpt-6-astra is neither."

The cause: who sealed a task (#1109, #1237) was read only from the
affinity stick of the lead's conversation, and that record is lost in
three ways: it is kept 24 hours; a restart reads back only the latest
512 conversations from affinity.json; and a reply that breaks off after
its 200 (response.failed server_is_overloaded) deletes it (unanswered,
#733). Once lost, every later turn carrying a sealed message was
refused before anyone was asked, so nothing could answer the lead
again to remember it: the task stayed stuck.

When the sticks don't make the asked provider a reader, the usage log's
answers to the lead's thread and the request's own conversation in the
last 30 days now count too (loggedAnswers). A refused or failed call,
a title and Codex's own sign-in (Provider "openai" on /backend-api/codex,
still told of as the ChatGPT backend) count for nothing. The sibling
leadFirst, which puts the lead's ChatGPT account first in a group, reads
the lead's key or account from the log the same way. The refusal names
who answered the lead when the log knows, and both messages now say how
to go on: use the lead's model, or have the lead spawn the subagent again.

Verified:
- TestSealedTaskStillGoesToTheLeadsProviderAfterItsReplyBrokeOff and
  TestSealedTaskStillGoesToTheLeadsProviderADayLater (fake Responses
  relay over httptest, Fernet-shaped gAAAAAB... payloads, lead and worker
  on xxxx/gpt-6-astra) fail on origin/main with the reporter's exact
  400 and pass with the change; the sealed payload reaches xxxx as it
  came.
- TestSealedTaskLogDoesNotOpenItToAnotherProvider: a lead answered by
  another provider names it; a lead never answered (refused twice) and
  an answer older than 30 days stay refused. With the 30-day bound
  removed it fails.
- TestSealedTaskGoesToTheLeadsLoggedAccountFirst: with the leadFirst
  fallback removed the group's other account (acct-1) is tried first.
- TestSealedTaskFromChatGPTLeadStillRefused fails if Codex's own
  sign-in is not left out of the log's answerers.
- TestSealedReplyRefusedWhereTheLeadWasNotAnswered now expects the lead
  moved to another provider to be named "(third)".
- go test -tags nogui ./internal/gateway passes (sandbox HOME, pinned
  caches); sealed tests pass under -race -count=5; gofmt -l clean;
  go vet -tags nogui ./internal/gateway for darwin, linux and windows.

Not verified: real Codex with a real CPA/new-api relay of ChatGPT Pro.
If such a relay sends the subagent's request to another ChatGPT account
than the one that sealed the task, that account can't open it either;
magpie can't see or fix that.
2026-10-09 10:34:03 +08:00
yetone a8ca790855 plugin: the host takes a proxy set after it started (#1363)
iamyhzhao's moved Grok account reads as signed out after nearly every
reboot. The plugin host (Bun, host.js) reads MAGPIE_*_PROXY once, at its
start, and nothing restarted it when the proxy changed. After a reboot
magpie can start before Clash or another proxy app sets the system proxy,
so the host had no proxy for its life; the Grok plugin renews by running
`grok models`, which got no proxy from the spawn wrapper, couldn't reach
auth.x.ai, and token() said the sign-in had expired. Moving to the
built-in and back installs and removes the plugin, each of which restarts
the host, which is why that worked.

Now the host remembers the proxy it was started with (hostProxy of
env()), and get() replaces a live host whose proxy is no longer what a
new host would get (proxyMoved, looked at no more than every 15s).

Run: new TestPluginHostTakesANewProxy (real host on the cliproxy
fixture: no proxy, then HTTPS_PROXY set, the CLI gets it; unchanged
proxy keeps the same host) passes, and fails with the check disabled.
go test -tags nogui ./internal/plugin ./internal/provider
./internal/gateway in a sandbox HOME: ok. gofmt -l clean; go vet for
darwin, linux and windows on ./internal/plugin: clean.
Not verified: the reporter's proxy setup (Clash with system proxy is the
likely case, not confirmed), a real reboot, a real Grok account.
2026-10-09 09:49:03 +08:00
yetone 58ba737811 remote magpie: a remote magpie's model prices are this magpie's list prices for its models, so both Usage pages count a call at one cost (Sorghum on Discord: 希望本地 magpie 可以自动集成 remote magpie 手动设置的模型价格)
What changed
- The local magpie asks a remote magpie's /v1/models with a new header,
  X-Magpie-Prices (provider.PricesHeader, beside the Drawers and
  Videomakers headers in listHeaders).
- The remote answers each model's "magpie_price": what its own Usage page
  counts the model at (provider.EntryPriceIn: the price its user set by
  hand, else its provider's list price at its price rate; a routing group
  only when every member costs the same). EntryPriceIn is agent/route.go's
  entryPrice moved into provider unchanged, so the agents' cost and the
  remote's price are one rule.
- It tells prices only to a request from its own computer or one let in
  with a gateway key (local || sharedWith), and only when asked: a machine
  let in without a key (MAGPIE_ADDR open, not shared) and an older magpie
  or an agent that sends no header get nothing new.
- The local keeps the price with the fetched list (catalog.Model.Price; read
  raw, so an odd value or a negative part drops the price and keeps the
  model and the list) and uses it as that provider's list price
  (ListPrice -> remotePrice). A price the local user set (provider/model,
  provider/*, */model) still comes first, and the local provider's own
  price rate still applies on top.
- Old remote <-> new local: no field, models priced as before.
  Old local <-> new remote: no header, nothing new sent (and an unknown
  field would be ignored by encoding/json anyway).
- The model editor's price tooltip and `magpie model price` say the greyed
  price is what the other magpie counts the model at (remoteList; new
  string in zh, zh-TW, ja, de).
- Side effect: smallModel/cheapest picks over a remote's models (image
  description, web search) now see these prices, where every remote model
  was unpriced before.
- docs/subsystems/providers-accounts.md: a row for it.

What was run
- New TestRemoteMagpiePrices (real gateway handler behind lanGuard as the
  remote, a local remote-magpie provider fetching it): the remote's hand-set
  price is the local's price; a real chat call through it costs $9 on both
  the local's and the remote's usage records; the local's own price wins
  and survives a refetch; an old remote's list leaves the model unpriced.
  New TestRemoteMagpiePricesTold: priced for loopback asking, not without
  the header, not for a keyless LAN caller with MAGPIE_ADDR open, priced
  with a gateway key. New TestLiveMagpiePrice in catalog.
- Broken by hand, each red: no remotePrice in ListPrice (FAIL: priced
  false); remote price over the user's (FAIL: remote's price won); the
  local/sharedWith gate removed (FAIL: keyless machine priced); the header
  gate removed (FAIL: told to a list that didn't ask); sane() always true
  (FAIL: a/negative priced). A string-typed magpie_price first failed the
  whole list ("not a model list"), hence the raw read.
- gofmt -l clean; go vet ./..., GOOS=linux go vet -tags nogui ./...,
  GOOS=windows go vet ./...: clean; go build, GOOS=linux -tags nogui and
  GOOS=windows builds OK; go test -tags nogui ./... green under a temp HOME
  with pinned caches.
- GUI: model-price-edit (new remote-title check), model-price-tiers,
  provider-price-rate, gui-ja, gui-de, gui-zh-tw, i18n-keys: 18/18 in
  Chromium and WebKit; the title check goes red with the remoteList branch
  removed.
- Not tried between two real magpie binaries on two computers, nor in the
  real window.
2026-10-09 09:43:54 +08:00
yetone 5da6c24e80 agents, gui, cli: an agent can be shown only the models picked for it, so a model added later stays out of its list until ticked (#1337, nianlee-official)
nianlee-official asked for an agent whose model list grows only by hand:
today the Agents page's per-agent list hides models one at a time, and
every new model, of a new provider or an old one, shows up in the
agent's picker and config on its own ("New models are shown").

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

Run:
- go test -tags nogui ./... in a temp HOME: 44 packages ok.
- New: provider TestOnlyPickedModels, gui TestAgentModelsOnlyPicked,
  gateway TestOnlyPickedModelsList (opencode's key: a new provider's and
  an old provider's new model are left out of /v1/models, cursor's key
  has them, and a chat request naming fresh/x1 reaches the upstream) and
  TestCodexModelListOnlyPicked. With ModelOff made to ignore picks, all
  four fail.
- GUI: new agent-models-only-picked.test.cjs, Chromium and WebKit, en/zh/de,
  420 and 1100 wide (12/12). With save() sending {hidden} in pick mode it
  fails. agent-model*, agent-models*, codex-model-order, cursor-local-models,
  desktop-model-menu, click-scroll, gui-ja, gui-de, gui-zh-tw: 85/85.
- gofmt -l clean; go vet -tags nogui ./... on darwin, GOOS=linux and
  GOOS=windows; go build, and linux/windows builds.
- The built CLI in a sandbox HOME: visible codex --only-picked, a model
  added after it is left out of `magpie models codex` and kept in claude's;
  --show-new keeps it out and new ones show again.
Not tried: the real window against a live gateway, or a real agent
reading the narrowed config.
2026-10-09 08:50:14 +08:00