* 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>
314 lines
12 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|