Files
magpie/internal/usage/request_page_test.go
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

314 lines
12 KiB
Go

package usage
import (
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/sessions"
)
func pageHome(t *testing.T) {
sessionHome(t)
sessions.Reset()
catalog.Reset()
t.Cleanup(func() { sessions.Reset(); catalog.Reset() })
os.MkdirAll(filepath.Dir(catalog.CachePath()), 0700)
os.WriteFile(catalog.CachePath(), []byte(`{"openai":{"id":"openai","models":{"m":{"id":"m","cost":{"input":2,"output":8,"cache_read":0.5,"cache_write":2.5}}}}}`), 0600)
}
func equalPage(t *testing.T, got, want RequestPage) {
t.Helper()
// Summation order changes without a global sort; tolerate float round-off.
var normalize func(reflect.Value)
normalize = func(v reflect.Value) {
switch v.Kind() {
case reflect.Pointer:
if !v.IsNil() {
normalize(v.Elem())
}
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
if v.Field(i).CanSet() {
normalize(v.Field(i))
}
}
case reflect.Slice:
for i := 0; i < v.Len(); i++ {
normalize(v.Index(i))
}
case reflect.Map:
for _, k := range v.MapKeys() {
x := reflect.New(v.Type().Elem()).Elem()
x.Set(v.MapIndex(k))
normalize(x)
v.SetMapIndex(k, x)
}
case reflect.Float64:
v.SetFloat(math.Round(v.Float()*1e9) / 1e9)
}
}
normalize(reflect.ValueOf(&got).Elem())
normalize(reflect.ValueOf(&want).Elem())
a, _ := json.Marshal(got)
b, _ := json.Marshal(want)
if string(a) != string(b) {
t.Fatalf("page differs\ngot %s\nwant %s", a, b)
}
}
func TestCompactPageMatchesLedger(t *testing.T) {
pageHome(t)
now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
var recs []Record
var logs []sessions.Call
for i := 0; i < 180; i++ {
at := Today.Since(now).AddDate(0, 0, -i%80).Add(time.Duration(i%24) * time.Hour)
r := Record{Time: at, Agent: []string{"claude", "codex", "opencode"}[i%3], Provider: []string{"a", "b"}[i%2], Model: "m", Input: 10 + i, Output: 2, CacheRead: 3, Status: 200, FirstText: 7, TTFT: 5, Millis: 100, Session: fmt.Sprint(i), RequestID: fmt.Sprintf("r%d", i), NativeSession: fmt.Sprint(i)}
if i%9 == 0 {
r.Status = 429
r.Error = "limited"
}
if i%19 == 0 {
r.Rejected = true
}
recs = append(recs, r)
c := sessions.Call{Time: at.Add(time.Second), Agent: r.Agent, Session: r.Session, Model: "m", Tokens: sessions.Tokens{Input: r.Input, Output: r.Output, CacheRead: r.CacheRead}, File: "/session", To: int64(i + 1)}
switch i % 5 {
case 0:
c.RequestID = r.RequestID
case 1:
c.RequestID = ""
case 2:
c.RequestID = "different"
default:
c.Session = "local"
}
if r.Failed() {
c.Error = "rate_limit"
c.ErrorText = "limited"
}
logs = append(logs, c)
}
// Same timestamp keeps gateway rows first, newest file order within locals.
logs = append(logs, sessions.Call{Time: recs[0].Time, Agent: "claude", Session: "tied", Model: "m", Tokens: sessions.Tokens{Input: 1}, File: "/session", To: 999})
slices.SortStableFunc(logs, func(a, b sessions.Call) int {
if n := b.Time.Compare(a.Time); n != 0 {
return n
}
return int(b.To - a.To)
})
price := pricer()
pack := func(r Record, source string) Row {
x := Row{Record: r, Source: source, Swapped: r.Served != "" && Swapped(r.Model, r.Served)}
if p := price(r); p != nil && r.Input+r.Output > 0 {
x.Cost = p.Cost(r.Input, r.Output, r.CacheRead, r.CacheWrite)
x.Priced = true
}
x.Agent = AgentOf(r.Agent)
return x
}
gateway := &rowChunk{}
for i, r := range recs {
gateway.add(pack(r, ""), "", int64(i), false)
}
local := &rowChunk{Source: sessions.CallSource{Path: "/session"}}
for _, c := range logs {
local.add(pack(logRecord(c), "log"), "", c.To, c.Error != "")
}
for _, period := range []Period{Today, Week, Month, All} {
since := period.Since(now)
gs := since
if !gs.IsZero() {
gs = gs.Add(-24 * time.Hour)
}
rs := slices.DeleteFunc(slices.Clone(recs), func(r Record) bool { return r.Time.Before(gs) })
cs := slices.DeleteFunc(slices.Clone(logs), func(c sessions.Call) bool { return c.Time.Before(since) })
rows, sum, agents, providers := ledgerWith(since, Filter{}, rs, cs)
all := Ledgered{rows, sum, agents, providers}
day := now.AddDate(0, 0, -1).Format(time.DateOnly)
for _, f := range []Filter{{}, {Agent: "claude"}, {Provider: "a"}, {Failed: true}, {Query: "LOCAL"}, {Agent: "codex", Provider: UnknownProvider}, {Query: "no match"}, {Day: day}, {Day: day, Provider: "a"}, {Day: day, Model: "m", Failed: true}, {Day: "1900-01-01"}, {Through: SourceGateway}, {Through: SourceSession}} {
for _, offset := range []int{0, 7, 500, int(^uint(0) >> 1)} {
t.Run(fmt.Sprintf("%s/%+v/%d", period, f, offset), func(t *testing.T) {
equalPage(t, buildRequestPage(period, f, offset, 7, gateway, []*rowChunk{local}), pageFromLedger(period, f, offset, 7, all))
})
}
}
}
}
func TestRequestPageModelRankingKeepsAlternatives(t *testing.T) {
pageHome(t)
now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
records := []Record{
{Time: now, Provider: "a", Agent: "codex", Model: "gpt-5", Input: 10},
{Time: now, Provider: "a", Agent: "codex", Model: "gpt-5-mini", Input: 20},
{Time: now, Provider: "b", Agent: "codex", Model: "other-provider", Input: 30},
{Time: now, Provider: "a", Agent: "claude", Model: "other-agent", Input: 40},
{Time: now, Provider: "a", Agent: "codex", Model: "rejected-model", Rejected: true},
}
gateway := &rowChunk{}
all := Ledgered{}
for i, r := range records {
row := Row{Record: r}
gateway.add(row, "", int64(i), false)
all.Rows = append(all.Rows, row)
}
f := Filter{Provider: "a", Agent: "codex", Model: "gpt-5"}
for name, page := range map[string]RequestPage{
"compact": buildRequestPage(Today, f, 0, 100, gateway, nil),
"ledger": pageFromLedger(Today, f, 0, 100, all),
} {
t.Run(name, func(t *testing.T) {
models := map[string]int{}
for _, share := range page.By["model"] {
models[share.ID] = share.Calls
}
if want := map[string]int{"gpt-5": 1, "gpt-5-mini": 1}; !reflect.DeepEqual(models, want) {
t.Fatalf("model ranking = %v, want %v", models, want)
}
if page.Total != 1 || len(page.Rows) != 1 || page.Rows[0].Model != "gpt-5" || page.Sum.Calls != 1 || page.Sum.Input != 10 {
t.Fatalf("selected model no longer filters rows and totals: %+v", page)
}
var calls, tokens int
for _, point := range page.Series {
calls += point.Calls
tokens += point.Input
}
if calls != 1 || tokens != 10 {
t.Fatalf("selected model no longer filters the chart: calls=%d input=%d", calls, tokens)
}
for _, dimension := range []string{"provider", "agent"} {
if shares := page.By[dimension]; len(shares) != 1 || shares[0].Calls != 1 {
t.Fatalf("%s ranking ignored the model filter: %+v", dimension, shares)
}
}
})
}
}
func TestQueryPageSourceAndIdentityInvalidation(t *testing.T) {
pageHome(t)
// QueryPage saves a copy of the account Codex is signed in to, at most
// once every 30s (rememberLogins), and this test expects only auth.json's
// identity. Look at the accounts before signing in, as an earlier test in
// a package run does, so no copy is saved while it runs.
provider.ForgetAccounts()
provider.Accounts()
sessionAuth(t, sessions.CodexDir(), "a", "u", "one@example.com")
now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
path := filepath.Join(sessions.CodexDir(), "sessions", "rollout-2026-09-30T00-00-00-test.jsonl")
os.MkdirAll(filepath.Dir(path), 0700)
meta := `{"type":"session_meta","payload":{"id":"test","model_provider":"custom","creator_account_id":"a","creator_user_id":"u"}}` + "\n" + `{"type":"turn_context","payload":{"model":"m","effort":"high"}}` + "\n"
line := func(n int) string {
return fmt.Sprintf(`{"timestamp":%q,"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":%d,"output_tokens":%d},"last_token_usage":{"input_tokens":10,"output_tokens":1}}}}`+"\n", now.Format(time.RFC3339Nano), n*10, n)
}
os.WriteFile(path, []byte(meta+line(1)), 0600)
check := func(n int, account string, official bool) {
t.Helper()
p := QueryPage(All, Filter{}, 0, 100)
if p.Total != n {
t.Fatalf("total %d want %d", p.Total, n)
}
for _, r := range p.Rows {
if r.Source == "log" && (r.SessionAccount != account || r.SessionOfficialLogin != official) {
t.Fatalf("identity %+v", r)
}
}
equalPage(t, p, pageFromLedger(All, Filter{}, 0, 100, LedgerOf(All, Filter{})))
}
check(1, "one@example.com", true)
before := requestCache.chunks[path]
check(1, "one@example.com", true)
if requestCache.chunks[path] != before {
t.Fatal("unchanged source rebuilt")
}
appendFile := func(p, s string) {
f, e := os.OpenFile(p, os.O_WRONLY|os.O_APPEND, 0600)
if e != nil {
t.Fatal(e)
}
defer f.Close()
f.WriteString(s)
}
appendFile(path, line(2))
check(2, "one@example.com", true)
sessionAuth(t, sessions.CodexDir(), "a", "u", "two@example.com")
check(2, "two@example.com", true)
sessionAuth(t, sessions.CodexDir(), "other", "u", "wrong@example.com")
check(2, "", false)
os.MkdirAll(filepath.Dir(Path()), 0700)
r, _ := json.Marshal(Record{Time: now, Model: "m", Agent: "opencode", Input: 10, Output: 1})
os.WriteFile(Path(), r, 0600)
check(2, "", false) // partial gateway line
appendFile(Path(), "\n")
check(3, "", false)
appendFile(Path(), string(r)+"\n")
check(4, "", false)
os.WriteFile(Path(), []byte(string(r)+"\n"), 0600)
check(3, "", false)
// Reprice cached rows when the shared catalogue changes.
b, _ := os.ReadFile(catalog.CachePath())
os.WriteFile(catalog.CachePath(), []byte(strings.Replace(string(b), `"input":2`, `"input":9`, 1)), 0600)
catalog.Reset()
check(3, "", false)
os.Remove(path)
check(1, "", false)
os.Remove(Path())
check(0, "", false)
}
// One model at two providers is ranked once at each, with each one's own
// first token and decode, and a provider picked still ranks the other
// (inaction on Discord).
func TestRequestPageRanksModelAtEachProvider(t *testing.T) {
pageHome(t)
now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
records := []Record{
{Time: now, Provider: "zhipu", Agent: "claude", Model: "glm-5.3", Output: 1000, Millis: 2200, TTFT: 200},
{Time: now, Provider: "zhipu", Agent: "claude", Model: "glm-5.3", Output: 1000, Millis: 2200, TTFT: 200},
{Time: now, Provider: "zcode", Agent: "claude", Model: "glm-5.3", Output: 1000, Millis: 10800, TTFT: 800},
{Time: now, Provider: "zcode", Agent: "claude", Model: "kimi-k3", Output: 10, Millis: 100, TTFT: 50},
}
gateway := &rowChunk{}
all := Ledgered{}
for i, r := range records {
row := Row{Record: r}
gateway.add(row, "", int64(i), false)
all.Rows = append(all.Rows, row)
}
f := Filter{Provider: "zhipu", Model: "glm-5.3"}
for name, page := range map[string]RequestPage{
"compact": buildRequestPage(Today, f, 0, 100, gateway, nil),
"ledger": pageFromLedger(Today, f, 0, 100, all),
} {
t.Run(name, func(t *testing.T) {
got := map[string]Share{}
for _, s := range page.By["modelAt"] {
got[s.ID] = s
}
fast, slow := got[ModelAtKey("zhipu", "glm-5.3")], got[ModelAtKey("zcode", "glm-5.3")]
if len(got) != 3 || fast.Calls != 2 || slow.Calls != 1 || got[ModelAtKey("zcode", "kimi-k3")].Calls != 1 {
t.Fatalf("model at each provider = %+v, want glm-5.3 at zhipu (2) and at zcode (1), kimi-k3 at zcode (1)", page.By["modelAt"])
}
if fast.Timed != 2 || fast.TTFT != 400 || slow.Timed != 1 || slow.TTFT != 800 {
t.Fatalf("first tokens mixed between providers: %+v %+v", fast, slow)
}
if fast.DecodeMs == 0 || slow.DecodeMs == 0 || fast.DecodeOut*1000/int(fast.DecodeMs) <= slow.DecodeOut*1000/int(slow.DecodeMs) {
t.Fatalf("decode speeds mixed between providers: %+v %+v", fast, slow)
}
if page.Total != 2 {
t.Fatalf("the provider and model picked no longer filter the requests: %d", page.Total)
}
})
}
}