88 Commits
Author SHA1 Message Date
yetone 3e5bc3d8fe gateway, usage: conversation text is kept only under a session the client named, and a client's "request-…" session counts like any other (#1355)
What changed: with recording on, a request that names no session used to
get a made-up "request-…" id and its text saved under it. Nothing listed
that id and GatewaySessionByID turned it away, so the text could never be
opened. magpie now makes up no id: such a request isn't recorded, and the
X-Magpie-Session reply header only echoes the client's own session.

With no made-up ids left, the "request-" prefix checks in appendUsage,
OTel, Usage's sessions, Routing's vias and the gateway Sessions projection
only dropped sessions clients named themselves (appendUsage wiped them from
the ledger). They are gone. The tests that held the old rule now say the
new one, and gateway-sessions.md says it.

Run: TestGatewayConversationNeedsTheClientsSession and
TestGatewayConversationClientRequestPrefixedSession fail on the PR's code
(a made-up id is returned; the ledger keeps 0 of 2 calls) and pass here;
putting appendUsage's prefix check back turns the second red again.
gofmt -l clean; go vet; GOOS=linux and GOOS=windows go vet -tags nogui on
gateway, gui, sessions, settings, usage; go test -race -timeout 900s on
those five: ok (gateway's TestCORSKeyThroughTheServer rerun after its fix).
GUI: gateway-conversations, gateway-mode and all sessions-* tests, 216
cases in Chromium and WebKit: pass.
2026-10-10 04:24:18 +08:00
木子不是木子狸 c9b8263378 sessions, gateway: Sessions lists gateway sessions, and can keep their text on this computer when turned on (#1355, mu-zi-lee)
What changed: the Sessions page lists sessions seen only through the
gateway (from the usage ledger, read-only, a native session of the same id
wins), and shows in gateway mode. A Sessions switch, off by default and
confirmed before it turns on, records each gateway exchange's text under
the config dir (scrubbed, at most 7 days and 256 MiB, cleaned hourly).
Settings sync and restore can't turn it on; POST /api/sessions/recording
turns it on or off and can clear what was kept.

Why: users of magpie as a gateway (gateway mode, LAN clients) had no way
to see their conversations; the owner chose this design on the PR.

Squashed from PR head 6a343ba1 onto origin/main. Conflicts: gui-shell.md
(kept main's placeMenu text, took the PR's Sessions in gateway mode) and
settings_keep_test.go (main's fields plus GatewayConversations).

Run: go build ./..., go vet on gateway, gui, sessions, settings, usage;
go test -race -timeout 900s on those five packages: all ok.
2026-10-10 04:24:18 +08:00
yetone ee657c0969 usage: a Kimi Code membership's k3 is counted at Kimi's API price for kimi-k3, not ≈¥0.000, and kimi-for-coding is unpriced rather than free (#1370, maicent: Kimi Code 的模型计费都是 0)
Why it was 0: magpie already counts a subscription's calls at the API list
price (a Claude or Codex account at its maker's, cf6b82f7). A Kimi Code
provider is looked up in its own models.dev catalog first, and models.dev
lists every model of kimi-code-plan-global / kimi-code-plan-cn at $0 (what
the membership charges per token), so k3, k3-256k, kimi-for-coding and
kimi-for-coding-highspeed were all "priced" at $0. The same $0 catalogs
were also among the maker catalogs, so MakerPrice("k3") was $0 too.

What changed (internal/provider/models.go):
- membershipCatalogs (the two Kimi Code plan catalogs) are never read for a
  price: ListPrice, MakerPrice and PricedName skip them.
- A provider served at a Kimi Code catalog prices its model as the Kimi API
  model it is (kimiCodeAPI), per Kimi Code's docs
  (https://www.kimi.com/code/docs/en/, read 2026-10-09): k3 and k3-256k are
  K3 -> kimi-k3; kimi-for-coding-highspeed is K2.7 Code HighSpeed ->
  kimi-k2.7-code-highspeed. models.dev's canonical_model_id agrees. The
  prices come from moonshotai's catalog, which matches Kimi's pricing page
  (https://platform.kimi.ai/docs/pricing/chat: kimi-k3 $3 in, $15 out,
  $0.30 cached, $3 cache write; kimi-k2.7-code-highspeed $1.90/$8/$0.38).
- kimi-for-coding is K2.8 Preview, which the Kimi API doesn't sell: it has
  no API price, so it is now unpriced (the "—" / "no known price" the GUI
  already shows) instead of ¥0.000. The user can give it a price with
  `magpie model price kimi-code/kimi-for-coding …` or in Names & levels.
- The alias is scoped to Kimi Code providers: a relay's own "k3" keeps its
  own catalog price; Moonshot pay-as-you-go's kimi-k3, an OpenCode free
  model ($0) and Codex's maker price are unchanged.
- PricedNameFor(provider, model): the Requests list's "API price
  reference" names kimi-k3 for a Kimi Code k3 (internal/gui/usage.go).
Docs: reference.md (What a model costs) and providers-accounts.md.

Tests: TestKimiCodeListPrice (internal/provider; fixture
testdata/kimi_code_catalog.json is models.dev's own moonshotai and Kimi Code
plan entries) and TestKimiCodeCost (internal/usage; the reporter's k3 row,
240K in / 78.4K out / 10.0M cached, counted at $4.896 in the ledger and the
summary, kimi-for-coding unpriced, moonshot/kimi-k3 unchanged). Both fail on
the old models.go (every Kimi Code model priced at $0, MakerPrice(k3) $0,
k3 cost=0, unpriced 0); PricedNameFor's check fails with the alias turned
off ("k3-256k").

Run: gofmt -l (empty); go vet -tags nogui, GOOS=linux go vet -tags nogui,
GOOS=windows go vet -tags nogui on internal/provider, internal/usage,
internal/gui; go build, GOOS=linux go build -tags nogui, GOOS=windows go
build; go test -tags nogui -count=1 ./... under a temp HOME with pinned
caches: all ok; -race -count=3 on the new tests ok. No GUI asset changed,
so the Playwright suite wasn't run. Not tried with a real Kimi Code
account or the reporter's usage log.
2026-10-09 11:48:41 +08:00
yetone d1d4b7ed2a usage: a heatmap of the last 53 weeks on the Requests tab, by tokens, requests or cost (#1369, nianlee-official)
What changed: the Requests tab of the Usage page shows a heatmap card like
CC Switch's: a cell per day for the last 53 weeks, Monday on top, month
names over the weeks and weekday names at the edge, a Tokens / Requests /
Cost switch (kept in localStorage), a five-shade Less…More legend, and a
tip on each day with its date, tokens, requests and cost.

How: GET /api/usage/heatmap (usage.HeatmapOf) reads QueryPage's cached
request index over a hidden period, "heatmap", which starts on the Monday 52
weeks before this week's. Its rows are summed by local calendar day in both
the compact and the ledger path. So no raw log is scanned per render, and the
days count what the chart counts:
- the page's agent/provider/model/key filters, but not the period or day
  picked;
- the gateway's own rejections left out;
- cost of priced calls only, with an unpriced count.
The days are in the local time zone and the shades use theme tokens
(.l0–.l4, as in the sessions calendar). There are no left-border accents and
no native <select>. A click never scrolls the page. A narrow window scrolls
the card sideways inside itself, with the newest week in sight (kept there
as it's laid out or resized, until the reader scrolls back), and the weekday
names stay at the card's edge. The card is hidden when no day has calls. New
strings are in zh, zh-TW, ja and de.

Run:
- go test -tags nogui ./internal/usage ./internal/gui (new TestHeatmapDays,
  TestHeatmapOf, TestUsageHeatmapRoute); gofmt -l clean; go vet darwin,
  GOOS=linux and GOOS=windows go vet -tags nogui on both packages; go build
  for darwin, linux and windows.
- New internal/gui/tests/usage-heatmap.test.cjs in Chromium and WebKit, at
  420 and 1100, in en, zh, zh-TW, ja and de, plus dark and the empty case.
  It checks:
  - the cells, labels, legend and the busiest and quiet days;
  - the newest week in sight and only the card scrolling sideways;
  - the tip's text and that it stays inside the card;
  - that a metric click moves nothing and is kept;
  - that the filters, but no period or day, are sent.
- Breaking checks: with the keep-at-end guard reverted, 5 of its 11 subtests
  fail in WebKit; with the server's heatmap dropped, TestUsageHeatmapRoute
  fails.
- i18n-keys, click-scroll, gui-de, gui-ja, gui-zh-tw, sessions-usage-open
  and all usage-*.test.cjs GUI tests pass in both engines (161 each).
- A sandboxed `magpie web` (temp HOME, 2605 fake calls over 400 days, ports
  3671/3672): the card fills 369 days at 1280 and 420 wide, and the tip
  reads "2026年10月1日周四 / 2.6M token · 23 个请求 · ≈$1.05+".
2026-10-09 11:33:47 +08:00
yetone f368f709c3 test(usage): TestLogChangeTrustedAppendReuse counts the prefix it re-hashes, not an empty fingerprint (CI red on Windows since 439cfc4a)
What changed. TestLogChangeTrustedAppendReuse (Windows-only) took
`after.hash != ""` to mean "the read after a trusted append re-read the
historical prefix". Since #1357 (439cfc4a) the trusted, settled path keeps
a fingerprint without re-reading: the old snapshot's SHA-256 state is
continued over the appended lines. So the Windows "usage cache" CI step
failed with "trusted append scanned the historical prefix" while the
guarantee still held. The test now wraps logRecordHash, as
TestUsageAppendReadHashesNoPreReadScan does, and fails if the read after
the append hashes any bytes through it; it still checks the sealed blocks
are reused, and now also that the continued fingerprint equals a fresh
SHA-256 of the parsed bytes.

Verified rather than assumed: in log_index.go the trusted path skips the
prefix check (`trusted ||` short-circuits it) and the after-read check
(`!trusted || !settled`), and the parse seeks to old.off. Siblings: the
only build-tagged test in internal/usage is log_change_windows_test.go.
TestLogChangeSnapshotReuseWithoutFingerprint clears hash only to simulate
a cache hit with no fingerprint, which a change-time stamp alone still
serves; it doesn't use hash as a proxy for a re-read and passes.
incremental_log_test.go:147 asserts hash != "" on the unsettled path,
where the fingerprint is still required. Neither needed a change.

What was run. Windows 11 box (xiaomi-gpu; no Go there, so test binaries
were cross-compiled with go1.26.8 GOOS=windows and run in a scratch dir
with USERPROFILE/APPDATA/LOCALAPPDATA/TEMP redirected):
- origin/main's test, -count=5: FAIL x5 at :164 "trusted append scanned
  the historical prefix" (CI's failure reproduced).
- fixed test, -count=5: PASS x5.
- break A (continuation always re-verifies the prefix: `trusted ||`
  removed): FAIL x5 "re-hashed 143726 bytes of the 143726-byte
  historical prefix".
- break B (after-read check runs on the trusted path too): FAIL x5
  "re-hashed 143849 bytes of the 143726-byte historical prefix".
- CI's "Windows usage cache" -run set (no tags): all 11 PASS.
- whole package (-tags nogui): everything passes but
  TestDesktopSessionAccountMetadata, which fails identically with
  origin/main's binary; that is #1356, already filed, not touched here.
macOS: go test -tags nogui ./internal/usage/... ok (temp HOME, pinned
caches). gofmt -l clean; go vet, GOOS=linux and GOOS=windows go vet
-tags nogui ./internal/usage/ clean.
2026-10-09 07:20:48 +08:00
yetone 439cfc4ac4 usage: the parse is the log fingerprint, dropping the build's full re-read before it (#1357)
What changed. A usage-log snapshot's fingerprint is now produced by the
parse itself: each complete line read is fed into a SHA-256. When a build
continues the old snapshot's blocks, it continues the old snapshot's digest
too (crypto/sha256's state, kept on the snapshot as `digest`); otherwise the
digest starts fresh from byte 0 with the parse. The build's before-read
logRecordHash over the whole file is gone. The fingerprint covers the bytes
actually parsed (`off`), not info.Size(), so a half line another writer is
in the middle of appending is no longer counted as read; the unchanged
check, the prefix check and the after-read check all hash `off` bytes.

What stays, as #1328's tests require (ggbdpq's analysis on #1357): the
unsettled unchanged check, the prefix re-verification on a non-trusted
continuation, and the after-read check, which now compares the parse's own
digest against the file as it is after the read. A same-tick rewrite is
still caught at the build, not at a later read. On the trusted, settled
path nothing is re-read, as before; that snapshot now also carries a
fingerprint (the continued digest), so a later non-trusted append can
verify its prefix instead of rebuilding from byte 0.

Test changed: TestUsageRewriteBeforeFingerprintIsNotCached counted
logRecordHash calls and rewrote the file at call 1 (after the before-read
hash, before the parse) or call 2 (after the read). With the parse being
the fingerprint there is no call 1 any more, so the hash=2 case could no
longer fire. Its guarantee still holds and is still tested at both
boundaries: "before-read" rewrites at the build's first change-time look,
after its stat and before it opens and parses; "after-read" rewrites at the
after-read fingerprint check. Removing the after-read check makes
after-read fail. No other test changed.

New tests:
- TestUsageAppendReadHashesNoPreReadScan: 20,000-row settled log
  (2,760,615 bytes), 5 x Append + Summarize(Today), counts the bytes
  logRecordHash re-reads. origin/main: 7,728,984 bytes per cycle (2.80x the
  log), FAIL; this commit: 4,968,615 (1.80x), PASS. Wall time per cycle on
  an M-series Mac, 3 runs each: 3.43-3.52 ms before, 2.28-2.52 ms after
  (~32% less). The remaining two passes are the prefix check and the
  after-read check, which the same-tick guarantee needs.
- TestUsagePartialLineThenCompletion (settled and unsettled): another
  writer's half line, a read, then the line completed; the fingerprint
  covers the parsed bytes only and the completed record is counted. On
  origin/main it fails on the fingerprint check (it covered the half line).

Break checks: no after-read check -> RewriteBeforeFingerprint/after-read
fails; no prefix verification on a continuation -> SameSizeAndMtimeRewrite
append=true, SameTickRewrite append=true and LogGrowingRewriteThenAppend
fail; lines not fed into the digest -> SameTickRewrite fails.

Run: go test -tags nogui ./internal/usage/... ./internal/gateway/... (ok,
temp HOME, pinned caches, removed after); go test -race ./internal/usage/...
(ok); -race -count=10 on the rewrite/append tests (ok); gofmt -l (empty);
go vet, GOOS=linux and GOOS=windows go vet -tags nogui ./internal/usage/
(ok). Not tried on Windows or Linux hardware; the change is
platform-independent hashing over the same read.
2026-10-09 06:54:28 +08:00
Ethereal49 3c629133c2 usage: refresh caches after same-tick log rewrites (#1328)
* usage: same-tick rewrites refresh cached totals and request pages

Trust change stamps only when they were settled at the snapshot read's
start. Fingerprint recent snapshots and append prefixes, and discard
fingerprint trust when the content changes during a read.

Fixes https://github.com/yetone/magpie/issues/1305

Verified: deterministic old-predicate FAIL/new PASS; usage tests with race;
nogui vet; Linux nogui and Windows builds; usage test cross-compilation for
Windows, Darwin and the unsupported-stamp fallback on FreeBSD.
Full nogui suite exposed the independent sessions TestList empty-session
limit failure. Native Linux GUI vet/build lack pkg-config and GTK/WebKit;
native Windows/NTFS execution was not run.

* usage: keep FILETIME stamp ages valid beyond UnixNano range

Convert whole seconds and the 100ns fraction separately. Checked the epoch, current dates with fractional ticks and year 2500; Windows build and usage test cross-compilation pass. Native Windows execution was not run.

* usage: keep history cached after an append settles

An unchanged log now validates a recent snapshot's nonempty fingerprint
before publishing a settled clone. Preserve its version and sealed blocks
so the summary and page caches stay warm without mutating readers' snapshots.

Follow-up to the review on https://github.com/yetone/magpie/pull/1328,
for https://github.com/yetone/magpie/issues/1305.

On e28e686, the real-clock append/read/settle/read tests fail with a
version bump and 32,428 additional allocations over 4,000 versus 10 rows.
With this change the targeted race run passes, with 165 extra allocations.
The existing performance test now measures this path on the real clock.

Verification uses Go 1.26.8, a temporary HOME and pinned Go caches.
Native Linux GUI checks require missing pkg-config/GTK/WebKit libraries;
native Windows/NTFS execution is not available. No GUI assets change.
Implementation and verification used AI assistance.

Final checks: usage race PASS (421 test results); nogui suite PASS (7,273
test results, 44 packages; 21 skipped tests, 3 packages without tests).
Nogui vet, Linux nogui build, Windows build, and usage test cross-compilation
for Windows amd64, Darwin arm64 and FreeBSD amd64 PASS. Native Linux GUI
vet/build were attempted and failed at the missing pkg-config executable.
2026-10-09 02:48:29 +08:00
yetone ed31293c0e usage, gui: a reply its vendor let go in a burst over a couple of hundred milliseconds tells no speed, not 2,237 tok/s (John on Discord)
John's Kimi Code rows after v0.1.1119 (OpenCode, group/k3-glm, api.kimi.ai,
Chat, k3-256k): 550 tokens, 22 of them reasoning, in 16 s with the first
content at 5.2 s read 2,237 tok/s, and 395 tokens in 19 s 2,012. 528 answer
tokens at 2,237 tok/s is 236 ms: the burst the vendor let go at the end.
de5455b8 measured how long the content took to come (flow) and took the
window as no longer than it, so a burst sent in one write (flow ~1 ms) fell
under the 100 ms floor and told none, but a burst let go over 236 ms of
many events read its tokens over those 236 ms, under the 10,000 tok/s
ceiling. The time a burst takes to come is not the time it took to write.

usage.DecodeOf now tells no speed for a reply held back and let go at its
end, both ways the burst can show:
- its content came in under a quarter of its window (flow*4 < window):
  the text came early, the tool call it wrote over the rest of the time
  came at the end (window 10.6 s, flow 236 ms);
- it reasoned, and its answer came over 20 times faster than its
  reasoning came between its first content and its first text: the text
  came with the burst, so the window from it is the burst itself (22
  reasoning tokens over 10.5 s, then 528 in 236 ms). Hidden reasoning
  (OpenAI's, Claude's omitted) comes before the first content and leaves
  that time short; streamed reasoning comes at its answer's pace.
Between the two the window is the whole window again, not the flow.
routing.js's decodeOf and app.js's ledDecode do the same; docs/reference.md
says it.

Tests: TestKimiHeldBurstTellsNoSpeed, a fake Kimi Code (Chat: four
reasoning_content deltas, then text and a tool call whose arguments come
as 40 writes 6 ms apart over the last 236 ms; usage 538/550/22), through
the real gateway for a Chat, a Responses and an Anthropic agent, with the
wait shortened (first content 1.3 s, end 4 s): the held call and the held
answer tell none (before: 2,112 to 2,237 tok/s on all three), a steady
50 tok/s answer after the same reasoning keeps ~50. TestKimiHeldBurstAtJohnsTimes
feeds firstToken the same streams at John's times (5.2 s, 16 s): before,
2,138 and 2,237 tok/s. TestJohnsRowsTellNoSpeed: his rows as records
(before: 2,237, 2,237, 2,015). routing-burst-speed and usage-speed gain
his rows and a steady 50 tok/s one; they fail on the old routing.js and
app.js ("2237 tok/s", "2,237 tok/s") and pass now in Chromium and WebKit,
en and zh, with reasoning-speed, routing-request-metrics,
usage-ledger-detail, click-scroll, gui-ja, gui-de and i18n-keys.

Run: go test -tags nogui ./internal/... (golang:1.26.8 in Docker, as a
non-root user: as root, internal/agent's TestAside* write-refusal tests
fail because root ignores the file modes they rely on, unrelated to this);
-race -count=5 of the new and the burst tests; go vet (linux, windows,
nogui) on internal/gateway and internal/usage. Not tried against the real
Kimi Code API: the owner's Kimi Code key answers 403 (subscription has no
access), so how Kimi Code lets the reply go is inferred from John's
numbers; which of the two shapes his rows were is not known, and both are
covered.
2026-10-09 00:10:23 +08:00
yetone de3b1c646a usage: a subscription card's window says what the whole window holds, "Whole ≈ N tokens · ≈ $X", reckoned from what magpie routed through the account since the window began over the share used, at API list prices (Chiao on Discord)
usage.WithWindowHolds fills QuotaWindow.Holds on GET /api/usage/quotas and its refresh, from usage.jsonl: the card's provider (by its id now, so a moved plugin's card and a renamed provider count), its account (ProviderAccount, or an older record's Host), calls to the models the window counts (Claude's Opus-only or Fable week), not turned away, from the window's start up to when the share was read. Priced by catalog.Price.CostSplit at the maker's list price, else the provider's own list, never the user's own subscription price: Anthropic's 5-minute and 1-hour cache writes at 1.25x and 2x input, OpenAI's cached input and no cache write price. A call with no API price leaves the window tokens-only. The caller's cards are not written to.

Not told where it wouldn't be honest: under 5% used, nothing routed in the window, magpie's log starting after the window did, no reset or no span (credits with no reset, Copilot's premium requests), Antigravity's per-model family and pool windows, Aside windows, a card with an error, a balance, or one read from another computer. The tooltip says how it was reckoned and that use of the account outside magpie makes it read low (the request said 偏高; such use raises the share without adding to what magpie saw, so the estimate is lower than the truth). en, zh, zh-TW, ja, de.

Run: go test -tags nogui ./... in Docker (golang:1.26.8): all pass but internal/agent's Aside* chmod tests, which fail only as root and pass with --user 1000. The new tests fail with the log-start check removed, the model scope removed, the caller's windows not cloned, and 1-hour cache writes at the 5-minute price. Against a read-only copy of the owner's usage.jsonl, quotas.json and models.json: Claude's week (71%) ≈ 46K tokens / $14.16 from 165 calls, its Fable week and 5 hours (12%) nothing, every other card nothing (under 5%, or no reset). New quota-holds.test.cjs passes in Chromium and WebKit at 360, 440 and 1000px in en, zh (万), zh-TW (萬), ja, de, and fails with the new CSS removed; click-scroll, gui-ja, gui-de, i18n-keys, quota-* and usage-* pass (304/304). go vet -tags nogui (darwin, GOOS=linux, GOOS=windows) and gofmt -l on the touched packages pass.
2026-10-08 20:53:03 +08:00
yetone de5455b8fe gateway, usage, gui: a reply whose vendor sent its first words and held the rest to send in one burst tells no speed, not 1,367 tok/s (John on Discord)
John's request (OpenCode, a group served by Kimi Code at api.kimi.ai,
Chat) showed 1,367 token/s: its tokens counted over the time from its
first token to its end, though nothing came in between and the rest came
at once. The 100 ms / 10,000 tok/s bounds catch a burst only when the
first content comes with it.

firstToken now reads on past the first text and marks when each content
event's bytes came (one mark a millisecond, up to 65,536 marks and the
8 MB it reads), and flowMs is how long the content took to come from
where the speed is counted: the span from a tenth of its bytes to nine
tenths, scaled to the whole. A steady stream gets about its whole window;
one let go in a burst about 1 ms. It is kept as Call/Try/Route .flow and
usage.Record.flow_ms (and the Requests page's packed rows), and
usage.DecodeOf, routing.js's decodeOf and app.js's ledDecode count no
longer a window than it. A record without it reads as before.

Verified: TestBurstTellsNoSpeed, a fake Chat vendor that sends a word,
waits 600 ms, then 199 words and the end in one write (1,000 tokens), for
a Chat and a Responses agent: no speed; before, 1636 and 1655 tok/s. Its
steady case (a word every 60 ms) keeps 200 tokens over most of its
window. TestCodexBackendBurstTellsNoSpeed is the same through Codex's own
ChatGPT backend (before: 1,000 tokens in 601 ms). TestFirstTokenKinds now
expects the stream read past its first text. Go tests ran on Linux
(golang:1.26 in Docker; this Mac's syspolicyd held new test binaries at
launch). GUI: routing-burst-speed (a route with John's 5.1 s first token,
6.7 s and a 2 ms flow: was "1367 tok/s", now none) and usage-speed (a row
with flow_ms 2 shows "—") fail without the JS change and pass with it,
with reasoning-speed, routing-request-metrics and usage-ledger-detail, in
Chromium and WebKit (en/zh, ja/de where the tests have them); no strings
changed. Not reproduced with Kimi Code itself: whether it holds the
stream so is inferred from the numbers.
2026-10-08 20:11:05 +08:00
yetone b2432754e5 usage, gateway: Antigravity's gemini-3.8-flash answered as gemini-3.8-flash-n is no longer marked as another model (dumplings on Discord)
Google's Code Assist backend names the deployment serving Gemini 3.8 Flash
"gemini-3.8-flash-n" in modelVersion, for every level it is asked at. On the
owner's real Antigravity account (daily-cloudcode-pa, 2026-10-08)
gemini-3.8-flash-low, -medium and -high all answered gemini-3.8-flash-n, with
or without a thinking level. gemini-3.8-flash-n itself is a 404 and
fetchAvailableModels doesn't list it. 3.7/3.6 Flash's levels answer the bare
family name, which #462 already allowed. So the requests log's amber "actual
model" and its "probably swapped" note were a false flag.

usage.GeminiServing accepts the sent Gemini model's own name plus "-n".
Swapped and the readers for kept rows and routes (request_page row(),
history routedAgain) all use it, so rows already logged read correctly
too. It covers Gemini only. gpt-5 -> gpt-5-mini, gpt-5 -> gpt-5-n,
claude-sonnet-4-5 -> -n, gemini-3.8-flash -> -lite, -> gemini-3.7-flash-n,
-> -nano and -high -> -low are still swaps.

Verified: TestSwappedGeminiServingName, TestLedgerGeminiServingNameIsNoSwap,
TestKeptRowGeminiServingNameNotSwapped and
TestRouteKeptGeminiServingNameNotSwapped fail on the old code ("gemini-3.8-flash
answered as gemini-3.8-flash-n is the model asked for", "still swapped") and
pass with it. Sandbox magpie on a copy of the real Antigravity login: before,
the routing log had swapped:true on every gemini-3.8-flash-n try. After, chat
completions at none/low/medium/high and /v1/responses are not swapped, and
the ledger reads the earlier rows as not swapped. go test -tags nogui ./...
passed under a temp HOME. go vet ran for darwin/linux/windows, and the
linux/windows builds passed.
2026-10-08 15:15:37 +08:00
wizzy-yangandwizzy-yang e95852e301 usage, gui: 用量页补上各 agent 会话文件里的用量,请求页按「经过 / 不经过 magpie」拆开 (#948)
* sessions: dsh、ZCode、WorkBuddy 的会话文件里有各自花掉的 token,用量台账从来不读

Sessions 页面一直都在读这些 agent 自己的会话文件,用量台账没有:用量页只显示网关本
身应答过的调用——也就是装上 magpie 之前的什么都没有,而一个直接指向厂商的 dsh 更
是什么都没有。本机上装 magpie 之前就已经在用这些 agent(用它们自带的模型),那段
用量一直对不上账。

  * 新增三类"整文件读取"的调用来源,走 wholeCallReaders(它们的调用不是一行一条,
    或者文件不能从中间接着读):dsh 的会话是 zstd 分帧追加的;ZCode 用的是 OpenCode
    那几张表;WorkBuddy 的同一句回复会在多行里重复同一个 messageId。三者的 token
    语义各自换算成 Call 的 Input/Output/CacheRead/CacheWrite,调用缓存跟着记一份(v9)。
  * 每个来源都跳过经由 magpie 网关发出的调用:网关自己记过那一本账,还带着真正应答
    的供应商和账号;dsh 发往网关的请求不带会话头,两边事后对不上(token 数每次不同、
    又没有可配对的 id),硬读就是同一通调用记两次。dsh 认它被配置去的那个 provider
    (dshGatewayProvider:magpie / dial),ZCode 和 OpenCode 认路由里写的 provider
    (ocGatewayProvider)。WorkBuddy 那部分见下一个提交。
  * WorkBuddy 的调用里,同一句回复的多行只记最后一次(它带这个回复的总量);而它从
    旧历史搬过来的回复一条 messageId 都没有,每一条都是独立的一通调用——会话汇总的
    读法(workbuddyLine)就是这么数的,这里照它来,否则台账会比 Sessions 页面少算,
    正是本次要消灭的那类"两页对不上",只是方向相反。
  * dsh 的一通调用保留它文件里记的两样东西:被配置去的供应商(Upstream,作为历史,
    从来不当成路由的证据),以及厂商真正应答的模型——回复的 replayState 里那个和
    请求模型不一致时,Model 记应答的、Requested 记被请求的,和 Claude Code 的文件
    一样的读法,好让请求表的若干列有东西可填。
  * 会话的 fork:dsh 的会话从另一个会话续起来时会带上对方已写入的事件(isSeeded /
    seedLength,session/end-seed 标记之后的不算),parser 跳过的这里同样跳过,否则
    同一通调用会在每个 fork 上再花一次。
  * WorkBuddy 的 subagents/ 子目录是父会话自己的调用来源(它带自己的消息 id,会话
    归到父会话名下);调用这条路不读 file.main,那个标记是 Sessions 页面给"会话自己的
    文件、不是子代理的"用的。

夹具:dsh 的三个会话(普通、带供应商与应答模型、以及一通经网关的),ZCode 的两条
(其中 ses_zcA 那条的 providerID 是 magpie,应被跳过),WorkBuddy 的既有会话(m1 的
usage 同时落在 assistant message 和它最后一个 function_call 上,只能记一次;旧历史
三条无 messageId 的回复各记一次)和一通经网关、一通走用户自建端点的会话。
调用缓存升到 v9:v8 里既没有 dsh 的供应商和应答模型,也没有"跳过网关调用"这件事,
旧缓存会继续把旧账 Serve 出来。

TestDshCalls、TestZCodeCalls、TestWorkBuddyCalls 是新的;把跳过网关调用那几行、以及
WorkBuddy 旧历史那条判据去掉,它们必挂、加上必过(都实际跑过反向验证)。

验证:go vet;go test ./internal/sessions(只余 TestCursor,Windows 路径前缀问题,
改动前同样失败);go build(windows)。

* usage: 请求页把"magpie 转的"和"它只读到的"分成两个数,GUI 跟着拆成两栏

用量页一直只显示网关自己应答过的调用,而现在它还要读 agent 自己会话文件里的调用:
两本账混在一行里,读者分不清哪一段是 magpie 转的、哪一段只是它从文件里读到的,
而总数事实上几乎全部来自后者(本机 30 天 847M token 里,网关只转了 2.4%)。Summary
里的两个来源卡片也加不出这个数来:它们只算 input 和 output,上面的 KPI 把缓存的
token 也算在内,于是"全部请求"读作 13.6M,而"Tokens"是 847M。

  * usage.RequestPage 和 usage.Filter 各多一样东西:前者在 Sum 之外带上 Through 和
    Direct(网关应答的,和从会话文件读到的),后者多一个 Through 维度。两种建页路径
    (流式的 queryPage 和治疗桌用的 pageFromLedger)都做同一个拆分,两边一致——
    TestCompactPageMatchesLedger 现在也覆盖两个来源筛选。
  * 每一行本来就是按 Source 区分这两类的("log" 是会话文件里的调用,其余走的网关),
    拆分就落在它上面;网关自己拒掉的调用两边都不算(页面在总数下面也这么说),所以
    SourceOf 对它返回 "",点哪一栏都同样把它排除,两栏自己的计数本来也不含它。
  * 两栏自己的数字是在"来源"这个挑选被清空的情况下数的,和各维度分组一样:三栏是读者
    来回切换的地方,各自得一直报着自己的数,否则点了其中一栏另一栏读作 0,且没有回
    到全部的路。
  * /api/usage/requests 支持 via=through|direct;GUI 那一侧的三个格子、计数单位、
    方块样式在下一个提交里。
  * Filter.Via 这个名字和 Record.Via(转发调用的那台电脑,#542)重了,改叫 Filter.Through。

Sessions 页列表长度那件事(sessionListLimit)拆到单独的 PR 里,不在这里。

验证:go vet;go test ./internal/usage(只余 TestOTelRetryAfterBound,计时的 ±30ms,
origin/main 同样失败);go build(windows)。

* gui: 请求页把来源拆成三栏,汇总那一排也改成方块

用途页的请求栏一直只有一本账(Sum),而现在它既显示网关应答的调用,也显示从各 agent
自己会话文件里读到的调用。三个格子画在上方汇总那一排方块里,样式一致:三张各自带
边框、彼此留缝的卡片,窄屏(≤620px)下竖排。

  * 每栏是一个 button:点一下就只列这一来源的请求,再点一下、或点"全部请求"回到
    全部;被选中的那一栏底下有一道本主题色的线。aria-pressed 标出当前选中的是哪一栏。
  * 计数后面跟单位("147 请求"),免得一个光秃秃的数字被读成下面的 token;token 是
    四项合计,和上面 KPI 的算法一致。
  * 三个格子在重绘之间是同一批元素、原地改(class / aria / 文本 / onclick),不是每次
    replaceChildren 重建——同一份 app.js 里 5810 行的规定就是这么写的("The switch is
    the same element across redraws, changed in place, so one being pressed or focused is
    never swapped out"),正在点的那个不该被换掉。
  * 汇总那一排本身(五张 KPI 卡)改成同样的方块:各自一张卡、有缝,边框写成
    var(--line) 而不是 --card-edge(那是面板用的变量,这些卡在独立窗口里也有)。
    ≤860px 两列排布时卡片之间的那几条线照旧:奇数位去掉左边框、第三张起加上边框,
    是 e0eaf5b4 带 #799 的老规则,这里只是跟着搬了位置,没有删。三张来源格是 button,
    不参与这套分隔线。
  * 供应商·账号 一栏:来自会话文件的行显示它文件里记的那个供应商 id(Codex 一向
    如此),有账号时仍是账号优先,什么都没有才说"本地会话"。"本地会话"角标的条件
    跟着放宽到账号或供应商任一存在,提示分两种说法:文件记了供应商的,说明这是它
    被配置去调用的那个记录、不是 magpie 看到的路由(不等于官方供应商);只认得出
    账号的,沿用原来那句"仅当本地元数据能明确识别时显示账号"。
  * 窗口参数支持 via,用后从地址栏清掉,和 tab/provider/agent 等一致。
  * 新增的串子在 en/zh/ja/de 四种语言里都有("{n} tokens" 上游已有,不重复加,
    i18n-keys 绿)。

验证:node --check(app.js、i18n.js);go vet;go build(windows)。
界面效果在本机用改动后的源码构建的二进制上过目:三栏的切换与计数、方块样式、
窄屏竖排、请求表新增的会话文件行(含供应商 id)。
浏览器套件(chromium 与 webkit、en/zh):session-identity、usage-ledger、
ledger-protocol、number-units、usage-ledger-chart、usage-ttft-rounding、
usage-chart-axis、usage-fold、web-page、usage-accounts、i18n-keys 全绿;
ledger-hscroll、usage-speed、click-scroll 全绿;gui-ja、gui-de 各一条失败、
usage-ledger-detail 六条失败,均在本机 origin/main 上同样失败,与本次改动无关。
usage-ledger-chart 新钉了 ≤620px 下 KPI 卡与来源格的分隔线(把 app.css 换回上游版本
它必挂,实测过)。

---------

Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
2026-10-07 19:43:01 +08:00
yetone 395c4bc51e tests: a test that sets time.Local does it through testenv.Zone, which first waits out the stand-ins' first runs — their os.Stat reads time.Local, and TestDsh -race alone raced it every run
testenv.StandIns (06420a71) starts each stand-in once in the background on
macOS and records it with os.Stat, which reads time.Local. internal/
sessions' inZone, internal/usage's TestDayFilterDST and the root package's
sessions CLI setup assigned time.Local while those runs could still be
under way. `go test -race -run '^TestDsh$' ./internal/sessions` reported
the race 6 of 6 runs on main (stats_test.go:14 against program.go:140).

testenv.Zone(t, loc) waits on the same WaitGroup RemovePrograms waits on,
sets time.Local and restores it in a Cleanup; the three helpers use it.
The root setup registers Zone before its own Cleanup, so the zone still
goes back after sessions.Reset's index write, as before.

After: the same command 0 races in 6 runs; -race -count=20 of
Dsh|DST over sessions and usage, -race -count=3 of the root Sessions
tests, -race of sessions/testenv/usage/root, go vet, gofmt pass. Tests only.
2026-10-07 06:17:56 +08:00
yetone 0bf9d77d3f agent tests: TestDriftBypassed's request reaching the gateway is kept to its run, so -count=2 passes
The test stood for a request with usage.Saw("codex"). Saw's record lasts
the process, which the gateway relies on (LastSeen answers from it before
a request is written to the log), so a second run started with a request
seconds old, newer than its prompt five minutes back, and found nothing
bypassed ("bypassed: <nil>", also on origin/main).

Drift reads LastSeen through a package variable the test replaces with its
own clock; production still reads usage.LastSeen. That Saw is LastSeen's
answer at once moves to usage's own TestSawIsLastSeen, which removes its
record afterwards.

-run 'TestDrift|TestSawIsLastSeen' -race -count=5 passes; before, -count=2
failed.
2026-10-07 03:03:06 +08:00
forever e961836fb0 usage, gateway, gui, provider, cli: the tests pass when a run crosses midnight, and a usage answer reads the clock once (#1046)
* 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 since 1caea638), 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 rows 117b6c7f made
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 on c5a54bc7, and those marked (698da36f) again on 698da36f,
with the same results. The tui package runs in UTC since 1caea638, a
zone at.sh doesn't move, so its runs on 698da36f used 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 (698da36f for
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 on c5a54bc7, when the tui package's tests still ran in the local
zone, and those marked (698da36f) again on 698da36f, 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.
2026-10-07 02:14:29 +08:00
forever e277f5c5ef usage: four tests pass when run alone and in any order — one saves no account copy mid-test, three price with the catalogue they write (#1045)
* usage: TestQueryPageSourceAndIdentityInvalidation passes when run alone

Run alone, the test failed at any time of day:

  request_page_test.go:224: unchanged source rebuilt

In a package run it passed only because an earlier test had looked at
the accounts less than 30 seconds before it. QueryPage calls
provider.All, whose rememberLogins saves a copy of the account Codex is
signed in to into logins.json, at most once every 30s (loginsSeenAt, a
package variable in provider). Alone, the test's first QueryPage made
that save. SessionIdentities then listed "one" twice (auth.json and the
saved copy), the request cache's key changed and every chunk was
rebuilt, so the pointer check failed. The test also expects only
auth.json's identity later on: with a saved "one" sharing a/u with
"two", the "two" step resolves to "" (two members of one workspace).
With the first check let through, it failed at request_page_test.go:237
"identity {... SessionAccount: ...}". The same failure hits a package
run whenever more than 30s pass between the last earlier look and this
test.

The test now calls provider.ForgetAccounts and provider.Accounts before
it signs Codex in, as an earlier test in a package run does, so no copy
is saved while it runs and the result no longer depends on test order.
This holds while the test runs within rememberLogins' 30s of that look;
it takes well under a second, -race and at.sh included. No other usage
test fails run alone: each test in the package was run alone, each
under a fresh HOME, and only this one failed.

Verified (go test -tags nogui under a temp HOME, Go caches pinned):
- before: -count=1 -run '^TestQueryPageSourceAndIdentityInvalidation$'
  ./internal/usage: --- FAIL ... request_page_test.go:224: unchanged
  source rebuilt; -race -count=20: 1 of 20 failed (the first; the
  later ones ran inside the 30s window); the same at local 12:00:00.
  With its neighbours (-run '^(TestQueryPage|TestRequestPage)') it
  passed, and failed again at :224 with a scratch 31s sleep at the
  test's start.
- after: alone -count=1 ok, -count=20 ok, -race -count=20 ok; at local
  12:00:00 and 00:00:30 PASS; with neighbours plus the scratch 31s
  sleep ok; go test ./internal/usage ok; go vet ok; gofmt clean.

Without the change the run-alone command fails with the FAIL line above.

* usage: tests that write their own catalogue price with it, in any order

TestDirectCountsOnlySessionFileCalls and TestLedgerWithSessionLogCalls
write a catalogue to catalog.CachePath() and expect calls priced from
it, but never called catalog.Reset. The catalog package reads the cache
once and keeps what it read until Reset, so each test priced with
whatever catalogue an earlier test had left loaded. In file order they
passed only because no earlier test had. In another order the calls
came out unpriced. Run as

  go test -run '^(TestDirectCountsOnlySessionFileCalls|TestLedgerUsesTheStatedPrice|TestLedgerWithSessionLogCalls)$' ./internal/usage

TestDirectCountsOnlySessionFileCalls ran first and left its gpt-6
catalogue loaded, and TestLedgerWithSessionLogCalls failed:

  ledger_test.go:228: logged call {... Provider:session-unknown ...
  Cost:0 Priced:false ... Source:log}

With -shuffle=1, TestLedgerWithSessionLogCalls ran first and left its
claude-sonnet-5 catalogue loaded, and the other one failed:

  direct_test.go:55: not priced: {Calls:2 ... Cost:0 Unpriced:2 ...}

Each passes alone, so running every test alone doesn't find this. It
was found in review with go test -shuffle=on ./internal/usage, which
failed 5 runs of 6.

Both tests now call catalog.Reset after writing the cache, and again
on cleanup, as TestLedger and TestSummaryByAccount do. So does
TestLedgerUsesTheStatedPrice, the only other test that writes the
cache with no Reset there. It passes in any order, since its stated
prices win, but it checks that no tier is counted at the catalogue's
price, which needs its own catalogue. Every other test in the
repository that writes catalog.CachePath() already resets the
catalogue there.

Verified (go test -tags nogui under a temp HOME, Go caches pinned):
- before, on main: the run above fails as shown. All six orders of the
  three tests (-shuffle=1, 2, 5, 7, 13 and 16) fail: at
  ledger_test.go:228 when TestDirectCountsOnlySessionFileCalls runs
  before TestLedgerWithSessionLogCalls, at direct_test.go:55 when it
  runs after. go test -shuffle=N ./internal/usage fails for the
  review's seeds 1791276105858112000, 1791276117280881000,
  1791276129005822000, 1791276140549291000 and 1791276151835143000,
  and for 101, 202, 505, 808 and 1010 of the seeds 101, 202, ... 1010;
  each failure is in one or both of these two tests.
- after: the run above ok; all six orders ok; -race -count=20 on the
  three tests ok; at local 12:00:00 PASS; go test ./internal/usage ok,
  and ok with each of those 15 seeds; each test in the package
  passes run alone; go vet ./internal/usage ok; gofmt clean.

Without the change the run above fails with the ledger_test.go:228
line, and with -shuffle=1 it fails with the direct_test.go:55 line.
2026-10-07 02:09:24 +08:00
yetone 117b6c7fb4 usage tests: TestReasoningReplyTimedByItsAnswer's timeline rows are made now, as LedgerSeries buckets today by the clock
The test gave LedgerSeries records dated 2026-10-06 14:30 UTC while
LedgerSeries reads today from time.Now(), so it passed only on 2026-10-06
and has failed on every machine since ("part: {Calls:0 ...}" just after
midnight CST on 10-07). The summary and row checks keep the fixed date;
the rows given to LedgerSeries take the clock's. go test -race
./internal/usage passes; gofmt and vet are clean.
2026-10-07 00:16:20 +08:00
yetone e6dd5d20bb 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. 2026-10-06 19:47:56 +08:00
yetone 0a49ffa9df usage tests: TestOTelRetryAfterBound times the retries from the first attempt (#973) — on Windows every attempt came 30ms late in the synctest bubble: a settings file not there is waited on (steady, 1ms × 15 per path) before it is taken as gone, and the bubble's clock counts those sleeps, so the first attempt was at 30ms, not 0. The spacing between attempts, what the test is about, was exact. No app change. Passes on the Windows box and on the Mac (-race -count=5). 2026-10-06 13:32:03 +08:00
yetone 88586975b2 usage test: the append budget is what 8000 records add, not a fixed count (#972, TryWorld2026) — TestRequestPageAppendDoesNotRepriceHistory failed on Windows at 3005–3028 allocations against 3000. pprof of the measured call on the Windows box: the extra over the Mac (2076 → ~2475 there) is a fixed per-query cost, provider sign-in lookups reading the environment and registry (syscall.Getenv, UTF16FromString, registry.ExpandString, LoginPath), not per block, so it varies with the machine. The test now subtracts the same append+query over a 10-record history: re-reading the 8000-record history costs ~65000 more (8 per record), the incremental path ~720 (~50 per block of 500); the budget is 2000 more. The sealed-block pointer check (no re-pricing) is unchanged. No app change. With the log snapshot forced to re-read, the test fails (66865 vs 1448); it passes on the Mac (-race -count=20) and on the Windows box (count 10). The sibling budgets in incremental_log_test.go measure 528/633 and 50/52 (Mac/Windows) against 1500 and are left. 2026-10-06 13:07:38 +08:00
yetone c5b57b9077 gateway, usage, gui: a relay's reply of tool calls ended "end_turn" or "tool_calls" goes on to the calls, a tool_use whose start carries its input keeps it, an Anthropic stream that just stops is an error, and every request says its protocol and the upstream's stop reason (蓝猫 on Discord)
蓝猫's custom Anthropic-protocol provider in front of Grok ended the agent's
turn a few words in with no error, where Alma on the same endpoint ran on.
Replayed from a fake upstream, three ways an Anthropic-shaped relay's
stream was read wrong when magpie translated it for an agent of another
protocol:

- a reply of tool calls whose stop_reason said end_turn, stop or OpenAI's
  tool_calls was handed on as finish_reason "stop": the agent ended its
  turn instead of running the calls. stopFromAnthropic takes tool_calls,
  function_call, length and content_filter; collector.finish makes a
  "stop" beside tool calls "tool".
- a tool_use block whose content_block_start carries its whole input,
  with no input_json_delta after, lost the input (the call went with {}).
  anthropicDecoder emits it, and skips deltas for that block after.
- a stream that ended cleanly with no stop_reason and no message_stop was
  finished as a whole reply. streamTranslated now takes it as cut: asked
  to go on where it can, else ended with "the reply ended before it was
  complete", as the passthrough already did.

Each request now keeps the upstream's own stop reason (Usage.Stop /
usage.Record.stop: stop_reason, finish_reason, a Responses status), from
the passthrough's sniffer and the translated stream, shown as "Upstream
stop reason" in a request's details on Usage › Requests. Each row there
is tagged with the protocol it was sent upstream in, "Chat → Anthropic"
when translated, told from its endpoint, so older rows have it too; the
details say Protocol. en/zh/ja/de, no left border, no select, a click
moves nothing.

relay_stop_test.go's four tests fail without the change (finish_reason
"stop" for tool calls under end_turn/tool_calls/stop, arguments "" for a
whole-input start, a cut stream finished "stop", no stop recorded) and pass
with it; the passthrough case sends "data:" without a space and CRLF.
ledger-protocol.test.cjs passes in Chromium and WebKit, en and zh.
go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test
-tags nogui ./... under a temp HOME pass.
2026-10-05 22:47:21 +08:00
yetone 71d2da1f42 catalog, usage, gateway, gui: a price can have a long-context tier and a 1-hour cache write, and the price boxes take decimals (PAMI on Discord)
OpenAI bills gpt-6-astra, gpt-6.1-sol and gpt-6-luna over 272K input at 2× input and cache and 1.5× output, for the whole request. A price now has tiers: `magpie model price <m> 10,50,1,12.5 --tier 272k 20,75,2,25` (--tier can repeat). A tier applies when input + cache reads + cache writes is over its size, so 272,000 is not over and 272,001 is. The built-in prices take tiers only where models.dev lists one (cost.tiers of type "context"), and it does list one for those three. The ledger, each request and OTLP are costed at the tier the call reached. Session and day totals sum many calls, so they stay at the base price.

Anthropic bills a cache write kept for an hour at 2× input and one kept for 5 minutes at 1.25×. Its usage reports which is which in cache_creation.ephemeral_5m/1h_input_tokens. The gateway (API and Claude subscription) and Claude Code's transcripts now keep that split. A price's fifth part is the 1-hour write. When it isn't given, it is 2× input: that is Anthropic's rule, and only Anthropic reports 1-hour writes. Claude list prices carry the 2× too. cache_write_tokens is still the total, and the usage CSV adds cache_write_5m_tokens and cache_write_1h_tokens at the end. The app shows the split on a request's Cache and on the usage tooltips. Old prices and old records, which have no 1-hour part, read as before. The sessions cache (18) and the call cache (calls-v8) are re-read once.

In the GUI, the price boxes were number inputs and dropped what they didn't parse ("0,25"). They are now decimal text boxes that take both "0.25" and "0,25". A model's price row has a 1-hour box (empty means 2× input) and a quiet Long-context price row. That row shows the list's tier greyed in, or sits behind a link when the list has none. A bad size is refused by name. en/zh/ja/de.

Tests:
- New: TestPriceTiers, TestCostSplit, TestPriceJSON, TestModelPriceTiers, TestModelPriceTierCmd, TestAnthropicCacheWriteSplit and TestClaudeCacheWriteSplit. The ledger CSV test expects the new columns.
- model-price-tiers.test.cjs is new; model-price-edit and provider-price-rate are updated. All of them fail against the old assets and pass in WebKit. Chromium couldn't be launched here.
- go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
2026-10-05 21:56:59 +08:00
yetone 5604c411a9 usage, gui: Usage › Requests' All opens at once after magpie has been up a few seconds, and in half the time before (the owner) — Requests on All stayed a skeleton for 39 s on the owner's magpie right after it started: the first All parses every session file (12.3k here: 11k Codex sessions, 1.2k Claude Code projects' files), one after the other, each a wait on the disk. Those a query has to parse are now read 8 at a time ahead of the loop that takes them in order (prefetchSources), at most 32 waiting, so the history is never all held at once; and magpie reads the whole history in the background once it is up, right after the Sessions page's own warm-up and not beside it on the disk, so the page opens on the kept index. Tested in a sandbox HOME on a copy of the owner's real ~/.codex and ~/.claude history (magpie app, dev UI): the first All right at startup took 10.6 s before and 4.6–4.9 s now; opened 20 s after startup it took 10.0 s before and 0.01 s now; later Alls 0.3–0.4 s as before. TestPrefetchSources (each source handed back as its own, read in parallel, no more than the window ahead, every one read once, an unwanted one read when asked) is new. go vet, go test -race ./internal/usage, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass. 2026-10-05 20:18:46 +08:00
yetone f506d08d28 usage, gui: the Requests tab ranks one model at each provider it went to (inaction on Discord) — the trend's ranking already gave each model its speed, first token and requests, but "Model" put a model's requests at every provider together, so glm-5.3 at Zhipu and at ZCode couldn't be told apart. The split has "Model · provider" now: a "modelAt" dimension (provider/model) whose rows are named by both with the provider's icon, each with its own tokens/s, first token and requests, ranked by any metric (Speed: the fastest first). A click lists that provider's requests of that model, again all of them; the others stay ranked. The trend's bar wraps at the narrowest window, "Split by" with its choices. The key of each provider and model is built once per page (TestRequestPageAppendDoesNotRepriceHistory's allocations). TestRequestPageRanksModelAtEachProvider, TestLedgerNamesModelAtProvider and usage-model-at-provider (WebKit, en and zh) fail without the change. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass (TestProvidersAnswerWhileListsComeIn and TestUserPath failed under load and pass alone). 2026-10-05 17:17:22 +08:00
zzzpyyy e957230629 fix: keep purpose-filtered routing state consistent (#784) 2026-10-05 14:26:23 +08:00
yetone e5981040dc gateway, gui: a request an aggregator passed on says which provider answered it behind it, "Upstream: DeepInfra" (leslie_luo on Discord: 像 CPA 那样指出实际供应商) — through OpenRouter a request was only "OpenRouter · deepseek/deepseek-chat", with no word of the provider (DeepInfra, StreamLake, Novita…) OpenRouter had sent it on to. OpenRouter names it in its replies, as tried with the owner's real key: Chat's body and every chunk of its stream have a top-level "provider", Messages' body has one and its stream's message_start a message.provider; its Responses replies and every reply's headers name none. The gateway now reads it (upstreamOf) from a body relayed as it came and from each event of one it translated, Chat, Messages or Responses, streamed or not, and keeps it on the request's try and route (upstream) and in the usage ledger (Record.Upstream; the CSV export's last column, searched as the rest). The Routing page's Requests list says Upstream: DeepInfra beside where it went, giving way after the provider's name when short of room, with its story saying the aggregator passed it on; a request's details on the Usage page have Upstream provider. A reply naming none says nothing. zh, ja, de strings. ClinePass and Cline weren't tried (no account); Vercel's gateway documents no such field in its replies, and a vendor whose reply has a top-level "provider" string is read the same way. In a sandbox magpie with the owner's real OpenRouter key: Chat, Messages and Responses (translated to Chat), streamed and not, all six said StreamLake in the trace, the ledger and on the Routing page. TestUpstreamRecorded (6 paths × streamed or not: the route, its try, Recent and the ledger) fails on the old code, TestUpstreamOf; routing-upstream.test.cjs (Chromium and WebKit, en and zh: the tag inside its row, the story, the Usage details, a click moves nothing, no left-border accent) fails on the old assets. ledger-hscroll, request-archive-usage, usage-ledger and usage-ledger-detail pass on WebKit; routing-served fails the same on origin/main (its swap wording). go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass but for TestTranslatedStreamKeepsClientAlive, which passes alone 5 times here and on origin/main. 2026-10-05 14:08:08 +08:00
yetone 5464a362f6 usage: the Requests tab says how fast replies came, and fits the reporter's window; a wide window keeps Agents and Settings readable (huoranxuanyuan, #860)
The ledger kept each reply's duration, first-token time and output tokens, but the page only showed a duration. Speed is now shown in four places, measured as output tokens a second after the first token (usage.DecodeWindow, the same guard Routing's tok/s uses):
- Each row has a Speed column. It shows "—" for a reply that wasn't streamed, and the row's details say it too.
- A fifth KPI, Output speed, gives the period's speed with "first token in … on average".
- The trend has a Speed metric. Each model, provider or agent gets a mark at its speed for every hour or day, with a faint column for the hour's overall speed. The tooltip gives each one's tok/s and TTFT, and the ranking sorts fastest first.
- The ranking shows each one's tok/s and TTFT under the other metrics.
usage.Part (a series point's per-model, per-provider and per-agent sums) now carries timed, ttft_ms, decode_ms and decode_out for this.

Narrow window: the reporter's window is 975px wide (1220 at 125%). The table was 1222px in a 941px box, so the Status and Speed columns needed scrolling sideways. Where the table doesn't fit, it now leaves out Sent, Effort and the two cache columns. A line under the totals says those are in each row's details, and the details list them. The check runs again when the width changes or the table grows (a font arriving). At 975 the table now fits.

Wide window: at 1670px the Agents, Providers, Gateway, Library, Plugins and Settings rows stretched to 1636px. They now stop at 1200px and are centred. Routing, Usage and Sessions keep the full width.

zh, ja and de strings.

Tests:
- usage-speed.test.cjs (WebKit, en and zh) fails on the old code (no Speed column). At 975×778 it checks: the Speed column (75 tok/s, 10, "—"), the table fitting with no sideways bar, the hint, and Sent and Speed in the details. It also checks the KPI (72 tok/s, first token in 600 ms), the ranking's tok/s, the Speed trend (fastest first, a mark per model per hour), and Agents and Settings rows at most 1200 wide and centred at 1670.
- TestLedgerSeriesSpeed.
- usage-ledger, usage-ledger-detail and ledger-hscroll updated for the new column and details. ledger-hscroll now runs at 700px, still narrower than the tight table.

Results: usage-*, ledger-hscroll, agent-*, settings-*, click-scroll, gui-ja, gui-de, import-row, member-model-setting, routing-sessions, routing-columns and sessions-overview/toolbar pass on WebKit. remote-group-served's zh run timed out waiting for Routing's By request tab in 2 of 9 runs and passed in the rest. go vet, the builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
2026-10-05 13:32:38 +08:00
yetone 4a87b30610 usage, gateway: a model the vendor spells with other separators isn't marked as another one (vincentzhang on Discord: 这个说明有点奇怪。看不明白。) — Volcengine Ark answers deepseek-v4.1-flash as deepseek-v4-1-flash, the dots written as hyphens, and the Usage page's Requests and the Routing page marked every such row amber 「实际 deepseek-v4-1-flash」, saying another model had answered, not the one asked for under a dated name. Swapped now compares the bare names without their separators (. - _ and spaces) and case, so deepseek-v4-1-flash, deepseek_v4_1_flash, GLM-4-6 for glm-4.6 and gpt-4-1-2025-04-14 for gpt-4.1 are the model asked for, while deepseek-v4-flash or deepseek-v4.1-pro for deepseek-v4.1-flash still are another. Rows and routes kept marked swapped before are read back as not swapped when the names differ only so (usage.SameSpelled, in rowChunk.row and Route.routedAgain). The tag's explanation says it in plainer words: asked for {sent}, the reply says {served} answered, likely another model; the same model under a dated name or spelled otherwise isn't marked (zh, ja, de). TestSwappedIgnoresSeparators fails on the old code; TestKeptRowSameSpelledNotSwapped, TestRouteKeptSpelledOtherwiseNotSwapped. usage-ledger (its expected title updated), remote-group-served, panel-routing, 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 (TestCodexVersion timed out once under load and passes alone). 2026-10-05 12:49:59 +08:00
yetone 258eae592d agent: Cursor Private Inference, which magpie finds by its product.json and starts on magpie with a command its row copies, and whose /models gives each model's api_types and capabilities (#299, mamba on Discord) — the build (Cursor 3.23.x, the same app name, bundle id and ~/.cursor as Cursor) runs its agent locally and asks the endpoint in CURSOR_LOCAL_AGENT_BASE_URL / CURSOR_LOCAL_AGENT_API_KEY when its Open configuration has none; mamba confirmed it works with any account pointed at magpie. magpie writes nothing of Cursor's (its settings share Cursor's state.vscdb): the Agents page lists it when an app's product.json says "Cursor Private Inference" (/Applications, ~/Applications; %LOCALAPPDATA%\Programs on Windows; /opt and the like on Linux), and its row's one control, Copy launch command (复制启动命令), copies CURSOR_LOCAL_AGENT_BASE_URL=<gateway>/v1 CURSOR_LOCAL_AGENT_API_KEY=magpie-cursor-local '<app>' (PowerShell on Windows); in the tray panel the row copies it. Its notice says to quit it first (a second start hands over to the running one) and that a base URL in Open configuration comes first. /v1/models asked with its key gives each model api_types (the APIs its provider serves it on, all three for a group or a translated one; it asks Anthropic Messages only when no OpenAI one is listed) and capabilities (context_length, max_output_tokens, supports_vision); any other client's list is unchanged. usage.AgentOf matches a name before a UA prefix, so cursor-local isn't counted as Cursor. TestCursorLocal, TestCursorLocalModels and cursor-local-launch.test.cjs (WebKit, en and zh) fail on the old code; agy-launch and agent-* GUI tests pass on WebKit. Not tried with the real app (not installed here). go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. 2026-10-04 23:38:19 +08:00
tobitege 21702d01ff Add German translation; harden paths against unset env vars (#770)
* Add German translation and harden paths against unset env vars

Add a German locale to the GUI, tray menu, usage alerts and website home page, and refuse to start when the home folder is unset or relative so nothing is written under the working folder; the pi plugin no longer adopts the user's PI_CODING_AGENT_DIR, and an inherited APPIMAGE no longer selects portable mode. Scripts refuse to run from the wrong folder or empty a shared out folder, and a new internal/testenv sandboxes every test package from the user's real files, which also fixes the Claude picker and Termux installer tests on Windows.

* Makefile: know magpie's folder by its go.mod, so paths with spaces work

The check compared $(CURDIR) with $(dir $(abspath …)), which make splits on spaces, so a checkout under 'My Projects' or 'John Doe' refused every target. It now reads go.mod relative to the working folder and wants magpie's module there: make -C and a symlinked Makefile work, make -f from another folder still stops before any recipe runs.

* i18n: German for the strings main added since, with a parity test

75 strings main added (group order, patterns, slow-start wait, usage caps, RTK savings …) get their German, and two that main dropped go. gui-de.test.cjs checks, as gui-ja does, that de has every zh string with its placeholders and tags, and that the window reads in German when picked or on a German system.

* appdir: ignore relative folder variables when reading, keep them for children

CheckEnv no longer unsets anything. magpie reads the XDG folders, APPDATA/LOCALAPPDATA and every agent's folder variable through appdir.Getenv/LookupEnv, which pass over a relative value; the environment stays as it is, so the agents magpie starts get the variable as the user set it. That covers what proc.UserPath lends from the login shell too, which is set after main's check and reached the desktop app unchecked. On Windows a path rooted on the current drive (\Users\x, Git Bash's /c/x) counts as usable, and magpie version and help answer without a home, before run makes any folder.

* Sandbox the tests main added since

internal/wslrun gets testenv's TestMain, and four tests main added that set HOME alone set their home with testenv.SetHome, so on Windows they don't read the runner's profile through USERPROFILE.

* agentenv: PI_CONFIG_DIR and OPENCODE_DB take relative names on purpose

omp puts $PI_CONFIG_DIR under the home and OpenCode a relative $OPENCODE_DB in its data folder, so neither depends on the working folder: appdir no longer passes them over.

* i18n: German for the Library's multi-select and System One's decision API

The 19 strings main added since (#791's skill multi-select, the decision API snippets) get their German; the one main replaced (four APIs → five APIs) goes.

* appdir: tidy the import into magpie's group; say what Go makes of /c/x

The appdir import joins the group of magpie's own imports where a file has one. rooted's comment now says that Go takes a Git Bash path like /c/x as \c\x on the current drive, not C:\x.
2026-10-04 23:30:56 +08:00
yetone 7909de22ac usage: magpie usage and the TUI's Usage page show the agents' own calls, read from their session files, beside the gateway's, and a Codex-based client's calls aren't counted twice (Kumo31 on Discord: Codex used directly, not through magpie, was on the window's Usage tab but not in magpie usage) — magpie usage and the TUI only ever summed the gateway's log (usage.Summarize); the calls the agents' session files record that the gateway never saw (Codex on the user's own ChatGPT sign-in, Claude Code, Claude Desktop…) were merged only into the ledger the window's Requests tab and the tray panel read (#239). usage.Direct sums those rows of the ledger (Source "log", this computer's, rejected ones left out) by agent, model, account and session, priced as the ledger prices them, and magpie usage prints them after the gateway's tables under "not through magpie · the agents' own requests, read from their session files" (a model whose provider the file doesn't tell reads "local session/<model>"); the TUI's Usage page shows the same block under the gateway's, each with its totals and agents' and models' tables. The two parts are separate, so nothing is counted in both; a period with only the agents' own calls shows them under "no calls through magpie". All of magpie usage's output now goes to the writer it is given. Checking it against the owner's real data showed the ledger counted a Codex-based client's calls twice: miyi-helper's requests through the gateway are logged under its own name while its Codex session file names Codex, and the gateway/session-file matcher keyed on the agent, so they never matched; the matcher now keys on the session, tokens and time alone (a session id names one conversation), which also corrects the Requests tab. With read-only copies of the owner's ~/.config/magpie and four days of ~/.codex/sessions in a sandbox HOME: magpie usage week printed 177 calls not through magpie, in 1.3M, out 52.4K, cache read 3.9M, gpt-6-astra 154 and gpt-6-sol 23 — the same rows, to the token, as usage.LedgerOf (what /api/usage/requests serves) gave for the period; before the matcher fix, today's 7 miyi-helper calls showed in both parts. TestDirectCountsOnlySessionFileCalls (the miyi-helper case included), TestUsageShowsCallsNotThroughMagpie and TestUsagePageShowsCallsNotThroughMagpie fail 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. 2026-10-04 23:03:15 +08:00
zzzpyyy 57e80efbf4 feat: filter usage and routing requests by purpose (#771) 2026-10-04 20:46:46 +08:00
yetone 6f64956f27 routing: a group can let a member go that hasn't begun answering after a set time and ask the next, and a request's detail splits its first token into magpie's time and the vendor's (Chen on X: answers through magpie felt slow, with no way to tell whether magpie or the vendor was the wait) — a group's editor has a Slow to start row (Wait, 30 s, 1 min, 2 min; Wait, the default, keeps today's behaviour). With a time set, a streamed request to a member that has sent no text, reasoning or tool call by then is cancelled before any of its bytes reach the agent (the held start is dropped; keepalives go on meanwhile) and the next member is asked; the try is marked slow ("slow to start", narrated in the route), and the member doesn't rest, since nothing is wrong with it. The last member left is always waited for. The card says "Next after 1 min without a first token". Each request now records when it went to the vendor (httptrace WroteRequest, sent_ms), and its detail shows In magpie and Vendor's first token beside First token; after a failover In magpie includes the time spent on the members let go. With the owner's real DeepSeek key in a sandbox, a group [a local member that sends its role chunk then stalls 60 s, deepseek-flash] with 3 s: the answer came from DeepSeek in 4.4 s, none of the stalled member's stream reached the client, its try read slow / "no first token in 3s" with no rest, and the ledger had ttft 3622, sent 3003. TestGroupFirstTokenFailsOver (failover, the last one waited for, one in time kept; passes -race) and group-first-token.test.cjs and usage-ledger-detail.test.cjs (WebKit, en and zh) fail on the old code. group-from-model-add's narrow-window case fails the same on origin/main; Chromium doesn't launch in this sandbox. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass (TestProvidersAnswerWhileListsComeIn flaked once, passes alone). 2026-10-04 19:33:55 +08:00
yetone 91fbf15fec usage: a reply that came in one burst at its end tells no tok/s, and is left out of the summed speed (#731, Arcadia822: a Gemini turn through Antigravity that was one write tool call, out 8264, ms 24360, ttft_ms 24359, read 8,264,000 tok/s and lifted the Usage page's average) — a reply's speed is out ÷ (ms − ttft_ms) since #196, and the sums are Σout ÷ Σ(ms − ttft_ms) (Totals.DecodeOut/DecodeMs). codeassist hands a native functionCall over whole, so a turn whose only content is one big tool call has its first content event a millisecond or two before the end: the window covers delivery, not writing, and its tokens over next to no time read millions a second and pull the summed speed up (the test's three calls: 100 tok/s each in truth, 616 with the burst counted). A decode window now counts only when it timed the writing: usage.DecodeWindow gives 0 when it is under MinDecodeMs (100 ms) or the tokens over it would be past MaxDecodeSpeed (10,000 tok/s), both far from any real stream (the fastest vendors write a few thousand a second); Totals.add (records) and Totals.addRow (ledger rows, which the Usage page sums) both go through it. Such a reply still counts as timed and its TTFT in the mean; only its speed is left out, so a model with only bursts shows no tok/s (the Usage page and the CLI already skip a speed with no decode window). The Routing page's story, which works out a try's speed itself (routing.js speedOf), applies the same bounds, so the burst says "first token in 24 s" and no tok/s. app.js's figure is decode_out ÷ decode_ms from the Go sums and needed no change. Tests: TestBurstTellsNoSpeed (the report's record with two 100 tok/s calls: 100 tok/s summed through summarize and addRow, TTFT mean keeps the burst; DecodeWindow's edges: 1 ms, 0 ms, 3 tokens in 50 ms, 8264 in 500 ms, exactly 10,000 tok/s over 100 ms, nothing written, not streamed) fails on the old rule with 616 tok/s; TestSummarizeTimesFirstTokens unchanged. routing-burst-speed.test.cjs (webkit, en and zh: the report's burst and a 500 ms one tell no speed, 100 and 50 tok/s replies keep theirs, picking a request doesn't move the page) fails on the old routing.js with "8264000 tok/s". routing-sessions, routing-served, routing-effort-change, usage-accounts, panel-usage and click-scroll GUI tests pass in webkit (routing-served failed once in a parallel run and passes on its own twice; its fixture has no TTFT). 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 ledger or a live Gemini stream; a short reply under 100 ms (a title, a few tokens) no longer counts toward the speed either, which moves a summed figure by next to nothing. 2026-10-04 11:28:50 +08:00
mofelee cab8a8e450 fix: refresh first-request discovery and retain late Pi timing (#708) 2026-10-04 00:49:15 +08:00
yetone d079dc6223 usage: an OpenCode session's row shows the effort and the model it asked for (#680, Sun1090: 数据不完整,推理强度是有的)
OpenCode rows read from its own data (opencode.db or storage/) had 请求模型 and 推理强度 as —. OpenCode keeps both on each reply (packages/schema/src/v1/session.ts, AssistantMessage): variant, the effort picked in its model menu for the prompt (high, max, low, xhigh, none, thinking … as the provider's variants name them; prompt.ts copies lastUser.model.variant onto every reply), and modelID, the model the reply was sent to. OpenCode 2 (session_message) keeps the variant in model.variant (Model.Ref). It records no model the response named, so modelID is what was asked for, as a Codex rollout's model is.

Now a call's Effort is the reply's variant ("default" or none is no effort picked); a compaction in OpenCode 2 takes the effort last replied with, as it takes the model. In the ledger an OpenCode call is told as a Codex one: requested = its model, served left empty rather than claimed. The call cache moves to calls-v5 so calls read before carry the effort.

TestOpenCodeCallsJSON/DB/V2 (fixtures with variant high, default, xhigh and none) and TestOpenCodeSessionCallIsWhatItAskedFor fail on the old code and pass. go vet ./internal/..., builds (nogui, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried against a real OpenCode database with variants (this machine's OpenCode data predates them).
2026-10-03 23:44:06 +08:00
Fanbuz c9d0d15b94 fix: 记录网关实际返回的响应 ID (#693) 2026-10-03 22:45:42 +08:00
mofelee ee68b96788 fix: avoid duplicate Pi interaction and gateway traces (#692) 2026-10-03 22:33:54 +08:00
mofelee d5591b8f8d usage: export effective model costs to Langfuse (#684) 2026-10-03 21:31:04 +08:00
Leo 48d524d65a feat: select a day in usage charts (#683) 2026-10-03 21:27:06 +08:00
mofelee 2f937f3f39 fix: defer unfinished session trace roots until completion (#633) 2026-10-03 14:28:10 +08:00
mofelee 7d7175cfb1 fix: preserve Langfuse coverage for first and auxiliary calls (#599) 2026-10-03 11:21:10 +08:00
Snowyy 5422306fac usage: guard cache transitions and Windows long-path reads (#598) 2026-10-03 02:19:11 +08:00
mofelee 38b4a9d984 feat: export gateway and agent conversation waterfalls to Langfuse (#581) 2026-10-03 01:43:32 +08:00
Snowyy 3d8c6f24c5 usage: capture Windows change time for cached logs (#594)
* usage: capture Windows change time for cached logs

* ci: show native Windows usage test outcomes
2026-10-03 01:15:29 +08:00
yetone f1ca6139b5 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). 2026-10-03 00:47:33 +08:00
yetone 0879894af3 gateway: each gateway key can have its own limit, a token total and/or an estimated cost per day, week or month, and a spent key is refused before any provider is asked (margbug01 on #585, v0.1.671: 每个网关密钥都能设置自己的用量上限) — a gateway key named who called and the Usage page grouped its calls, but nothing held a key to an amount, so one client shared on the network could spend a subscription's whole window. A key now carries limit {period day|week|month, tokens, cost, cacheReads} in caller-keys.json, set with the new "limit-key" change. Windows are calendar ones in local time (from midnight, from Monday, from the 1st) and a call counts in the window it started in. Tokens are a call's uncached input, output and cache writes, plus cache reads when the limit says so; cost is an estimate at the Usage page's effective prices (usage.NewCoster), a call with no known price adding none and counted as unpriced. What a key used is summed from usage.jsonl (usage.Visit), so it survives a restart; the new internal/budget keeps the sums in memory per window and appendUsage writes a key's record through budget.Append, which appends and counts under one lock so a window read meanwhile never counts a call twice. Before a keyed POST reaches the mux, keyLimited reserves for it: its body's size in tokens (4 bytes a token) plus the key's mean output per call this window, at the window's cost per token or the named model's input price; it is let in while used + reserved is under the cap, so requests sent at once overshoot by about one call, and the reservation is released when the request ends, a streamed reply being settled then with the usage its vendor reported. A spent key gets a 429 in its API's error shape (Anthropic, OpenAI chat/responses, Gemini) naming the key, the cap and the reset time, with Retry-After, X-Should-Retry: false and X-Magpie-Limit-Reset, recorded as a rejected call (gateway_key_limit); other keys go on. Requests from this computer without a gateway key are not limited; a key used from this computer is. count_tokens and bridge callbacks are never held. GET /v1/magpie/limit tells a key its own status. Gateway keys rows show a Limit badge (what is used of it, per what, Spent in red) and a line under a limited key (used of limit, left, resets / refused until, in flight; unpriced calls in its title); Limit opens an editor under the row (window, tokens with k/m/万/亿, cost in US$, Count cache reads too, what is counted) staged until Save, marked unsaved, Cancel/Escape dropping it, a bad amount disabling Save, No limit removing it; en and zh; no left-border accent; no click scrolls. The CLI gains magpie gateway-key limit <id> [off | day|week|month --tokens N --cost USD --cache-reads] and a LIMIT column in list. TestKeyLimitRefusesBeforeUpstream (two calls within 700 tokens, the third a 429 the fake upstream never sees, message/type/headers, the rejected record, Anthropic's shape on /v1/messages, another key and a keyless loopback request let in, still refused after budget.Forget as after a restart, a raised and a removed limit let in, /v1/magpie/limit), TestKeyLimitHoldsParallelRequests (20 at once against 2000 tokens: some refused, at most one call over, no reservation left; without the reservation all 20 got in and 7350 were used), TestKeyLimitWindowResets, TestLimitWindow, TestLimitValid and TestGatewayKeyLimitCLI are new; gateway-key-limit.test.cjs (WebKit, en and zh) is new and passes, as do gateway-caller-keys, gateway-connect-keys, api-key-theme and click-scroll (whose request-pick case flaked once under a parallel run and passes alone, as on main). 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's known TempDir-cleanup flake aside). 2026-10-03 00:18:16 +08:00
TryWorld e41cacb42a fix(gateway): account for streamed failures (#573) 2026-10-02 22:42:40 +08:00
Snowyy 7464ee5075 usage: bound retained chunks and invalidate same-stat rewrites (#563) 2026-10-02 22:00:10 +08:00
Snowyy 66844ab0f9 usage: cover growing rewrites followed by local and external appends (#559) 2026-10-02 20:59:51 +08:00