f7cf98ee3afa1d15c19b9778ef8f44500c92c276
121
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d1fca72f69 |
library: the Library's MCP servers go to WorkBuddy and CodeBuddy Code (#1266)
WorkBuddy had skills only, and CodeBuddy Code had no library target at all,
so their MCP chips could not be switched on.
WorkBuddy 5.5.6 (app.asar) reads ConnectorService.customMcpConfigPath:
$WORKBUDDY_CONFIG_DIR, then $CODEBUDDY_CONFIG_DIR, then ~/.workbuddy, plus
mcp.json, as {"mcpServers": {...}} in Claude's shape. It connects a server
only once the user has switched it on in its MCP settings
(mcp-approvals.json, module.app-server.js). magpie doesn't write that
approval. Instead, the servers page tells the user to switch it on once,
in en/zh/zh-TW/ja/de.
CodeBuddy Code 2.162.0 (PathUtils.resolveMcpFilePath) reads the first of
<dir>/.mcp.json, <dir>/mcp.json and <base>/.codebuddy.json that exists.
<dir> is $CODEBUDDY_CONFIG_DIR or ~/.codebuddy. The file holds mcpServers
in Claude's shape, and ${NAME} is expanded (McpConfigEnvExpandService).
User scope needs no approval. Skills go in <dir>/skills.
Verified: the real CodeBuddy Code 2.162.0 CLI, in a sandbox HOME, read
SaveServer's file back with `mcp get` and `mcp list`. That covered stdio
with ${MY_TOKEN} expanded, http and sse, and the user's own entry and
disabledMcpServers were kept. WorkBuddy.app was not run; its side is
from its code.
Tests: TestAgentMCPFiles (4 new cases) and TestCodeBuddyMCPFile fail
without targets.go. library-workbuddy-trust fails without library.js.
Run: go test ./internal/library ./internal/gui; go vet for darwin, linux
and windows; in Chromium and WebKit: gui-ja, gui-de, gui-zh-tw, i18n-keys,
click-scroll and all library-* GUI tests.
|
||
|
|
e5e87d980a |
支持 Ante:把 magpie 作为 provider 写进 ~/.ante/catalog.json (#1210)
* agent: Ante takes magpie as a provider in ~/.ante/catalog.json Ante (antigma.ai, AntigmaLabs/ante) is asked for in #1179. Its catalog is ~/.ante/catalog.json ($ANTE_HOME moves the folder), merged on top of the providers it ships: a providers map keyed by the id --provider takes, each with base_url, wire_style (AnthropicMessage, OpenAiCompatible, OpenAiResponse or Gemini), an optional auth, and preferred_models, the ids its model picker shows (docs.antigma.ai, reference/catalog-reference). magpie adds the entry "magpie" at the gateway's /v1 on OpenAiCompatible, which is the wire style Ante speaks Chat Completions with and what the gateway takes every model on, and lists magpie's models in its preferred_models. The user's own providers and the models section are left as they are, and magpie's entry goes again when magpie steps out; a file magpie made goes with it. No auth block is written. The gateway listens on loopback and lets any token in from this machine (gateway.Token's own comment, lan.go's identifyCaller: "Loopback takes any token"), and Ante counts a provider with no auth as authenticated. A bearer env_key would mean putting the gateway's key into Ante's environment, which the user would have to do themselves -- the same decision Empryo's entry makes, which sends no key of the user's either. A gateway shared on the local network is another matter, and the same one every agent magpie wires has. Two things are deliberately left off a model's entry. A routing group is not told what it reasons at: which member answers, and what that member takes, is the group's own to decide per turn, and magpieModels sets no APIs for a group for the same reason. And support_vision is never written: Ante takes a model as seeing images unless told otherwise, and what magpie knows is only as good as a provider's own list -- a relay's models are often not described at all, so writing false would have Ante drop the reader's images before the gateway, which describes them to a model that can't see them itself (provider.Described). No other agent magpie wires declares it either. Ante ships macOS and Linux builds alone and suggests WSL on Windows, so the Windows machine's Ante is usually the one in a distro: wslKinds gains it beside Empryo, which also means the gateway address Ante is pointed at is the one that distro reaches magpie on (place.base), and a distro not known to be in mirrored networking gets the notice it already gets for the others. TestAnte covers a user's own providers and models section kept as they are, magpie's entry added and taken away, Sync repointing it, and a file magpie made going again. TestAnteKeepsTheUsersFile covers a catalog magpie can't parse costing magpie's provider and not the user's. TestAnteGroupSaysNoCapab ilities covers a group declaring neither effort nor support_vision. TestAnteEfforts covers the ladder: Ante's has no "none" or "minimal", its two lowest, so both are its "min", and the rungs come out in Ante's order whatever order magpie lists them in. Tried with a real Ante 0.2.9 in WSL Ubuntu: magpie wrote the catalog, and `ante catalog` then read 16 providers with magpie's among them, 39 models, and Ante filled in support_vision: true for each itself. Ante then ran a real turn through the gateway — `ante --provider magpie --model group/auto-deepseek-v4-1-flash -p …` answered, and magpie's own usage log has the request (ep /v1/chat/completions, req the group's id, served by one of its members, out 10) — and so did a second model on another provider. The gateway was reached over the Windows host's address from the distro (172.17.128.1), this machine's own being on 127.0.0.1 alone, which is what notMirrored tells the user about. go vet, gofmt -l and go build (linux nogui, windows) pass. go test -tags nogui ./internal/agent: 12 failures here, the same 12 on unmodified main (the Windows ones AGENTS.md lists). ./internal/gui and ./internal/provider: TestGatewayFixQuitsOnlyAMagpie, TestPurposeAssetMatchesUsage, TestUpdateThatWontStart and TestLoginUsageClaudeAskedMeanwhile, all four on unmodified main here too. docs/reference.md's adapter table gains Ante. * agent: Ante keeps the user's own keys, reads ANTE_HOME, sends no effort it wasn't told to, and starts Ante on magpie (#1210) Seven points from the review, each with a test that fails without it. 1. ANTE_HOME was in the file's own comment and in docs/reference.md but never read: the path was fixed to $HOME/.ante, so a user who set it had magpie write a catalog Ante does not look at. anteDir reads it through place.getenv, the way MiniMax Code reads MINIMAX_DATA_DIR, so a distro's own ANTE_HOME - which magpie can't read - still means ~/.ante there. "ANTE_HOME" joins agentenv.Vars so a sandbox clears it. 2. providers.magpie was written whole, so every key of the user's that magpie doesn't write - the auth block a gateway shared on the LAN or reached from WSL needs to send a key, http_headers, extra_body, stream_idle_timeout_secs, thinking_display - went at the next sync, and an unchanged catalog was rewritten every round. It goes through theirsKept and syncJSON now, as #1103 did for the others: the user's keys in magpie's entry stay, preferred_models is magpie's list alone, and a file that says the same as it does is left as it is. 3. The two files were written in separate steps, so a change that failed halfway left Ante's catalog without magpie's entry and its settings still naming magpie. Each field's Set and its Sync are wrapped in atomic(), as empryo, minimax and kimi do. 4. The comment said Ante's "min" was the rung it sends no effort at, and folded both magpie's "none" and its "minimal" into it. Ante's reference says what min is: "OpenAI-compatible providers send the selected level as the same-named reasoning_effort (min is sent as minimal)" - so min is a sent level, and magpie's "none" has no rung of Ante's to fold into. Ante's "min" is magpie's "minimal"; a model magpie knows only "none" of comes out as the empty list, which is Ante's own way of saying it takes no effort setting at all. 5. The default was the highest rung the model takes, so every model magpie listed ran at Ante's slowest and dearest level. No effort is written: "Omit for models with no reasoning knob (Ante then sends no effort parameter and hides the effort slider)". 6. Having magpie in the catalog never meant Ante used it. What a session starts on is the provider and the model of ~/.ante/settings.json; with neither set Ante "uses the first authenticated provider in catalog order", and Ante's own presets come before magpie's entry - so while the user has any of those signed in, that is the one picked and magpie is not. (Measured: nothing named in settings, two keyless providers ordered aaa_other then magpie, and the request went to aaa_other's port; settings provider=magpie and it went to magpie's.) magpie sets that pair now and saves what it found there, so Disconnect hands it back; a provider the user picks in Ante's own /providers while magpie is wired is theirs and stands, and Check says so instead of keeping quiet. Ante's settings name the model apart from the provider, so magpie writes the catalog id it put in preferred_models, un-prefixed, and reads a bare id as magpie's only beside magpie's provider. Tests lean on the package's own testenv, not a real ~/.ante. TestAnteSyncKeepsTheUsersKeys is modelled on ad3b7fe5's TestAirProviderWritesKeepCustomFields and goes both ways - sync and pick - and wants an unchanged file to keep its mtime. Tried with a real Ante 0.2.9 in WSL Ubuntu on this machine (Ante ships macOS and Linux builds alone; there is no Windows Ante here). The strongest of these is the whole of it: magpie's own Go code - ante(home).Connect(), then the model field - wrote a catalog.json and settings.json into a temp folder, that folder was handed to the real Ante as ANTE_HOME, and Ante ran a turn through it with no --provider, no --model and nothing typed by hand. - `ante doctor` read `home ok /tmp/ante-e2e` and `16 providers (1 authed), 108 models` from ANTE_HOME, and magpie's entry came back with the base_url it was given: ANTE_HOME really moves the folder, and a base_url already ending in /v1 is not doubled (the request went to /v1/chat/completions). - What a session starts on is settings.json's provider, measured against two keyless providers each answering on its own port, so the logged port names the one Ante asked. Catalog ordered aaa_other then magpie, nothing named in settings: Ante asked 44331, aaa_other - the first authenticated in catalog order, exactly the review's "很可能选不到 magpie". settings provider=magpie: it asked 44220, magpie. settings provider=aaa_other: 44331 again, so a pick the user makes in Ante's own /providers overrules what magpie wrote, which is why stepping out leaves it rather than handing back the one magpie found. And with magpie the only authenticated provider, auto-detection does reach it - so the catalog entry is not useless, it is simply not enough while the user has any other provider signed in, which is every real install. - What Ante sends for effort, from the same files: no reasoning_effort at all for a model with no effort written (item 5, as its reference says omitting it sends none); `--effort min` on a model sent `reasoning_effort: "minimal"` - Ante's min is magpie's minimal, exactly item 4; and a model Ante was told `"supported_efforts": []` for sent no reasoning_effort even when asked `--effort low`, which is what a none-only model now gets instead of a level that refuses it. - The user's own keys inside magpie's entry are ones Ante reads, not decoration: `ante catalog` hands back the entry's auth, http_headers, extra_body, stream_idle_timeout_secs and thinking_display unchanged, and a bearer env_key there makes `ante doctor` count the provider as the one authed. That is item 2. - The no-auth decision holds on Ante's side, isolated by doctor's counts: Ante's own presets alone read `15 providers (0 authed)`; a magpie with no auth block reads `(1 authed)`; the same magpie given a bearer env_key that isn't set drops back to `(0 authed)`; with the key set, `(1 authed)`. So an entry with no auth is authenticated and one that asks for a key the user doesn't have is not - writing an env_key, which the first version's comment considered, would have made magpie the one provider Ante refuses to auto-detect. - Ante fills `support_vision: true` in for itself, as the PR's own note said, and a routing group declared with no capabilities at all still worked. Not covered by that run: the stand-in server answered inside the distro, so this proves Ante's config handling end to end, not a live magpie gateway relay (the first commit's message records a real turn through one). Every probe used ANTE_HOME into a temp folder; this machine's real ~/.ante gained no catalog.json or settings.json. What each fix failed without it, one test each: taking ANTE_HOME out -> TestAnteHome "$ANTE_HOME ignored"; theirsKept out -> "the user's auth lost or changed"; syncJSON out -> "an unchanged catalog was rewritten"; atomic out -> "settings.json kept half of a change that failed"; the old min mapping -> "anteEfforts([minimal high]) = [high], want [min high]"; a written default -> "deepseek/pro: effort written"; settings never pointed at magpie -> TestAnte; a model never written -> TestAnteModelField; Check left quiet -> "moved off magpie: \"\""; handing magpie back over a pick the user made while wired -> TestAnteKeepsWhatTheUserPickedWhileWired; never restoring the model -> TestAnteConnectDisconnect "not put back"; Connect picking the model field instead of magpie as provider -> "Connect left the user's model beside magpie's provider"; ANTE_HOME kept out of agentenv.Vars -> TestFolderVarsAreListed. Taking preferred_models out of the whole list is the one mutation no test turns red on: mergeTheirs cannot merge a JSON array (sync.go returns want for it), so the file behaves the same either way and the argument is there for intent and for the other keys, not for behaviour. go vet ./... and go build ./... pass, as do GOOS=linux go build -tags nogui ./... and GOOS=windows go build ./..., and gofmt is clean on LF content (this clone has core.autocrlf and no .gitattributes, so gofmt -l reports every file). go test -tags nogui ./internal/agent fails twelve tests, and an untouched worktree of origin/main run the same command on this machine fails the same twelve, test for test; ./internal/agentenv, ./internal/appdir and ./internal/edit pass, and every Ante test passes at -count=5. The whole ./... was run on both: the failing sets are alike, origin/main having one more, TestLoginUsageClaudeAskedMeanwhile, already filed as #1228. None of those twelve is this change's, and none is fixed here. * docs: agent-wiring records that Ante keeps the user's keys in magpie's entry and starts on settings.json's provider (#1210) The two rules the review asked for, written where the repo keeps them - the same lines |
||
|
|
e52d1cb820 | docs: README rewritten for what magpie does now — stat line, flow diagram, warm-up, context, image MCP, keep awake, agent updates, all 47 agents; "coding agent" is now just "agent"; per-agent notes moved to the reference | ||
|
|
b6d88ce66e |
provider, gui: each of a provider's keys can go through a proxy of its own, as a subscription's accounts can (Beyfish_Wang on X: 不同 key 走不同的代理)
A provider of keys kept one proxy for all of them: Save dropped
AccountProxies for it. Now AccountProxies holds a key's proxy by its
KeyID, ProxyChoice reads it for the key a try uses (WithKey), and Save
keeps only the keys the provider still holds. The key editor's Proxy
row has a line per key (Provider's proxy | Direct | Custom), named as
the Accounts list names it, when the provider has two or more.
Run:
- go test ./internal/provider ./internal/gui ./internal/gateway: ok;
TestKeyProxy fails without the provider change ("kept map[]").
- account-proxy.test.cjs, Chromium + WebKit, en + zh: 16/16; the new
key cases fail without the app.js change. Also at 440px: 16/16.
- gui-ja, gui-de, i18n-keys, account-models, provider-many-keys,
provider-typed, provider-proxy: all pass.
- Built binary, `magpie serve` in a sandbox HOME, three keys, four
local proxies: k-1 (429) through its own proxy, k-2 (429) through its
own, k-3 through the provider's; the global proxy got nothing.
|
||
|
|
f00ed2901c |
routing: filter by several purposes with checkboxes (#1247)
* routing: filter by several purposes with checkboxes Ticking a purpose immediately shows the union of the selected purposes while keeping the menu open. All purposes, Clear filter and removing the last selection restore every purpose. Counts, the story and history follow the same list. Live menus ignore renderer-induced scrolling while deliberate outside scrolling still dismisses them. Update the GUI shell reference and browser coverage for multi-purpose counts, keyboard toggling, history, aliases and narrow windows in five languages. Verified 115 isolated Playwright checks in Chromium and WebKit, JavaScript syntax and git diff --check. The new checkbox case fails against the old assets with 0 checkbox choices instead of 5; restoring the old menu scroll handler separately detaches the menu during selection in WebKit. * routing: All purposes returns keyboard focus to its button Address the keyboard-focus regression reported in PR #1247. Choosing All purposes closes its menu and focuses the purpose button without scrolling. Unticking the last purpose keeps the live menu and its checkbox focused. The expanded checkbox test fails without the focus restoration in Chromium and WebKit: All purposes returns keyboard focus to the purpose button (false !== true). With the fix, 162 isolated Playwright checks pass across both engines, including five locales, narrow widths, purpose state, history, request/session grouping, Usage and scroll/menu regressions. JavaScript syntax and git diff --check pass. The full GUI/Go suites and the packaged desktop app were not run. |
||
|
|
01e66e0f4a |
gateway: a reply a Claude subscription account answered carries its anthropic-ratelimit-unified-5h/7d headers, so a Claude Code signed in to claude.ai shows the 5-hour and weekly limits in its status line (claude-hud, #1257)
Claude Code tells the allowance in its rate_limit_event, not as headers, so magpie says what it keeps of the account again as Anthropic does: a share 0-1 and a Unix-seconds reset per window, set as the request comes in and again as the reply begins, after the turn's own event. Claude Code 2.1.293 reads them only when it is signed in to claude.ai (the sign-in field's claudeai); with magpie's key it ignores them. Run: go test -tags nogui ./... (temp HOME); the two new tests fail without limitHeaders; GOOS=linux/windows go vet of gateway and provider; real Claude Code 2.1.293 in a sandbox HOME against a fake server sending these headers: its statusline stdin had rate_limits.five_hour/seven_day with used_percentage 42/17 when OAuth-signed in, none with ANTHROPIC_AUTH_TOKEN. Not run: a real Claude account through a running magpie. |
||
|
|
db0feb3615 |
library, gui: a remote MCP server's headers and a command's env can name ${NAME}: the library keeps the placeholder and each agent gets its own syntax, so the token is never written into an agent's settings (#1250)
The library keeps `${NAME}` as typed. refsOf (internal/library/envref.go)
gives each MCP format the agent's own syntax, with the source beside each:
${NAME} for Claude Code, Droid, Grok, Gemini, Crush, Copilot, Pi, OmO, omp,
Qoder, AtomCode, Hermes and Command Code; {env:NAME} for OpenCode and MiMo;
${env:NAME} for Cursor; Codex's bearer_token_env_var, env_http_headers and
env_vars; Goose's ${NAME} plus env_keys. An agent with no syntax (Claude
Desktop, Antigravity, Kimi, Cline, ZCode, dsh, Devin, Pi with
pi-mcp-extension) is never given such a server: supports() refuses it with
the reason, an entry magpie wrote before is taken out, and its chip is
greyed with the reason on the row and in the editor. Codex's and Goose's
extra keys are magpie's only while the server names a variable, so the
user's own env_vars / env_keys stay. The editor says how to write one under
Headers and Environment; a check whose variable isn't set where magpie runs
says which one ("GH_TOKEN not set").
Verified:
- go test -tags nogui ./... (sandbox HOME): all ok.
- New TestEnvRefs* (per-agent encode/read-back, left-out agents, library
keeps the placeholder, user values kept, health check novar) fail with
the behaviour turned off (refsOf -> ${NAME} for all, no refsProblem /
refKeys, no expansion).
- library-env-refs.test.cjs (Chromium + WebKit, en/zh/ja/de at 440px)
fails on the old library.js (4/4 subtests); gui-ja, gui-de, i18n-keys,
library-servers-bulk, library-all, by-agent, project-servers,
library-mcp-health, library-null-agents, library-items: 109/109 pass.
- Real: magpie built from this tree synced a library server with
Authorization: Bearer ${FAKE_MCP_TOKEN} and X-Api-Key: ${FAKE_KEY} into a
sandbox HOME, then against a fake MCP on 127.0.0.1:3561 that answers only
the right bearer: Claude Code 2.1.293 `mcp list` Connected, Gemini CLI
0.62.0 Connected, Codex 0.160.1 app-server listed the tool (authStatus
bearerToken), cursor-agent sent both headers expanded; no agent file held
the token. Antigravity got the Problem and no entry. Not run for real:
OpenCode (its mcp list goes through the user's background service),
Goose, Crush, Copilot and the rest; their syntax is from their source.
- go vet (darwin, GOOS=linux, GOOS=windows) for internal/library and
internal/gui.
|
||
|
|
1b8c33b5e2 |
provider: a routing group reasons when any member does, not only when all of them do (#950)
* provider: a routing group reasons when any member does, not only when all of them do groupEntries started a group at Reasoning: true and intersected it with each member's (e.Reasoning = e.Reasoning && thinks), so one member that doesn't think made the whole group 不支持推理. Pi reads that off /v1/models and turns /thinking off for the model: a routing group of deepseek-v4-flash-0731-max (a Trae plugin model magpie read as one that doesn't reason) with a DeepSeek that does lost reasoning for both. The group now reasons when any member does. Images already worked this way (#756: a text-only member doesn't turn every image away), and the gateway fits a request's effort to the member that gets it (fitFor), failing over when a member refuses the level (failEffort). What the group offers as levels is not changed: a member known to take none still leaves the group none (#597), so such a group is Levelless — a shape magpie serves — rather than one that doesn't reason. group_reasoning_test.go (6 tests) fails without the change: TestGroupReasoningFromAMemberThatThinks, TestGroupReasoningLevels and TestGroupReasoningQuietMemberKeepsTheRest report reasoning off. TestModelsListReasoning, the /v1/models contract test, fails the same way and is updated to the new semantics. go vet, go build, GOOS=linux GOARCH=amd64 go build -tags nogui and GOOS=windows go build pass; go test -tags nogui ./internal/provider ./internal/plugin pass under a temp HOME. internal/gateway passes except TestPluginStreamStalledClientKeepsOther RequestsMoving, which fails on this machine with and without the change. * gateway, provider: a group's member that can't think is sent no reasoning, rather than a 400 A routing group reasons when any member does (#950, 533887c2), so an agent asks a group for reasoning that a member of it may not think at all. The effort reached every member, and some vendors turn a reasoning ask away with a 400 on a model that can't think — the review's second point: the comment said the gateway routes a reasoning request to the members that take it, and nothing did. The gateway now sends such a member no reasoning ask, in the agent's own protocol: Chat's reasoning_effort and reasoning, Responses' reasoning (with reasoning_effort, which some relays take), Anthropic's thinking when it asks to think — thinking disabled is no ask and stays — and output_config's effort alone, the rest kept. Everything else goes as the agent sent it, the Responses effort-update history is skipped for it, and the trace's Effort is "" (nothing was sent) with Fixed kept, so the user's own pin is still read. Which member goes first is unchanged: the group routes as it did, and only the body changes. provider.Entry.Quiet is the judgment: magpie has a word on the model — its vendor's or its maker's list, or its provider's own account list — and that word says no reasoning. A model nothing speaks for isn't quiet: it is sent the effort the agent asked for, as #597 leaves it. Nor is one that takes levels, which includes a model the user gave levels of their own: those take the effort the gateway fits to them. Direct requests outside a group are untouched, as TestPluginThinkingOff pins. TestModelsReasoningWithoutLevels (thinks_test.go) still expected group/mixed false; the review's first point. It is updated with a comment saying why the #402 expectation changes, and a group of two quiet members keeps the "none of them thinks" case. TestGroupNonThinkingMemberNoEffort (group_reasoning_test.go) fails without the change: with the withoutReasoningAsk call disabled it reports "B: the quiet member was sent map[... reasoning_effort:high]". TestEntryQuiet (provider) pins the judgment, and TestWithoutReasoningAsk the field set per protocol. go vet ./..., go build ./..., GOOS=linux go build -tags nogui ./... and GOOS=windows go build ./... pass. go test ./internal/provider, ./internal/catalog and ./internal/plugin pass whole; internal/gateway passes in three chunks covering every test (TestGroup|TestFixed|TestFast| TestModels|TestPluginThink|TestWithout|TestMember, TestEffort|TestResponse| TestReasoning|TestQuiet|TestTrace|TestRoute|TestRules|TestRule, and the rest), 43s + 36s + 790s. The whole suite in one process times out on this machine in an unrelated test (TestRuleDecidesAtTurnStartAndHolds here, TestRequestBodyProgressRenewsIdleDeadline on another run) — the base commit 533887c2 times out the same way, at 800s — so the chunks are what was run. The GUI is untouched, so no Playwright run. * gateway, provider: a member the user fixed at an effort keeps it, quiet or not The review's remaining point: Quiet() rests on magpie's catalog or an account's own model list, and what this PR is about is that word being wrong — the Trae plugin gave no capabilities, and magpie read a thinking model as one that doesn't. A member the user fixed at an effort ("provider/model:high", #189) is the user saying this model does think, so magpie's word doesn't override theirs: the gateway sends the fixed member its effort and strips the reasoning ask from the others as before. quiet := isGroup && c.effort == "" && quietTo(c.p.ID, c.model). c.effort is only ever the member's own fixed effort (Member.Effort, planLevel), never the agent's, a group's Group.Levels, the turn's pick or the Settings' — so those are stripped as they were. TestGroupNonThinkingMemberNoEffort's E is now the other way: the fixed quiet member is sent reasoning_effort "high", and the trace's Effort is "high" with Fixed "high". Dropping the c.effort == "" guard makes it fail with "E: the fixed member was sent map[... model:plain]". quiet.go and Entry.Quiet say why. gofmt, go vet ./..., go build ./..., GOOS=linux go build -tags nogui ./... and GOOS=windows go build ./... pass. The review's own set passes — go test -count=1 -run 'TestGroup|TestWithoutReasoningAsk|TestEntryQuiet| TestModelsReasoning|TestModelsListReasoning|Thinks|TestPluginThinking' ./internal/provider ./internal/gateway — and so does internal/provider whole (208s). internal/gateway passes in chunks (Test[A-D] 340s, Test[S-Z] 216s, Test[^A-DS-Z] split over TestEffort|TestResponse|TestReasoning|TestQuiet| TestTrace|TestRoute|TestRules|TestRule|TestFixed|TestFast 38s and the rest earlier): the whole suite in one process still hangs here on an unrelated test (TestCodexBodyReadsPastOldLimit this time, TestRuleDecidesAtTurnStart AndHolds and TestRequestBodyProgressRenewsIdleDeadline before), which the base commit does too and which passes alone in 0.9s. |
||
|
|
5d0c1dcad7 |
agent, library, sessions: a Claude Desktop installed as an MSIX package on Windows is found, wired, given MCP servers and skills, and its Cowork calls read, in the package's folders (Kilig on Discord, Windows 10 LTSC 21H2)
Desktop from its setup.exe there runs packaged, so Windows keeps what it
writes under %APPDATA% and %LOCALAPPDATA% in
%LOCALAPPDATA%\Packages\Claude_<publisher>\LocalCache\{Roaming,Local}.
Nothing was in %APPDATA%\Claude or %LOCALAPPDATA%\Claude*, so magpie didn't
recognise Desktop at all. The packaged app reads its own copy before the
real folder's, so writing the usual folders wouldn't reach it either.
New internal/desktopdir is the one resolver for Desktop's folders: Data
(its own userData: %APPDATA%\Claude, the package's LocalCache\Roaming\Claude),
Mode (the Claude folder deploymentMode goes into, %LOCALAPPDATA%\Claude as
before) and ThreeP (Claude-3p). The package is found by globbing
Packages\Claude_* (the publisher id isn't assumed) and used unless Desktop
runs in the usual folders: it has run there (Chromium's "Local State"; an
%APPDATA%\Claude holding only another tool's claude_desktop_config.json
doesn't count) and not later in the package's. Every place that built
these paths goes through it:
- agent: desktopDirs (its 3p switch's writes, DesktopConfig3p, Claude
Code's desktop check in claude.go) and detection, which also looks at
Data (replacing the separate %APPDATA%\Claude check). windowsDesktopDir
moved into desktopdir unchanged.
- library: apps() (Desktop's MCP file, was os.UserConfigDir) and
desktopSkillRoots (Cowork's skills-plugin).
- sessions: desktopDataDirs, a copy of agent's resolver, now desktopdir's
All(); it also adds %APPDATA%\Claude on a usual Windows install, where
Desktop keeps 1p Cowork sessions (only %LOCALAPPDATA%'s were read). usage's
request_page and session_provider read it through sessions.DesktopDataDirs.
Tests: TestFindMSIX/TestFindWindowsClassic/TestFindOthers (desktopdir),
TestClaudeDesktopMSIX (agent: detected, switch writes LocalCache\Local\Claude
and Claude-3p, nothing in the usual folders), TestClaudeDesktopMSIXLibrary
(MCP file and skills-plugin in LocalCache\Roaming\Claude),
TestCallsCoworkMSIX (sessions). With the call sites put back to origin/main
the last three fail: "an MSIX Claude Desktop isn't detected", "no Claude
Desktop target: <nil>", "dirs: [.../Claude .../Claude-3p]".
Run: go test -tags nogui ./... under a temp HOME with pinned caches (all
ok); GOOS=darwin|linux|windows go vet -tags nogui on desktopdir, agent,
library, sessions, usage; GOOS=windows go build, GOOS=linux go build -tags
nogui. On the Windows 11 box (Squirrel install, no MSIX package): the
windows test binaries pass every Desktop/MSIX/Cowork test; the full agent
and sessions binaries fail the same 16 and 76 tests (run outside their
testdata) as origin/main's; a read-only probe of desktopdir.Here() gives
the usual %APPDATA%\Claude, %LOCALAPPDATA%\Claude and Claude-3p, Package "".
Not tried against a real MSIX Desktop: none on the box, and Claude-3p's
place in the package (LocalCache\Local, as Local\Claude is in Kilig's
listing) is inferred, not seen.
|
||
|
|
69b6772794 |
provider: Qoder check-ins follow the campaign window, so a campaign open past Beijing midnight is claimed and the next one isn't missed (#1173)
Qoder and Qoder CN campaigns run on [startAt, endAt) windows that cross Beijing midnight, so a campaign claimed yesterday could stand in for the whole new day and the next campaign was never claimed. - A campaign is active by its upstream window, so one still open after Beijing midnight can be claimed. - A saved result records the campaign's end in `until`, and is reused only on the same Beijing day, within 30 minutes and before that end; after that the list is read again (about 48 list reads per account per day, `inactive` included). - Manual check-in always verifies upstream; failures and older records without `until` keep the old retry behaviour. - Only Qoder sets `recheck`; WorkBuddy, Trae CN and MiniMax keep their once-a-day settlement. |
||
|
|
3aeb7d1bf8 |
gateway: a model a key's plan doesn't serve rests that model alone, not the key (#1235)
SenseNova answers a Token Plan key asked for deepseek-v4.1-flash with
403 "model is not available in the current token plan" while the same
key serves deepseek-v4-flash. That refusal was a plain failure: it
rested the whole key, its backoff counted per key and doubling up to
10 minutes, so an agent that kept asking for the model left every key
of the provider resting for every model (Sun1090: both keys "失败 · 9
分钟后恢复").
A 400/403/404/422 whose body says the model isn't served
(unservedWords), with no quota, credit, rate-limit or channel words,
now rests only that key's or account's model (modelRefused, keyed by
restID, which is now restKey/model for every candidate), its backoff
counted by the model and cleared when the model answers. The key's
other models stay first in order, and its row shows no rest.
TestModelNotInPlanRestsTheModelNotTheKey (two keys, four requests for
the refused model, SenseNova's error shape) fails without the change:
"key One rests for a model its plan lacks: {Why:other Status:403 ...
By:backoff Failures:3}" and "deepseek-v4.1-flash doesn't rest". With
it: go test -tags nogui ./... under a temp HOME passes; -race -count=5
over TestModelNotInPlan|TestSeveralKeys|TestFallback|TestRouting|
TestPool|TestGroup passes; go vet (darwin, GOOS=linux, GOOS=windows,
nogui) passes. Not tried against a real SenseNova key: the body's
"code" field in the fixture is a guess; the message is the reporter's.
|
||
|
|
be1ce5d4d2 |
davsync: incomplete S3 listings keep cached usage days (#1243)
Reject truncated listings with missing or repeated tokens and listings that exceed the page limit. On the old code the regression reports successful partial listings and shareWith removes a cached day. Focused tests pass in a temporary HOME. |
||
|
|
2d9db1b60d |
edit: malformed JSON configs stay untouched when agents connect (#1242)
Validate the complete comment-stripped document before object and array edits, including previews. Regression tests fail on the old implementation because edits and VS Code Connect succeed and alter malformed files. Targeted edit and VS Code tests pass in an isolated HOME. |
||
|
|
814dca9390 |
gateway: the ChatGPT backend's 502 "response protection is unavailable" is the request's, not the account's: asked once, no account rests, and a magpie-model compaction refused so is asked again as text (vs on Discord)
A long Codex conversation's compaction refused so went to every ChatGPT account in turn, each rested on a growing backoff, then the last was asked twice more: 5 upstream tries per Codex request with 3 accounts, times Codex's own retries. Now the provider's other accounts and models are not asked it, nor the same one after a moment; another vendor left in a group is asked once. A reply broken off with it rests nobody either. codexCompact falls back to plainCompact on it, as vs's control experiment (the same history as text) was answered. TestProtectionRefusalAskedOnceNobodyRests fails on the old code with "asked 5 times ([acct-1 acct-3 acct-2 acct-2 acct-2]), not once" and two accounts resting; TestProtectionRefusedCompactionAsText with status 502. The error body's exact bytes are not the reporter's; no real ChatGPT account was tried. |
||
|
|
ad3b7fe5c2 | agents: keep Air provider fields on sync and model picks (#1239) | ||
|
|
70ec514872 |
provider: a Copilot editor token GitHub refuses gives way to the Copilot CLI's sign-in of the same account, and a sign-in from magpie of that account is kept (#1238, eightHundreds)
A stale oauth_token for octocat left in ~/.config/github-copilot/apps.json was read before everything else, so its 401 Bad credentials left Copilot with no models although the Copilot CLI was signed in to octocat and worked; signing in again from magpie didn't help, since the new token was let go as the editors' own account. - copilotToken remembers an editor token whose trade GitHub refuses (401 or 403; tried again after an hour, or as soon as one trade works). - copilotApp.session then uses the CLI's sign-in of the same login on the same host, sent as the CLI sends it, and copilotLogin reads that sign-in for the account from then on. A CLI signed in to another account is never used in its place; the error names the editors' file, and that the CLI is another account. - The Usage card (loginQuota) reads the CLI's sign-in the same way when the editor token gets 401. - The device sign-in asks GitHub about the editors' token of the same account first (copilotProbeEditor); refused, addCopilotLogin keeps magpie's token, standing for the account. With a working editor token it is let go as before. - TestMain stubs copilotCLISecret, so no test reads the runner's keychain. Tests: copilot_stale_editor_test.go (fake exchange, user and API servers, temp HOME). Without the fix TestCopilotStaleEditorFallsBackToCLI and TestCopilotStaleEditorNotAnotherAccount fail "Copilot is signed out (Bad credentials); sign in to Copilot again", TestCopilotSignInKeptOverStaleEditor lists octocat with the editors' gho_x, TestCopilotStaleEditorUsageReadsCLI reads "Unauthorized". Not tried with a real stale apps.json or a real Copilot CLI login. |
||
|
|
1efa4c642f |
gateway: a Codex lead's turn carrying its subagent's sealed reply goes back to the Responses provider that answered the lead, instead of being refused as a subagent task "sealed by the ChatGPT backend" (#1237)
SuperrNaruto (#1237), and PennyVibe on #1109 with v0.1.1108: Codex with the lead and its subagents all on one custom Responses provider serving gpt-6-astra. #1109's fix sends the subagent's sealed task back to that provider, and the subagents ran. But when a subagent answers the lead with send_message, Codex hands the lead an agent_message to /root whose payload the provider sealed, and hasSealedAgentMessage marks the lead's next turn as a sealed task. The lead has no parent thread, so leadProvider found nobody, sealedReader accepted only a ChatGPT account, and the lead was refused before anyone was asked with "This subagent's task was sealed by the ChatGPT backend that answered its lead ... is neither". The main conversation stopped while the subagents finished. The providers that can read a sealed message are now those that answered the lead (the parent thread, as before) and the one that answered the request's own conversation (leadProvider of its conversationID): a subagent sent a task sealed by the lead's provider had to be on that provider, so its reply was sealed there too. A lead moved to another provider, one magpie never answered, and a subagent whose lead a ChatGPT account answered are still refused before anyone is asked, with the same messages. Verified: TestSealedReplyGoesBackToTheProviderThatAnsweredTheLead (lead, subagent, then the lead with the subagent's sealed FINAL_ANSWER reply, all on third/gpt-6-astra with Codex's web_search tool) fails on origin/main with the reporter's exact 400 and passes with the change; the reply's ciphertext reaches the provider as it came. TestSealedReplyRefusedWhereTheLeadWasNotAnswered and TestSealedTaskFromChatGPTLeadStillRefused (lead on gpt-5.5 through the ChatGPT sign-in, subagent on the custom provider) pass on both. go test -tags nogui ./internal/gateway passes; the sealed tests pass under -race -count=5; go vet -tags nogui ./internal/gateway passes for darwin, linux and windows. Not tried with real Codex and a real relay of the ChatGPT backend: the reply's item is built from the "Message Type: FINAL_ANSWER / Task name / Sender / Payload" layout in the codex 0.160.1 binary and PennyVibe's description, not from a captured request. |
||
|
|
40c5b92a93 |
gateway: structured output crosses every translation: a Responses text.format or a Chat response_format reaches a Chat, Responses, Messages or Gemini upstream in its own words (frostming on Discord: responses.parse through magpie to OpenRouter came back as plain text)
The IR held a schema only from Anthropic's output_config.format, and only Messages (and Claude Code's --json-schema) were asked for one. A Responses request to a provider magpie speaks Chat or Messages to (OpenRouter has no Responses URL) lost text.format; a Chat request translated to Responses or Messages lost response_format; an Anthropic schema translated to Chat or Responses was lost too. Format (format.go) now carries json_schema (name, description, schema, strict) or json_object from Responses, Chat, Anthropic and Gemini, and each builder asks for it in its upstream's field. Where a target can't say it (json_object on Messages or Claude Code; the vendor bridges Cursor, Devin, Kiro, Qoder, Command Code, Zed) it is told in the system prompt. An upstream answering 400/422 naming the field (DeepSeek's "This response_format type is unavailable now") is asked again with it told in words, and so from then on. Relayed requests are unchanged. |
||
|
|
ac142bb1d2 |
agent, gateway: Claude Code through magpie can keep its claude.ai sign-in (#1218)
* gateway: Claude Code signed in to claude.ai is told a provider's 401 as a 502, not as its own sign-in refused
Claude Code signed in to claude.ai, its base URL magpie's and no key set,
sends its own sign-in as its key (Authorization: Bearer sk-ant-oat…),
which magpie never passes on. A 401 it reads as that sign-in refused: it
renews the sign-in before each of its retries (ten for one request) and
then asks to /login; a 403 saying "OAuth token has been revoked" the
same. Whatever magpie answers such a request with is never about that
sign-in: a provider refused magpie's credential, or one of magpie's own
Claude accounts lapsed (the bridge's 401 or revoked 403). keepsSignIn,
on /v1/messages, /messages and /v1/messages/count_tokens, tells such a
request's 401 as a 502, and a 403 too when its body says the token was
revoked; the body is the provider's message as it was. Recent calls and
the usage log keep the provider's status. Any other 403, and a request
with magpie's key or an API key, is answered as before; no
x-should-retry is added, so Claude Code retries a 502 as it does a 5xx.
Verified: TestClaudeSignInNotToldItsSignInFailed (401 on /v1/messages,
/messages and count_tokens; revoked 403; another 403; magpie's key; an
API key; a reply and a stream untouched; Recent calls' status) fails
without the route change ("401: 401 … want 502", "revoked: 403 … want
502", "401 counting: 401 … want 502"); TestSignInWriterReleasesA403. go
test -tags nogui ./internal/gateway, go vet and gofmt are clean.
* gateway: a provider never gets the oauth beta of Claude Code's claude.ai sign-in
Claude Code signed in to claude.ai asks oauth-2025-04-20 in
anthropic-beta with its sign-in. magpie relayed the agent's betas to
every Anthropic-protocol provider, so that one went too, naming a
credential that never leaves magpie: a provider got a request claiming
an OAuth sign-in it was not sent, beside magpie's key for it. askedBetas
leaves the agent's oauth- betas out of what forwardOnce asks, so a
provider gets the request as Claude Code with magpie's key sends it. A
beta the user set on the provider (header.anthropic-beta, fitUserBetas)
still goes, oauth- or not.
Verified: TestSignInBetaStaysWithMagpie (the oauth beta among others,
alone, and the user's own on a provider) fails without the forwardOnce
change (sent ["claude-code-20250219,oauth-2025-04-20,…"], and
["oauth-2025-04-20"] where none was wanted); TestUserBetas* and
TestClaudeCodeHeadersReachAnthropicRelay pass. go test -tags nogui
./internal/gateway, go vet and gofmt are clean.
* agent, gui: Claude Code through magpie can keep its claude.ai sign-in
Claude Code's new sign-in field is magpie's key (the default) or claude.ai.
claude.ai writes ANTHROPIC_AUTH_TOKEN empty, which Claude Code reads as no
key: it stays signed in to claude.ai (/usage plan limits, connectors, voice,
/teleport) and sends that sign-in to magpie, which never passes it on. Every
later write (a model, a tier, the subagents, a /model pick followed, Reapply,
a port move, Disconnect and Connect) keeps it so, and a key put there by hand
is reported by Check rather than taken for magpie's. A token emptied or taken
out by hand in key mode is reported with the way to keep it so.
It is offered only where the gateway takes any key (loopback, a WSL distro
mirrored), not from a distro under NAT while magpie is shared on the network.
The GUI draws no square for a Claude Code with no other sign-in to take.
Verified: go test -tags nogui ./internal/agent (new claude_login_test.go,
each test fails with its change reverted) and -run 'SignIn|Claude|Beta'
./internal/gateway; go vet; GOOS=linux/windows builds; claude-signin.test.cjs
(fails without the app.js change) plus i18n-keys, agent-field-names,
agent-layout, claude-direct, claude-effort, codex-signin-always,
subagent-effort and menu-scroll, Chromium and WebKit, en/zh/ja/de, 1100 and
440px. Real Claude Code 2.1.292 with the token empty sent Bearer sk-ant-oat
and the oauth beta to the base URL, took a 502 as a server error, and stayed
signed in to claude.ai.
* agent: Claude Code given a magpie model at an older gateway address keeps the endpoint it had before magpie
When the gateway moved while magpie wasn't running to follow it, a magpie
model picked next found Claude Code at the older address and stashed what
was there as the user's own: the dead gateway URL, magpie's key or the
empty one of a claude.ai sign-in kept, and magpie's own tier models. That
overwrote the endpoint and token stashed when magpie was first wired in, so
switching off put the dead address back, and with the claude.ai sign-in
kept "model default" left it and five magpie tiers too.
What's there at an older address is now taken for magpie's (keyOurs: its
key, or an empty token with the claude.ai sign-in kept), and the stash made
when magpie was wired in stays. The same test replaces "model default"'s
guess from the model's name, which could take out a user's own endpoint
with an empty token.
Verified: TestClaudeAtAnOlderGatewayAddress (both sign-ins, Disconnect and
model default; fails on the previous commit, the key-mode case on main
too); go test -tags nogui ./internal/agent; -race -count=5 on the Claude
and WSL Claude tests; go vet; GOOS=windows build.
* gateway: Claude Code signed in to claude.ai is told a provider's refusal in magpie's words, never mentioning x-api-key
Claude Code 2.1.293 tells any error whose message mentions x-api-key as
"Not logged in · Please run /login", whatever its status, so the 502 alone
still signed it out (yetone on #1218). A 401, or a 403 saying the OAuth
token was revoked, now reaches it as an api_error "<provider> refused
magpie's credential (HTTP 401): invalid API key", and a stream's error
after its 200 says "API key" too. Recent calls keep the provider's status
and message.
Checked with Claude Code 2.1.292 signed in to claude.ai, through the
gateway to a provider answering 401 invalid x-api-key: it shows "API
Error: 502 Fake refused magpie's credential (HTTP 401): invalid API key.
This is a server-side issue" and stays signed in.
* agent: an empty token at an endpoint of the user's own stays theirs, though Claude Code once kept its claude.ai sign-in through magpie
keyOurs took any empty ANTHROPIC_AUTH_TOKEN for magpie's once the sign-in
was kept, so after Disconnect a user's own https://corp.example with an
empty token lost both to the model default (yetone on #1218). The empty
token is magpie's only beside the address magpie last wrote, stashed as
claude.wired_at, which Rewire carries to the gateway's new port.
* gateway: no error Claude Code signed in to claude.ai is told mentions x-api-key, whatever its status
Claude Code reads any error naming x-api-key as its own sign-in refused,
so a provider's 403 "invalid x-api-key" or 400 "x-api-key header is
required" still told it "Not logged in · Please run /login" (yetone on
#1218, Claude Code 2.1.293). Every error such a request is told is held
until its body and says "API key" there, at the status it had; a 401 and
a revoked 403 stay the 502 in magpie's words.
|
||
|
|
202031642a |
provider: an account refused while its allowance is being read has it read again once that reading is back, and keeps the share it had meanwhile (#1142)
* provider: an account refused while its allowance is being read has it read again once that reading is back, and keeps the share it had meanwhile
StaleAllowance zeroed the time an agent's allowances were read, and
dropped the account's own reading, but a reading Allowances already had
out, asked before the refusal, came back after it, set that time to now
and was trusted for a minute. For that minute the Routing page and the
trace showed the share from before the refusal, the tray's account in
use kept the refused account, and a second refusal of the account found
its window not full: a quota refusal with no reset time rested 15
minutes by "quota" instead of until the window renews, and the account
was tried and refused again after it. Every caller of StaleAllowance had
it: the gateway after a refusal, Claude's limit headers, a Codex reset
spent and both warm-ups.
StaleAllowance now marks the agent stale when a reading is out, as
StaleKeyAllowance does a key (
|
||
|
|
84f37ac977 |
provider: a Copilot account renamed with its host keeps its proxy, models, cap and concurrency, moved from its bare login before the new name is written (#1220)
Before #1220 a bare Copilot login named one account (a same-named one
wrote over it), so the per-account settings in providers.json kept under
it are that account's. When an account is renamed login -> login@host
(an old enterprise record read by nameAlike, or the editors' own
enterprise account renamed in sideLogins), renameTo remembers the old
name (savedLogin.was, never written) and pins the account's stable id
from it. writeLogins then moves accountProxies, accountModels,
accountCaps and accountConcurrency of the copilot provider from the old
key to the new (moveAccountSettings), and writes the new name only once
that has worked (renameSettled):
- a setting the new name has already stays, and the old with it;
- an old name another account still bears is left to that account;
- providers.json that can't be read or written stops the move, and the
account is written under its old name, its settings with it, retried
a minute later (renameDue) rather than at every listing.
sideLogins writes a pending rename the first time copilot is listed,
on a copy so the listing keeps its order.
Not moved: usage history, quota history, usage-alert marks and the
GUI's expanded/brief card state (localStorage) - records and view
state, not settings. Gateway keys' account lists go by the stable
login id, which is kept.
Tests (each FAILs with moveAccountSettings stubbed to do nothing):
- TestCopilotRenameKeepsSettings: proxy, cap, models, concurrency under
"mona" are mona@a.ghe.com's after the upgrade; a github.com mona added
after has neither proxy nor cap.
- TestCopilotOwnGHERenameKeepsSettings: the editors' own GHE account
keeps cap and proxy.
- TestCopilotRenameWaitsForSettings: an unreadable providers.json is
left as it is and logins.json keeps "mona"; once readable, the
settings move and the new name is written.
- TestCopilotRenameKeepsNewNamesSetting.
Ran: rebased onto origin/main (
|
||
|
|
74a809dcdf |
provider, cli: a Copilot account is told apart by its host as well as its login, so one GitHub login on github.com and on enterprises' *.ghe.com is kept as separate accounts, none written over or dropped as the editors' own (#1220)
A Copilot account is now named "<login>" on github.com and "<login>@<name>.ghe.com" on an enterprise's host (CopilotAccountName). That name is its logins.json user, so addSideLogin, the own-account dup check, switch, on/off, forget, the account lists, usage cards, routing ids and per-account settings all go by login and host. The device-code sign-in reports the account under that name, so the window opens the one just added. Plan refresh for the editors' own account on an enterprise's host matches it under the same name. logins.json written before keeps working: a saved account on an enterprise's host stored under its login alone (host only in its auth) is read as login@host, keeping its id and token (copilotSavedName in nameAlike). The editors' own account remembered by its login alone is renamed in place, keeping its plan and its hidden mark (copilotRenamed). github.com accounts are unchanged. CLI: `magpie accounts switch|forget copilot` now works. It takes login@<name>.ghe.com, the login with --host <name>.ghe.com (or --host=), or the login alone when it names one account. An ambiguous login lists the matches and asks for one. Switching says "magpie now uses X", since the editors' own sign-in isn't changed. Tests (new, each FAILs on the old code): - TestCopilotSameLoginOnTwoHosts: mona on a.ghe.com and b.ghe.com, through the device flow. Old: the sign-in reports "mona" and the second overwrites the first. - TestCopilotOwnAndSameNamedGHE: the editors' github.com mona plus a.ghe.com's mona. - TestCopilotLoginsBeforeHosts: an old-format logins.json. Old: listed "mona* hubot". - TestCopilotOwnGHERenamed. Old: own row still "mona". - TestAccountsCopilotHosts. Old: "copilot": only Claude Code, Codex, ... can be added and switched. TestCopilotGHESignIn and TestCopilotCLIGHELogin now expect mona_acme@acme.ghe.com. Ran gofmt; go vet -tags nogui . ./internal/provider for GOOS=darwin, linux and windows; go build ./...; GOOS=linux and windows go build -tags nogui; go test -tags nogui -count=1 ./internal/provider ./internal/gui ./internal/gateway . under a temp HOME with pinned caches, all ok; -race -count=5 on the Copilot account tests, ok. No GUI asset changed, so no Playwright run. Not tried with a real GHE.com account. |
||
|
|
b6f33753ce |
gateway: a gzip or zstd request body is read on every route, so Codex with a custom provider on /v1/responses no longer gets "expected a JSON object" (#1223)
Only the Codex backend path decoded a Content-Encoding (codexReader); /v1/responses, /v1/chat/completions, /v1/messages, count_tokens, Gemini's routes, embeddings, images, videos and System One read the compressed bytes as JSON. Codex sends zstd to a custom provider's /v1/responses by default. readBoundedRequestBody now always decodes (decodedBody, moved from codexReader): identity, gzip and zstd are read, the header is dropped so the provider gets plain JSON, the size limit holds for the decoded bytes (413), and any other encoding is refused 415 naming it. keyLimited reads a limited key's compressed body the same way, so its reservation is the decoded size, not the compressed one. Verified: request_encoding_test.go posts real gzip and zstd bodies to a running httptest server of New().Handler() on every route. Without the change TestEncodedBodiesOnEveryRoute and TestEncodedEmbeddingsBody fail with 400 "expected a JSON object" / "the body isn't a JSON object", TestEncodedBodyInflatedPastLimit gets 400 instead of 413, TestUnknownBodyEncodingRefused gets 400/502 instead of 415, and with only key_limit.go reverted TestKeyLimitReservesDecodedBody fails "held at 19 tokens for a 40061-byte body sent as 79 bytes". gofmt; go vet -tags nogui ./internal/gateway for GOOS linux, windows, darwin; go test -tags nogui ./internal/gateway (temp HOME, pinned caches) ok; the new tests -race -count=5 ok. Not tried with the real Codex CLI. |
||
|
|
d151c10839 |
gateway: a whole reply after keepalives reaches the agent as its stream, not as an "OK" error (#1175)
Sometimes a held try's vendor says nothing for longer than keepHeldAfter.
keepAlive then sends the agent the stream's 200 and SSE comments. On the
passthrough path the vendor could then ignore stream:true and answer 200
with its reply whole. The try wasn't a failure, so release() told it to
the agent as the stream's error, "OK: {...the reply...}", and the agent
lost a reply that was billed. Found by reading the code, then reproduced
for Chat, Responses and Anthropic, in a group and for a lone provider.
Once the agent has a stream's headers, release() now sends a whole 200
reply as that protocol's stream (wholeAsStream). Each choice, output item
and content block is sent in the events that stream it, as the vendor
wrote it:
- text, with its citations (Anthropic citations_delta, Responses
output_text.annotation.added)
- reasoning summaries, and thinking with its signature
- tool calls, refusals and stop details
- usage
A 200 under any type other than JSON or SSE is held too. Before, a
text/plain reply was written raw into the SSE stream. Its first bytes
decide (sseStart). If its first line begins with an SSE field, it was a
stream all along under the wrong type, and it goes through as it comes,
as before. While those bytes have yet to come, the agent is still kept
alive.
A 200 that is no reply of the protocol's now fails in settle(), the way
a translated reply that didn't stream does (502, "... did not stream").
The next member answers. On the last member the agent gets that error
and the request is logged as a 502.
Gemini gets no keepalives, so this changes nothing for it.
Verified:
- Each new test fails on the code before its fix and passes with it:
- TestQuietWholeReplyStreamed covers Chat, Chat as text/plain,
Responses, Anthropic, "not a reply" and "a stream as octet-stream".
Before the fix the first four get "error:OK: {...}", or nothing for
text/plain. The octet-stream case is the regression found on
review: its stream was buffered and failed over.
- TestQuietNotAReplyLastFails: before the fix the agent got the OK
error, logged as 200.
- TestWholeAsStreamClientReads and TestWholeAsStreamResponsesCitations.
- TestHeldUnsureStreamPasses covers prefixes split across writes,
id:/retry: first, and a whole reply.
- TestHeldUnsureKeptAlive.
- go test -tags nogui -race -count=20 on these tests together with
TestQuiet|TestSilentHeld|TestKeepAlive.
- go test -tags nogui ./internal/gateway, and the prebuilt test binary
with GOMAXPROCS=2.
- go vet ./....
- go build, GOOS=linux go build -tags nogui, and GOOS=windows go build.
- go test -tags nogui ./... under a temp HOME.
- The synthesized streams were also fed to openai-node 7.30.0
(chat.completions.stream, responses.stream) and @anthropic-ai/sdk
0.131.0 (messages.stream, beta.messages.stream). Each accumulated the
whole body's reply field for field.
- Not tried against a real vendor that ignores stream:true.
|
||
|
|
a2c3905368 |
provider, gateway: a usage window whose reset has passed is renewed on every path, and an account resting out of it is back once a reading finds it renewed (#1200)
* provider: a usage window whose reset has passed is renewed on every path, so a fresh Claude /usage reading at 100% a minute after its reset no longer counts the account spent or moves Claude Code off it
Claude Code's /usage gives a reset to the minute and can lag it, so a run
just after a reset can still say "100% used · resets <a minute ago>".
claudeWindows handed that fresh reading back raw while its cached and
header paths already went through elapsed, and usedPast judged Used
without looking at ResetsAt. NextLogin dropped passed windows only for a
Claude reading kept from before (AsOf) and only for the account the agent
is on, not for the account to go back to or the spares.
- claudeWindows: the fresh /usage path returns its windows as of now
(elapsed), as the cached and header paths do. What is kept in
claudeUsage stays as read.
- resetPassed is the one test for a window that has started again.
usedPast, usedUp, UsedUp and BackAt take now and leave such a window out,
for Codex and Claude Code alike; NextLogin's Claude-and-AsOf special case
goes, since every account it looks at (on, back, spares) is now judged
that way. capReached already was.
- quota_last's reading still decides whether a Codex hold came with a
used-up window from the reading as it was read (usedUp at the zero time),
so the hold ends when that window starts again, as before.
- magpie quota wait and the Codex expiring resets pass their own now, so
quota wait no longer waits out a window that has already reset.
- docs/subsystems/providers-accounts.md says so, and that a reset /usage
gives as a time alone ("resets 3pm") is read as its next one by
claudeResetTime, which this change can't see as passed.
Verified under a temporary HOME with GOPATH, GOMODCACHE and GOCACHE
pinned: go vet ./..., go build, GOOS=linux go build -tags nogui and
GOOS=windows go build pass. go test -tags nogui ./... passes except
internal/gui's TestSetAnswersWhileDevinAndProvidersHang once in the full
run (set slow/slow-1 took 1.67s against its 1.5s limit, with other
sessions' suites running); it passed -race -count=20 on this branch and on
upstream/main, and internal/gui passed on its own. go test -race -count=20
on the new tests passes.
New tests (window_renewed_test.go) and what fails without the change:
- TestFreshClaudeReadingPastItsResetIsNotSpent: with the fresh path
returning raw ws, "the session reads 100% (reset ... 14:20:00 UTC)". The
control with its reset an hour away is spent and moves to the spare.
- TestNextLoginTakesAPassedResetAsRenewed (codex and claude): with usedPast
and BackAt ignoring ResetsAt and the old AsOf drop back, NextLogin moves
off an account whose full window reset a minute ago (fresh, and for Codex
kept), won't move to a spare or back to the first whose full window has
reset; the controls with resets an hour away are unchanged.
- TestUsedUpLeavesOutARenewedWindow: with BackAt and usedUp ignoring
ResetsAt, "a window reset a minute ago stops the account".
TestClaudeUsageExpiredReadingDoesNotSwitchAccounts and TestCodexHeld* pass
unchanged. Not tried against a real Claude or Codex account here.
* provider, gateway, docs: a subscription account resting out of its window is back once a reading finds it renewed
A Claude account rested for a full five hours or week waited out the whole
rest (the time Claude Code's refusal named, or c.full), even when a new
/usage reading or the reset passing showed the window started again: only
a Codex reset spent and a key's reading ever told OnRenewed.
provider: Allowances, the reading routing goes by for every account,
keeps each account's last reading and when it was taken (usedCache.seen,
kept through a failed reading). A new reading that finds the account full
till sooner than the last (renewedFrom, the rule keyAllowances' keep uses
for keys) tells OnRenewed as the agent its usage is read under
(Account.UsageAgent: "claude", or "plugin:<id>" for a plugin-served
subscription) and the user. Scopes are the whole account, and each pool of
some models with the whole account's windows over it. A window used below
SpentShareOf its routing, or whose reset passed, is full no more; one full
with its reset unknown is full for good. Only windows in both readings
are compared. So a first, failed, alike or partial reading tells nothing,
nor does a five hours renewed inside a used-up week, nor two windows of
one name. Hooks are told after usedCache is unlocked and before the
reading's waiters are released. renewedNow drops the account's seen
reading so a spent reset is told once.
gateway: renewed lifts only an account's rests out of quota or by
"window"; a rest for a rate limit, credit, verification or a failure
stays, as a key's already did. Before, it only ran for a Codex reset spent;
now readings reach it for every subscription.
docs: providers-accounts (Allowance step, test list), gateway-routing
(account rests lifted by a reading, test lists), reference (when an
account runs out).
Verified: TestClaudeAccountToldRenewed (a fake /usage: 100% tells
nothing, 99% tells nothing, 3% tells once, more use doesn't tell again; a
kept window whose reset passes tells once), TestPluginAccountToldRenewed
(plugin:fakeco; a failed reading in between, a reset spent, and a reset
passed while the vendor still says 100%), TestRenewedFrom (table, including
the five hours inside a full week and same-named windows), and
TestClaudeAccountBackOnceItsWindowRenews (gateway: a Claude account resting
by "resets" is back once a reading shows 4%, another still full keeps its
rest). TestQuotaRestsUntilItsReset now checks a rate-limit and a backoff
rest survive renewed. Without the tell, all three renewal tests fail (told
[], still rests by resets); mutating each branch of renewedFrom/fullTill
fails TestRenewedFrom or the plugin test. Under a temporary HOME: go vet
./..., go build, GOOS=linux go build -tags nogui, GOOS=windows go build,
go test -tags nogui ./... and go test -race -count=20 (the gateway test also -count=50) on the new and
changed tests pass. Not tried with a real Claude account.
Not covered here: once a /usage run prints Claude's limit refusal,
claudeWindows returns that error until /usage or a header succeeds again,
so Allowances skips the account and the reset passing tells nothing. That
path is claudeWindows' own; the change making passed windows count as
renewed there lets this tell reach it.
* gateway: a Codex subagent's sealed task goes back to the Responses provider that answered its lead (a Sub2API relay of the ChatGPT backend), relayed as it came, instead of being refused before it is asked; the refusal names who sealed it (#1109)
Codex on Ultra with s2a/gpt-6-astra for both the lead and the subagent,
s2a a Sub2API relay: the relay's ChatGPT backend sealed the lead's
spawn_agent task, and magpie turned the subagent away because only a
Codex account counted as able to read it, telling a user already on a
magpie model to "use a Magpie-served model for the lead".
The provider that answered the lead (leadProvider, from the lead
thread's affinity in any scope) now reads it too when it is a key
provider serving the model on Responses; the task is relayed, even
when Codex offers web_search (it was translated, which dropped the
agent_message's encrypted_content). A subagent on another provider, or
whose lead magpie never answered, is still refused before anyone is
asked, with a message naming the lead's provider when known.
Verified: TestSealedTaskGoesBackToTheRelayThatSealedIt (lead and
subagent on s2a/gpt-6-astra, with Codex's web_search tool) fails without
the change with the old 400; without only the relay part it fails with
the ciphertext dropped from the upstream request.
TestSealedTaskRefusedByAnotherRelay covers a lead on s2b and an unknown
lead. go test -race ./internal/gateway, go vet ./..., GOOS=linux and
GOOS=windows builds pass.
* codexcat: a third-party entry for a model Codex has its own entry for (s2a/gpt-6-astra) says the same multi_agent_reasoning_effort, so Codex's Ultra sends xhigh for it as for OpenAI's gpt-6-astra instead of falling back to max (#1108)
Codex resolves Ultra to the entry's multi_agent_reasoning_effort, and to
max when the entry has none and lists max. magpie's entries never carried
the field, so the same model ran at max through a relay and at xhigh
through the ChatGPT account. The value is taken from Codex's own entry in
models_cache.json for the model's slug (after the provider prefix and a
dated snapshot suffix), only when the third-party entry offers Ultra and
the effort itself; models whose own entry says nothing (gpt-6-sol today)
keep Codex's max. The gateway's literal "ultra" -> max mapping is
unchanged: Codex sends the resolved effort, not "ultra".
TestUltraEffortAsCodexOwnEntry, built from the reporter's entries, fails
without the change ("s2a/gpt-6-astra: multi_agent_reasoning_effort <nil>,
want xhigh"). go test -race ./internal/codexcat ./internal/gateway
./internal/agent and go vet ./... pass in a sandbox HOME.
* gateway: a DeepSeek model whose Codex request is passed through as it is (OpenCode Go's deepseek-v4.1-flash) gets its reasoning back as reasoning_text, and is never asked without it (#1104)
Codex hands a reasoning item back as it got it; one from a turn magpie
translated, or another provider in a mixed group served, has only its
summary. Passed through to OpenCode Go's /responses, DeepSeek refused the
request with "The `reasoning_text` in the thinking mode must be passed back
to the API". That refusal names the input, so passthrough asked once more
without every unsealed reasoning item (withoutBareReasoning) and marked the
model as refusing them, which left the reasoning out of every later request.
For a model that wants its reasoning back (replaysReasoning, the predicate
#388's buildResponses uses), a reasoning item without reasoning_text now
gets its summary as one, and withoutBareReasoning is never applied to it.
TestDeepSeekReasoningPassedThrough plays OpenCode Go with the reporter's
error word for word. Without the change it fails ("reasoning_text sent =
\"\", want the summary"); with only the strip guards taken out it fails ("a
reasoning item with nothing in it was answered"). go test -race
./internal/gateway, -race -count=10 on the new test, and go vet ./... pass.
* agent: a catalog sync or a model pick keeps the keys a user or another tool added to magpie's provider block (Pi's providers.magpie.compat.sendSessionAffinityHeaders from pi-cache-optimizer) instead of writing the block whole (#1103)
syncJSON and Pi's writeMagpie set providers.magpie to a freshly built
object, so a key magpie doesn't write there was dropped on every sync.
theirsKept now merges magpie's block into what the file holds: keys magpie
writes are its own, objects in both are merged key by key, and anything
else (compat, an options key, Crush's extra_headers) stays. The model list
is still magpie's whole, so a model gone from the catalog leaves it, and a
sync with nothing new writes nothing.
The same goes for every agent that writes the block whole: Pi and OmO,
Aside, Pencil, OpenCode (and the agents built on it), OpenChamber, Crush
and Command Code.
Verified: TestPiKeepsTheirKeysInMagpiesProvider (the reporter's
compat.sendSessionAffinityHeaders: true in Pi's models.json, through
SyncCatalog and a pick) and TestOpenCodeAndCrushKeepTheirKeysInMagpiesProvider
fail without the change ("compat.sendSessionAffinityHeaders dropped by the
sync", "opencode sync") and pass with it. go test -race ./internal/agent
and go vet ./... pass in a sandbox HOME.
* providers: a SiliconFlow key's card says SiliconFlow retired its balance query and has no API in its place yet, and that the balance and vouchers are on its console, instead of "410 Gone: {...}" (#1094)
SiliconFlow stopped /v1/user/info on 2026-08-14 (release notes, 2026.08.11)
and answers it with 410 {"code":20092,"message":"This endpoint is
deprecated and is no longer available.","data":null}. Its docs and release
notes name no replacement yet ("new interfaces will be announced
separately"), and the balance given before 2025-12 became vouchers, seen
under 余额充值 → 代金券 on the console. So the card now says that and links
cloud.siliconflow.cn (cloud.siliconflow.com for the international site). A
key SiliconFlow refuses (401 30014 "Token is invalid.") is still said as it
came, and a 200 is still read.
TestSiliconFlowRetiredBalanceQuery replays the reporter's literal 410 body
for both hosts, plus the real 401 body and the documented 200; without the
change it fails with `410: true 410 Gone: {"code":20092,...}, want it
retired and https://cloud.siliconflow.cn named`. go test -race
./internal/provider and go vet ./... pass. Not tried with a real
SiliconFlow key (none on this machine); the 401 body was read from the
live endpoint with a dummy key.
* gui tests: routing-failed-open measures the 400's row once the live page has followed the newest request and drawn its story
In WebKit the live cases read the row's top as soon as the list was there,
before the page had followed the newest request and drawn its story above
the list (.rt-log grows 17 → 104px), so the row was 87px lower by the click
and "picking the request moved the page" failed — with the test's own
commit's assets too (
|
||
|
|
dfde3acafc |
provider, gateway: a Claude /usage reset with no year is read within its window, so a reading a few days old no longer benches an account for a year (#1212)
* provider: a Claude reset read in the hour the clocks repeat, or the night they skip it, is the coming one, and a Dec 31 with no year read just after New Year is last year's
claudeResetTime turns /usage's reset text into the ResetsAt of the
"5 hours" and "7 days" windows, which smart routing orders accounts by and
rests a full account until.
- Where the clocks go back and read a time twice, time.Date gives either
instant: the first west of UTC, the second east of it. Each reading is
now considered (nextReading), and the one not yet past is taken.
Before, at 2026-11-01 01:20 EST (the second 01:20), "Nov 1, 1:30am
(America/New_York)" was 01:30 EDT, 50 minutes past, so a full window
read as renewed; "1:30am" was 24h10m away instead of 10m. In Berlin at
02:20 CEST on 2026-10-25, "2:30am" was 02:30 CET, 1h10m away instead of
10m (Sydney and Lord Howe alike).
- A date with no year is in the first of last year, this year and next
that is less than a day past, as the doc comment already said. Before,
only this year and next were tried, so at 2027-01-01 00:00:05
"Dec 31, 11:59pm" was 2027-12-31, 8760h away, and a 7-day window that
had just renewed read as renewing in a year.
- A time alone the clocks skip today is the next day that has it,
rebuilt from the clock read rather than from the normalized time.
Before, the evening after New York skipped 02:00-02:59, "2:30am" was
tomorrow's 1:30am; at 23:31 before Santiago skips midnight, "12:30am"
was 23:30 that night, a minute past, instead of 00:30 the day after
tomorrow.
What Claude Code prints, from the /usage formatter in the 2.1.290 binary:
every line of `claude -p /usage` carries a date, with the year only when
it isn't this one, so the New Year case needs /usage run just before
midnight and parsed after, and the repeated hour shows in the dated form.
Whether "at" or a comma joins date and time follows the platform's ICU,
and both are parsed. A time alone is the older form, which the TUI still
prints for the session within a day.
No other parser in internal/provider fills in a missing date or year:
zhipuTime and mimoTime read full dates in UTC+8, and the rest read
RFC3339 or Unix times.
Verified:
- TestClaudeResetTimeAroundClockChanges and TestClaudeResetTimeAtNewYear
are new. With upstream's claudeResetTime put back, 15 rows fail, e.g.
`"Nov 1, 1:30am (America/New_York)" at 2026-11-01 01:20:00 -0500 EST:
2026-11-01 01:30:00 -0400 EDT true (in -50m0s)` and `"Dec 31, 11:59pm
(America/New_York)" at 2027-01-01 00:00:05 -0500 EST: 2027-12-31
23:59:00 -0500 EST true (in 8759h58m55s)`; the 16 controls pass. With
the change, all pass, as does TestParseClaudeUsage, also with
-race -count=20.
- An oracle that lists every instant a zone shows a clock at was swept
over 518 zones, every transition 2025-2030, 85M calls: no deviation in
anything Claude Code can print. What remains is Feb 29 in a non-leap
year and dated times the clocks skip, both unchanged from upstream.
- Under a temporary HOME with GOPATH, GOMODCACHE and GOCACHE pinned:
go vet ./..., go build, GOOS=linux go build -tags nogui,
GOOS=windows go build, go test -tags nogui ./... all pass.
- Not run: claude -p /usage on a real account across a clock change or
New Year.
* provider: a Claude reset further ahead than its window can reach is the one already past, so a /usage reading days old no longer rests an account for a year
claudeResetTime now gets the window's length (5 hours for "Current
session", 7 days for "Current week ...") and never reads a reset further
ahead than that plus claudeResetSlack (2h, for this machine's clock being
behind Anthropic's; Claude Code prints the reset from Anthropic's instant
with seconds dropped, so never later).
- A date with no year is in the first of last year, this year and next
that is less than a day past and within the window. Before, it was the
first less than a day past: read on Oct 5, a week's "Oct 1, 3pm" was
2027-10-01, 8667h away, and routing (candidate.full, Allowance.Full)
rested a full account that long. Now it is 2026-10-01.
- Where no year fits, the latest year where it is already past is taken,
rather than dropping ResetsAt. The date read fine, and the window's
length rules out every later year, so the reset has passed and the
window has renewed, which is what elapsed() does with a past reset.
Dropping it would keep a 100% window with no time to clear it: Pooled
counts a full model window with no reset as out, so Opus on the account
stayed refused until the next read, and the card showed 100% with no
reset.
- A 5-hour window's date 6 days ahead ("Oct 11, 3pm" on Oct 5) is likewise
last year's and reads as renewed; a week's keeps Oct 11.
- A time alone (the older form) is the next one within the window, else
the latest day where it is already past: at noon, "11am" is today's for
the session (was tomorrow's, 23h on) and tomorrow's for the week.
- A fresh /usage read now goes through elapsed() as a kept one does, so a
reset it gives as past reads as renewed at once. Before, only the next
look did, and the switch (NextLogin's usedPast) and quota alerts saw the
fresh 100%.
- Dates with a year are unchanged.
Tests: TestClaudeResetTimeWithinItsWindow covers the cases above, the
inclusive bound (exactly span+slack is kept, a minute more is not, for a
date and a time alone), a week across New York's fall-back, and
claudeWindows' fresh and kept reads of a 4-day-old reset (Used 0, no
reset, Full zero). The DST rows and the other New Year rows pass
unchanged, at the week's length. Changed existing rows, each of which
asserted a reset further ahead than any window reaches: TestParseClaudeUsage's
"Current week (Opus) ... Jan 2 at 2pm" read Oct 1 (now 2026-01-02), the
#631 "Jan 2, 9:05am (UTC)" row (2026-01-02), and the New Year "Dec 30,
11pm" read Jan 1 01:00, 26h past (2026-12-30, not 2027-12-30). The #631
format rows are read on Oct 3 instead of Oct 1, so "Oct 9, 2:59pm" stays
within its week. Fixtures that gave a 5-hour session a reset the next day
(soon(1) at 3:30pm in claude_usage, claude_seats, claude_usage_switched;
claude_made_first in gateway; "Jul 20 at 10am" at midnight in
claude_scoped) now give one 3 hours on, as /usage would.
Fails without: with the bound removed and fresh reads not through
elapsed, TestClaudeResetTimeWithinItsWindow fails ("Oct 1, 3pm (UTC)" for
168h at 2026-10-05: 2027-10-01 15:00 (in 8667h), want 2026-10-01 15:00;
"Oct 11, 3pm (UTC)" for 5h: 2026-10-11 (in 147h), want 2025-10-11;
"read true: held until 2027-10-03 15:00"), as do TestParseClaudeUsage and
TestClaudeResetTimeAtNewYear on the changed rows. With the bound but
fresh reads not through elapsed, it fails with "read true: 5 hours
Used:100 ResetsAt:<4 days ago>".
Verified, under a temp HOME with GOPATH, GOMODCACHE and GOCACHE pinned:
go vet ./..., go build, GOOS=linux go build -tags nogui, GOOS=windows go
build, go test -tags nogui ./... (all pass), and go test -race -count=20
-run 'TestClaudeResetTime|TestParseClaudeUsage' ./internal/provider.
Not run: claude /usage on a real account read days late. Unchanged: a
week's time alone read minutes after its reset still reads as tomorrow's
(at most a day).
* gateway: a subscription account that fails with a window full rests until it renews, but no more than 8 days, so a reset time read a year off no longer benches it for that year
restAfter's default branch (a failure that isn't credit, quota, a rate
limit or verification: a 502, a sign-in error) rests a subscription
account until its fullest window renews, by c.full(now). That time is
the vendor's reading, not the failure's: Claude Code's /usage text, read
by claudeResetTime, takes a date with no year for next year when it reads
it wrong, and the account then sat out ~365 days until the user noticed
and lifted it by hand. The failQuota branch of the same function already
holds every rest, window included, to longestQuota (a week's window and a
day over); the window rest here now takes the same bound:
min(t.Sub(now), longestQuota). A window renewing within 8 days rests
exactly as before.
Why longestQuota and not the full window's own Span: Allowance.Full
returns only the renewal time, so the call site doesn't know which window
was full or how long it runs; Span is zero where a reading doesn't say
(plugin windows may not); and a failure that isn't about quota must not
sit out longer than one that is, which the quota branch already caps at
8 days. Bounding by Span would only shorten a misread 5-hour window from
8 days to 5 hours, and would have to change the quota branch with it, so
it is left for a change of its own.
Siblings: restAfterMarked is the only place that stores a rest
(restingUntil). Its other branches are bounded already: failQuota by
longestQuota (resets in the body, the noted header, the window and
Retry-After alike), failRate by longestWait and longestRateRest, credit
and verify by constants; a key never takes the default branch's window
rest (c.p.Account != nil). Every other use of a window's reset (capHeld,
Allowance.Full/Renewal/Restarts/Pace in weighing, the sink condition) is
computed again from the latest reading on each request and stores
nothing. Not changed here: cappedError's soonest renewal still becomes
the agent's Retry-After uncapped, and a stored window rest isn't lifted
when a later reading corrects the time (only OnRenewed or a manual
Unrest does), so a bad reading can still bench an account for up to 8
days. Claude's parsing is fixed in the commit before this one, and a
Claude account's window rest is lifted by a renewed reading in #1200.
docs/subsystems/gateway-routing.md says so in the runtime path; its
gateway test list already takes the new test by its TestRouting prefix.
Verified under a temporary HOME with GOPATH, GOMODCACHE and GOCACHE
pinned: go vet ./..., go build, GOOS=linux go build -tags nogui and
GOOS=windows go build pass. TestRoutingHoldsAWindowRestToLongestQuota (new): a
Claude account whose window is full with ResetsAt a year out, failing
502, rests at most longestQuota; a control renewing in 3h rests 3h.
Without the change it fails: "window read a year off: rests
8759h59m59.999682292s by window (other), want at most 192h0m0s". It
passes with -race -count=20, with TestOrderedKeepsAnAccountAt98 and
TestQuotaRestsUntilItsReset. The routing reference's gateway test set
passes.
go test -tags nogui ./... ran with the machine at a load average of
~125 on 12 cores, and three packages failed there, none on this path:
internal/davsync timed out at 10m (it passes alone in 353s);
TestSetAnswersWhileDevinAndProvidersHang fails on
|
||
|
|
d1bbc5b65b |
provider: Volcengine plans use their preset models instead of /models (xyenon) (#1216)
Coding Plan's /models returns Ark's general catalog, while Agent Plan's returns 404. Skip their model-list requests and ignore old fetched lists, including their protocol and key restrictions. Pay-as-you-go and an explicit list URL still fetch normally; saved model choices stay intact. Both plan endpoints were checked with real keys. TestVolcengineModelLists fails without the no-list behavior and passes with it. Vet (gtk3) and Linux GUI/CLI and Windows builds pass under a temporary HOME. The full nogui suite passes except TestCodexArchivedSameSizeRewriteInvalidatesDiscovery in sessions, also reproduced separately with -race -count=20. Amp-Thread-ID: https://ampcode.com/threads/T-01a11791-db8b-7229-ade8-5e3600b835fc Co-authored-by: Amp <amp@ampcode.com> |
||
|
|
586f2bce8f |
context: magpie inspects the context window of every request, session and agent
The gateway reads each request's prompt into its parts (system prompt, tools and MCP servers, memory, files, tool results, chat), measured against the model's window and calibrated to the tokens the vendor counted. Routing shows a request's window as a card: a 400-cell grid by part, a hover naming what is largest in a cell, and the contents by part. The Usage page's Context tab scores each agent out of 100 (cache reuse, how lean it starts, how fast it grows, how reliably it answers), tags it (lean, cache-friendly, mcp-heavy, compacts, near-full…), and draws each session's growth with its latest request's card. Older Safari (15) gets media-query and solid-colour fallbacks for the card's container queries and color-mix. |
||
|
|
aff4f9f22c |
gui, tray, alerts, settings: magpie speaks Traditional Chinese (繁體中文) with Taiwan wording, picked on its own for a zh-TW, zh-HK or zh-Hant system
Settings → Language offers 繁體中文 beside 简体中文. On System, a browser or OS locale of zh-TW, zh-HK, zh-MO or zh-Hant (zh_TW.UTF-8 too) shows zh-TW; zh-CN, zh-SG and zh-Hans-HK keep Simplified. The tray menu and quota alerts follow the same choice. Release notes fall back to Simplified. The zh-TW strings are generated from the zh strings with opencc (cn→twp), then corrected to Taiwan wording: 設定, 檔案, 帳號, 伺服器, 預設, 閘道, 連接埠, 參數, 選單, 透過, 取得, 用戶端, 存取, 終端機, 「」 quotes. A quota window is 時段 and a context window stays 上下文視窗. Number units use 萬/億, and #unitsRow is shown for zh-TW. Verified: - New gui-zh-tw.test.cjs, in Chromium and WebKit: - key parity, placeholders and tags against zh; - no Simplified characters; - the Taiwan words; - locale detection for each system tag; - <html lang>, nav labels, fmtN units, the picker; - Settings Language at 1000px and 440px. With the sysLocale zh-TW line removed, it fails. On the base assets, all 3 fail. - gui-ja, gui-de and i18n-keys pass. - go vet, and go test -tags nogui for ./internal/gui and ./internal/settings, pass. That includes the new zh-TW cases in TestTrayLang / TestTrayLabels / quota_alert_test, which fail without the zh-TW branches. |
||
|
|
9dc0121f90 |
library: installing a set of skills skips the ones in the way and installs the rest; the toast names the ones already in the library and the ones skipped (lc on Discord: 安装skills的时候,没有跳过已经安装的)
installFrom stopped the whole set at the first skill whose name was taken,
and returned before change saved library.json. The folders it had already
copied for the skills before that one stayed in the library's folder,
unlisted. Every later try then failed on the first of them with "the
library's folder already has a caveman-commit in it".
Now each skill is installed on its own (InstallSkills, InstallMarketSkill
and AddNewSkills, through installChange):
- the library lists it from the same source: had already (Result.Had),
nothing written, so the user's edits stay;
- a folder in the library's folder holding exactly its files (not a link,
sameTree, which counts a failed read as different): listed where it is,
as had;
- another skill by that name, listed or only a folder, or a folder that
can't be read: left as it is, in Result.Skipped with why;
- the rest are installed (Result.Installed). Only when nothing was
installed or had does the change fail, with the first reason.
The GUI's toast and the TUI's line say how many were installed, the ones
already in the library and each one skipped with why.
Verified (temp HOME, pinned caches):
- go vet -tags nogui ./internal/library ./internal/tui ./internal/gui .;
GOOS=windows go build and GOOS=linux go build -tags nogui of
internal/library and internal/tui: clean
- go test -tags nogui ./internal/library ./internal/tui ./internal/gui: ok
- with the old skills.go, market.go and skillnew.go, TestInstallSetSkipsOneInTheWay,
TestInstallListsItsOwnLeftoverFolder (fails with lc's exact "the library's
folder already has a caveman-commit in it") and
TestInstallSetSkipsAnotherByTheSameName FAIL; TestSkillsFromGitHub's
reinstall now asserts Had
- skill-install-skip.test.cjs (Chromium + WebKit, en and zh at 440px) fails
on the old library.js ("status ok", no names) and passes now; with
gui-ja, gui-de and 11 library GUI files, 99/100 passed, and the one that
failed (by-agent, Chromium, at load average ~150) passes 3 times in a row
in both engines when run alone
- not tried in the real app, or with lc's library
|
||
|
|
bc7c62dc11 |
library: the skills market offers magpie's own magpie-quota skill first, marked official, so an agent can read what is left of every subscription, plan and key (magpie quota --json) and wait for allowance (magpie quota wait); it lives in magpie-community/plugins, and its card shows no installs or skills.sh link until skills.sh lists it (ttmouse on X: magpie quota works, suggested an official skill to go with it)
Verified: TestFeaturedSkills; market-featured-skill.test.cjs, Chromium and WebKit, en/zh/ja/de at 520px, 8 pass (4 fail on the old library.js); market-have and skill-online-local pass (34); a livemarket-tagged run of InstallMarketSkill in a sandbox HOME fetched the real skill from GitHub into the library; npx skills add magpie-community/plugins -l lists it. go test ./...: all pass but TestSetAnswersWhileDevinAndProvidersHang (2.8s under two other -count=5 suites); alone -count=5 passes. |
||
|
|
842272df84 |
gateway: a Codex subagent's sealed task goes back to the Responses provider that answered its lead (a Sub2API relay of the ChatGPT backend), relayed as it came, instead of being refused before it is asked; the refusal names who sealed it (#1109)
Codex on Ultra with s2a/gpt-6-astra for both the lead and the subagent, s2a a Sub2API relay: the relay's ChatGPT backend sealed the lead's spawn_agent task, and magpie turned the subagent away because only a Codex account counted as able to read it, telling a user already on a magpie model to "use a Magpie-served model for the lead". The provider that answered the lead (leadProvider, from the lead thread's affinity in any scope) now reads it too when it is a key provider serving the model on Responses; the task is relayed, even when Codex offers web_search (it was translated, which dropped the agent_message's encrypted_content). A subagent on another provider, or whose lead magpie never answered, is still refused before anyone is asked, with a message naming the lead's provider when known. Verified: TestSealedTaskGoesBackToTheRelayThatSealedIt (lead and subagent on s2a/gpt-6-astra, with Codex's web_search tool) fails without the change with the old 400; without only the relay part it fails with the ciphertext dropped from the upstream request. TestSealedTaskRefusedByAnotherRelay covers a lead on s2b and an unknown lead. go test -race ./internal/gateway, go vet ./..., GOOS=linux and GOOS=windows builds pass. |
||
|
|
8731dd9f38 |
docs: LESSONS.md gets the review of 10-07's commits — fix-the-class, re-derive, red-on-main and merge-record lessons seen a third day move into the code standards; not tried with the real thing seen a second day (b2345078→7837f90d, #1050, cda11d84); positives from 6b6113aa, d40fee80, bed306ae and #948
|
||
|
|
bed306ae60 |
gateway: reasoning off reaches Gemini 3 at its lowest level, and Google refusing minimal no longer stops a provider's thoughts (#1062)
* gateway: reasoning off goes to Gemini 3 at its lowest level where it has no minimal, and Google refusing minimal no longer stops the provider's thoughts until restart
With reasoning off, a request to Gemini's OpenAI-compatible API (AI
Studio's, or a proxy in front of it on this machine or the LAN) went with
reasoning_effort minimal, since Gemini 3 can't stop thinking.
gemini-3.8-flash has no minimal: models.dev gives its levels as low,
medium and high, and Google answers minimal with 400 "Thinking level is
unsupported: THINKING_LEVEL_MINIMAL". The message names thinking, so
refusesThinkingConfig took it for thinking_config turned away and marked
that field unfit for the provider. Until magpie restarted, nothing sent to
the provider asked for Gemini's thoughts again, on any model, and a client
that asked to see them got none. The turn itself was asked again with no
reasoning_effort, at the model's default level.
* Reasoning off goes at the model's lowest level where its levels are
known and have no minimal, and at minimal where they have it or aren't
known (Request.OffLevel, geminiOffLevel). gemini-3.8-flash on AI
Studio goes at low, in one request.
* Where the levels aren't known (a proxy's own name for the model),
Google's 400 for minimal is told apart from thinking_config turned
away (geminiMinimalRefused, which also matches gemini-3.1-pro-preview's
"thinking_level MINIMAL is not supported by this model" at
generateContent). It is remembered as the model refusing reasoning off
(offRefused, as for other vendors), and the model is asked again at its
lowest level, low when none are known, and so from then on.
thinking_config is kept, and the next turn that thinks gets the
model's thoughts.
* An effort of minimal set on the request, which fitFor keeps where the
levels aren't known, is turned away the same way and asked again at
low by the existing offEffort step, with its thinking_config.
Passthrough (a Chat client calling AI Studio itself) is unchanged: it fits
the effort to the model's levels where they're known, and has no retry for
a proxy whose levels aren't.
Tests, with what upstream's gateway gives:
* TestGeminiThinkingOffAtItsLowest: an Anthropic client's turn with
thinking disabled, then one thinking at high, then disabled again, to
a fake Gemini that answers minimal with the 400 above, in its literal
bytes. With the levels known, the first turn goes at [low]; not known,
at [minimal low]. Either way the thinking turn then sends
thinking_config {include_thoughts: true, thinking_level: high} and
gets a thinking block, and the last turn goes at low in one request.
Upstream sends [minimal <nil>] both ways.
* TestAIStudioThinking: "off at its lowest" sends reasoning_effort low.
Upstream sends minimal.
Each part was also broken on its own, and each break fails a test:
buildChat ignoring OffLevel ("reasoning_effort = "minimal", want "low"",
and [minimal minimal <nil>]), the loop never setting it ([minimal minimal
<nil>] both ways), no check for the refusal of minimal ([minimal <nil>]
with the levels not known), and geminiOffLevel ignoring the refusal
([minimal minimal <nil>]) or the known levels ([minimal low]).
Verified on
|
||
|
|
c83c0d5fef |
gateway: sharing keeps the host MAGPIE_ADDR names (127.0.0.1 behind Tailscale Serve, one interface's address) instead of listening on every interface, and still asks what reaches it from elsewhere for a gateway key (#1112)
listenAddr turned sharing into 0.0.0.0:<port> and dropped the host of an
explicit MAGPIE_ADDR, so requiring gateway keys from remote callers also
widened the listener. A host MAGPIE_ADDR names is now kept while shared;
0.0.0.0, :: or no host still means every interface, and an unset
MAGPIE_ADDR still shares on every interface. LANURLs offers the pinned
address alone, or none on loopback (MAGPIE_PUBLIC_URL says the proxy's).
Verified: TestSharedKeepsPinnedAddr fails without the lan.go change
("shared, MAGPIE_ADDR on loopback, listens on 0.0.0.0:4555") and passes
with it, -race -count=20. go test -race ./internal/gateway ./internal/gui
green under a temp HOME.
|
||
|
|
6b6113aa7a |
gateway keys: a key can be created with a value of the user's own, one their clients already send, so moving from another gateway (CLIProxyAPI's api-keys) doesn't mean re-keying every client (love1sbug on X)
Gateway → Gateway keys → Add gateway key has a second field, "Your own key
(optional)"; `magpie gateway-key add <name> --key <value>` (or `--key -`
from stdin) does the same. Left empty, magpie makes a sk-magpie-key- as
before. A value is 8–256 printable ASCII characters with no spaces, can't
start with sk-magpie-, and can't be another key's. The list shows only its
last four characters ("…0042"); rotating it gives a magpie-made key.
From another computer any enabled key was already checked, so an own value
works there as is. From this computer the gateway only identified a request
whose key looked like sk-magpie-…, so an own value would have been let in
unidentified: not counted as the key, nor held to its limit, models or
accounts. managedKey now asks access.Named, which also knows the store's
own values (cached by the file's size and time).
Tested: go test -race . ./internal/access ./internal/gateway ./internal/gui;
-count=20 on the new and LAN guard tests; TestOwnKeyValueIsTheKey fails with
managedKey back on access.Managed. gateway-own-key.test.cjs passes 16/16
(chromium and webkit, en/zh/ja/de, 1000 and 440px), and the gateway-key
suites, i18n-keys, gui-ja and gui-de pass. A built binary in a sandbox HOME
with sharing on: own key from the LAN address 200, a wrong or no key 401,
from 127.0.0.1 200, the old value after rotate 401. GOOS=linux and windows
builds pass.
|
||
|
|
fb9d1a4cc6 |
provider, plugins: a sign-in reads as done only once its account is listed again, and the command line's plugin sign-in brings the account back and takes its lapsed mark off (#1061)
* provider: a sign-in reads as done only once the account it brings back is listed again, so the window opens it rather than saying magpie can't list it (TestClaudeTeamSeatBesidePersonal failed so on macOS in #1046's CI) Signing in brings back an account removed from magpie (ShowAccount clears its Hidden mark). Since |
||
|
|
54b4e36dcf |
providers, gui: a custom provider saved as OpenAI Responses opens as OpenAI Responses, not OpenAI compatible (01huadalang on Discord: 我选 response 保存了然后再打开一会就变成 openai 兼容了)
The editor never saved which API the Base URL was picked as; reopening showed the first API with a URL, chat first. A provider added as OpenAI compatible keeps its chat URL when Responses is picked (#105), so after Responses was picked, its URL typed and saved, it opened as OpenAI compatible again. Picked with no URL of its own, the Save sent nothing for it and the pick was gone without a word. Now the pick is saved with the provider (baseAPI), sent back to the editor and shown while it has a URL; a save that doesn't say keeps it, and one whose URL is cleared elsewhere drops it. A Save with the picked API's URL empty while another has one says so in the form. Requests are unchanged: they go by which URLs are set. Verified: reproduced on origin/main in a sandbox HOME with the built binary (magpie web, real GUI via Playwright, fake Responses-only upstream): a provider added as OpenAI compatible, then saved as Responses with and without its URL typed, reopened as OpenAI compatible. With the change it reopens as OpenAI Responses after a reload, a 65s wait and an app restart, and the empty Save shows the message. A provider added as Responses from the start was already kept and still is. TestProviderBaseAPIKept fails without the change ("added as OpenAI compatible, shown as \"\""); provider-base-api.test.cjs fails on the old app.js (actual 'OpenAI compatible', expected 'OpenAI Responses') and passes in Chromium and WebKit, en and zh. gui-ja, gui-de, i18n-keys and the provider*/system-one/azure/add-sheet/menu-scroll GUI tests pass in both engines. go vet, GOOS=linux/windows builds, go test -tags nogui ./... pass under a temp HOME. |
||
|
|
6f65ffb5ff |
agent: CodeBuddy Code, Cline, Gemini CLI, AtomCode, Goose and Reasonix installed in a WSL distro are found and wired there (lgtm on Discord: magpie 现在无法检测到安装在wsl中的codebuddy呀?)
wslKinds had none of these CLIs, so each was set up only on Windows. Each now has a place-aware constructor (codebuddyIn, clineIn, geminiIn, atomcodeIn, gooseIn, reasonixIn). It writes the distro's own config with the gateway as the distro reaches it (mirrored or NAT) and the key it takes from there. A distro reads none of Windows' variables (CODEBUDDY_CONFIG_DIR, CLINE_DIR, APPDATA...). Kinds are found by their folder or their command. Gemini CLI is found by its command alone, because Antigravity keeps ~/.gemini too. Goose is found by its folder alone, because a goose command can be pressly's. Reasonix also needs a native version (new wslKind.accept). Gemini CLI also counts a .env that names an old address with magpie's key as magpie's own. Before this, a WSL restart that moved the distro's address made it read "custom", and picking the model again stashed magpie's old address as the user's. Verified: - On the Windows test box (WSL2 magpie-desktop, NAT), with a sandboxed nogui magpie.exe (own USERPROFILE/APPDATA, port 3441), real CodeBuddy Code 2.161.4, Gemini CLI 0.63.0 and Cline CLI 3.0.68 were installed in the distro. `magpie ls` listed each as "· WSL magpie-desktop". Picking relay/glm-4.6 wrote ~/.codebuddy/models.json, ~/.gemini/.env + settings.json and ~/.cline/data/settings/providers.json in the distro at http://172.17.32.1:3441, and wrote nothing in the sandbox home's agent folders. The installs were removed afterwards. - Not tried for real: AtomCode, Goose and Reasonix in a distro, and a chat request from a WSL CLI through the gateway. - Tests: gofmt; go vet ./...; -race on internal/agent; the full `go test -tags nogui ./...`; GOOS=windows and GOOS=linux builds. TestWSLProbeFindsMoreAgents and the rest of TestWSLMore* panic with "no WSL agent codebuddy" on the old wsl.go. TestWSLGeminiAddressMoved fails ("auth custom") without the gemini routed change. |
||
|
|
d5468283bb |
gateway: a web page that isn't listed in Settings, on this computer or the gateway's own address is refused 403 on every route
On loopback a call needs no key and the body's Content-Type isn't checked, so any page the user visited could send a "simple" text/plain POST to 127.0.0.1:3425 and spend their subscriptions without reading the reply. corsGuard now refuses a request whose Origin is an http(s) origin that is not on Settings → Network and sharing → Web pages, not loopback (localhost, *.localhost, 127.0.0.0/8, [::1], any port) and not the request's own (host:port equal to Host). usemagpie.ai still reads /v1/magpie/omarchy. No Origin (agents), "null" and non-http schemes (app://, file://, tauri://, vscode-webview://, chrome-extension://) pass as before: those clients could only call before by not enforcing CORS, and no web page can send those origins. Such a page never read a reply before (no CORS headers), so nothing that worked is refused. Verified: TestForeignPagesRefused (text/plain POSTs from https://evil.example and other foreign origins on every route family: /v1/chat/completions, /v1/responses, /v1/messages, count_tokens, /v1/systemone, /v1beta, /v1/embeddings, /v1/rerank, images, videos, the Codex backend path, /mcp, /_magpie/claude-mcp, /v1/magpie/*, /v1/models, the GET /v1/responses upgrade, OPTIONS; loopback, *.localhost, listed with a key, same-origin on a LAN address through lanGuard, no Origin, null, app://, file://, tauri://, vscode-webview://, chrome-extension:// answered) fails without the change (120 routes answered, the fake vendor reached 139 times). go test -tags nogui ./... green; -race -count=10 on the CORS tests; darwin, linux nogui and windows builds. The built binary served from a sandbox HOME on 127.0.0.1:3591: curl from the foreign origins got 403 on every route with the vendor untouched; the allowed origins got 200. |
||
|
|
b234507842 |
gateway, settings, gui: web pages the user lists in Settings › Network may call the gateway from a browser, each request with a gateway key (SkyAerope, #1051)
A page on http://localhost:3000 asking /v1/models got a 404 to its preflight and no Access-Control-Allow-Origin, so the browser hid every answer. Settings › Network › Web pages now takes origins (cleaned to scheme://host[:port]; "*" and anything that isn't an origin are refused, said in the page's language). For a listed origin corsGuard answers the preflight (204, the asked header names, Private Network Access) and adds Access-Control-Allow-Origin; every other request from it needs an enabled gateway key, even from loopback, or gets a readable 401. Default: none listed, and a request with any other Origin or none (every agent's) is unchanged. A save of the rest of the Settings page keeps the list. Verified: TestCORSOrigins (gateway, loopback requests: preflight, keyed calls, keyless/managed-token/turned-off key refused, unlisted origins incl. "null" and port/scheme variants get nothing, agents untouched) fails without corsGuard in Handler (preflight 404); TestCleanOrigins; TestCORSOriginsSetting fails without keeping CORSOrigins in POST /api/settings; TestSettingsSaveKeepsWhatItDoesNotSend. GUI cors-origins.test.cjs (en/zh, 440px, add by button and Enter, refusals, remove, no scroll, no left border) in Chromium and WebKit fails without renderCORS; gui-ja, gui-de, i18n-keys, gateway-port, lan-docker, redact-rule-row, settings-* pass in both. go test -tags nogui ./... green; -race -count=5 on the new tests; darwin, linux nogui, windows builds. click-scroll's two Requests-page subtests fail the same on origin/main. |
||
|
|
0c08b1d5bb |
agent: Claude Code's tier and subagent models the user set for their own endpoint come back with it when magpie steps out (#1050)
They were stashed nowhere: Connect took ANTHROPIC_DEFAULT_*_MODEL, ANTHROPIC_SMALL_FAST_MODEL and CLAUDE_CODE_SUBAGENT_MODEL over and Disconnect or Claude Code's own model deleted them, giving back only the base URL and token. |
||
|
|
c44698bdaa |
agent: a model given to one of Claude Code's tiers or its subagents stays theirs when the main model moves onto it and on again (#1050)
#1050's sonnet tier and subagents were given Sonnet 5.5 with the main model on Opus 5.5. Moving the main model to Sonnet 5.5 made the two read as following it, so its next move took them along: back on Opus 5.5, ANTHROPIC_DEFAULT_SONNET_MODEL said Opus and CLAUDE_CODE_SUBAGENT_MODEL was gone, the pick lost without a word. set now marks a tier or the subagents whose own model the new main model is (ownKey, the mark a tier already got when the main model was picked for it where it would follow another), in magpie and through /model's Follow alike. While marked they read as that model, not "same as model"; "same as model" picked for them takes the mark away and they follow again. TestReseatOff expected the sonnet tier given ds/gpt-5 to read empty after the main model was reseated onto ds/gpt-5; it now keeps ds/gpt-5. The #1050 drift tests leave the marks out, as a magpie before this left the state. TestClaudeOwnTierStaysThroughTheMainModel (magpie and /model) fails without the change ("on the main model, the tier and subagents read as their own": sonnet and subagent empty), as does TestReseatOff with the old expectation flipped. go test -race ./internal/agent ./internal/profile pass; -race -count=20 on the Claude, WSL and reseat tests passes. |
||
|
|
99f07249e8 |
agent: Claude Code's tiers and subagents following a main model moved onto the model magpie gave them aren't "changed outside magpie" (#1050, cjyrainbow: Claude Code · WSL Ubuntu 已在 magpie 之外换掉 claude/claude-sonnet-5-5[1m])
With the sonnet tier and the subagents on Sonnet 5.5 and the main model on Opus 5.5, moving the main model to Sonnet 5.5 in Claude Code's own /model left the two following it: they read as empty, and Drift took that for the agent's default replacing the Sonnet 5.5 in applied.json. Drift now reads an empty field that follows another (Follows) as on that field's model, so the usual rules apply: a move between magpie's models isn't drift, the main model taken off magpie still is. The subagents field now says it follows the main model, and a stopped WSL distro's agent keeps Follows too. TestClaudeFollowerOnTheMainModelIsNoDrift and TestWSLClaudeFollowerOnTheMainModelIsNoDrift (running and stopped distro) build #1050's record through Apply and the switch through /model + Follow. Without the change both fail with "replaced Field:sonnet Now: Want:anth/claude-sonnet-5-5[1m]"; without the wsl.go line the stopped distro still does. go test -race ./internal/agent ./internal/profile pass. |
||
|
|
89b3a20f5d |
gui tests: a test run leaves no gui.test folder in the real ~/Library/WebKit or ~/Library/Caches (waitingsnow) (#1047)
TestTrayCellClickReleasedPanel shows its panel in the test binary run
again as a child. The package's TestMain gives the tests testenv's HOME,
but WebKit takes the home from the account's record, so every run wrote
the child's WebKit data to ~/Library/WebKit/gui.test and its caches to
~/Library/Caches/gui.test in the real home.
The child now starts through runAppKit (appkit_darwin_test.go). It sets
CFFIXED_USER_HOME, which CoreFoundation reads before the account's
record, to a temporary folder, so WebKit's data and caches go there. It
runs the binary under a name of its own, through a hard link: WebKit
names its folders after the process, whose name comes from the file, not
argv[0]. The test fails when a folder of that name is left in the real
~/Library/WebKit or ~/Library/Caches. The empty folders WebKit makes
under the name in the account's temporary folder (NSTemporaryDirectory,
which TMPDIR doesn't move) are removed. Wails v3 beta.24 offers no
non-persistent WKWebsiteDataStore: its windowNew makes a plain
WKWebViewConfiguration, which uses WebKit's default store.
CFFIXED_USER_HOME doesn't move the account's settings: cfprefsd still
serves the global domain and com.apple.universalaccess, where dark mode
and Increase Contrast are kept. A probe window read the same appearance,
Increase Contrast, Reduce Transparency, layout direction, languages and
non-default values (AppleShowScrollBars, AppleReduceDesktopTinting, the
zoom factor) with and without it, on macOS 26.7.1. Nothing is written
to the temporary home after the child exits.
Without CFFIXED_USER_HOME the test fails:
trayusage_panel_darwin_test.go:20: the windows' process left /Users/wei/Library/WebKit/gui.test-1791307161567260000 in the account's real home
trayusage_panel_darwin_test.go:20: the windows' process left /Users/wei/Library/Caches/gui.test-1791307161567260000 in the account's real home
--- FAIL: TestTrayCellClickReleasedPanel (0.45s)
With it, it passes, and nothing new appears in the real ~/Library or in
the temporary folder.
PR #1042's TestMainWindowCorners and TestMainWindowRightToLeft, moved
onto runAppKit at its head f89991b1, pass and print the same with and
without CFFIXED_USER_HOME: corners 16.00pt (plain 16.00, toolbar
26.00), lights [[26 26] [49 26] [72 26]], title bar 66pt, a sheet 66pt
below the top, right to left lights [[874 26] [851 26] [828 26]].
Without it, both fail the same way.
go vet ./..., go build ./..., GOOS=linux go build -tags nogui ./...,
GOOS=windows go build ./..., go test -tags nogui ./..., go test
-count=1 ./internal/gui/... and gofmt -l pass under a temp HOME with
Go's caches pinned. docs/subsystems/gui-shell.md says it. Tests only.
|
||
|
|
05683637d5 |
plugins: the Bun the plugin host runs on is kept as magpie-bun, so a proxy app's PROCESS-NAME rule can route the requests plugins (Cursor and the other moved subscriptions) make (#1048)
magpie downloads its own Bun into ~/.cache/magpie/bun/<version>/. It is now written there as magpie-bun (magpie-bun.exe on Windows), and a Bun downloaded before is renamed in place the first time it is looked for, so nothing is downloaded again and no second copy takes disk. The host, bun add and bun update all run it, so all of their traffic carries the one name. A $MAGPIE_BUN the user set is run as it is. Verified: TestBunDownloadedAsMagpieBun, TestBunDownloadedBeforeTakesTheName and TestHostRunsAsMagpieBun fail without the change (the Bun is .../bun, the host runs .../bun). TestHostRunsAsMagpieBun starts the real host on a copy of Bun 1.4.2 and `ps -o comm=` names it magpie-bun on macOS. On the Windows box the three tests pass with the Windows build of Bun 1.4.2, a running bun.exe renames to magpie-bun.exe, and Get-Process/Win32_Process name it magpie-bun. Linux not run here (CI's ubuntu job runs the test with Bun on PATH). go test -tags nogui ./... green; -race -count=10 on the new tests green. |
||
|
|
de2c84ace7 |
gateway: a Responses relay that turns away reasoning with nothing sealed in it, naming only the input, is asked once more without it, and isn't sent it again for that model (#1044: Codex's /compact through Anyrouter failed with 400 invalid_request_error param=input on old conversations)
Codex hands back reasoning items with no encrypted_content (an rs_ id and a summary, as magpie's translated replies give it). #1008 left them out only for OpenAI's API and Azure OpenAI. A relay in front of OpenAI turns them away the same way but with its own message ("bad response status code 400", param "input"), so the compaction, which is relayed as it is, failed. They are not left out for every other upstream: a DeepSeek model behind a Responses relay wants its reasoning back (#388), and magpie's own translator reads the summary as thinking. passthrough now, on a 400 to a Responses request whose error names the input (refusesInput: param input / input[…], or an item named by id), asks once more without the bare reasoning (withoutBareReasoning), sealed reasoning and every other item kept; once that works the provider and model are remembered (bareReasoningRefused) and later requests go without it at once. Any other 400 goes back as it came. TestBareReasoningLeftOutWhereRefused (the reporter's item kinds: message, function_call(_output), custom_tool_call(_output), two bare reasoning items and a sealed one; the relay's 400 word for word) fails without the gateway.go change for both the compaction and a relayed turn (400 back to Codex) and passes. TestBareReasoningKeptWhereTaken and TestBareReasoningKeptOverAnotherRefusal guard the upstreams that take it and other 400s; TestAzureCompactionWithoutMagpiesReasoning (OpenAI/Azure unchanged) still passes. -race -count=5 on these, TestCodexCompact* and TestImageTool*; go test -tags nogui ./internal/gateway ./internal/provider, go vet ./internal/..., gofmt, linux and windows (nogui) builds pass. Not tried against Anyrouter itself (no account); the 400 is from the issue's capture. |
||
|
|
698da36f31 |
gateway, providers, gui: a model on this computer or the local network can be set to get requests unmasked by Settings' redaction (lc on Discord: 本地模型可以不脱敏)
The provider editor has a Redaction row, "Send requests unmasked", while
every address of the provider is on this computer or the local network
(localhost, *.local, a loopback or private IP): Ollama, LM Studio, a vLLM
box. Ticked (provider.Unredacted; CLI `magpie provider set <id>
unmasked=yes`), a request whose provider and fallbacks, or whose routing
group's models, all skip redaction (provider.SkipsRedaction) goes to them
as the agent wrote it; anything that may reach another provider stays
masked for all of them. An address edited to a vendor's masks it again; a
signed-in account, a plugin's provider and another magpie never skip. The
log and recent calls keep the masked body. count_tokens and embeddings
follow the same rule.
It is a per-provider choice, not automatic for local addresses, because
a local address isn't a local model: a relay run locally (new-api,
CLIProxyAPI) or Ollama's cloud models pass the request on to a vendor.
Verified: TestLocalUnredactedProviderGetsTheRequestWhole (set, unset,
fallback to a vendor, fallback to another local one, a group of it alone,
a group with a vendor; chat and count_tokens) and TestLocalUnredactedEmbeddings
fail without the gateway change ("sent whole false, want true: … my key is
{{API_KEY_…}}"; count_tokens alone: "counted whole false"); TestSkipsRedaction;
TestProviderSaveKeepsUnredacted fails without the preset save line.
provider-unmasked.test.cjs (Chromium+WebKit, en/zh, 900 and 440 wide) 32/32,
3 of 4 fail on the old app.js; gui-ja, gui-de, i18n-keys, provider-search
and the other provider editor suites pass. go test -race on gateway,
provider, redact, gui; -race -count=10 on the new tests; go vet,
GOOS=linux/windows builds. Real: a sandbox gateway in front of the local
Ollama (llama3 8B) through a logging proxy: unset, Ollama was sent
{{API_KEY_…}}; with unmasked=yes it was sent the key as written, and
answered both times.
|
||
|
|
92469ee9f9 |
library, gui: a check for updates lists the skills a repository added beside the library's skills from it, to Add or Ignore (Raven on Discord)
Raven: "check for updates" only updated skills already in the library; when
dontbesilent2025/dbskill or mattpocock/skills adds a skill, magpie never said.
CheckSkills now asks GitHub's git trees API once per repo@ref (ETag-cached, a
304 is free) and only when a skill folder under the installed skills' common
folder (newBase) is neither in the library nor already offered does findNew
fetch the tarball for names and descriptions. library.json's seenSkills keeps,
per repo@ref, the paths the install picker showed and the ones set aside, so
an offered skill is offered once. The Skills page shows them under "Also in
their repositories" with Add (to the agents that have the repo's other skills)
and Ignore, plus Add all / Ignore all (/api/library/skills/{add-new,ignore-new}).
Verified: TestCheckSkillsFindsNewOnes (fake repo: nothing offered after
install, no tarball on a second check; two added skills offered, a nested one
and a .wip one not; Add installs with the repo's agents; Ignore keeps it out
on the next check via 304; remove clears) fails without the change (no
NewSkills / findNew) and passes -race -count=10; TestNewBase. A real run in a
sandbox HOME against GitHub: tdd from mattpocock/skills/skills/engineering
offered its 19 siblings; dbs from dbskill offered nothing (the picker showed
all), 33 with seenSkills wiped, and Add installed dbs-action. GUI:
skill-new-in-repo.test.cjs (Chromium+WebKit, 860/440px, en+zh) fails on
main's library.js and passes; gui-ja, gui-de, click-scroll, library-items,
every-skill, pick-skills, import/remove-all-skills, skill-groups,
skill-repo-groups, github-token, library-all pass. -race on library, gui,
tui; gofmt, vet, darwin/linux/windows builds.
|
||
|
|
a7e7fe2b0f |
library: switching a group of skills on or off no longer reads every copy every agent has (#1031, mintonight: 开关一整个技能组时很卡,要10多秒才能完成)
The page already sends one request for a group (agents-some), but its one
change ran a sync that, for every skill given as a copy (every WSL agent,
Windows without the right to link, agents set to copies), hashed the copy
and the library's skill twice, in every agent, on every change, and the
page's read hashed the copies once more. Over a WSL distro's share that is
every file of every skill read through 9P each click.
A copy's mark now keeps the hash of what was copied (markCopy). A copy
whose mark says the library skill's current hash, with nothing in it newer
than the mark, is current without its files being read (copyIsFresh);
takeEdits' walk of a copy is not done a second time in the same sync, and
each library skill is hashed once per change (libHash). Copies with an old
mark, or edited since, are hashed as before. Claude Desktop's copies and
project copies use the same check.
Measured in a sandbox (55 skills x 31 files, copies for 10 agents, a
5-skill group switched on for an 11th): the change went from 1.54s to
0.31s and the page's read from 0.66s to 0.25s on a local SSD; the copies
not touched are no longer read at all, which is the cost over WSL.
TestSyncDoesNotReadUnchangedCopies fails without the change ("codex's
copy of pdf was read and made again"): an unreadable file in an unchanged
copy is left alone now. TestCopyFreshByMark checks a changed library skill
is still copied again, an old mark without a hash still works, and an edit
in a copy is still taken. go test -race ./internal/library ./internal/gui
pass, the new tests pass -race -count=10; vet, gofmt, darwin/linux/windows
builds pass. Not run on Windows or WSL.
|