Files
providers: each account of a subscription holding several can go through its own proxy — the provider's, none, or its own address (Discord, gakki: 是否可以为不同的codex账号设置不用的代理,现在是同一个供应商下使用相同的代理) — the per-provider proxy (#237) was one for all of Codex's signed-in accounts. A subscription's entry in providers.json now keeps accountProxies, by the account's name in lower case, each checked by settings.CheckProxy on save (ftp:// refused), addresses trimmed, accounts following the provider's proxy left out. Provider.ProxyChoice picks an account's own proxy, else the provider's, else the global one, so everything that already went through Provider.Via follows the account: gateway turns and fallback across accounts, usage and quota, token refresh, Codex's warm-up, reset and saved accounts' model lists, Claude's warm-up and the *_PROXY of Claude Code runs (ViaLogin / ProxyOfLogin), and Codex's own sign-in relayed as it came (ViaSignedIn). Extra accounts of Copilot, ZCode, Kiro and the like, made afresh without the provider's settings, now read them from providers.json, so they honour the provider's proxy too. A save without accountProxies keeps them; {} clears them. The provider editor, for a subscription with two or more accounts, lists each under the Proxy row: Provider's proxy | Direct | Custom, the address field's room kept so a pick moves nothing; Custom with no address is refused before anything is posted, its field focused without scrolling. en and zh together; no border stripes. Tested with internal/gateway/account_proxy_test.go in a sandbox HOME against fake proxies and a fake ChatGPT backend: three Codex accounts (own proxy, direct, following the provider) each reach the backend their own way in one turn and for usage; clearing an account's proxy sends it through the provider's, clearing the provider's through the global one, direct staying direct; ftp:// refused; TestClaudeAccountProxyEnv covers each Claude account's HTTPS_PROXY. Both fail with the per-account lookup off. account-proxy.test.cjs, Chromium and WebKit, en and zh (new), provider-proxy, click-scroll and old-webkit pass. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried against a real proxy, real accounts or the real Wails window; Grok's per-account usage and Kiro's usage on a key still use the provider's proxy.
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
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)
gateway: Claude Code's auto mode classifier gets an answer through magpie on models that reason, and never lingers as a kept Claude Code run (#250 netfishx: the classifier often fails; Claude Code notes the session "isn't eligible" for free classifier requests through 127.0.0.1:3425) — per code.claude.com/docs/en/auto-mode-classifier-billing and Claude Code 2.1.285, behind a gateway its first requests ask the API to classify (the dangerous-tool-use-2026-09-03 beta, a
safeguards field, safeguard_results in the reply); keyed Anthropic already relays them as sent and returns the reply as it came (now guarded by a test), and no other model answers them, so Claude Code falls back to classifying locally: a request of its own on the session's model, the transcript in <transcript> blocks, a system prompt asking <block>yes|no</block>, thinking off, 64 tokens for the first stage stopping at </block>, 60s. A model that reasons whatever it is told (OpenAI Responses/Chat, DeepSeek, a vendor's model behind an Anthropic API) spent the 64 tokens reasoning and answered nothing, which Claude Code reads as no verdict and blocks. Now (automode.go) such a request to a model that isn't Claude gets 2048 tokens more when it asked for less, as Claude Code gives a Claude model that can't turn thinking off, and the least reasoning its levels allow (fitEffort "none"), left to the vendor when they aren't known; a Claude model's request goes byte for byte. Through the Claude subscription the classifier, whose transcript spans several user messages, was kept as an idle claude run (the one-off test was fewer than two messages), pushing out the conversations' own: a request with no tools and no reply is one-off now, and the classifier runs at --effort low. magpie never answers safeguard_results itself. Tested in automode_test.go against httptest fakes and a fake claude: Responses and Chat (budget raised, effort none/low), unknown levels (room given, effort left, a title ask untouched), a vendor on an Anthropic endpoint, the bridge (no idle runs, --effort low) fail on main; TestAutoModeServerSideReviewRelayed keeps the beta, safeguards, safeguard_results and tool-use ids as they are. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried in a real auto-mode session nor with real vendors taking effort none.
gateway: Claude Code's auto mode classifier gets an answer through magpie on models that reason, and never lingers as a kept Claude Code run (#250 netfishx: the classifier often fails; Claude Code notes the session "isn't eligible" for free classifier requests through 127.0.0.1:3425) — per code.claude.com/docs/en/auto-mode-classifier-billing and Claude Code 2.1.285, behind a gateway its first requests ask the API to classify (the dangerous-tool-use-2026-09-03 beta, a
safeguards field, safeguard_results in the reply); keyed Anthropic already relays them as sent and returns the reply as it came (now guarded by a test), and no other model answers them, so Claude Code falls back to classifying locally: a request of its own on the session's model, the transcript in <transcript> blocks, a system prompt asking <block>yes|no</block>, thinking off, 64 tokens for the first stage stopping at </block>, 60s. A model that reasons whatever it is told (OpenAI Responses/Chat, DeepSeek, a vendor's model behind an Anthropic API) spent the 64 tokens reasoning and answered nothing, which Claude Code reads as no verdict and blocks. Now (automode.go) such a request to a model that isn't Claude gets 2048 tokens more when it asked for less, as Claude Code gives a Claude model that can't turn thinking off, and the least reasoning its levels allow (fitEffort "none"), left to the vendor when they aren't known; a Claude model's request goes byte for byte. Through the Claude subscription the classifier, whose transcript spans several user messages, was kept as an idle claude run (the one-off test was fewer than two messages), pushing out the conversations' own: a request with no tools and no reply is one-off now, and the classifier runs at --effort low. magpie never answers safeguard_results itself. Tested in automode_test.go against httptest fakes and a fake claude: Responses and Chat (budget raised, effort none/low), unknown levels (room given, effort left, a title ask untouched), a vendor on an Anthropic endpoint, the bridge (no idle runs, --effort low) fail on main; TestAutoModeServerSideReviewRelayed keeps the beta, safeguards, safeguard_results and tool-use ids as they are. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried in a real auto-mode session nor with real vendors taking effort none.
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)
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: 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
gateway: Claude Code's auto mode classifier gets an answer through magpie on models that reason, and never lingers as a kept Claude Code run (#250 netfishx: the classifier often fails; Claude Code notes the session "isn't eligible" for free classifier requests through 127.0.0.1:3425) — per code.claude.com/docs/en/auto-mode-classifier-billing and Claude Code 2.1.285, behind a gateway its first requests ask the API to classify (the dangerous-tool-use-2026-09-03 beta, a
safeguards field, safeguard_results in the reply); keyed Anthropic already relays them as sent and returns the reply as it came (now guarded by a test), and no other model answers them, so Claude Code falls back to classifying locally: a request of its own on the session's model, the transcript in <transcript> blocks, a system prompt asking <block>yes|no</block>, thinking off, 64 tokens for the first stage stopping at </block>, 60s. A model that reasons whatever it is told (OpenAI Responses/Chat, DeepSeek, a vendor's model behind an Anthropic API) spent the 64 tokens reasoning and answered nothing, which Claude Code reads as no verdict and blocks. Now (automode.go) such a request to a model that isn't Claude gets 2048 tokens more when it asked for less, as Claude Code gives a Claude model that can't turn thinking off, and the least reasoning its levels allow (fitEffort "none"), left to the vendor when they aren't known; a Claude model's request goes byte for byte. Through the Claude subscription the classifier, whose transcript spans several user messages, was kept as an idle claude run (the one-off test was fewer than two messages), pushing out the conversations' own: a request with no tools and no reply is one-off now, and the classifier runs at --effort low. magpie never answers safeguard_results itself. Tested in automode_test.go against httptest fakes and a fake claude: Responses and Chat (budget raised, effort none/low), unknown levels (room given, effort left, a title ask untouched), a vendor on an Anthropic endpoint, the bridge (no idle runs, --effort low) fail on main; TestAutoModeServerSideReviewRelayed keeps the beta, safeguards, safeguard_results and tool-use ids as they are. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried in a real auto-mode session nor with real vendors taking effort none.
providers: each account of a subscription holding several can go through its own proxy — the provider's, none, or its own address (Discord, gakki: 是否可以为不同的codex账号设置不用的代理,现在是同一个供应商下使用相同的代理) — the per-provider proxy (#237) was one for all of Codex's signed-in accounts. A subscription's entry in providers.json now keeps accountProxies, by the account's name in lower case, each checked by settings.CheckProxy on save (ftp:// refused), addresses trimmed, accounts following the provider's proxy left out. Provider.ProxyChoice picks an account's own proxy, else the provider's, else the global one, so everything that already went through Provider.Via follows the account: gateway turns and fallback across accounts, usage and quota, token refresh, Codex's warm-up, reset and saved accounts' model lists, Claude's warm-up and the *_PROXY of Claude Code runs (ViaLogin / ProxyOfLogin), and Codex's own sign-in relayed as it came (ViaSignedIn). Extra accounts of Copilot, ZCode, Kiro and the like, made afresh without the provider's settings, now read them from providers.json, so they honour the provider's proxy too. A save without accountProxies keeps them; {} clears them. The provider editor, for a subscription with two or more accounts, lists each under the Proxy row: Provider's proxy | Direct | Custom, the address field's room kept so a pick moves nothing; Custom with no address is refused before anything is posted, its field focused without scrolling. en and zh together; no border stripes. Tested with internal/gateway/account_proxy_test.go in a sandbox HOME against fake proxies and a fake ChatGPT backend: three Codex accounts (own proxy, direct, following the provider) each reach the backend their own way in one turn and for usage; clearing an account's proxy sends it through the provider's, clearing the provider's through the global one, direct staying direct; ftp:// refused; TestClaudeAccountProxyEnv covers each Claude account's HTTPS_PROXY. Both fail with the per-account lookup off. account-proxy.test.cjs, Chromium and WebKit, en and zh (new), provider-proxy, click-scroll and old-webkit pass. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried against a real proxy, real accounts or the real Wails window; Grok's per-account usage and Kiro's usage on a key still use the provider's proxy.
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
copilot: Auto — Copilot's own pick, the only choice a Student plan has — is listed for every Copilot account and works through magpie, as Copilot's clients do it (#256; Discord, CakeJskSUMM: Copilot Student can only use Auto) — Copilot's /models doesn't list Auto; its clients add it themselves (Copilot CLI 1.0.79: {id:"auto",name:"Auto"}, AUTO_MODEL_ID) and ask POST {api}/models/session with {"auto_mode":{"model_hints":["auto"]}} for a session_token, the selected_model and expires_at, each request then naming that model with the token in Copilot-Session-Token. magpie listed no Auto, failed an account whose list left nothing to pick by hand ("Copilot lists no chat model for this account"), and sent "auto" as a model. Now copilot_auto.go keeps the session per GitHub account, asked again two minutes before it expires; the gateway resolves Auto before choosing the API, so a picked model served only on /responses goes there; a body still naming "auto" (the model test) is rewritten; the endpoints of every model Copilot lists, hidden ones too, are kept for the picked one. A reply to "auto" from another model isn't counted as swapped (Cursor's auto too); the Copilot CLI's picker, which offers auto itself, leaves out the listed one. Tested with fakes in a sandbox HOME: provider/copilot_auto_test.go (a Student-like account whose models are all disabled lists [auto], the session body sent as above, requests carry the picked gpt-5-mini and the token, one session reused, renewed near expiry, an object-shaped selected_model, a refusal reported) and gateway/copilot_auto_test.go (a chat request for copilot/auto goes to /responses as gpt-5-mini with the token, two turns one session; fails without the gateway change); served_test gains the auto cases. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried with a real Student account: what its /models returns, whether the plain session request suffices (Auto v2's /models/session/intent is not done), and the body is from VS Code's Copilot Chat, not captured traffic.
providers: each provider, a signed-in account like Codex's among them, goes through its own proxy — the one in Settings, none, or its own address (#237 xosi: 目前似乎是全局设置代理的,codex和国模供应商无法单独区分设置代理) — magpie had one proxy, Settings' (or the environment's, or the system's), for every request it made, so Codex, which needs a proxy from China, and a vendor at home, which is slower or refused through one, couldn't both be right. A provider now has a proxy, "" (following the global one, as before and the default), "direct" (none, whatever Settings says) or an http://, https:// or socks5:// address (host:port meaning http), kept as proxy in providers.json only when set, and for an account provider in its picks entry beside its models. It is checked on save by the same rule Settings' proxy is (settings.CheckProxy, now shared), so ftp:// and the like are refused. netproxy.With puts a provider's choice on a request's context and netproxy.Func honours it; netproxy.Dispatch sends such a request through a clone of the transport kept for that proxy alone (at most 32, cleared past that), since a transport shares an HTTP/2 connection by host whatever proxy dialled it; loopback is never proxied. The gateway's client and http.DefaultClient go through Dispatch, and every request made on a provider's behalf carries its choice: the gateway's turns (attempt, forward, draw, decide, the Codex backend), and the model lists, balances, plan and subscription usage, tests, sign-in, Codex's warm-up and reset, while the Claude subscription's and warm-up's CLI runs get the account's proxy in their *_PROXY (netproxy.EnvWith). The provider editor, a key provider's and a signed-in account's alike, has a Proxy row after Fallback: Global proxy | Direct | Custom (全局代理 | 直连 | 自定义), the address field beside the options only for Custom, its room kept otherwise so a pick never changes the centred dialog's height and moves nothing, and a line under it saying what the pick does; Save posts proxy ("" , "direct" or the trimmed address), a save without it keeps the old one, and Custom with no address is refused before anything is posted ("Proxy: type its address, like http://127.0.0.1:7890"). No border stripes; en and zh strings together. Tested with a new internal/gateway/proxy_test.go in a sandbox HOME: with a global proxy set, provider A's turns and model list go through its own fake proxy, B's (direct) straight to the vendor, C's (following) through the global one, none through another, and A set back to following goes through the global one; ftp:// is refused (fails with With neutered). netproxy_test.go's TestWith covers Func, Dispatch's per-proxy transports and EnvWith. A new internal/gui/tests/provider-proxy.test.cjs in Chromium and WebKit, en and zh: a key provider opens on Global proxy with no address, Custom unfilled is refused and nothing posted, a padded socks5 address saved trimmed, Direct saved as "direct" and Global as ""; Codex's account opens on Custom with its address and saves Direct with its models; every pick leaves the option within 1px; no left border. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./..., provider-proxy, click-scroll, old-webkit, balance-fix and add-sheet pass (click-scroll's WebKit case failed once alongside the others and passes alone, twice). Not tried against a real proxy or the real Wails window.
providers: each provider, a signed-in account like Codex's among them, goes through its own proxy — the one in Settings, none, or its own address (#237 xosi: 目前似乎是全局设置代理的,codex和国模供应商无法单独区分设置代理) — magpie had one proxy, Settings' (or the environment's, or the system's), for every request it made, so Codex, which needs a proxy from China, and a vendor at home, which is slower or refused through one, couldn't both be right. A provider now has a proxy, "" (following the global one, as before and the default), "direct" (none, whatever Settings says) or an http://, https:// or socks5:// address (host:port meaning http), kept as proxy in providers.json only when set, and for an account provider in its picks entry beside its models. It is checked on save by the same rule Settings' proxy is (settings.CheckProxy, now shared), so ftp:// and the like are refused. netproxy.With puts a provider's choice on a request's context and netproxy.Func honours it; netproxy.Dispatch sends such a request through a clone of the transport kept for that proxy alone (at most 32, cleared past that), since a transport shares an HTTP/2 connection by host whatever proxy dialled it; loopback is never proxied. The gateway's client and http.DefaultClient go through Dispatch, and every request made on a provider's behalf carries its choice: the gateway's turns (attempt, forward, draw, decide, the Codex backend), and the model lists, balances, plan and subscription usage, tests, sign-in, Codex's warm-up and reset, while the Claude subscription's and warm-up's CLI runs get the account's proxy in their *_PROXY (netproxy.EnvWith). The provider editor, a key provider's and a signed-in account's alike, has a Proxy row after Fallback: Global proxy | Direct | Custom (全局代理 | 直连 | 自定义), the address field beside the options only for Custom, its room kept otherwise so a pick never changes the centred dialog's height and moves nothing, and a line under it saying what the pick does; Save posts proxy ("" , "direct" or the trimmed address), a save without it keeps the old one, and Custom with no address is refused before anything is posted ("Proxy: type its address, like http://127.0.0.1:7890"). No border stripes; en and zh strings together. Tested with a new internal/gateway/proxy_test.go in a sandbox HOME: with a global proxy set, provider A's turns and model list go through its own fake proxy, B's (direct) straight to the vendor, C's (following) through the global one, none through another, and A set back to following goes through the global one; ftp:// is refused (fails with With neutered). netproxy_test.go's TestWith covers Func, Dispatch's per-proxy transports and EnvWith. A new internal/gui/tests/provider-proxy.test.cjs in Chromium and WebKit, en and zh: a key provider opens on Global proxy with no address, Custom unfilled is refused and nothing posted, a padded socks5 address saved trimmed, Direct saved as "direct" and Global as ""; Codex's account opens on Custom with its address and saves Direct with its models; every pick leaves the option within 1px; no left border. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./..., provider-proxy, click-scroll, old-webkit, balance-fix and add-sheet pass (click-scroll's WebKit case failed once alongside the others and passes alone, twice). Not tried against a real proxy or the real Wails window.
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报错)
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
gateway: a request the vendor's safety filter refuses with an error status (OpenAI's 400 bio_policy, cyber_policy, content_policy_violation, invalid_prompt flagged against its usage policy, Azure's content_filter) goes on to the next account or model, as a refusal with nothing said already did (#248 Koohoko: Codex was handed "This content was flagged for possible biological risk" with the other accounts never asked) — settle read only whole 2xx replies for a refusal, so an error status was passed straight through. policyRefusal tells such an error from a malformed request, in a body, an error object or a stream's error event (Responses' own error event included); keepRetry notes it in X-Magpie-Refused beside the error it rewrites, for settle, and the header never reaches the agent. Tested with refusal_accounts_test.go: a 400 bio_policy on the first Codex account is answered by the second, a stream error event likewise, a plain 400 invalid_request_error is not retried. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass.
copilot: Auto — Copilot's own pick, the only choice a Student plan has — is listed for every Copilot account and works through magpie, as Copilot's clients do it (#256; Discord, CakeJskSUMM: Copilot Student can only use Auto) — Copilot's /models doesn't list Auto; its clients add it themselves (Copilot CLI 1.0.79: {id:"auto",name:"Auto"}, AUTO_MODEL_ID) and ask POST {api}/models/session with {"auto_mode":{"model_hints":["auto"]}} for a session_token, the selected_model and expires_at, each request then naming that model with the token in Copilot-Session-Token. magpie listed no Auto, failed an account whose list left nothing to pick by hand ("Copilot lists no chat model for this account"), and sent "auto" as a model. Now copilot_auto.go keeps the session per GitHub account, asked again two minutes before it expires; the gateway resolves Auto before choosing the API, so a picked model served only on /responses goes there; a body still naming "auto" (the model test) is rewritten; the endpoints of every model Copilot lists, hidden ones too, are kept for the picked one. A reply to "auto" from another model isn't counted as swapped (Cursor's auto too); the Copilot CLI's picker, which offers auto itself, leaves out the listed one. Tested with fakes in a sandbox HOME: provider/copilot_auto_test.go (a Student-like account whose models are all disabled lists [auto], the session body sent as above, requests carry the picked gpt-5-mini and the token, one session reused, renewed near expiry, an object-shaped selected_model, a refusal reported) and gateway/copilot_auto_test.go (a chat request for copilot/auto goes to /responses as gpt-5-mini with the token, two turns one session; fails without the gateway change); served_test gains the auto cases. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried with a real Student account: what its /models returns, whether the plain session request suffices (Auto v2's /models/session/intent is not done), and the body is from VS Code's Copilot Chat, not captured traffic.
…
…
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.
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
providers: each provider, a signed-in account like Codex's among them, goes through its own proxy — the one in Settings, none, or its own address (#237 xosi: 目前似乎是全局设置代理的,codex和国模供应商无法单独区分设置代理) — magpie had one proxy, Settings' (or the environment's, or the system's), for every request it made, so Codex, which needs a proxy from China, and a vendor at home, which is slower or refused through one, couldn't both be right. A provider now has a proxy, "" (following the global one, as before and the default), "direct" (none, whatever Settings says) or an http://, https:// or socks5:// address (host:port meaning http), kept as proxy in providers.json only when set, and for an account provider in its picks entry beside its models. It is checked on save by the same rule Settings' proxy is (settings.CheckProxy, now shared), so ftp:// and the like are refused. netproxy.With puts a provider's choice on a request's context and netproxy.Func honours it; netproxy.Dispatch sends such a request through a clone of the transport kept for that proxy alone (at most 32, cleared past that), since a transport shares an HTTP/2 connection by host whatever proxy dialled it; loopback is never proxied. The gateway's client and http.DefaultClient go through Dispatch, and every request made on a provider's behalf carries its choice: the gateway's turns (attempt, forward, draw, decide, the Codex backend), and the model lists, balances, plan and subscription usage, tests, sign-in, Codex's warm-up and reset, while the Claude subscription's and warm-up's CLI runs get the account's proxy in their *_PROXY (netproxy.EnvWith). The provider editor, a key provider's and a signed-in account's alike, has a Proxy row after Fallback: Global proxy | Direct | Custom (全局代理 | 直连 | 自定义), the address field beside the options only for Custom, its room kept otherwise so a pick never changes the centred dialog's height and moves nothing, and a line under it saying what the pick does; Save posts proxy ("" , "direct" or the trimmed address), a save without it keeps the old one, and Custom with no address is refused before anything is posted ("Proxy: type its address, like http://127.0.0.1:7890"). No border stripes; en and zh strings together. Tested with a new internal/gateway/proxy_test.go in a sandbox HOME: with a global proxy set, provider A's turns and model list go through its own fake proxy, B's (direct) straight to the vendor, C's (following) through the global one, none through another, and A set back to following goes through the global one; ftp:// is refused (fails with With neutered). netproxy_test.go's TestWith covers Func, Dispatch's per-proxy transports and EnvWith. A new internal/gui/tests/provider-proxy.test.cjs in Chromium and WebKit, en and zh: a key provider opens on Global proxy with no address, Custom unfilled is refused and nothing posted, a padded socks5 address saved trimmed, Direct saved as "direct" and Global as ""; Codex's account opens on Custom with its address and saves Direct with its models; every pick leaves the option within 1px; no left border. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./..., provider-proxy, click-scroll, old-webkit, balance-fix and add-sheet pass (click-scroll's WebKit case failed once alongside the others and passes alone, twice). Not tried against a real proxy or the real Wails window.
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)
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)
gateway: a request the vendor's safety filter refuses with an error status (OpenAI's 400 bio_policy, cyber_policy, content_policy_violation, invalid_prompt flagged against its usage policy, Azure's content_filter) goes on to the next account or model, as a refusal with nothing said already did (#248 Koohoko: Codex was handed "This content was flagged for possible biological risk" with the other accounts never asked) — settle read only whole 2xx replies for a refusal, so an error status was passed straight through. policyRefusal tells such an error from a malformed request, in a body, an error object or a stream's error event (Responses' own error event included); keepRetry notes it in X-Magpie-Refused beside the error it rewrites, for settle, and the header never reaches the agent. Tested with refusal_accounts_test.go: a 400 bio_policy on the first Codex account is answered by the second, a stream error event likewise, a plain 400 invalid_request_error is not retried. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass.
gateway: a vendor's safety filter refusing a request before saying anything goes on to the next account or model instead of reaching the agent as an empty reply (#248 Koohoko) — a stream that ended in a refusal (Anthropic's stop_reason "refusal", OpenAI's content_filter on Chat and Responses, Gemini's SAFETY / PROHIBITED_CONTENT / BLOCKLIST / SPII / IMAGE_SAFETY, or an error event with such a code) with no text, reasoning or tool call before it was passed to the agent as an answer, which it would ask again for, paying each time. The held stream now counts framing events that say nothing (block starts and stops, signatures, empty items, parts and deltas) as lead-in, so a refusal is seen while nothing has been sent; the try is marked "refused", logged at status 400 with its tokens, the account not rested, and the next member asked. With nobody left, the agent gets a 400 invalid_request_error "<model> refused this request (safety filter: …) via <provider>". A refusal after content has been sent passes through as before. A Responses reply rendered from another API says incomplete with reason content_filter. Routing shows "refused (safety filter)" with its explanation, en and zh. Tested with internal/gateway/refusal_test.go against fake vendors: a refusal fails over to the next member, content_filter fails over, a refusal with nobody left is the 400, a refusal after text passes through, and each vendor's refusal shape is recognised; the new tests fail without the change. go vet, go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./... and -race on the gateway pass; routing-kind, routing-steady, panel-routing, old-webkit and click-scroll pass. Not tried against real vendors' refusals or the real claude CLI; the Gemini shapes are from its API docs; a stream that only sends empty framing is held until its first text, within the existing 15s / 1MB bound.
gateway: a vendor's safety filter refusing a request before saying anything goes on to the next account or model instead of reaching the agent as an empty reply (#248 Koohoko) — a stream that ended in a refusal (Anthropic's stop_reason "refusal", OpenAI's content_filter on Chat and Responses, Gemini's SAFETY / PROHIBITED_CONTENT / BLOCKLIST / SPII / IMAGE_SAFETY, or an error event with such a code) with no text, reasoning or tool call before it was passed to the agent as an answer, which it would ask again for, paying each time. The held stream now counts framing events that say nothing (block starts and stops, signatures, empty items, parts and deltas) as lead-in, so a refusal is seen while nothing has been sent; the try is marked "refused", logged at status 400 with its tokens, the account not rested, and the next member asked. With nobody left, the agent gets a 400 invalid_request_error "<model> refused this request (safety filter: …) via <provider>". A refusal after content has been sent passes through as before. A Responses reply rendered from another API says incomplete with reason content_filter. Routing shows "refused (safety filter)" with its explanation, en and zh. Tested with internal/gateway/refusal_test.go against fake vendors: a refusal fails over to the next member, content_filter fails over, a refusal with nobody left is the 400, a refusal after text passes through, and each vendor's refusal shape is recognised; the new tests fail without the change. go vet, go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./... and -race on the gateway pass; routing-kind, routing-steady, panel-routing, old-webkit and click-scroll pass. Not tried against real vendors' refusals or the real claude CLI; the Gemini shapes are from its API docs; a stream that only sends empty framing is held until its first text, within the existing 15s / 1MB bound.
gateway: a vendor refusing the client outright (WorkBuddy's 400 "Illegal API invocation from an unapproved channel") hands a group's request to its next member (PR #232 Y-C-Fan: a routing group stopped at a WorkBuddy member refusing Claude Code's chats) — retryable let a 400 fall over only on quota or unserved-model words, so this refusal, which is of the provider and not the request, went back to the agent. A new refusedWords (unapproved channel, illegal api invocation) joins them. The request body is not changed: a chat opening with Claude Code's own system prompt is still sent as it came. Tested with a new case in TestGroupFailsOverAnUnservedModel's retryable table (false before) and the gateway package.
gateway: a request the vendor's safety filter refuses with an error status (OpenAI's 400 bio_policy, cyber_policy, content_policy_violation, invalid_prompt flagged against its usage policy, Azure's content_filter) goes on to the next account or model, as a refusal with nothing said already did (#248 Koohoko: Codex was handed "This content was flagged for possible biological risk" with the other accounts never asked) — settle read only whole 2xx replies for a refusal, so an error status was passed straight through. policyRefusal tells such an error from a malformed request, in a body, an error object or a stream's error event (Responses' own error event included); keepRetry notes it in X-Magpie-Refused beside the error it rewrites, for settle, and the header never reaches the agent. Tested with refusal_accounts_test.go: a 400 bio_policy on the first Codex account is answered by the second, a stream error event likewise, a plain 400 invalid_request_error is not retried. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass.
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
copilot: Auto — Copilot's own pick, the only choice a Student plan has — is listed for every Copilot account and works through magpie, as Copilot's clients do it (#256; Discord, CakeJskSUMM: Copilot Student can only use Auto) — Copilot's /models doesn't list Auto; its clients add it themselves (Copilot CLI 1.0.79: {id:"auto",name:"Auto"}, AUTO_MODEL_ID) and ask POST {api}/models/session with {"auto_mode":{"model_hints":["auto"]}} for a session_token, the selected_model and expires_at, each request then naming that model with the token in Copilot-Session-Token. magpie listed no Auto, failed an account whose list left nothing to pick by hand ("Copilot lists no chat model for this account"), and sent "auto" as a model. Now copilot_auto.go keeps the session per GitHub account, asked again two minutes before it expires; the gateway resolves Auto before choosing the API, so a picked model served only on /responses goes there; a body still naming "auto" (the model test) is rewritten; the endpoints of every model Copilot lists, hidden ones too, are kept for the picked one. A reply to "auto" from another model isn't counted as swapped (Cursor's auto too); the Copilot CLI's picker, which offers auto itself, leaves out the listed one. Tested with fakes in a sandbox HOME: provider/copilot_auto_test.go (a Student-like account whose models are all disabled lists [auto], the session body sent as above, requests carry the picked gpt-5-mini and the token, one session reused, renewed near expiry, an object-shaped selected_model, a refusal reported) and gateway/copilot_auto_test.go (a chat request for copilot/auto goes to /responses as gpt-5-mini with the token, two turns one session; fails without the gateway change); served_test gains the auto cases. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried with a real Student account: what its /models returns, whether the plain session request suffices (Auto v2's /models/session/intent is not done), and the body is from VS Code's Copilot Chat, not captured traffic.
…
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
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