44 Commits
Author SHA1 Message Date
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 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 baecbb1be0 provider, gateway: Cursor moved onto its 0.2.x plugin gets Fast back, in the agent picker (#954) and for a Routing group's member, on the models Cursor has a fast variant of (#1360, loosheng: 插件 0.2.x 后 Cursor 的快速模式不见了,保存 cursor/<model>:fast 报 "has no fast mode magpie can ask for")
Plugin 0.2.x lists Cursor's picker once per context size (claude-opus-5-5@300k, @1m) and no -fast model, so CanFast, which looked for a -fast sibling, said no for every model: FastFor in route.go, canFast in the group editor, SetFastPick and cleanFast all go through it. The plugin itself still ran service_tier priority as the fast variant. opencode-cursor-auth 0.2.2 (magpie-community/plugins fb253f9) now says fast on a model listed at a size Cursor has a fast variant at; host.js passes it on as plugin.Model.Fast, CanFast takes it, and a plugin before 0.2.2 is still read by its -fast sibling. The Cursor mover asks for 0.2.2. On the way a fast request translated for a moved Cursor (Anthropic, Responses, chat not streamed) lost its service_tier: buildChat sets it only for host "cursor", and a moved provider's host is empty; buildHost names it.

Tested: the fake plugin lists sized models with fast on one (FAKE_FAST=tier); TestCursorPluginGroupFast (CanFast per size, @1m:fast refused, a group's @300k:fast sends priority), TestCursorPluginGroupFastSibling (the old -fast list), TestCursorPluginFastPick (moved: picker fast on chat, chat streamed, Responses and Anthropic sends priority, codex's doesn't). Without groupfast.go, host.js's fast or buildHost they fail. go test -race -tags nogui on gateway, provider, plugin, agent, gui; go vet, GOOS=linux/windows vet and build. With the built binary under a temp HOME, the real npm 0.2.2 plugin and Cursor's API answered by a preload (a picker fixture, the agent API's Run caught): /api/state offers fastFor on @300k and composer-2.5, not @1m or grok; /api/groups canFast the same; saving @1m:fast is refused, @300k:fast saved; opencode's picked @300k reaches Cursor's Run with fast=true on chat, Anthropic and Responses, codex's with fast=false. origin/main's binary in the same HOME offers none and refuses @300k. Not tried with a real Cursor account.
2026-10-09 05:32:36 +08:00
yetoneandClaude cdf325f178 plugins: a package that is only a command (an MCP server like magpie-x-search) isn't listed under Unreviewed, isn't added as a plugin, and one added before says what it is instead of a host stack (#1327, congee949)
A repository tagged magpie-plugin whose npm copy (from that repository)
names no file to load (no main, no exports) was listed and installed from
npm without the entry check the GitHub route has; it then failed every
host start with "has no entry" and a stack. Now:

- the Unreviewed list checks npm's copy the same way: one that names no
  file needs the index.js the repository's own route needs (askTagged);
- `magpie plugin add` / the Plugins page refuse a package that names no
  file, has no index file and is neither pi's nor a middleware, take the
  package just installed for it out again, and say it isn't an OpenCode
  or magpie plugin, and, for one with a bin, to add an MCP server under
  Library → MCP servers (notPlugin);
- the host says the same, without a stack, for one already on the list.

Run: TestCommandOnlyPackageIsNoPlugin (magpie-x-search 1.2.0's real
package.json) fails without the change (Add = <nil>; with only host.js
reverted, the stack message), TestTaggedRepos with the real GitHub
package.json and npm latest of cyberElar/magpie-x-search fails without it
(listed), both pass with it; TestNotPluginKeepsPlugins (index.mjs, main,
exports, middleware, pi, a file stay plugins). go test -tags nogui
./internal/plugin under a temp HOME ok; go vet -tags nogui for darwin,
linux, windows. The built binary in a sandbox HOME: `magpie plugin add
magpie-x-search@latest` from real npm prints the new error, and
plugins/package.json no longer lists it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-08 23:09:25 +08:00
forever 8412a5d1a0 gateway tests: the keepalive tests count only the idle time a client waits through — the keepalive after the vendor's first words, and nothing after the reply's last event (#1163)
* gateway tests: the quiet mid-reply tests wait for, and count, the keepalive after the vendor's first words, as a held stream's before them is the agent's too (TestQuietStreamMidReplyKeptAlive failed once on main at GOMAXPROCS=2, load ~30)

The failing body began ": keepalive" before the role chunk and "hel".
That ordering is allowed, not a bug: quietFast lets watch keep the agent
alive 40ms into the try, and a vendor slow to answer (status 0, nothing
held) or one that flushed response.created and paused before "hel" (only
the role chunk, a lead, held) gets the held stream's keepalive first, as
TestSilentHeldStreamKeptAlive requires. Content that reaches the
holdWriter is scanned and flowed under h.mu, which keepQuiet and
keepAlive take, so no keepalive goes out after content the writer has
and before it is relayed. A vendor delayed 150ms before its first bytes,
or between response.created and "hel", gives the failing body every
time, with the keepalive sent at status 0 or with only the role chunk
held.

The tests released the vendor at the first keepalive and required "hel"
before it, so a slow first write made them fail, or test the held path
instead of the mid-reply one. readQuiet now releases the vendor at the
first keepalive after the given text, and aliveAfter asserts a keepalive
between "hel" and "lo": TestQuietStreamMidReplyKeptAlive and
TestQuietStreamsKeptAliveEveryProtocol's mid-reply cases.
TestQuietKeptAliveOnlySoLong counts only the keepalives after "hel", as
the held ones before them have their own 200ms. The every-protocol check
matches `"lo"` quoted: bare, it matched the anthropic cases'
content_block.

With a 150ms vendor delay injected, the old assertions fail all 5
mid-reply cases and the new ones pass. With keepQuiet's passing-branch
keepalive removed, all 6 mid-reply tests fail, delay or not. go vet;
go test -tags nogui -race -count=20 -run
'TestQuietStream|TestSilentHeld|TestQuietKeptAliveOnlySoLong'
./internal/gateway; the whole package with go test, and as a prebuilt
binary at GOMAXPROCS=2 three times (load 18-28), all pass.

* gateway tests: TestTranslatedStreamKeepsClientAlive stops counting the client's idle timeout once it has the reply's last event (red on ubuntu CI at ef5fba2c, run 37471004500: responses_to_chat "idle_timeout=true completed=true") — the gateway writes and flushes finish_reason, usage and data: [DONE] back to back in chatEncoder.finish, then serve's bookkeeping (NoteServed, saveSticks, usage.Append) runs before the handler returns and net/http sends the stream's close. On a loaded runner that, or the test's own goroutine being held, passed the 150 ms the test still counted after [DONE]. No client's idle timeout counts that wait: Codex hangs up on response.completed, and the clients that read on to the close give minutes. Chat's marker is now data: [DONE], the stream's last event, instead of finish_reason two events before it. With a 200 ms sleep after enc.finish() on the Chat path, the old test fails 20 of 20 exactly as CI did and the new one passes 20 of 20. The new test still fails 20 of 20 with the sleep before enc.finish() (completed=false), with readSSEAlive's keepalives taken out (#436), and with the Chat keepalive followed by a blank line again ("events with no data"). go test -race -count=20 -run '^TestTranslatedStreamKeepsClientAlive$' ./internal/gateway, go vet and go test -tags nogui ./... pass.

* plugin tests: the fake plugin's windows renew at times set when the host loads it, so the gateway's TestPluginFailover passes when an hour begins between two readings of an account's usage (waitingsnow)

The fake plugin told each account's five hours to renew an hour after
its usage was read, and its week a day after. TestPluginFailover weighs
the accounts by what the plugin tells (provider.Allowances), and Smart
routing puts first the account whose week renews sooner, to the hour;
accounts alike keep their order. A request that fails on an account's
quota, or in its stream, drops that account's reading
(staleAllowance), and the next request reads it again. A 401, as in
the gone round, is failOther and drops nothing (rest,
gateway/routing.go). When an hour began between a@fake's read after
the quota round and its read after the stream round, a@fake's week
renewed in the hour after b@fake's. b@fake then went first in the gone
round, failed with a 401 and kept its first reading, so the request
with both out was tried as b, a, full, which fails the test at
plugin_failover_test.go:210. An hour that began after the first
readings but before a@fake's read after the quota round put b@fake
first in the stream round instead, which failed b@fake and read it
again too, and the test passed.

The test fails every time an hour begins between those two reads of
a@fake, and passes otherwise: the 8 such runs below failed and the
other 13 passed. The reads are 12 to 76ms apart in the runs below
without the hold, and a reviewer's -race build measured 33 to 106ms in
6 runs, so a run at a random time fails about once in 34,000 to
300,000. CI's Linux job installs bun 1.4.2 and runs the test in its
Test and Race steps. Found by a review of the tests that compare
renewals to the hour; no CI run was checked for it.

The windows now renew an hour and a day after born, when the host
loaded the plugin, however often an account is read, as a vendor's
windows renew at times of their own. Run once in a process, as CI's
Test and Race steps run it, TestPluginFailover reads its accounts from
the one host started after its plugin.Add, so they renew together. Run
again in one process (-count), a run can still weigh an account by the
previous run's host, born perhaps in the hour before, until that
account fails and is read again: provider's allowances and usage
readings outlive fresh(t) and plugin.Settle. That account then goes
first, and the test's rounds fail it and read it again before the
checks that depend on order, so it still passes; clearing those caches
is left for its own commit.

TestPluginUsage now reads a@fake's card a second time from its refresh
button, 2ms later, and wants each window to renew when it did the first
time, as a vendor's do. That is the check that fails without this
change. Its other bounds (the five hours in 50 minutes or more, the
week in 23 to 25 hours, full@fake's five hours used up) hold while the
host is under 10 minutes old; no other test reads these times.

Verified under a temporary HOME with Go's caches pinned, every test run
with -tags nogui and bun 1.4.2. With 01aaa8e9's index.js put back,
TestPluginUsage fails every run:
  plugin_usage_test.go:109: a@fake's 5 hours renews at 2026-10-07
  12:16:35.567 +0000 UTC read again, at 2026-10-07 12:16:35.539 +0000
  UTC first
and with this one it passes, also with -count=5.

With 01aaa8e9's index.js, TestPluginFailover passes unless an hour
begins between those two reads. failgap.sh, a scratch script, builds
the gateway's test binary once and runs TestPluginFailover with the
old plugin and with this one. It makes the plugin's usage hook tell
its windows from Date.now() plus a shift, set at the first reading so
that an hour begins a set time later (Go's clock is left alone), and
logs each reading. Its copy of the test can hold 2s before the stream
round, which puts its lines 5 further down. With the hour 30ms after
the first reading, the old plugin failed 3 runs of 3, its two reads of
a@fake either side of the hour (at 23 and 42ms, 20 and 34, 18 and 37):
  plugin_failover_test.go:215: fake-claude with the others out: 200
  {...} (tried [b a full])
At 45ms it failed 1 of 3 (44 and 69ms). At 60, 80 and 120ms it passed
9 of 9, a@fake read twice before the hour, or once after it and b@fake
read again. Held 2s before the stream round, with the hour 50 or
1100ms after the first reading, it failed 4 of 4 (a@fake read at 24 to
34ms and 2044 to 2121ms). This plugin passed all 19 of those runs.
That shows only that the test passes with it: its windows are fixed
from born when the host loads it, before failgap's first reading moves
the clock, so the moved hour never reaches them. Held 2s with the hour
30 minutes away, both plugins passed 2 of 2. born.sh, run as a process
per run, logged the host and the born behind each of the plugin's
reads in 20 more runs of this one: 9 a run, each run's from one host
and one born. It logs the plugin's reads, not what routing weighed.

gofmt -l internal/provider/plugin_usage_test.go prints nothing. go vet
./..., go build ./..., GOOS=linux go build -tags nogui ./...,
GOOS=windows go build ./... and GOOS=windows go vet -tags nogui
./internal/provider/ pass. go test -tags nogui ./... passes, 43
packages ok, with bun 1.4.2 on PATH, and so do TestPluginFailover and
TestPluginUsage* with -race -count=20.

* gateway tests: TestTranslatedStreamKeepsClientAlive fails when the stream doesn't close within 5s of its last event, rather than waiting on it with no limit

After the reply's last event the read had no time limit, so a gateway
that never closed the stream would hang the test until the package
timeout (yetone on #1163). The idle timeout still stops at the last
event; a 5s limit on the close follows it.
2026-10-08 10:28:06 +08:00
forever 470d1e71a5 provider: a plugin account's usage reading begun before a request was answered no longer overrules the answer, so a 401's lapse mark isn't taken off by a clean reading already out (TestPluginFailover, about 1 run in 90) (waitingsnow) (#1176)
TestPluginFailover failed about 1 run in 90 at "a refused isn't marked".
With notePluginLapse logged, a failing run read: a background reading of
a's allowance (loginReading, after the "stream" round's StaleAllowance)
began at 31.985; the upstream answered a's request 401 at 32.001 and
notePluginSaid marked a lapsed at 32.003; the reading ended clean at
32.011 and notePluginLapse(200) took the mark off; the test read the
logins at 32.042. Users meet the same: a reading of the Usage page or of
routing's allowances that is out when a request is refused clears the
mark the refusal left, and routing goes on trying the refused account;
one that ends refused marks an account a request has just gone through
on.

What plugin accounts' sign-ins are told of is now counted under
loginsMu (pluginSignInTold, pluginSignInSaid). A request's answer, a
sign-in and the plugin's own signIn events are told now and apply as
before. pluginLoginQuota takes the count before plugin.AccountUsage, and
what its reading found applies only when nothing was told of that
account since (notePluginLapseSince), as Allowances leaves out an
account renewed while it read. A count rather than a time: Windows'
clock moves in ticks of up to 15.6ms, and a reading begun just after an
answer would read as begun with it.

TestPluginUsageReadOlderThanAnswer holds the fake plugin's usage on an
httptest server ($FAKE_USAGE, whose 401 the fake now reads as an expired
sign-in) while a request's answer is noted, both ways, and checks that
readings begun afterwards still mark and clear. Each direction fails
without the fix. TestPluginFailover now reads the allowances the
"stream" round left stale before the "gone" round: the fake's usage
doesn't ask the upstream that refuses, so a reading that asks after the
401 finds the sign-in good and rightly takes the mark off. A review
reproduced that with a delay injected before the count is taken.

Left as it is: a reading whose own renewal of the token tells "renewed"
before it asks skips the "expired" it then finds; the next 401 marks the
account. Not tried with a real plugin account.

Under a temp HOME with Go's caches pinned and bun 1.4.2: gofmt, go vet
-tags nogui ./..., builds for darwin, GOOS=linux -tags nogui and
GOOS=windows (and windows vet of provider and gateway) pass; go test
-tags nogui ./... passes; the changed provider tests and
TestPluginSaysSignIn/TestPluginFailover pass with -race -count=20; the
report's `go test -tags nogui -race -cpu 1,2,4 -count=30 -run
'^TestPluginFailover$' ./internal/gateway/` passed 3 rounds (270 runs),
where it failed 3 of about 54 runs before the fix on the same loaded
machine.
2026-10-08 06:48:41 +08:00
yetone f6f960e42c plugins: a GitHub repository tagged magpie-plugin shows on the Plugins page under Discover, as an unofficial plugin nobody at magpie has reviewed
GitHub's search for topic:magpie-plugin (not archived, not a fork, not
magpie-community's own, which the market lists) is asked at most every six
hours and kept on disk, so a restart or GitHub being down shows what it said
last. Each repository is read from its package.json on the default branch.
When npm has that package published from the same repository, it is
installed from npm, because a repository often builds its dist only to
publish and Bun runs no build for a git install. Otherwise it is installed as
github:owner/repo, and only if the file it loads (exports, main or
magpie.middleware) is in the repository. Its page warns that it is
unofficial, says whether npm or the repository is what gets installed, and
shows the repository's README until it is installed. MAGPIE_PLUGIN_MARKET=off
asks GitHub nothing.

Checked against real GitHub with the topic set to opencode-plugin in a
sandbox: 43 repositories came back, and 32 were kept (30 from npm, 2 from
git). github:ndom91/open-plan-annotator, one of the git ones, really
installed and loaded. alfaoz/opencode-see-image, whose dist isn't in its
repository, failed to load before the entry check and is now left out. The
test fixtures are GitHub's and npm's real answers.

Ran: go test -race -count=3 ./internal/plugin, go test ./internal/gui, go
vet, and plugin-github.test.cjs (Chromium and WebKit, 980/440, en/zh/ja/de,
18/18). The full GUI suite passed 3343 of 3348. The 5 failures were WebKit
number-units, purpose-filter and routing-failed-open, which pass 3/3 on
their own and don't touch the Plugins page.
2026-10-07 10:13:20 +08:00
yetone 14af0f100b provider, plugins: a plugin's browser sign-in finishes from the address the browser ended on, for magpie on a server or in Docker (Chicring)
The built-in sign-ins that come back to a port on this machine (Codex,
ChatGPT API, Devin, Kiro, Zed, Gemini, Antigravity) already took the
pasted address. Their plugins didn't: a subscription moved onto its plugin
(Devin, Kiro, Zed) or a plugin's own browser sign-in (Trae) showed only
"the account shows up here as soon as you're done", and a browser on
another computer than magpie's ended on a page that won't load with no way
to finish.

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

Verified: TestPluginSignInTakesAPastedCallback (fake plugin with a loopback
sign-in, $FAKE_LOOPBACK) fails without the wiring in pluginBegin ("a
sign-in that comes back to a port here takes no pasted address:
{... PasteCallback:false ...}") and passes with it, -race -count=10;
TestLoopbackPorts covers the community plugins' page addresses.
go test -race ./internal/provider ./internal/plugin and the root package's
Plugin|Login|SignIn tests pass; vet, gofmt, darwin/linux nogui/windows
builds pass. With the real community Devin, Zed and Kiro plugins in a
sandbox HOME, each sign-in offered the field and a pasted address reached
the plugin's listener and the vendor (bogus code refused by Devin 401, Zed
"couldn't be decrypted", Kiro 400). signin-paste-plugin.test.cjs (new),
signin-callback and signin-paste-codex pass in Chromium and WebKit, en and
zh, at 360px; no new strings. Not tried: a real successful sign-in from a
browser on another computer.
2026-10-07 00:13:03 +08:00
yetone 51d2cf88ea plugin host: a plugin's node:http2 connection goes through magpie's proxy like its fetch does (#1035, cataglyphis: Cursor opus-5-5 high ends after ~148s with "Cursor: 502 Bad Gateway" cursor_error) — the Cursor plugin runs agent.v1.AgentService/Run over node:http2, which Bun connects directly and ignores the proxy that host.js gives every fetch; the built-in sent the same request through s.client, so a user who needs magpie's proxy (setting, env or the system's) to reach Cursor had their sign-in, models and usage proxied but every Run went out directly. host.js now wraps http2.connect: for a URL proxyFor gives a proxy to (provider/account via, global, SOCKS bridged), it opens an HTTP CONNECT tunnel (Proxy-Authorization from the URL) and runs TLS with ALPN h2 over it; "direct", a caller's own createConnection and unproxied URLs keep Bun's connect; a tunnel that fails ends the session with "proxyconnect tcp: …". TestPluginHTTP2TakesTheProxy (an HTTP/2 vendor at an unresolvable host reached only through a CONNECT proxy; direct and a dead proxy fail with the reason) fails without the change ("h2: getaddrinfo ENOTFOUND h2vendor.invalid"; it carried 0) and passes -race -count=10. With the owner's Cursor account in a sandbox HOME and a logging proxy set, Claude Code's captured request on cursor/claude-opus-5-5 high: before, the proxy saw api2.cursor.sh only; after, it also sees agentn.global.api5.cursor.sh and the reply streams. The reporter's 502 wasn't reproduced here (direct runs answer in 4–20s). go test -race ./internal/plugin, the gateway/provider Plugin|Proxy|Cursor tests, gofmt and vet pass. 2026-10-06 22:16:24 +08:00
yetone 87487c5ef0 plugin, provider: a plugin can press its vendor's daily check-in itself (auth.checkin), and magpie runs it as its own check-ins (Lemon on Discord #feedback: provider 每日签到的方法能放到插件里面去做吗; ARNO earlier asked for Qoder's) — the check-ins magpie had (WorkBuddy, Trae CN, MiniMax Code, Qoder) are each written in magpie against the vendor's endpoint, even for a subscription that moved to its plugin, so a plugin author had no way to give their provider one. A plugin's auth hook may now have checkin(getAuth, provider) beside usage: the host lists the provider with checkin: true (plugin.Provider.Checkin) and answers checkin({provider, account}) (plugin.AccountCheckin), refreshing the account as usage does, with {outcome, credit, streak, message}; outcome is claimed, done, ineligible, inactive, captcha or failed, and a throw or an outcome outside these is failed with the reason. internal/provider/plugin_checkin.go runs these through checkin.go's checkiner, as the built-ins are: each signed-in account in use once a Beijing day (first look 2 minutes after start, then every 30), a failure asked again after wbCheckinRetry, every other outcome settled for the day, results kept in plugin-checkin.json by provider|account; a failure is recorded as failed and resets nothing. captcha is new (CheckinCaptcha): the vendor wants a captcha, which magpie never solves, so it is said as "check in in its own app", not as a failure, and not asked again that day. The results mark the account's Usage card (checkinBy "plugin:<provider>", WithCheckins), so the card's check-in row, its Auto check-in and Check in now work for it (POST /api/settings/plugin-checkin {provider, on}, POST /api/usage/plugin-checkin {provider}); Settings → Usage gains a Plugins tab, shown while such a plugin is signed in, a Daily check-in row per provider with Off/On and how each account went; magpie accounts checkin and c on the TUI's Usage page include them (the TUI's "no account" line names check-in plugins). The switch (settings.PluginCheckins, by provider) is off by default; for WorkBuddy, Trae CN, MiniMax Code and Qoder it follows that vendor's existing switch until set on its own, and magpie's own check-in through the plugin's fetch leaves a provider whose plugin checks in to it (pluginChecksIn), so no account is checked in twice. The Settings page's save carries PluginCheckins over (TestSettingsSaveKeepsWhatItDoesNotSend's fixture names it). No plugin declares the hook yet; Qoder's check-in stays magpie's own here. TestPluginCheckinRunsAsTheBuiltinsDo (the fake plugin with FAKE_CHECKIN, five accounts answering claimed, done, captcha, a throw and an unknown outcome: off presses nothing; on, each outcome kept with its vendor; a minute on nothing pressed again; past the retry only the two failures; the next day all five; PluginCheckins and the cards marked; another provider's press asks none) and TestPluginCheckinTakesOverTheVendors (a qoder plugin without the hook leaves magpie's Qoder check-in its account, with it none; PluginCheckinOn's fallbacks) pass -race -count=10, and the latter fails with pluginChecksIn stubbed false; TestTUIChecksPluginsIn passes -race -count=20. plugin-checkin.test.cjs (the card's row with the captcha said, Auto check-in posting {provider, on}, no page move; the Settings Plugins tab at 440px with the row fitting, no sideways scroll, On posting) passes on Chromium and WebKit in en, zh, ja and de; usage-checkin, i18n-keys, gui-ja, gui-de and settings-* pass. go test -tags nogui ./internal/..., go vet, gofmt -l, linux (nogui) and windows builds pass. Not tried with a real plugin or account: no published plugin has the hook yet. docs/subsystems/provider-plugins.md (the hook's contract) and docs/reference.md say it. 2026-10-06 18:40:45 +08:00
yetone bc00ee1df2 plugin, provider: a program a plugin starts has magpie's proxy, and a CLI login with no link says why (𝕏 on Discord)
The Grok plugin's sign-in said only "grok login gave no link to open".
The plugin host has magpie's proxy only as MAGPIE_*_PROXY, so the
`grok login` the plugin runs went out with none, where the built-in's had
it (netproxy.Env); where x.ai is reached only through a proxy, it failed.
host.js's spawn wrapper now gives a started program HTTPS_PROXY and
HTTP_PROXY (the account's own proxy, none for direct, else magpie's) when
the plugin set none.

grok 1.0.46, unable to reach x.ai, prints "Error: error sending request
for url (https://auth.x.ai/oauth2/device/code): …", whose URL the
built-in's runCLISignIn took for the page to open; a line saying error
gives no link, and a login with none ends with the CLI's last line. The
plugin does the same from 0.1.8, which the grok mover now asks for.

plugin_cliproxy_test.go and TestCLISignInSaysWhyNoLink fail before this.
Checked with grok 1.0.46 itself through the plugin host and the published
plugin's code, given a proxy that refuses: the CLI goes through it and the
error names what it said.
2026-10-05 22:25:33 +08:00
yetone 9e13f18fb9 gateway, provider: a request turning reasoning off stops Factory's DeepSeek V4.1 Flash, Kimi K3 and GLM-5.2 thinking (#899, tedestar) — they offered only low/high/max (GLM-5.2 high/max), so an agent's none (or Claude Code's thinking disabled) was asked for low, or nothing, and Fireworks thought anyway. droid 0.231.0 lists off for them and sends reasoning_effort "none"; through OpenRouter pinned to Fireworks, none came back with 0 reasoning tokens on DeepSeek V4.1 Flash (2 completion tokens; 19 at the default, 16 reasoning at low) and Kimi K3 (8; 71 at the default), and on Baseten's GLM-5.2 (2; 114 at the default), while GLM-5.3 and GLM-5.3-Flash turn none away ("Reasoning is mandatory for this endpoint and cannot be disabled") and stay at low, their least (Qwen3.8 Max and MiniMax have no off in droid either). The three now have none among their levels, in the built-in and in @magpie-community/opencode-factory-auth 0.1.17, which the mover's min is raised to; and a translated request that turned reasoning off (Effort reads it as low) is asked for none on a model with none among its levels, whatever the provider, else as before (a refusal still falls back to the lowest level on). No Factory account was at hand to send through Factory itself. TestFactoryThinkingOff (the built-in: chat, Responses and Anthropic's disabled; GLM-5.3 still low) and TestPluginThinkingOff (the plugin host, a model whose variants have none and one without) are new and fail without the change. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass (TestSavedButSignedOutClaudeServed timed out once under load and passes alone). 2026-10-05 20:09:00 +08:00
yetone dad4b98729 gateway, gui: the Cline plugin's provider has ClinePass's Upstream tick too (ARNO on Discord: 这个功能可以也加到cline的插件里面吗) — "DeepSeek models only from DeepSeek's own API" was the built-in ClinePass's alone: the Cline plugin's provider (@magpie-community/opencode-cline-auth) is an account, whose editor had no such tick, whose save didn't keep pinUpstream and whose requests clinePin didn't touch. Now ClinePinnable is ClinePass, a cline.bot provider or the Cline plugin's; its editor shows the Upstream tick (one upstreamField for both editors), its save posts pinUpstream, the account keeps it (Save, allProviders), and a DeepSeek model's request reaches the plugin with "providerOptions":{"gateway":{"only":["deepseek"]}} on Chat and Anthropic Messages, as ClinePass's does. The plugin 0.1.3 has the same as an option of its own for OpenCode. TestClinePluginPinsDeepSeek (the fake plugin as cline, with a deepseek-v4-pro): unticked nothing is pinned; ticked and saved, the tick is kept and both DeepSeek requests are pinned, fake-1's not; it fails on the old code (not pinnable; the tick not kept). clinepass-pin.test.cjs: the plugin's editor shows the tick as saved and Save posts it off when unticked (WebKit, en and zh); it fails on the old app.js. model-first-pick, plugin-moved-signin, click-scroll, gui-ja and gui-de pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. 2026-10-05 12:59:32 +08:00
yetone 0ae7330242 plugin: a pi package's sign-in screen on pi's terminal UI is asked in magpie, and an empty reply a package makes of a vendor's error says the error (all pi plugins: Kiro's login hung, pi-zcode's quota ran out as an empty answer) — pi-provider-kiro asks how to sign in through ctx.ui.custom, a component of pi-tui (a SelectList, then Input fields for a start URL and region), and magpie gave pi's runner no UI, so custom came back undefined and the sign-in waited forever. pi's runner now has a UI for the sign-in's questions: select, confirm, input and editor become magpie's sign-in prompts, and custom draws the package's component and asks its list or field the same way, answering through the component's own onSelect/onSubmit, until the package calls done. A component may keep its parts to itself (Kiro's gives only render and handleInput) and each package loads its own copy of pi-tui, so the parts are found by what drawing walks: every container goes through its children, so the arrays iterated while it renders are what it shows, in order; the text above the list is its question. Outside a sign-in the UI answers as pi's no-UI context does. session_start now reaches every package at startup, which Kiro needs to keep the context it asks through. Separately, pi-zcode turns ZCode's 1005 quota error into a reply with no content; magpie keeps the small JSON or text bodies a package's fetch gets while it streams, and a stream that ends empty answers that body's message and code (429 for quota or rate limits, else 502) instead of an empty message. TestPiPackage drives a fake package that asks on real pi-tui classes (a list, then a start URL field) and fakes ZCode's empty quota reply; it fails on the old code (no first prompt) and passes. With real accounts in a sandbox HOME: Kiro's "Kiro Login" (API Key, Web Login, Device Code) came as a select, Web Login gave the app.kiro.dev sign-in, and after signing in kiro-plugin/claude-sonnet-4-5 called get_weather and answered with its result. pi-cursor (1.4.38, latest) answers plain turns but its models say no get_weather tool exists, though the package sends it (toolCount 1, its MCP schema) and Cursor's stream carries fields the package doesn't know; that is between pi-cursor and Cursor. The ZCode quota path wasn't tried with a real account. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. 2026-10-03 23:47:15 +08:00
yetone 67092380fb plugin: pi packages run as pi runs them — a program a login starts can't take the host's stdin, a select is answered with its option's id, and the extensions get pi's runner, pi's auth.json and session_start (owner: 都补上,用真实账号测) — tried with real accounts, five pi packages had problems that the fake one didn't show. pi-devin-plus runs devin auth login with stdio "inherit" when it finds no Devin sign-in, and the program read magpie's next requests from the host's stdin as its answer ("Login canceled") while writing over its answers; host.js now gives every program started through Bun.spawn and Bun.spawnSync (all of node:child_process goes through them) nothing to read and stderr to write. pi-zcode asks its region with onSelect and options { id, label }, pi's own shape, and checks the id ("bigmodel"); pi.js answered with the label, so the login said "Login cancelled". Packages read their sign-in back from pi's auth.json (readStoredCredential: pi-zcode for its plans, pi-cursor for its token, pi-provider-qoder through the registry, pi-antigravity syncs its accounts through it) and register what they find on session_start (pi-zcode its plans' models, pi-devin-plus and pi-xai-oauth refresh their catalogs), and a provider registered after loading went into a queue nothing read. Now each package is bound with pi's ExtensionRunner as pi's session binds it (bindCore: no UI, tools or session of magpie's), so a later registerProvider reaches the runtime; each pi provider's first account is kept in $PI_CODING_AGENT_DIR/auth.json as pi keeps it, written when it changes; and a provider newly signed in, and each one signed in when the host starts, is sent session_start, after which magpie lists its models again if they changed. A runner that can't be made leaves the packages registered as before. TestPiPackage now has a select answered with an id, a login that starts a program on the terminal, and a session_start that reads the sign-in from pi's auth.json and adds a model; it fails on the old code (the label answered; the model never listed, auth.json empty) and passes. With real accounts in a sandbox HOME: pi-zcode signed in through Z.ai and lists 11 models (none before); its requests reach ZCode but the account's Start Plan answers 200 {"code":1005,"msg":"exceed quota limit"}, which pi-zcode reads as an empty answer (its bug). pi-provider-qoder signed in through Google, 16 models, tool round trips on Kimi-K3 and Efficient. pi-devin-plus signed in, its catalog refreshed on session_start (53 models), tool round trips on swe-2 and claude-opus-5.5; pi-xai-oauth (grok-4.7) and pi-antigravity (gemini-3.8-flash) tool round trips; @rahularya01/pi-cursor answers text (its agent doesn't call a client's tools, upstream). pi-provider-kiro wasn't signed in: it needs pi's terminal, a Builder ID or an IdC login. go vet, builds (darwin, GOOS=linux -tags nogui, GOOS=windows) pass. 2026-10-03 22:51:24 +08:00
yetone 1baad0d2d4 plugin: pi packages are plugins as OpenCode's are — a pi coding agent package (its extensions) adds providers to magpie: their models, their sign-in, renewals and requests (owner: 我希望全面兼容 pi 插件!!!!!!!!!!) — a package is pi's when its package.json has a "pi" manifest, the "pi-package" keyword, or pi-coding-agent among its dependencies (IsPi, mirrored in host.js). Adding or updating one installs @earendil-works/pi-coding-agent beside it, and the plugin host loads every pi package through pi's own loader (pi.js: discoverAndLoadExtensions and ModelRuntime, with the credentials kept in plugin-auth.json), so an extension's registerCommand, registerTool and pi.on are taken and left unused, and its registerProvider calls become magpie providers. A provider's models keep their context, output, images and the reasoning levels their thinkingLevelMap allows; its OAuth login runs as pi runs it, each onPrompt asked in magpie's sign-in in turn, onAuth's page opened, a later prompt being the code pasted back; a provider with apiKey takes a key; magpie renews through refreshToken and saves the result. Requests arrive as Anthropic Messages and go to pi's stream: system and tools, images, tool results, thinking budgets and effort as the nearest level the model has (and the least it allows when it can't turn thinking off: Antigravity's gpt-oss refused requests without one), max_tokens held to the model's; the answer comes back as Messages SSE or JSON with thinking and its signature, redacted thinking, tool calls (their thought signatures kept for the next turn), usage, and a failure before anything is said as the vendor's status with Retry-After. The Plugins page's search also finds pi packages that bring a provider. Tried with the owner's real Antigravity account through pi-antigravity, next to the Grok OpenCode plugin in the same host: 14 models listed, Gemini streaming with usage, Claude Sonnet thinking with signatures, gpt-oss, tool round trips on gemini-3.8-flash, gemini-3.1-pro and claude-sonnet-4-6, a 400 and Antigravity's 429 passed on with their status. A Claude Code turn didn't get through: Antigravity answers 429 to any system prompt with Claude Code's billing header or its "Claude agent" line, through magpie's built-in Antigravity just the same. TestPiPackage (a fake pi package: models, an OAuth login asking twice with a wrong code then a right one, a key, streaming and JSON answers, thinking and tool signatures back, a 429, a renewal) and TestSearchFindsPiPackages pass. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestClaudeRunEndsWhenTheClientLeaves, TestProvidersAnswerWhileListsComeIn and TestSavedButSignedOutClaudeServed, timing tests that pass alone. 2026-10-03 19:12:19 +08:00
yetone 37809ac722 usage: WorkBuddy's credits are said beside their share — on the Routing page, the Usage page and the menu bar, used or left as the share is (#659)
Moody-Sin: the Routing page showed a WorkBuddy account as "已用 71%" only, which for a plan counted in credits says little. A quota window now carries its own count when the vendor counts in amounts — amount of limit, in unit (QuotaWindow, QuotaSpan for quota --json and /v1/magpie/quotas, and the routing trace's Weighed, from the window its share is of: Allowance.Count). WorkBuddy's built-in fills it from the resource summary, and @magpie-community/opencode-workbuddy-auth 0.1.8 tells it from auth.usage (amount / limit / unit, passed through host.js); the workbuddy mover's min is 0.1.8. The Routing page's stage and its list of what each account did, the Usage page's meter, the menu bar's tooltip and the TUI say "3,550 / 5,000 credits · 71% used", or "1,450 / 5,000 credits · 29% left" with Left on (积分 in Chinese); a window counted in shares alone reads as before.

quota-credits.test.cjs (WebKit, en/zh, used/left, Routing and Usage pages) fails on the old assets and passes; TestQuotaWindowCount, TestAllowanceCount, TestTraceTellsTheCount, TestPluginUsage (the count through the host) and TestWorkBuddyAccounts cover the Go side. Tried with the real WorkBuddy and WorkBuddy AI accounts in a sandbox magpie: moved onto the local plugin, /api/usage/quotas gives 33.29 / 3900 and 13.47 / 2250 credits and the Usage page reads "33.29 / 3,900 积分 · 已用 0.9%", "3,866.71 / 3,900 积分 · 剩余 99.1%" after a click on the count; not moved, the built-in gives the same for WorkBuddy. The Routing page with two real accounts of one provider wasn't tried (one account each). go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
2026-10-03 16:45:48 +08:00
yetone 123594489a plugin: a host stopped mid-renewal saves the new token first, and two hosts never write each other's sign-ins away — a stopped host (quit, plugin update, restart) was killed 2 s after stdin closed even while it renewed a sign-in through auth.refresh, after the vendor had spent the old refresh token, so the account was signed out; and while a host restarts the old one and the new one both rewrote all of plugin-auth.json from what each had read, so one host's save could drop the other's accounts or newer token. host.js now tells magpie how many renewals are under way (event "renewing"), and stop gives a host still renewing after the 2 s up to 15 s in all, until the renewal is saved; with none it stops as fast as before. Every change to plugin-auth.json (setAuth, removeAuth, settle, take, auth.set, a renewal's save, and magpie's SignOut when no host runs) is made under plugin-auth.json.lock (O_EXCL, broken when older than 10 s), reading the file afresh and changing only its own accounts; a renewal's save checks under the lock that the stored sign-in is still the one it renewed, so an older host never writes a stale token over a newer one. TestStopLetsARenewalEnd (a 4 s refresh, the host restarted in the middle: old code kept the spent token) and TestTwoHostsKeepEachOthersSignIns (two hosts saving 40 accounts each at once: old code lost 38 of 80) fail on the old code and pass; TestStopWithoutRenewalIsQuick keeps the quick stop. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestProvidersAnswerWhileListsComeIn and TestClaudeSignedOut, timing tests that pass alone and in their packages rerun. Not tried with real plugin accounts. 2026-10-03 15:47:10 +08:00
yetone f0e4a79c06 plugins: auth.refresh — magpie renews a plugin's sign-ins before they run out, once per account at a time (owner: 按你建议的优先级,先做 1 和 2, from the pi plugin study) — each community plugin renewed its own tokens inside fetch, deduping by hand (factory's "two refreshes would spend it twice", minimax's renewing, cline's REFRESH_LEAD, workbuddy's EARLY_MS). A plugin can now give auth.refresh(auth, provider), returning the fields to keep over the old sign-in, and optionally auth.refreshLead (ms, 5 minutes by default). Before an account's loader options, provider.models and auth.usage, the host renews an OAuth account whose expires is that close: requests that find it due share one renewal (vendors that spend a refresh token once never see it twice), waiting up to 15s, and a renewal that takes longer is still saved when it ends (pending keeps the host up). What it gives is merged over the account and saved through setAuth, which clears the loader caches here and in Go, and the account is said renewed; a sign-in replaced or removed meanwhile stands. A throw leaves the account as it was and the request goes with the old token; signIn: "expired" on it marks the account and it isn't tried again until signed in again, any other error is retried after 30s. OpenCode ignores the hook, so plugins keep their in-fetch check for it. Documented in /docs/plugins (#renew, en and zh). TestPluginRenewsSignIns fails without the host change (no renewal). go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass. 2026-10-03 03:08:15 +08:00
yetone 33acb6bca0 providers: each model's credit rate shows by it in the provider's model list and an agent's model picker, Qoder's 0.5× and WorkBuddy's 0.03×, a discount's price before it struck through (01huadalang on Discord: qcoder 或者 workbuddy 能不能实时抓取或者按一段时间抓取能展示列表模型的积分消耗倍率,这样不用打开软件查看,能选择到便宜的模型) — magpie read each model's price only to tell free from not: Qoder's price_factor (06584e15) and WorkBuddy's "credits" ("x0.03", wbFreeCredits), so the lists said FREE or nothing, and picking a cheap model meant opening Qoder or WorkBuddy. catalog.Model now keeps the price, Rate (the credits a request costs, as a multiple; 0 is not listed, or Free) and RateWas (the price before a discount running now), and carries both through provider.Entry, agent.Option and the providers page's models. Qoder (and Qoder CN): Rate is the price_factor; RateWas an active promotion's before_promotion_price_factor, else an original_price_factor above the price (Qwen3.8-Flash: free, 0.1× struck through), and a promotion's 0 that isn't free is its price before times its discount_factor. WorkBuddy and WorkBuddy AI (the CodeBuddy plan; both builds read the same /v3/config): Rate is the credits (wbCredits; x0.00 stays FREE, an unreadable or infinite rate is none). A plugin's model gives it as it gives free: rate and rateWas on the model its provider.models hook lists, a number (0.5) or as the vendor writes it ("x0.03", "0.5×"), which host.js reads (rateOf) into plugin.Model.Rate/RateWas and pluginCatalog keeps; the moved Qoder and WorkBuddy users get it once their plugins send it. The badge is small and grey (the context badge's colours, no uppercase), beside FREE where that shows, its title saying what a request costs (每次请求消耗 0.5× 积分;折扣前 / 限时免费,原价). Rates are as of the provider's last list: a built-in's is fetched at start-up when it has none, on Refresh and when its key changes, a plugin's when its plugins are asked again (start-up, a sign-in, a plugin change); nothing asks on a timer. Kiro, Cursor, ZCode and Devin list no per-model rate magpie reads, so they show none. TestParseModelsRate (qoder) and TestWBCreditsRate (provider) are new and don't build without the change; TestFakePlugin now checks a plugin's rate given as a number and as "x0.03" (0 for both with host.js not reading them). model-rate.test.cjs (WebKit, en and zh: the chips' badges and titles, struck-through prices, grey not green, no stripe, a click picks and scrolls nothing, the picker's badges) fails without the change and passes with it; click-scroll, models-free-only, model-pick and favorite-star pass in WebKit. No request was made to Qoder, WorkBuddy or any vendor. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME, go test ./internal/gui pass. 2026-10-02 22:48:45 +08:00
yetone 987d72c133 providers: Max concurrent requests — over it, an agent's requests wait in line and go out in the order they came (Lemon on Discord: 希望 provider 或者插件能够支持一个参数类似 max_concurrency,如果超过上限,放到无界队列里面依序请求; Memo: ChatGPT/Codex subscriptions' risk control trips at about 5-6 requests at once) — the gateway sent every request to its provider the moment it came, so a few agents (or one agent's subagents) on one subscription could have any number out at the vendor at once, and nothing in magpie could hold them to what an account takes. A provider now has maxConcurrency (providers.json; nil or 0 is no limit, a negative one is saved as 0), and a signed-in agent's accounts keep theirs with their picks. The limit is per key or account, as the gateway already tells them apart (candidate.who(): provider#keyid, provider@user, else the provider), so a subscription pooling several accounts lets each one have that many out. Before a try is forwarded it takes a slot in that lane (internal/gateway/concurrency.go); with none free it waits in a FIFO queue with no bound, and is let in, first come first served, as one before it ends. The slot is held for the whole try (attempt reads the vendor's reply to its end or until the agent hangs up, and its body is closed when it returns) and given back once whatever happens (sync.Once), and a lane with nothing out and no one waiting is dropped. An agent that hangs up while waiting leaves the queue at once, is never sent and takes no slot (a slot granted in the same instant is handed on), and goes the way of any canceled request (499, nothing recorded). Waiting is not a failure: a request in line stays for its provider and is not moved on to a fallback or another group member, the account isn't rested, and the time it waited is told in its route's try (Try.Queued, ms). A plugin can say how many each of its accounts takes, as its own field: the auth hook's maxConcurrency, else package.json's magpie.maxConcurrency (a whole number over 0, at most 1000); the user's setting on the provider goes over it. GET /v1/magpie/concurrency (local or shared callers, as quotas) gives each lane's out, waiting and limit. The provider editor (keys, custom, signed-in accounts) has "Max concurrent requests" / 最大并发请求数 under Proxy, "超出时排队,按顺序发出;0 或留空为不限制" below it (with a plugin's own number as its placeholder), refuses anything but a whole number from 0 to 1000 before anything is posted, and an empty field saves none (the plugin's, if it has one); a save that doesn't send the field keeps what the provider had. No coloured left border, and a click in the field leaves the page where it is. TestMaxConcurrency (limit 2, five streams: never more than 2 at the vendor, 3 waiting, let in a..e in order; one canceled while waiting leaves the queue, takes no slot and never reaches the vendor; slots given back when each stream ends and when an agent hangs up mid-stream; the next one served at once) fails without the change (all five reach the vendor at once, no lane), as do TestLanesLimitLifted, TestProviderConcurrency and the updated TestPluginOwnIconAndKeyHint (the auth hook's 3, package.json's 5 when the hook's is 2.5); provider-concurrency.test.cjs (chromium and webkit, en and zh: typed and saved, 2.5 refused with nothing posted, empty saved as null, an account opening on its 5 and saving 0, a plugin's 4 as the placeholder, every string translated, no stripe, no scroll on click) passes. No vendor was asked. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... (under a temp HOME) and go test -race on the new tests pass. provider-proxy.test.cjs's account test fails the same on HEAD without the change (its editor redrawn under it). 2026-10-02 13:38:23 +08:00
yetone a999770531 plugins: a plugin's subscription does what a built-in's does, in seven places it didn't (the user: 要保证插件订阅 provider 跟内置的订阅 provider所有逻辑都要一模一样) — an installed plugin's provider id is kept for it signed in or not, as a built-in's is: a provider of the user's own isn't saved on it, renamed onto it or imported as it (freeID gives id-2). A plugin's account whose plan lacks the model is tried after the ones that list it, as a Codex Free account behind a Plus, by the list the plugin told for it. Cursor's plugin has fast mode where the built-in Cursor has it: a model with a -fast one is saved :fast in a group and sent service_tier "priority", one without is refused. Removing a plugin's provider hides it as a built-in's is, its accounts kept and shown again from Hidden or by signing in, rather than signing every account out ("stays signed in" said so already). magpie accounts lists, adds, switches and forgets a plugin's accounts by its provider's id or name. Grok's plugin beside the built-in Grok (grok-plugin) searches by itself as Grok does. A plugin's plan is named as its built-in's ("Cursor Pro", "SuperGrok"), and a plugin's resets are shown but have no Use button or Auto-use, which spent Codex's reset of the same user. TestPluginIDReservedSignedOut, TestPluginAccountLackingTheModelGoesLast, TestCursorPluginGroupFast, TestAccountsOfAPlugin, TestGrokPluginSearchesItself, plugin-plan and codex-auto-reset fail without the change; TestPluginSignIn and TestPluginAccounts now expect a removed plugin hidden. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestClaudeSignedOut failed once under load, passes alone); the gateway, provider and plugin suites and TestAccountsOfAPlugin pass on Windows, the plugin tests on Linux. 2026-10-01 20:26:55 +08:00
yetone 2a6ba1a20f workbuddy: an added account is told from the others by its uid, and the one WorkBuddy is signed in to stays once WorkBuddy moves on (#413: 添加已提前登录的 WorkBuddy 账号会顶掉默认第一个账号) — magpie matched a new sign-in to a listed account by its name, the nickname: another account of the same nickname replaced the one listed, or was taken for WorkBuddy's own and let go with "signed in". And WorkBuddy's own account is only read from WorkBuddy's file, so signing WorkBuddy in to the next account (to pick it on WorkBuddy's page from magpie) renamed that row, the last account gone, and the one picked, being WorkBuddy's own, was let go: one account at a time. A WorkBuddy or WorkBuddy AI sign-in is now matched by its uid (one of the same name whose uid isn't known is still taken to be it, #155); another account of the same name is added as "name (end of uid)"; the same account renews its sign-in where it is listed, and the one WorkBuddy is signed in to becomes magpie's own sign-in of it, first as it was, so it stays when WorkBuddy signs in to another. The sign-in says so: "该账号已在列表中,已更新登录" in the app, "is already listed — its sign-in was renewed" from magpie accounts add. On the plugin, the host no longer takes two sign-ins of one account id for one account when their uids differ. No request header or user agent changes. TestWorkBuddyAddSameNicknameAsOwn, TestWorkBuddyAddSameNicknameTwice, TestWorkBuddyAddWhatWorkBuddyIsSignedInTo, TestPluginSameNameOtherUID and signin-again.test.cjs fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; signin-again, click-scroll, old-webkit pass. 2026-10-01 19:38:36 +08:00
yetone 4f9dcb39e5 plugins: a plugin subscription goes through every path a built-in one does (the user: 要保证插件订阅 provider 跟内置的订阅 provider所有逻辑都要一模一样) — a SOCKS5 proxy on a plugin provider or account now carries its requests: Bun speaks http(s) proxies only, so magpie stands a loopback http proxy in front of the SOCKS one, and a proxy that refuses says so as Go's transport does. A plugin's model list and its loader are asked through the account's own proxy, as a built-in's list is fetched through it. The Grok plugin draws and makes videos at Grok's Imagine API through the plugin, and keeps Codex's namespaced tools, as the built-in does; an official plugin beside its built-in (kiro-plugin, zcode-plugin) counts tokens by estimate as the built-in and the moved plugin do. FetchNew no longer re-lists plugin providers every ten minutes, their list being told by the plugin host; a plugin account's usage is cached under one name whichever way it is asked for; and a fetch hook that throws on its sign-in (a refused refresh) marks the account lapsed, as a built-in's refused refresh does. TestPluginSOCKSProxy, TestPluginModelListThroughAccountProxy, TestGrokPluginDraws, TestPluginBesideBuiltinCountsByEstimate, TestFetchNewLeavesListedPlugins, TestPluginUsageReadOnceWhicheverName and TestPluginFetchSignInExpiredLapses fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (the known load flakes pass alone); internal/plugin, netproxy and the plugin/proxy tests of provider and gateway pass on Linux and Windows. 2026-10-01 19:07:12 +08:00
yetone 8cd520662f plugin: a plugin can say the list it hands back is a fallback, and magpie keeps the one told last, as a built-in does — Command Code's Go accounts and ZCode give a table of their own when their vendor's list can't be had, not the defaults they were given, so magpie couldn't tell it from a list read and a hiccup swapped the models for the table. A models hook's list marked Symbol.for("magpie.fellBack") (OpenCode never sees a symbol's key) now counts as fallen back; commandcode 0.1.7 and zcode 0.1.6 mark theirs. TestFallenBackListKeepsTheLastOne/throws=own fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/plugin ./internal/provider pass; internal/plugin passes on Windows and Linux. 2026-10-01 17:06:17 +08:00
yetone ca29a7badd plugin: a plugin whose models hook throws keeps the list it told last, as a built-in does — 9abf441d kept the last list for a hook that gave its defaults back, but Cursor's, Grok's and Devin's hooks throw when their vendor fails, and an account past the first whose hook threw was taken to have all the provider's models; a vendor's hiccup still swapped the list for the plugin's defaults. A hook that throws now marks its listing fallen back, the provider's and each account's, and magpie keeps the list told last in its place. TestFallenBackListKeepsTheLastOne/throws=1 fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; internal/plugin passes on Windows, internal/plugin and internal/provider on Linux. 2026-10-01 16:56:41 +08:00
yetone 181eefdc31 plugins: built-in Devin shows its usage as the Devin plugin does, and a Grok account whose sign-in has expired no longer stops Grok's move (goal: 插件订阅 provider 跟内置的所有逻辑都要一模一样) — the plugin showed a Devin account's day and week quotas, its ACUs and extra balance from GetUserStatus while the built-in showed no usage at all, so routing had no allowance to fail over on until the move; the built-in now reads the same and fills the same windows (1 day, 7 days, ACUs aside), plan end and balance, and a refused key says to sign in again. A move checks each account through the plugin and stopped when one failed; Grok's plugin says an expired sign-in only in words ("Grok's sign-in has expired"), so one dead account kept every Grok account on the built-in. Such an account now moves along untried and unmarked, as a built-in Grok account was never marked; any other failure still stops the move. TestDevinUsage and TestMoveExpiredInWords fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass. 2026-10-01 12:24:45 +08:00
yetone 4ba51bb627 plugin: a plugin names its own icon and the key field's title and hint (Lemon on Discord) — a plugin's provider took only the market's icon, and an api method's key field always read "<name> API key" with no hint. auth.icon (or package.json "magpie": {"icon"}) as an https URL, fetched once through the public-only icon path, or a data:image URI now comes before the market icon; an api method's label titles the key field as in OpenCode, and a new placeholder hints it, in the window and in magpie plugin login. README has a For plugin authors section. TestPluginOwnIcon, TestPluginOwnIconAndKeyHint and plugin-key-hint.test.cjs (Chromium, WebKit, en/zh) fail without the change. 2026-10-01 11:42:45 +08:00
yetone 77c2ad12fa migrate: a move that fails leaves nothing behind and says why, and a move only goes through on an account the plugin proved works (要保证插件订阅 provider 跟内置的订阅 provider所有逻辑都要一模一样 / 记得把整套迁移逻辑做得丝滑优雅精致优美简单方便易用) — a failed move left the plugin it had installed (a Plugins row saying signed out) and hidden plugin:<id> rows in logins.json, and a move checked only that the plugin loaded, so an account the vendor refused or couldn't be reached through moved as if it worked. The plugin a move installed now goes with a failed move, and with a move back when it serves nothing else; the stray rows go; the check reads each account's usage as well as its models, and an account the vendor refuses is marked and moves untried, as a lapsed one does, while one whose vendor can't be reached at all fails the move with nothing changed. A move with every account lapsed says so before installing anything. Failures carry a code (offline, install, lapsed, unserved, account) for the page to say in its language, with the models named, not their ids. A moved provider shows the built-in's API host (magpie providers showed the plugin's id), and Zed's accounts take their model list with them, so the plugin can serve it until it reads its own. TestMoveNeedsAWorkingAccount, TestMoveTidiesItsPlugin, TestMoveProvesEachAccount, TestMovedShowsBuiltinHost and TestZedMoveCarriesItsModels fail without the change. go vet, go build -tags nogui, go test -tags nogui ./... pass. 2026-10-01 11:15:23 +08:00
yetone 813fdcb2e5 migrate: a moved built-in keeps its models' reasoning levels, its own account's plan and one own row, and the move checks each account through its proxy (要保证插件订阅 provider 跟内置的订阅 provider所有逻辑都要一模一样) — Command Code's, Grok's and WorkBuddy's models their vendor gives no levels for lost the ones the built-in borrowed from their maker; moved, they borrow them again. Devin's own account moved without the plan the Devin CLI says, which the built-in showed. The agent signing in to another account while moved wrote a new own row for the built-in, which came back beside the one set aside as a second own account; nothing is written while moved, and going back keeps the one. And the move's check of an account asked the vendor directly, not through the proxy its requests take. Found walking Command Code, Grok, Devin, ZCode and WorkBuddy through sandbox moves. TestMovedKeepsEfforts, TestMovedOwnRowNotWritten, TestDevinOwnMovesWithPlan and TestPluginProxy fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass. 2026-10-01 07:04:40 +08:00
yetone 78c32292e4 plugins: a models hook can say its account's sign-in expired, as a built-in whose model list the vendor refused marked the account (要保证插件订阅 provider 跟内置的订阅 provider所有逻辑都要一模一样) — a refusal in provider.models was only logged, so an account Zed or Qoder turned away while listing its models stayed unmarked where the built-in marked it. An error thrown with signIn: expired, kept or renewed now tells magpie, which reads it as it does an answer's X-Magpie-Sign-In. TestPluginModelsSayExpired fails without the change. go vet, go build -tags nogui (GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/provider ./internal/plugin ./internal/gateway pass. 2026-10-01 06:12:11 +08:00
yetone 1fa21850a3 plugins: a plugin can say its sign-in renewed, and what a usage read means for it (要保证插件跟内置的所有逻辑都要一模一样) — a built-in whose sign-in renewed took the lapse mark off whatever the request then met, and a plugin could only clear it with a success: X-Magpie-Sign-In: renewed now clears it on any status. A usage read marked a plugin account whenever its error said to sign in again and cleared it on any clean read, where Qoder's, Kiro's and others' built-ins marked none from usage: auth.usage may now say signIn: expired, kept or renewed, taken as the header is; one that doesn't say keeps the old reading. TestPluginSaysSignIn and TestPluginUsageLapse fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/gateway ./internal/provider ./internal/plugin pass. 2026-10-01 04:46:24 +08:00
yetone d4817bea56 plugin: a moved provider goes through its own proxy, and each account through its own (#237) — a plugin's fetches and usage went through magpie's global proxy whatever the provider or the account was set to. Go now hands the host the proxy ctx names, and host.js gives every fetch made for it Bun's proxy option; "direct" goes through none. Bun reads *_PROXY once and puts every fetch through them, so the host is started with them as MAGPIE_*_PROXY and applies magpie's itself to a fetch with no proxy of its own. A plugin sign-in's usage looks its proxy up under the provider's id, not "plugin:<id>". TestPluginProxy and TestMovedLoginProxy fail without the change. 2026-10-01 03:42:06 +08:00
yetone 8756ece70c plugins: a plugin's model is priced only when the plugin prices it, and says whether it takes images when the plugin says — OpenCode prices a model it has no price for at 0, as the plugins give their plans' models, and a 0 price made each plugin model the cheapest where magpie picks by price (a small model to search or describe images with, one to draw with) and showed it free. A price of 0 is none now. A model the plugin (or models.dev) says takes images, or doesn't, carries that answer (ImageInput), as a built-in's list did; one it said nothing of stays unknown. TestPluginCatalogPriceAndImages and TestFakePlugin fail without the change. 2026-10-01 03:41:46 +08:00
yetone c1d86e2ed1 gateway: a moved provider counts a prompt's tokens by estimate, as its built-in did — count_tokens estimated for Cursor, Grok, Devin, Kiro, Qoder, Zed, Factory and a Command Code Go key by their agent, which a moved one's is "plugin", so the count went to the vendor through the plugin (Factory's to its API). A plugin's provider moved from one of them estimates; Command Code's whatever its plan, as only the plugin knows a Go key. The fake plugin takes $FAKE_ID to stand as a built-in's. TestMovedCountTokensEstimated fails without the change. 2026-10-01 03:41:45 +08:00
yetone d8f2e17dd7 plugins: a plugin's fetch gets its body as a string, as OpenCode hands it — the host passed a Buffer, and the plugins that reshape a request (Grok, WorkBuddy, MiMo) touch only a string body, so a moved provider's requests went out as the gateway built them. A UTF-8 body now goes as a string; one that isn't stays bytes. TestFakePlugin fails without the change. 2026-10-01 03:41:42 +08:00
yetone 566ae09db1 plugins: a moved provider's accounts keep what the built-in's had (你确保插件版的订阅 provider 和内置的一模一样了吗) — each account of a plugin provider has its own plan's models, not the first account's; a move carries each account's plan and a lapsed mark; a usage read that finds the sign-in gone marks the account and a clean one clears it, a network failure shows the last reading; a plugin that fails to load keeps its providers rather than taking them away; Kiro's API key is named and an OAuth account beside it stays off as in the built-in; Command Code Plan keeps its name. TestUnloadedPluginKeepsProviders, TestPluginUsageLapse, TestPluginAccountModels, TestMovedProviderName, TestKiroMover and the move test fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/provider ./internal/plugin ./internal/gateway pass. 2026-10-01 03:27:47 +08:00
yetone 1ce05f0270 plugins: a plugin's models show as the built-in's did (插件版…保持一模一样) — a model a plugin says its plan serves at no cost (WorkBuddy's x0.00 credits) is marked free, as the built-in marked it; Cursor's plugin models not named 1M hold what the catalog knows their base to (GPT-4o 128K, Kimi K2 131K), not Cursor's 200K, as the built-in's list; ZCode's own account moves marked as ZCode's, for the plugin (0.1.2) to read ZCode's own sign-in for each request and follow its switches, and comes back as ZCode's own with nothing saved. TestPluginUsage (free), TestCursorPluginContext and TestZCodeMover fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass. 2026-10-01 01:18:04 +08:00
yetone ee5b1b7d44 plugins: a plugin's account behaves as its built-in's (the user: 插件版的订阅 provider 和内置的…保持一模一样了吗) — a plugin's failed sign-in now says why ("the sign-in failed: the sign-in page expired") instead of a bare failure; a plugin account the provider refuses with 401 is marked lapsed, "<user>'s <provider> sign-in has expired — sign in again", beside the account and on its usage card as a built-in's is, and the mark goes when it answers again or is signed in anew; a Devin or Command Code account taken from the agent's CLI moves to the plugin marked as the CLI's and back as the agent's own, so the one it follows stays the one the CLI is signed in to. TestPluginProvider (lapsed), TestMoveSideState (cli) and TestPluginSignIn (reason) fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass. 2026-10-01 01:00:07 +08:00
yetone f5f10bda4d plugins: a plugin's accounts show their allowance as the built-ins' do (the user: 所有插件要完整支持显示额度!跟内置一模一样!) — a plugin's auth.usage now tells plan, user, until, renew, balance, resets (count, until, by window, five hours, weekly) and windows (used past 100 when overspent, resets at/in, display, span, the models a window counts or leaves out, aside), and magpie shows them on the usage page, in the menu bar, beside each account and to the gateway, which passes over an account used up for the model asked; a built-in moved onto its plugin keeps its card's name and icon. Also: Command Code's subscription endpoint answering 200 {"success":false} was read as "No plan" by the built-in (and the plugin), it is now unread; a moved Devin was listed twice, the built-in still finding the Devin CLI's own sign-in; and moving back left the plugin's rows in logins.json, listed as empty accounts. Checked in a sandbox HOME with real Command Code and Devin accounts moved onto the community plugins (0.1.1): GOAT, its period, 5 hours / Weekly / Credits for Command Code, Teams, balance and Daily/Weekly for Devin, the same on the built-in after moving back, no plugin rows left. TestPluginUsage, TestCommandCodeSubscriptionUnread and TestMove (the plugin's rows after moving back) fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestClaudeSignedOut and TestUserPath timed out once at load 22, pass alone). 2026-09-30 22:58:56 +08:00
yetone 3bec6c0e40 providers: a built-in subscription moves onto its community plugin and back, its accounts with it (the user: 慢慢下掉内置的那些 Subscription Provider…不要让用户一升级完之后,突然发现他的所有服务都不可用了) — eleven built-ins (Zed, Qoder, Factory, MiMo, Devin, Grok, Command Code Plan, Cursor, Kiro, WorkBuddy, ZCode) each get a mover to their @magpie-community package. A move installs the plugin (updated when older than the mover needs), hands it every account in the plugin's shape, checks the plugin lists models for them, and only then lets the plugin serve the same provider id, so agents' model ids don't change; any failure rolls back and the built-in keeps serving. The built-in's accounts are kept as a backup, and "Run built-in again" (magpie plugin move-back <id>) takes the plugin's current sign-ins back, never copying a rotating token: they are taken out of the plugin in one step, and an account the built-in renewed meanwhile keeps its newer token. An account only the plugin can hold (Cursor's browser sign-in) stays with it. The provider editor shows "Runs on" with the button, a failed move's reason, and the CLI has magpie plugin move / move-back. Automatic moves (Retiring) are wired in but the list stays empty until the packages are on npm. Checked with real accounts in a sandbox HOME: Command Code Plan and Devin moved, answered a chat through the gateway on the plugin, moved back and answered again on the built-in. TestMove*, TestPutBackKeepsNewer, the movers' round-trip tests and plugin-move.test.cjs (Chromium, WebKit, en/zh) are new. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; click-scroll, old-webkit pass. 2026-09-30 21:21:58 +08:00
yetone 52c8e838d2 providers, sign-in, tray, plugins: four of StringKe's reports on Discord — (1) Removing a provider left its editor open and listed, and a second Remove said there was no such provider: the removal had been made, but moving the agents off its models failed on one agent whose config couldn't be written, and Reseat returned that as the change's error; now such an agent is a move with its error ("still on X, not moved: …", a warning in the panel, a line in the CLI and TUI), the others are moved, and the panel, told a delete failed, re-reads the list and closes the editor when the provider is gone. (2) The sign-in waiting sheet shows the sign-in link itself, selected whole on a click, with a copy button, for a browser other than the one that opens. (3) A subscription with several accounts offers "Account in use" among the menu bar's usages, following the account the gateway goes to first. (4) The Antigravity plugin's sign-in never started: magpie called every plugin's authorize with an inputs object, the way OpenCode's CLI does, and opencode-antigravity-auth then asked its Project ID on the terminal — magpie's stdin — until the minute ran out; inputs now go only to a method with prompts, as OpenCode's TUI passes them (checked against the real package under Bun in a sandbox: the Google sign-in URL comes back at once). TestReseatUnmovable, TestTrayInUse, TestInUseOf, TestAuthorizeNoPrompts, provider-remove.test.cjs, signin-link.test.cjs and tray-inuse.test.cjs fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; click-scroll, old-webkit, tray-usages, plugin-signin, signin-callback, factory-sub pass. 2026-09-30 20:45:23 +08:00
yetone b63a0f1fd5 plugins: a plugin's provider takes more than one account, as a built-in subscription does (the user: 插件当然要支持多账号了) — a plugin kept one sign-in per provider, and signing in again replaced it. Now each sign-in goes beside the others: plugin-auth.json keeps the first under the provider's id, as OpenCode does (so what is signed in already stays the first account), the rest under id#slot; logins.json lists them as it lists the built-ins' accounts, so the editor shows them all, one first, the others ticked on behind it or off, any made first, any removed (which signs it out). The host runs every request in its account's scope, so a plugin's getAuth, client.auth.set (a refreshed token), loader and provider.models see that account alone; a sign-in to an account already there (the same id, else the same secret) replaces that one's rather than listing it twice. The gateway spreads and fails over across the accounts on, as for the built-ins. Accounts with no id are told apart by the end of their key ("API key …abcd"), else by their slot. Also on the Devin page: the header shows the plugin's icon, and Cognition's opencode-devin is off the market (its gateway answers 404 model_not_found for models and chat). TestPluginAccounts (plugin: per-account requests and refreshes, dedupe, sign-out; provider: first, on/off, switch, forget, labels), TestPluginProvider (gateway: a 429 on the first account goes on the second) and plugin-signin.test.cjs (add a second account in the editor, make it first; fails without the change) are new. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; plugin-signin, plugin-market, old-webkit, click-scroll pass. 2026-09-30 19:58:22 +08:00
yetone a31f97b30d plugin: OpenCode's provider plugins sign in to subscriptions in magpie — an npm package OpenCode users install for a plan (its auth hook: loader, methods, oauth authorize/callback, api keys, chat.headers) runs in a Bun host magpie downloads the first time a plugin needs it, speaking JSON-RPC over stdio; its sign-ins are kept in plugin-auth.json (0600). A provider a plugin signs in to is a provider like the others: its models the plugin lists, each spoken to on the API its AI SDK package speaks (anthropic → Messages, openai/azure → Responses, google → Gemini, Copilot's GPT-5+ → Responses, else chat completions), its requests carried by the plugin's fetch to the base URL its loader gives; an id magpie already has is <id>-plugin. CLI: magpie plugin [list|add|rm|update|on|off|login|logout]. App: Settings → Plugins adds, updates, switches off and removes them and says why one didn't load; Add provider → From plugins signs in with the plugin's methods, its questions (picks and checked text), the browser and a pasted code or a key, followed as the built-in sign-ins are; removing the account signs out. The built-in subscriptions stay. TestFakePlugin, TestPluginProvider (chat, Anthropic and Gemini models through the gateway) and TestPluginSignIn (a wrong code fails, the right one signs in, removing signs out, a key) run a fake plugin on Bun; plugin-signin.test.cjs (Chromium, WebKit, en/zh) covers the add sheet and Settings. signin-callback, add-sheet, zed-sub, account-proxy, own-signin-remove, routing-signin, click-scroll GUI tests pass; go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestUserPath, TestClaudeSignedOut, TestClaudeAccountNamedAsItsLogin, TestClaudeIdentityServedWhileAsked, known flakes under load, pass alone). Not yet tried with a real plugin and account. 2026-09-30 16:44:32 +08:00