Files
Tested with new internal/gateway/served_test.go: TestServedModelRecorded (fake vendors in each protocol, streamed and not, relayed — Anthropic, Chat, Responses — and translated — Chat from Anthropic, Anthropic from Chat, Anthropic from Responses — each answering luna (marked), sol-2026-01-01 and models/SOL (not): the route, its try and the request log keep the name, Swapped as said), TestServedModelUnnamed, TestSwappedModel (the names above), TestSniffServedModel and TestCodexOwnModelServedTraced; and new internal/gui/tests/routing-served.test.cjs in Chromium and WebKit, en and zh (the tag on the swapped request alone, whole and in its row, no left border, its title; the story's line; a click that doesn't move the page; the dated and unnamed ones unmarked). With three requests alone the Routing page above the list jiggles by a few to 70px in WebKit on its own (on main too, with no served model in the fixture), so the fixture lists six. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./... and all GUI tests (80) pass. Not tested against a real vendor serving another model.
agent: Pi starts on the model magpie picks, and asks a Messages-only provider's models on Messages (#160 Aimer779; Cleary on Discord)
routing: a group's member may be a model at an effort of its own, provider/model:low (#189, @luw2007: model + effort 才是完整的组合项) — a member was only a model, reasoning as the agent asked or as the group's classifier picked, so "the same model, cheap and quick" and "the same model, thinking hard" could not both be in a group, nor could a rule send to one of them. A member now may carry an effort (none … max): a request routed to it is asked for that much reasoning whatever the agent asked or effort=auto picked, at the level the model has nearest, while the vendor is asked for the model's own id. The same model at two efforts is two members that rules (use=m:high), stickiness and the trace tell apart; an id whose own last part is a colon (a :free, :7b, :0) stays the model's. The group's reasoning levels are those of its members that follow the agent, and the editor has a reasoning button on each member. Tested with a fake provider through a sandbox magpie: the upstream bodies carry the fixed effort in Chat, Responses and Anthropic shapes, and the routing view shows m1:low / m1:high as two seats, light and dark
gateway: a subagent Codex's MultiAgentV2 spawns from a magpie-served lead gets its task (#141) — a namespaced call (spawn_agent, send_message, followup_task) now carries "encrypted_function_args": [], since Codex reads a call without that list as sealed and handed spawn_agent's plain message on as encrypted_content, which OpenAI could not decrypt for a gpt-6 subagent; and the agent_message item V2 hands a subagent its task in, and agents their messages to each other, is read as the receiving agent's user turn instead of being dropped, so a magpie-served subagent no longer started with nothing to do (an item with no content adds no turn). Top-level calls and the other encoders are as before. Checked end to end with Codex 0.155.1 and features.multi_agent_v2=true in a sandbox HOME against a scripted upstream: before, the subagent's request held only the environment context; after, it holds "Message Type: NEW_TASK … Payload: …" and answers it. Patch and tests by relus-cy, who found and reported both gaps
qoder: thinking turned off reaches Qoder as off, a refused usage sign-in no longer lapses the account, only a gone sign-in is a 401, and a tool's images are sent (#162 review follow-ups)
providers: Codex on Amazon Bedrock's GPT models goes to the runtime's Responses API (#176: Amazon Bedrock: Function tools with reasoning_effort are not supported for global.openai.gpt-6-luna in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.) — the preset gave Bedrock chat completions alone for every model not Claude, so Codex's Responses, tools and reasoning in it, were translated to chat, which the runtime refuses for its GPT models. bedrock-runtime serves the Responses API at the same /openai/v1 with the same Bedrock API key as a Bearer, for OpenAI's closed GPT models (not gpt-oss, which stays on chat): the preset and each region now have it, a Bedrock provider saved before is given it where its chat is, and a GPT model is asked there first (Codex's request relayed as it came; a chat client's taken there once chat refuses it). A chat endpoint with no Responses to go to that refuses tools with reasoning so is asked again with reasoning_effort "none". Tested with a fake runtime returning that 400 on chat and serving /openai/v1/responses (Codex and a chat client with tools, a provider saved without Responses, gpt-oss still on chat) and a chat-only relay refusing the same (retried at none); both fail on main. Not tested against a real Bedrock account
bedrock: Claude Code's betas are fitted to what Bedrock takes (#176: API Error: 400 Amazon Bedrock: Unexpected value(s)
advanced-tool-use-2025-11-20, prompt-caching-scope-2026-01-05, redact-thinking-2026-02-12 for the anthropic-beta header) — Bedrock's runtime checks anthropic-beta by name and turns the whole request away for one it doesn't know, and magpie passed Claude Code's betas on as it sent them. Now, at Bedrock, tool search is asked by Bedrock's name for it (tool-search-tool-2025-10-19, as Claude Code asks Bedrock itself) and the two it refused are left out; and at any provider, a 400 naming betas as unexpected is asked again once without them, and they're not sent to that provider again, so a beta Claude Code adds later costs one retry, not a failed turn. An anthropic_beta list in the body (how Bedrock's InvokeModel takes betas) is fitted alike. Tested against a fake Bedrock runtime that refuses unknown betas as the real one does: the issue's betas plus an unknown one go through on the first turn with one retry and straight on the next, and body betas (fails on main)
qoder: thinking turned off reaches Qoder as off, a refused usage sign-in no longer lapses the account, only a gone sign-in is a 401, and a tool's images are sent (#162 review follow-ups)
workbuddy: an unfinished chunk's empty finish_reason reaches the agent as null, and a repeated empty tool-call name no longer rides along — CodeBuddy CN (copilot.tencent.com) says "finish_reason": "" on every chunk that hasn't finished, where the OpenAI contract has null, and a strict client (the AI SDK) rejects the chunk for the empty value; it also repeats "function": {"name": ""} on every tool-call argument fragment after the first, and a client that merges a delta by overwriting any field it carries (the grok CLI) assembles a tool call named "" and fails it with "Tool not found". chatTidy, which already drops the empty reasoning_content of that stream, now sets the empty finish_reason to null and drops the empty duplicate, so the real name is only sent once. Tested against a fake WorkBuddy stream: both shapes mended, and a last-wins merge of the mended fragments assembles the call as it was meant (#190)
workbuddy: an unfinished chunk's empty finish_reason reaches the agent as null, and a repeated empty tool-call name no longer rides along — CodeBuddy CN (copilot.tencent.com) says "finish_reason": "" on every chunk that hasn't finished, where the OpenAI contract has null, and a strict client (the AI SDK) rejects the chunk for the empty value; it also repeats "function": {"name": ""} on every tool-call argument fragment after the first, and a client that merges a delta by overwriting any field it carries (the grok CLI) assembles a tool call named "" and fails it with "Tool not found". chatTidy, which already drops the empty reasoning_content of that stream, now sets the empty finish_reason to null and drops the empty duplicate, so the real name is only sent once. Tested against a fake WorkBuddy stream: both shapes mended, and a last-wins merge of the mended fragments assembles the call as it was meant (#190)
gateway: Claude Code's body reaches a relay as it sent it, but for the model's name (#179: packy: 您的请求体疑似被篡改) — swapping the model re-encoded the whole body, so its fields went out sorted and its <, > and & as \u003c escapes, and a Claude model's request without thinking gained thinking off; a relay that only lets Claude Code in (packy) took that for a body tampered with. The model is now replaced where it stands and nothing else is touched, a Claude model's request is not given thinking off (Anthropic's models think only when asked), and a model that thinks by default (DeepSeek) has it added at the end with the rest left as sent; other rewrites no longer escape <, > and &. Tested with two requests captured from Claude Code 2.1.284 in a sandbox (85 KB with its tools and system prompt): through the gateway to a keyed Anthropic provider they arrive byte for byte as Claude Code sent them; not yet against packy
gateway: Claude Code's own headers reach a key+URL Anthropic provider (#179: 403 packy: This API endpoint is only accessible via the official Claude CLI) — magpie sent Claude Code's request on with only anthropic-version and anthropic-beta and its own User-Agent, so a relay that serves only Claude Code, telling it by claude-cli/ and x-app, refused it. Now a request from Claude Code to a provider without an account goes on with its User-Agent, x-app, anthropic-*, X-Stainless-* and x-claude-code-* headers and ?beta=true as it sent them; its key to magpie never does, the provider's own is set. Tested against a fake upstream: the headers and query arrive, the provider's key and not magpie's, another agent still goes as magpie
claude: a turn in which Claude answered with two tool calls no longer gets stuck — Claude Code makes its MCP calls one after another, so the second was made only once the first had its result, while the agent ran both and sent both results back at once; the second result, for a call not made yet, was dropped, and Claude waited for it while the agent waited for Claude. A result sent before its call is now kept and handed over as the call is made, and only the results sent since the last answer are looked at (#84)
claude: the signed-in account is one account, and one out of quota hands over to the next (#177) — the account Claude Code is signed in to was named one way as a provider and another as a saved login, so it was served twice and its limits weren't found under its name; and
claude auth status was asked with magpie's own ANTHROPIC_BASE_URL/token in the environment, telling of those rather than the sign-in. Now both go by ~/.claude.json's email (with the org), the CLI's email when that has none, and the status is asked without magpie's wiring. A turn the CLI ends with an error result (out of quota, rate limited) ends the reply there instead of waiting on, and a non-streamed one answers 429 for quota words, so another account takes over as in a stream. Tested: the account named as its login, the quota result ending the reply, another account taking over
provider: Antigravity's models at several levels are one model with those levels (#150: 统一 Antigravity 模型变体与客户端推理档位的映射) — Antigravity lists gemini-3.7-flash-low, -medium and -high as three models, "Gemini 3.7 Flash (Low)" …, so a client picked a level twice: once in the model, once as its reasoning effort, with no telling which won. Now ids that are one id with an effort word after it (-low, -medium, -high, -extra-low, -minimal, -xhigh) are one model, gemini-3.7-flash "Gemini 3.7 Flash", declaring only the levels there are (low,medium,high; Gemini 3.1 Pro low,high), so Codex, Pi and the rest get reasoning levels for it. The level a variant is at is what Antigravity's name says where each name says a different one (gemini-3.5-flash-extra-low is "(Low)", gemini-3.5-flash-low "(Medium)"), else its id's word; a model only named like another (gemini-3-flash-agent, "Gemini 3.5 Flash (High)"; gemini-pro-agent) or with no sibling (gpt-oss-120b-medium) stays as listed. A request asks for the variant the effort picks, an effort it lacks at the nearest level (xhigh is high; medium on low/high is high); with none asked, the id without a level if Antigravity has one, else high — what Gemini 3 does unasked — else the highest. A variant's request carries no thinkingLevel, so the id and the level can't disagree. An old variant id still works: as named with no effort, and moved to the effort asked for. Saved picks and agents set to a variant (antigravity/gemini-3.7-flash-high) are moved to the family, at that level where the agent's own is unset, as Cursor's are. For Gemini 3 models without variants, medium now goes as medium to Flash (which takes minimal/low/medium/high) and still as high to Pro (low/high). Tested against the list an account's fetchAvailableModels gave (sandbox cache)
gateway: a tool's schema reaches Antigravity with only the keywords it takes (#187: Unknown name "exclusiveMinimum") — Antigravity refuses a request whose function declarations carry a JSON Schema keyword outside its subset, and magpie cut out only the ones it knew of, so zcode's and dsh's tools, whose numbers have exclusiveMinimum, were refused with 400 on every turn. Now each schema keeps only type, description, nullable, enum, properties, required, items, the bounds and pattern, and propertyOrdering, anything else left out; an exclusive bound given as a number becomes the inclusive one when there is none. Tested on a schema with exclusive bounds, format, title, uniqueItems, contains, dependentRequired and not
providers: Claude Code is signed in to its next account once the one it is on is spent, as Codex is, and an account keeps its own affinity, backoff and usage across a switch (#208 Cyb3rN8: Claude Code 用自己的模型时,登录账号额度用满不会自动切换 · #209: Smart 选中的账号和 Claude Code 登录账号互不同步…切换登录账号还会把按 id 记的状态串到另一个账号上) — Claude Code on its own model asks Anthropic as the account it is signed in to, never through magpie, so with A spent its sessions got "You've hit your session limit" while the gateway had moved on to B. The Codex watcher (KeepCodexOnAnAccountWithRoom, now KeepOnAnAccountWithRoom, same place: started by the magpie serving the gateway, a minute in and every 5 minutes) now looks at Claude Code too: when the account it is signed in to is spent and another ticked account's allowance is known and has room, it is signed in to that one through SwitchLogin (credentials and .claude.json's oauthAccount), as Use on the Accounts page does; not when the Claude account was removed from magpie. The rule for #209: the sign-in follows Smart, not the other way round — it moves at the share Smart counts an account spent (provider.SpentShare = 98, which the gateway's usedShare now is), for Codex too (it moved at 100%). The Routing field of a Codex or Claude subscription with several accounts says so: routing picks the account for what comes through magpie; the agent on its own uses the one it is signed in to, moved on at 98%. Sessions already running keep the account they started with until restarted, as SwitchLogin says; routing Claude Code's own model through the gateway (the codexFailover way) is not done here. #209's second part was a real bug: the account the agent is on is the candidate "claude" and the others "claude@user", so a switch handed "claude" from A to B — a conversation A answered was kept on B and traced kept=true, and the Least used tokens and the backoff count followed the id. who(), routed.used and routed.failures are now keyed by restKey (provider@user, as rests already were); rest itself (group members, the trace) is unchanged. Tested with new TestClaudeSwitchedWhenSpent (sandbox HOME, keychain off, a fake usage endpoint: 97% stays, 98% signs Claude Code in to b — credentials and .claude.json — a stays ticked; on b it stays; both spent it stays), TestCodexSwitchedWhenUsedUp still passing, new TestLoginSwitchKeepsEachAccountItself (fails on main: the conversation went to b, kept) and new internal/gui/tests/routing-signin.test.cjs in Chromium and WebKit, en and zh; login-import and routing-kind GUI tests pass. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./... pass (TestUserPath, TestClaudeAccountNamedAsItsLogin, TestClaudeIdentityServedWhileAsked and TestCodexVersion timed out at 5s under the full run's load and pass on their own). Not tested against real Claude accounts or a running Claude Code.
gateway: count_tokens counts the masked prompt, and a count that cannot be had is told apart — the count Claude Code asks for before every turn went to the vendor as the conversation was written, so Mask secrets was defeated on every one; it now goes masked, the same prompt the turn it counts sends. An endpoint that doesn't implement counting (404/405/501, or a 400 that says so, in English or Chinese) falls back to the local estimate as before; a limited key or a broken connection yields to the next checked key and rests the one that failed, as a generation would, while a client's own cancellation rests nothing; and a real error — a wrong key, a prompt past the model's context, an image it won't take — reaches the agent. The gateway tests are isolated from the machine now (a temp HOME, inert stubs for the sign-in CLIs and the keychain) so a cache that outlives a test can't reach a real login, and the redaction tests' cold starts run in parallel. (#103)
models: a model its maker lists with a thinking switch alone takes no borrowed levels, and the gateway sends it no more than high (#214 Moody-Max: opencode 的 mimo-v2.6 Flash 你这边设置的默认推理强度是 max 导致400报错)
models: a model its maker lists with a thinking switch alone takes no borrowed levels, and the gateway sends it no more than high (#214 Moody-Max: opencode 的 mimo-v2.6 Flash 你这边设置的默认推理强度是 max 导致400报错)
providers: a model list that says which endpoints each model is served on (supported_endpoints, as Command Code's does) is heeded for every provider, not Copilot's alone — a live-fetched list left it out, so Claude Code's request for Command Code's deepseek went to its /messages, which serves only its Claude models, and was turned away; it now goes to /chat/completions, translated, and a Claude model asked for over Chat goes to /messages. /messages and /v1/messages both name Anthropic's. Issue #93
claude: the signed-in account is one account, and one out of quota hands over to the next (#177) — the account Claude Code is signed in to was named one way as a provider and another as a saved login, so it was served twice and its limits weren't found under its name; and
claude auth status was asked with magpie's own ANTHROPIC_BASE_URL/token in the environment, telling of those rather than the sign-in. Now both go by ~/.claude.json's email (with the org), the CLI's email when that has none, and the status is asked without magpie's wiring. A turn the CLI ends with an error result (out of quota, rate limited) ends the reply there instead of waiting on, and a non-streamed one answers 429 for quota words, so another account takes over as in a stream. Tested: the account named as its login, the quota result ending the reply, another account taking over
providers: Claude Code is signed in to its next account once the one it is on is spent, as Codex is, and an account keeps its own affinity, backoff and usage across a switch (#208 Cyb3rN8: Claude Code 用自己的模型时,登录账号额度用满不会自动切换 · #209: Smart 选中的账号和 Claude Code 登录账号互不同步…切换登录账号还会把按 id 记的状态串到另一个账号上) — Claude Code on its own model asks Anthropic as the account it is signed in to, never through magpie, so with A spent its sessions got "You've hit your session limit" while the gateway had moved on to B. The Codex watcher (KeepCodexOnAnAccountWithRoom, now KeepOnAnAccountWithRoom, same place: started by the magpie serving the gateway, a minute in and every 5 minutes) now looks at Claude Code too: when the account it is signed in to is spent and another ticked account's allowance is known and has room, it is signed in to that one through SwitchLogin (credentials and .claude.json's oauthAccount), as Use on the Accounts page does; not when the Claude account was removed from magpie. The rule for #209: the sign-in follows Smart, not the other way round — it moves at the share Smart counts an account spent (provider.SpentShare = 98, which the gateway's usedShare now is), for Codex too (it moved at 100%). The Routing field of a Codex or Claude subscription with several accounts says so: routing picks the account for what comes through magpie; the agent on its own uses the one it is signed in to, moved on at 98%. Sessions already running keep the account they started with until restarted, as SwitchLogin says; routing Claude Code's own model through the gateway (the codexFailover way) is not done here. #209's second part was a real bug: the account the agent is on is the candidate "claude" and the others "claude@user", so a switch handed "claude" from A to B — a conversation A answered was kept on B and traced kept=true, and the Least used tokens and the backoff count followed the id. who(), routed.used and routed.failures are now keyed by restKey (provider@user, as rests already were); rest itself (group members, the trace) is unchanged. Tested with new TestClaudeSwitchedWhenSpent (sandbox HOME, keychain off, a fake usage endpoint: 97% stays, 98% signs Claude Code in to b — credentials and .claude.json — a stays ticked; on b it stays; both spent it stays), TestCodexSwitchedWhenUsedUp still passing, new TestLoginSwitchKeepsEachAccountItself (fails on main: the conversation went to b, kept) and new internal/gui/tests/routing-signin.test.cjs in Chromium and WebKit, en and zh; login-import and routing-kind GUI tests pass. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./... pass (TestUserPath, TestClaudeAccountNamedAsItsLogin, TestClaudeIdentityServedWhileAsked and TestCodexVersion timed out at 5s under the full run's load and pass on their own). Not tested against real Claude accounts or a running Claude Code.
gateway: Claude Code's body reaches a relay as it sent it, but for the model's name (#179: packy: 您的请求体疑似被篡改) — swapping the model re-encoded the whole body, so its fields went out sorted and its <, > and & as \u003c escapes, and a Claude model's request without thinking gained thinking off; a relay that only lets Claude Code in (packy) took that for a body tampered with. The model is now replaced where it stands and nothing else is touched, a Claude model's request is not given thinking off (Anthropic's models think only when asked), and a model that thinks by default (DeepSeek) has it added at the end with the rest left as sent; other rewrites no longer escape <, > and &. Tested with two requests captured from Claude Code 2.1.284 in a sandbox (85 KB with its tools and system prompt): through the gateway to a keyed Anthropic provider they arrive byte for byte as Claude Code sent them; not yet against packy
gateway: a request for a reply of a token or two reaches a provider that answers no less (#64) — an app checking a model is up asks for max_tokens 1, which some providers turn away with 400 "max_tokens must be greater than 2"; the least the provider named is read from that error, and the request is sent once more asking for it (max_tokens, max_completion_tokens, max_output_tokens or Gemini's maxOutputTokens, whichever it set), before any of the reply reaches the app; the routing page says so
gateway: a request for a reply of a token or two reaches a provider that answers no less (#64) — an app checking a model is up asks for max_tokens 1, which some providers turn away with 400 "max_tokens must be greater than 2"; the least the provider named is read from that error, and the request is sent once more asking for it (max_tokens, max_completion_tokens, max_output_tokens or Gemini's maxOutputTokens, whichever it set), before any of the reply reaches the app; the routing page says so
providers: Claude Code is signed in to its next account once the one it is on is spent, as Codex is, and an account keeps its own affinity, backoff and usage across a switch (#208 Cyb3rN8: Claude Code 用自己的模型时,登录账号额度用满不会自动切换 · #209: Smart 选中的账号和 Claude Code 登录账号互不同步…切换登录账号还会把按 id 记的状态串到另一个账号上) — Claude Code on its own model asks Anthropic as the account it is signed in to, never through magpie, so with A spent its sessions got "You've hit your session limit" while the gateway had moved on to B. The Codex watcher (KeepCodexOnAnAccountWithRoom, now KeepOnAnAccountWithRoom, same place: started by the magpie serving the gateway, a minute in and every 5 minutes) now looks at Claude Code too: when the account it is signed in to is spent and another ticked account's allowance is known and has room, it is signed in to that one through SwitchLogin (credentials and .claude.json's oauthAccount), as Use on the Accounts page does; not when the Claude account was removed from magpie. The rule for #209: the sign-in follows Smart, not the other way round — it moves at the share Smart counts an account spent (provider.SpentShare = 98, which the gateway's usedShare now is), for Codex too (it moved at 100%). The Routing field of a Codex or Claude subscription with several accounts says so: routing picks the account for what comes through magpie; the agent on its own uses the one it is signed in to, moved on at 98%. Sessions already running keep the account they started with until restarted, as SwitchLogin says; routing Claude Code's own model through the gateway (the codexFailover way) is not done here. #209's second part was a real bug: the account the agent is on is the candidate "claude" and the others "claude@user", so a switch handed "claude" from A to B — a conversation A answered was kept on B and traced kept=true, and the Least used tokens and the backoff count followed the id. who(), routed.used and routed.failures are now keyed by restKey (provider@user, as rests already were); rest itself (group members, the trace) is unchanged. Tested with new TestClaudeSwitchedWhenSpent (sandbox HOME, keychain off, a fake usage endpoint: 97% stays, 98% signs Claude Code in to b — credentials and .claude.json — a stays ticked; on b it stays; both spent it stays), TestCodexSwitchedWhenUsedUp still passing, new TestLoginSwitchKeepsEachAccountItself (fails on main: the conversation went to b, kept) and new internal/gui/tests/routing-signin.test.cjs in Chromium and WebKit, en and zh; login-import and routing-kind GUI tests pass. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./... pass (TestUserPath, TestClaudeAccountNamedAsItsLogin, TestClaudeIdentityServedWhileAsked and TestCodexVersion timed out at 5s under the full run's load and pass on their own). Not tested against real Claude accounts or a running Claude Code.
models: a provider's model can go by a name of the user's, and offer only some of its reasoning levels — Claude Opus 5.5 · Claude Code can be Opus 5.5, and a model with six levels can offer just low, medium and high. Both are kept in settings.json by provider/model (modelNames, modelEfforts), apart from the vendor's list, so a Refresh, a restart or an upgrade leaves them, and the same model from another provider keeps its own; a provider renamed takes them along. They are applied where the catalog is made, so the gateway's /v1/models, the lists magpie writes into the agents (Codex's model catalog, ZCode, Pi, OpenCode, OMP …) and magpie's own pages all show them, and a change rewrites the agents' lists as any other change of the catalog does. Only what is shown changes: the id, where requests go, and the levels the vendor is asked at stay as they were; a found routing group keeps the model's own name. The provider's editor has Names & levels under its models — a name typed in place, a tick per level, Restore default — and the terminal has magpie model name <provider/model> <name>|--reset and magpie model efforts <provider/model> low,medium,high|--reset. Issue #92
gateway: a subagent Codex's MultiAgentV2 spawns from a magpie-served lead gets its task (#141) — a namespaced call (spawn_agent, send_message, followup_task) now carries "encrypted_function_args": [], since Codex reads a call without that list as sealed and handed spawn_agent's plain message on as encrypted_content, which OpenAI could not decrypt for a gpt-6 subagent; and the agent_message item V2 hands a subagent its task in, and agents their messages to each other, is read as the receiving agent's user turn instead of being dropped, so a magpie-served subagent no longer started with nothing to do (an item with no content adds no turn). Top-level calls and the other encoders are as before. Checked end to end with Codex 0.155.1 and features.multi_agent_v2=true in a sandbox HOME against a scripted upstream: before, the subagent's request held only the environment context; after, it holds "Message Type: NEW_TASK … Payload: …" and answers it. Patch and tests by relus-cy, who found and reported both gaps
agents: a provider switched off or removed, or its last account signed out, moves the agents on its models to others (#200: 希望关闭某个llm provider的时候,更改agent选择的model到可用的model … 关闭这个provider之后,第一次使用会报错,因为copilot/ 开头的模型不存在) — switching Copilot off took copilot/… out of the catalog and the agents' model lists, but an agent picked on one kept it in its config and its next request was refused as switched off. Now every field on one of magpie's models (the model, Claude Code's opus/sonnet/haiku/fable tiers, any other) whose model the change stopped serving is moved: to the same model from a provider still on, the same id first, else one spelt another way (claude-sonnet-4.5 and claude-sonnet-4-5-20250929), else back to the agent's own default (a Claude Code tier: following the main model). It is done around switching off, removing, a key removed or off, and a sign-in signed out or off, in the window, the tray panel, the TUI and the CLI; the page's status line, the TUI and the CLI say which agent went to what. A session started before the switch still asks for the old model: the gateway now takes a switched-off provider's model as unserved, so Claude Code's stand-in (its model as set now) answers it rather than a refusal. Tested in a sandbox HOME: Claude Code on the main model and three tiers plus Hermes, switched off and removed (same id, other spelling, default, a tier following), switched on again moving nothing; /api/provider/off returns the moves; the gateway serves a switched-off model through the stand-in (fails without the change) and still refuses it to an agent left on it
gateway: a tool's schema reaches Antigravity with only the keywords it takes (#187: Unknown name "exclusiveMinimum") — Antigravity refuses a request whose function declarations carry a JSON Schema keyword outside its subset, and magpie cut out only the ones it knew of, so zcode's and dsh's tools, whose numbers have exclusiveMinimum, were refused with 400 on every turn. Now each schema keeps only type, description, nullable, enum, properties, required, items, the bounds and pattern, and propertyOrdering, anything else left out; an exclusive bound given as a number becomes the inclusive one when there is none. Tested on a schema with exclusive bounds, format, title, uniqueItems, contains, dependentRequired and not
gateway: prompt_cache_test keeps its tests —
ac2340d wrote TestAnthropicPromptCache over the file, dropping TestCodexThreadOnChatUpstreamCaches, TestPromptCacheKeyRefused and TestChatUsageCache; they're back, the new test after them
providers: Baidu Qianfan Token Plan lists no kimi-k2.6 (#193 follow-up) — Baidu's Token Plan 个人版 page takes Kimi-K2.6 (and DeepSeek-V4-Flash, already left out) offline 2026-09-29, the day the preset landed, so it was dropped from the models the preset lists; it can still be typed in by hand while it answers. gofmt on the preset and its gateway test
qoder: thinking turned off reaches Qoder as off, a refused usage sign-in no longer lapses the account, only a gone sign-in is a 401, and a tool's images are sent (#162 review follow-ups)
qoder: thinking turned off reaches Qoder as off, a refused usage sign-in no longer lapses the account, only a gone sign-in is a 401, and a tool's images are sent (#162 review follow-ups)
routing: an account out of quota rests until it says it's back, not an hour at a time (关于路由组优先级高的模型异常的展示文案 #147) — ChatGPT's usage-limit refusal reaches the failover put in words, its resets_at only as the Retry-After magpie made of it, and a Retry-After is trusted for an hour at most, so a Plus account out until 21:34 read "59 分钟后恢复" and was tried, refused and benched again each hour; the reset now goes on beside the error for the rest to take as the vendor's own, the window it filled renews next, a bare Retry-After alone keeps the hour, a rate limit and a failure keep their short rests, and a Codex reset spent in magpie brings the account back at once. The Routing page says when as well as how long: "quota used up · back at 10:29 PM · in 3 h 41 min" (额度用尽 · 22:29 恢复 · 还有 3 小时 41 分钟)
gateway: a 429 that is a per-minute rate limit rests a minute, not a quarter of an hour (found reviewing #153) — usedUpWords took "Rate limit exceeded" and "Rate limit reached … tokens per min" for a used-up plan, and as magpie words an upstream error ("rate_limit_error" its type) exceeded.*limit matched every such 429 whatever the vendor said, so a provider rate-limited for a minute sat out 15 as out of quota (an OpenRouter free model's free-models-per-min, OpenAI's TPM, Anthropic's per-minute limit). Now a 429 in rate words (rate limit, too many requests, per second/minute, RPM/TPM) is a rate limit unless it also says the plan's allowance is used (quota, usage limit, per day/week/month, daily…), and a 429 saying that is out of quota, as a day's free requests are. Tested: those bodies as vendors and magpie word them, and a group through the gateway whose first member answers OpenRouter's free-models-per-min — it rests rate for 1m where main rested quota for 15m
redact: a gateway's key in "key": "oc_sk_…" is masked, and Settings → Privacy takes the user's own masking rules (#195) — an OpenCode Go key in providers.json went to the vendor as it was: the API key rules wanted sk- with a hyphen, and a field called key alone was none of the secret field's names. Now a key of a gateway or relay, two to four letters and _sk_ (oc_sk_, or_sk_) with digits in it, is an API_KEY wherever it is, and a field named key and nothing more (not sort_key, monkey or obj.key) is a SECRET when its value is 16 or more letters and digits both, so a map's "key": "value" and key = "someIdentifier" are left alone; \"key\": \"…\" in JSON inside a string is found too. The secret field's names and its markers come from one list, so they can't drift apart, and of two rules finding the same value the first keeps it, so its placeholder no longer changes between an API_KEY and a SECRET from one request to the next. Masking rules, next to 脱敏词: a name for the placeholders (upper case letters, digits and _, 24 at most) and a prefix (then 8 or more key characters) or an RE2 regular expression, group 1 the value if it has one, masked with the secrets while 脱敏密钥 is on and put back as the others are, the user's name winning where one of magpie's finds the same value. They are set on their own: a pattern that doesn't compile, matches nothing at all, or is too long (300), a prefix under 3 or over 64, both at once, or more than 32 rules, is refused with the reason under the add row and what was typed kept. Tested: every row of the issue's table and the false positives, the rules' tidying, errors, stable placeholders, restore, overlaps, and kinds with digits in a stream; the settings file and the gateway's count_tokens with a rule; the page in a stub magpie web (add, a bad regex, remove, zh dark)
redact: a gateway's key in "key": "oc_sk_…" is masked, and Settings → Privacy takes the user's own masking rules (#195) — an OpenCode Go key in providers.json went to the vendor as it was: the API key rules wanted sk- with a hyphen, and a field called key alone was none of the secret field's names. Now a key of a gateway or relay, two to four letters and _sk_ (oc_sk_, or_sk_) with digits in it, is an API_KEY wherever it is, and a field named key and nothing more (not sort_key, monkey or obj.key) is a SECRET when its value is 16 or more letters and digits both, so a map's "key": "value" and key = "someIdentifier" are left alone; \"key\": \"…\" in JSON inside a string is found too. The secret field's names and its markers come from one list, so they can't drift apart, and of two rules finding the same value the first keeps it, so its placeholder no longer changes between an API_KEY and a SECRET from one request to the next. Masking rules, next to 脱敏词: a name for the placeholders (upper case letters, digits and _, 24 at most) and a prefix (then 8 or more key characters) or an RE2 regular expression, group 1 the value if it has one, masked with the secrets while 脱敏密钥 is on and put back as the others are, the user's name winning where one of magpie's finds the same value. They are set on their own: a pattern that doesn't compile, matches nothing at all, or is too long (300), a prefix under 3 or over 64, both at once, or more than 32 rules, is refused with the reason under the add row and what was typed kept. Tested: every row of the issue's table and the false positives, the rules' tidying, errors, stable placeholders, restore, overlaps, and kinds with digits in a stream; the settings file and the gateway's count_tokens with a rule; the page in a stub magpie web (add, a bad regex, remove, zh dark)
qoder: thinking turned off reaches Qoder as off, a refused usage sign-in no longer lapses the account, only a gone sign-in is a 401, and a tool's images are sent (#162 review follow-ups)
providers: Claude Code is signed in to its next account once the one it is on is spent, as Codex is, and an account keeps its own affinity, backoff and usage across a switch (#208 Cyb3rN8: Claude Code 用自己的模型时,登录账号额度用满不会自动切换 · #209: Smart 选中的账号和 Claude Code 登录账号互不同步…切换登录账号还会把按 id 记的状态串到另一个账号上) — Claude Code on its own model asks Anthropic as the account it is signed in to, never through magpie, so with A spent its sessions got "You've hit your session limit" while the gateway had moved on to B. The Codex watcher (KeepCodexOnAnAccountWithRoom, now KeepOnAnAccountWithRoom, same place: started by the magpie serving the gateway, a minute in and every 5 minutes) now looks at Claude Code too: when the account it is signed in to is spent and another ticked account's allowance is known and has room, it is signed in to that one through SwitchLogin (credentials and .claude.json's oauthAccount), as Use on the Accounts page does; not when the Claude account was removed from magpie. The rule for #209: the sign-in follows Smart, not the other way round — it moves at the share Smart counts an account spent (provider.SpentShare = 98, which the gateway's usedShare now is), for Codex too (it moved at 100%). The Routing field of a Codex or Claude subscription with several accounts says so: routing picks the account for what comes through magpie; the agent on its own uses the one it is signed in to, moved on at 98%. Sessions already running keep the account they started with until restarted, as SwitchLogin says; routing Claude Code's own model through the gateway (the codexFailover way) is not done here. #209's second part was a real bug: the account the agent is on is the candidate "claude" and the others "claude@user", so a switch handed "claude" from A to B — a conversation A answered was kept on B and traced kept=true, and the Least used tokens and the backoff count followed the id. who(), routed.used and routed.failures are now keyed by restKey (provider@user, as rests already were); rest itself (group members, the trace) is unchanged. Tested with new TestClaudeSwitchedWhenSpent (sandbox HOME, keychain off, a fake usage endpoint: 97% stays, 98% signs Claude Code in to b — credentials and .claude.json — a stays ticked; on b it stays; both spent it stays), TestCodexSwitchedWhenUsedUp still passing, new TestLoginSwitchKeepsEachAccountItself (fails on main: the conversation went to b, kept) and new internal/gui/tests/routing-signin.test.cjs in Chromium and WebKit, en and zh; login-import and routing-kind GUI tests pass. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./... pass (TestUserPath, TestClaudeAccountNamedAsItsLogin, TestClaudeIdentityServedWhileAsked and TestCodexVersion timed out at 5s under the full run's load and pass on their own). Not tested against real Claude accounts or a running Claude Code.
routing: a group's member may be a model at an effort of its own, provider/model:low (#189, @luw2007: model + effort 才是完整的组合项) — a member was only a model, reasoning as the agent asked or as the group's classifier picked, so "the same model, cheap and quick" and "the same model, thinking hard" could not both be in a group, nor could a rule send to one of them. A member now may carry an effort (none … max): a request routed to it is asked for that much reasoning whatever the agent asked or effort=auto picked, at the level the model has nearest, while the vendor is asked for the model's own id. The same model at two efforts is two members that rules (use=m:high), stickiness and the trace tell apart; an id whose own last part is a colon (a :free, :7b, :0) stays the model's. The group's reasoning levels are those of its members that follow the agent, and the editor has a reasoning button on each member. Tested with a fake provider through a sandbox magpie: the upstream bodies carry the fixed effort in Chat, Responses and Anthropic shapes, and the routing view shows m1:low / m1:high as two seats, light and dark
gateway: Codex's namespaced tools reach a model magpie serves — spawn_agent, wait_agent and the rest of collaboration come inside a Responses namespace, which was dropped whole; each of its functions is offered as <namespace>__<name> (cut to 64 with a hash when longer), a past call with a namespace is read back under that name, and a call to one goes back to Codex with its name and namespace, streamed or not (#130)
workbuddy: requests are streamed to it, Test's too (#124: 测试 400 · Non-stream chat request is currently not supported, 对话正常的) — WorkBuddy refuses any request that doesn't stream, so its Test, a small non-streaming request, failed with 400 while agents, which stream, answered. A WorkBuddy account is now a backend that only streams: Test sends it stream:true, and the gateway streams it a client's non-streaming request and gives the client one answer, as it does Codex's and Google's. Tested: Test's request to such a backend streams on each endpoint, and a non-streaming chat request through the gateway to a fake upstream that refuses one comes back as one chat.completion
gateway: an account or key added while an agent works no longer takes over its running conversation (#63) — who answered a conversation was remembered only when there were two or more to route to, so the first request after a second ChatGPT or Claude account (or key) was added went wherever routing put it, and Codex reasoning sealed by the first account came back 400 "The encrypted content … could not be verified" and ended the session; who answered is remembered with one as well, and matched by the key itself, not by an id that changes once a second key is on. A conversation that still reaches another account (the first used up, or magpie restarted) has the other account's sealed reasoning taken out and is asked again, and the routing page says so
gateway: images a tool returned reach a Chat model after the tool messages (charan-rathore in #167: Claude Code's tool_result images were dropped on the way to Chat Completions)
usage: a streamed reply's time to its first token is kept, and its first text's (#196: usage 记录首字延迟(TTFT),用于计算模型的真实生成速度) — usage.jsonl had only a call's whole ms and its out tokens, so how fast a model writes couldn't be told from how long it waited or thought. The reply is now read as it goes to the agent, in the agent's protocol whatever the vendor spoke (Anthropic content_block_delta text/thinking/input_json and a tool_use's start, Chat content/reasoning_content/reasoning/tool_calls, Responses output_text/reasoning/function_call_arguments deltas and a function call's start, Gemini parts), and each record gets ttft_ms to the first content and first_text_ms to the first text, counted from the request as ms is, so ms − ttft_ms is the writing time; a reply that isn't streamed gets neither, and omitempty leaves old records as they were. Only the stream's events up to its first text are read (a JSON reply, or past 8 MB, not at all). Each try in the routing trace keeps its own from when it was sent, so a group that fails over shows which member was slow, and the route, the request log and the Codex backend's relay keep them too. Shown on the Routing page (TTFT in the list, first token / first text / tok/s in a try's story), in the Gateway's request log, and on the Usage page and in
magpie usage per model as the mean TTFT and tok/s = out ÷ (ms − ttft) over the answered streamed calls. Tested with fake vendors streaming a gap apart for Anthropic, Chat and Responses, as they came and translated (Chat from Anthropic, Responses from Chat), the ChatGPT backend without a Content-Type, a whole reply, a group whose first member fails (its try untimed, the second's timed from its own start, the request's including the failed try), each protocol's events and split writes, and the summary's sums; the GUI's browser tests pass
usage: a streamed reply's time to its first token is kept, and its first text's (#196: usage 记录首字延迟(TTFT),用于计算模型的真实生成速度) — usage.jsonl had only a call's whole ms and its out tokens, so how fast a model writes couldn't be told from how long it waited or thought. The reply is now read as it goes to the agent, in the agent's protocol whatever the vendor spoke (Anthropic content_block_delta text/thinking/input_json and a tool_use's start, Chat content/reasoning_content/reasoning/tool_calls, Responses output_text/reasoning/function_call_arguments deltas and a function call's start, Gemini parts), and each record gets ttft_ms to the first content and first_text_ms to the first text, counted from the request as ms is, so ms − ttft_ms is the writing time; a reply that isn't streamed gets neither, and omitempty leaves old records as they were. Only the stream's events up to its first text are read (a JSON reply, or past 8 MB, not at all). Each try in the routing trace keeps its own from when it was sent, so a group that fails over shows which member was slow, and the route, the request log and the Codex backend's relay keep them too. Shown on the Routing page (TTFT in the list, first token / first text / tok/s in a try's story), in the Gateway's request log, and on the Usage page and in
magpie usage per model as the mean TTFT and tok/s = out ÷ (ms − ttft) over the answered streamed calls. Tested with fake vendors streaming a gap apart for Anthropic, Chat and Responses, as they came and translated (Chat from Anthropic, Responses from Chat), the ChatGPT backend without a Content-Type, a whole reply, a group whose first member fails (its try untimed, the second's timed from its own start, the request's including the failed try), each protocol's events and split writes, and the summary's sums; the GUI's browser tests pass