Files
* usage: the usage tests pass at any time of day, midnight included The usage tests stamped their calls with the machine's time and then asked for today, 7 or 30 days, or a cached answer, which the code worked out from its own time.Now a moment later. A minute after midnight a call made "a minute ago" was yesterday's, and when midnight fell between the test's read and the code's, the call and the period were of two days. magpie usage, the TUI's Usage page, the two Codex cache-write tests and the computers' shared days failed in the first minutes after midnight (the TUI's after midnight UTC, the zone its package's tests run in since1caea638), and 28 more failed when midnight fell inside them. usage.Clock is now the time every period is read by: the start of today, 7 days and 30 days (LedgerOf, Summarize, Direct, QueryPage), the hours or days of the chart (LedgerSeries, QueryPage), the day a cached summary or page is for, the time Append gives a call that has none, the days davsync shares and the date in an exported CSV's name. It is time.Now, as before, and a variable for the tests, like LogCalls. Each test that built its calls from the time now holds Clock at noon on 2026-09-30 (holdClock; holdUsageClock in gui and the CLI; three lines in tui and davsync) and stamps its calls from that instant, among them TestReasoningReplyTimedByItsAnswer, whose timeline rows117b6c7fmade at time.Now. TestPeriodsReadTheClock checks that every one of these reads goes through Clock. TestQueryPageSourceAndIdentityInvalidation is held too; that it fails when run alone is fixed apart from this, in #1045. Seven held tests could not fail at midnight before, and are held so that the tests read one clock: TestUsageSameSizeAndMtimeRewrite (All's totals), TestDirectCountsOnlySessionFileCalls (30 days around its own noon), TestQueryPageIOOutsideCacheLock (a file two days old), gui's TestUsageLedgerDayRoutes (7 days hold its calls from either day), TestLedgerSingleSnapshotAndLocalSession and TestLedgerNamesModelAtProvider (their calls are stamped after the code reads the time), and TestUsageRawAndPricedChunksShareBudget, which fails only when the midnight in it starts a new week of All's chart, on a Sunday night; at.sh sets the time of day and not the weekday, so that one was not shown. Verified with at.sh, a scratch script that runs a test binary under a zone made with zic, so that the local time of day is the one asked for as the run starts (HOME in a sandbox, Go's caches pinned). The runs were first made onc5a54bc7, and those marked (698da36f) again on698da36f, with the same results. The tui package runs in UTC since1caea638, a zone at.sh doesn't move, so its runs on698da36fused copies of both trees whose TestMain keeps the zone TZ sets. Before, at 00:00:30, 00:01:30 and 12:00:00 (698da36f): TestUsageShowsCallsNotThroughMagpie, 00:00:30: usage_cli_test.go:95: no calls today · route an agent through magpie and its usage shows up here 00:01:30: "5 tokens today · 1 call · no price", without the calls not through magpie; TestUsagePageShowsCallsNotThroughMagpie (tui) fails the same two ways at usage_test.go:138 --- FAIL: TestCodexCacheWriteNotInInput (00:00:30) codex_cache_write_test.go:104: rows [] --- FAIL: TestCodexCacheWriteMatchesGatewayCall (00:01:30) codex_cache_write_test.go:73: filter {...}: 0 rows, 0 calls, want the gateway's 2: [] --- FAIL: TestUsageSharedBetweenComputers (00:01:30) usage_share_test.go:134: a's days on the server: [...-2026-10-05 ...-2026-10-06 ...-2026-10-07] 12:00:00: PASS Midnight inside a test, in scratch copies of the head. Started at 23:59:59.3 with the code's first period read held 1.5s, so that it reads after midnight what the test stamped before it, 16 usage tests, 3 gui tests and TestUsageCSV FAIL, among them ledger_test.go:257: today: 0 review_test.go:69: chart counted 0 incremental_log_test.go:202: concurrent append lost TestCachedSummaryRemainsCallerOwned: panic: runtime error: index out of range [0] with length 0 usage_test.go:121: second page: {... Period:today Rows:[] ... Total:0 ...} usage_test.go:311: "": magpie-requests-7d-2026-10-06 Started at 23:59:55 with the test waiting past midnight between two of its reads, 7 more FAIL: request_day_test.go:45: selecting a day changed the chart or its legend cache_bounds_test.go:84: uncached today summary stale growing_rewrite_test.go:71: today summary stale cache_bounds_test.go:153: page key changed instead of exercising the same cache priced_blocks_test.go:55, caller_provider_test.go:129 and :172: page differs TestReasoningReplyTimedByItsAnswer, with a scratch 1.5s sleep before its LedgerSeries, started at 23:59:59.3 (698da36f): usage_test.go:255: part: {Calls:0 Tokens:0 Cost:0 Timed:0 TTFT:0 DecodeMs:0 DecodeOut:0} After: the same at.sh runs PASS at 00:00:30, 00:01:30 and 12:00:00 (698da36f); the same scratch copies made of this change PASS, each test taking 1.5s or 5.2s, so midnight did fall inside it (698da36ffor TestReasoningReplyTimedByItsAnswer's); and every changed test PASS at 23:59:59.9, 00:00:30 and 12:00:00 (698da36f). Without the change (698da36f): with the reads put back on time.Now, 21 usage tests, 6 gui, 2 CLI, the tui and the davsync test fail, among them --- FAIL: TestPeriodsReadTheClock clock_test.go:55: Direct(Today): 0 calls since 2026-10-06 00:00:00 -0700 PDT, want 1 since 2026-09-30 00:00:00 -0700 PDT clock_test.go:63: Summarize(Today) the next day: 1 calls, want 0 --- FAIL: TestReasoningReplyTimedByItsAnswer usage_test.go:255: part: {Calls:0 Tokens:0 ...} Each of the eleven reads put back alone fails a test: TestPeriodsReadTheClock for the nine in usage (LedgerSeries: "clock_test.go:52: LedgerSeries(Today): 0 calls, want 2"; the page's cache key: "clock_test.go:66: QueryPage(Today) the next day: 1 rows, want 0"), TestCSVStamp for the CSV's name (usage_test.go:325: "": magpie-requests-7d-2026-10-06) and TestUsageSharedBetweenComputers for davsync (usage_share_test.go:224: all computers: 4 rows, 707 in; want 5, 737). On this commit: go test -tags nogui -count=1 . ./internal/usage ./internal/tui ./internal/davsync ./internal/gui ok; -race -count=20 over the changed tests of each of these packages ok; gofmt -l prints nothing, and go vet -tags nogui on the five packages finds nothing. * usage: pages, CSVs and magpie usage asked at midnight show one day An answer about usage read the time more than once. The Requests page read it for the day it is kept for, for the period its log and session files are read over, for the period of its rows and for its chart's hours; the page from the ledger (QueryPage's path when LogCalls is set, as in the tests) for its rows and for its chart; Direct for its period and for its rows; a CSV for its rows and for its name; magpie usage and the TUI's Usage page for Summarize and then for Direct. Most of these reads have the log and the session files read between them, seconds on a large log, and when midnight fell between two of them the answer was of two days: yesterday's calls listed over today's empty chart, Direct starting yesterday and counting none of yesterday's calls, the calls not through magpie left out of magpie usage and the Usage page (they were the new day's, none yet), and yesterday's calls saved as magpie-requests-today-<the new day>.csv. Each answer now reads Clock once and passes that moment down. queryPage reads it for its key, its sources, its rows and its chart (buildRequestBlocks takes it); the page from the ledger reads its rows and its chart at it (LedgerOfAt, ledgerSeriesAt); Direct reads the ledger at the moment of its period. usage.Summaries(p) is Summarize(p) and Direct(p) at one moment, and magpie usage and the TUI's Usage page, when a period is picked and when it is read again, ask for it: one exported name, the clock left inside usage and one line changed at each caller, where SummarizeAt and DirectAt would be two names and each caller would read the clock itself. The CSV download and export read their rows with the new usage.LedgerOfAt at the moment their file is named for, which csvStamp now takes; the rows and the name are read in two packages, so LedgerOfAt is exported. LedgerOf, LedgerSeries, Summarize, Direct and QueryPage keep their signatures, each reading Clock once. The new tests set Clock to read a tenth of a second before midnight the first time and a tenth of a second after it from then on, set again for each answer. The midnight is the one that starts 2026-10-01, local time: a past instant, at midnight because that is what is tested, and the day before it is the 2026-09-30 the held tests use. TestAnswerReadsTheClockOnce asks QueryPage(Today) from the indexed log and from the ledger: its 3 rows, one from a session file written that day at 23:00, are under that day's chart, and asked again after midnight the page is the next day's, not the one kept for the day before; Direct(Today) counts the call and starts that day. TestUsageAskedAtMidnight (magpie usage today), TestUsagePageAtMidnight (the TUI's Usage page, today picked and read again) and TestUsageCSVAtMidnight (download and export) check that the calls through magpie and those not through it, or the rows and the file's name, are of the day asked on. TestCSVStamp passes its time. Verified on the previous commit, at.sh as there. The runs were first made onc5a54bc7, when the tui package's tests still ran in the local zone, and those marked (698da36f) again on698da36f, with the same results. Before: the new tests on the previous commit, at any time of day (at.sh at 12:00:00 too) (698da36f): --- FAIL: TestAnswerReadsTheClockOnce clock_test.go:131: QueryPage(Today): 0 rows, 0 in a chart from 2026-10-01 00:00:00 -0700 PDT; want 3 under one from 2026-09-30 00:00:00 -0700 PDT clock_test.go:141: QueryPage(Today): 3 rows, 0 in a chart from 2026-10-01 ...; want 3 under one from 2026-09-30 ... clock_test.go:145: Direct(Today): 0 calls since 2026-09-30 00:00:00 -0700 PDT, want 1 since 2026-09-30 00:00:00 -0700 PDT --- FAIL: TestUsageAskedAtMidnight usage_cli_test.go:154: 5 tokens today · 1 call · no price ..., with no part for the calls not through magpie --- FAIL: TestUsagePageAtMidnight usage_test.go:184: today picked: ... (the same) usage_test.go:187: read again: ... --- FAIL: TestUsageCSVAtMidnight usage_test.go:367: download: 200, attachment; filename="magpie-requests-today-2026-10-01.csv" with 2 rows (<nil>), want magpie-requests-today-2026-09-30.csv with 2 usage_test.go:378: export: 200 {"path":"~/Downloads/magpie-requests-today-2026-10-01.csv", "rows":1}, want magpie-requests-today-2026-09-30.csv with 1 row With the real clock, in scratch copies of both trees: scratch tests held one of Clock's reads 1.5s after it was made, as reading a large log does (the first; for one of them the page's third, its chart's), and at.sh started each at 23:59:59.3. Before, all 9 FAIL, among them asked 23:59:59.564, answered 00:00:01.068 Direct(Today): 0 calls since 2026-10-05 00:00:00 -0940 MID, want 1 since 2026-10-05 00:00:00 -0940 MID QueryPage(Today): 2 rows, 0 in a chart from 2026-10-06 ...; want 2 under one from 2026-10-05 ... download: attachment; filename="magpie-requests-today-2026-10-06.csv" with a call of 2026-10-05 23:59 After, all 9 PASS, answered at 00:00:00.9 to 00:00:01.0, so midnight fell inside each, except the chart's: its held read is gone with the change. A scratch sleep before the chart shows that one: before "2 rows, 0 in a chart from 2026-10-06", after PASS, asked 23:59:59.344 and answered 00:00:00.941. At 12:00:00 all of them PASS in both trees. After: every changed test PASS with at.sh at 23:59:59.9, 00:00:30 and 12:00:00 (698da36f). Without the change (698da36f): with the production put back to the previous commit's (gui/usage.go only so far that TestCSVStamp compiles: csvStamp takes the time, read apart from the rows as before), go test -count=1 . ./internal/usage ./internal/tui ./internal/gui fails the four new tests and no other, with the lines above. Each of the 16 places put back alone fails one of them: the page's key (clock_test.go:134: "3 rows, 3 in a chart from 2026-09-30 ...; want 0 under one from 2026-10-01 ..."), its sources (:131: "2 rows"), its rows (:131: "0 rows"), its chart (:131), the ledger page's rows and chart (:141), LedgerOfAt (:141, :145), ledgerSeriesAt (:141), Direct (:145); Summaries, indexedSummary and magpie usage (usage_cli_test.go:154); Summaries and the TUI's period key and reload (usage_test.go:184, :187); the CSV's download and export (usage_test.go:367, :378). On this commit: go test -tags nogui -count=1 . ./internal/usage ./internal/gui ./internal/tui ./internal/davsync ./internal/gateway ok; -race -count=20 over the changed tests of each package ok; gofmt -l prints nothing, and go vet -tags nogui finds nothing. * gateway: the key-limit tests pass when a run crosses local midnight A go test run that crossed local midnight could fail the gateway's key-limit tests. keyLimited and the /v1/magpie/limit endpoint each read time.Now() when they check a key, and access.Window turns that into a calendar window (a day from midnight, a week from Monday, a month from the 1st). The tests seed "yesterday" from their own time.Now() and then make several calls, so a run that crossed midnight saw its calls split over two windows: the second call of a 350-token day was let in, or a restart counted nothing. The gateway's key-limit path now reads limitClock, a variable for the tests like ruleClock and idClock, and production behaviour is unchanged (it is time.Now). TestKeyLimitRefusesBeforeUpstream, TestKeyLimitHoldsParallelRequests and TestKeyLimitWindowResets pin it at 2026-09-30 12:00 local and seed "yesterday" from that instant. Calls are still recorded at the real time, after the pinned one, and a window counts every call from its start, so they all count in the pinned window; the "tomorrow's window" check in TestKeyLimitWindowResets stays on the real now, the first window with none of those calls in it. No other test sets a key limit with records in its window (the gateway-key CLI test and the GUI caller-key tests count 0 calls or have no limit). TestKeyLimitRefusesBeforeUpstream now also checks that the 429's Retry-After and X-Magpie-Limit-Reset name the next midnight after the check, and that the limit endpoint's week starts on the pinned Monday. Verified with at.sh, a scratch script that runs the test binary under a zone made with zic, so that the local time of day is the one asked for as the run starts: - before, at.sh at 23:59:59 with COUNT=500/300 and FRAC 0.3/0.6/0.8: 3 of 6 runs of each test failed with key_limit_test.go:217: spent key let in key_limit_test.go:117: a restart forgot what the key used 200 key_limit_test.go:121: status &{Period:day Start:2026-10-06 ... Calls:0 Tokens:0 ...} - after, the same 6 runs (COUNT=500, both tests) print PASS, and so do ^TestKeyLimit at 12:00:00 and 00:00:00 with COUNT=20. - go test -tags nogui -count=1 ./internal/gateway: ok (79.5s); -race -count=20 -run '^TestKeyLimit' ./internal/gateway: ok. - gofmt -l prints nothing; go vet -tags nogui ./internal/gateway is clean. The month boundary (TestKeyLimitHoldsParallelRequests) and Monday can't be reproduced by changing the time of day; the pinned clock covers them the same way. Fails without the change (tests pinned, keyLimited back on time.Now): --- FAIL: TestKeyLimitRefusesBeforeUpstream key_limit_test.go:107: headers map[... Retry-After:[80228] X-Magpie-Limit-Reset:[2026-10-07T00:00:00-07:00] ...] and with only the limit endpoint back on time.Now: --- FAIL: TestKeyLimitRefusesBeforeUpstream key_limit_test.go:159: limit endpoint {... "period":"week", "start":"2026-10-05T00:00:00-07:00" ...} * gateway: the routing history tests pass when a run crosses midnight TestHistoryKeepsDaysAndDropsOld and TestRemoteMagpieGroupHistory took now := time.Now() and saved routes at now and now+1s. saveRoute files a route by its own day (r.Time.Local()), so a test started at 23:59:59 put the +1s route in tomorrow's file and History(today) found one route less. TestHistoryKeepsSessionAndTokenTiers raced the other clock: saveRoute prunes by time.Now(), so when the day turned between the test's time.Now() and the first saveRoute, the prune gzipped "today" after the first route, the second went to a new .jsonl, and the test's own pruneHistory gzipped that over the first .gz, losing route 1. saveRoute now prunes by historyClock (time.Now outside the tests). The three tests take historyNoon(t), which holds that clock at 2026-09-30 12:00 local and returns it as their now, restored with t.Cleanup. Every route, day file and prune is then on that one past day. The fixture can't be a fixed past day without the clock: pruning by the real today gzips that day's file at once (and, a month on, drops it as too old). The other history tests keep the real clock: TestHistoryRouteOutsideDayLimit writes its day files itself from one now and the code under test reads no clock, and TestHistoryRouteDayAndNoLock already uses a fixed day. Verified with at.sh, a scratch script that sets the local time of day through a zone made with zic as the run starts: - before, at.sh ./internal/gateway at 23:59:59: --- FAIL: TestHistoryKeepsDaysAndDropsOld history_test.go:50: days [{2026-10-06 1} {2026-10-05 1} {2026-10-04 1}] --- FAIL: TestRemoteMagpieGroupHistory remote_group_served_test.go:144: routes [... only ID 1 ...] and, with a 2s sleep after now := time.Now() standing in for the turn of the day, --- FAIL: TestHistoryKeepsSessionAndTokenTiers history_test.go:22: compressed history lost accounting: [only ID 2] 12:00:00 control: PASS. - after: the three tests PASS at 23:59:59, 23:59:59.9 (FRAC=0.9), 12:00:00, and 23:59:59 with the same 2s sleep; all TestHistory* and TestRemoteMagpieGroupHistory PASS at 23:59:59 with -test.count=3. - with saveRoute pruning by time.Now() again (the fixtures still pinned), at any time of day: --- FAIL: TestHistoryKeepsSessionAndTokenTiers history_test.go:32: compressed history lost accounting: [only ID 2] - go test -tags nogui -count=1 ./internal/gateway: ok; -race -count=20 on the three tests: ok; gofmt -l and go vet -tags nogui ./internal/gateway print nothing. * gateway, davsync: the request archive tests pass when a run crosses UTC midnight The archive names a call "<date>/<id>" from the call's own start, in UTC (archiving in archive.go). TestArchiveOn read time.Now().UTC() before posting and TestRequestArchiveToS3 read it before post(), and each expected that read's date. A run where UTC midnight (17:00 PDT) fell between the test's read and the request's start expected the day before the one the archive used, and failed. Both tests now take the date from the Time the call carries (s.Recent()[0].Time, the same start the archive's name is made from), in UTC, so nothing is read from the clock a second time. No production code changes. The other archive tests already go by the call's Archive name, and internal/gui's archive test uses a fixed name; no other test in internal/gateway, internal/davsync or internal/gui predicts an archive key or a UTC-dated name from its own clock. Verified: at.sh only moves the local time of day, and this is a UTC crossing (UTC midnight was ~16h away), so it was shown in a scratch copy whose serve starts each request 0.2s after the next UTC midnight, as a request does when the test read the clock just before it. There the old tests fail: archive_test.go:115: call archive "2026-10-07/000000-f4690de23ee8a859", want "2026-10-06/000000-f4690de23ee8a859" archive_test.go:93: nothing at "team x+y/magpie/archive/2026-10-06/000000-0e74a87816ef6a56.json" and the new ones pass. Unshifted, under at.sh at local 23:59:59, 00:00:00 and 12:00:00, both pass, as the old ones do at 23:59:59 and 00:00:00 (local time doesn't enter the name). With archiving naming by start.Format (local) instead of start.UTC().Format, both new tests fail under at.sh at local 23:00:00, where the local date is a day behind UTC's: --- FAIL: TestArchiveOn ... archive_test.go:116: call archive "2026-10-05/082500-7322712bd89f71da", want "2026-10-06/082500-7322712bd89f71da" --- FAIL: TestRequestArchiveToS3 ... archive_test.go:95: nothing at "team x+y/magpie/archive/2026-10-06/082500-3587ab4192da2cd5.json" and pass at 12:00:00. go test -tags nogui -count=1 ./internal/gateway ./internal/davsync passes in a sandbox HOME; -race -count=20 -run '^(TestArchiveOn|TestRequestArchiveToS3)$' passes; gofmt -l and go vet -tags nogui on both packages are clean. * gui: TestWhatsNewTodayRoute passes when the run crosses local midnight TestWhatsNewTodayRoute posts {"on":true} to /api/whatsnew/today, which writes today() to whatsnew-quiet-day, then checks quietToday(), which compares the file with a second today(). A run that crossed local midnight between the two read different days and failed with "on: 204 false". The neighbouring TestWhatsNewQuietAfterRestartToUpdate already pins today; this test didn't. The test now pins today to 2026-09-30 (restored with t.Cleanup), so the post and the check read the same day. The other What's new tests start from an empty folder, so quietToday finds no file and never reads today(). Verified: with a temporary 1s sleep between the post and the check, the unpinned test under at.sh (local time of day set by a zone) started at local 23:59:59.5 failed with "whatsnew_test.go:237: on: 204 false" and passed at 12:00:00; pinned, it passes at 23:59:59.5 (same sleep), and, without the sleep, at 23:59:59, 00:00:00 and 12:00:00. With setQuietToday writing time.Now().Format("2006-01-02") instead of today(), the test fails: "--- FAIL: TestWhatsNewTodayRoute ... whatsnew_test.go:239: on: 204 false". go test -tags nogui -count=1 ./internal/gui passes in a sandbox HOME; -race -count=20 -run '^TestWhatsNewTodayRoute$' passes; gofmt -l and go vet -tags nogui ./internal/gui are clean. * provider: TestZCodeStartSentAsTheApp passes when the run crosses midnight A ZCode Start Plan request opens with a context prefix that says "Today's date is <day>" (zcodeDateReminder), the day zcodeStartRequest took from its own time.Now(). TestZCodeStartSentAsTheApp sent the request, then built the expected prefix from a second time.Now(). A run that crossed midnight between the two failed on "the context prefix", the request holding the day before. zcodeStartRequest now takes the time from zcodeClock (a package variable, time.Now outside tests, as gateway's ruleClock), and the test pins it to 2026-09-30 12:00 local, restored with t.Cleanup, and expects "Today's date is 2026-09-30." The test sets TZ=Asia/Shanghai, which moves time.Local only when nothing has read it yet; a mid-day instant in time.Local formats to the same day either way. The other calls to zcodeStartBody in the test check shaping only, not the day. No other provider test builds a day string from a second clock: the WorkBuddy credits, daily warm-up and check-in tests pass fixed times to code that takes now as an argument, and TestQoderCheckinOnTheCard's day is only carried, not compared. Verified with at.sh (local time of day set by a zone, run as -run '^(TestDailyCredits|TestZCodeStartSentAsTheApp)$' so that time.Local is read before the test's t.Setenv("TZ"), as in a full run; alone, Local becomes Asia/Shanghai): with a temporary 1s sleep after the request, the unpinned test started at local 23:59:59.5 failed with "zcode_client_test.go:195: the context prefix: ... Today's date is 2026-10-05." and passed at 12:00:00; pinned, it passes at 23:59:59.5 (same sleep) and, without the sleep, at 23:59:59, 00:00:00 and 12:00:00. With zcodeStartRequest back on time.Now(), the pinned test fails: "--- FAIL: TestZCodeStartSentAsTheApp ... zcode_client_test.go:196: the context prefix: ... Today's date is 2026-10-06." go test -tags nogui -count=1 ./internal/provider passes in a sandbox HOME; -race -count=20 -run '^TestZCodeStartSentAsTheApp$' passes; gofmt -l and go vet -tags nogui ./internal/provider are clean. * accounts, quota: a window's reset reads against one time per listing; TestQuotaCell passes across midnight quotaCell, a window in `magpie accounts` and `magpie quota`, read the clock three times: whether the reset had passed, how long until it, and ResetClock's label, which names the calendar day ("tomorrow 02:13" or "02:13"). TestQuotaCell set the reset 2h13m30s from time.Now() and built the expected label from yet another time.Now(); a run that crossed local midnight between quotaCell's read and the test's got "tomorrow 02:13" from one and "02:13" from the other and failed. quotaCell now takes now. accountsCmd and quotaCmd take one time.Now() just before printing and pass it to every window's cell, so a listing's windows agree on what "today" is. The test passes a fixed 2026-09-30 12:00 local and expects "↻2h13m 14:13", and for a reset the next day "↻14h13m tomorrow 02:13"; no zone in tzdata 2026c changes its clock between those instants, so both read the same in any TZ. resetsCell's label, quotaReadingCell's expired check and quotaResetCmd's prompt still read time.Now(); no test compares them against a second clock. internal/tui's quotaCell and quotaLines already take now, and their tests pass a fixed one. Verified with at.sh (local time of day set by a zone): with a temporary 1s sleep between quotaCell and the check, the old test started at local 23:59:59.5 failed with "accounts_cli_test.go:128: \"5h 42% ↻2h13m tomorrow 02:13\"" and passed at 12:00:00; the new one passes at 23:59:59.5 (same sleep) and, without the sleep, at 23:59:59, 00:00:00 and 12:00:00. Putting any of quotaCell's three reads back on the wall clock fails the test ("--- FAIL: TestQuotaCell"). ResetClock's now alone fails the next-day case: "accounts_cli_test.go:134: tomorrow: \"7d 42% ↻14h13m 02:13\"". The passed check alone, or all three: "accounts_cli_test.go:129: \"5h 42% · reset time passed Sep 30 14:13\"". untilShort alone: "accounts_cli_test.go:129: \"5h 42% ↻now 14:13\"". go test -tags nogui -count=1 . passes in a sandbox HOME; -race -count=20 -run '^TestQuotaCell$' passes; gofmt -l and go vet -tags nogui . are clean.
gateway: a provider that turns Codex's image tool away with a bare 400 is asked again without it, and isn't offered it again once that works, so a plain chat no longer breaks on it; a ChatGPT sign-in, openai.com and a Codex backend that takes it still get it (#949, cjyrainbow)
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.
usage: each subscription account's tokens and cost, by the account that answered (#557: 希望用量账本能按订阅账号统计,每个 Codex / Claude 账号各用了多少 token、多少钱) — a call's record named the upstream key it went out on (ProviderKeyID/Name, #500) but never the subscription account, so with two Codex accounts signed in, or a Claude account that took over after a failover, the ledger could say what Codex cost and not what each account did. A record now carries providerAccount, the account that answered as the Routing trace names it (Account.User: its email or login, never a token), set by the gateway at the try that went out (the candidate loop's last try, so a failover's answer is the account that took over and the refused try stays the first account's; an X-Magpie-Account pin is the pinned one), and on the decide, draw and video paths too. Record.Account() gives it, or for a record written before the field the account its host named (Where() has long written "chatgpt.com as dee@example.com" for an account), and otherwise "" — shown as "account not recorded", never inferred from today's sign-in. The summary groups calls by provider and account (Summary.Accounts, the same email on Codex and on Claude being two accounts), priced as every other group; the ledger's Filter has Account, the search finds a call by its account, the CSV has a provider_account column beside provider_key_name (the provider's identity columns together, the caller's still last), and the Requests page's packed rows keep it and offer the period's accounts. magpie usage prints an accounts table after the provider keys, and magpie usage --csv --account <name> keeps one account's calls. The app's Usage page lists Accounts on the Overview (tokens and cost per account, the provider under its name, an unrecorded one in the faint colour, no stripe down a row's side) and has an Account filter on Requests, each account noting the providers it answered for, which asks for and exports that account's calls without moving the page. OTLP export is an allowlist and does not carry the account. TestUsageNamesPinnedAccount (both pinned accounts on magpie's endpoint and on Codex's own, the trace's Who and the record agreeing, no token in usage.jsonl) and TestUsageNamesAccountThatTookOver (Ordered routing, the first account refusing: the answer recorded as the spare, the refusal as the first) fail without the gateway change; TestSummaryByAccount, TestLedgerByAccount and TestRequestPageByAccount cover the grouping, the host fallback, "not recorded", keyed providers kept out, the filter, the search, the CSV column and the packed rows; TestLedger and TestLedgerWithSessionLogCalls take the new column. usage-accounts.test.cjs (WebKit and Chromium, en and zh: the Overview's accounts and costs, the Account picker's notes, its request and export, All accounts clearing it, scrollTop unchanged) fails without the asset change; api-key-usage, click-scroll, panel-usage, request-archive-usage, tray-usages, claude-usage-asked and every usage-* test pass in WebKit. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState failed once on its TempDir cleanup, passes alone).
codex: a group's effort for the turn keeps Codex's reasoning.context, and Codex's custom tools (code mode's exec, apply_patch) reach a model magpie translates for (#534, #520 follow-up, azir12345: since v0.1.637 a Plus account's gpt-6.1-sol fails with 400 "X-OpenAI-Internal-Codex-Responses-Lite requires
reasoning.context to be all_turns", and Codex said it had only the collaboration and wait tools, no terminal) — ChatGPT's catalog marks gpt-6.1-sol (as gpt-6-sol, gpt-6-luna, gpt-5.6-*) use_responses_lite with tool_mode code_mode_only, and Codex asks for the bare slug with its native metadata: the X-OpenAI-Internal-Codex-Responses-Lite header (add_responses_lite_header, which magpie forwards with the other x-openai-* headers), reasoning {"effort","summary","context":"all_turns"} (build_reasoning), and its tools moved into an additional_tools item, every other tool reached through exec, a custom (freeform, lark grammar) tool in the "functions" namespace, beside wait and the collaboration namespace. magpie routes the bare id to the group auto-gpt-6-1-sol, and the effort the group picks for the turn went through withEffort, whose Responses branch rebuilt reasoning as {effort, summary} and dropped context, so ChatGPT refused the Lite request; it now sets only the effort (withBodyEffort), keeping every other field of reasoning as Codex sent it. A member served by translation (Chat, Anthropic, a Responses plugin) had parseResponses drop custom tools and ignore custom_tool_call / custom_tool_call_output items, so exec vanished and the model saw only wait and the collaboration tools; a custom tool (top-level or in a namespace) is now offered as a function taking one string "input", its description its own plus a note on input and its grammar, an earlier custom_tool_call goes to the model as a call with {"input":…} and its output as the call's result, and the model's call goes back to Codex as the custom_tool_call it runs (name and namespace as Codex knows them, input from the "input" argument, else the arguments as given), streamed without function_call_arguments events and in the non-streamed reply alike. This also gives translated models Codex's freeform apply_patch, which was dropped silently before. The Lite header is still forwarded as it was; nothing showed it needs dropping. Codex's base_instructions and identity line are untouched. No request was made to ChatGPT. TestWithEffort's Lite case fails without the decide.go change ("want \"context\":\"all_turns\""); TestCodexCustomToolsTranslated (a code-mode Lite turn through a fake Chat provider: exec offered with input and its grammar, the earlier call and output shown, the model's exec call back as a custom_tool_call with its input and no arguments) and TestCodexCustomToolNamespaced (namespaced and non-JSON calls, renderResponses) cover the tools; TestAdditionalToolsTranslated, TestNamespacedToolsOffered and TestGrokPluginGetsNamespacedTools now expect the custom tools offered. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestDevinSignedInOnlyInMagpie failed once on its wait, passes alone; untouched).
gateway: Codex's Responses Lite tools reach SuperGrok and every other backend, and a group moves on when a member can't read a request (#350) — Codex sends a Lite model's tools as an additional_tools input item, its own functions in a "functions" namespace; magpie relayed the item to xAI, which 422s on it (unknown item type), and translated routes (Cursor, Anthropic, Chat…) dropped it, so the model had no tools. Outside OpenAI the item's tools now go in tools, the functions namespace opened into plain tools and earlier calls un-namespaced; OpenAI and the ChatGPT account still get Codex's own form. A 400/422 over the request's shape (failed to deserialize, unknown item type/parameter…) now moves to the group's next member, each asked once, the member not resting; the Routing page says so. TestAdditionalToolsRelayed, TestAdditionalToolsTranslated and TestGroupFailsOverARequestShape fail without the change; routing-shape.test.cjs (Chromium, WebKit, en/zh) added. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; click-scroll, old-webkit, routing-wb-refused pass.
gateway, provider: the Routing page says what a lone key or account has left, and a key bought as a plan (Kimi Code, GLM Coding Plan, MiniMax, OpenCode Go, Command Code) is weighed by its plan's windows (#1016, gphysign: Kimi Code (China) with one key on, Smart routing; the card said only "Kimi Code (China) 只启用了一个 Key,无需选择。" while /v1/magpie/quotas had its 5 hours 49 used, 51 left and its 7 days 45 used) — two gaps. weighRouted returned before reading anyone's allowance when there was one candidate, so a lone key or a lone subscription account was never known in the trace, and the Routing page, which shows a bar and "{n} used · renews in {d}" for a known row, had nothing to show; every single-account subscription said "what's left not known yet" the same way. And KeyAllowance read only a sub2api key's limits, so a key whose plan the Usage page reads (planQuotaSourceOf: Kimi Code, GLM Coding Plan and Z.ai, MiniMax Coding Plan, OpenCode Go, Command Code) was never known to routing, with one key or several. weighRouted now reads what each candidate has left before returning a lone one (one alone is still weighed against nobody). readsKeyWindows takes a plan key too; keyWindows reads it through planKeyWindows (planWindows, with Zhipu's team quota behind a key that has none of its own, as PlanQuotas does), and PlanQuotas' own read of a card is noted for routing at once (noteKeyAllowance), so the Usage page and routing never ask twice; the card kept on disk seeds it after a restart (keyCardTag keys a plan card as plan:, a sub2api one as balance:). A key at such a vendor that turns out to be pay-as-you-go, with no windows, is asked again every 30 minutes (keyNoWindowsAge), not every minute it is used. So, beyond the issue: several plan keys on one provider are now weighed by their windows under Smart, usage and pace, and a plan key the vendor refuses as out of quota rests until its window renews and comes back once read short of full, as a sub2api key does. affine no longer says a lone key or account all but used up "moved" its conversation: there is nobody to move to (at > 0). The GUI needed no change. TestLoneOneTellsWhatItHasLeft (a lone Kimi key known at 49% with both renews, the week first; a lone Codex account known; a plain DeepSeek key not), TestLoneSpentKeyKeepsItsConversation (fails with the at > 0 guard taken out), TestPlanKeyAllowance (the windows read behind the request, asked once; DeepSeek and Moonshot keys never asked), TestPlanKeyAllowanceFromItsCard (PlanQuotas' read known at once without a second ask; after a restart with the vendor down, the card on disk stands in and a failed read keeps it) and TestPayAsYouGoKeyAskedSeldom (a GLM pay-as-you-go key asked once, not two minutes on, again 31 minutes on) all fail without the change and pass -race -count=20 (provider) / -count=10 (gateway). routing-key-windows.test.cjs gains a lone Kimi Code key at 1100px and 440px (the one-key story, the row's bar at 49%, its provider row "49% used · renews in …" fitting, no sideways scroll) and passes in full on Chromium and WebKit in en, zh, ja and de; routing-quota-left and panel-routing pass. go test -tags nogui ./internal/..., go test -race on ./internal/provider and ./internal/gateway in full, go vet, gofmt -l, darwin, linux and windows (nogui) builds pass. Not tried with a real Kimi Code key. StepFun's Step Plan is read through a sign-in, not the key, so it isn't one of these. docs/subsystems/gateway-routing.md, providers-accounts.md and docs/reference.md say it.
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
agent, gateway: Cursor Private Inference's row has an effort, which magpie's gateway asks its requests for (#1003, gjf7) — other agents' rows pick a reasoning effort that magpie writes into their config, but Cursor Private Inference reads only its base URL and key from the environment, and its app offers no Reasoning control for most of magpie's models, so there was no way to set one short of a routing group with a member fixed at a level, which the reporter used as a workaround. Its row now has an effort field like the others': the default (as Cursor asks) first, then every level the models it lists have (cursorLocalEfforts: their levels in MemberEfforts order, low/medium/high where a model reasons with levels unknown, none at all when nothing it lists reasons or it isn't connected). The pick is kept in magpie, settings' agentEfforts by agent (provider.AgentEffort / SetAgentEffort, a level of MemberEfforts or "" for none), and the gateway's candidate loop asks a cursor-local request for it with withFixedEffort, fitted to the model's levels and bounded by a group's, on a model that reasons (the new Provider.Thinks: levels, its list's Reasoning, or models.dev's). Precedence: a group member's fixed effort, then a :effort suffix, then a routing rule's turn pick, then the agent's pick, then the request's own; the pick wins over a level Cursor sent, as Cursor sends its own default as often as a choice, and Default hands the choice back to Cursor. A model that doesn't reason and every other agent's requests go as they came. The Settings page's save carries agentEfforts over as it does fastPicks (TestSettingsSaveKeepsWhatItDoesNotSend's fixture names it). In the window the field sits under "Settings" rather than "New sessions" for this agent alone, as it applies to every request from then on, not to what a session starts on; no new t() strings (Settings, effort and default are translated in zh, ja and de already). TestAgentEffortOnCursorLocal (Chat and Responses with and without a level of their own, Claude on Messages made to think, OpenCode's request untouched, a non-reasoning model untouched, a member fixed at low kept, the pick taken off) fails without the gateway change and passes; TestCursorLocalEffort covers the options (none with no reasoning model, the union of levels, the low/medium/high fallback, none while disconnected), Apply and a bad level refused; both pass -race (-count=20 gateway, -count=10 agent). cursor-local-effort.test.cjs (the window at 560px and the tray panel at 440px: the field under Settings, the slider's five stops with Default first, a pick posting the field, no sideways scroll) passes on Chromium and WebKit in en, zh, ja and de; claude-effort, subagent-effort, agent-field-names, agent-connect, agent-layout, agent-tweak-fold, agent-models, cursor-local-launch and i18n pass on both. go test -tags nogui ./internal/..., go vet, gofmt -l, linux (nogui) and windows builds pass. Not tried with the real Cursor Private Inference: whether it sends a reasoning level of its own on models it shows a Reasoning control for is unverified, which the precedence makes moot. docs/subsystems/gateway-routing.md and agent-wiring.md say it.
gateway: a Gemini functionResponse is the user's turn whatever role it came under, so agy's conversations end with the user's turn (#650, pwu4: Antigravity through a remote magpie answered from the second turn "Requests ending with a model turn are not supported") — Antigravity CLI, captured against a stub, sends each tool's result as {"role":"model","parts":[{"functionResponse":…}]} right after the call. parseGemini kept it in an assistant message, so once a turn ran a tool the request ended with the model's turn: straight to Code Assist, or through another magpie over Anthropic Messages (Chat and Responses happened to put it right). A functionResponse now starts a user message of its own, and the parts around it keep their role and order. TestAgyToolResultsAreTheUsers (agy's captured shape, with and without a tool, direct and via Anthropic, Chat and Responses) fails on the old code. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried against real Antigravity.
gateway: a tool whose input_schema has anyOf/oneOf/allOf at its root reaches Anthropic-protocol vendors as an object, and moved Factory users get the plugin that keeps its constraints (#646, tedestar; magpie-community/plugins#12, SadWood)
antigravity: its 429 to Claude Code's system prompt says it isn't a quota (#666: Claude Desktop 调用 antigravity 恒定报 429) — Antigravity answers "Resource has been exhausted (e.g. check quota)" to any request whose systemInstruction holds Claude Code's billing line (x-anthropic-billing-header: …) or the Agent SDK's identity line (You are a Claude agent, built on Anthropic's Claude Agent SDK.), on every model (gemini-3.8/3.7 flash, 3.5 flash lite, 2.5 flash, claude-sonnet-4-6, gpt-oss), whatever the quota left, while the same words in a message go through. Claude Desktop's chats run the Agent SDK and carry both, so they always failed, while its title requests (no system prompt) and Codex on the same account answered. Not the session id (magpie already sends request.sessionId as -<uint63>), thinking signatures (never sent back) or the User-Agent. magpie doesn't hide the lines from Google; the gateway now adds to such a 429 that it is no quota and that Antigravity turns away Claude Code's and the Agent SDK's system prompt, so no one chases a quota. Reproduced on the owner's real Antigravity account with Claude Code 2.1.288 through a sandbox gateway (11/11 429; system field removed 200; either line alone 429). TestAntigravityTurnsAwayClaudeCodesSystemPrompt. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
usage, gateway, gui, provider, cli: the tests pass when a run crosses midnight, and a usage answer reads the clock once (#1046)
archive: a request the archive kept is opened and downloaded from the Usage page, with its bodies whole (jorben, #447: 请求存档 reachable from usage history, full bodies, download) — the archive's name was only on the Gateway page's recent calls, gone once a call left the list or the gateway restarted, and the archive kept the first 256 KB of each body, the recent list's cut. The usage ledger's row now names the call's archive (archive in usage.jsonl, kept through the Requests page's packed rows), and its details on Usage → Requests show the Request archive panel, Fetch from archive and Download; with the archive on, a request it has no copy of says "Not archived". The bodies are kept whole up to requestArchiveMaxMB in settings.json (32 MB unless set, at most 1024): the response is teed to a temp file as it is sent, the request body written to one when the call ends, so the upload queue holds file handles, not bodies, and one upload at a time reads them; a body past the limit is kept to it and marked cut, with its whole size. What goes upstream is unchanged, the archive stays off unless turned on, headers and bodies are scrubbed as before, and the temp files are removed after the upload or when the queue is full. The page is sent a body to show only when it is 256 KB or less (a longer one is said by its size), an archive over 80 MB is only said to be; Download streams the object whole, to Downloads in the app (archive/export, -2, -3… for a name taken) and as the browser's download in magpie web (archive/file). TestArchiveWholeBodies, TestArchiveLimit, TestRowKeepsArchive and TestArchivePreviewAndDownload fail without the change; TestArchiveOffUsage, TestArchiveTooLargeToPreview, TestArchiveOldPreview keep the rest. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; request-archive-usage (new), request-archive, click-scroll, old-webkit, usage-ledger, usage-ledger-detail, usage-ledger-content, web-page, sse-body pass.
gateway: an image or a PDF sent as Gemini's inline_data, or as a Chat Completions file part, reaches the model, and a Gemini stream gets no keepalive comment (#934, handong66)
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: reasoning magpie made isn't relayed to Azure OpenAI or OpenAI's API, and a summary request refused for an item not found is asked again as text (#1008, zhouxiong100: Codex on an Azure OpenAI deployment failed every compaction with "Azure OpenAI: Item with id 'rs_18dbe693b3dce4800000003c' not found", Reconnecting 5/5, store=true by a middleware no help) — Codex offers web search on every turn, so its turns on Azure went out translated, and the translated replies gave Codex reasoning items magpie made: rs_ and newID (the reporter's id is one, a UnixNano in hex and a counter), a summary and nothing sealed. Codex hands them back on every turn. buildResponses replays no reasoning to Azure, so the turns worked; Codex's compaction offers no tools, so it isn't translated but relayed as it is, and Azure, with store false, looked rs_… up among stored items, found none and answered 400 for the whole request. codexCompact asked again as text only on a 404 (#866), so the 400 went back to Codex. passthrough now drops reasoning with no encrypted_content from a Responses request to OpenAI's API or Azure OpenAI unless store is true (withoutBareReasoning): both look such an item up and can't have it, its summary being the model's notes no model reads back; their own sealed reasoning still goes, and every other host gets the request as before (a relay may read the summary for a vendor that wants reasoning back). The same holds for any turn relayed as it is, Codex's with web search off for one. codexCompact's fallback also takes a 400 naming an item not found (itemNotFound: unreadableItem and "not found"), as a relay in front of either passes on; any other 400 still goes back as it came, and the local summary names the status it got. TestAzureCompactionWithoutMagpiesReasoning (the Azure preset at a fake /openai/v1/responses answering a storeless request with a bare reasoning item Azure's 400 word for word; the compaction and a relayed turn, each asking Azure once with the sealed reasoning kept and magpie's dropped) fails without the passthrough change (the turn 400 with the reporter's exact message, the compaction asking twice) and passes; TestCodexCompactItemNotFoundAsksAgainAsText fails without the codexCompact change (400 to Codex) and passes; TestCodexCompactOther400Stays guards other 400s. -race -count=10 on them, TestCodexCompact* and TestAzureRoutes. go test -tags nogui ./internal/gateway ./internal/provider, go vet, gofmt -l, linux and windows (nogui) builds pass. Not tried with a real Azure OpenAI resource (none on this machine); the 400's text is from the reporter's screenshot and issue. docs/subsystems/gateway-routing.md says it.
gateway: a Responses relay that turns away reasoning with nothing sealed in it, naming only the input, is asked once more without it, and isn't sent it again for that model (#1044: Codex's /compact through Anyrouter failed with 400 invalid_request_error param=input on old conversations)
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
update: a restart to update waits until the gateway is idle instead of cutting off what the agents have in flight (#577: 正式版更新时保持网关请求与工具链不中断 — a Codex reply streaming through magpie, or a tool chain under way, was cut off by Restart to update) — the app serves the gateway in its own process and a restart to update is install, quit, relaunch, so whatever was in flight ended with it: a stream mid-reply, and a Claude Code turn of the subscription bridge waiting on its caller's tool results, whose Claude Code lives only in this process. The gateway now says what a restart would cut short (Server.Busy): its requests in flight (all but GETs and the bridge's own MCP callbacks), the bridge's runs parked on tool results, and when the last request ended. Restart to update (Settings → Version, the header's Update pill, the tray's item) restarts at once only when nothing is in flight, no turn waits on tools and nothing ended in the last 10 s (a tool chain's gap, the agent running a tool between two requests); otherwise it waits, saying what for: Settings shows "restarts once the gateway is idle · 2 in flight · 1 awaiting tool results" (网关空闲后自动重启 · 2 个请求进行中 · 1 个工具调用待返回) with Restart now and Cancel, the pill reads "Waiting to update" (等待更新) and its next click (not a double click's second half) restarts now, the tray item becomes "Restart Now to Update (3 in flight)" (立即重启以更新(3 个进行中)). The wait asks the feed again once idle, so versions out meanwhile collapse into the newest, downloaded before the restart; it gives up after an hour, saying so, the update then going in at the next restart or quit. A gateway served by another magpie (magpie serve, an older one) is not this one's to wait for. Handing the gateway over to the new version instead (the dev handover,
e0c6892) is not used for releases: it rests on SO_REUSEPORT, which Windows lacks, and the new app would have to start beside the old one (single-instance, the Mac's bundle swapped under it). TestBusy (a stream counts until it ends, a GET never, a parked bridge run until it is answered or ends), TestRestartToUpdateWaitsForTheGateway (fails without the change: the click restarted with 2 requests in flight), TestRestartWhenIdle (not while streaming, parked or in a tool chain's gap; then once; an hour's wait gives up), TestRestartWhenIdleIntoTheNewest (0.1.11 staged, 0.1.12 and 0.1.13 out while waiting: restarted into 0.1.13) and TestTrayRestartNow are new; update-wait.test.cjs (WebKit, en and zh: what is in flight, waiting with Restart now and Cancel, counts following the gateway, the hour's give-up, the pill's wait and restart now, an idle click restarting at once, no click scrolling, no left border) fails without the change and passes with it. update-hide, update-check, sync-update, update-auto and click-scroll pass in WebKit. go vet ./internal/..., go build (darwin GUI), go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState's known TempDir cleanup flake aside; passes on rerun).
library: a remote MCP server signed in once in magpie is used by every agent without signing in again (#615) — a streamable-HTTP server behind OAuth (Neon, Notion, Supabase) had each agent run its own sign-in. The server's editor now has Sign in: magpie finds the authorization server from the server's 401 (RFC 9728 protected-resource metadata, RFC 8414/OIDC metadata, else the 2025-03 /authorize, /token, /register at its origin), registers itself (RFC 7591, a 127.0.0.1 redirect), opens the browser with PKCE S256 and the resource, and keeps the tokens in mcp-signins.json beside its other account secrets (0600, never the keychain). While signed in, the agents are given the gateway's /mcp/<name> for that server, without the Authorization the library has for it; the gateway passes POST (JSON or event stream), the GET stream and DELETE on with the Mcp-Session-Id and magpie's token, renews it 2 minutes before it runs out and once on a 401, one renewal per server at a time, and a sign-in the server won't renew is answered 502 "sign in to it again" (never a 401 the agent would act on). Signing out, a new URL or an SSE server gives the agents the server's own address as before; a rename keeps the sign-in and removing the server drops it. /mcp/ is local-only or LAN-shared with the key, and refuses a request with an Origin or a Host not naming this computer, so a web page can't borrow the sign-in through the browser (form posts, DNS rebinding), and isn't counted as busy or key-limited. WSL agents are given their distro's gateway address. The row says Signed in or Sign-in ran out; zh strings added. Tests: TestSignIn, TestSignInWithoutMetadata, TestSignInPage, TestNothingToSignIn, TestTokenRenewsBeforeItRunsOut (8 calls, 1 renewal), TestRenewRefused, TestRenewRefusedForGood, TestMCPProxy, TestMCPProxyStaysHere, TestMCPProxyNotForWebPages (fails without the Origin/Host check), TestSignedInServerGoesThroughMagpie against a local fake OAuth+MCP server; mcp-signin.test.cjs (webkit, en and zh). Not tried against a real Neon/Notion/Supabase sign-in. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: a test that a Chat reply relayed to a Chat client hands on its reasoning as it comes (Dazzle-sys, #473: dsh on WorkBuddy AI's deepseek-v4.1-flash at max heard nothing for 300s, then pi-ai's stream idle timeout canceled it) — looked into, magpie isn't what kept dsh waiting: a DeepSeek model's reasoning_content goes to the client as each piece arrives (refusesAfterThinking is false for it, so the hold for a refusal lets the stream through at its first thought), and what is held before it (the role chunk, empty deltas, SSE comments) is held 15s at most. A WorkBuddy reply that says nothing for five minutes has nothing magpie could hand on. No behaviour changes. TestChatReasoningRelayedAsItComes fails when the reasoning is held for a refusal as Claude's is. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
gateway: a Chat upstream gets a mended tool exchange — strict ones (Kimi) answered a broken one 400 "tool_call_id is not found" (#554)
gateway: an image or a PDF sent as Gemini's inline_data, or as a Chat Completions file part, reaches the model, and a Gemini stream gets no keepalive comment (#934, handong66)
provider, gateway, gui: a ChatGPT plan can be signed in with OpenAI's Sign in with ChatGPT, as the ChatGPT API subscription, and asked on api.openai.com's Responses with the agent's own instructions (#933, knothhe)
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: an earlier assistant turn's thinking goes to Mistral as the thinking part Mistral itself returns, and a vendor that turns it away inside messages is asked again without it (#494: Mistral's 422 extra_forbidden for OpenCode's reasoning_content, after any tool call) — OpenCode sends an earlier assistant turn back with its thinking in reasoning_content, a tool-call turn too (content null), and magpie relayed it as sent; Mistral refuses any field it doesn't know, loc ["body","messages",2,"assistant","reasoning_content"], so a Mistral reasoning model (GLM on api.mistral.ai) in OpenCode failed as soon as it used a tool. The #393 retry only dropped a fixed list of top-level fields. To Mistral (its preset, or api.mistral.ai) an assistant message's reasoning_content (or reasoning, or reasoning_details' text) now goes as a leading {"type":"thinking","thinking":[{"type":"text","text":…}]} part of its content, its text after it as a text part, its tool_calls as they were, so the thinking stays in context, as the reporter found Mistral takes it; the other messages keep their bytes. Any other Chat upstream answering extra_forbidden located in messages for one of those fields is asked again with it left out of the messages, and once that works it is left out for that provider from then on; DeepSeek, Kimi and every vendor that takes it are sent it as before. Prompts and headers are unchanged. TestMistralReasoningAsThinking (the issue's request, and a tool-call turn) and TestMessageReasoningRefused fail without the change. go vet and the darwin build pass; the full test and cross-build chain could not run, the disk being full.
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
gateway: a Claude subscription's reply goes on when Anthropic's stream is cut short and Claude Code asks again, instead of ending in an empty end_turn (#726, EZN7L2C3 on Claude Code 2.1.288 at effort max: a 601 s turn ended with only 74,427 chars of thinking, output_tokens 5, end_turn, and Claude Code appended "Your previous response had no visible output"; the next turns each wrote ~467k tokens to the cache again, five of them 2.2M, and the 5h window went from 84% to 0) — when Anthropic's stream drops or stalls, Claude Code 2.1.28x closes the open block and writes its own message_stop with no message_delta before it, emits a system api_retry and asks again, streamed (a new message_start); cut short again, it asks once more without a stream and tells that answer only as assistant envelopes, one per content block, each carrying the whole message's id, model, stop_reason and usage (checked against a fake Anthropic API under a sandbox HOME, with the bridge's own flags: one cut, two cuts, two cuts then tool_use blocks, every stream cut). readOutput took that message_stop for the reply's end: relay ended with no stop, which anthropicEncoder.finish writes as end_turn, with the usage message_start had told (output 5, cache write 467,708), and ended() aborts a run whose stop isn't "stop", killing Claude Code's retry and the run kept for the next turn, so the next turn started a new run with the whole conversation in one message, written to the cache again (the 38,623 read at 05:10 is Claude Code's own prefix). Now a message_stop with no message_delta before it marks the attempt cut short and ends nothing: the next message_start goes on in the same reply, its usage added to the attempt's as after Claude Code's own tool call, and an assistant envelope under an id other than the streamed message's (not Claude Code's <synthetic> error message) is that unstreamed answer: its thinking (with signature), text and tool calls are passed on, client calls handed over as from a stream, and the reply ends with its stop on the next line of anything else, the result, or once nothing has come for unstreamedQuiet (300 ms), as Claude Code writes all the blocks at once and then calls the client's tools and waits. A cut short after words or a client call were shown fails the reply (502, "Claude Code's stream from Anthropic was cut short after the reply had begun") rather than repeating the answer after half of it; one with nothing to go on in (the output or the turn ends) fails too, never a false end_turn. readOutput's loop body moved into a closure run under a mutex, which the quiet timer takes as well; toolStart, text, begin, end and failed are shared by the stream and the unstreamed paths. Output accounting itself was right: message_delta's output_tokens reach the client (TestClaudeStreamOutputCounted, which passes on the old code too); the 5 was the cut attempt's message_start count. TestClaudeCutStreamGoesOnInRetry (answer, end_turn, output 12005, run kept), TestClaudeCutStreamAnsweredUnstreamed, TestClaudeCutStreamUnstreamedToolCalls, TestClaudeCutStreamWithNoAnswer and TestClaudeCutStreamAfterWords fail on the old code and pass; the real Claude Code 2.1.289 output captured against the fake API (retry, unstreamed answer, unstreamed tool calls, every stream cut) gives the answer, its stop and a kept run through readOutput and relay. go vet, builds (darwin, GOOS=linux, GOOS=windows), go test -race on the gateway's Claude tests and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried with the reporter's account or a real Anthropic cut. What this doesn't change: a hot switch to another account starts a new run, and Anthropic's cache is per organisation, so the whole conversation is written once on the new account; sonnet and opus caches are separate too, and the sonnet served at 04:56 came from inside Claude Code (its own fallbacks; magpie passes no --fallback-model and maps no opus to sonnet). PR #626 (one work dir, the caller's system prompt cached on its own) would help a new run read more of its prefix; it isn't part of this.
claude: a reply that fills its segment before the request reads it doesn't hold the run's lock (CI Test 37434913069 on
8fbd39f8: TestClaudeLastWordsAreRead hung 20m on macOS -race) — emit sent each event to the segment, a 64-event channel, with r.mu held, and the request calls run.heard, which takes r.mu, before it starts reading the segment. A Claude Code that wrote more than 64 events first (the test's 300 deltas, or a quick long reply) left emit blocked on the send holding the lock and heard waiting on it: the run hung for good, and abort, stderr and every other r.mu user with it. emit now takes the segment and updates shown under r.mu, lets it go, and sends under a lock of its own (sending); the two places that close a segment (endSegment, finish) take that lock first, so a send never meets a closed channel. Events keep their order, emit being serialised by sending. TestEmitOnFullSegmentLeavesTheRunFree (200 events emitted before anyone reads, then heard) fails on the old code (heard waits) and passes; it, TestClaudeLastWordsAreRead, TestClientGoneRecordedAsCanceled and Keepalive pass -race -count=20; the whole gateway package passes -race; go vet, linux(nogui)/windows builds pass. The other sends in the file (answered, the MCP waiter) already send after unlocking.
claude: Claude Code's output is read to the end before its run ends, not cut by Wait closing the pipe (CI Test run 37415124103 on
963a8d8f: TestClientGoneRecordedAsCanceled got event: error … "read |0: file already closed" in place of message_stop on ubuntu, race shard) — the run read Claude Code's stdout from cmd.StdoutPipe, and launch's goroutine called Wait as soon as Claude Code exited, which closes that pipe. A run stopped as its client went away (letGo → abort → Kill) could have Wait close the pipe under readOutput before abort's finish closed the segment: the scanner failed with os.ErrClosed, readOutput emitted it as a KError, and relay wrote it to the client as the stream's error. The same cut lost what Claude Code wrote last whenever it exited: whatever was still in the pipe never reached the client. Its stdout and stderr now come through os.Pipe pipes of the run's own, which Wait doesn't touch; once Claude Code has exited and its group been ended, the run waits for both readers (up to outputDrain, 2s, for a process that left the group and still holds them), closes the pipes, then finishes. A read cut by that close is taken as the output's end, not an error. TestClaudeLastWordsAreRead (a fake Claude Code writes ~300KB and exits) fails on the old code on every run (the reply's last delta lost) and passes; TestClientGoneRecordedAsCanceled|Keepalive|TestClaudeLastWordsAreRead pass -race -count=50 under GOMAXPROCS=2 with 4 busy cores; the gateway's race suite through build/race-shards.sh (4 shards, as CI) and go vet pass. The CI failure itself didn't reproduce on macOS. Not changed: the "http: panic serving …: invalid concurrent Body.Read call" lines in the same log are in shards that pass and in green runs too (5 in 37416877275), near the Copilot auto / count_tokens tests; their handler wasn't found.
gateway: a Claude subscription conversation the client sends back rewritten goes on in the run that had its last turn, and Claude Code's temp images go with their session (#912, margbug01) — Alma takes the [Context: …] block out of its last user message once the turn is over, the <alma_notification> block out of the end of the reply, and picks the tools for each turn, so turnKey, which hashes all of it, never matched and every turn started a new claude -p told the whole conversation, written to the prompt cache again with its images. ended keeps a looseTurn beside the key: the settings without the tools, the conversation before the last turn hashed, and the last turn's messages one by one. When the exact key misses, rewritten takes the one idle run or saved session whose looseTurn has the same owner, settings and earlier turns, the same roles, tool calls and results in the last turn, where a user message only had one part taken out (cutFrom) and the reply is a non-empty start of what the run said. Two that could be it start a new run, as does an idle run not told a tool offered now (offers) or a saved session one of whose tools is gone (toolsCover). Claude Code's <CLAUDE_CODE_TMPDIR or /tmp>/claude-<uid>/<work project>/<session> folder, where it keeps the images it was given, is removed with the session (removeSession), and the sweep removes those a stopped gateway left once their session file is gone and nothing in them changed for a day; CLAUDE_CODE_TMPDIR joins agentenv.Vars. Old images are still sent to a new run: replacing them would change what the model sees. docs/subsystems/claude-subscription-bridge.md is new. claude_rewritten_test.go has a case for each relaxation (failing before the change) and one for each rule that keeps another conversation out (each failing with that rule taken out). Tested for real with the owner's Claude Max account in a sandbox HOME: turn one asked with a [Context] block and two tools, turn two sent it back with the block taken out, the reply cut short and one tool, and went on in the same claude process ("goes on in its run", cache_read 1932). go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
gateway: a Claude subscription conversation the client sends back rewritten goes on in the run that had its last turn, and Claude Code's temp images go with their session (#912, margbug01) — Alma takes the [Context: …] block out of its last user message once the turn is over, the <alma_notification> block out of the end of the reply, and picks the tools for each turn, so turnKey, which hashes all of it, never matched and every turn started a new claude -p told the whole conversation, written to the prompt cache again with its images. ended keeps a looseTurn beside the key: the settings without the tools, the conversation before the last turn hashed, and the last turn's messages one by one. When the exact key misses, rewritten takes the one idle run or saved session whose looseTurn has the same owner, settings and earlier turns, the same roles, tool calls and results in the last turn, where a user message only had one part taken out (cutFrom) and the reply is a non-empty start of what the run said. Two that could be it start a new run, as does an idle run not told a tool offered now (offers) or a saved session one of whose tools is gone (toolsCover). Claude Code's <CLAUDE_CODE_TMPDIR or /tmp>/claude-<uid>/<work project>/<session> folder, where it keeps the images it was given, is removed with the session (removeSession), and the sweep removes those a stopped gateway left once their session file is gone and nothing in them changed for a day; CLAUDE_CODE_TMPDIR joins agentenv.Vars. Old images are still sent to a new run: replacing them would change what the model sees. docs/subsystems/claude-subscription-bridge.md is new. claude_rewritten_test.go has a case for each relaxation (failing before the change) and one for each rule that keeps another conversation out (each failing with that rule taken out). Tested for real with the owner's Claude Max account in a sandbox HOME: turn one asked with a [Context] block and two tools, turn two sent it back with the block taken out, the reply cut short and one tool, and went on in the same claude process ("goes on in its run", cache_read 1932). go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
claude: a tool the client's framework ran after a reply keeps the run, so the cache is read, not written again (#858)
claude: a run waiting for its conversation's next turn is kept an hour, as long as the prompt cache it wrote (#463: large cache writes after a conversation sat idle past 20 minutes) — Claude Code writes Anthropic's prompt cache for an hour on a subscription within its limits (its own help: "Unset = automatic: 1 hour on a Claude subscription within its usage limits", 2.1.286), but the bridge let an idle run go after 20 minutes. The next turn then went to a new run told the whole conversation in one message, a prefix never cached, so all of it was written again while the old run's cache could still have been read. idleLongest is now claudeCacheTTL (an hour); at most idleMost (6) runs are still kept, oldest let go first. A cancelled turn still ends its run: Claude Code's history then holds a turn the client may not have, and without session persistence there is no way to take it back. Prompts, identity lines and headers are unchanged. TestClaudeIdleRunKeptWhileItsCacheLasts fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
gateway tests: the rewind tests wait for the rewind to be asked before the resend — letGo runs on its own after the request given up on returns, and keeps the run for the resend just before it tells Claude Code to rewind; the tests sent the resend at once, so under load it found no run kept and started a new one (TestClaudeTurnGivenUpOnIsRewound: "the resend went to another Claude Code"; NotRewound/refuse: "2 runs"), and the refuse case's rewind still read rewindLongest as the silent case set it (the data race in CI run 37395880309 on
2bd49cc5). rewindAsked waits for the rewind_conversation line in the stand-in's log; the resend then waits on the rewind as a real one does. Test-only: a user rewording a message is never that quick. Before, go test -race -count=30 -cpu 1,2 of the four rewind tests failed IsRewound once; with this, -count=40 -cpu 1,2 passes.
gateway: a quit gateway's Claude bridge temp folders are swept (#958) — a run's $TMPDIR/magpie-claude-<n> (its tools.json, its work folder when it has none shared) is removed when the run ends, but a gateway quit for an update, or killed, ends none, and nothing removed them (sweepBridgeProjects only cleans Claude Code's project folders). The first run of a gateway now also removes magpie-claude-<digits> folders untouched for tempLongest (24h), as session temp files are swept: another gateway on the account (a dev build) makes its runs' folders there too, and a live run's are newer; the shared magpie-claude-work-<uid> and other names are left. TestBridgeTempsSwept fails with the sweep stubbed out; -race -count=20 passes; go vet, go test ./internal/gateway/, and windows/linux builds pass.
gateway: a Claude subscription run knows the tools ToolSearch loaded before its agent is handed the results (TestToolSearchLoadKeepsTheRun failed on CI now and then; wizzy-yang's #1026 retried the assertion) — on the continue path run.continueWith answered the agent's MCP call before run.tools recorded the request's tools, so a turn that finished quickly could be shelved (shelve copies run.tools) or asked offers() without the tool ToolSearch had just loaded, and the next request started a new run; on a new run, run.tools was written without run.mu while the agent was already running. The tools are now recorded under run.mu before continueWith, and a new run's tools and search are set under one lock. A 50ms sleep after continueWith fails TestToolSearchLoadKeepsTheRun 5/5 on the old code with CI's message and passes with this; go test -race -count=100 -cpu 1,2,8 -run TestToolSearch and the gateway's subscription tests pass; gofmt, vet pass.
claude: Claude Code runs share a folder only the user owns, the caller's system prompt is cached apart, and auto memory is off (#626, yg3415) — main already ran every Claude Code run in one $TMPDIR/magpie-claude so Anthropic's prompt cache is read again; this takes yg3415's version of that: the folder is magpie-claude-work-<uid> (the uid where the temp folder is shared, as /tmp is on Linux), made 0700 and used only when it is a real folder the user owns that no one else can write to (else the run works in a folder of its own, as before), so nobody else can put a CLAUDE.md in front of every run; the caller's system prompt goes to Claude Code as a block of its own with a 1h cache_control breakpoint (CLAUDE_CODE_PROMPT_CACHE_TTL=1h, since Anthropic refuses a 1h breakpoint after a 5m one), so requests with one system prompt and different conversations share its cache entry; and CLAUDE_CODE_DISABLE_AUTO_MEMORY=1, so a memory Claude Code writes in one caller's conversation doesn't show up in every other one in the shared folder. Merged onto main by hand: the run still starts through binary.command/binary.env, so a Claude Code in WSL works in the shared folder too; on Windows the folder is in the user's own %TEMP% and Go reports every folder there as 0777, so the mode check is skipped there (it refused every run before). Tests from the PR (TestClaudeRunsShareAWorkDir with a home kept in git, TestClaudeWorkDirOthersCanWriteIsAvoided, TestClaudeInstructionsCachedApart) pass; go vet (darwin, GOOS=windows), builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
claude: Claude Code runs share a folder only the user owns, the caller's system prompt is cached apart, and auto memory is off (#626, yg3415) — main already ran every Claude Code run in one $TMPDIR/magpie-claude so Anthropic's prompt cache is read again; this takes yg3415's version of that: the folder is magpie-claude-work-<uid> (the uid where the temp folder is shared, as /tmp is on Linux), made 0700 and used only when it is a real folder the user owns that no one else can write to (else the run works in a folder of its own, as before), so nobody else can put a CLAUDE.md in front of every run; the caller's system prompt goes to Claude Code as a block of its own with a 1h cache_control breakpoint (CLAUDE_CODE_PROMPT_CACHE_TTL=1h, since Anthropic refuses a 1h breakpoint after a 5m one), so requests with one system prompt and different conversations share its cache entry; and CLAUDE_CODE_DISABLE_AUTO_MEMORY=1, so a memory Claude Code writes in one caller's conversation doesn't show up in every other one in the shared folder. Merged onto main by hand: the run still starts through binary.command/binary.env, so a Claude Code in WSL works in the shared folder too; on Windows the folder is in the user's own %TEMP% and Go reports every folder there as 0777, so the mode check is skipped there (it refused every run before). Tests from the PR (TestClaudeRunsShareAWorkDir with a home kept in git, TestClaudeWorkDirOthersCanWriteIsAvoided, TestClaudeInstructionsCachedApart) pass; go vet (darwin, GOOS=windows), builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
providers: ClinePass lists Cline's real free models and asks for them as Cline's desktop app (lml and ARNO on Discord: the free models didn't match Cline's) —
a3962602 listed the Cline API's OpenRouter-style ":free" ids (qwen/qwen3.8-27b:free…), which are not what Cline offers free: Cline's clients take their picker from GET /api/v1/ai/cline/recommended-models (cline/cline sdk/packages/llms/src/catalog/catalog-cline-recommended.ts), whose free bucket is the desktop's FREE section ("Try with limited usage, separate from ClinePass quota": cline-free/deepseek-v4.1-flash, stealth/space-bunny-alpha, cline-free/mimo-v2.6-flash, cline-free/muse-spark-1.3-contributor) and whose clinePass bucket is the plan's. ClinePass now fetches that feed, asked as Cline's desktop app and with no key, as Cline asks it (pi-freeflow measured that X-CLIENT-TYPE cline-desktop is what the feed keys its free list on); the plan's models are its clinePass ones, the free ones are marked Free (the FREE badge) and priced at nothing, and its recommended paid models are left out, as before. When the feed can't be had the API's list is asked as before, and the free models are the desktop's list of today (from Cline's generated fallback, cline-recommended.generated.ts); the ":free" ids are dropped with FreeTag, as Cline's own clients never offer them as free and their cost on a Cline key was never shown. Requests to the Cline API (the gateway's and the Test button's) carry the desktop's headers — HTTP-Referer https://cline.bot, X-Title Cline, X-IS-MULTIROOT false, X-CLIENT-TYPE cline-desktop, X-CLIENT-VERSION 3.5.54, User-Agent Cline/3.5.54 — since the cline-free/ models answer 403 "only available via Cline product surfaces" without them (pi-freeflow, measured live); the key goes as before. ListPrice prices a model free by its cline-free/ id, by the fetched list or by the fallback at zero. No request was made to Cline and nothing was tried with a real Cline key. TestClineFeed (the feed asked as the desktop with no key; plan, free and no paid models listed; a free model the feed alone names priced at zero), TestClineClientHeaders (the headers on a cline-free request to api.cline.bot and on no other provider's) and the updated TestPlanModels fail without the change. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/provider/ ./internal/gateway/ pass (TestClaudeSignInOverStaleCredentials failed once under the full run, passes alone and on the rerun; Cline untouched by it).
gateway: a key held to some accounts may not compact on the sign-in Codex is on either, the last path #967 left open (a follow-up to #967, on the maintainer's invitation, LCorleone) — /responses/compact for one of Codex's own models relays as it came, deliberately (#876): the encrypted history it carries is readable only by the ChatGPT backend that sealed it, so native compaction keeps its own sign-in. But that relay spent the account Codex is signed in to with nothing of the calling key asked, so a key held to some accounts (#905) could spend an account it may not use through compaction alone, after #967 held its turns. compactSigninHeld asks, before the relay, whether the sign-in (resolved as codex/<model>, the id the catalog gives Codex's own) is one of the key's accounts — and fails closed, as the review asked: a Codex switched off in magpie, or a sign-in nowhere resolvable, is refused as an account the key may not use is, the relay reaching the sign-in all the same; the one allowance is Codex signed in with its own API key, told from auth.json's auth_mode (provider.CodexAPIKeySignedIn — the request's own Authorization can't tell it, magpie's token already in it for a keyed request). A refusal carries the API's own 403 shape naming the model and the accounts the key may use, and the two ways out: add the signed-in account to the key, or use one of magpie's models, whose compaction goes through a compaction_trigger on /responses and the key's accounts — native compaction has no other route, its history bound to that account. The account in use compacts on it as it always did, the relay untouched; a key that names no Codex account (AllowsAccount's rule), one held to some models alone, and a free key are relayed as they were. TestGatewayKeyAccountsHoldCodexCompact (held to a spare account: refused with nothing asked of OpenAI; held to the sign-in: the compaction relays as it came; naming no Codex account: allowed as ever; a free key: unchanged; and the provider switched off: refused with nothing upstream, the review's repro) fails without the gate and passes, TestGatewayKeyCompactOnCodexAPIKey pins the allowance; go test ./internal/gateway/ ./internal/provider/, go vet, linux(nogui)/windows(nogui) builds pass. (#976)
codex: a Settings switch has Codex's OpenAI models say multi-agent V1, so a GPT lead's subagents can run on magpie's other models (#141, congee949: with "v1" in the entry and multi_agent_v2 off, the Codex App's GPT lead spawned V1 subagents on Opus/Grok groups and they ran there) — Settings › "Codex subagents on any model", off by default. On, the ChatGPT backend's /models list magpie passes Codex (and the cached one it falls back to), the codex/ entries and the GPT routing groups (the Fast ones) in model_catalog_json say multi_agent_version "v1"; nothing else in an entry changes, base_instructions untouched. The setting is in CodexListTag as a "v1." prefix (so neither tag is found inside the other), so ETag and X-Models-Etag change and Codex asks again; catalog.Touched rewrites the catalog. Codex keeps the stamped list in models_cache.json, so the backend's own versions are kept in codex-agent-versions.json (from each fresh list, and from an unstamped cache) and CacheEntries puts them back when the cache's ETag is a stamped one: turned off, gpt-6 is "v2" again and a model the backend gave none has none. Codex's features.multi_agent_v2=true still wins, and a thread keeps its version. TestCodexAgentsV1Entries, TestCodexAgentsV1CacheRestores, TestCodexModelsAgentsV1 cover on/off/revert and the ETag; go vet, builds (darwin, linux, windows) and go test ./... under a temp HOME pass. Not tried in the Codex App.
codexcat, gateway, gui: Settings › Codex auto-review model picks the model Codex's auto-review decides approvals on (#938, guitaoliu)
gateway: a Responses relay that turns away reasoning with nothing sealed in it, naming only the input, is asked once more without it, and isn't sent it again for that model (#1044: Codex's /compact through Anyrouter failed with 400 invalid_request_error param=input on old conversations)
codex: a cached entry keeps the cache's own max when magpie's list has none, and Codex's models_cache.json is read under CODEX_HOME everywhere (follow-up to #674, from reviewing #679) — restoring a cached native entry (backend away) set max_context_window to max(MaxContext, Context) from magpie's live list, so a list saved without the max (before magpie kept it) wrote the window over the cache's own (872000 became 272000); now a list with no max leaves the cache's, and codexcat.Window still raises it to a window past it. codexcat.CacheEntries and catalog.Codex read ~/.codex/models_cache.json even with CODEX_HOME set, while codexVersion and codexInstructions followed it; all four now take catalog.CodexModelsCache() (CODEX_HOME, else ~/.codex), and provider's codexCLIHome and the copy in codexInstructions are gone. TestCodexReadsCacheUnderCodexHome, TestCodexCachedListUnderCodexHomeKeepsItsMax and TestCodexCatalogEntryFromCodexHome fail on the old code (the ~/.codex cache read, none listed from CODEX_HOME); TestCodexNativeModelsTakeSetWindows now wants a cached gpt-5.5 with no live max to keep the cache's 400000 (old code: 272000). go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestProvidersAnswerWhileListsComeIn, a timing test that passes alone.
codex: a call id longer than 64 characters goes as one the backend takes on Codex's own ChatGPT sign-in too, and tool_search ids with it (#732, congee949) — #732 bounded them where magpie writes the request (codexBody: Chat and Anthropic clients on a Codex model, Codex App with spare accounts), but Codex on one ChatGPT account is relayed as it came (codexUpstream), so a conversation with a foreign provider's tool calls (two ids joined, 86 or 87 characters) still got 400 "input[7].call_id … maximum length 64" there: 30 in a week in the report, 29 of them thread_description on gpt-6-luna, on v0.1.869. The relay now gives every call_id over 64 characters as provider.BoundCallID has it (the sha256 of the trimmed id, so a call and its output, paired by the trimmed id, get the same one on every request), the rest of the request byte for byte; codexBody uses the same helper and covers tool_search_call / tool_search_output as well. TestCodexRelayBoundsLongCallIDs (single-account relay: function and tool_search ids bounded, paired and stable across requests, a 64-character id kept) fails on the old code. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: Codex's own headers go on to a keyed provider, as Claude Code's do (#179) (lx_78654 on Discord: "This account only allows Codex official clients") — magpie sent Codex's requests as magpie/<version> with none of Codex's headers, so a provider that serves only Codex's clients (by User-Agent or originator, a version and an x-codex- header) refused them. A request from Codex (User-Agent or originator starting codex: codex_cli_rs, codex_exec, codex_vscode, Codex Desktop) to a key+URL provider over Responses or Chat, relayed or rebuilt, now carries its User-Agent, originator, session-id, thread-id and the x-codex- headers codexHeader passes; the provider's own key is the one sent, never magpie's, nor Codex's attestation; other agents go as magpie/<version>, and a provider's own User-Agent is still set when it signs. Headers as codex-cli 0.159.2 sent them to a fake upstream in a sandbox HOME. TestCodexHeadersReachKeyedRelay fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
gateway: Codex's compaction on a magpie model goes on when the provider answers the summary 404 (#866) — a relay (flyapi) that serves the conversation turn by turn answered the summary request 404 "Upstream request failed", which went back to Codex as it came: "Error running remote compact task", a reconnect loop and the compaction aborted. Now a 404 is logged with the provider, model, base URL and message, and the summary is asked for again with the conversation as plain text in one message (no item ids, sealed reasoning or call ids for the relay to look up); when that fails too magpie compacts without a model — the user's messages (the latest 80k characters), the last reply and why — so Codex gets its compaction item and goes on. 401, 429, 500 go back as they came. Codex's base_instructions are untouched. TestCodexCompact404AsksAgainAsText and TestCodexCompact404CompactsLocally fail on the old code; TestCodexCompactOtherFailuresStay. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: a Codex compaction for a magpie model asks for its summary as Codex asks for its own — streamed, with tool_choice and parallel_tool_calls — so an upstream that checks a Codex request's shape no longer refuses it with "invalid codex request" (#292, byhooi). Codex compacts remotely (a compaction_trigger in /responses) whenever it is on the OpenAI provider, which is how it reaches magpie, and it has no setting to turn that off (remote_compaction_v2 is gone). magpie answers the trigger by asking the model for a summary, but it sent that with stream false and no tool_choice or parallel_tool_calls. The tools are still left out, so the reply is text; compactReply reads the summary from a stream as well as from a whole response, and a failed stream is a 502 with the upstream's message rather than an empty summary. Tested with TestCodexCompactsThroughCodexRelay (a fake upstream that refuses a request not streamed or without tool_choice, parallel_tool_calls, prompt_cache_key or include; fails on main with "invalid codex request") and TestCodexCompactsMagpieModel. go vet, go build -tags nogui (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass.
gateway: Codex's compact reaches xAI, Grok and Copilot, and a web-search turn keeps its client_metadata (#378, #374) — a compact request drops its tools but kept tool_choice "auto", which Grok's subscription, the xAI API and Copilot's /responses refuse; a lone tool_choice is now dropped for those (a relay like #292's still gets the body as sent). A Codex request rebuilt by magpie (web_search) lost client_metadata and text (its output schema); both now go on to Responses upstreams as sent, never to Chat or Anthropic ones. TestGrokBodyDropsLoneToolChoice, TestCodexCompactsThroughXAI, TestCodexSearchKeepsClientMetadata and TestClientMetadataOnlyToResponses fail without the change.
gateway, usage: when the credits switch holds every account, the record and 429 say so accurately; the switch's text says it follows the latest reading (#999)
codex: a group's effort for the turn keeps Codex's reasoning.context, and Codex's custom tools (code mode's exec, apply_patch) reach a model magpie translates for (#534, #520 follow-up, azir12345: since v0.1.637 a Plus account's gpt-6.1-sol fails with 400 "X-OpenAI-Internal-Codex-Responses-Lite requires
reasoning.context to be all_turns", and Codex said it had only the collaboration and wait tools, no terminal) — ChatGPT's catalog marks gpt-6.1-sol (as gpt-6-sol, gpt-6-luna, gpt-5.6-*) use_responses_lite with tool_mode code_mode_only, and Codex asks for the bare slug with its native metadata: the X-OpenAI-Internal-Codex-Responses-Lite header (add_responses_lite_header, which magpie forwards with the other x-openai-* headers), reasoning {"effort","summary","context":"all_turns"} (build_reasoning), and its tools moved into an additional_tools item, every other tool reached through exec, a custom (freeform, lark grammar) tool in the "functions" namespace, beside wait and the collaboration namespace. magpie routes the bare id to the group auto-gpt-6-1-sol, and the effort the group picks for the turn went through withEffort, whose Responses branch rebuilt reasoning as {effort, summary} and dropped context, so ChatGPT refused the Lite request; it now sets only the effort (withBodyEffort), keeping every other field of reasoning as Codex sent it. A member served by translation (Chat, Anthropic, a Responses plugin) had parseResponses drop custom tools and ignore custom_tool_call / custom_tool_call_output items, so exec vanished and the model saw only wait and the collaboration tools; a custom tool (top-level or in a namespace) is now offered as a function taking one string "input", its description its own plus a note on input and its grammar, an earlier custom_tool_call goes to the model as a call with {"input":…} and its output as the call's result, and the model's call goes back to Codex as the custom_tool_call it runs (name and namespace as Codex knows them, input from the "input" argument, else the arguments as given), streamed without function_call_arguments events and in the non-streamed reply alike. This also gives translated models Codex's freeform apply_patch, which was dropped silently before. The Lite header is still forwarded as it was; nothing showed it needs dropping. Codex's base_instructions and identity line are untouched. No request was made to ChatGPT. TestWithEffort's Lite case fails without the decide.go change ("want \"context\":\"all_turns\""); TestCodexCustomToolsTranslated (a code-mode Lite turn through a fake Chat provider: exec offered with input and its grammar, the earlier call and output shown, the model's exec call back as a custom_tool_call with its input and no arguments) and TestCodexCustomToolNamespaced (namespaced and non-JSON calls, renderResponses) cover the tools; TestAdditionalToolsTranslated, TestNamespacedToolsOffered and TestGrokPluginGetsNamespacedTools now expect the custom tools offered. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestDevinSignedInOnlyInMagpie failed once on its wait, passes alone; untouched).
gateway: a developer message Codex adds mid-thread stays where it is, so the thread's cached prefix does too (#502, Koohoko: after a memory request the next turn of the same session wrote the whole conversation to the cache again) — Codex adds a developer message to a thread where its context changed (a world-state or skills diff, a settings or model change, and so after its memory pipeline has run) and keeps it there in every later request, which leaves OpenAI's prefix as it was. parseResponses lifted every developer and system message, wherever it stood, into the system prompt, so on a translated route that turn's system changed: Anthropic's system comes before the messages, so the whole conversation was written to the cache again, and a Claude subscription's waiting Claude Code is found by its system (turnKey), so a new one was started and told the whole conversation in one message. Developer messages before the thread's first reply (Codex's initial context) are lifted as before; one after it now stays in place as a user message inside <system-reminder>, which the router's classifier already leaves out of the user's words. The memory request itself (another model, its own prompt, the same session) moves nothing in magpie: affinity is scoped by model or by a group's first words, the Claude bridge's runs by the conversation. TestCodexLaterDeveloperMessageKeepsTheSystem and TestCodexThreadKeepsItsClaudeRunAcrossAMemoryCall (a Codex thread on a Claude subscription, a memory call in the same session between, then a developer message: the same Claude Code goes on) fail without the change. No real vendor request was made. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/gateway/ ./internal/provider/ pass.
gateway: a Codex model of OpenAI's that fails with nothing said says where it went (#409: Codex showed "unexpected status 502 Bad Gateway: Unknown error" with DeepSeek picked in magpie) — the thread was still on Codex's own gpt-5.6-terra ("5.6 Terra" in the composer), which goes to OpenAI with Codex's sign-in, and OpenAI answered 502 with an empty body that magpie passed through, which Codex shows as "Unknown error". An error status with a blank body on /responses is now answered with that status and a message: OpenAI answered it and said nothing more, the model is one of Codex's own that goes to OpenAI rather than a provider in magpie, pick one of magpie's models; the request log and the trace say the same. An error with a body still passes unchanged. TestCodexOwnModelEmptyError fails without the change; TestCodexOwnModelErrorBodyKept and TestCodexMagpieModelEmptyError guard the rest. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build pass; gateway TestCodex* pass.
codex: a memory call on a model of Codex's own goes where Codex's turns go, rather than a 404 (#742, congee949) — Codex writes and consolidates its memories on models of its own by their bare names (memories.extract_model and consolidation_model, gpt-5.6-luna and gpt-5.6-terra by default) whatever it is set to, so with magpie as its provider every memory_consolidation call was turned away as "magpie knows no model" before it was routed (28 in a week in the report, from Codex on two other computers with their own gateway keys, where the stand-in read from this computer's ~/.codex/config.toml can't reach). A Codex memory call (memory_consolidation, memgen, memory) on a model magpie can't resolve now goes to the model the same Codex (the agent and the gateway key it came with) last had a turn of its own answered on here, else the last such turn in the ledger a week back (so after a restart too), and is routed and recorded as the memory call it is. Any other call on an unknown model, and a memory call from a Codex with no turn answered, is turned away as before. Base instructions are untouched. TestCodexMemoryCallsGoWhereItsTurnsGo (terra and luna after a turn, after a restart from the ledger, unknown turn and review still 404) fails on the old code. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
codex: a memory call on a model of Codex's own goes where Codex's turns go, rather than a 404 (#742, congee949) — Codex writes and consolidates its memories on models of its own by their bare names (memories.extract_model and consolidation_model, gpt-5.6-luna and gpt-5.6-terra by default) whatever it is set to, so with magpie as its provider every memory_consolidation call was turned away as "magpie knows no model" before it was routed (28 in a week in the report, from Codex on two other computers with their own gateway keys, where the stand-in read from this computer's ~/.codex/config.toml can't reach). A Codex memory call (memory_consolidation, memgen, memory) on a model magpie can't resolve now goes to the model the same Codex (the agent and the gateway key it came with) last had a turn of its own answered on here, else the last such turn in the ledger a week back (so after a restart too), and is routed and recorded as the memory call it is. Any other call on an unknown model, and a memory call from a Codex with no turn answered, is turned away as before. Base instructions are untouched. TestCodexMemoryCallsGoWhereItsTurnsGo (terra and luna after a turn, after a restart from the ledger, unknown turn and review still 404) fails on the old code. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
agents: Codex's /model lists its models in the order dragged on the Agents page (M3chD09, #855) — Codex lists the models by the priority magpie gives them: a ChatGPT account's own first, as the backend has them, then magpie's in the catalog's order, with no way to change it. Codex's model list (Pick on its row) now has a third view, Order: the models shown as Codex lists them, numbered, each dragged elsewhere (its grip shows on hover) or moved with Alt+↑/↓; it is saved at once (settings orderedModels, by agent and entry id), and the account's own models and magpie's go in one order. A model not in the order (one added later) goes after those that are, as it stood; the hidden stay hidden and out of the order view, hiding keeps the order, and a provider renamed renames its ids in the order too. Default order puts magpie's back. Codex gets it at once: signed in to ChatGPT, the gateway's /models gives the backend's and magpie's models priorities 1..n in that order and the list's ETag carries the order so Codex asks again; not signed in, the catalog file magpie writes for Codex (ListedFor → InOrder) is rewritten in it. Only Codex's list can be ordered; base_instructions are untouched. zh, ja, de strings. TestCodexModelListInUsersOrder (gateway: default order, an order across the account's and magpie's, the ETag changing, CatalogFor, opencode's untouched, none restoring it) and TestAgentModelOrderAPI (gui: orderable, an order read back, a new model last, hidden kept, hiding keeping it, reset, opencode refused) fail on the old code (no SetModelOrder/ModelOrder); codex-model-order.test.cjs (WebKit, en and zh: Order lists the own first and leaves the hidden out, Alt+↓ and a drag post the order, Default order posts none, nothing scrolls; a list that can't be ordered has no Order) fails on the old code (no Order). agent-models, agent-models-all-hidden, agent-models-loading, agent-model-groups, click-scroll, gui-ja and gui-de pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: Codex's /models builds magpie's providers once, and cursor-agent's Keychain token is read once a while (#746, sperwe: /backend-api/codex/models called All() over and over, rescanning accounts and running /usr/bin/security some 200 times, 5.3–5.6 s against about 1 s straight to the backend, past Codex's 5 s model refresh, so Codex fell back to the official list) — after the ChatGPT backend's list, codexModels read magpie's: CodexListed (CatalogFor's catalog, and AutoVision through codexListed's descriptions), CodexNativeWindow, CodexNativePicked and CodexListTag (CodexListed again, codexWindowsTag), seven builds of All() in a test with one provider, each going through Accounts() and every agent's sign-in; a plugin's accounts ask pluginOwnUser, and for a Cursor moved onto its plugin that is cursorSignedOut, a
security find-generic-password each time. All() now goes through the hold the GUI's reads use (heldOf, dff14ff4): while a request holds the catalog it is built once (a clone each call, so a caller's change stays its own; resolveIn's own heldOf("all", All) is plain All() now), and codexModels takes the hold (provider.Hold) for what follows the backend's answer. A hold is dropped by a write, as before (store → catalog.Touched → Changed, a plugin's sign-in), and now also by writeLogins and forgetAccountCaches, so an account signed in, switched or removed while one is held is seen at once; a sign-in changed outside magpie shows within the hold's second (heldFor). cursor-agent's Keychain token (readCursorToken, cursorToken) is read at most once in 10 s (cursorTokenTTL, as claudeCacheTTL); forgetCursorStatus, which a sign-in or out in magpie calls, drops it, and CursorToken reads it afresh after asking cursor-agent to renew it, so a renewed or new token isn't missed. AllBuilt counts builds for tests. TestCodexModelListBuildsProvidersOnce counts one build for one /models request (7 on the old code), TestHoldBuildsProvidersOnce one build for two All() calls under a hold, a caller's change not leaking, a provider saved and logins written while held being seen, and TestCursorKeychainReadOnce, with a fake security on PATH, one run for three reads, a fresh one after forgetCursorStatus and after a fresh read. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not timed with the reporter's accounts: here the request built the providers 7 times and builds them once, so their ~200 security runs should come to about a seventh, Cursor's from one a build to one in 10 s; the GUI's held reads (GET /api/*) now share one All() too.
codex: a cached entry keeps the cache's own max when magpie's list has none, and Codex's models_cache.json is read under CODEX_HOME everywhere (follow-up to #674, from reviewing #679) — restoring a cached native entry (backend away) set max_context_window to max(MaxContext, Context) from magpie's live list, so a list saved without the max (before magpie kept it) wrote the window over the cache's own (872000 became 272000); now a list with no max leaves the cache's, and codexcat.Window still raises it to a window past it. codexcat.CacheEntries and catalog.Codex read ~/.codex/models_cache.json even with CODEX_HOME set, while codexVersion and codexInstructions followed it; all four now take catalog.CodexModelsCache() (CODEX_HOME, else ~/.codex), and provider's codexCLIHome and the copy in codexInstructions are gone. TestCodexReadsCacheUnderCodexHome, TestCodexCachedListUnderCodexHomeKeepsItsMax and TestCodexCatalogEntryFromCodexHome fail on the old code (the ~/.codex cache read, none listed from CODEX_HOME); TestCodexNativeModelsTakeSetWindows now wants a cached gpt-5.5 with no live max to keep the cache's 400000 (old code: 272000). go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestProvidersAnswerWhileListsComeIn, a timing test that passes alone.
gateway: a vendor's Responses API gets Codex's request as Codex sends it to a provider not OpenAI's (#292, @byhooi: Anyrouter's gpt-6-astra answered "invalid codex request" through magpie, and through CC Switch too with its remote compaction on) — Codex decides by its provider's name alone (is_openai: name == "OpenAI") whether to send what only OpenAI takes: each message's internal_chat_message_metadata_passthrough (content_item_kinds, turn_id), a call's encrypted_function_args, stream_options.reasoning_summary_delivery, configuration_update items. CC Switch's remote compaction switch is that name; signed in, Codex keeps its built-in provider, named OpenAI, and comes through openai_base_url, so every request magpie relayed carried them, and a relay that checks it is Codex's turned it away. A relayed Responses request to anyone but OpenAI's API and the ChatGPT backend now goes without them, as Codex itself leaves them out under another name; everything else as it came, byte for byte when none is in it (another client's stream_options stay). Codex's prompts and identity untouched. TestCodexOpenAIFieldsStayWithOpenAI fails without the change (the relay's 400 "invalid codex request"). go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
gateway: a gateway key can be held to some models (#882, LCorleone) — a key could reach every model magpie serves. A key now keeps an optional list of models (caller-keys.json "models", none = every model, so older files read as before): <provider>/<model> or <provider>/*, matched against the provider that serves the call, so a bare model name is resolved first and OpenRouter's "anthropic/x" is not Anthropic's. Such a key sees only its models in /v1/models, /v1/models/{id} and the Anthropic (Claude Desktop) and Gemini lists, a routing group only when every member is its; asking for another, by id, bare name or through a group with a member it may not use, is refused before any provider is asked with a 403 in the API's own error shape (permission_error) that names the key and its models, logged as a rejected call like an over-limit key's 429; a fallback it may not use is skipped; embeddings, images and videos it names are held too. magpie's own calls for it (a web search, a picture described, Codex titles with the model Settings name) are marked in their context, not by a User-Agent a client could send, and go on. Set it with the new badge on the key's row in the Gateway page (the app's menu: All models, every model of a provider, each model; sent when the menu closes) or
magpie gateway-key models <id> openai/gpt-5 anthropic/* (all takes it off); gateway-key list has a MODELS column. The app's menu, fitting neither below nor above its button, now goes on the roomier side and scrolls instead of running off the window. en/zh/ja/de. TestGatewayKeyModelsHoldAKey (lists, 403s by id, bare name and group, Anthropic shape, fallback skipped, magpie's own call, a free key) fails without the change; TestModelPatterns, TestKeyModelsKept, TestGatewayKeyModelsCLI, TestCallerKeyModelsRoutes pass; gateway-key-models.test.cjs (Chromium and WebKit, en and zh: no native select, menu inside a 420px window, picks sent on close, All models, no scroll, no left border, 560px fits) passes with gateway-connect-keys, gateway-key-limit, gateway-caller-keys and click-scroll. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
codex: Ultra on GPT-6.1 Sol wherever it is served, Copilot's too, with Codex's agents taking parts of the task (#656) — Codex offers Ultra on a model whose entry lists "ultra", and on a thread at multi-agent V2 hands parts of the task to agents of its own; its catalog (models-manager/models.json) lists it for gpt-6.1-sol, gpt-6-sol, gpt-6-astra, gpt-5.6-sol and gpt-5.6-terra, not the Lunas, which stop at max. magpie listed Copilot's gpt-6.1-sol only up to max, so Codex had no Ultra for it. Now an entry for one of those models (by the id the vendor serves: openai/…, dated snapshots) that reaches max lists ultra too, in Codex's catalog and /v1/models, and its Codex entry says multi_agent_version v2, as Codex's own does; GPT-6 Luna still stops at max. A ChatGPT account's entry stays as the backend lists it, and a group with a ChatGPT account in it offers ultra but isn't told V2 (its lead's subagent tasks come sealed, #141); the "subagents on any model" setting still stamps v1 on OpenAI's entries. Ultra is no API's level: the gateway now always sends max for it (fitEffort), even to a model whose list says ultra. Subagent tasks with web_search kept (the translated path) were fixed in v0.1.689 (#613); TestCopilotUltraSubagentWithWebSearch now covers it against a stub Copilot: the lead's spawn_agent comes back with plain arguments and encrypted_function_args [], no "encrypted" goes upstream, and the subagent's agent_message reaches Copilot as text. TestCopilotSolOffersUltra (sol has ultra in /v1/models and Codex's catalog with v2, luna not; ultra goes to Copilot as max, with and without web_search) and TestUltraIsSentAsMax fail on the old code; TestUltraOnModelsThatOfferIt and TestUltraEntriesSayV2 cover entries, groups and the catalog. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried in the Codex App with a real Copilot account.
agents: Codex signed in with only an API key gets magpie as its provider, its picker listing magpie's models (#322, dakango: switching the Codex app to one of its own models went to OpenAI, 502) — magpie counted an API key in auth.json as a ChatGPT sign-in and only set openai_base_url, counting on Codex to ask the gateway for its models; but Codex asks for none with an API key (models-manager refresh_available_models returns early), so the picker showed Codex's built-in models and the one magpie model, and a built-in one went to OpenAI with the relay's key. Now only a ChatGPT sign-in takes that route; with an API key alone magpie is the model_provider with model_catalog_json listing every magpie model on for Codex, as when signed out, and a sync moves a setup an older magpie wrote; going back to Codex's own model restores the user's model_provider. A Codex model whose OpenAI can't be reached now fails naming the model and what to do instead of a bare 502. TestCodexAPIKeyUsesProvider, TestCodexAPIKeyBaseURLMoved and TestCodexOwnModelUnreached fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
provider: Command Code's refusal of a Go plan's key says where the key works (#969, pan17) — a Go key added as the Command Code API-key provider (the preset, on api.commandcode.ai/provider) can never be served: Command Code's Provider API answers every request "Your Go plan doesn't include API access. Upgrade to Provider or higher …". APIError now adds that a Go key works through the Command Code plugin (Plugins › Command Code, signed in with the key), which asks as Command Code's CLI does. TestAPIErrorShapes and the new TestCommandCodeGoKeySaysWhereItWorks (chat, messages and responses through the gateway) fail without it and pass; go test provider/gateway/plugin/gui, vet, linux/windows builds pass.
gateway: an upstream that answers 200 with a web page, with nothing at all, or with JSON that isn't, has failed as a 502 does, so a group asks its next member (#1012, cth123456: Codex on group/medium, agentrouter/deepseek-v4-flash now and then answered 200 text/html; the log said status=200 done=true in=0 out=0 served=null, codex/gpt-6-luna was never asked, and Codex failed with "stream disconnected before completion: stream closed before response.completed") — the candidate loop judged a try by its status alone, and holdWriter passes a 200 that is neither text/event-stream nor application/json straight to the agent, so the page went to Codex as the reply. notAnAPIReply now reads every 2xx (but 204) an upstream gives, in forwardOnce (every relayed and translated request, every API, plugins included) and in the built-in clients' own requests (the ChatGPT backend's /responses, Kiro, Zed, Qoder, Devin, Cursor, Command Code): a text/html reply, a body that begins as a web page whatever its Content-Type, an empty body, or one sent as JSON that doesn't begin with { or [ is turned into the 502 it stands for, with a JSON error saying what came back ("answered 200 OK with a web page (text/html), not an API reply: “Just a moment...”", the firewall hint for a block page). It then fails over, rests a minute and is logged as any 502 before the reply begins does; a provider with nobody after it hands the agent that 502 rather than the page. Only the first bytes are peeked, which a stream waits for anyway; a read that fails is left to the reader, a compressed body and anything else as it came. TestNotAnAPIReplyFailsOver (Chat, Responses and Messages, streamed and not, a member answering a Cloudflare page as text/html, as JSON and as SSE, an empty body as JSON and as SSE, and plain text as JSON; the next member must answer and the log name why) fails without the change in all 36 cases (the page relayed to the agent, or b never asked, or no reason logged) and passes; TestNotAnAPIReplyAlone (a 502 with the reason, logged 502) and TestCodexBackendWebPage (the ChatGPT backend's page reached Codex as a 200) fail without it and pass; TestAPIReplyUntouched keeps JSON after whitespace, SSE with and without a Content-Type, gzip, XML, a 204 and a 404 as they came. -race -count=10 on them; go test -tags nogui ./internal/gateway ./internal/provider, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay (agentrouter) itself; the page is a Cloudflare challenge's, as the issue has only its Content-Type. docs/subsystems/gateway-routing.md says it.
gateway, gui, cli: each account or key can have its own limit on requests at once, with a bounded queue that turns requests away with a 429 (#892, margbug01) — a provider's Concurrency applied to every key and account alike, the queue past it had no bound and no wait, and nothing showed what ran or waited. A key or account now may have its own limit over the provider's (Provider.AccountConcurrency, by lowercased account or KeyID; 0 none; unset takes the provider's, as before), counted in one lane across every model, routing group and agent. A provider's Queue size bounds how many wait for each of its lanes (one more is turned away at once) and Queue wait how many seconds one waits (then it leaves the queue); both 0 by default, as before. A request turned away goes on to the next key, account or group member without resting anything, else gets a 429 in its API's own shape (Anthropic rate_limit_error, OpenAI's, Gemini's) with Retry-After: 1, saying the limit and the queue. A slot is given back when the reply is read to the end, fails or the agent goes away; a waiter that goes away leaves the queue, and one granted as it gave up hands the slot on. In a routing group, or among a provider's keys and accounts, a full one gives way once to another with a free slot now, so a group spreads before anyone waits; a fallback provider is never asked for want of a slot, and with all full the request waits for the one routing chose. The Claude subscription path and plugin providers take their slot the same way, around the attempt; a parked Claude Code tool turn has already answered and holds none. GUI: each account and key row has a pill — "1/1 running · 2 queued" while requests are out (GET /api/lanes, read every two seconds and written into the pill in place), else its limit; none set shows on hover only — opening the app's menu (provider's, no limit, 1/2/3/5/10, Other… typed in place), posting provider/accountconcurrency; the editor has Queue size and Queue wait under Concurrency. CLI: magpie provider account-concurrency <id> [account|key [n|off|default]] and magpie provider queue <id> [size [seconds]]. en/zh/ja/de. TestAccountLimitSharedAcrossGroups, TestQueueFullAndWaitedOut, TestLaneReleasedOnFailure and TestFullMemberGivesWay (pass with -race, with TestMaxConcurrency and TestLanesLimitLifted), TestLaneLimitRoutes and TestAccountConcurrencyCmd are new and fail without the change; lane-limit.test.cjs (WebKit, en and zh: a key's busy/queued pill updated in place from /api/lanes, set from the menu, provider's posts null, Other… typed, an account's own limit, none on hover only, queue fields saved and refused out of range, labels on one line, every string in zh/ja/de, no select, no scroll, no left border) fails without the change. key-weight-fit, account-cap, provider-concurrency, click-scroll, account-models and api-key-usage pass in WebKit; the other WebKit failures fail the same on main. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
gateway, gui, cli: each account or key can have its own limit on requests at once, with a bounded queue that turns requests away with a 429 (#892, margbug01) — a provider's Concurrency applied to every key and account alike, the queue past it had no bound and no wait, and nothing showed what ran or waited. A key or account now may have its own limit over the provider's (Provider.AccountConcurrency, by lowercased account or KeyID; 0 none; unset takes the provider's, as before), counted in one lane across every model, routing group and agent. A provider's Queue size bounds how many wait for each of its lanes (one more is turned away at once) and Queue wait how many seconds one waits (then it leaves the queue); both 0 by default, as before. A request turned away goes on to the next key, account or group member without resting anything, else gets a 429 in its API's own shape (Anthropic rate_limit_error, OpenAI's, Gemini's) with Retry-After: 1, saying the limit and the queue. A slot is given back when the reply is read to the end, fails or the agent goes away; a waiter that goes away leaves the queue, and one granted as it gave up hands the slot on. In a routing group, or among a provider's keys and accounts, a full one gives way once to another with a free slot now, so a group spreads before anyone waits; a fallback provider is never asked for want of a slot, and with all full the request waits for the one routing chose. The Claude subscription path and plugin providers take their slot the same way, around the attempt; a parked Claude Code tool turn has already answered and holds none. GUI: each account and key row has a pill — "1/1 running · 2 queued" while requests are out (GET /api/lanes, read every two seconds and written into the pill in place), else its limit; none set shows on hover only — opening the app's menu (provider's, no limit, 1/2/3/5/10, Other… typed in place), posting provider/accountconcurrency; the editor has Queue size and Queue wait under Concurrency. CLI: magpie provider account-concurrency <id> [account|key [n|off|default]] and magpie provider queue <id> [size [seconds]]. en/zh/ja/de. TestAccountLimitSharedAcrossGroups, TestQueueFullAndWaitedOut, TestLaneReleasedOnFailure and TestFullMemberGivesWay (pass with -race, with TestMaxConcurrency and TestLanesLimitLifted), TestLaneLimitRoutes and TestAccountConcurrencyCmd are new and fail without the change; lane-limit.test.cjs (WebKit, en and zh: a key's busy/queued pill updated in place from /api/lanes, set from the menu, provider's posts null, Other… typed, an account's own limit, none on hover only, queue fields saved and refused out of range, labels on one line, every string in zh/ja/de, no select, no scroll, no left border) fails without the change. key-weight-fit, account-cap, provider-concurrency, click-scroll, account-models and api-key-usage pass in WebKit; the other WebKit failures fail the same on main. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
gateway: a Gemini reply that says nothing is asked again on the same account before it fails, so a long conversation that ends with only reasoning a few times running goes on instead of stopping the agent (#667, tianshuo886)
gateway, copilot: Auto's pick is asked as Copilot Chat asks it, and a refused pick is made again on its own API (infinitr0us, #256) — on a Student plan copilot/auto's pick from POST /models/session (gpt-5.4-mini, gpt-5.3-codex) was refused 400 "The requested model is not supported.", and the retry with the next pick, gpt-4.1, went to the same /responses and got "model gpt-4.1 is not supported via Responses API." (1) Copilot Chat 0.69 (now in microsoft/vscode extensions/copilot, automodeService.ts / AutoV2Fetcher) no longer asks /models/session: it POSTs {capiBase}/auto with the prompt at X-GitHub-Api-Version 2026-08-01 and gets session_token, expires_at and selected_model with its supported_endpoints; /models/session answers a fixed, stale list (asked with the owner's account it still offers gpt-5.3-codex/gpt-5.4-mini while /auto picks gpt-6-luna). magpie now asks /auto first, with the last user message as the prompt, takes the pick's endpoints for its API, keeps the session at most an hour, and falls back to /models/session and then the hand-picked model as before. (2) After a refusal the retry with Auto's next pick breaks out of forward's same-API loop when that model isn't served on it, and the candidates' loop makes the request again (at most twice) on the new model's own API; a held refusal is passed on only when no retry is left. TestCopilotAutoRepickTakesItsAPI (gpt-5.3-codex refused on /responses, gpt-4.1 refused there and served on chat) fails on the old code with infinitr0us's exact error; TestCopilotAutoAsksAuto (version, prompt, token, /models/session not asked) fails with a 502. With the owner's real Copilot account in a sandbox gateway, copilot/auto on /v1/chat/completions came back 200 from gpt-6-luna, picked by /auto and sent on /responses. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway, copilot: Auto's pick is asked as Copilot Chat asks it, and a refused pick is made again on its own API (infinitr0us, #256) — on a Student plan copilot/auto's pick from POST /models/session (gpt-5.4-mini, gpt-5.3-codex) was refused 400 "The requested model is not supported.", and the retry with the next pick, gpt-4.1, went to the same /responses and got "model gpt-4.1 is not supported via Responses API." (1) Copilot Chat 0.69 (now in microsoft/vscode extensions/copilot, automodeService.ts / AutoV2Fetcher) no longer asks /models/session: it POSTs {capiBase}/auto with the prompt at X-GitHub-Api-Version 2026-08-01 and gets session_token, expires_at and selected_model with its supported_endpoints; /models/session answers a fixed, stale list (asked with the owner's account it still offers gpt-5.3-codex/gpt-5.4-mini while /auto picks gpt-6-luna). magpie now asks /auto first, with the last user message as the prompt, takes the pick's endpoints for its API, keeps the session at most an hour, and falls back to /models/session and then the hand-picked model as before. (2) After a refusal the retry with Auto's next pick breaks out of forward's same-API loop when that model isn't served on it, and the candidates' loop makes the request again (at most twice) on the new model's own API; a held refusal is passed on only when no retry is left. TestCopilotAutoRepickTakesItsAPI (gpt-5.3-codex refused on /responses, gpt-4.1 refused there and served on chat) fails on the old code with infinitr0us's exact error; TestCopilotAutoAsksAuto (version, prompt, token, /models/session not asked) fails with a 502. With the owner's real Copilot account in a sandbox gateway, copilot/auto on /v1/chat/completions came back 200 from gpt-6-luna, picked by /auto and sent on /responses. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
copilot: Auto falls back to gpt-4.1 first, the reply names the pick that answered, and the route says each pick's API and session token (infinitr0us, #256) — the route data from v0.1.921 showed /auto does answer a Student account: it picked gpt-5.6-luna, which Copilot refused "The requested model is not supported." magpie then went down /models in its order, but Copilot's list no longer flags any model as the chat default or fallback, nor bills any, so the order was the list's own: gpt-5.4-mini, gpt-6-luna, kimi-k3, mai-code-1.1-flash, gpt-5-mini all refused and the request failed with 400 before reaching gpt-4.1, last on the list and the one the plan is served; a second request reached it after claude-haiku-4.5. gpt-4.1 (Copilot's base model) now ranks right after a flagged fallback or default. X-Magpie-Model named mai-code-1.1-flash / claude-haiku-4.5, a refused pick: each new pick now updates it, so it ends on the one sent last. Each pick in tries[].auto now carries api (the path it went to) and session (whether Auto's Copilot-Session-Token went with it), shown on the Routing page's pick line (zh, ja, de strings), since why /auto's own pick is refused is still open: VS Code's /auto request matches magpie's. TestCopilotAutoFallsBackToBase (a Student list as the reporter's, /auto picking a refused model, only gpt-4.1 served) fails on the old code: eight refusals, X-Magpie-Model copilot/claude-haiku-4.5; routing-copilot-auto.test.cjs (WebKit, en and zh) asserts the API/session text. routing-wb-refused, routing-effort, routing-kind, routing-zcode-blocked pass on WebKit; go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
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)
cursor: a conversation's Runs keep one conversation_id, so Grok's prompt cache on Cursor sticks, and Cursor's usage no longer counts the cache twice (#498, congee949). buildCursorRun put a new UUID in AgentRunRequest's conversation_id (field 5) on every Run, so Cursor's backend had nothing to keep a conversation on the machine holding its cached prefix — the x-grok-conv-id grokSigned sends xAI direct — and a Codex thread on cursor/grok-4.7-fast read about 2% from cache against 94% on the grok provider, its first token growing with the whole prompt. The id is now a UUID (v4-shaped, like the official client's) hashed from what names the session — the client's prompt_cache_key (Codex's thread id), else the agent's own session header (Claude Code's, OpenCode's) — together with the conversation's first user message, which every later request repeats; with neither key nor session header each Run still gets a fresh one, since a first message alone ("hi") would put strangers under one id. On concurrent subagents: Cursor's own client keeps one conversationId per agent session, and whether its backend treats the id as more than a cache-affinity key isn't known, so subagents sharing a session (Claude Code's Task agents share its session id; a Codex collab agent has its own thread and key anyway) get ids of their own through their own first message, never one id sent twice at once. TurnEndedUpdate's input_tokens is the whole prompt, cache read and written included, as Cursor's client has it; decode kept it as Usage.Input, which everywhere else is the uncached rest that prompt() and pricing add the cache to, so a cached turn was counted twice (in=137300 with cache_read=132352 is 4948 uncached). Input is now input_tokens less cache read and write, never below 0, and reasoning_tokens (field 5) is kept as Reasoning. TestCursorConversationID (same Codex key → same id across turns, other key → other id; same Claude Code session → same id, a subagent's first message → another; nothing naming it → a new id each Run), TestCursorUsageTakesTheCacheOut and the updated TestCursorDecodesTheEnd fail without the change. No real Cursor request was made. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/gateway/ ./internal/provider/ pass.
gateway: a Cursor tool call whose arguments aren't whole JSON keeps its place in the history (follow-up to #434) — a call's arguments are sent to Cursor as they are, to keep large integers exact; arguments cut short, as the search loop's streamed ones can be, made the whole assistant message fail to marshal and drop out of the history. Arguments that aren't valid JSON are now sent as {} as before #434. TestCursorTruncatedArgsKeepCall fails without the change.
cursor: a step that calls tools records what it used, the cache included (#676) — Cursor tells a turn's usage (TurnEndedUpdate) only once the client has run the tools and sent their results in the same Run, and the caller runs them after the Run is closed, so every tool step was recorded as an estimated input with no cache (2395 / 0 where Cursor counted 4265, 4224 of them cached), while text turns showed ~99% cache. After the calls Cursor sends only the checkpoint (no token details) and heartbeats; a cancelled Run ends with an error and no usage. Every Run is counted in the dashboard's usage events (DashboardService/GetFilteredUsageEvents) by its conversation_id, input apart from the cache read and written; one shows about 2.5 s after the Run closes. So a tool step with no TurnEnded now closes the Run and looks for its event (after 1.5 s, every 0.5 s, up to 6 s): its conversation's, no older than the Run, not already counted; else the estimate as before. A Run with no conversation id gets one, to be found by. The estimate is also no longer added to a turn read wholly from the cache (input 0, cache read > 0). The mover's min for cursor is 0.1.9, which does the same in the plugin (magpie-community/plugins: cursor 0.1.9). TestCursorToolStepReadsItsUsageEvent fails on the old code (5008 estimated, 0 cached) and passes. Tried with the real Cursor account: the built-in (grok-4.7-medium-fast) reports 6153 in, 6144 cached on a tool call, streamed or not; and through a sandbox magpie's plugin host with plugin 0.1.9, 6186 in, 6144 cached, 121 out, where 0.1.8 recorded 5057 estimated, 0 cached. A tool step now takes about 2.5 s longer to finish. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: an upstream that answers 200 with a web page, with nothing at all, or with JSON that isn't, has failed as a 502 does, so a group asks its next member (#1012, cth123456: Codex on group/medium, agentrouter/deepseek-v4-flash now and then answered 200 text/html; the log said status=200 done=true in=0 out=0 served=null, codex/gpt-6-luna was never asked, and Codex failed with "stream disconnected before completion: stream closed before response.completed") — the candidate loop judged a try by its status alone, and holdWriter passes a 200 that is neither text/event-stream nor application/json straight to the agent, so the page went to Codex as the reply. notAnAPIReply now reads every 2xx (but 204) an upstream gives, in forwardOnce (every relayed and translated request, every API, plugins included) and in the built-in clients' own requests (the ChatGPT backend's /responses, Kiro, Zed, Qoder, Devin, Cursor, Command Code): a text/html reply, a body that begins as a web page whatever its Content-Type, an empty body, or one sent as JSON that doesn't begin with { or [ is turned into the 502 it stands for, with a JSON error saying what came back ("answered 200 OK with a web page (text/html), not an API reply: “Just a moment...”", the firewall hint for a block page). It then fails over, rests a minute and is logged as any 502 before the reply begins does; a provider with nobody after it hands the agent that 502 rather than the page. Only the first bytes are peeked, which a stream waits for anyway; a read that fails is left to the reader, a compressed body and anything else as it came. TestNotAnAPIReplyFailsOver (Chat, Responses and Messages, streamed and not, a member answering a Cloudflare page as text/html, as JSON and as SSE, an empty body as JSON and as SSE, and plain text as JSON; the next member must answer and the log name why) fails without the change in all 36 cases (the page relayed to the agent, or b never asked, or no reason logged) and passes; TestNotAnAPIReplyAlone (a 502 with the reason, logged 502) and TestCodexBackendWebPage (the ChatGPT backend's page reached Codex as a 200) fail without it and pass; TestAPIReplyUntouched keeps JSON after whitespace, SSE with and without a Content-Type, gzip, XML, a 204 and a 404 as they came. -race -count=10 on them; go test -tags nogui ./internal/gateway ./internal/provider, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay (agentrouter) itself; the page is a Cloudflare challenge's, as the issue has only its Content-Type. docs/subsystems/gateway-routing.md says it.
models: Provider in model names has a third way, a name the user gave used just as they wrote it (#92 marsxxl: v0.1.591 的 Codex 下拉里自定义名称又带上了 · WorkBuddy / · Claude Code) —
559ce021 (#203) had a custom name keep " · <provider>" after it like any other, so "Opus 5.5" read "Opus 5.5 · Claude Code" in every list Labels feeds (Codex's model_catalog_json and the gateway's Codex /models, Pi's, OpenCode's, ZCode's, OMP's, Muse's), while #92 had been promised the name as written. Both are kept: the setting is now Off / Not on names I set (自定义名称不带供应商) / On. On stays the default and keeps #203's behaviour, so nobody's list changes unasked; own leaves a name the user gave as it is and a vendor's name with its provider's; off is #335's plain names. Stored as plainOwnNames beside plainNames, so a settings.json of before reads as it did (plainNames: true is off). POST /api/settings/plain-names takes mode on|own|off (a body of on alone still works), magpie model suffix on|own|off, a Settings save keeps it, and /v1/models' magpie_label now follows the setting through Labels as the agents' lists do. The row's hint is one line that fits (en, zh), and its zh name says 供应商 as the rest of the app. Prompts and headers are unchanged. TestSuffixMode, TestSuffixModesReachAgentFiles (Codex model_catalog_json and Pi models.json), TestModelsListSuffixModes (/v1/models magpie_label) and model-suffix-own.test.cjs (Chromium and WebKit, en and zh) fail without the change; TestModelName and TestModelNameAndLevelsReachAgentFiles keep #203's assertions. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestConfiguredModelsAdvertiseImageInput load-flaky, passes alone); plain-names, click-scroll, old-webkit pass.
gateway: a gateway key can be held to some accounts (#905, LCorleone) — a key could burn any account a provider had: two coding plans of one vendor, and a key given to someone had them both. A key now keeps an optional list of accounts and keys (caller-keys.json "accounts", none = every account, so older files read as before): "<provider>/<account>", an account by its stable id — the login's, set once it is written and kept through renames (logins.json's savedLogin id, written by every write and stood in for by one made of the name until then, so an entry made before stands), a key by its fingerprint — every one a provider has when the list is set, and one gone later (a key rotated, an account signed out) kept as it is, matching nothing, holding the key closer, never wider. The command line and the menu name an account by who is signed in and keep its id. Routing holds a plan to the key's accounts beside its models: a candidate whose account the key may not use is left out — through a routing group the key names no less, for naming a group is not naming its accounts — the plan's order keeping step and its left-out list marking what the key held out (Weighed's held); magpie's own calls for the key — a web search, a picture described, a Codex title — are of the models the user picked, so the models don't hold them, but their spend lands on an account all the same, and the accounts hold them too. When every account or key a model has is one the key may not use, the request is refused with a 403 in the API's own error shape naming the model and the accounts the key may use, logged as a rejected call: after a usage cap's 429 and before an account barred for the model's, itself before the no-member 404. /v1/models and the Anthropic and Gemini lists show the key only the models an account or key it may use serves (a provider's account set not to serve one, #474, is none of the key's); a provider with no account and no key (a local ollama) is one the key names no account of — its models stay listed and its asks pass — while an entry kept after its provider lost its last account or key serves none, holding the key closer. The embeddings, images and videos endpoints hold their candidate to the key's accounts the same way, and counting tokens counts on those alone (the local estimate where it may use none). System One asks its decision model of an account or key the key may use — and sends on that key, of those in use, not the provider's first, which a key held to a later one was spent on; the videos and images keyed paths send on one the key may use too, held to a later key no longer refused for the first alone. Set it in the app's menu the models are picked in — the accounts after the models, an account by who is signed in with their plan in the note, a key by its fingerprint, what the CLI kept that no provider has now kept as "Not signed in now" — or
magpie gateway-key accounts <id> codex/me@example.com openai/<key id> (all takes it off); gateway-key list has an ACCOUNTS column. en/zh/ja/de, the German "{n} accounts" main already had left to it rather than written twice. The images API's fan-out over a subscription's accounts is held to the key's accounts as one: one of the key's whose plan refuses to draw no longer hands the pen to an account it may not use, and the second in the order, when it is the key's, draws what the first may not (drawOnAccounts filters the candidates, the account pinned to with the account header told plainly when the key may not use it). The Routing view's left-out list says an account the key held out for what it is, not that its plan lacks the model, in Chinese, Japanese and German too; the key's badge names its accounts by who is signed in, not by the stable ids it keeps. (#932)
gateway: an upstream that answers 200 with a web page, with nothing at all, or with JSON that isn't, has failed as a 502 does, so a group asks its next member (#1012, cth123456: Codex on group/medium, agentrouter/deepseek-v4-flash now and then answered 200 text/html; the log said status=200 done=true in=0 out=0 served=null, codex/gpt-6-luna was never asked, and Codex failed with "stream disconnected before completion: stream closed before response.completed") — the candidate loop judged a try by its status alone, and holdWriter passes a 200 that is neither text/event-stream nor application/json straight to the agent, so the page went to Codex as the reply. notAnAPIReply now reads every 2xx (but 204) an upstream gives, in forwardOnce (every relayed and translated request, every API, plugins included) and in the built-in clients' own requests (the ChatGPT backend's /responses, Kiro, Zed, Qoder, Devin, Cursor, Command Code): a text/html reply, a body that begins as a web page whatever its Content-Type, an empty body, or one sent as JSON that doesn't begin with { or [ is turned into the 502 it stands for, with a JSON error saying what came back ("answered 200 OK with a web page (text/html), not an API reply: “Just a moment...”", the firewall hint for a block page). It then fails over, rests a minute and is logged as any 502 before the reply begins does; a provider with nobody after it hands the agent that 502 rather than the page. Only the first bytes are peeked, which a stream waits for anyway; a read that fails is left to the reader, a compressed body and anything else as it came. TestNotAnAPIReplyFailsOver (Chat, Responses and Messages, streamed and not, a member answering a Cloudflare page as text/html, as JSON and as SSE, an empty body as JSON and as SSE, and plain text as JSON; the next member must answer and the log name why) fails without the change in all 36 cases (the page relayed to the agent, or b never asked, or no reason logged) and passes; TestNotAnAPIReplyAlone (a 502 with the reason, logged 502) and TestCodexBackendWebPage (the ChatGPT backend's page reached Codex as a 200) fail without it and pass; TestAPIReplyUntouched keeps JSON after whitespace, SSE with and without a Content-Type, gzip, XML, a 204 and a 404 as they came. -race -count=10 on them; go test -tags nogui ./internal/gateway ./internal/provider, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay (agentrouter) itself; the page is a Cloudflare challenge's, as the issue has only its Content-Type. docs/subsystems/gateway-routing.md says it.
gateway: ModelScope's image models draw, on its async task API, one at a time (#645) — Qwen/Qwen-Image-2.1 at api-inference.modelscope.cn always came back "drew nothing": its images API answers without OpenAI's data (a task_id, or images[].url), so nothing was read and a 502 was made of it. A provider at ModelScope (the preset or one added by hand) now starts a task with X-ModelScope-Async-Mode, asks GET /tasks/<id> with X-ModelScope-Task-Type: image_generation until SUCCEED (output_images) or FAILED, and reads a sync answer's images too. It is sent model, prompt and size only, and an edit's images as image_url. ModelScope holds one task per user, so magpie draws there one image at a time per key, n of them in turn, and asks again after 3/8/15 s when told the queue is full or 429. A failed task, or any images API answer with no image, now says what the vendor said (vendorMessage reads ModelScope's errors.message) instead of "drew nothing". TestModelScopeDraws, TestModelScopeOneAtATime, TestModelScopeQueueFull, TestModelScopeSaysWhy and TestImagesAPINoImageSaysWhy run against a stub of ModelScope's API and fail on the old code. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build and go test -tags nogui -count=1 ./... under a temp HOME pass but TestUserPath, a timing test that passes alone. Not tried against the real ModelScope.
gateway: ModelScope's image models draw, on its async task API, one at a time (#645) — Qwen/Qwen-Image-2.1 at api-inference.modelscope.cn always came back "drew nothing": its images API answers without OpenAI's data (a task_id, or images[].url), so nothing was read and a 502 was made of it. A provider at ModelScope (the preset or one added by hand) now starts a task with X-ModelScope-Async-Mode, asks GET /tasks/<id> with X-ModelScope-Task-Type: image_generation until SUCCEED (output_images) or FAILED, and reads a sync answer's images too. It is sent model, prompt and size only, and an edit's images as image_url. ModelScope holds one task per user, so magpie draws there one image at a time per key, n of them in turn, and asks again after 3/8/15 s when told the queue is full or 429. A failed task, or any images API answer with no image, now says what the vendor said (vendorMessage reads ModelScope's errors.message) instead of "drew nothing". TestModelScopeDraws, TestModelScopeOneAtATime, TestModelScopeQueueFull, TestModelScopeSaysWhy and TestImagesAPINoImageSaysWhy run against a stub of ModelScope's API and fail on the old code. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build and go test -tags nogui -count=1 ./... under a temp HOME pass but TestUserPath, a timing test that passes alone. Not tried against the real ModelScope.
gateway: a gateway key can be held to some accounts (#905, LCorleone) — a key could burn any account a provider had: two coding plans of one vendor, and a key given to someone had them both. A key now keeps an optional list of accounts and keys (caller-keys.json "accounts", none = every account, so older files read as before): "<provider>/<account>", an account by its stable id — the login's, set once it is written and kept through renames (logins.json's savedLogin id, written by every write and stood in for by one made of the name until then, so an entry made before stands), a key by its fingerprint — every one a provider has when the list is set, and one gone later (a key rotated, an account signed out) kept as it is, matching nothing, holding the key closer, never wider. The command line and the menu name an account by who is signed in and keep its id. Routing holds a plan to the key's accounts beside its models: a candidate whose account the key may not use is left out — through a routing group the key names no less, for naming a group is not naming its accounts — the plan's order keeping step and its left-out list marking what the key held out (Weighed's held); magpie's own calls for the key — a web search, a picture described, a Codex title — are of the models the user picked, so the models don't hold them, but their spend lands on an account all the same, and the accounts hold them too. When every account or key a model has is one the key may not use, the request is refused with a 403 in the API's own error shape naming the model and the accounts the key may use, logged as a rejected call: after a usage cap's 429 and before an account barred for the model's, itself before the no-member 404. /v1/models and the Anthropic and Gemini lists show the key only the models an account or key it may use serves (a provider's account set not to serve one, #474, is none of the key's); a provider with no account and no key (a local ollama) is one the key names no account of — its models stay listed and its asks pass — while an entry kept after its provider lost its last account or key serves none, holding the key closer. The embeddings, images and videos endpoints hold their candidate to the key's accounts the same way, and counting tokens counts on those alone (the local estimate where it may use none). System One asks its decision model of an account or key the key may use — and sends on that key, of those in use, not the provider's first, which a key held to a later one was spent on; the videos and images keyed paths send on one the key may use too, held to a later key no longer refused for the first alone. Set it in the app's menu the models are picked in — the accounts after the models, an account by who is signed in with their plan in the note, a key by its fingerprint, what the CLI kept that no provider has now kept as "Not signed in now" — or
magpie gateway-key accounts <id> codex/me@example.com openai/<key id> (all takes it off); gateway-key list has an ACCOUNTS column. en/zh/ja/de, the German "{n} accounts" main already had left to it rather than written twice. The images API's fan-out over a subscription's accounts is held to the key's accounts as one: one of the key's whose plan refuses to draw no longer hands the pen to an account it may not use, and the second in the order, when it is the key's, draws what the first may not (drawOnAccounts filters the candidates, the account pinned to with the account header told plainly when the key may not use it). The Routing view's left-out list says an account the key held out for what it is, not that its plan lacks the model, in Chinese, Japanese and German too; the key's badge names its accounts by who is signed in, not by the stable ids it keeps. (#932)
gateway: a gateway key can be held to some accounts (#905, LCorleone) — a key could burn any account a provider had: two coding plans of one vendor, and a key given to someone had them both. A key now keeps an optional list of accounts and keys (caller-keys.json "accounts", none = every account, so older files read as before): "<provider>/<account>", an account by its stable id — the login's, set once it is written and kept through renames (logins.json's savedLogin id, written by every write and stood in for by one made of the name until then, so an entry made before stands), a key by its fingerprint — every one a provider has when the list is set, and one gone later (a key rotated, an account signed out) kept as it is, matching nothing, holding the key closer, never wider. The command line and the menu name an account by who is signed in and keep its id. Routing holds a plan to the key's accounts beside its models: a candidate whose account the key may not use is left out — through a routing group the key names no less, for naming a group is not naming its accounts — the plan's order keeping step and its left-out list marking what the key held out (Weighed's held); magpie's own calls for the key — a web search, a picture described, a Codex title — are of the models the user picked, so the models don't hold them, but their spend lands on an account all the same, and the accounts hold them too. When every account or key a model has is one the key may not use, the request is refused with a 403 in the API's own error shape naming the model and the accounts the key may use, logged as a rejected call: after a usage cap's 429 and before an account barred for the model's, itself before the no-member 404. /v1/models and the Anthropic and Gemini lists show the key only the models an account or key it may use serves (a provider's account set not to serve one, #474, is none of the key's); a provider with no account and no key (a local ollama) is one the key names no account of — its models stay listed and its asks pass — while an entry kept after its provider lost its last account or key serves none, holding the key closer. The embeddings, images and videos endpoints hold their candidate to the key's accounts the same way, and counting tokens counts on those alone (the local estimate where it may use none). System One asks its decision model of an account or key the key may use — and sends on that key, of those in use, not the provider's first, which a key held to a later one was spent on; the videos and images keyed paths send on one the key may use too, held to a later key no longer refused for the first alone. Set it in the app's menu the models are picked in — the accounts after the models, an account by who is signed in with their plan in the note, a key by its fingerprint, what the CLI kept that no provider has now kept as "Not signed in now" — or
magpie gateway-key accounts <id> codex/me@example.com openai/<key id> (all takes it off); gateway-key list has an ACCOUNTS column. en/zh/ja/de, the German "{n} accounts" main already had left to it rather than written twice. The images API's fan-out over a subscription's accounts is held to the key's accounts as one: one of the key's whose plan refuses to draw no longer hands the pen to an account it may not use, and the second in the order, when it is the key's, draws what the first may not (drawOnAccounts filters the candidates, the account pinned to with the account header told plainly when the key may not use it). The Routing view's left-out list says an account the key held out for what it is, not that its plan lacks the model, in Chinese, Japanese and German too; the key's badge names its accounts by who is signed in, not by the stable ids it keeps. (#932)
gateway: a ChatGPT account whose plan doesn't take the reasoning effort asked for hands the request to another account, and isn't asked for it again (#520: 在同时登录plus和免费账户时,如果将免费账户的优先级设置到最高,此时会错误的将6-luna的high路由给免费账户,但是免费账户不提供high及以上的思考强度,返回400错误) — accounts of one subscription were picked by their order alone, with nothing of what each plan takes at what reasoning level: perKeyBarred puts an account whose plan lacks the model after the others (Account.Lists), but a Free account that lists gpt-6-luna and turns high and above away was asked first, and its 400 ("Unsupported value: 'high' is not supported with the 'gpt-6-luna' model. Supported values are: …", param reasoning.effort, code unsupported_value) either reached the agent, or — where retryable's unserved words happened to match "invalid_request_error" — set the Free account aside for a minute for every effort. Now (effort_accounts.go): an account candidate known not to take the level it would be sent — it turned that level away within the hour, or its own model list (Account.Levels, the account's supported_reasoning_levels) gives the model levels without it — is moved behind the next account of the same member that may take it; an account whose 4xx is that refusal (effortRefused: the "Unsupported value: '<level>'" form for the level sent, or one naming reasoning.effort; another field's value such as text.verbosity's 'high' isn't one), of a level the model has, is noted, doesn't rest, and the next is asked (failEffort, "effort" in the routing trace, its matesFirst); when no account left takes it, the agent gets a 400 saying which account doesn't take which effort on which model, the levels it takes and the vendor's words, never the effort lowered behind its back. The codex provider's levels for a model are now its on accounts' together (codexPoolLevels), so a Free account signed in to Codex no longer has a Plus one's high lowered to medium. A request at fault otherwise (a 400 over its input) still goes to the agent, unchanged. Codex's prompts are untouched. TestEffortRefusedGoesToAnotherAccount (Free first: high goes Free then Plus, then straight to Plus, xhigh too; medium is the Free one's; a bad input is not sent on), TestEffortTheAccountListsLacks (Free signed in, its list without high: high goes to Plus alone, low to Free, with Plus out of quota the Free one's refusal is told as such) fail without the change; TestEffortRefusedWords. No request was made to OpenAI. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass, except internal/proc's TestUserPath (the login shell's PATH not coming within 5s under the temp HOME; passes under the real one; untouched).
gateway: a ChatGPT account whose plan doesn't take the reasoning effort asked for hands the request to another account, and isn't asked for it again (#520: 在同时登录plus和免费账户时,如果将免费账户的优先级设置到最高,此时会错误的将6-luna的high路由给免费账户,但是免费账户不提供high及以上的思考强度,返回400错误) — accounts of one subscription were picked by their order alone, with nothing of what each plan takes at what reasoning level: perKeyBarred puts an account whose plan lacks the model after the others (Account.Lists), but a Free account that lists gpt-6-luna and turns high and above away was asked first, and its 400 ("Unsupported value: 'high' is not supported with the 'gpt-6-luna' model. Supported values are: …", param reasoning.effort, code unsupported_value) either reached the agent, or — where retryable's unserved words happened to match "invalid_request_error" — set the Free account aside for a minute for every effort. Now (effort_accounts.go): an account candidate known not to take the level it would be sent — it turned that level away within the hour, or its own model list (Account.Levels, the account's supported_reasoning_levels) gives the model levels without it — is moved behind the next account of the same member that may take it; an account whose 4xx is that refusal (effortRefused: the "Unsupported value: '<level>'" form for the level sent, or one naming reasoning.effort; another field's value such as text.verbosity's 'high' isn't one), of a level the model has, is noted, doesn't rest, and the next is asked (failEffort, "effort" in the routing trace, its matesFirst); when no account left takes it, the agent gets a 400 saying which account doesn't take which effort on which model, the levels it takes and the vendor's words, never the effort lowered behind its back. The codex provider's levels for a model are now its on accounts' together (codexPoolLevels), so a Free account signed in to Codex no longer has a Plus one's high lowered to medium. A request at fault otherwise (a 400 over its input) still goes to the agent, unchanged. Codex's prompts are untouched. TestEffortRefusedGoesToAnotherAccount (Free first: high goes Free then Plus, then straight to Plus, xhigh too; medium is the Free one's; a bad input is not sent on), TestEffortTheAccountListsLacks (Free signed in, its list without high: high goes to Plus alone, low to Free, with Plus out of quota the Free one's refusal is told as such) fail without the change; TestEffortRefusedWords. No request was made to OpenAI. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass, except internal/proc's TestUserPath (the login shell's PATH not coming within 5s under the temp HOME; passes under the real one; untouched).
gateway: a group's auto effort keeps a conversation's reasoning while its cache is warm, going up but not down (#617: Kenoia — with group/core effort "auto", 40 of 42 effort changes between a Pi session's turns on gpt-6.1-sol read 0 cached tokens, against 4 of 834 turns without one)
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报错)
gateway: an effort raised mid-thread starts the thread again at it, only a lower one goes as an effort update (Lutra-Fs, #617) — the backend gives a configuration_update up a fraction of the reasoning the same effort at the top gets. Lutra-Fs measured it on chatgpt.com's Codex backend without magpie (gpt-6.1-sol: low / high / xhigh at the top 328 / 826 / 3350 reasoning tokens, low updated to xhigh 802); with the owner's real Codex Pro account, direct to the backend, one hard prompt, 2 samples each: low 410 / 669, xhigh 6214 / 14304, low updated to xhigh 3106 / 2070, xhigh updated to low 327 / 458. An update down reasons as the lower effort does; an update up about a quarter of what was asked. With MAGPIE_EFFORT_UPDATES=on, an effort above the thread's own now starts it again at the top (the cache lost once for each level up, as a group's auto effort loses it), and one at or below it goes as an update, keeping the cache. Compaction already starts a thread again at the effort asked, its history no longer the last one and more, which is what Codex CLI does after a compaction. TestEffortChangeKeepsTheCache fails on the old code (sent low with u:high). go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: an effort raised mid-thread starts the thread again at it, only a lower one goes as an effort update (Lutra-Fs, #617) — the backend gives a configuration_update up a fraction of the reasoning the same effort at the top gets. Lutra-Fs measured it on chatgpt.com's Codex backend without magpie (gpt-6.1-sol: low / high / xhigh at the top 328 / 826 / 3350 reasoning tokens, low updated to xhigh 802); with the owner's real Codex Pro account, direct to the backend, one hard prompt, 2 samples each: low 410 / 669, xhigh 6214 / 14304, low updated to xhigh 3106 / 2070, xhigh updated to low 327 / 458. An update down reasons as the lower effort does; an update up about a quarter of what was asked. With MAGPIE_EFFORT_UPDATES=on, an effort above the thread's own now starts it again at the top (the cache lost once for each level up, as a group's auto effort loses it), and one at or below it goes as an update, keeping the cache. Compaction already starts a thread again at the effort asked, its history no longer the last one and more, which is what Codex CLI does after a compaction. TestEffortChangeKeepsTheCache fails on the old code (sent low with u:high). go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: a routing group's members are tried in turn for /v1/embeddings and /v1/rerank (#773, dbydd: group/tmpembed of an OpenRouter :free embeddings model out of its daily quota and an NVIDIA one that answers gave OpenRouter's 429, where the same group on /v1/chat/completions moved on to its next member) — retrieve resolved a group to its first member and sent the request there alone. It now takes the group's members as its routing orders them (planGroup, every account or key of theirs, an Off member left out, those capped aside) and asks the next when one fails: a vendor without the API (404), one out of quota (429), any refusal or a failure to reach it; a model asked by its own id is asked alone, as before. The body is masked once, before the first is asked, so a member passed over leaves nothing written. The answer is the first member's that answers, recorded under it with the members passed over as the call's fallback (the Requests page); when every member fails, the last one's refusal comes back with its code, those tried first named. With the owner's real keys under a sandbox HOME: group/embpool of openrouter/openai/gpt-6-luna (400 from OpenRouter) then aihubmix/text-embedding-3-small returned aihubmix's 4-d vector, in the ledger as aihubmix's for group/embpool. TestRetrievalGroupFailsOver (an embeddings group past a 429, a rerank group past a 404, a group whose only member fails gives its 429) fails on the old code (the 429 comes back). go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass (TestProvidersAnswerWhileListsComeIn and TestClaudeSignedOut flaked once under load and pass alone).
agents: Empryo (#612) — picking a magpie model in Empryo adds a "magpie" provider at the gateway to ~/.empryo/config.json's providers (baseURL + modelsAPI, no envVar) and sets defaultModel to magpie/<catalog id>; the user's defaultModel is stashed and put back on leaving, magpie's provider goes again, and a file magpie made is removed when empty. The user's own providers and keys are left alone. The gateway now answers GET /v1 (and /v1/) with its info, as Empryo lists a keyless provider only when its baseURL answers. Also on WSL. Built from Empryo's docs and its public v2 code, not run against the Empryo binary. TestEmpryo, TestBaseURLAnswers (GET /v1 was 404 before).
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
gateway, provider: a request turning reasoning off stops Factory's DeepSeek V4.1 Flash, Kimi K3 and GLM-5.2 thinking (#899, tedestar) — they offered only low/high/max (GLM-5.2 high/max), so an agent's none (or Claude Code's thinking disabled) was asked for low, or nothing, and Fireworks thought anyway. droid 0.231.0 lists off for them and sends reasoning_effort "none"; through OpenRouter pinned to Fireworks, none came back with 0 reasoning tokens on DeepSeek V4.1 Flash (2 completion tokens; 19 at the default, 16 reasoning at low) and Kimi K3 (8; 71 at the default), and on Baseten's GLM-5.2 (2; 114 at the default), while GLM-5.3 and GLM-5.3-Flash turn none away ("Reasoning is mandatory for this endpoint and cannot be disabled") and stay at low, their least (Qwen3.8 Max and MiniMax have no off in droid either). The three now have none among their levels, in the built-in and in @magpie-community/opencode-factory-auth 0.1.17, which the mover's min is raised to; and a translated request that turned reasoning off (Effort reads it as low) is asked for none on a model with none among its levels, whatever the provider, else as before (a refusal still falls back to the lowest level on). No Factory account was at hand to send through Factory itself. TestFactoryThinkingOff (the built-in: chat, Responses and Anthropic's disabled; GLM-5.3 still low) and TestPluginThinkingOff (the plugin host, a model whose variants have none and one without) are new and fail without the change. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass (TestSavedButSignedOutClaudeServed timed out once under load and passes alone).
gateway, provider: the Routing page says what a lone key or account has left, and a key bought as a plan (Kimi Code, GLM Coding Plan, MiniMax, OpenCode Go, Command Code) is weighed by its plan's windows (#1016, gphysign: Kimi Code (China) with one key on, Smart routing; the card said only "Kimi Code (China) 只启用了一个 Key,无需选择。" while /v1/magpie/quotas had its 5 hours 49 used, 51 left and its 7 days 45 used) — two gaps. weighRouted returned before reading anyone's allowance when there was one candidate, so a lone key or a lone subscription account was never known in the trace, and the Routing page, which shows a bar and "{n} used · renews in {d}" for a known row, had nothing to show; every single-account subscription said "what's left not known yet" the same way. And KeyAllowance read only a sub2api key's limits, so a key whose plan the Usage page reads (planQuotaSourceOf: Kimi Code, GLM Coding Plan and Z.ai, MiniMax Coding Plan, OpenCode Go, Command Code) was never known to routing, with one key or several. weighRouted now reads what each candidate has left before returning a lone one (one alone is still weighed against nobody). readsKeyWindows takes a plan key too; keyWindows reads it through planKeyWindows (planWindows, with Zhipu's team quota behind a key that has none of its own, as PlanQuotas does), and PlanQuotas' own read of a card is noted for routing at once (noteKeyAllowance), so the Usage page and routing never ask twice; the card kept on disk seeds it after a restart (keyCardTag keys a plan card as plan:, a sub2api one as balance:). A key at such a vendor that turns out to be pay-as-you-go, with no windows, is asked again every 30 minutes (keyNoWindowsAge), not every minute it is used. So, beyond the issue: several plan keys on one provider are now weighed by their windows under Smart, usage and pace, and a plan key the vendor refuses as out of quota rests until its window renews and comes back once read short of full, as a sub2api key does. affine no longer says a lone key or account all but used up "moved" its conversation: there is nobody to move to (at > 0). The GUI needed no change. TestLoneOneTellsWhatItHasLeft (a lone Kimi key known at 49% with both renews, the week first; a lone Codex account known; a plain DeepSeek key not), TestLoneSpentKeyKeepsItsConversation (fails with the at > 0 guard taken out), TestPlanKeyAllowance (the windows read behind the request, asked once; DeepSeek and Moonshot keys never asked), TestPlanKeyAllowanceFromItsCard (PlanQuotas' read known at once without a second ask; after a restart with the vendor down, the card on disk stands in and a failed read keeps it) and TestPayAsYouGoKeyAskedSeldom (a GLM pay-as-you-go key asked once, not two minutes on, again 31 minutes on) all fail without the change and pass -race -count=20 (provider) / -count=10 (gateway). routing-key-windows.test.cjs gains a lone Kimi Code key at 1100px and 440px (the one-key story, the row's bar at 49%, its provider row "49% used · renews in …" fitting, no sideways scroll) and passes in full on Chromium and WebKit in en, zh, ja and de; routing-quota-left and panel-routing pass. go test -tags nogui ./internal/..., go test -race on ./internal/provider and ./internal/gateway in full, go vet, gofmt -l, darwin, linux and windows (nogui) builds pass. Not tried with a real Kimi Code key. StepFun's Step Plan is read through a sign-in, not the key, so it isn't one of these. docs/subsystems/gateway-routing.md, providers-accounts.md and docs/reference.md say it.
gui: a model with a fast mode is sent fast, or not, from its row in the agent's picker (#954, Gyarados4157) — Fast was only a routing group member's (Group.Fast), so a GPT on a ChatGPT account picked straight in Codex or OpenCode could only go fast through a group of one. A model provider.CanFast says has a fast mode (a ChatGPT account's GPT, an OpenAI key's Responses GPT/o-series, Cursor's -fast, Claude Opus on an Anthropic key) now has a bolt beside its star in the agent's picker and in the same picker opened from a provider's row; a click posts /api/agent-fast and switches it for that agent (settings FastPicks, by agent, "<provider>/<model>", moved by a provider rename and kept by the Settings page's save) without changing the pick or closing the picker; a refusal puts it back. The field shows a bolt after a model sent fast. The gateway asks for the vendor's fast mode (service_tier "priority" / speed "fast", as for a group member) on that agent's requests for that entry, every account and key of it, not its fallbacks, another agent's requests or the same model at another provider; the Routing trace marks the try Fast. A routing group is never switched: its members say it. zh/ja/de added. fast-pick (window and 440px panel, en/zh/ja/de, Chromium and WebKit; the bolt only on models with a fast mode, the post, the refusal, the provider row's picker) passes and fails without the fix; TestFastPick (gateway, Chat/Responses/Anthropic in, fails with the gateway hunk off), TestSetFastPick, TestFastPickOptions and the settings-keep test (fails without the carry) pass with -race; i18n-keys, gui-ja, gui-de, favorite-star, agent-models-picker, claude-picker-fold and click-scroll pass; go test ./internal/gui/ ./internal/provider/ ./internal/agent/ ./internal/gateway/ ./internal/settings/ pass.
gateway: a Codex conversation compacted on GPT goes on after switching to Grok (waroy on Discord: Grok (SuperGrok): Could not decrypt the provided encrypted_content) — when Codex compacts on OpenAI's own models, the earlier turns become a compaction item sealed by OpenAI (encrypted_content), which magpie kept for magpie models. Grok can't read it, any more than another vendor's reasoning. The retry after such a refusal (
2476cd5) left out only the sealed reasoning and asked once, so Grok refused the compaction too and Codex got xAI's error. It now asks again without the compaction when the refusal comes back (or there was no reasoning to leave out), and remembers the compaction with the reasoning as refused for that conversation and account, so later turns leave both out up front while Grok's own reasoning goes along; each step remembers only what it took out. The summary of the turns before the compaction is lost to Grok; what was said since stays. TestForeignCompactionToGrok (Codex path, a SuperGrok-like upstream refusing in the stream) fails without the change with the reporter's error. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestClaudeSignedOut, TestClaudeAccountNamedAsItsLogin, TestClaudeIdentityServedWhileAsked, known flakes under load, pass alone). Not tested against xAI itself.
provider(copilot) + gateway: a Copilot model listed with no supported_endpoints (GPT-4o, GPT-4.1, the models a Student/Education plan is served) is asked on chat completions, and Copilot's /v1/messages "no model endpoints available given user constraints" goes on to the model's other APIs (#754, on v0.1.813: copilot/gpt-4o from Claude Code answered "Copilot: 400 Bad Request: no model endpoints available given user constraints", which read as the plan refusing the model) — Copilot's /models gives the newer models their supported_endpoints, and magpie sends each request to an API listed there (
6f19f60b), but GPT-4.1, GPT-4o, GPT-4o mini, GPT-3.5 Turbo and their snapshots have none, which copilotAPIs took for "not known": every API the provider speaks was tried, an Anthropic client's request went to Copilot's /v1/messages first, and Copilot answers a model it serves elsewhere there with that plain-text line, which wrongEndpoint didn't know, so the request was never tried on chat completions. Checked with a real github.com account (free_github_star_quota) against https://api.individual.githubcopilot.com: gpt-4o and gpt-4.1 answer on /chat/completions (streamed and not), refuse /responses ("not supported via Responses API", unsupported_api_for_model) and refuse /v1/messages with "no model endpoints available given user constraints" (gpt-5.6-terra and gemini-3.8-flash say the same there); a model Copilot doesn't serve at all is model_not_supported on /v1/messages too, so the line means the API, not the plan. copilotAPIs now gives a model with no supported_endpoints chat alone, and copilotSeen keeps it for every chat model the list names, so a model asked for by hand that no picker lists (gpt-4o) is asked on chat too; wrongEndpoint knows the line, so a model on no list (or a list magpie hasn't fetched) still gets past /v1/messages to the next API; and Copilot's errors gain an explain: a refusal that still reaches the user with that line says "Copilot doesn't serve this model on Anthropic's Messages API; it isn't about your plan" after Copilot's own words. The issue's other claims didn't hold in the code: the model list is the account's own GET {endpoints.api}/models with Editor-Version and the other editor headers (copilotModels, filtered by model_picker_enabled/category and policy), the three gpt-5.6 ids in its providers.json are the user's picks kept under the account's entry, not a fixed list; requests go to the token's endpoints.api (apiBase), never endpoints.proxy, so no request met proxy's '"stream": false is not supported' (it does say that, and api.githubcopilot.com still answers both ways); and the message the user saw was Copilot's own, passed on unchanged. TestCopilotModelsWithoutEndpointsOnChat (a fetched list with gpt-4.1 listed and gpt-4o not: both chat, Claude chat and anthropic; the explain is added to that line and not to model_not_supported), TestCopilotAPIs (nil and [] are chat; it expected nil before), TestAnthropicClientFallsBackPastCopilotNoEndpoints (/v1/messages answers the plain-text line, chat completions answers: 200, and the second request goes straight to chat) and a TestWrongEndpoint row fail on the old code and pass. Verified with the owner's real Copilot account through the gateway's handler in-process (temp HOME with a copy of the editor sign-in): on the old code /v1/messages for copilot/gpt-4o and copilot/gpt-4.1, streamed or not, gave the reporter's exact 400; with this change all four answer 200 "OK" through chat completions, claude-haiku-4.5 still goes to /v1/messages. Not tried with an Education account, whose list may differ; gpt-4o and the other models no picker offers stay out of the list as before (VS Code doesn't offer them either), they are only asked on the right API when picked by hand. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: a Responses relay that turns away reasoning with nothing sealed in it, naming only the input, is asked once more without it, and isn't sent it again for that model (#1044: Codex's /compact through Anyrouter failed with 400 invalid_request_error param=input on old conversations)
gateway: a Gemini reply that says nothing is asked again on the same account before it fails, so a long conversation that ends with only reasoning a few times running goes on instead of stopping the agent (#667, tianshuo886)
gateway: a Gemini functionResponse's own parts reach the upstream — the image agy's view_file read (#1038, handong66: agy via magpie can't see images, the model makes up what they show)
gateway: an image model's picture reaches a Gemini client (#620, tao-bai: antigravity/gemini-3.1-flash-image 返回 parts: []) — through /v1beta generateContent and streamGenerateContent the answer came back with no parts, though candidatesTokenCount (1567) said the image was made. The IR had no event for a picture the model made: codeAssistDecoder (Antigravity, Gemini CLI and Factory's Gemini route) turned functionCall, thought and text parts into events and dropped inlineData, and geminiParts / geminiEncoder had nothing to write it back as. A new KImage event (Name the media type, Text the base64) carries it; the collector keeps it as an Image part, and the Gemini renderer and stream encoder send it as inlineData in its place among the text. A thought image (a draft, thought: true) stays with the reasoning and isn't sent, as before; thought text and signatures are untouched. Chat, Responses and Anthropic clients still don't get the picture (their renderers skip Image parts). TestGeminiImageModelReturnsItsImage (a fake Factory Gemini upstream, generateContent) and TestCodeAssistStreamsImages (Code Assist chunks through the Gemini stream encoder) fail without it with parts: []. Not tried against a real Antigravity account; the image part's thoughtSignature isn't returned to the client, so multi-turn image editing on Gemini 3 image models may still need it. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: Gemini's thought signatures come back on its tool calls, through Pi and every other client (#687) — Pi on the built-in Google Gemini (an API key, gemini-3.8-flash at high) failed the turn after every tool call with 400 "Function call is missing a thought_signature in functionCall parts … default_api:bash, position 3". Gemini 3 signs the first call of each step it thinks through and wants the signature back; its OpenAI-compatible API gives it in tool_calls[].extra_content.google.thought_signature, which magpie relayed to Pi as it came, and Pi's openai-completions (like the AI SDK) keeps a call's id but drops extra_content, so the call went back unsigned. magpie's own translation (Claude Code, Codex, Gemini CLI on the same provider) never read the field at all. Now a call Gemini signed reaches the client with an id carrying the signature (<id>__ts__<signature as base64url>, letters, digits, _ and - only), which every client sends back as given: a Chat request relayed to Gemini has it taken off the call's and the result's ids and put in extra_content, and parse does the same for a translated request (the signature on the call's Part, given back by buildChat). A turn with no signature at all — another model's calls, or an id a client shortened — goes to Gemini with skip_thought_signature_validator on its calls, the value Google documents and the Code Assist path already sends; a later call of a signed step goes without, as Gemini signs the first. Any other upstream gets the calls' own ids, never the long ones. TestGeminiThoughtSignatureThroughPi (streamed and whole) and TestGeminiThoughtSignatureThroughAnthropic, against an upstream that checks signatures as Google does, fail on the old code with the issue's 400 and pass; TestGeminiCallsWithoutSignature and TestSignedID cover the fallback, other upstreams and the id's round trip. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./internal/gateway/... under a temp HOME pass. Not tried against generativelanguage.googleapis.com: there is no Gemini API key on this machine.
gateway: Gemini's thought signatures come back on its tool calls, through Pi and every other client (#687) — Pi on the built-in Google Gemini (an API key, gemini-3.8-flash at high) failed the turn after every tool call with 400 "Function call is missing a thought_signature in functionCall parts … default_api:bash, position 3". Gemini 3 signs the first call of each step it thinks through and wants the signature back; its OpenAI-compatible API gives it in tool_calls[].extra_content.google.thought_signature, which magpie relayed to Pi as it came, and Pi's openai-completions (like the AI SDK) keeps a call's id but drops extra_content, so the call went back unsigned. magpie's own translation (Claude Code, Codex, Gemini CLI on the same provider) never read the field at all. Now a call Gemini signed reaches the client with an id carrying the signature (<id>__ts__<signature as base64url>, letters, digits, _ and - only), which every client sends back as given: a Chat request relayed to Gemini has it taken off the call's and the result's ids and put in extra_content, and parse does the same for a translated request (the signature on the call's Part, given back by buildChat). A turn with no signature at all — another model's calls, or an id a client shortened — goes to Gemini with skip_thought_signature_validator on its calls, the value Google documents and the Code Assist path already sends; a later call of a signed step goes without, as Gemini signs the first. Any other upstream gets the calls' own ids, never the long ones. TestGeminiThoughtSignatureThroughPi (streamed and whole) and TestGeminiThoughtSignatureThroughAnthropic, against an upstream that checks signatures as Google does, fail on the old code with the issue's 400 and pass; TestGeminiCallsWithoutSignature and TestSignedID cover the fallback, other upstreams and the id's round trip. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./internal/gateway/... under a temp HOME pass. Not tried against generativelanguage.googleapis.com: there is no Gemini API key on this machine.
gateway: a Gemini functionResponse's own parts reach the upstream — the image agy's view_file read (#1038, handong66: agy via magpie can't see images, the model makes up what they show)
sessions: omp's local sessions are in Usage › Sessions (#303), and the GLM Coding Plans take Codex's Responses as they are (#306) — omp (oh-my-pi) keeps Pi's session jsonl (v3) under ~/.omp/agent/sessions (and profiles' and $XDG_DATA_HOME/omp's) with a title line, title changes, model_usage for calls outside the chat, provider/model changes and subagent/advisor transcripts beside the file; they are read through Pi's parser, subagents counted in their session, a fork's copied entries in the original only, resumed by omp --resume <id>. The zhipu and zai Coding Plan regions get their Responses base (open.bigmodel.cn/api/v1, api.z.ai/api/v1, per their Codex docs), so Codex's requests go there as they are instead of rebuilt for Chat; Chat and Claude Code routes are unchanged. TestOmp, TestDirsMore and TestGLMCodingPlanResponses fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
grok: Codex's sub-agent tools reach a Grok subscription (#404) — Codex groups spawn_agent, wait_agent, send_message and followup_task in its collaboration namespace and set_thread_title in codex_app, and since
aa08863d12 a namespace was left out whole, as Grok's backend turns a request away over the type: multi_agent_v2 on Grok had no tools to spawn with. A namespace's functions now go to Grok as functions of their own under the flat name the translation path gives them (collaboration__spawn_agent, flatName, now provider.FlatName), and so do a call to one handed back and a tool_choice naming one; the namespace type itself still never reaches Grok, nor does a freeform tool (apply_patch, or one inside a namespace), and a namespace with no function goes whole. Responses Lite's "functions" namespace stays plain names. On the way back a call Grok makes to a flat name reaches Codex under its own name and namespace with an empty encrypted_function_args, as callTo gives one, streamed (output_item.added/done, response.completed) or not. A ChatGPT account still gets the namespace as Codex sent it. TestGrokGetsNamespacedTools (a fake Grok upstream returning a call to collaboration__spawn_agent), TestGrokBodyFlattensNamespacedCalls and the updated TestGrokBodyLeavesOutCustomTools fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestUserPath, a load flake, passes alone).
routing: a group can be routed by hand, its model picked on its card (#317, @azir12345: pick the channel as CC Switch does) — a group's routing may now be Manual: every request goes to the one member picked (pick in the group's JSON; the first when none or one no longer in it), over that member's own accounts or keys, and the others and the rules wait (kept, not dropped) until another routing is chosen; none takes over when it fails. On Routing, a manual group's card lists its models as pills, the picked one marked by a dot (role radio), and clicking another saves the group with that pick without opening its editor or moving the page; its rules tag says they wait. The editor offers Manual beside the other routings.
magpie group pick <id> <model> (or group set <id> pick=<model>, a bare model id when it names one member) picks from the CLI; magpie groups and group show mark the pick, the TUI shows "manual → model". A renamed provider or model renames the pick. TestManualGroup, TestGroupPick and routing-manual.test.cjs (Chromium, WebKit, en/zh) fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; click-scroll, old-webkit, routing-levels, routing-steady, panel-routing, settings-groups pass.
groups: a routing group's own id keeps a model's dots (#968) — a group named GPT 6.1 Sol was group/gpt-6-1-sol, so a harness pricing or tuning by the model's name didn't know it (dbydd). provider.GroupSlug is Slug with the dots kept (a run of them one, none at the ends); SaveGroup, RenameGroup, the backup's import and export, the GUI's save and rename, the CLI's group add and edit, and the Routing editor (groupSlug) derive and check a group's id by it. The ids magpie finds stay Slug's (auto-gpt-6-1-sol), so no id saved before changes, and an agent sending the bare name gpt-6.1-sol reaches a group of that id first (GroupFor). groupid_test (ids, save, rename, bad ids, found ids unchanged) and gateway's TestDottedGroupID (group/gpt-6.1-sol and the bare name served, falling through as the group's order says; -race -count=5) pass and fail without the fix; group-id-dots (hint and Add's body, groupSlug cases) passes in Chromium and WebKit, en and zh, and fails without it. The other group-* GUI tests, agent-model-groups and i18n-keys pass, except group-arrange, whose Chromium cases fail the same on origin/main (mouse.move: Invalid parameters at line 165). go test . provider/gui/gateway/agent/tui, vet, linux/windows builds pass.
gui: a model with a fast mode is sent fast, or not, from its row in the agent's picker (#954, Gyarados4157) — Fast was only a routing group member's (Group.Fast), so a GPT on a ChatGPT account picked straight in Codex or OpenCode could only go fast through a group of one. A model provider.CanFast says has a fast mode (a ChatGPT account's GPT, an OpenAI key's Responses GPT/o-series, Cursor's -fast, Claude Opus on an Anthropic key) now has a bolt beside its star in the agent's picker and in the same picker opened from a provider's row; a click posts /api/agent-fast and switches it for that agent (settings FastPicks, by agent, "<provider>/<model>", moved by a provider rename and kept by the Settings page's save) without changing the pick or closing the picker; a refusal puts it back. The field shows a bolt after a model sent fast. The gateway asks for the vendor's fast mode (service_tier "priority" / speed "fast", as for a group member) on that agent's requests for that entry, every account and key of it, not its fallbacks, another agent's requests or the same model at another provider; the Routing trace marks the try Fast. A routing group is never switched: its members say it. zh/ja/de added. fast-pick (window and 440px panel, en/zh/ja/de, Chromium and WebKit; the bolt only on models with a fast mode, the post, the refusal, the provider row's picker) passes and fails without the fix; TestFastPick (gateway, Chat/Responses/Anthropic in, fails with the gateway hunk off), TestSetFastPick, TestFastPickOptions and the settings-keep test (fails without the carry) pass with -race; i18n-keys, gui-ja, gui-de, favorite-star, agent-models-picker, claude-picker-fold and click-scroll pass; go test ./internal/gui/ ./internal/provider/ ./internal/agent/ ./internal/gateway/ ./internal/settings/ pass.
gateway: a routing group's own reasoning levels are the most its members are asked for, whoever asks (#671: 我在设置里限制了最高到high,但是他突破了这个限制)
routing: a group in a group is routed by its own routing whatever the outer group's, and an account with five hours and no week is ranked by its five hours (#576: 只有五小时额度的账号在智能/重置前用完下是否合理;嵌套路由组时上层路由是否会干预下层) — a group routed other than In order (Smart, In turn, Least used, Weekly pace, Manual) put every account and key of every group in it into one pool and weighed them together, so A (Smart) holding A-1 and A-2 (each In order) ranked all five of their accounts and keys by Smart and A-1's and A-2's own In order did nothing; only an outer In order let each inner group route itself. planLevel now plans each group in the group by its own routing, whatever the outer's, and weighs it as one beside the outer group's own models' keys, by the first of its own order that isn't resting; where that lands, the inner group is tried whole in its own order, the trace in the same order. As for the five hours: Smart already ranked such an account (Claude Enterprise) by its five hours, the biggest window it has, so ahead of every week renewing later, but a five hours not started (no reset told) counted as a reset not known and went after every week; Allowance.Renewal now has a window not started that says its span renew that long from now, which Smart sorts by (the trace's renews unchanged). Weekly pace took an account with no week as a fresh week with the share used (100/168 per hour), so a week with 80% left and five days to go went ahead of an Enterprise account whose five hours were being lost; it now goes by what its five hours have left per hour until they renew, the whole span when not started, except where the reading may leave a week out — a Claude account only heard of from Claude Code's rate_limit_event, never read whole by /usage (QuotaWindow.partial, claudeUsageEntry.whole) — which keeps the fresh week. The group editor's Smart and Weekly pace hints and the trace's say how an account with five hours and no week is ranked, a hint under Routing says a group among the models keeps its own routing, and the docs (en, zh) say both. TestGroupInGroupKeepsItsOwnRouting (Outer rotating over two In order groups: b c d a without the change, c d a b with it), TestSmartFiveHoursAlone (a five hours not started went after a week in two days), TestFiveHoursAlone, TestClaudeHeardOnlyIsPartial; TestWeeklyPace and TestAllowancePace now pin the five hours alone. settings-groups, group-member-fast, group-found-off, routing-idle-frame pass in WebKit. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState, TestProvidersAnswerWhileListsComeIn, TestUserPath, TestClaudeSignedOut flaked; the last passes 3/3 alone).
group: a routing group's members can be patterns, so a group follows a provider's catalog (#766: magpie group add my-free-pool models='openrouter/*:free,opencode-zen/*-free') — a group names its models one by one, so a pool of a vendor's free models went stale as the vendor added and dropped them. A group now keeps patterns apart from the models it names (Group.Match): a glob, * any run of characters (/ too) over the provider/model id in any case, or re:<regexp> over the whole id. groupsIn, which every reader goes through (Groups, FindGroup, /v1/models, MemberGroups, GroupsWith, RenameGroup, rules, Off, Pick, the GUI, TUI and CLI), puts after the named models every enabled provider's model a pattern matches, in catalog order, deduplicated, the named one keeping its place; a pattern never matches a group. What was matched is derived (Group.Matched) and never stored: store() and SaveGroup keep only the named members, so a save never freezes matches. Off and rules may name a matched model. A group with only patterns is allowed; one matching nothing routes nowhere and says so: magpie group <id> lists each pattern with how many models it matches now ("matches nothing now" in amber), the group list and the TUI too, and the GUI card shows a line per pattern with its count. CLI: models= takes patterns among the ids, models+= adds them (naming a matched model moves it to where it is named), models-=<pattern> drops one, models-= of a matched model is refused naming its pattern; the help documents patterns. GUI: the editor has a Patterns box (Add pattern / Remove, validated as the server does), matched models follow those named marked "by pattern" with their off switch and no Remove, and saves send members without the matched ones plus match; zh strings added. Tests: TestGroupPatterns, TestGroupPatternMembersOffAndRules, TestGroupPatternMatchingNothing, TestPatternSyntax (provider), TestPatternGroupRoutes (gateway: a request reaches a matched model, a model a provider adds joins, Off on a matched one, /v1/models lists the group), TestGroupPatternsCLI, and group-patterns.test.cjs (WebKit, en+zh); they fail on the old code (the Go ones don't build, the GUI one finds no pattern lines). GUI: group-patterns, group-found-off, group-member-fast, group-member-off, settings-groups, agent-model-groups, remote-group-served, routing-manual pass on WebKit; group-from-model-add has the same 2 failures (600x420) as origin/main. A request to a group whose patterns match nothing now is a 404 naming the group and its patterns (emptyGroupError), not 'magpie knows no model' and every model; TestEmptyPatternGroupSaysSo. With the owner's real config under a sandbox HOME: group/ds-pool of models='deepseek/*' listed deepseek-flash and deepseek-v4-pro by pattern, stored only the pattern, was in /v1/models and answered from deepseek-flash; none-pool of 'nobody/*' said its pattern matches nothing. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
group: a routing group's members can be patterns, so a group follows a provider's catalog (#766: magpie group add my-free-pool models='openrouter/*:free,opencode-zen/*-free') — a group names its models one by one, so a pool of a vendor's free models went stale as the vendor added and dropped them. A group now keeps patterns apart from the models it names (Group.Match): a glob, * any run of characters (/ too) over the provider/model id in any case, or re:<regexp> over the whole id. groupsIn, which every reader goes through (Groups, FindGroup, /v1/models, MemberGroups, GroupsWith, RenameGroup, rules, Off, Pick, the GUI, TUI and CLI), puts after the named models every enabled provider's model a pattern matches, in catalog order, deduplicated, the named one keeping its place; a pattern never matches a group. What was matched is derived (Group.Matched) and never stored: store() and SaveGroup keep only the named members, so a save never freezes matches. Off and rules may name a matched model. A group with only patterns is allowed; one matching nothing routes nowhere and says so: magpie group <id> lists each pattern with how many models it matches now ("matches nothing now" in amber), the group list and the TUI too, and the GUI card shows a line per pattern with its count. CLI: models= takes patterns among the ids, models+= adds them (naming a matched model moves it to where it is named), models-=<pattern> drops one, models-= of a matched model is refused naming its pattern; the help documents patterns. GUI: the editor has a Patterns box (Add pattern / Remove, validated as the server does), matched models follow those named marked "by pattern" with their off switch and no Remove, and saves send members without the matched ones plus match; zh strings added. Tests: TestGroupPatterns, TestGroupPatternMembersOffAndRules, TestGroupPatternMatchingNothing, TestPatternSyntax (provider), TestPatternGroupRoutes (gateway: a request reaches a matched model, a model a provider adds joins, Off on a matched one, /v1/models lists the group), TestGroupPatternsCLI, and group-patterns.test.cjs (WebKit, en+zh); they fail on the old code (the Go ones don't build, the GUI one finds no pattern lines). GUI: group-patterns, group-found-off, group-member-fast, group-member-off, settings-groups, agent-model-groups, remote-group-served, routing-manual pass on WebKit; group-from-model-add has the same 2 failures (600x420) as origin/main. A request to a group whose patterns match nothing now is a 404 naming the group and its patterns (emptyGroupError), not 'magpie knows no model' and every model; TestEmptyPatternGroupSaysSo. With the owner's real config under a sandbox HOME: group/ds-pool of models='deepseek/*' listed deepseek-flash and deepseek-v4-pro by pattern, stored only the pattern, was in /v1/models and answered from deepseek-flash; none-pool of 'nobody/*' said its pattern matches nothing. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: a lone provider's Claude or GPT reasoning streams as it comes, not held for a refusal no one else could answer (iTianbao on X: 请加一个开关,让思考实时透传; through a relay Claude Code showed the thinking only all at once with the text, Codex none until then) — holdWriter held Claude's, GPT's and Gemini's reasoning (refusesAfterThinking, up to holdThinking) on every candidate, so a refusal after it could go to the next one (#248), but a refusal on the last candidate is never asked again (the !last checks in serve), so there it only delayed the thinking. On the last candidate Claude's and GPT's reasoning is now shown at once (thinkingShown), and a refusal after it reaches the agent as the vendor's own stop; Gemini's stays held there, for a reply that only reasoned to be asked again (#667, TestGeminiEmptyRepliesAskedAgain). A group with members after it still holds as before. Before: on a slow fake relay (4 thoughts 300ms apart, then text) every relay shape (Messages, Chat with reasoning_content and reasoning, Responses) for claude-sonnet-4-5 and gpt-5.1 gave Claude Code's thinking_delta and Codex's reasoning_summary_text.delta all at ~1.2–1.5s with the text; after, 0/300/600/900ms. TestThinkingStreamsWithNoOneElse (Messages, Chat for Claude and GPT, Responses; each asked from /v1/messages and /v1/responses) fails without the change in every case and passes; TestHeldThinkingKeepsTheAgentAlive's nobody-left case now expects the thinking and Claude's refusal rather than a held error. -race -count=5 on TestThinking*, TestRefusal*, TestHeld*, TestGeminiEmpty*; go test -tags nogui ./internal/gateway, go vet, gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay: whether it sends GPT's reasoning at all (OpenAI's Chat API doesn't) is unknown, and no switch was added. docs/subsystems/gateway-routing.md says it.
usage, gateway, gui, provider, cli: the tests pass when a run crosses midnight, and a usage answer reads the clock once (#1046)
usage, gateway, gui, provider, cli: the tests pass when a run crosses midnight, and a usage answer reads the clock once (#1046)
group: a routing group takes images when one of its members sees them, and an image goes to the members that do (#756, HuHong-Tao on v0.1.813: group/auto-deepseek-v4-1-flash turned 1198 image requests away with 400 "model does not support image input" in a day, where commandcode's deepseek-v4.1-flash of the group took them) — groupEntries said a group took images only when every member did (Images ANDed, ImageInput through sharedImageInput, where one explicit text-only answer wins), and the gateway's membersImageInput said the same of a request, so one text-only member turned away every image before any member was tried, and /v1/models gave the group input ["text"]. Since a rule for images can send one past a text-only member, the gateway already dropped text-only members for a request with a new image; it now does so for every group without a model to describe images: a new image goes to the members that see it (an explicit answer, or models.dev's estimate as blindTo reads it for a model of its own), to members magpie knows nothing of when none is known to see, and is turned away (400) only when every member is text-only. With a describer the members keep their order, a text-only one given the image described, as before. groupEntries ORs Images and takes ImageInput through anyImageInput (true when a member's is, false when all are, else unknown); membersImageInput drops the rule's members (the rule's member first changes nothing: one that can't see is passed over). With the owner's real accounts (a copy of the config, Vision off): an image to group/auto-deepseek-v4-1-flash was 400 on origin/main and is answered by the Devin account's deepseek-v4-1-flash ("White"); group/auto-kimi-k3, whose only member that sees is an OpenCode Go key without a subscription, went from 400 to that member's 403. TestGroupImages (a group with one member that sees takes images, all text-only doesn't), TestGroupImagesFromAMemberThatSees, TestKnownTextOnlyModelRejectsImagesBeforeUpstream (an ordered group with its text-only member first sends the image to the one that sees), TestRuleImages and TestRuleImagesUnlistedMember now expect the image answered by the member that sees; they fail on the old code. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: a Gemini functionResponse's own parts reach the upstream — the image agy's view_file read (#1038, handong66: agy via magpie can't see images, the model makes up what they show)
gateway: a provider that turns Codex's image tool away with a bare 400 is asked again without it, and isn't offered it again once that works, so a plain chat no longer breaks on it; a ChatGPT sign-in, openai.com and a Codex backend that takes it still get it (#949, cjyrainbow)
gateway: a provider that turns Codex's image tool away with a bare 400 is asked again without it, and isn't offered it again once that works, so a plain chat no longer breaks on it; a ChatGPT sign-in, openai.com and a Codex backend that takes it still get it (#949, cjyrainbow)
accounts: Codex or Claude Code can be kept signed in to any of its accounts, not only the first, while the gateway still tries them in their order (Koohoko on #524: 建议不只是支持始终登录"首选"账号,应该可以支持始终登录任一选定的账号。有时候首选只是为了按顺序先消耗它的额度,不一定是用户期望的常用登录账号) — making an account first did two things at once: the gateway tried it first (the account the agent is signed in to always stood ahead of the others, AlsoOn behind it), and Codex or Claude Code was signed in to it (SwitchLogin). "Keep {agent} signed in to the first account" (
817a5f50) only stopped magpie moving the sign-in on, so a user who wanted one account's allowance spent first had to sign the agent in to it too. Now beside that tick is a pick, "Keep {agent} signed in to [the first account ▾ | each saved account]" ({agent} 始终登录 [首选账号 ▾]): picking one keeps Provider.KeepLoginAs (providers.json, kept across an editor Save like KeepLogin; POST provider/keeplogin with keepLoginAs) and signs the agent in to it at once, the order the gateway went by then — the one signed in to first — written as the accounts' own first, so the first stays first. While it is kept: the login switcher signs the agent back in to it whenever it is on another and never moves it off, however used up (NextLogin); a drag or Make first sets the order without a sign-in (arrangeLoginsAs), Make first posting provider/arrange; the gateway puts the account signed in to at its own place in that order, not first (perKeyBarred by Provider.LoginRanks), so In order spends the first's allowance first; InUseLogin (the tray's in-use account) is the first in use in the order. The rows keep their order, the first saying First and the one kept saying Signed in (已登录), an account not in use ticked on by its dot rather than a Use that would sign the agent in; the Routing note says the agent stays signed in to that account and requests go in the accounts' order. Picking "the first account", or unticking, lets it go and signs the agent in to the first again, so the one signed in to is the first once more. TestKeepLoginAsChosen (signed in to the chosen at once, the order kept, stays at 100%, moved off it goes back, a drag changes the order not the sign-in, the first's again when let go) and TestKeptLoginStandsAtItsPlace (through the gateway In order: kept on spare, the first, acct-1, is tried first; acct-2 without the change) are new; keep-login.test.cjs now also picks an account (the post, First and Signed in, Make first arranging, back to the first, every string's Chinese, the page not moving) and passes 8/8 in WebKit; account-return, routing-account-order, account-arrange and click-scroll pass in WebKit. No live agent config, keychain or vendor was touched. go vet ./internal/..., go build (darwin GUI), go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: a gateway key can be held to some accounts (#905, LCorleone) — a key could burn any account a provider had: two coding plans of one vendor, and a key given to someone had them both. A key now keeps an optional list of accounts and keys (caller-keys.json "accounts", none = every account, so older files read as before): "<provider>/<account>", an account by its stable id — the login's, set once it is written and kept through renames (logins.json's savedLogin id, written by every write and stood in for by one made of the name until then, so an entry made before stands), a key by its fingerprint — every one a provider has when the list is set, and one gone later (a key rotated, an account signed out) kept as it is, matching nothing, holding the key closer, never wider. The command line and the menu name an account by who is signed in and keep its id. Routing holds a plan to the key's accounts beside its models: a candidate whose account the key may not use is left out — through a routing group the key names no less, for naming a group is not naming its accounts — the plan's order keeping step and its left-out list marking what the key held out (Weighed's held); magpie's own calls for the key — a web search, a picture described, a Codex title — are of the models the user picked, so the models don't hold them, but their spend lands on an account all the same, and the accounts hold them too. When every account or key a model has is one the key may not use, the request is refused with a 403 in the API's own error shape naming the model and the accounts the key may use, logged as a rejected call: after a usage cap's 429 and before an account barred for the model's, itself before the no-member 404. /v1/models and the Anthropic and Gemini lists show the key only the models an account or key it may use serves (a provider's account set not to serve one, #474, is none of the key's); a provider with no account and no key (a local ollama) is one the key names no account of — its models stay listed and its asks pass — while an entry kept after its provider lost its last account or key serves none, holding the key closer. The embeddings, images and videos endpoints hold their candidate to the key's accounts the same way, and counting tokens counts on those alone (the local estimate where it may use none). System One asks its decision model of an account or key the key may use — and sends on that key, of those in use, not the provider's first, which a key held to a later one was spent on; the videos and images keyed paths send on one the key may use too, held to a later key no longer refused for the first alone. Set it in the app's menu the models are picked in — the accounts after the models, an account by who is signed in with their plan in the note, a key by its fingerprint, what the CLI kept that no provider has now kept as "Not signed in now" — or
magpie gateway-key accounts <id> codex/me@example.com openai/<key id> (all takes it off); gateway-key list has an ACCOUNTS column. en/zh/ja/de, the German "{n} accounts" main already had left to it rather than written twice. The images API's fan-out over a subscription's accounts is held to the key's accounts as one: one of the key's whose plan refuses to draw no longer hands the pen to an account it may not use, and the second in the order, when it is the key's, draws what the first may not (drawOnAccounts filters the candidates, the account pinned to with the account header told plainly when the key may not use it). The Routing view's left-out list says an account the key held out for what it is, not that its plan lacks the model, in Chinese, Japanese and German too; the key's badge names its accounts by who is signed in, not by the stable ids it keeps. (#932)
usage, gateway, gui, provider, cli: the tests pass when a run crosses midnight, and a usage answer reads the clock once (#1046)
usage, gateway, gui, provider, cli: the tests pass when a run crosses midnight, and a usage answer reads the clock once (#1046)
gateway, provider, gui: a Sub2API key given 5h/1d/7d limits is weighed and rested by its own windows, like an account (#1004)
gateway: a key held to some models counts no tokens and decides on none it may not use (#882, LCorleone) — counting tokens asked a provider's help or estimated locally whatever the key was held to, and System One asked its decision model of any key. Both now meet the hold the model endpoints do: count_tokens (Anthropic's shape) and Gemini's :countTokens — which the gateway answers itself, so no shared path covered it — resolve the model named and are refused with a 403 in the API's own error shape when the key may not use it, a group judged by its members, a bare group's name and an auto group's stand-in resolved first (countHeld in key_models.go); System One is held to the decision model its prefix names, after RouteDecider. TestGatewayKeyModelsHoldCountTokensAndSystemOne (403s by id, bare name and through a group on both counting APIs, Anthropic's and Gemini's error shapes, a held count or decision reaching no provider, an allowed model counted, a free key going on, the decider among the key's models) fails without the change. (#902)
gateway: a key held to some accounts may not compact on the sign-in Codex is on either, the last path #967 left open (a follow-up to #967, on the maintainer's invitation, LCorleone) — /responses/compact for one of Codex's own models relays as it came, deliberately (#876): the encrypted history it carries is readable only by the ChatGPT backend that sealed it, so native compaction keeps its own sign-in. But that relay spent the account Codex is signed in to with nothing of the calling key asked, so a key held to some accounts (#905) could spend an account it may not use through compaction alone, after #967 held its turns. compactSigninHeld asks, before the relay, whether the sign-in (resolved as codex/<model>, the id the catalog gives Codex's own) is one of the key's accounts — and fails closed, as the review asked: a Codex switched off in magpie, or a sign-in nowhere resolvable, is refused as an account the key may not use is, the relay reaching the sign-in all the same; the one allowance is Codex signed in with its own API key, told from auth.json's auth_mode (provider.CodexAPIKeySignedIn — the request's own Authorization can't tell it, magpie's token already in it for a keyed request). A refusal carries the API's own 403 shape naming the model and the accounts the key may use, and the two ways out: add the signed-in account to the key, or use one of magpie's models, whose compaction goes through a compaction_trigger on /responses and the key's accounts — native compaction has no other route, its history bound to that account. The account in use compacts on it as it always did, the relay untouched; a key that names no Codex account (AllowsAccount's rule), one held to some models alone, and a free key are relayed as they were. TestGatewayKeyAccountsHoldCodexCompact (held to a spare account: refused with nothing asked of OpenAI; held to the sign-in: the compaction relays as it came; naming no Codex account: allowed as ever; a free key: unchanged; and the provider switched off: refused with nothing upstream, the review's repro) fails without the gate and passes, TestGatewayKeyCompactOnCodexAPIKey pins the allowance; go test ./internal/gateway/ ./internal/provider/, go vet, linux(nogui)/windows(nogui) builds pass. (#976)
providers: a Kilo Gateway preset serves Kilo Code's free models with no key (lml on Discord: the community plugin's free models come from OpenCode Zen, KiloCode Gateway and Cline) — Kilo's gateway is OpenRouter's API at https://api.kilo.ai/api/openrouter (KILO_OPENROUTER_BASE in Kilo-Org/kilocode packages/kilo-gateway; /api/gateway is the same), its list GET {base}/models in OpenRouter's shape with an isFree flag, readable with no key, and its docs (kilo-docs gateway/authentication.md) and backend (Kilo-Org/cloud web-shared ai-gateway/handlers/llm-proxy.ts, is-free-model.ts: kilo-auto/free, ":free", openrouter/free, stealth/*-alpha, Kilo's own free models) serve a free model to a caller with no credential, by IP, 200 requests an hour. The new "kilo" preset (Kilo Gateway, NoKey) is Ready with no key: its list is the gateway's own, asked as the Kilo CLI asks it, the free models alone without a key and every model with one, the free ones (isFree, or free by id) marked Free and priced at nothing in ListPrice; a model that says it takes no tools, or that makes no text (Lyria), is left out. Requests to Kilo's host (the gateway's and the Test button's) carry the Kilo CLI 7.8.3's headers (packages/opencode session/llm/request.ts, kilocode/const.ts, kilo-gateway headers.ts): HTTP-Referer https://kilocode.ai, X-Title Kilo Code, User-Agent Kilo-Code/7.8.3, X-KILOCODE-EDITORNAME "Kilo CLI 7.8.3", and on a chat x-kilocode-mode code with the conversation as its task (X-KILOCODE-TASKID, x-session-affinity, X-Session-Id, a ses_ id as for OpenCode), which the gateway keys its prompt cache on; no other provider gets them. Without a key no Authorization is sent: Kilo's clients send "Bearer anonymous", which the gateway reads as no credential only until no released client sends it (tokens.ts ANONYMOUS_BEARER_SENTINEL, "Delete this constant once no released client sends it"), and any other bearer it can't verify is refused with 401 rather than served as anonymous; pi-freeflow measured that a placeholder bearer gets 401 INVALID_TOKEN and no Authorization gets 200 on every free model. A key, when given, goes as a Bearer and reaches the paid models. The key field can now say more than "optional for local servers" (PresetDef.KeyHint: "optional: free models need no key", 可不填:免费模型无需密钥); Kilo's icon is its own (kilocode packages/ui provider/kilo.svg, MIT). Only Chat is set; Kilo's Responses and Anthropic routes are not used. One GET of the public /models was made while looking (399 models, 18 free); no chat request was made to Kilo. TestKiloModels (a fake gateway: asked as the CLI with no Authorization, the free models alone without a key and all with one, a model free by its flag priced at nothing), TestKiloClient and TestKiloClientHeaders (the CLI's headers and no Authorization on a keyless request through the gateway, the key on a keyed one, none of them on another provider) fail without the change. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/... pass (TestClaudeRunEndsWhenTheClientLeaves, TestUserPath and TestClaudeSignedOut failed once under the full run's load, pass on the rerun; none touches Kilo).
agents: Kimi Code offers a model's thinking levels, and the level picked reaches the model (#333) — the new Kimi Code (2.x) builds its thinking picker from a model's support_efforts and default_effort, which magpie never wrote, so it offered thinking only on or off. magpie now writes the model's levels (none left out: Kimi Code's Off is that) and default_effort high when the model has it, to ~/.kimi-code only; the old kimi-cli's schema has neither. Kimi Code sends the level inside thinking ({"type":"enabled","effort":…}), not as reasoning_effort, so the gateway moves it there on a Chat request that doesn't say reasoning_effort itself, and fits it like any other agent's. Checked with the real kimi CLI (2.1.1) in a sandbox HOME against a fake upstream: kimi doctor passes, /model shows Off Low Medium High Max, a pick reaches upstream as reasoning_effort; unknown values don't make it reject the file. TestKimiEfforts and TestKimiCodeEffortReachesChatVendor fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
gateway: a tool schema with an enum and no type goes to Kimi with the type its values have (#886) — Kimi Code refused a request whose tools had cua-driver's parse_visual_regions, 400 "tools.function.parameters is not a valid moonshot flavored json schema … At path 'properties.options.properties.kinds.anyOf.items': type is not defined": its items were {"enum":["text","icon"]}, and the whole request failed though the tool was never called. To Moonshot and Kimi Code (api.moonshot.ai/.cn, api.kimi.com/.ai, or a moonshot/kimi preset) each schema in the tools with an enum or a const and no type is now given the type its values all have (string, integer, number, boolean), written in place after its brace, through properties, items, anyOf/oneOf/allOf, $defs and the rest; values of mixed types or with a null are left as they were, and other providers are sent the tools unchanged. TestKimiToolEnumTypes fails without the change. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
gateway: a tool schema with an enum and no type goes to Kimi with the type its values have (#886) — Kimi Code refused a request whose tools had cua-driver's parse_visual_regions, 400 "tools.function.parameters is not a valid moonshot flavored json schema … At path 'properties.options.properties.kinds.anyOf.items': type is not defined": its items were {"enum":["text","icon"]}, and the whole request failed though the tool was never called. To Moonshot and Kimi Code (api.moonshot.ai/.cn, api.kimi.com/.ai, or a moonshot/kimi preset) each schema in the tools with an enum or a const and no type is now given the type its values all have (string, integer, number, boolean), written in place after its brace, through properties, items, anyOf/oneOf/allOf, $defs and the rest; values of mixed types or with a null are left as they were, and other providers are sent the tools unchanged. TestKimiToolEnumTypes fails without the change. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
gateway: an upstream that answers 200 with a web page, with nothing at all, or with JSON that isn't, has failed as a 502 does, so a group asks its next member (#1012, cth123456: Codex on group/medium, agentrouter/deepseek-v4-flash now and then answered 200 text/html; the log said status=200 done=true in=0 out=0 served=null, codex/gpt-6-luna was never asked, and Codex failed with "stream disconnected before completion: stream closed before response.completed") — the candidate loop judged a try by its status alone, and holdWriter passes a 200 that is neither text/event-stream nor application/json straight to the agent, so the page went to Codex as the reply. notAnAPIReply now reads every 2xx (but 204) an upstream gives, in forwardOnce (every relayed and translated request, every API, plugins included) and in the built-in clients' own requests (the ChatGPT backend's /responses, Kiro, Zed, Qoder, Devin, Cursor, Command Code): a text/html reply, a body that begins as a web page whatever its Content-Type, an empty body, or one sent as JSON that doesn't begin with { or [ is turned into the 502 it stands for, with a JSON error saying what came back ("answered 200 OK with a web page (text/html), not an API reply: “Just a moment...”", the firewall hint for a block page). It then fails over, rests a minute and is logged as any 502 before the reply begins does; a provider with nobody after it hands the agent that 502 rather than the page. Only the first bytes are peeked, which a stream waits for anyway; a read that fails is left to the reader, a compressed body and anything else as it came. TestNotAnAPIReplyFailsOver (Chat, Responses and Messages, streamed and not, a member answering a Cloudflare page as text/html, as JSON and as SSE, an empty body as JSON and as SSE, and plain text as JSON; the next member must answer and the log name why) fails without the change in all 36 cases (the page relayed to the agent, or b never asked, or no reason logged) and passes; TestNotAnAPIReplyAlone (a 502 with the reason, logged 502) and TestCodexBackendWebPage (the ChatGPT backend's page reached Codex as a 200) fail without it and pass; TestAPIReplyUntouched keeps JSON after whitespace, SSE with and without a Content-Type, gzip, XML, a 204 and a 404 as they came. -race -count=10 on them; go test -tags nogui ./internal/gateway ./internal/provider, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay (agentrouter) itself; the page is a Cloudflare challenge's, as the issue has only its Content-Type. docs/subsystems/gateway-routing.md says it.
remote magpie: another computer makes videos with the video models a shared magpie has, as it draws with its image models (#545: 图像生成能否也提供给局域网中其他终端) — drawing from another computer already worked both ways, a plain client with the gateway's key at its /v1/images/generations and a magpie with it as a Remote magpie (since v0.1.555), but a Remote magpie's videos did not: its list never gave them, and /v1/videos turned away any provider but a Grok subscription. A magpie now asks its Remote magpie's list for them too (X-Magpie-Videomakers), the other lists them marked kind:video (a magpie that doesn't ask isn't given them, as it would take grok-imagine-video for an image model), and they are kept apart from the models to chat or draw with (catalog.Model.Films). A video with one is started at the other's /v1/videos, its id there carried, base64, in this magpie's video id, and asked after and fetched at its /v1/videos/{id} and /content. TestRemoteMagpieVideos fails without the change (no video models listed); TestLANDraws covers images from another computer both ways through lanGuard with a caller key. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui of gateway, provider, catalog and imagemcp pass.
gateway: a request a proxy or tunnel on this computer forwards to loopback is answered as one from another machine, and needs the gateway key (#1022, znjhahaha: asked for a Cloudflare Tunnel in magpie, with the gateway key checked for requests through it) — local() was the request's address alone, so cloudflared, ngrok, Tailscale serve or funnel, frp's HTTP proxy, or a Caddy or nginx here, all reaching 127.0.0.1:3425 for clients anywhere, were taken for this computer's agents: whoever had the tunnel's address used the user's subscriptions with any token, read /v1/magpie/quotas (the accounts and their balances) and the remote cards, and their usage was counted as local, all without sharing on. reference.md said a proxy here had to sign its clients in itself; a quick tunnel signs in nobody. A request on loopback carrying a forwarding header (Forwarded, X-Forwarded-For, X-Real-IP, Cf-Connecting-IP, True-Client-IP, Tailscale-User-Login), which no agent sends, is now proxied, and local() is false for it, so every check built on local() takes it as from the network: lanGuard refuses it while magpie isn't shared, saying it came through a proxy or tunnel and that Settings → Share on local network and a gateway key let it in; shared, it needs an enabled gateway key and is then sharedWith, as a LAN client is, for the model APIs, quotas, remote cards, concurrency, session route and MCP sign-ins; the Omarchy theme and the Codex title link stay this machine's only, and its usage is no longer marked local. A gateway MAGPIE_ADDR opens to the network stays open to a tunnel as to anyone; one MAGPIE_ADDR keeps on loopback is closed to it. MAGPIE_TRUST_PROXY=1 says a proxy here signs its clients in itself and keeps what it forwards local, as before (listed in agentenv_test's notAnAgent). A tunnel forwarding bare TCP (frp tcp, ssh -R, socat) adds no header and can't be told from an agent: reference.md says to point one at the LAN address with sharing on. Running cloudflared from magpie with a pasted Tunnel Token is not done here: magpie would have to download or run a program it doesn't ship; reference.md says how to serve magpie through a Cloudflare Tunnel today. TestProxiedLoopbackNeedsTheKey (from 127.0.0.1 with cloudflared's, ngrok's, nginx's, RFC 7239's, Akamai's and Tailscale serve's headers: refused unshared, 401 shared without a key, with magpie's token or a wrong key, let in with the key under its identity, a disabled key refused; an agent on loopback let in with any token; MAGPIE_TRUST_PROXY; MAGPIE_ADDR on loopback and open) and TestProxiedLoopbackRoutes (quotas, quotas history, models, Omarchy and /mcp through a tunnel, unshared and shared, with and without the key) fail on main (200 for every tunnel unshared) and pass -race -count=10 with TestLANGuard, TestQuotasOverLAN, TestManagedLANGuard and TestExplicitOpenGatewayAcceptsAllRemoteTokens; go test -race -tags nogui ./internal/gateway (579 s, under -timeout 30m) and go test -tags nogui ./internal/... under a temp HOME pass; go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. No GUI change. Not tried with a real cloudflared (not installed here); its Cf-Connecting-IP and X-Forwarded-For are what Cloudflare documents it sends.
gateway: the model list comes as ids one a line with ?format=text, to paste into ZCode's custom provider from a magpie on another computer (悠悠哥 on Discord, v0.1.671: ZCode pointed at magpie on a NAS, http://192.168.0.200:3425, Anthropic Messages, never got the model list, while at 127.0.0.1 it did) — ZCode (3.14.3) asks a custom provider for no list at all: its settings take the models typed one a line ("模型列表 · 每行一个模型名称"), its "智能配置" matches built-in metadata by id and base URL, and neither its desktop app nor its CLI requests /models anywhere. What filled it at 127.0.0.1 was a magpie on that computer, which writes its models into ZCode's own provider_config.json (personalModelIds) and config.json under the provider "magpie" at its own 127.0.0.1 gateway; a magpie on a NAS can't write that computer's files, so the provider pointed at it is left with what was typed. The gateway itself answers another computer as it answers this one (no Host check, the key read wherever the client puts it,
1c047f36 in v0.1.664). GET /v1/models?format=text (and /models) now answers text/plain, the ids in the list's order one a line, so a browser on that computer opening http://<nas>:3425/v1/models?format=text&key=<gateway key> gets what to paste into ZCode's model list; without a key from the network it is refused as the JSON list is. TestLANModelsAsText (from 192.168.0.9 to Host 192.168.0.200:3425: text/plain, the JSON list's ids in its order, none empty or JSON; no key 401) fails without the change. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: a request a proxy or tunnel on this computer forwards to loopback is answered as one from another machine, and needs the gateway key (#1022, znjhahaha: asked for a Cloudflare Tunnel in magpie, with the gateway key checked for requests through it) — local() was the request's address alone, so cloudflared, ngrok, Tailscale serve or funnel, frp's HTTP proxy, or a Caddy or nginx here, all reaching 127.0.0.1:3425 for clients anywhere, were taken for this computer's agents: whoever had the tunnel's address used the user's subscriptions with any token, read /v1/magpie/quotas (the accounts and their balances) and the remote cards, and their usage was counted as local, all without sharing on. reference.md said a proxy here had to sign its clients in itself; a quick tunnel signs in nobody. A request on loopback carrying a forwarding header (Forwarded, X-Forwarded-For, X-Real-IP, Cf-Connecting-IP, True-Client-IP, Tailscale-User-Login), which no agent sends, is now proxied, and local() is false for it, so every check built on local() takes it as from the network: lanGuard refuses it while magpie isn't shared, saying it came through a proxy or tunnel and that Settings → Share on local network and a gateway key let it in; shared, it needs an enabled gateway key and is then sharedWith, as a LAN client is, for the model APIs, quotas, remote cards, concurrency, session route and MCP sign-ins; the Omarchy theme and the Codex title link stay this machine's only, and its usage is no longer marked local. A gateway MAGPIE_ADDR opens to the network stays open to a tunnel as to anyone; one MAGPIE_ADDR keeps on loopback is closed to it. MAGPIE_TRUST_PROXY=1 says a proxy here signs its clients in itself and keeps what it forwards local, as before (listed in agentenv_test's notAnAgent). A tunnel forwarding bare TCP (frp tcp, ssh -R, socat) adds no header and can't be told from an agent: reference.md says to point one at the LAN address with sharing on. Running cloudflared from magpie with a pasted Tunnel Token is not done here: magpie would have to download or run a program it doesn't ship; reference.md says how to serve magpie through a Cloudflare Tunnel today. TestProxiedLoopbackNeedsTheKey (from 127.0.0.1 with cloudflared's, ngrok's, nginx's, RFC 7239's, Akamai's and Tailscale serve's headers: refused unshared, 401 shared without a key, with magpie's token or a wrong key, let in with the key under its identity, a disabled key refused; an agent on loopback let in with any token; MAGPIE_TRUST_PROXY; MAGPIE_ADDR on loopback and open) and TestProxiedLoopbackRoutes (quotas, quotas history, models, Omarchy and /mcp through a tunnel, unshared and shared, with and without the key) fail on main (200 for every tunnel unshared) and pass -race -count=10 with TestLANGuard, TestQuotasOverLAN, TestManagedLANGuard and TestExplicitOpenGatewayAcceptsAllRemoteTokens; go test -race -tags nogui ./internal/gateway (579 s, under -timeout 30m) and go test -tags nogui ./internal/... under a temp HOME pass; go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. No GUI change. Not tried with a real cloudflared (not installed here); its Cf-Connecting-IP and X-Forwarded-For are what Cloudflare documents it sends.
gateway: a provider's floor on max_tokens said with >= or > is read, so a one-token probe to Command Code is asked again at 16 instead of failing with a 400 (#260 HuHong-Tao: "Expected a value >= 16, but got 1 instead." — Claude Desktop's gateway check reported the model unavailable)
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, provider: the Routing page says what a lone key or account has left, and a key bought as a plan (Kimi Code, GLM Coding Plan, MiniMax, OpenCode Go, Command Code) is weighed by its plan's windows (#1016, gphysign: Kimi Code (China) with one key on, Smart routing; the card said only "Kimi Code (China) 只启用了一个 Key,无需选择。" while /v1/magpie/quotas had its 5 hours 49 used, 51 left and its 7 days 45 used) — two gaps. weighRouted returned before reading anyone's allowance when there was one candidate, so a lone key or a lone subscription account was never known in the trace, and the Routing page, which shows a bar and "{n} used · renews in {d}" for a known row, had nothing to show; every single-account subscription said "what's left not known yet" the same way. And KeyAllowance read only a sub2api key's limits, so a key whose plan the Usage page reads (planQuotaSourceOf: Kimi Code, GLM Coding Plan and Z.ai, MiniMax Coding Plan, OpenCode Go, Command Code) was never known to routing, with one key or several. weighRouted now reads what each candidate has left before returning a lone one (one alone is still weighed against nobody). readsKeyWindows takes a plan key too; keyWindows reads it through planKeyWindows (planWindows, with Zhipu's team quota behind a key that has none of its own, as PlanQuotas does), and PlanQuotas' own read of a card is noted for routing at once (noteKeyAllowance), so the Usage page and routing never ask twice; the card kept on disk seeds it after a restart (keyCardTag keys a plan card as plan:, a sub2api one as balance:). A key at such a vendor that turns out to be pay-as-you-go, with no windows, is asked again every 30 minutes (keyNoWindowsAge), not every minute it is used. So, beyond the issue: several plan keys on one provider are now weighed by their windows under Smart, usage and pace, and a plan key the vendor refuses as out of quota rests until its window renews and comes back once read short of full, as a sub2api key does. affine no longer says a lone key or account all but used up "moved" its conversation: there is nobody to move to (at > 0). The GUI needed no change. TestLoneOneTellsWhatItHasLeft (a lone Kimi key known at 49% with both renews, the week first; a lone Codex account known; a plain DeepSeek key not), TestLoneSpentKeyKeepsItsConversation (fails with the at > 0 guard taken out), TestPlanKeyAllowance (the windows read behind the request, asked once; DeepSeek and Moonshot keys never asked), TestPlanKeyAllowanceFromItsCard (PlanQuotas' read known at once without a second ask; after a restart with the vendor down, the card on disk stands in and a failed read keeps it) and TestPayAsYouGoKeyAskedSeldom (a GLM pay-as-you-go key asked once, not two minutes on, again 31 minutes on) all fail without the change and pass -race -count=20 (provider) / -count=10 (gateway). routing-key-windows.test.cjs gains a lone Kimi Code key at 1100px and 440px (the one-key story, the row's bar at 49%, its provider row "49% used · renews in …" fitting, no sideways scroll) and passes in full on Chromium and WebKit in en, zh, ja and de; routing-quota-left and panel-routing pass. go test -tags nogui ./internal/..., go test -race on ./internal/provider and ./internal/gateway in full, go vet, gofmt -l, darwin, linux and windows (nogui) builds pass. Not tried with a real Kimi Code key. StepFun's Step Plan is read through a sign-in, not the key, so it isn't one of these. docs/subsystems/gateway-routing.md, providers-accounts.md and docs/reference.md say it.
gateway, provider tests: the tests stay off the network (testenv.Offline in their TestMains) — after
555f5706 (#1016) routing reads a plan key's quota windows, and a test whose provider has a vendor's real base URL and a made-up key would ask the vendor in the background. Routing's own read (keyAllowance, allowances) was already stubbed in routing_test.go's init, and a survey with a dial guard found no plan-key dial, but it found others: every plugin host's first start fetched models.dev's catalog (65 dials in the gateway's plugin tests), and provider tests asked api.github.com (TestSubscriptionUsageEachAccount, TestSubscriptionUsageServesStale), chatgpt.com (TestCopilotQuotaWithoutEditorsSignIn), server.codeium.com (TestDevinSignedInOnlyInMagpie) and decide.test (TestModelTest) with made-up accounts; each passed whether the vendor answered or not. internal/testenv's Offline wraps http.DefaultTransport's DialContext (so http.DefaultClient and every transport cloned from it later): localhost, *.localhost, loopback and unspecified addresses are dialled, anything else is refused before it is looked up, said once per address on stderr ("testenv: a test asked models.dev:443, refused: tests stay off the network"). internal/gateway's TestMain and internal/provider's (kiro_main_test.go, in RunIn's setup) call it before the tests run. TestLocal (the address table) and TestOffline (a loopback httptest server answers through DefaultClient; models.dev is refused through it and through a Clone made after the call) pass -race -count=3. go test -race -tags nogui -timeout 30m ./internal/testenv ./internal/provider ./internal/gateway passes under a temp HOME (gateway 516 s). The gateway and provider test binaries were then run under sandbox-exec with every non-loopback network-outbound denied, with the kernel's Sandbox reports streamed: both PASS, Offline refused only the six addresses above, and the kernel reported no denial, so no transport outside DefaultTransport reaches the network either. go vet, gofmt -l, linux and windows (nogui) builds pass. Tests only.
library: a remote MCP server signed in once in magpie is used by every agent without signing in again (#615) — a streamable-HTTP server behind OAuth (Neon, Notion, Supabase) had each agent run its own sign-in. The server's editor now has Sign in: magpie finds the authorization server from the server's 401 (RFC 9728 protected-resource metadata, RFC 8414/OIDC metadata, else the 2025-03 /authorize, /token, /register at its origin), registers itself (RFC 7591, a 127.0.0.1 redirect), opens the browser with PKCE S256 and the resource, and keeps the tokens in mcp-signins.json beside its other account secrets (0600, never the keychain). While signed in, the agents are given the gateway's /mcp/<name> for that server, without the Authorization the library has for it; the gateway passes POST (JSON or event stream), the GET stream and DELETE on with the Mcp-Session-Id and magpie's token, renews it 2 minutes before it runs out and once on a 401, one renewal per server at a time, and a sign-in the server won't renew is answered 502 "sign in to it again" (never a 401 the agent would act on). Signing out, a new URL or an SSE server gives the agents the server's own address as before; a rename keeps the sign-in and removing the server drops it. /mcp/ is local-only or LAN-shared with the key, and refuses a request with an Origin or a Host not naming this computer, so a web page can't borrow the sign-in through the browser (form posts, DNS rebinding), and isn't counted as busy or key-limited. WSL agents are given their distro's gateway address. The row says Signed in or Sign-in ran out; zh strings added. Tests: TestSignIn, TestSignInWithoutMetadata, TestSignInPage, TestNothingToSignIn, TestTokenRenewsBeforeItRunsOut (8 calls, 1 renewal), TestRenewRefused, TestRenewRefusedForGood, TestMCPProxy, TestMCPProxyStaysHere, TestMCPProxyNotForWebPages (fails without the Origin/Host check), TestSignedInServerGoesThroughMagpie against a local fake OAuth+MCP server; mcp-signin.test.cjs (webkit, en and zh). Not tried against a real Neon/Notion/Supabase sign-in. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: a reply says which provider and model answered it, in X-Magpie-Provider and X-Magpie-Model, and Settings › Models › Replies can have its model say magpie's provider/model id too (liuweifeng, #822) — a reply's model was only the vendor's own name (glm-5.3-flash), naming neither the provider nor the group's member, so pi, which counts usage by provider/responseModel, put every member's calls in one bucket. Every reply now carries X-Magpie-Provider (the provider id) and X-Magpie-Model (provider/model of the member that answered, after a failover the one that did), relayed or translated, streamed or not; the body is left as the vendor wrote it by default, since agents read it themselves (Claude Code its features, Codex its catalog). Name the member in replies (memberModel, off by default; zh, ja, de strings), or a request's X-Magpie-Response-Model: member, has the body's model say the member's id as well: Chat's model, Responses' response.model, Anthropic's message.model / model, Gemini's modelVersion, in each event of a stream as it goes and in a JSON body once whole (memberWriter, between the masking and the request log, which keeps the vendor's). Claude Code, Claude Desktop and Codex always get the vendor's name; X-Magpie-Response-Model: vendor asks for it too. The vendor's own name (dated versions included) stays in the usage ledger's Served. With the owner's real DeepSeek key in a sandbox, a group [a dead member, deepseek/deepseek-flash]: the headers said deepseek and deepseek/deepseek-flash on Chat, Messages and Responses, streamed and not; with member asked the body said deepseek/deepseek-flash in all six, with the setting on pi's token got it and Codex's token and a claude-cli User-Agent got deepseek-flash. TestReplyNamesMember (6 paths × streamed or not × default, on, asked, vendor asked, Codex, Claude Code, each after a failover; passes -race) fails on the old code, TestMemberLine; member-model-setting.test.cjs (WebKit, en and zh). settings-sections, settings-groups and searcher-pick pass on WebKit; redact-rule-row fails 2 the same on origin/main. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: a reply says which provider and model answered it, in X-Magpie-Provider and X-Magpie-Model, and Settings › Models › Replies can have its model say magpie's provider/model id too (liuweifeng, #822) — a reply's model was only the vendor's own name (glm-5.3-flash), naming neither the provider nor the group's member, so pi, which counts usage by provider/responseModel, put every member's calls in one bucket. Every reply now carries X-Magpie-Provider (the provider id) and X-Magpie-Model (provider/model of the member that answered, after a failover the one that did), relayed or translated, streamed or not; the body is left as the vendor wrote it by default, since agents read it themselves (Claude Code its features, Codex its catalog). Name the member in replies (memberModel, off by default; zh, ja, de strings), or a request's X-Magpie-Response-Model: member, has the body's model say the member's id as well: Chat's model, Responses' response.model, Anthropic's message.model / model, Gemini's modelVersion, in each event of a stream as it goes and in a JSON body once whole (memberWriter, between the masking and the request log, which keeps the vendor's). Claude Code, Claude Desktop and Codex always get the vendor's name; X-Magpie-Response-Model: vendor asks for it too. The vendor's own name (dated versions included) stays in the usage ledger's Served. With the owner's real DeepSeek key in a sandbox, a group [a dead member, deepseek/deepseek-flash]: the headers said deepseek and deepseek/deepseek-flash on Chat, Messages and Responses, streamed and not; with member asked the body said deepseek/deepseek-flash in all six, with the setting on pi's token got it and Codex's token and a claude-cli User-Agent got deepseek-flash. TestReplyNamesMember (6 paths × streamed or not × default, on, asked, vendor asked, Codex, Claude Code, each after a failover; passes -race) fails on the old code, TestMemberLine; member-model-setting.test.cjs (WebKit, en and zh). settings-sections, settings-groups and searcher-pick pass on WebKit; redact-rule-row fails 2 the same on origin/main. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway, provider: a Claude model is asked on Messages from Chat and Responses too, where its provider has an Anthropic URL (ReturnTrue on Discord, after #997/050e629b: OpenCode on supergpt@claude-opus-5-5 had no cache with Chat, Responses and Anthropic URLs filled; Chat → Anthropic with Anthropic alone wrote 16,318 to the cache) — OpenCode speaks Chat to magpie for every model, Claude's too, and a request on an API the provider serves was relayed as it is, so with a Chat URL beside the Anthropic one OpenCode's Chat went on as Chat and the cache breakpoints the translation to Messages adds never reached the relay; Codex's Responses at a relay with a Responses URL the same. MessagesFirst only ordered the APIs a request is translated to. Provider.OnMessages (MessagesFirst, no "Asked on" set for the model, and no vendor list naming its APIs) now stops the pass-through for Chat and Responses when Messages is usable, and the request is translated there. A model the user pinned to Chat stays on Chat; Copilot's Claude, whose list names Chat and Messages, and every non-Claude model are relayed as before; Messages is relayed as it is. The reporter's second setup (Responses and Anthropic URLs, Chat → Responses in the screenshot) already went to Messages on v0.1.1081 — Chat isn't served there and
050e629b put Messages first — so that screenshot was from before it. TestClaudeFromChatIsAskedOnMessages (the reporter's three setups plus Chat+Anthropic, OpenCode's streamed Chat with a system prompt and a tool and Codex's Responses, each asserting /v1/messages upstream with cache_control in the body) fails without the change on the Chat+Responses+Anthropic and Chat+Anthropic setups and passes; TestClaudePinnedToChatStaysOnChat guards the pin; TestClaudeGoesOnMessagesWhereTheRelayHasThem and TestAutoRelaysOnTheClientsOwnAPI now expect Messages for Claude (GLM and GPT unchanged). -race -count=5 on them and the Copilot tests. go test ./internal/gateway ./internal/provider, go vet (darwin, windows), gofmt -l, darwin, linux (nogui) and windows builds pass. Not tried with the real SuperGPT relay or a real OpenCode. OpenCode's config still names @ai-sdk/openai-compatible for every model; a per-model Anthropic SDK was not added, as it isn't tried with the real OpenCode and the gateway now does the same. docs/subsystems/gateway-routing.md says it.
gateway: a vendor that turns away a field it doesn't know is asked again without it (#393: Mistral's 422 extra_forbidden for OpenCode's "store") — OpenCode sends store:false on every Chat Completions request, and Kimi Code its thinking switch; relayed as sent, Mistral refused each one, so no OpenCode request reached a Mistral model through magpie. Only the cache key was asked again without, and only on a translated request. Now a relayed Chat or Responses request that an upstream refuses naming one of the optional fields it carries (store, metadata, service_tier, prompt_cache_key, prompt_cache_retention, safety_identifier, stream_options, parallel_tool_calls, verbosity, thinking, enable_thinking) — quoted, as Mistral's loc ["body","store"], Gemini's Unknown name "store" and Groq's 'store' do — is sent again without them, and once that works they're left out for that provider from then on. Any other refusal reaches the client as before. TestMistralRefusesStore (a fake upstream answering Mistral's extra_forbidden body) fails without the change; go vet and go test -tags nogui ./internal/gateway pass.
gateway: Mistral's thinking, sent as typed parts in a Chat reply's content, reaches a Chat client as reasoning_content and a string content, and a translated one as thinking (#483: OpenCode's 'Invalid magpie/openai-compatible-chat stream event' with GLM on api.mistral.ai) — Mistral streams a reasoning model's thinking as delta.content = [{"type":"thinking","thinking":[{"type":"text","text":…}]},{"type":"text","text":"ok"}], thinking and text sometimes in one delta, and message.content so when not streamed; magpie relayed it as it came, the Chat contract has a string or null there, and the AI SDK OpenCode reads Chat with turned the first such chunk away and gave the request up (499 in the usage log). A relayed chunk or whole reply whose content is parts now has its thinking in reasoning_content, after any already there, and its text as a string content; a delta of thinking alone goes without content. Spoken to Claude Code's API or Codex's, such a chunk wasn't read at all, its thinking and text lost; it is now thinking and then text. Replies of every other upstream pass byte for byte, streamed or not. Prompts and headers are unchanged. TestMistralPartsStreamFlattened, TestMistralPartsWholeFlattened and TestMistralPartsTranslated (Anthropic, Responses) fail without the change; TestChatRelayWithoutPartsUntouched keeps other replies byte for byte. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
gateway: an earlier assistant turn's thinking goes to Mistral as the thinking part Mistral itself returns, and a vendor that turns it away inside messages is asked again without it (#494: Mistral's 422 extra_forbidden for OpenCode's reasoning_content, after any tool call) — OpenCode sends an earlier assistant turn back with its thinking in reasoning_content, a tool-call turn too (content null), and magpie relayed it as sent; Mistral refuses any field it doesn't know, loc ["body","messages",2,"assistant","reasoning_content"], so a Mistral reasoning model (GLM on api.mistral.ai) in OpenCode failed as soon as it used a tool. The #393 retry only dropped a fixed list of top-level fields. To Mistral (its preset, or api.mistral.ai) an assistant message's reasoning_content (or reasoning, or reasoning_details' text) now goes as a leading {"type":"thinking","thinking":[{"type":"text","text":…}]} part of its content, its text after it as a text part, its tool_calls as they were, so the thinking stays in context, as the reporter found Mistral takes it; the other messages keep their bytes. Any other Chat upstream answering extra_forbidden located in messages for one of those fields is asked again with it left out of the messages, and once that works it is left out for that provider from then on; DeepSeek, Kimi and every vendor that takes it are sent it as before. Prompts and headers are unchanged. TestMistralReasoningAsThinking (the issue's request, and a tool-call turn) and TestMessageReasoningRefused fail without the change. go vet and the darwin build pass; the full test and cross-build chain could not run, the disk being full.
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
models: Provider in model names has a third way, a name the user gave used just as they wrote it (#92 marsxxl: v0.1.591 的 Codex 下拉里自定义名称又带上了 · WorkBuddy / · Claude Code) —
559ce021 (#203) had a custom name keep " · <provider>" after it like any other, so "Opus 5.5" read "Opus 5.5 · Claude Code" in every list Labels feeds (Codex's model_catalog_json and the gateway's Codex /models, Pi's, OpenCode's, ZCode's, OMP's, Muse's), while #92 had been promised the name as written. Both are kept: the setting is now Off / Not on names I set (自定义名称不带供应商) / On. On stays the default and keeps #203's behaviour, so nobody's list changes unasked; own leaves a name the user gave as it is and a vendor's name with its provider's; off is #335's plain names. Stored as plainOwnNames beside plainNames, so a settings.json of before reads as it did (plainNames: true is off). POST /api/settings/plain-names takes mode on|own|off (a body of on alone still works), magpie model suffix on|own|off, a Settings save keeps it, and /v1/models' magpie_label now follows the setting through Labels as the agents' lists do. The row's hint is one line that fits (en, zh), and its zh name says 供应商 as the rest of the app. Prompts and headers are unchanged. TestSuffixMode, TestSuffixModesReachAgentFiles (Codex model_catalog_json and Pi models.json), TestModelsListSuffixModes (/v1/models magpie_label) and model-suffix-own.test.cjs (Chromium and WebKit, en and zh) fail without the change; TestModelName and TestModelNameAndLevelsReachAgentFiles keep #203's assertions. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestConfiguredModelsAdvertiseImageInput load-flaky, passes alone); plain-names, click-scroll, old-webkit pass.
gateway: a translated Responses request doesn't ask the upstream to seal a tool's arguments (#613) — Codex marks spawn_agent's message "encrypted"; its calls go back to Codex as unsealed (encrypted_function_args []), so an upstream honouring the mark would hand the subagent ciphertext as its message. The marks are left out of the tools sent (unsealed). TestNamespacedCallKeepsArgsWhenSearchTakesOver checks the upstream isn't asked to seal.
codex: a group's effort for the turn keeps Codex's reasoning.context, and Codex's custom tools (code mode's exec, apply_patch) reach a model magpie translates for (#534, #520 follow-up, azir12345: since v0.1.637 a Plus account's gpt-6.1-sol fails with 400 "X-OpenAI-Internal-Codex-Responses-Lite requires
reasoning.context to be all_turns", and Codex said it had only the collaboration and wait tools, no terminal) — ChatGPT's catalog marks gpt-6.1-sol (as gpt-6-sol, gpt-6-luna, gpt-5.6-*) use_responses_lite with tool_mode code_mode_only, and Codex asks for the bare slug with its native metadata: the X-OpenAI-Internal-Codex-Responses-Lite header (add_responses_lite_header, which magpie forwards with the other x-openai-* headers), reasoning {"effort","summary","context":"all_turns"} (build_reasoning), and its tools moved into an additional_tools item, every other tool reached through exec, a custom (freeform, lark grammar) tool in the "functions" namespace, beside wait and the collaboration namespace. magpie routes the bare id to the group auto-gpt-6-1-sol, and the effort the group picks for the turn went through withEffort, whose Responses branch rebuilt reasoning as {effort, summary} and dropped context, so ChatGPT refused the Lite request; it now sets only the effort (withBodyEffort), keeping every other field of reasoning as Codex sent it. A member served by translation (Chat, Anthropic, a Responses plugin) had parseResponses drop custom tools and ignore custom_tool_call / custom_tool_call_output items, so exec vanished and the model saw only wait and the collaboration tools; a custom tool (top-level or in a namespace) is now offered as a function taking one string "input", its description its own plus a note on input and its grammar, an earlier custom_tool_call goes to the model as a call with {"input":…} and its output as the call's result, and the model's call goes back to Codex as the custom_tool_call it runs (name and namespace as Codex knows them, input from the "input" argument, else the arguments as given), streamed without function_call_arguments events and in the non-streamed reply alike. This also gives translated models Codex's freeform apply_patch, which was dropped silently before. The Lite header is still forwarded as it was; nothing showed it needs dropping. Codex's base_instructions and identity line are untouched. No request was made to ChatGPT. TestWithEffort's Lite case fails without the decide.go change ("want \"context\":\"all_turns\""); TestCodexCustomToolsTranslated (a code-mode Lite turn through a fake Chat provider: exec offered with input and its grammar, the earlier call and output shown, the model's exec call back as a custom_tool_call with its input and no arguments) and TestCodexCustomToolNamespaced (namespaced and non-JSON calls, renderResponses) cover the tools; TestAdditionalToolsTranslated, TestNamespacedToolsOffered and TestGrokPluginGetsNamespacedTools now expect the custom tools offered. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestDevinSignedInOnlyInMagpie failed once on its wait, passes alone; untouched).
gateway: an upstream that answers 200 with a web page, with nothing at all, or with JSON that isn't, has failed as a 502 does, so a group asks its next member (#1012, cth123456: Codex on group/medium, agentrouter/deepseek-v4-flash now and then answered 200 text/html; the log said status=200 done=true in=0 out=0 served=null, codex/gpt-6-luna was never asked, and Codex failed with "stream disconnected before completion: stream closed before response.completed") — the candidate loop judged a try by its status alone, and holdWriter passes a 200 that is neither text/event-stream nor application/json straight to the agent, so the page went to Codex as the reply. notAnAPIReply now reads every 2xx (but 204) an upstream gives, in forwardOnce (every relayed and translated request, every API, plugins included) and in the built-in clients' own requests (the ChatGPT backend's /responses, Kiro, Zed, Qoder, Devin, Cursor, Command Code): a text/html reply, a body that begins as a web page whatever its Content-Type, an empty body, or one sent as JSON that doesn't begin with { or [ is turned into the 502 it stands for, with a JSON error saying what came back ("answered 200 OK with a web page (text/html), not an API reply: “Just a moment...”", the firewall hint for a block page). It then fails over, rests a minute and is logged as any 502 before the reply begins does; a provider with nobody after it hands the agent that 502 rather than the page. Only the first bytes are peeked, which a stream waits for anyway; a read that fails is left to the reader, a compressed body and anything else as it came. TestNotAnAPIReplyFailsOver (Chat, Responses and Messages, streamed and not, a member answering a Cloudflare page as text/html, as JSON and as SSE, an empty body as JSON and as SSE, and plain text as JSON; the next member must answer and the log name why) fails without the change in all 36 cases (the page relayed to the agent, or b never asked, or no reason logged) and passes; TestNotAnAPIReplyAlone (a 502 with the reason, logged 502) and TestCodexBackendWebPage (the ChatGPT backend's page reached Codex as a 200) fail without it and pass; TestAPIReplyUntouched keeps JSON after whitespace, SSE with and without a Content-Type, gzip, XML, a 204 and a 404 as they came. -race -count=10 on them; go test -tags nogui ./internal/gateway ./internal/provider, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay (agentrouter) itself; the page is a Cloudflare challenge's, as the issue has only its Content-Type. docs/subsystems/gateway-routing.md says it.
gateway: an upstream that answers 200 with a web page, with nothing at all, or with JSON that isn't, has failed as a 502 does, so a group asks its next member (#1012, cth123456: Codex on group/medium, agentrouter/deepseek-v4-flash now and then answered 200 text/html; the log said status=200 done=true in=0 out=0 served=null, codex/gpt-6-luna was never asked, and Codex failed with "stream disconnected before completion: stream closed before response.completed") — the candidate loop judged a try by its status alone, and holdWriter passes a 200 that is neither text/event-stream nor application/json straight to the agent, so the page went to Codex as the reply. notAnAPIReply now reads every 2xx (but 204) an upstream gives, in forwardOnce (every relayed and translated request, every API, plugins included) and in the built-in clients' own requests (the ChatGPT backend's /responses, Kiro, Zed, Qoder, Devin, Cursor, Command Code): a text/html reply, a body that begins as a web page whatever its Content-Type, an empty body, or one sent as JSON that doesn't begin with { or [ is turned into the 502 it stands for, with a JSON error saying what came back ("answered 200 OK with a web page (text/html), not an API reply: “Just a moment...”", the firewall hint for a block page). It then fails over, rests a minute and is logged as any 502 before the reply begins does; a provider with nobody after it hands the agent that 502 rather than the page. Only the first bytes are peeked, which a stream waits for anyway; a read that fails is left to the reader, a compressed body and anything else as it came. TestNotAnAPIReplyFailsOver (Chat, Responses and Messages, streamed and not, a member answering a Cloudflare page as text/html, as JSON and as SSE, an empty body as JSON and as SSE, and plain text as JSON; the next member must answer and the log name why) fails without the change in all 36 cases (the page relayed to the agent, or b never asked, or no reason logged) and passes; TestNotAnAPIReplyAlone (a 502 with the reason, logged 502) and TestCodexBackendWebPage (the ChatGPT backend's page reached Codex as a 200) fail without it and pass; TestAPIReplyUntouched keeps JSON after whitespace, SSE with and without a Content-Type, gzip, XML, a 204 and a 404 as they came. -race -count=10 on them; go test -tags nogui ./internal/gateway ./internal/provider, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay (agentrouter) itself; the page is a Cloudflare challenge's, as the issue has only its Content-Type. docs/subsystems/gateway-routing.md says it.
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
omp: each model is asked on its provider's own API (#329) — magpie wrote every model into omp's models.yml as openai-completions, so a Claude's thinking signatures had no place between tool turns. As for Pi, a Responses model now gets api: openai-responses and an Anthropic one api: anthropic-messages at the gateway's /v1/messages, thinking adaptively on Claude 4.6 and later, else on a budget (budget-effort would send output_config.effort, which Sonnet/Haiku 4.5 refuse); groups and the rest stay on Chat. A model offering max made omp 16.3.5 reject the whole file, so every magpie model vanished; max is now offered as xhigh, which the gateway fits to max. Checked with the real omp 16.3.5 in a sandbox HOME against a fake upstream: Claude → POST /v1/messages, deepseek → /v1/chat/completions. TestOmpModelsAskedOnTheirNativeAPI fails without the change; TestOmpMessagesRelayedAsSent guards the relay. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
gateway: a conversation too long for one member of a group goes on to a member with room for it, as before (#700: ynky007 — Kimi Code on DeepSeek V4.1 Flash through magpie showed a 176k context after updating to 0.1.768) — magpie still writes max_context_size = 1048576 for it into ~/.kimi-code/config.toml (checked in a temp HOME with the models.dev cache). 176k is Kimi Code's own: on a context overflow it takes floor(0.85 × the request's tokens) as the model's window for the session (observeContextOverflow, OVERFLOW_CONTEXT_SAFETY_RATIO, 2.1.1), so it was told a request of about 207k overflowed. Since
11a09d41 an overflow (a 400/413/422 saying so, which tooLongRe now knows in many more words) is not retryable and goes to the agent at once, and b3ab3e42 tells some unexplained failures as one; before, a 400 with "exceeded" failed over to the next member, which answered. That is right for other accounts of the same model, not for a group member of another model or vendor, whose window may be larger. Now an overflow goes on to the members left that are not the same provider and model and not known (windowOf) to be smaller than the request; nobody rests (Fail "overflow", shown as "too long for its model"), and if they fail on quota the agent is still told the overflow, to compact. With none left the agent is told at once, as now. TestOverflowGoesToAMemberWithRoom fails on the old code (400 context_length_exceeded, the second member never asked) and passes; it also checks a member known too small and a second key of the same model are not asked. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried with the reporter's providers: which member refused at ~207k is not known.
gateway: a stream the upstream cut off mid-reply ends in an error event, not as if whole (#370) — a relay of a Messages or Responses stream that the upstream dropped, or ended short of its last event, was closed as though complete: no message_stop and no error, which dsh reads as "DeepSeek Messages stream ended before message_stop" (STREAM_CLOSED) and never retries, saying nothing of the cause. The relay now ends such a stream with the protocol's own error event (Anthropic event: error / api_error, which dsh retries; Responses response.failed; a Chat error only when the read failed, since a whole Chat stream may end without [DONE]) naming the provider, and logs the request as failed. A stream counts as whole by its last event's data or its event: name, so a response.completed too big to read (an image's base64) is not taken for a cut one. TestPassthroughStreamSeveredUpstream, TestPassthroughStreamEndsShort, TestPassthroughResponsesEndsShort and TestPassthroughResponsesBigLastEvent fail without the change; TestPassthroughStreamWhole guards a whole stream. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
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
codex: a group's effort for the turn keeps Codex's reasoning.context, and Codex's custom tools (code mode's exec, apply_patch) reach a model magpie translates for (#534, #520 follow-up, azir12345: since v0.1.637 a Plus account's gpt-6.1-sol fails with 400 "X-OpenAI-Internal-Codex-Responses-Lite requires
reasoning.context to be all_turns", and Codex said it had only the collaboration and wait tools, no terminal) — ChatGPT's catalog marks gpt-6.1-sol (as gpt-6-sol, gpt-6-luna, gpt-5.6-*) use_responses_lite with tool_mode code_mode_only, and Codex asks for the bare slug with its native metadata: the X-OpenAI-Internal-Codex-Responses-Lite header (add_responses_lite_header, which magpie forwards with the other x-openai-* headers), reasoning {"effort","summary","context":"all_turns"} (build_reasoning), and its tools moved into an additional_tools item, every other tool reached through exec, a custom (freeform, lark grammar) tool in the "functions" namespace, beside wait and the collaboration namespace. magpie routes the bare id to the group auto-gpt-6-1-sol, and the effort the group picks for the turn went through withEffort, whose Responses branch rebuilt reasoning as {effort, summary} and dropped context, so ChatGPT refused the Lite request; it now sets only the effort (withBodyEffort), keeping every other field of reasoning as Codex sent it. A member served by translation (Chat, Anthropic, a Responses plugin) had parseResponses drop custom tools and ignore custom_tool_call / custom_tool_call_output items, so exec vanished and the model saw only wait and the collaboration tools; a custom tool (top-level or in a namespace) is now offered as a function taking one string "input", its description its own plus a note on input and its grammar, an earlier custom_tool_call goes to the model as a call with {"input":…} and its output as the call's result, and the model's call goes back to Codex as the custom_tool_call it runs (name and namespace as Codex knows them, input from the "input" argument, else the arguments as given), streamed without function_call_arguments events and in the non-streamed reply alike. This also gives translated models Codex's freeform apply_patch, which was dropped silently before. The Lite header is still forwarded as it was; nothing showed it needs dropping. Codex's base_instructions and identity line are untouched. No request was made to ChatGPT. TestWithEffort's Lite case fails without the decide.go change ("want \"context\":\"all_turns\""); TestCodexCustomToolsTranslated (a code-mode Lite turn through a fake Chat provider: exec offered with input and its grammar, the earlier call and output shown, the model's exec call back as a custom_tool_call with its input and no arguments) and TestCodexCustomToolNamespaced (namespaced and non-JSON calls, renderResponses) cover the tools; TestAdditionalToolsTranslated, TestNamespacedToolsOffered and TestGrokPluginGetsNamespacedTools now expect the custom tools offered. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestDevinSignedInOnlyInMagpie failed once on its wait, passes alone; untouched).
gateway: a plugin's model that writes its tool call into the text is answered as a built-in's is, proven (#823, Dazzle-sys: Trae CN's DeepSeek V4.1 Flash in a group ended the session in Pi) — TestPluginTextWrittenCall serves a plugin provider (trae-cn) whose Chat stream writes <tool_call>{"name":"bash",…}</|DSML|parameter></|DSML|invoke across two chunks, as the screenshot had it: a Chat client gets a tool_calls delta with the command intact and finish_reason tool_calls, an Anthropic Messages client a tool_use, neither any DSML. It fails on bbc89f9e^ (before v0.1.909's text-call reading) and passes now. The trae plugin reads these forms itself from 0.1.11. go vet and go test -tags nogui ./internal/gateway/ under a temp HOME pass.
gateway, provider: a request turning reasoning off stops Factory's DeepSeek V4.1 Flash, Kimi K3 and GLM-5.2 thinking (#899, tedestar) — they offered only low/high/max (GLM-5.2 high/max), so an agent's none (or Claude Code's thinking disabled) was asked for low, or nothing, and Fireworks thought anyway. droid 0.231.0 lists off for them and sends reasoning_effort "none"; through OpenRouter pinned to Fireworks, none came back with 0 reasoning tokens on DeepSeek V4.1 Flash (2 completion tokens; 19 at the default, 16 reasoning at low) and Kimi K3 (8; 71 at the default), and on Baseten's GLM-5.2 (2; 114 at the default), while GLM-5.3 and GLM-5.3-Flash turn none away ("Reasoning is mandatory for this endpoint and cannot be disabled") and stay at low, their least (Qwen3.8 Max and MiniMax have no off in droid either). The three now have none among their levels, in the built-in and in @magpie-community/opencode-factory-auth 0.1.17, which the mover's min is raised to; and a translated request that turned reasoning off (Effort reads it as low) is asked for none on a model with none among its levels, whatever the provider, else as before (a refusal still falls back to the lowest level on). No Factory account was at hand to send through Factory itself. TestFactoryThinkingOff (the built-in: chat, Responses and Anthropic's disabled; GLM-5.3 still low) and TestPluginThinkingOff (the plugin host, a model whose variants have none and one without) are new and fail without the change. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass (TestSavedButSignedOutClaudeServed timed out once under load and passes alone).
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
gateway: a member whose proxy is down goes on to the next without resting (#381) — with the local proxy (127.0.0.1:1082) not listening, Cursor and SuperGrok in a group failed with "proxyconnect tcp: … connection refused", were counted as failing ("other") and set aside on a backoff that grew with every request, so they stayed out after the proxy came back. A 502 whose error is Go's proxyconnect or socks connect is now "proxy": nothing reached the vendor, the next member is asked as for a request a vendor couldn't read, and nobody rests. The Routing page tags it "proxy not reachable" and says the request never reached the vendor, in English and Chinese. TestProxyDownDoesNotRest (HTTP and SOCKS proxies) and routing-proxy.test.cjs (Chromium, WebKit, en/zh) fail without the change; routing-shape, click-scroll, old-webkit pass. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass. (#381)
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.
subscriptions: Qoder CN, for accounts on qoder.cn (#349) — Qoder's China site (qoder.cn, Alibaba Cloud or phone sign-in) has its own accounts, sign-in page, account API (openapi.qoder.com.cn) and gateway (gateway.qoder.com.cn), so a qoder.cn account couldn't sign in through magpie's Qoder, which only knows qoder.com. A new Qoder CN subscription signs in there with the client id Qoder CN's own CLI sends, and everything after — polling, job token and refresh, user info, models, usage and chat — follows the site saved with the account; if qoder.cn refuses the job token, the account chats with its device token and renews it, as that CLI does. Qoder's tile is now Qoder (international), each tile saying which accounts it is for. Accounts saved before keep working on qoder.com unchanged. Not tried with a real qoder.cn account. TestSites, TestCNDeviceChatRefresh, TestQoderCNSignIn, TestQoderCNDeviceChat, TestQoderGlobalUnchanged and TestQoderCNGateway fail without the change; qoder-cn-tile.test.cjs (Chromium, WebKit, en/zh) added. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass but for TestCursorPluginContext, which fails on main (
1ce05f0) as well; click-scroll, old-webkit pass.
subscriptions: Qoder CN, for accounts on qoder.cn (#349) — Qoder's China site (qoder.cn, Alibaba Cloud or phone sign-in) has its own accounts, sign-in page, account API (openapi.qoder.com.cn) and gateway (gateway.qoder.com.cn), so a qoder.cn account couldn't sign in through magpie's Qoder, which only knows qoder.com. A new Qoder CN subscription signs in there with the client id Qoder CN's own CLI sends, and everything after — polling, job token and refresh, user info, models, usage and chat — follows the site saved with the account; if qoder.cn refuses the job token, the account chats with its device token and renews it, as that CLI does. Qoder's tile is now Qoder (international), each tile saying which accounts it is for. Accounts saved before keep working on qoder.com unchanged. Not tried with a real qoder.cn account. TestSites, TestCNDeviceChatRefresh, TestQoderCNSignIn, TestQoderCNDeviceChat, TestQoderGlobalUnchanged and TestQoderCNGateway fail without the change; qoder-cn-tile.test.cjs (Chromium, WebKit, en/zh) added. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass but for TestCursorPluginContext, which fails on main (
1ce05f0) as well; click-scroll, old-webkit pass.
gateway: an upstream that answers 200 with a web page, with nothing at all, or with JSON that isn't, has failed as a 502 does, so a group asks its next member (#1012, cth123456: Codex on group/medium, agentrouter/deepseek-v4-flash now and then answered 200 text/html; the log said status=200 done=true in=0 out=0 served=null, codex/gpt-6-luna was never asked, and Codex failed with "stream disconnected before completion: stream closed before response.completed") — the candidate loop judged a try by its status alone, and holdWriter passes a 200 that is neither text/event-stream nor application/json straight to the agent, so the page went to Codex as the reply. notAnAPIReply now reads every 2xx (but 204) an upstream gives, in forwardOnce (every relayed and translated request, every API, plugins included) and in the built-in clients' own requests (the ChatGPT backend's /responses, Kiro, Zed, Qoder, Devin, Cursor, Command Code): a text/html reply, a body that begins as a web page whatever its Content-Type, an empty body, or one sent as JSON that doesn't begin with { or [ is turned into the 502 it stands for, with a JSON error saying what came back ("answered 200 OK with a web page (text/html), not an API reply: “Just a moment...”", the firewall hint for a block page). It then fails over, rests a minute and is logged as any 502 before the reply begins does; a provider with nobody after it hands the agent that 502 rather than the page. Only the first bytes are peeked, which a stream waits for anyway; a read that fails is left to the reader, a compressed body and anything else as it came. TestNotAnAPIReplyFailsOver (Chat, Responses and Messages, streamed and not, a member answering a Cloudflare page as text/html, as JSON and as SSE, an empty body as JSON and as SSE, and plain text as JSON; the next member must answer and the log name why) fails without the change in all 36 cases (the page relayed to the agent, or b never asked, or no reason logged) and passes; TestNotAnAPIReplyAlone (a 502 with the reason, logged 502) and TestCodexBackendWebPage (the ChatGPT backend's page reached Codex as a 200) fail without it and pass; TestAPIReplyUntouched keeps JSON after whitespace, SSE with and without a Content-Type, gzip, XML, a 204 and a 404 as they came. -race -count=10 on them; go test -tags nogui ./internal/gateway ./internal/provider, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay (agentrouter) itself; the page is a Cloudflare challenge's, as the issue has only its Content-Type. docs/subsystems/gateway-routing.md says it.
gateway: a streamed reply whose vendor goes quiet keeps its agent alive on a timer of its own, held or passed, so a proxy in front doesn't end it (#947, jorben) — a vendor that sent its 200 and response.created and then nothing had the stream held for its first content with no event to scan, so the agent got no headers at all until it spoke again, and Cloudflare in front of a remote magpie ended the request at 125s (524); a translated reply quiet mid-way was kept alive only as the vendor sent something, and the proxy reset it. holdWriter.watch now runs beside every streamed try (it was only Group.FirstToken's watchFirst): past keepHeldAfter it sends a held try's agent the stream's 200 and SSE comments (keepAlive), none of the try, so another account may still answer in the same stream; once passed, a comment goes between two of its lines after keepaliveEvery of quiet; it stops after keepaliveLongest with nothing from the vendor, and Gemini streams get none (#934). Covers every protocol, relayed or translated. quiet_keepalive_test.go is new (held, mid-reply, failover after keepalives, the 5-minute cap, Responses/Anthropic/Chat relayed and Anthropic from Chat) and fails without the change. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass (TestUserPath in internal/proc failed once under the full run and passes alone).
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 分钟)
usage: quota cards draw what was left over time, against an even burn, and the tray card its cycle's line (#651, songlairui) — each reading magpie makes anyway of a subscription's windows (and of a plan bought with a key) is kept as a point in quota-history.json beside the config: provider, account, window, when read, percent left, the window's start and reset — no token, no prompt. A failed reading, one kept from before (As of), an unlimited window and a model family's aren't kept; Antigravity's pools are kept by pool. Within a cycle points are 5 minutes apart at least and a run of one figure is its two ends; a cycle ends where the window starts again (its reset passed, or moved further than the time between two readings, so a rolling window's isn't one). 45 days and 6000 points a window are kept. The Usage page's card has, under its meters, a line a window (several tiers of one account on one chart), broken at each reset, a dashed even burn from each window's start to its reset and an upright now line, with 2 days / Cycle (the account's longest current window, start to reset) in the app's segmented control, remembered, turning every card without moving the page. The tray's card has a thin line of its first window's current cycle. GET /v1/magpie/quotas/history (?days=, ?provider=, ?user=; local or LAN with the key, as /v1/magpie/quotas) and /api/usage/quotas/history serve it; /v1/magpie/quotas and
magpie quota stay snapshots. Computers sharing usage (WebDAV/S3, Settings › Sync › usage) put their history beside their days, sealed, as <id>.magpie-quotas, and merge the others': the union by account, window and time, two points within 30 s the newer, nothing twice however often they sync; an older magpie never reads the file. TestQuotaHistoryRecords, TestQuotaHistoryCycles, TestMergeQuotaHistory, TestQuotasHistory and TestQuotaHistorySharedBetweenComputers cover recording, cycles, pruning, merge, the API and two computers on one WebDAV folder; quota-curve.test.cjs (WebKit, en/zh, light and dark) the curves, the toggle, no curve without readings, and the tray's line (Chromium doesn't start in this sandbox). go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried with real accounts; pay-as-you-go balances aren't drawn.
gateway: a request from another machine turned away says whether a key came at all, and names a wrong one by its last four characters (azir12345, #545: Codex on a second computer got "API key is disabled, removed or invalid" while the same key worked with curl) — the refusal was one message for a request with no key and one with a key that isn't an enabled gateway key, so a client that drops its key (a proxy stripping Authorization, a base_url pointing elsewhere, an env key never read) read as a wrong key, and the reporter had no way to tell which. lanGuard's refusal (identifyCaller, refusedKey) now says "no API key was sent: send a magpie gateway key as Authorization: Bearer <key> (or x-api-key)" when none came, and "the API key ending in XXXX is not an enabled magpie gateway key: it is disabled, removed or mistyped" when one did, its last four characters named only for a key of 12 or more so a short one isn't mostly given away; magpie's log says the same with the caller's address. Requests from this computer are still let through without a key. TestLANRefusalSaysWhichKey (no key, a long wrong key named by its end and nothing more, a short wrong key not echoed) fails without the change and passes with it; TestQuotasOverLAN passes. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.
quota: each subscription account, plan and key says when it last answered, and the latest is marked last (#570: quota --json / GET /v1/magpie/quotas 增加每个订阅、plan、key 最近一次应答请求的时间) — under routing Smart nobody is first for good, so a menu-bar widget had nothing to tell the one in use by: a Quota's asOf is when its reading was taken, accounts --json covers signed-in Codex and Claude accounts only, and /v1/magpie/route wants a session. Each Quota in magpie quota --json and GET /v1/magpie/quotas now has lastServedAt (RFC3339, left out when it hasn't answered) and last: true on the latest (each of a key's plan and balance, which are one key). The gateway notes each answer where servedCandidate counts it (provider.NoteServed): an account by its agent and user, a key by its provider and key id, matched to a card by its user, or by the key's name or mask, or, for a card with no user, by any of its provider's. It is kept in served.json beside providers.json, entries older than 30 days dropped, since magpie quota runs in a process of its own and computes the report itself (provider.QuotaReport) rather than asking the gateway: both read the file, so they say the same, and a restart keeps it. TestQuotasLastServedAt (gateway: a request answered by one key, its balance card has lastServedAt and last, the other key's neither) and TestQuotaJSONLastServedAt (the CLI's --json, fake CLIs and keychain on PATH) fail without the change; TestQuotasLastServed covers the matching (account by user, key by name and by mask, a card with no user, the tie, read back from the file alone). README tells the fields. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: a thinking model behind a Responses or chat API gets its reasoning back between tool calls (#388) — Codex offers its web_search, so its request to a provider that doesn't search by itself is translated, and the translation left every reasoning item out: DeepSeek V4.1 Flash refused the next turn with "The reasoning_text in the thinking mode must be passed back to the API". A turn's reasoning (the item's reasoning_text, else its summary, all magpie hands a client of a translated reply) now goes back as a reasoning item, for a DeepSeek model (another may be passed on to OpenAI, which refuses unsealed reasoning), to any Responses API but OpenAI's, xAI's, Copilot's and Azure's, which read only their own sealed reasoning; on chat completions a DeepSeek model gets reasoning_content by its name as well as its host. TestReasoningReplayed fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass. (#388)
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 gpt-6.x refusal after the ChatGPT backend's safety buffering goes on to the next account, and a refusal tries the same model's other accounts first (#248, Koohoko) — the backend's response.metadata event (safety_buffering, moderation metadata) was taken for the reply's content and let the stream through, so the response.failed bio_policy after it reached Codex as a 200 ("This content can't be shown") with nobody else asked; it is now a lead, and a stream the backend said it holds back for safety checks is held up to 4 minutes rather than 15s (gpt-6.1-sol at xhigh said nothing for 35s). A turn kept on the account that answered it had that account alone first and its member's other accounts where the member sits in the group, so a refusal went to the group's first member (Sonnet); the refused model's other keys or accounts that aren't resting now go next. TestRefusalAfterResponseMetadataMovesToNextAccount, TestSafetyBufferedStreamHeldLonger and TestRefusalTriesTheModelsOtherAccountFirst fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
gateway: GLM's thinking streams again through a group (悠悠哥 on Discord: ZCode's thinking shows only at the end, all at once) — since
a8f0baaa (#248) a stream that had only reasoned was held, up to 4 minutes, until its text or a call began, for a refusal after the reasoning to go to the next account; so GLM on a ZCode account (or any key with retries left, or any group member before the last) showed Claude Code its thinking only once the text came, in one burst. Only Claude's, OpenAI's and Gemini's safety filters end a reply that has only reasoned with a refusal, so reasoning is now held only for those models (claude-*, gpt-*/o-series/codex, gemini-*); anybody else's (GLM, DeepSeek, Kimi, MiniMax…) goes through as it comes, as before #248. Requests, headers and prompts are unchanged. TestThinkingStreamsThroughGroup (a fake vendor streaming three thoughts 200ms apart, on Anthropic's API and on Chat with reasoning_content, read by a real HTTP client through a group) and TestThinkingHeldFor fail without the change; TestRefusalAfterThinkingFailsOver now names Claude and GPT models and still passes. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
usage, gateway, gui, provider, cli: the tests pass when a run crosses midnight, and a usage answer reads the clock once (#1046)
providers: a Remote magpie provider uses another computer's shared magpie, each request translated at most once (Jorben on Discord: 远程 magpie / magpie 接力) — a custom provider pointed at another magpie has one API per address and couldn't know which API each model's own provider serves, so a request was translated on both computers (Chat → Messages here, then again there). magpie's /v1/models now tells each model's native_endpoints (the APIs its provider serves it on; none for a routing group) and its image input, and reads them, with max_output_tokens and supported_reasoning_levels, from a list; the new remote-magpie preset takes the other magpie's address however typed (host:port, /v1, /v1/messages) for all three APIs and lists all its models, its user's choice already. Add sheet tile with the address field and a hint to Settings → Share on local network; CLI: magpie provider add remote-magpie <key> url=http://<ip>:3425. Checked with two sandbox magpies over the LAN address with the key check on and a Messages-only fake upstream: Chat, Messages (streamed), count_tokens, Responses, a routing group and Gemini all reached it as /v1/messages, translated once. TestRemoteMagpieNativeAPI, TestFetchMagpieFields and TestRemoteMagpie fail without the change; remote-magpie.test.cjs (Chromium, WebKit, en/zh) added. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass but for TestCursorPluginContext, which fails on main (
1ce05f0) as well; click-scroll, old-webkit pass.
provider: a Codex reset about to run out is spent half an hour before it does, not three hours, and at once only when the account is held up past then (#718, thedavidweng: account A, held up for its five hours until 17:09 with 32% of its week used, spent a reset expiring that afternoon at 15:20 — within resetExpiryLead (3h) and its windows used — losing the 68% of its week it could have used between 17:09 and the reset running out). Spending a reset starts the windows again and loses what they have left at that moment, while the fresh windows are the same whenever it is spent, so check now spends it as late as is safe: resetExpiryLead is 30 minutes; an account holding a reset that runs out is looked at every 30 minutes (resetExpiryWatch), never past the start of the reset's last hour, and every 5 minutes within it (resetExpiryClose, nextResetLook), a failed read (by the reset last read, kept per account) or spend there tried again in 5, not 30; the loop goes over the accounts every 5 minutes (each read only when due), and check compares wall-clock times (Round(0)), so a Mac asleep past the lead spends it on the first pass awake. It is spent at once when the account is held up now (usedUp: a window that stops it at 100%) and won't be free again (BackAt; not known counts as not) before the reset would be spent: waiting can use nothing more, and spending it frees the account — any held reset that runs out, however far off. Windows not used still spend nothing; the week-used-up AutoUseCodexReset path is as before. expiringResetSpent takes whether the account is stopped and when it is back, and routing's Allowance.Restarts passes the same from its limits (heldUp), so Weekly pace's and Smart's deadline (dueRestart) is when check spends it. The GUI's tooltips (en, zh: 到期前约半小时, 若账号正被限流、要到那之后才解封,则立即用掉), quota auto-reset's help and settings' comment say so. TestResetSpentAsLateAsSafe walks the report's afternoon in 5-minute passes (reset running out 18:15: five hours free at 17:09 or 17:40 → spent 17:45; free at 17:50, after it runs out, not saying when, or the week used up past it → spent 15:20; not held up → 17:45; nothing used → never; at most 20 reads), TestResetExpiryLastHour (look cadence, a failed read and spend retried in 5 minutes, spent after sleeping past the lead) and TestRestartsWhenHeldUp fail on the old code; the existing tables move from 3h to the new times. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass (TestDevinSignedInOnlyInMagpie failed once under load, passes alone and in the package rerun); codex-auto-reset and routing-reset-deadline pass in webkit. Not tried with the reporter's accounts. The spend reads the account again under autoReset's lock (stillExpiring, spendExpiringNow) and spends nothing if the week's used-up spend has started its windows again since the look, so a held-up account isn't given two resets; TestExpiringSpendReadAgain.
usage: a Codex call through the gateway that wrote to its upstream's cache is no longer listed a second time as a local session call (laowangba233 on #589: 用量中的统计是不是重复了 … 为啥会一个是提供商的一个是本地会话的) — the Usage page drops a Codex rollout's call when it matches a gateway record by session, end time within 2s and equal tokens. Codex reads input_tokens_details.cache_write_tokens from a Responses reply and keeps it as cache_write_input_tokens; the gateway's rUsage read only cached_tokens, so for a call whose upstream told a write the record had CacheWrite 0 and the rollout didn't, the tokens never matched, and the call stayed listed under Local session beside its provider's row. Calls without a write matched, so only some calls were counted twice (Maru Code 92 calls plus 22 "本地会话" in the report). Also, sessions' spent took only the cache read out of Codex's input_tokens, which holds the write too, so such a call's write was counted twice in its own input. Now: the gateway reads cache_write_tokens (input = input_tokens - cached - written) and tells it to Codex when it translates a reply; spent takes the write out of the input as well; and the matcher compares a call's input plus cache writes, its output and its cache reads, so a record and a rollout that split the write differently — an older Codex without the field, or records already in usage.jsonl from before this change — still match, and the duplicates already on a user's page go too. The session caches' versions go up (sessions.json 11, calls-v4) so kept parses are read again, and older calls-v* folders are removed. Under a provider filter the provider ranking still lists the other providers, as it is a picker; the duplicates were what made it read as double counting. TestCodexCacheWriteMatchesGatewayCall (a Codex rollout and gateway records for two calls, one with a 9000-token cache write; with the rollout naming the write and the record not, both naming it, and an older Codex without it: two rows, no Local session row, the provider ranking maru-code alone, unfiltered and filtered to the provider, by QueryPage and LedgerOf; fails without the change with a third row from the log), TestCodexCacheWriteNotInInput and TestResponsesUsageCacheWrite (parse and round trip through what Codex is told) are new and fail without the change; TestCallsCodex's call with a write now expects input 200 (2500 - 2000 read - 300 written). go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./internal/... under a temp HOME pass (TestFXInState's known TempDir-cleanup flake aside, which passes alone).
gateway: a Responses client's include goes on to a Responses provider (#315), and the Routing list says what a DeepSeek chat's GPT calls were (#314) — the rebuilt request had no include but magpie's own web_search_call.action.sources, so a relay that asks for Codex's reasoning.encrypted_content refused it; the client's include now goes on in its order, deduped, sources added once (not on api.x.ai), reasoning.encrypted_content only with reasoning (OpenAI refuses it without). A DeepSeek chat in Codex showed codex/gpt calls: magpie's own web searches for a model that can't search, and Codex's title of a new chat, made on a thread of its own, marked by thread_source in x-codex-turn-metadata, not x-openai-subagent. callKind now reads thread_source (not user/subagent) and tags magpie's SearchAgent calls web_search, which carry the agent and model searched for; the list tags them Web search and Title, a search's detail naming whom it was for. TestCallKindThreadSource, TestWebSearchCallSaysWhose, TestResponsesKeepsInclude, TestCodexSearchKeepsInclude and routing-side-calls.test.cjs (Chromium, WebKit, en/zh) fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; routing-kind, click-scroll, old-webkit pass.
gateway: Codex's compact reaches xAI, Grok and Copilot, and a web-search turn keeps its client_metadata (#378, #374) — a compact request drops its tools but kept tool_choice "auto", which Grok's subscription, the xAI API and Copilot's /responses refuse; a lone tool_choice is now dropped for those (a relay like #292's still gets the body as sent). A Codex request rebuilt by magpie (web_search) lost client_metadata and text (its output schema); both now go on to Responses upstreams as sent, never to Chat or Anthropic ones. TestGrokBodyDropsLoneToolChoice, TestCodexCompactsThroughXAI, TestCodexSearchKeepsClientMetadata and TestClientMetadataOnlyToResponses fail without the change.
gateway: a WebSocket upgrade on /v1/responses is answered 426 "magpie relays Responses over HTTP only", not 404 (#1005, Clarence12138) — Pi's Responses WebSocket extension opens GET /v1/responses with Upgrade: websocket, and only POST was routed there, so the catch-all's 404 ("magpie serves …") read as a broken gateway and the extension retried five times a session before falling back to SSE. GET /v1/responses and /responses now tell an upgrade 426 with the reason and the way that works (POST, streamed as SSE) — the answer a client falls back to HTTP on at once, as the Codex backend path already gives (TestCodexWebSocketUpgradeRequired) — and a plain GET 405 with Allow: POST. Relaying Responses over a WebSocket itself is not done here (a long-lived connection whose model is named per response.create, outside the per-request routing, failover, key limits and usage the gateway has). TestResponsesWebSocketUpgradeRequired fails without the change (404) and passes; go test ./internal/gateway, go vet (darwin, windows), gofmt -l and the linux build pass. Not tried with Pi's extension itself. docs/reference.md and gateway-routing.md say it.
gateway: a custom tool's call magpie hands Codex has a ctc_ id, and a magpie model or group on OpenAI's API gets call ids OpenAI takes (Lullaby on Discord: Invalid 'input[155].id': 'fc_18da5b9a10256a3e00000011'. Expected an ID that begins with 'tsc'. 这个问题貌似还是没有修复, then "Expected an ID that begins with 'ctc'", auto-compaction failing with it) — two causes. A vendor's apply_patch, translated to Codex's custom_tool_call, was given an fc_ id as a function call is (itemPrefix), and OpenAI turns away a custom_tool_call whose id isn't a ctc_ one. And the tsc_ rename (
1a9c4ca6) ran only for Codex's own models: a magpie model or routing group relayed to OpenAI's API on a key (passthrough) sent the session's fc_ ids as they were. The id kinds are now one table, openaiItemPrefix (tool_search_call tsc_, custom_tool_call ctc_, the two OpenAI's errors name): calls are minted with them in the stream and the rendered reply, and callItemIDs (was searchCallIDs) renames fc_X to tsc_X / ctc_X before Codex's own /responses and /responses/compact and before a passthrough to an openai.com host, call_ids and the rest of the body byte for byte. A ChatGPT account's request keeps no ids (provider.codexInput), so it is left alone. TestCustomToolCallHasCtcID, TestMagpieModelOpenAICallIDs (a fake OpenAI behind the provider's proxy answering the same 400s, direct and through a group) and TestCodexOwnModelCustomCallIDs (turn and compaction) fail without the change; TestCustomCallIDsUntouched keeps a clean request byte for byte. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: a rate limit on the last provider left is waited out even when it doesn't say for how long (#503, ynky007) — a 429 was tried again only when it came with a Retry-After (or a ratelimit-reset header) of 8 s at most, so a relay's 429 that says nothing of when ("Rate limit exceeded, please try again later", "当前分组上游负载已饱和,请稍后再试") went straight to the agent. Where another member, key or account is left a 429 already moves on to it, as before; on the last one left (or the last that may answer, the others out of their allowance) a 429 failure() reads as a rate limit is now asked again up to 3 times (rateRetries; busy 5xx/529 keep lastRetries = 2), after 1, 2 and 4 s or what Retry-After says (each 8 s at most, under half a minute in all), while nothing has been sent to the agent and only until it goes away. A 429 that says the plan is used up (usage limit, resets in hours: failQuota), that the money is gone (creditWords), or a Retry-After past 8 s (ChatGPT's resets_at goes on as one) is passed on at once, as before. passing() now takes the error body and the try count, and the last one's reply is held for as many tries as a rate limit gets. TestRateLimitIsWaitedOut (no Retry-After, a relay's own words, three in a row, then 200; four in a row → 429 after 4 tries; usage limit, 余额不足, Retry-After: 3600 → 429 after one try, at once) fails without the change (429 after 1 try in the first three, 3 tries in the fourth); TestLastOneLeftIsTriedAgain still passes. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/gateway/ ./internal/provider/ pass.
gateway, provider, gui: a Sub2API key given 5h/1d/7d limits is weighed and rested by its own windows, like an account (#1004)
gateway, provider: the Routing page says what a lone key or account has left, and a key bought as a plan (Kimi Code, GLM Coding Plan, MiniMax, OpenCode Go, Command Code) is weighed by its plan's windows (#1016, gphysign: Kimi Code (China) with one key on, Smart routing; the card said only "Kimi Code (China) 只启用了一个 Key,无需选择。" while /v1/magpie/quotas had its 5 hours 49 used, 51 left and its 7 days 45 used) — two gaps. weighRouted returned before reading anyone's allowance when there was one candidate, so a lone key or a lone subscription account was never known in the trace, and the Routing page, which shows a bar and "{n} used · renews in {d}" for a known row, had nothing to show; every single-account subscription said "what's left not known yet" the same way. And KeyAllowance read only a sub2api key's limits, so a key whose plan the Usage page reads (planQuotaSourceOf: Kimi Code, GLM Coding Plan and Z.ai, MiniMax Coding Plan, OpenCode Go, Command Code) was never known to routing, with one key or several. weighRouted now reads what each candidate has left before returning a lone one (one alone is still weighed against nobody). readsKeyWindows takes a plan key too; keyWindows reads it through planKeyWindows (planWindows, with Zhipu's team quota behind a key that has none of its own, as PlanQuotas does), and PlanQuotas' own read of a card is noted for routing at once (noteKeyAllowance), so the Usage page and routing never ask twice; the card kept on disk seeds it after a restart (keyCardTag keys a plan card as plan:, a sub2api one as balance:). A key at such a vendor that turns out to be pay-as-you-go, with no windows, is asked again every 30 minutes (keyNoWindowsAge), not every minute it is used. So, beyond the issue: several plan keys on one provider are now weighed by their windows under Smart, usage and pace, and a plan key the vendor refuses as out of quota rests until its window renews and comes back once read short of full, as a sub2api key does. affine no longer says a lone key or account all but used up "moved" its conversation: there is nobody to move to (at > 0). The GUI needed no change. TestLoneOneTellsWhatItHasLeft (a lone Kimi key known at 49% with both renews, the week first; a lone Codex account known; a plain DeepSeek key not), TestLoneSpentKeyKeepsItsConversation (fails with the at > 0 guard taken out), TestPlanKeyAllowance (the windows read behind the request, asked once; DeepSeek and Moonshot keys never asked), TestPlanKeyAllowanceFromItsCard (PlanQuotas' read known at once without a second ask; after a restart with the vendor down, the card on disk stands in and a failed read keeps it) and TestPayAsYouGoKeyAskedSeldom (a GLM pay-as-you-go key asked once, not two minutes on, again 31 minutes on) all fail without the change and pass -race -count=20 (provider) / -count=10 (gateway). routing-key-windows.test.cjs gains a lone Kimi Code key at 1100px and 440px (the one-key story, the row's bar at 49%, its provider row "49% used · renews in …" fitting, no sideways scroll) and passes in full on Chromium and WebKit in en, zh, ja and de; routing-quota-left and panel-routing pass. go test -tags nogui ./internal/..., go test -race on ./internal/provider and ./internal/gateway in full, go vet, gofmt -l, darwin, linux and windows (nogui) builds pass. Not tried with a real Kimi Code key. StepFun's Step Plan is read through a sign-in, not the key, so it isn't one of these. docs/subsystems/gateway-routing.md, providers-accounts.md and docs/reference.md say it.
gateway: a rule naming a model that a group in the group has too sends the turn to it (#625, Koohoko: a group of group/x (Opus, Astra) and Astra on its own, a rule for Astra came out unready — "没有可用的账号或 Key" — and Opus answered; the same with Sol on 0.1.697) — a model met again is kept where it was first (provider's membersIn), so with the group listed ahead of it Astra was a member of that group only, and a rule naming Astra found no member of its own: unready, sent on to the rules after it (the group, Opus first). named counts as the rule's member a model a group in the group has where the group names it too, in ruleMembers, the rules after it (Then) and the member contexts a rule's tokens go by; Unready is the one going first not being the rule's own, and Instead the rule after it whose member it is. And with the rule's model going first, the rules of the group it is kept in (nestedRules) no longer pick another over it (modelRuled). TestRuleNamesAModelAGroupInItHasToo fails without either part (unready, from the group's first). go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (timeouts under full-suite load pass alone).
group: a routing group takes images when one of its members sees them, and an image goes to the members that do (#756, HuHong-Tao on v0.1.813: group/auto-deepseek-v4-1-flash turned 1198 image requests away with 400 "model does not support image input" in a day, where commandcode's deepseek-v4.1-flash of the group took them) — groupEntries said a group took images only when every member did (Images ANDed, ImageInput through sharedImageInput, where one explicit text-only answer wins), and the gateway's membersImageInput said the same of a request, so one text-only member turned away every image before any member was tried, and /v1/models gave the group input ["text"]. Since a rule for images can send one past a text-only member, the gateway already dropped text-only members for a request with a new image; it now does so for every group without a model to describe images: a new image goes to the members that see it (an explicit answer, or models.dev's estimate as blindTo reads it for a model of its own), to members magpie knows nothing of when none is known to see, and is turned away (400) only when every member is text-only. With a describer the members keep their order, a text-only one given the image described, as before. groupEntries ORs Images and takes ImageInput through anyImageInput (true when a member's is, false when all are, else unknown); membersImageInput drops the rule's members (the rule's member first changes nothing: one that can't see is passed over). With the owner's real accounts (a copy of the config, Vision off): an image to group/auto-deepseek-v4-1-flash was 400 on origin/main and is answered by the Devin account's deepseek-v4-1-flash ("White"); group/auto-kimi-k3, whose only member that sees is an OpenCode Go key without a subscription, went from 400 to that member's 403. TestGroupImages (a group with one member that sees takes images, all text-only doesn't), TestGroupImagesFromAMemberThatSees, TestKnownTextOnlyModelRejectsImagesBeforeUpstream (an ordered group with its text-only member first sends the image to the one that sees), TestRuleImages and TestRuleImagesUnlistedMember now expect the image answered by the member that sees; they fail on the old code. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
group: a routing group takes images when one of its members sees them, and an image goes to the members that do (#756, HuHong-Tao on v0.1.813: group/auto-deepseek-v4-1-flash turned 1198 image requests away with 400 "model does not support image input" in a day, where commandcode's deepseek-v4.1-flash of the group took them) — groupEntries said a group took images only when every member did (Images ANDed, ImageInput through sharedImageInput, where one explicit text-only answer wins), and the gateway's membersImageInput said the same of a request, so one text-only member turned away every image before any member was tried, and /v1/models gave the group input ["text"]. Since a rule for images can send one past a text-only member, the gateway already dropped text-only members for a request with a new image; it now does so for every group without a model to describe images: a new image goes to the members that see it (an explicit answer, or models.dev's estimate as blindTo reads it for a model of its own), to members magpie knows nothing of when none is known to see, and is turned away (400) only when every member is text-only. With a describer the members keep their order, a text-only one given the image described, as before. groupEntries ORs Images and takes ImageInput through anyImageInput (true when a member's is, false when all are, else unknown); membersImageInput drops the rule's members (the rule's member first changes nothing: one that can't see is passed over). With the owner's real accounts (a copy of the config, Vision off): an image to group/auto-deepseek-v4-1-flash was 400 on origin/main and is answered by the Devin account's deepseek-v4-1-flash ("White"); group/auto-kimi-k3, whose only member that sees is an OpenCode Go key without a subscription, went from 400 to that member's 403. TestGroupImages (a group with one member that sees takes images, all text-only doesn't), TestGroupImagesFromAMemberThatSees, TestKnownTextOnlyModelRejectsImagesBeforeUpstream (an ordered group with its text-only member first sends the image to the one that sees), TestRuleImages and TestRuleImagesUnlistedMember now expect the image answered by the member that sees; they fail on the old code. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: a custom tool's call magpie hands Codex has a ctc_ id, and a magpie model or group on OpenAI's API gets call ids OpenAI takes (Lullaby on Discord: Invalid 'input[155].id': 'fc_18da5b9a10256a3e00000011'. Expected an ID that begins with 'tsc'. 这个问题貌似还是没有修复, then "Expected an ID that begins with 'ctc'", auto-compaction failing with it) — two causes. A vendor's apply_patch, translated to Codex's custom_tool_call, was given an fc_ id as a function call is (itemPrefix), and OpenAI turns away a custom_tool_call whose id isn't a ctc_ one. And the tsc_ rename (
1a9c4ca6) ran only for Codex's own models: a magpie model or routing group relayed to OpenAI's API on a key (passthrough) sent the session's fc_ ids as they were. The id kinds are now one table, openaiItemPrefix (tool_search_call tsc_, custom_tool_call ctc_, the two OpenAI's errors name): calls are minted with them in the stream and the rendered reply, and callItemIDs (was searchCallIDs) renames fc_X to tsc_X / ctc_X before Codex's own /responses and /responses/compact and before a passthrough to an openai.com host, call_ids and the rest of the body byte for byte. A ChatGPT account's request keeps no ids (provider.codexInput), so it is left alone. TestCustomToolCallHasCtcID, TestMagpieModelOpenAICallIDs (a fake OpenAI behind the provider's proxy answering the same 400s, direct and through a group) and TestCodexOwnModelCustomCallIDs (turn and compaction) fail without the change; TestCustomCallIDsUntouched keeps a clean request byte for byte. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.
gui, gateway, settings: the Usage page's agent rows show their cache hit rate, and web search can go to the search APIs first (#925, xiaozhu1337; #928, hypooo)
search: a Kimi Code plan searches the web for its own models with its own search service, before the Codex account or whatever magpie picks, and Settings may name it to search for every model (Discord: on Claude Code with the Kimi Code plan, why does web search use Codex's model?) — kimi-cli's SearchWeb (kimi_cli/tools/web/search.py, 1.52.0) POSTs {text_query, limit, enable_page_crawling, timeout_seconds:30} to the plan's search_url, which auth/platforms.py makes <base_url>/search (https://api.kimi.com/coding/v1/search) with the same key as the models (setup.py and oauth.py write services.moonshot_search with the plan's key or sign-in), User-Agent KimiCLI/<ver> and X-Msh-Tool-Call-Id, and reads search_results [{site_name,title,url,snippet,content,date}]. provider.KimiCodeSearch finds a plan by api.kimi.com/api.kimi.ai /coding or its kimi-code preset; magpie asks it the same way (no model asked, pages as the search APIs' are, date as page_age). A model served by a plan is given magpie's web_search even with nothing else set up, and its searches go to the plan first; failing, magpie's pick, then the search APIs, as before. For other providers' models a plan is never picked automatically (it would spend the plan's allowance and change today's searcher): Settings › 联网搜索 offers it once, by its web search with no model, and says it searches for its own models first. TestKimiCodePlanSearchesForItsModels fails on the old code (no search tool with nothing else set up; with a searching API and a search API, the old code asked them and never Kimi) and passes; TestKimiCodePlanNamedToSearch and TestKimiCodeSearchAddress cover naming it and the address; searcher-pick.test.cjs (webkit, en/zh) the picker. Against the real account's key from a temp copy of the config: api.kimi.com/coding/v1/search exists (404 on a wrong path, 400 on a bad body) but answers 403 access_terminated_error for search and chat alike, as that Kimi Code subscription has lapsed, so a real search result wasn't seen. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: Gemini on a Google sign-in searches with Google Search and draws with its image models (#757, on Antigravity: gemini-3.8-flash's searches went to the Codex account and spent its allowance, and neither the searcher picker nor the image model picker offered Antigravity's models) — a Google sign-in (Gemini CLI's or Antigravity's, not a plugin's) was never said to search by itself, and buildCodeAssistSent sent no googleSearch, so a Claude Code WebSearch on an Antigravity model was handed to magpie's search, which the searcher magpie picked (Codex first) answered.
gateway: a request offering web search is translated on the client's own API where the provider serves it, not on Chat (#997, wlj521: Codex on Xiaomi MiMo's pay as you go showed "Responses → Chat" on v0.1.1082 with all three URLs filled) — Codex offers its web_search tool on every turn, and a provider that doesn't search by itself can't be relayed it: magpie searches for it, which takes a translated request. That translation went to usable's first API, Chat for every model but OpenAI's and Claude's, so on any provider serving Responses (MiMo, whose api.xiaomimimo.com/v1/responses answers 401 to a made-up key, a wrong path 404) each Codex turn went out as Chat, and Claude Code's WebSearch on a provider with an Anthropic URL the same. The translation now stays on the client's API when the provider serves it there and neither ResponsesFirst nor MessagesFirst prefers another (firstElsewhere); the OnMessages check moved before the search check so a Claude model still goes to Messages. The search tool itself isn't sent to the provider (forwardTranslated drops it where the provider doesn't search), and magpie's own web_search function is given on Responses as it was on Chat. TestSearchOfferedStaysOnTheClientsAPI (the preset's three URLs; Codex's Responses with a function and web_search, Claude Code's Messages with web_search_20250305, Chat's web_search_options) and TestCodexSearchAnsweredOnResponses (a searcher configured: the model is asked on /v1/responses twice, the search runs once, Codex gets the answer) fail without the change (/v1/chat/completions) and pass; -race -count=5 on them and TestWebSearch*, TestClaudeFromChat*, TestAutoRelays*. go test ./internal/gateway ./internal/provider, go vet, gofmt -l, linux/windows (nogui) builds pass. Not tried with a real MiMo key (none on this machine) or a real Codex. docs/subsystems/gateway-routing.md says it.
search: a searcher is asked on the API it searches on, and one that finds nothing gives way to the next searcher and the search APIs (#669: with Automatic resolving to deepseek/deepseek-flash and Tavily set up, every client's web_search got web_search_tool_result.content [], the routing log saying 200) — magpie asks its searcher on Anthropic's API, and DeepSeek's Anthropic API wasn't counted among those that search, so the request was translated onto DeepSeek's first API, Chat; translate chose the API a provider searches on (DeepSeek's Responses) only for a client's request, never for magpie's own search, so DeepSeek got no search tool at all, and deepseek-flash said it couldn't search or wrote its own DSML tool-call markup as text. That text went to the model as what was found, with no page, and was taken as a success, so Tavily was never asked. Now translate sends a searcher to the API it searches on as it does a client; api.deepseek.com searches on Anthropic too (tried: web_search_20250305 there answers web_search_tool_result blocks of real pages with DeepSeek's encrypted_content, and takes magpie's own blocks back in a later turn); a searcher's results are read one by one, title, address and page_age (now carried to the client's block), never its encrypted_content, which stays "" in what magpie sends; a reply with no page and no address in its text is "found nothing", and the search goes to the next searcher magpie would pick (up to 3, within the 2 min), then to the search APIs. TestSearcherAskedWhereItSearches (DeepSeek searching on Responses only: old code asked Chat and gave the DSML text and []), TestDeepSeekNativeSearchResults (DeepSeek's native reply: pages, their ages, no encrypted_content passed on) and TestSearcherFindingNothingGivesWay (a 200 with nothing found goes to Tavily, or to the next searcher first) fail on the old code; TestDeepSeekSearchesOnResponses now expects the Anthropic API to search. With the real DeepSeek key in a sandbox gateway (temp HOME, copy of providers.json, searcher deepseek/deepseek-flash) the issue's curl on aihubmix/glm-5.3-flash got 10 web_search_result pages (weather.com.cn, moji, …) and an answer from them; deepseek/deepseek-flash asked directly with that reply in its history searched natively and answered. No Tavily key here, so the fallback to it was tried only against the fake. go vet ./internal/..., builds (darwin -tags nogui, GOOS=linux -tags nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestClaudeRunEndsWhenTheClientLeaves, a timing test that passes alone 3/3.
gui, gateway, settings: the Usage page's agent rows show their cache hit rate, and web search can go to the search APIs first (#925, xiaozhu1337; #928, hypooo)
gateway: Codex's Responses Lite tools reach SuperGrok and every other backend, and a group moves on when a member can't read a request (#350) — Codex sends a Lite model's tools as an additional_tools input item, its own functions in a "functions" namespace; magpie relayed the item to xAI, which 422s on it (unknown item type), and translated routes (Cursor, Anthropic, Chat…) dropped it, so the model had no tools. Outside OpenAI the item's tools now go in tools, the functions namespace opened into plain tools and earlier calls un-namespaced; OpenAI and the ChatGPT account still get Codex's own form. A 400/422 over the request's shape (failed to deserialize, unknown item type/parameter…) now moves to the group's next member, each asked once, the member not resting; the Routing page says so. TestAdditionalToolsRelayed, TestAdditionalToolsTranslated and TestGroupFailsOverARequestShape fail without the change; routing-shape.test.cjs (Chromium, WebKit, en/zh) added. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; click-scroll, old-webkit, routing-wb-refused pass.
gateway, gui: a provider's keys can be routed by weight, a key weighing 3 taking three requests for every one a key weighing 1 takes (dengtongcai, #841) — Routing already spread requests over a provider's keys in order, in turn, least used first or by pace, but never in a set proportion: two team keys meant to split 75/25 could only go evenly or one after the other. Routing now has By weight (routing "weight") for a provider with keys: each key that's on shows its weight and its share beside it in the editor (Weight 3 · 75%), a click edits it in place and posts keys/weight (provider.SetKeyWeight, 1 to 1000; none or 0 counts as 1, kept out of the file), and providers.json takes it as "weight" on each of keys (keyWeight for the first). The gateway picks as NGINX's smooth weighted round robin does (weighted.go): 3, 1 and 4 go round in every 8 requests in that ratio, never bunched; the others follow in their order for failover. A key resting after a failure is left out of the round while it rests, the others taking its share by their weights, and goes last. A conversation still stays with its key as Stays says (cache-warm across turns by default, within a turn always), and a request kept so is counted against the key that took it, not the one the round picked, so new conversations make up the share; Stays Off gives the ratio request by request. The weight goes with its key through Make first, the editor's Save, backups and WebDAV sync. The Routing page offers By weight for key pools and explains a pick by it; sink (Goes to the back) is off for it as for In turn. zh, ja, de strings. TestWeightedKeys (3:1:4 exact in each of 50 rounds of 8 through perKey and route; with one resting the other two 75/25 and the resting one last; a kept request made up by the next picks) and TestSetKeyWeight fail on the old code (no Weighted); key-weight.test.cjs (WebKit, en and zh: By weight shows the weights and shares, a click sets one and the shares redraw, Smart hides them, no scroll) fails on the old code (no By weight). key-import, panel-routing, routing-save, rate-limited-sink, routing-reset-deadline, account-arrange, gui-ja, gui-de and click-scroll pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: every full id of a Claude family Claude Code names goes to its tier's model, served by a provider of the user's or not (KevinXC on Discord, v0.1.665, T3 Code 0.0.45 over Claude Code 2.1.287: claude-haiku-4-5, claude-opus-5, claude-fable-5 and claude-sonnet-4-6 went to the haiku/opus/fable/sonnet tiers, but claude-fable-5-1, claude-sonnet-5 and claude-sonnet-5-5 went to the Claude account, 429 "requires usage credits" / "weekly limit") — the stand-in (
4cbd96a3) was only asked for a model magpie shows no entry for, so a full id a provider of the user's serves bare (the Claude account lists claude-sonnet-5, claude-fable-5-1, …; a found auto-<model> group has it too) went there, while one none listed went to the tier: which tier a model reached depended on what the user's providers happen to list, not on the model. T3 Code names every model by its full id rather than Claude Code's alias, so it hit both. Now an id of the opus, sonnet, haiku or fable family (claudeFamily: claude-<family>-<any version>[-date][1m], and claude-3-5-sonnet-… alike, so a release magpie has never heard of is one too) from Claude Code routed through magpie goes to the model its tier is set to before groups or providers are looked at, count_tokens counting it there too. Kept as asked: a provider's "anth/claude-…", another agent's request, Claude Code not routed through magpie (StandIn says nothing), a routing group of the user's own named by the id (one magpie found, auto-…, was never picked for it), and the main model when Claude Code's ANTHROPIC_MODEL is that very id. A tier at an effort of its own (#536, "fake/sol:high[1m]") now reaches its model at that level through a stand-in too, where it was looked up whole and refused. TestClaudeTierStandInByFamily (claude-haiku-4-5, -20251001, claude-haiku-5, claude-opus-5, -5-5, -5-5[1m], claude-opus-6, claude-fable-5, -5-1, -5-1[1m], claude-sonnet-4-6, -5, -5-5, -5-5-20261001, claude-3-5-sonnet-20241022 through /v1/messages, beside a provider serving seven of them bare) fails without the change for the nine served ones; TestClaudeStandIn gains the families and the main model. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestDevinSignedInOnlyInMagpie, a known flake, aside).
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)
gateway: a stream whose connection dropped mid-reply is said to have lost its connection, which an agent retries (#470: OpenCode Go: unexpected EOF) — with several dsh sessions on OpenCode Go, a dropped connection ended each session's stream with Go's own read error, "OpenCode Go: unexpected EOF"; dsh's pi-ai adapter places a failure by its words (429, 5xx, timeout, network/connection/socket…) and took this one for PI_AI_ERROR, which it never retries, so the turn failed. Every session at once, as magpie's requests to one host share one HTTP/2 connection and all its streams end so when it drops. A relayed or translated stream whose read fails mid-reply now ends with "<provider>: connection lost mid-reply (<error>)", which dsh retries as TRANSPORT; one cut before anything was said is still magpie's to try again, as before. TestStreamCutSaysConnection fails without the change; TestStreamCutBeforeContentTriedAgain guards the try again. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestAccountProxy fails on main as well, passes alone).
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: a tool call GLM writes into its text in its own format is made, and Codex's exec gets its code as a custom tool call (#906, nullburn)
gateway: a tool call DeepSeek writes into its text as {"name":…} then <parameter name="k">v, its closes missing, is the call (#917, Moody-Sin)
gateway: a reply that is only a <tool_call> and DeepSeek's closing tags says nothing, and a <tool_call> wrapping DeepSeek's own invoke is the call (Dazzle-sys, #823 again: Trae CN's DeepSeek V4.1 Flash in Pi showed <tool_call> </|DSML|parameter> </|DSML|invoke> </ as its reply) — the reply (17 tokens out, the 04:23:54 Trae CN row in the request log) was the Hermes opener with no JSON in it, then DeepSeek's closes, cut short. A <tool_call> was read only for a JSON object, so the held text went to the agent as it was, and the model read it back. A <tool_call> whose content starts with a tag, or is nothing, now has its opener dropped and what follows read as it is: a DSML invoke in it is the call; held text that is tags and nothing else (a tag cut short at its end, </, too) is dropped, and the reply ends as it did (stop) when no call was in it. Text that only looks like a tag (x </tool_call> y) goes on whole. trae 0.1.13 does the same in the plugin. TestTextWrittenTagsOnly fails on the old code with the screenshot's text; go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway: a tool call DeepSeek writes into its text as {"name":…} then <parameter name="k">v, its closes missing, is the call (#917, Moody-Sin)
gateway: a lone provider's Claude or GPT reasoning streams as it comes, not held for a refusal no one else could answer (iTianbao on X: 请加一个开关,让思考实时透传; through a relay Claude Code showed the thinking only all at once with the text, Codex none until then) — holdWriter held Claude's, GPT's and Gemini's reasoning (refusesAfterThinking, up to holdThinking) on every candidate, so a refusal after it could go to the next one (#248), but a refusal on the last candidate is never asked again (the !last checks in serve), so there it only delayed the thinking. On the last candidate Claude's and GPT's reasoning is now shown at once (thinkingShown), and a refusal after it reaches the agent as the vendor's own stop; Gemini's stays held there, for a reply that only reasoned to be asked again (#667, TestGeminiEmptyRepliesAskedAgain). A group with members after it still holds as before. Before: on a slow fake relay (4 thoughts 300ms apart, then text) every relay shape (Messages, Chat with reasoning_content and reasoning, Responses) for claude-sonnet-4-5 and gpt-5.1 gave Claude Code's thinking_delta and Codex's reasoning_summary_text.delta all at ~1.2–1.5s with the text; after, 0/300/600/900ms. TestThinkingStreamsWithNoOneElse (Messages, Chat for Claude and GPT, Responses; each asked from /v1/messages and /v1/responses) fails without the change in every case and passes; TestHeldThinkingKeepsTheAgentAlive's nobody-left case now expects the thinking and Claude's refusal rather than a held error. -race -count=5 on TestThinking*, TestRefusal*, TestHeld*, TestGeminiEmpty*; go test -tags nogui ./internal/gateway, go vet, gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay: whether it sends GPT's reasoning at all (OpenAI's Chat API doesn't) is unknown, and no switch was added. docs/subsystems/gateway-routing.md says it.
gateway: a model that thinks with no levels to pick says it thinks on /v1/models (#402) — mimo-v2.6-flash (Xiaomi's, OpenCode Go's, and a group of them) was listed reasoning: false with no levels, though models.dev says it reasons: /v1/models took reasoning to be having levels, and the model thinks with a switch alone. A model now carries whether it thinks — models.dev's reasoning flag for its own vendor, a plugin's, else what most of the providers models.dev lists serving it say, for a vendor's or relay's list that says nothing of it (Xiaomi's API gives ids alone) — and is listed reasoning: true with empty supported_reasoning_levels, told apart from one that doesn't think; levels the user kept down to none leave it thinking. A group thinks when every member does, or when it offers levels. TestModelsReasoningWithoutLevels fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestClaudeRunEndsWhenTheClientLeaves and TestUserPath, load flakes, pass alone).
docs, site: Integrating your app or agent with magpie, at docs/integrating.md and usemagpie.ai/docs/integrate (#918, Magixyne)
claude: each of Claude Code's tiers (opus, sonnet, haiku, fable) and its subagents can run at a reasoning effort of their own through magpie, picked from the tiers' square under the tier's model (#536: 希望 Claude Code 的 opus/sonnet/haiku/subagent 各自可设置推理强度) — until now one effort was set for Claude Code (modelSettings / effortLevel / CLAUDE_CODE_EFFORT_LEVEL) and every tier ran at what Claude Code asked, so a haiku tier used for small side calls ran at the main model's high. Claude Code takes a tier only as a model id (ANTHROPIC_DEFAULT_<TIER>_MODEL, CLAUDE_CODE_SUBAGENT_MODEL) and has no per-tier effort setting, so the effort travels in that id, the way a routing group's member is fixed at one (#189): a tier at an effort is "<model>:<level>", before any [1m] mark ("v/big:high[1m]"), and the gateway (askedAt) takes the level off before resolving, then asks the model for it at the model's nearest level (withFixedEffort, fitLevel) whatever Claude Code asked, the trace's Try.Fixed saying so; a group member's own effort still wins, and a model whose own id ends in ":<level>" (MemberEffort's isModel) is taken whole. Token counting strips it too.
gateway: a model that won't be made to call a tool is asked again with the call left to it (#668: OmO's first turn forces its todo tool, and Claude Opus 5.5 answers 400) — the issue reads the 400 as Anthropic wanting additionalProperties:false on every object of a named tool's schema; it doesn't. Against Anthropic's API (the owner's Claude subscription) the same two tools, schemas without additionalProperties, with tool_choice {type:"tool"} are taken by claude-sonnet-4-5, claude-opus-4-8, claude-opus-5 and claude-sonnet-5, and refused by claude-opus-5-5, claude-sonnet-5-5 and claude-fable-5-1 with
tool_choice: type "tool" and "any" are not supported for this model., with or without additionalProperties:false on every object; OpenRouter (Anthropic, Bedrock, Azure, Claude Platform on AWS) answers the same. Anthropic says "tools.N.custom: For 'object' type, 'additionalProperties' must be explicitly set to false" only of a tool sent with strict:true, auto or forced alike, and buildAnthropic sends no strict; the reporter's own capture shows none either, so it is the vendor behind their Claude Pro endpoint holding the forced tool to strict mode. Either way the 400 came from forcing the call on a model that can't be forced, and it was relayed as is, with no fallback. forwardTranslated now asks again when a forced tool_choice (a named tool, or required) is turned away with either error: the call is left to the model (auto), only the named tool offered, as the Claude subscription's bridge does, and the provider isn't sent a forced choice for that model again (unfit, as reasoning-off refusals are). Schemas are unchanged. TestForcedToolChoiceRefused (Chat to an Anthropic-only provider, with each error) fails on the old code. Through a sandbox gateway on an Anthropic-only copy of the owner's OpenRouter provider, a Chat request forcing todo to anthropic/claude-opus-5.5 answered 400 with the old build and a todo call with this one, twice. go vet ./internal/..., builds (darwin, GOOS=linux -tags nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestProvidersAnswerWhileListsComeIn, a timing test that passes alone. A Chat client on a Chat endpoint (passthrough, e.g. OpenRouter's chat) is not changed.
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)
gateway: a tool sent on to a Responses API says strict, as Codex says it (#383) — MiniMax Code on a Codex model through magpie was turned away on every request with "Invalid JSON schema: regex lookaround is not supported. Found at $.properties.path.pattern": its Anthropic tools went to the ChatGPT backend with no strict, which holds them to strict mode's rules, while the same tools with strict false or null are taken. Every function tool on a translated Responses request now carries strict: false, or true when the client asked for it (Responses and Chat tools keep theirs). TestResponsesToolStrict fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (load flakes rerun alone). (#383)
codex: third-party and group models get Codex's tool search, so the first request no longer carries every MCP and ChatGPT-app tool's schema (#258: 190K tokens before the first word on a magpie model, ~26K on Codex's own)
gateway: Codex's Responses Lite tools reach SuperGrok and every other backend, and a group moves on when a member can't read a request (#350) — Codex sends a Lite model's tools as an additional_tools input item, its own functions in a "functions" namespace; magpie relayed the item to xAI, which 422s on it (unknown item type), and translated routes (Cursor, Anthropic, Chat…) dropped it, so the model had no tools. Outside OpenAI the item's tools now go in tools, the functions namespace opened into plain tools and earlier calls un-namespaced; OpenAI and the ChatGPT account still get Codex's own form. A 400/422 over the request's shape (failed to deserialize, unknown item type/parameter…) now moves to the group's next member, each asked once, the member not resting; the Routing page says so. TestAdditionalToolsRelayed, TestAdditionalToolsTranslated and TestGroupFailsOverARequestShape fail without the change; routing-shape.test.cjs (Chromium, WebKit, en/zh) added. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; click-scroll, old-webkit, routing-wb-refused pass.
usage: WorkBuddy's credits are said beside their share — on the Routing page, the Usage page and the menu bar, used or left as the share is (#659)
usage, gateway, gui: a reply that reasoned is timed by its answer, from its first text, not by all its output from its first token (tony on Discord #general: Usage › Requests read "gpt-6.1-sol 163 token/秒 · 8,036 个请求 · 首字 14 秒" and asked whether subagents were added up) — speed was every output token over the time after the first content (text, reasoning or a tool call). Output counts the reasoning, and a model whose reasoning is hidden (OpenAI's encrypted reasoning, a summary sent when it is done, Claude's omitted thinking) writes it before the stream shows anything, inside the TTFT; so a 2,000-token think and a 200-token answer over the 1.5 s after the first token read 2,200 in 1.5 s, 1,467 tok/s. Concurrency was not the cause: the aggregate is Σ tokens over Σ per-request windows, so subagents running at once never add up. In the owner's own usage.jsonl codex/gpt-6-luna turns read up to 600 tok/s (out 496, reasoning 224, 11,055 ms, ttft 10,228) and its aggregate 133; it is 80 now. usage.DecodeOf is the one rule: a reply with reasoning counts out − reasoning over ms − firstText, one that reasoned and wrote only tool calls tells no speed, one without reasoning is timed from its TTFT as before; DecodeWindow's burst cut (#731) still applies. Record.Decode feeds summarize, the ledger's Totals and its timeline Parts; a route keeps its reply's reasoning (Route.Reasoning, from gateway.go and codex_backend.go), and routing.js's decodeOf/speedOf times its rows, the story and a session's average by it (an older route takes its served usage's reasoning); app.js's ledDecode does the Usage page's rows, whose tooltip says the reasoning left out, and the Output speed help says the rule, in en, zh, ja and de. History is read the same way, as it keeps the reasoning and the first text. Routing never ranks by speed. TestReasoningReplyTimedByItsAnswer (the 2200/2000 turn, a summary 10 ms before the text, a concurrent subagent, tool calls only, reasoning over output, no reasoning, not streamed: 1,200 tokens in 11 s through summarize, addRow, LedgerSeries and Decode) fails without the change and passes -race -count=10; TestUsageKeepsTimeToFirstToken now has its paced replies report reasoning and checks the route keeps it. reasoning-speed.test.cjs (Routing rows 133, 100, none and 100, the story never 1,467, the session 105; the Usage rows, tooltip and KPI the same; no sideways scroll) passes on Chromium and WebKit in en, zh, ja and de, and fails against the old assets (1467 tok/s); routing-request-metrics, routing-burst-speed, usage-speed, usage-ledger-detail, i18n-keys, gui-ja, gui-de, ledger-hscroll and click-scroll pass on both. go test -race -tags nogui ./internal/... under a temp HOME passes but internal/proc's TestUserPath ("the login shell's PATH never came"), which failed under the full run's load, passes 3/3 alone and is not touched here; go vet, gofmt -l, linux and windows (nogui) builds pass. Not tried with tony's data. docs/reference.md says how output speed is counted.
gateway: a repeated tool call id continues the call it names, not a new call — some relays repeat a tool call's id on every fragment of a streamed reply, where the OpenAI spec sends it on the first alone. The decoder read any non-empty id as "a new call starts", so one tool call came out of translation as one call per fragment: chatEncoder numbered each of them a call of its own, and anthropicEncoder and responsesEncoder opened a content block and an output item for each, every one holding a slice of the arguments and none of them valid JSON. An agent asking for a tool through such a relay was answered several broken calls instead of the one it made — Claude Code, which asks on the Anthropic API, among them. A fragment now continues the call already open when its index matches and it either names no id or names the id already open; only a new id or a new index starts the next one. (#395)
usage, gateway, gui: a reply that reasoned is timed by its answer, from its first text, not by all its output from its first token (tony on Discord #general: Usage › Requests read "gpt-6.1-sol 163 token/秒 · 8,036 个请求 · 首字 14 秒" and asked whether subagents were added up) — speed was every output token over the time after the first content (text, reasoning or a tool call). Output counts the reasoning, and a model whose reasoning is hidden (OpenAI's encrypted reasoning, a summary sent when it is done, Claude's omitted thinking) writes it before the stream shows anything, inside the TTFT; so a 2,000-token think and a 200-token answer over the 1.5 s after the first token read 2,200 in 1.5 s, 1,467 tok/s. Concurrency was not the cause: the aggregate is Σ tokens over Σ per-request windows, so subagents running at once never add up. In the owner's own usage.jsonl codex/gpt-6-luna turns read up to 600 tok/s (out 496, reasoning 224, 11,055 ms, ttft 10,228) and its aggregate 133; it is 80 now. usage.DecodeOf is the one rule: a reply with reasoning counts out − reasoning over ms − firstText, one that reasoned and wrote only tool calls tells no speed, one without reasoning is timed from its TTFT as before; DecodeWindow's burst cut (#731) still applies. Record.Decode feeds summarize, the ledger's Totals and its timeline Parts; a route keeps its reply's reasoning (Route.Reasoning, from gateway.go and codex_backend.go), and routing.js's decodeOf/speedOf times its rows, the story and a session's average by it (an older route takes its served usage's reasoning); app.js's ledDecode does the Usage page's rows, whose tooltip says the reasoning left out, and the Output speed help says the rule, in en, zh, ja and de. History is read the same way, as it keeps the reasoning and the first text. Routing never ranks by speed. TestReasoningReplyTimedByItsAnswer (the 2200/2000 turn, a summary 10 ms before the text, a concurrent subagent, tool calls only, reasoning over output, no reasoning, not streamed: 1,200 tokens in 11 s through summarize, addRow, LedgerSeries and Decode) fails without the change and passes -race -count=10; TestUsageKeepsTimeToFirstToken now has its paced replies report reasoning and checks the route keeps it. reasoning-speed.test.cjs (Routing rows 133, 100, none and 100, the story never 1,467, the session 105; the Usage rows, tooltip and KPI the same; no sideways scroll) passes on Chromium and WebKit in en, zh, ja and de, and fails against the old assets (1467 tok/s); routing-request-metrics, routing-burst-speed, usage-speed, usage-ledger-detail, i18n-keys, gui-ja, gui-de, ledger-hscroll and click-scroll pass on both. go test -race -tags nogui ./internal/... under a temp HOME passes but internal/proc's TestUserPath ("the login shell's PATH never came"), which failed under the full run's load, passes 3/3 alone and is not touched here; go vet, gofmt -l, linux and windows (nogui) builds pass. Not tried with tony's data. docs/reference.md says how output speed is counted.
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
gateway: a gateway key can be held to some accounts (#905, LCorleone) — a key could burn any account a provider had: two coding plans of one vendor, and a key given to someone had them both. A key now keeps an optional list of accounts and keys (caller-keys.json "accounts", none = every account, so older files read as before): "<provider>/<account>", an account by its stable id — the login's, set once it is written and kept through renames (logins.json's savedLogin id, written by every write and stood in for by one made of the name until then, so an entry made before stands), a key by its fingerprint — every one a provider has when the list is set, and one gone later (a key rotated, an account signed out) kept as it is, matching nothing, holding the key closer, never wider. The command line and the menu name an account by who is signed in and keep its id. Routing holds a plan to the key's accounts beside its models: a candidate whose account the key may not use is left out — through a routing group the key names no less, for naming a group is not naming its accounts — the plan's order keeping step and its left-out list marking what the key held out (Weighed's held); magpie's own calls for the key — a web search, a picture described, a Codex title — are of the models the user picked, so the models don't hold them, but their spend lands on an account all the same, and the accounts hold them too. When every account or key a model has is one the key may not use, the request is refused with a 403 in the API's own error shape naming the model and the accounts the key may use, logged as a rejected call: after a usage cap's 429 and before an account barred for the model's, itself before the no-member 404. /v1/models and the Anthropic and Gemini lists show the key only the models an account or key it may use serves (a provider's account set not to serve one, #474, is none of the key's); a provider with no account and no key (a local ollama) is one the key names no account of — its models stay listed and its asks pass — while an entry kept after its provider lost its last account or key serves none, holding the key closer. The embeddings, images and videos endpoints hold their candidate to the key's accounts the same way, and counting tokens counts on those alone (the local estimate where it may use none). System One asks its decision model of an account or key the key may use — and sends on that key, of those in use, not the provider's first, which a key held to a later one was spent on; the videos and images keyed paths send on one the key may use too, held to a later key no longer refused for the first alone. Set it in the app's menu the models are picked in — the accounts after the models, an account by who is signed in with their plan in the note, a key by its fingerprint, what the CLI kept that no provider has now kept as "Not signed in now" — or
magpie gateway-key accounts <id> codex/me@example.com openai/<key id> (all takes it off); gateway-key list has an ACCOUNTS column. en/zh/ja/de, the German "{n} accounts" main already had left to it rather than written twice. The images API's fan-out over a subscription's accounts is held to the key's accounts as one: one of the key's whose plan refuses to draw no longer hands the pen to an account it may not use, and the second in the order, when it is the key's, draws what the first may not (drawOnAccounts filters the candidates, the account pinned to with the account header told plainly when the key may not use it). The Routing view's left-out list says an account the key held out for what it is, not that its plan lacks the model, in Chinese, Japanese and German too; the key's badge names its accounts by who is signed in, not by the stable ids it keeps. (#932)
vision: a model whose list says nothing of images is given the image described, and the description is in Routing as its own call (Discord, Fate: since v0.1.725 images reach a text-only model in Codex, but it reads them badly and Routing shows no call to the Vision model) —
19e239c7 tells agents every model takes images while a describer is set, but the gateway described an image only for a model whose list says explicitly it takes none (ImageInput false); a model the vendor's /models says nothing of (ImageInput nil — most API keys' models, which magpie counts text-only and agents were told so before) had the image sent on as it came, so the text-only upstream got the raw image (dropped, or read by whatever it does with it) and the Vision model was never asked. Now, while a describer is set, a model that isn't known to see (its catalog entry's Images: the list's answer, the user's override, else models.dev) is given the description, on every API (Responses and Codex's /backend-api/codex, Chat, Anthropic, Gemini), for its current turn, older turns and tool results (function_call_output's input_image), and as a routing group's member (blindTo). Without a describer an unknown model is passed images as before. The description was already a call through the gateway (agent magpie-vision) but untied to anything; it now has kind "vision" ("Image description" in Routing, zh 图片描述), For naming the agent and model it described for, and the request's session, so it shows in that conversation's Routing beside the turn. The describer's prompt (every text transcribed, layout, values) and the image (sent whole, never downscaled) were already right. TestModelNotKnownToSeeIsGivenTheDescription (data: and https input_image on /v1/responses and the Codex backend path, Chat), TestModelNotKnownToSeeToolAndGroupImages and TestDescriptionIsInRouting fail on the old code and pass. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestUserPath, TestCodexVersion, TestClaudeRunEndsWhenTheClientLeaves and davsync, timing tests under load that pass alone and in their packages rerun. Not tried in the Codex App with a real account.
gateway: a gateway key can be held to some models (#882, LCorleone) — a key could reach every model magpie serves. A key now keeps an optional list of models (caller-keys.json "models", none = every model, so older files read as before): <provider>/<model> or <provider>/*, matched against the provider that serves the call, so a bare model name is resolved first and OpenRouter's "anthropic/x" is not Anthropic's. Such a key sees only its models in /v1/models, /v1/models/{id} and the Anthropic (Claude Desktop) and Gemini lists, a routing group only when every member is its; asking for another, by id, bare name or through a group with a member it may not use, is refused before any provider is asked with a 403 in the API's own error shape (permission_error) that names the key and its models, logged as a rejected call like an over-limit key's 429; a fallback it may not use is skipped; embeddings, images and videos it names are held too. magpie's own calls for it (a web search, a picture described, Codex titles with the model Settings name) are marked in their context, not by a User-Agent a client could send, and go on. Set it with the new badge on the key's row in the Gateway page (the app's menu: All models, every model of a provider, each model; sent when the menu closes) or
magpie gateway-key models <id> openai/gpt-5 anthropic/* (all takes it off); gateway-key list has a MODELS column. The app's menu, fitting neither below nor above its button, now goes on the roomier side and scrolls instead of running off the window. en/zh/ja/de. TestGatewayKeyModelsHoldAKey (lists, 403s by id, bare name and group, Anthropic shape, fallback skipped, magpie's own call, a free key) fails without the change; TestModelPatterns, TestKeyModelsKept, TestGatewayKeyModelsCLI, TestCallerKeyModelsRoutes pass; gateway-key-models.test.cjs (Chromium and WebKit, en and zh: no native select, menu inside a 420px window, picks sent on close, All models, no scroll, no left border, 560px fits) passes with gateway-connect-keys, gateway-key-limit, gateway-caller-keys and click-scroll. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
routing: a group in a group is routed by its own routing whatever the outer group's, and an account with five hours and no week is ranked by its five hours (#576: 只有五小时额度的账号在智能/重置前用完下是否合理;嵌套路由组时上层路由是否会干预下层) — a group routed other than In order (Smart, In turn, Least used, Weekly pace, Manual) put every account and key of every group in it into one pool and weighed them together, so A (Smart) holding A-1 and A-2 (each In order) ranked all five of their accounts and keys by Smart and A-1's and A-2's own In order did nothing; only an outer In order let each inner group route itself. planLevel now plans each group in the group by its own routing, whatever the outer's, and weighs it as one beside the outer group's own models' keys, by the first of its own order that isn't resting; where that lands, the inner group is tried whole in its own order, the trace in the same order. As for the five hours: Smart already ranked such an account (Claude Enterprise) by its five hours, the biggest window it has, so ahead of every week renewing later, but a five hours not started (no reset told) counted as a reset not known and went after every week; Allowance.Renewal now has a window not started that says its span renew that long from now, which Smart sorts by (the trace's renews unchanged). Weekly pace took an account with no week as a fresh week with the share used (100/168 per hour), so a week with 80% left and five days to go went ahead of an Enterprise account whose five hours were being lost; it now goes by what its five hours have left per hour until they renew, the whole span when not started, except where the reading may leave a week out — a Claude account only heard of from Claude Code's rate_limit_event, never read whole by /usage (QuotaWindow.partial, claudeUsageEntry.whole) — which keeps the fresh week. The group editor's Smart and Weekly pace hints and the trace's say how an account with five hours and no week is ranked, a hint under Routing says a group among the models keeps its own routing, and the docs (en, zh) say both. TestGroupInGroupKeepsItsOwnRouting (Outer rotating over two In order groups: b c d a without the change, c d a b with it), TestSmartFiveHoursAlone (a five hours not started went after a week in two days), TestFiveHoursAlone, TestClaudeHeardOnlyIsPartial; TestWeeklyPace and TestAllowancePace now pin the five hours alone. settings-groups, group-member-fast, group-found-off, routing-idle-frame pass in WebKit. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState, TestProvidersAnswerWhileListsComeIn, TestUserPath, TestClaudeSignedOut flaked; the last passes 3/3 alone).
gateway, gui: a provider's keys can be routed by weight, a key weighing 3 taking three requests for every one a key weighing 1 takes (dengtongcai, #841) — Routing already spread requests over a provider's keys in order, in turn, least used first or by pace, but never in a set proportion: two team keys meant to split 75/25 could only go evenly or one after the other. Routing now has By weight (routing "weight") for a provider with keys: each key that's on shows its weight and its share beside it in the editor (Weight 3 · 75%), a click edits it in place and posts keys/weight (provider.SetKeyWeight, 1 to 1000; none or 0 counts as 1, kept out of the file), and providers.json takes it as "weight" on each of keys (keyWeight for the first). The gateway picks as NGINX's smooth weighted round robin does (weighted.go): 3, 1 and 4 go round in every 8 requests in that ratio, never bunched; the others follow in their order for failover. A key resting after a failure is left out of the round while it rests, the others taking its share by their weights, and goes last. A conversation still stays with its key as Stays says (cache-warm across turns by default, within a turn always), and a request kept so is counted against the key that took it, not the one the round picked, so new conversations make up the share; Stays Off gives the ratio request by request. The weight goes with its key through Make first, the editor's Save, backups and WebDAV sync. The Routing page offers By weight for key pools and explains a pick by it; sink (Goes to the back) is off for it as for In turn. zh, ja, de strings. TestWeightedKeys (3:1:4 exact in each of 50 rounds of 8 through perKey and route; with one resting the other two 75/25 and the resting one last; a kept request made up by the next picks) and TestSetKeyWeight fail on the old code (no Weighted); key-weight.test.cjs (WebKit, en and zh: By weight shows the weights and shares, a click sets one and the shares redraw, Smart hides them, no scroll) fails on the old code (no By weight). key-import, panel-routing, routing-save, rate-limited-sink, routing-reset-deadline, account-arrange, gui-ja, gui-de and click-scroll pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
gateway, gui: a provider's keys can be routed by weight, a key weighing 3 taking three requests for every one a key weighing 1 takes (dengtongcai, #841) — Routing already spread requests over a provider's keys in order, in turn, least used first or by pace, but never in a set proportion: two team keys meant to split 75/25 could only go evenly or one after the other. Routing now has By weight (routing "weight") for a provider with keys: each key that's on shows its weight and its share beside it in the editor (Weight 3 · 75%), a click edits it in place and posts keys/weight (provider.SetKeyWeight, 1 to 1000; none or 0 counts as 1, kept out of the file), and providers.json takes it as "weight" on each of keys (keyWeight for the first). The gateway picks as NGINX's smooth weighted round robin does (weighted.go): 3, 1 and 4 go round in every 8 requests in that ratio, never bunched; the others follow in their order for failover. A key resting after a failure is left out of the round while it rests, the others taking its share by their weights, and goes last. A conversation still stays with its key as Stays says (cache-warm across turns by default, within a turn always), and a request kept so is counted against the key that took it, not the one the round picked, so new conversations make up the share; Stays Off gives the ratio request by request. The weight goes with its key through Make first, the editor's Save, backups and WebDAV sync. The Routing page offers By weight for key pools and explains a pick by it; sink (Goes to the back) is off for it as for In turn. zh, ja, de strings. TestWeightedKeys (3:1:4 exact in each of 50 rounds of 8 through perKey and route; with one resting the other two 75/25 and the resting one last; a kept request made up by the next picks) and TestSetKeyWeight fail on the old code (no Weighted); key-weight.test.cjs (WebKit, en and zh: By weight shows the weights and shares, a click sets one and the shares redraw, Smart hides them, no scroll) fails on the old code (no By weight). key-import, panel-routing, routing-save, rate-limited-sink, routing-reset-deadline, account-arrange, gui-ja, gui-de and click-scroll pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
workbuddy: its "Illegal API invocation from an unapproved channel" says what to do (QuentinHsu on Discord, deepseek-v4-flash on WorkBuddy from Codex) — WorkBuddy, both builds, refuses a chat whose system prompt is Codex's or Claude Code's (#182), whatever the headers, and magpie does not rewrite that prompt. The refusal now carries "WorkBuddy refuses chats from Codex and Claude Code (their system prompt); use it from Hermes, OpenCode or Pi, or add another provider to this group", to the agent and on the Routing page (apart from the vendor's words, in the page's language); a group still passes the request on to its next member. workbuddy_refused_test (provider, gateway) and routing-wb-refused.test.cjs (Chromium, WebKit, en/zh) fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; click-scroll, old-webkit and the routing tests pass.
zed: every tool result sent to Zed says is_error, false as well as true, so a turn after a tool call is answered again (yetone/magpie-releases#9, aurevae: "Zed: failed to parse Anthropic request: missing field
is_error" on Pi through /v1/chat/completions and on /v1/messages) — magpie's Anthropic request leaves is_error out when it is false, as Anthropic's API allows, but Zed's cloud refuses a tool result without it, so with a Zed account every request after the model's first tool call failed with a 400. The reporter's own table shows the same request going through with is_error true. Zed's Anthropic request now has is_error on each tool_result; the tool inputs' numbers are kept as they were. TestZedToolResultSaysIsError checks a successful and a failed tool result, and a 20-digit number in a tool's input; it fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestDevinSignedInOnlyInMagpie, a known flake under load, passes alone).
gateway: an upstream that answers 200 with a web page, with nothing at all, or with JSON that isn't, has failed as a 502 does, so a group asks its next member (#1012, cth123456: Codex on group/medium, agentrouter/deepseek-v4-flash now and then answered 200 text/html; the log said status=200 done=true in=0 out=0 served=null, codex/gpt-6-luna was never asked, and Codex failed with "stream disconnected before completion: stream closed before response.completed") — the candidate loop judged a try by its status alone, and holdWriter passes a 200 that is neither text/event-stream nor application/json straight to the agent, so the page went to Codex as the reply. notAnAPIReply now reads every 2xx (but 204) an upstream gives, in forwardOnce (every relayed and translated request, every API, plugins included) and in the built-in clients' own requests (the ChatGPT backend's /responses, Kiro, Zed, Qoder, Devin, Cursor, Command Code): a text/html reply, a body that begins as a web page whatever its Content-Type, an empty body, or one sent as JSON that doesn't begin with { or [ is turned into the 502 it stands for, with a JSON error saying what came back ("answered 200 OK with a web page (text/html), not an API reply: “Just a moment...”", the firewall hint for a block page). It then fails over, rests a minute and is logged as any 502 before the reply begins does; a provider with nobody after it hands the agent that 502 rather than the page. Only the first bytes are peeked, which a stream waits for anyway; a read that fails is left to the reader, a compressed body and anything else as it came. TestNotAnAPIReplyFailsOver (Chat, Responses and Messages, streamed and not, a member answering a Cloudflare page as text/html, as JSON and as SSE, an empty body as JSON and as SSE, and plain text as JSON; the next member must answer and the log name why) fails without the change in all 36 cases (the page relayed to the agent, or b never asked, or no reason logged) and passes; TestNotAnAPIReplyAlone (a 502 with the reason, logged 502) and TestCodexBackendWebPage (the ChatGPT backend's page reached Codex as a 200) fail without it and pass; TestAPIReplyUntouched keeps JSON after whitespace, SSE with and without a Content-Type, gzip, XML, a 204 and a 404 as they came. -race -count=10 on them; go test -tags nogui ./internal/gateway ./internal/provider, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay (agentrouter) itself; the page is a Cloudflare challenge's, as the issue has only its Content-Type. docs/subsystems/gateway-routing.md says it.