diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index af5513ca..9f0509ff 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -773,9 +773,11 @@ body.window .field.solo { grid-column: auto; } .led-strip .meter { height: 5px; margin-top: 8px; border-radius: 3px; background: var(--pill); overflow: hidden; } .led-strip .meter i { display: block; height: 100%; border-radius: 3px; background: var(--green); } .led-trend { padding: 10px 14px 10px; background: var(--card); border: 1px solid var(--line); border-radius: 12px; } -.led-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } +.led-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; margin-bottom: 8px; } .led-bar .grow { flex: 1; } .led-by { font-size: 11.5px; color: var(--muted); } +/* "Split by" and its choices wrap together, under the metrics when narrow */ +.led-split { display: flex; align-items: center; gap: 8px; } .led-body { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 272px; gap: 18px; } .led-chart { min-width: 0; } .led-chart .plot { position: relative; height: 236px; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 93198a14..c10c4986 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -13364,7 +13364,9 @@ function ledMoney(v) { const LED_METRICS = [["tokens", "Tokens"], ["cost", "Cost"], ["calls", "Requests"]]; // the window's chart can also say how fast the replies came (#860) const LED_TREND = [...LED_METRICS, ["speed", "Speed"]]; -const LED_SPLITS = [["model", "Model"], ["provider", "Provider"], ["agent", "Agent"]]; +// "modelAt" is a model at the provider it went to, so one model's speed at +// each provider is ranked side by side (inaction on Discord) +const LED_SPLITS = [["model", "Model"], ["modelAt", "Model · provider"], ["provider", "Provider"], ["agent", "Agent"]]; const LED_SHOWN = 7; // told apart in a chart; the rest are "Other" const allTokens = (x) => x.input + x.output + x.cache_read + x.cache_write; // how fast the timed replies of a point, a share or a part wrote, in tokens @@ -13723,10 +13725,17 @@ function drawLedTrend() { }); rank.chart = chart; if (!rank.rail) ledRail($("#ledRail"), rank); - const picked = ledSplit === "provider" ? ledProvider : ledSplit === "agent" ? ledAgent : ledModel; + const picked = ledSplit === "provider" ? ledProvider : ledSplit === "agent" ? ledAgent + : ledSplit === "modelAt" ? (ledProvider && ledModel ? ledProvider + "/" + ledModel : "") : ledModel; drawLedRank(rank, l, ledSplit, ledMetric, picked, (x) => { // a click lists only that one's requests; on the one listed, all again - if (ledSplit === "provider") ledProvider = ledProvider === x.id ? "" : x.id; + if (ledSplit === "modelAt") { + // provider/model: both filters, the provider's id has no "/" + const q = $("#ledQ"), cut = x.id.indexOf("/"), off = picked === x.id; + ledProvider = off ? "" : x.id.slice(0, cut); + ledModel = off ? "" : x.id.slice(cut + 1); + ledQuery = ledModel; q.value = ledQuery; + } else if (ledSplit === "provider") ledProvider = ledProvider === x.id ? "" : x.id; else if (ledSplit === "agent") ledAgent = ledAgent === x.id ? "" : x.id; else { const q = $("#ledQ"); ledModel = ledModel === x.id ? "" : x.id; ledQuery = ledModel; q.value = ledQuery; } ledOffset = 0; diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 9023ba57..6648b503 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -1554,6 +1554,7 @@ const I18N = { "Key": "密钥", "{env}=magpie. The gateway trusts everything on loopback, so any value works.": "{env}=magpie。网关信任本机回环请求,可填任意值。", "Model": "模型", + "Model · provider": "模型 · 供应商", "Model id": "模型 ID", "Model ids": "模型 ID", "provider/model, as listed below. Click a model there to put it in the snippets.": "格式为 provider/model,见下方列表。点击某个模型即可放入示例。", @@ -4765,6 +4766,7 @@ const I18N = { "Key": "キー", "{env}=magpie. The gateway trusts everything on loopback, so any value works.": "{env}=magpie。ゲートウェイはループバック上のすべてを信頼するため、どんな値でも動作します。", "Model": "モデル", + "Model · provider": "モデル · プロバイダ", "Model id": "モデル ID", "Model ids": "モデル ID", "provider/model, as listed below. Click a model there to put it in the snippets.": "下の一覧にある provider/model の形式です。モデルをクリックするとスニペットに入ります。", @@ -7952,6 +7954,7 @@ const I18N = { "Key": "Schlüssel", "{env}=magpie. The gateway trusts everything on loopback, so any value works.": "{env}=magpie. Der Zugangspunkt vertraut allem über Loopback, daher funktioniert jeder Wert.", "Model": "Modell", + "Model · provider": "Modell · Anbieter", "Model id": "Modell-ID", "Model ids": "Modell-IDs", "provider/model, as listed below. Click a model there to put it in the snippets.": "provider/model, wie unten aufgeführt. Klicken Sie dort auf ein Modell, um es in die Snippets zu übernehmen.", diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index 04112c7f..1fab6467 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -167,8 +167,7 @@
- Split by -
+ Split by
diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 5618a72d..1226e8e5 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -779,6 +779,13 @@ request listed there is nothing; in English and Chinese, light and dark. The daily chart also checks selection, gray bars on other days, filtered rows and CSV, an empty day, clearing a selection, keyboard input and resizing. +`usage-model-at-provider.test.cjs` splits the Requests tab's chart by "Model · +provider" (inaction on Discord): one model at two providers is two rows of the +ranking, each named by both with the provider's icon and its own first token, +the faster first by Speed though fewer requests went to it. A click lists that +provider's requests of that model (provider and model both asked for), a click +again all of them, and the page doesn't move. English and Chinese. + `usage-chart-axis.test.cjs` checks the usage chart's side labels, in the window's Requests tab (1180 and 560 wide) and the tray panel's Usage tab (440 and 340 wide): for tokens, cost in dollars and in yuan, and requests, small and diff --git a/internal/gui/tests/usage-model-at-provider.test.cjs b/internal/gui/tests/usage-model-at-provider.test.cjs new file mode 100644 index 00000000..e6662eb4 --- /dev/null +++ b/internal/gui/tests/usage-model-at-provider.test.cjs @@ -0,0 +1,134 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// The Usage page's Requests tab ranks one model at each provider it went to +// (inaction on Discord: "相同模型在不同提供商的速度情况"). The chart's split +// has "Model · provider": glm-5.3 at Zhipu and at ZCode are two rows of the +// ranking, each with its own speed and first token, the fastest first by +// Speed; a click lists that provider's requests of that model, a click again +// all of them, and the page doesn't move. English and Chinese; no backend, +// the API is faked. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); +const reader = require("./reader.cjs"); + +const assets = path.resolve(__dirname, "../assets"); +const now = Date.now(); +const H = 3600e3; +const hour = (i) => new Date(Math.floor(now / H) * H - i * H).toISOString(); + +const row = (i, provider, providerName) => ({ + route_id: 900 + i, t: new Date(now - (i + 1) * 60e3).toISOString(), agent: "claude", agentName: "Claude Code", icon: "claudecode-color", + provider, providerName, req: "glm-5.3", model: "glm-5.3", served: "glm-5.3", in: 1000, out: 500, cache_read: 0, cache_write: 0, + ms: 2000, ttft_ms: 300, status: 200, rid: "req_" + i, ep: "/v1/messages", cost: 0.01, priced: true, +}); +// Zhipu: 2 replies, 300 ms to the first token, 100 tok/s; ZCode: 4 replies, +// 1200 ms, 25 tok/s. More requests went to ZCode, so by requests it leads. +const ZHIPU = { id: "zhipu/glm-5.3", name: "glm-5.3 · Zhipu", icon: "zhipu-color", calls: 2, input: 1, output: 1000, cache_read: 0, cache_write: 0, cost: 0.1, timed: 2, ttft_ms: 600, decode_ms: 10000, decode_out: 1000 }; +const ZCODE = { id: "zcode/glm-5.3", name: "glm-5.3 · ZCode", icon: "zcode", calls: 4, input: 1, output: 2000, cache_read: 0, cache_write: 0, cost: 0.2, timed: 4, ttft_ms: 4800, decode_ms: 80000, decode_out: 2000 }; +const MODEL = { id: "glm-5.3", calls: 6, input: 2, output: 3000, cache_read: 0, cache_write: 0, cost: 0.3, timed: 6, ttft_ms: 5400, decode_ms: 90000, decode_out: 3000 }; +const part = (x) => ({ calls: x.calls, tokens: x.output, cost: x.cost, timed: x.timed, ttft_ms: x.ttft_ms, decode_ms: x.decode_ms, decode_out: x.decode_out }); +const timing = { timed: 6, ttft_ms: 5400, decode_ms: 90000, decode_out: 3000 }; + +function ledger(q) { + const provider = q.get("provider"), model = q.get("model"); + const rows = [row(0, "zhipu", "Zhipu"), row(1, "zcode", "ZCode")].filter((r) => (!provider || r.provider === provider) && (!model || r.model === model)); + return { + period: q.get("period"), rows, offset: 0, total: rows.length, calls: 6, errors: 0, + input: 2, output: 3000, cache_read: 0, cache_write: 0, cost: 0.3, unpriced: 0, ...timing, + by: { + model: [MODEL], modelAt: [ZCODE, ZHIPU], + provider: [{ ...ZCODE, id: "zcode", name: "ZCode" }, { ...ZHIPU, id: "zhipu", name: "Zhipu" }], + }, + bucket: "hour", + series: [{ + label: "", time: hour(0), calls: 6, input: 2, output: 3000, ...timing, + by: { model: { "glm-5.3": part(MODEL) }, modelAt: { "zhipu/glm-5.3": part(ZHIPU), "zcode/glm-5.3": part(ZCODE) } }, + }], + agents: [{ id: "claude", name: "Claude Code", icon: "claudecode-color" }], + providers: [{ id: "zhipu", name: "Zhipu", icon: "zhipu-color" }, { id: "zcode", name: "ZCode", icon: "zcode" }], + }; +} + +function server(lang, asked) { + return async (route) => { + const url = new URL(route.request().url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } }); + if (url.pathname === "/api/usage/requests") { asked.push(url.searchParams); return json(ledger(url.searchParams)); } + if (url.pathname === "/api/usage/quotas") return json([]); + if (url.pathname === "/api/usage") return json({ calls: 0, errors: 0, input: 0, output: 0, reasoning: 0, unpriced: 0, cost: 0, bucket: "day", series: [], agents: [], models: [], path: "" }); + if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], models: [], gateway: { running: false } }); + if (url.pathname === "/api/sessions") return json({ sessions: [], dirs: [] }); + if (url.pathname === "/api/sessions/stats") return json({ from: "", to: "", days: [], agents: {} }); + if (url.pathname === "/api/groups") return json({ groups: [], models: [] }); + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + await route.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +const L = { + en: { split: "Model · provider", speed: "Speed", fast: "TTFT 300 ms", slow: "TTFT 1.2 s" }, + zh: { split: "模型 · 供应商", speed: "速度", fast: "首字 300 毫秒", slow: "首字 1.2 秒" }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": one model ranked at each provider", async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + for (const lang of ["en", "zh"]) { + const w = L[lang]; + await t.test(lang, async () => { + const errors = [], asked = []; + const ctx = await browser.newContext({ viewport: { width: 1100, height: 800 }, reducedMotion: "reduce" }); + const p = await ctx.newPage(); + p.setDefaultTimeout(5000); + p.on("pageerror", (e) => errors.push(e.message)); + await p.route("**/*", server(lang, asked)); + await p.goto("http://magpie.test/"); + await p.locator('[data-view="usage"]').first().click(); + await p.locator("#usageTab .opt").nth(1).click(); + await p.locator("#ledWrap .led tbody tr").first().waitFor(); + await p.waitForTimeout(300); + + await reader.click(p, p.locator("#ledSplit .opt").filter({ hasText: w.split })); + await reader.click(p, p.locator("#ledMetric .opt").filter({ hasText: w.speed })); + await p.waitForTimeout(150); + const rows = p.locator("#ledRank button.rk"); + assert.equal(await rows.count(), 2); + // by speed the fastest first, though fewer requests went to it + const names = await p.locator("#ledRank button.rk .rk-nm").allTextContents(); + assert.deepEqual(names, ["glm-5.3 · Zhipu", "glm-5.3 · ZCode"]); + const said = await p.locator("#ledRank button.rk .rk-b").allTextContents(); + assert(said[0].includes(w.fast) && said[1].includes(w.slow), "each its own first token: " + said.join(" | ")); + // the provider's icon beside each + assert.deepEqual(await p.locator("#ledRank button.rk .rk-a .ic").evaluateAll((x) => x.map((e) => e.dataset.icon)), ["zhipu-color", "zcode"]); + + // a click lists ZCode's glm-5.3 only; the page stays where it was + const y = await p.evaluate(() => [document.scrollingElement.scrollTop, document.querySelector("#ledRank").scrollTop]); + const before = asked.length; + await reader.click(p, rows.nth(1)); + await p.waitForFunction((n) => document.querySelectorAll("#ledRank button.rk.on").length === 1, before); + const q = asked.at(-1); + assert(asked.length > before); + assert.equal(q.get("provider"), "zcode"); + assert.equal(q.get("model"), "glm-5.3"); + assert.equal(await p.locator("#ledRank button.rk.on .rk-nm").textContent(), "glm-5.3 · ZCode"); + assert.deepEqual(await p.evaluate(() => [document.scrollingElement.scrollTop, document.querySelector("#ledRank").scrollTop]), y); + + // again: all of them + await reader.click(p, p.locator("#ledRank button.rk.on")); + await p.waitForFunction(() => !document.querySelector("#ledRank button.rk.on")); + assert.equal(asked.at(-1).get("provider"), null); + assert.equal(asked.at(-1).get("model"), null); + assert.deepEqual(errors, []); + await ctx.close(); + }); + } + }); +} diff --git a/internal/gui/usage.go b/internal/gui/usage.go index 32721509..f96bf29b 100644 --- a/internal/gui/usage.go +++ b/internal/gui/usage.go @@ -11,6 +11,7 @@ import ( "path/filepath" "slices" "strconv" + "strings" "time" "github.com/yetone/magpie/internal/access" @@ -149,7 +150,8 @@ type ledgerJSON struct { // Series: the period by hour, day or week (Bucket), before the day filter Bucket string `json:"bucket"` Series []usage.SeriesPoint `json:"series"` - // By: the rows told apart by provider, agent and model, the most tokens + // By: the rows told apart by provider, agent, model and model at each + // provider ("modelAt", named "model · provider"), the most tokens // first. The one by a dimension the filter has picked is of the rows // without that pick, so the others are still there to switch to. By map[string][]ledgerShare `json:"by"` @@ -293,6 +295,11 @@ func ledgerPage(p usage.Period, f usage.Filter, offset, limit int) ledgerJSON { case "agent": a := who(s.ID) ls.Name, ls.Icon = a.Name, a.Icon + case "modelAt": + // the model, and the provider it went to + prov, model, _ := strings.Cut(s.ID, "/") + a := which(prov) + ls.Name, ls.Icon = model+" · "+a.Name, a.Icon } shares = append(shares, ls) } diff --git a/internal/gui/usage_test.go b/internal/gui/usage_test.go index b46496a6..ef543bc6 100644 --- a/internal/gui/usage_test.go +++ b/internal/gui/usage_test.go @@ -288,6 +288,22 @@ func TestLedgerSingleSnapshotAndLocalSession(t *testing.T) { } } +// A model at a provider is named by both, with the provider's icon, so +// one model's speed at each provider reads apart (inaction on Discord). +func TestLedgerNamesModelAtProvider(t *testing.T) { + sandboxHome(t) + before := usage.LogCalls + usage.LogCalls = func(time.Time) []sessions.Call { + return []sessions.Call{{Time: time.Now(), Agent: "claude", Session: "s", Model: "claude-sonnet-5", Tokens: sessions.Tokens{Input: 10, Output: 2}}} + } + t.Cleanup(func() { usage.LogCalls = before }) + l := ledgerPage(usage.Today, usage.Filter{}, 0, 1) + at := l.By["modelAt"] + if len(at) != 1 || at[0].ID != usage.ModelAtKey(usage.UnknownProvider, "claude-sonnet-5") || at[0].Name != "claude-sonnet-5 · Local session" { + t.Fatalf("model at provider %+v", at) + } +} + func TestCSVStamp(t *testing.T) { for _, day := range []string{"", "2026-02-30", "../../other", "2026-09-30\""} { want := "magpie-requests-7d-" + time.Now().Format(time.DateOnly) diff --git a/internal/usage/ledger.go b/internal/usage/ledger.go index 8d8d34ac..c3c208ab 100644 --- a/internal/usage/ledger.go +++ b/internal/usage/ledger.go @@ -516,8 +516,14 @@ func (t *Totals) addRow(r Row) { } // Dimensions are what calls are told apart by: the provider they went to, -// the agent that made them, the model. -var Dimensions = []string{"provider", "agent", "model"} +// the agent that made them, the model, and the model at the provider it +// went to ("modelAt"), so one model's speed at each provider can be compared +// (inaction on Discord). +var Dimensions = []string{"provider", "agent", "model", "modelAt"} + +// ModelAtKey is a call's "modelAt": its provider's id and its model, as +// "provider/model" (a provider's id has no "/"). +func ModelAtKey(provider, model string) string { return provider + "/" + model } // key is the row's part of a dimension: its provider's id, its agent, its model. func (r Row) key(by string) string { @@ -526,6 +532,8 @@ func (r Row) key(by string) string { return r.Provider case "agent": return r.Agent + case "modelAt": + return ModelAtKey(r.Provider, r.Model) case "computer": if r.Computer == "" { return ThisComputer @@ -535,6 +543,23 @@ func (r Row) key(by string) string { return r.Model } +// keyer is Row.key for many rows, made once for each provider and model: +// a "modelAt" is a string built of two, which a page of thousands of rows +// would otherwise build again for each. +type keyer map[[2]string]string + +func (m keyer) key(r Row, by string) string { + if by != "modelAt" { + return r.key(by) + } + k, ok := m[[2]string{r.Provider, r.Model}] + if !ok { + k = r.key(by) + m[[2]string{r.Provider, r.Model}] = k + } + return k +} + // AllTokens is what went in and out and through the cache. func (t Totals) AllTokens() int { return t.Input + t.Output + t.CacheRead + t.CacheWrite } @@ -549,11 +574,12 @@ type Share struct { func Breakdown(rows []Row, by string) []Share { at := map[string]*Share{} var out []*Share + keys := keyer{} for _, r := range rows { if r.IsRejected() { continue } - k := r.key(by) + k := keys.key(r, by) s := at[k] if s == nil { s = &Share{ID: k} @@ -642,6 +668,7 @@ func LedgerSeries(p Period, rows []Row) (bucket string, pts []SeriesPoint) { } } kept := map[string]map[string]bool{} + keys := keyer{} for _, d := range Dimensions { kept[d] = map[string]bool{} for i, s := range Breakdown(rows, d) { @@ -664,7 +691,7 @@ func LedgerSeries(p Period, rows []Row) (bucket string, pts []SeriesPoint) { } pts[i].addRow(r) for _, d := range Dimensions { - if k := r.key(d); kept[d][k] { + if k := keys.key(r, d); kept[d][k] { part := pts[i].By[d][k] part.add(r) pts[i].By[d][k] = part diff --git a/internal/usage/request_page.go b/internal/usage/request_page.go index 16f83969..01d72c6e 100644 --- a/internal/usage/request_page.go +++ b/internal/usage/request_page.go @@ -746,6 +746,7 @@ func buildRequestBlocks(p Period, f Filter, offset, limit int, gateways, chunks, groups := map[string]map[string]*Share{} chartGroups := map[string]map[string]*Share{} seriesGroups := map[string]map[string]*Share{} + keys := keyer{} for _, d := range Dimensions { groups[d] = map[string]*Share{} chartGroups[d] = map[string]*Share{} @@ -791,7 +792,7 @@ func buildRequestBlocks(p Period, f Filter, offset, limit int, gateways, chunks, } if keep || f.Day != "" && chartFilter.keeps(r.Record) { for _, d := range Dimensions { - k := r.key(d) + k := keys.key(r, d) s := seriesGroups[d][k] if s == nil { s = &Share{ID: k} @@ -822,13 +823,16 @@ func buildRequestBlocks(p Period, f Filter, offset, limit int, gateways, chunks, if d == "agent" { g.Agent = "" } - if d == "model" { + if d == "model" || d == "modelAt" { g.Model = "" } + if d == "modelAt" { + g.Provider = "" + } if f.Day != "" { g.Day = "" if g.keeps(r.Record) { - k := r.key(d) + k := keys.key(r, d) if chartGroups[d][k] == nil { chartGroups[d][k] = &Share{ID: k} } @@ -839,7 +843,7 @@ func buildRequestBlocks(p Period, f Filter, offset, limit int, gateways, chunks, if !g.keeps(r.Record) { continue } - k := r.key(d) + k := keys.key(r, d) s := groups[d][k] if s == nil { s = &Share{ID: k} @@ -909,7 +913,7 @@ func buildRequestBlocks(p Period, f Filter, offset, limit int, gateways, chunks, pt := &out.Series[i] pt.addRow(r) for _, d := range Dimensions { - k := r.key(d) + k := keys.key(r, d) part := pt.By[d][k] part.add(r) pt.By[d][k] = part @@ -967,9 +971,12 @@ func pageFromLedger(p Period, f Filter, offset, limit int, all Ledgered) Request if d == "agent" { g.Agent = "" } - if d == "model" { + if d == "model" || d == "modelAt" { g.Model = "" } + if d == "modelAt" { + g.Provider = "" + } out.By[d] = Breakdown(all.Filtered(g).Rows, d) if f.Day != "" { g.Day = "" diff --git a/internal/usage/request_page_test.go b/internal/usage/request_page_test.go index b59c7f5d..04e96766 100644 --- a/internal/usage/request_page_test.go +++ b/internal/usage/request_page_test.go @@ -257,3 +257,49 @@ func TestQueryPageSourceAndIdentityInvalidation(t *testing.T) { 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 := time.Now() + 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) + } + }) + } +}