From 5464a362f69aa869229a6e042ce85b94198cf05a Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 13:32:32 +0800 Subject: [PATCH] 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) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/gui/assets/app.css | 20 ++- internal/gui/assets/app.js | 138 +++++++++++--- internal/gui/assets/i18n.js | 33 ++++ internal/gui/tests/ledger-hscroll.test.cjs | 4 +- .../gui/tests/usage-ledger-detail.test.cjs | 16 +- internal/gui/tests/usage-ledger.test.cjs | 4 +- internal/gui/tests/usage-speed.test.cjs | 168 ++++++++++++++++++ internal/usage/ledger.go | 30 +++- internal/usage/ledger_test.go | 30 ++++ internal/usage/request_page.go | 6 +- 10 files changed, 405 insertions(+), 44 deletions(-) create mode 100644 internal/gui/tests/usage-speed.test.cjs diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 0a40c90e..d7cf99a7 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -294,6 +294,11 @@ body.window .top.inrow .actions { margin-left: 0; } .view { flex: 1; min-height: 0; overflow-y: auto; padding: 0 12px 4px; display: flex; flex-direction: column; animation: view-in .24s var(--ease); } @keyframes view-in { from { opacity: 0; transform: translateY(5px); } } body.window .view { padding: 2px 16px 8px; } +/* a page of rows and settings keeps to a width the eye can follow across, + centred in a maximized window, rather than its name at one edge and its + switch at the other (#860); the tables and charts of Routing, Usage and + Sessions take the whole width */ +body.window :is(#view-agents, #view-providers, #view-gateway, #view-library, #view-plugins, #view-settings) { padding-left: max(16px, calc(50% - 600px)); padding-right: max(16px, calc(50% - 600px)); } .view > .view-room { flex: none; pointer-events: none; } /* see "where the reader is" */ .view::-webkit-scrollbar { width: 0; } /* Windows and Linux draw a classic scrollbar, arrows and all; a thin rounded @@ -750,7 +755,7 @@ body.window .field.solo { grid-column: auto; } were of — by provider, agent or model — which is also the chart's legend */ .led-dash { display: flex; flex-direction: column; gap: 8px; margin-bottom: 10px; } .led-dash[hidden] { display: none; } -.led-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); background: var(--card); border: 1px solid var(--line); border-radius: 12px; } +.led-strip { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); background: var(--card); border: 1px solid var(--line); border-radius: 12px; } .led-strip .blk { min-width: 0; padding: 11px 15px 10px; } .led-strip .blk + .blk { border-left: 1px solid var(--line-2); } .led-strip .k { display: block; font-size: 11px; color: var(--muted); } @@ -772,6 +777,8 @@ body.window .field.solo { grid-column: auto; } .led-chart .axis { fill: var(--muted); font-size: 10.5px; } .led-chart .hot { fill: var(--pill); } .led-chart rect.col { transition: opacity .12s; } +/* a speed chart's column is all the replies', each one told apart a mark on it */ +.led-chart rect.col.all { fill-opacity: .55; } .led-chart .led-day { cursor: pointer; outline: none; } .led-chart .led-day:focus-visible { stroke: var(--accent); stroke-width: 1; } .led-chart .tip { position: absolute; top: 2px; z-index: 2; min-width: 172px; max-width: 260px; padding: 8px 10px 7px; background: var(--pop-bg); border-radius: 9px; box-shadow: var(--shadow); font-size: 11.5px; pointer-events: none; } @@ -820,8 +827,9 @@ body.window .field.solo { grid-column: auto; } .led-body { grid-template-columns: minmax(0, 1fr); gap: 8px; } .led-rail { display: none; } .led-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .led-strip .blk:nth-child(3) { border-left: 0; } + .led-strip .blk:nth-child(odd) { border-left: 0; } .led-strip .blk:nth-child(n + 3) { border-top: 1px solid var(--line-2); } + .led-strip .blk:last-child:nth-child(odd) { grid-column: 1 / -1; } } /* over the requests: their sum, and at its right the request archive's switch */ .led-head { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; margin: 0 2px 7px; min-width: 0; } @@ -868,6 +876,14 @@ body.window .field.solo { grid-column: auto; } #ledRoute[hidden] { display: none; } #ledRouteClear { font-size: 18px; padding: 2px 6px; } .led .model, .led .where { max-width: 200px; overflow: hidden; text-overflow: ellipsis; } +/* a window too narrow for every column leaves out those the row's details + say (#860), and narrows the names */ +.led.tight .lc-opt, .led:not(.tight) .led-dl .lc-opt { display: none; } +.led.tight .led-dl .lc-opt { display: block; } +.led.tight .model, .led.tight .where { max-width: 160px; } +.led.tight th, .led.tight td { padding-left: 6px; padding-right: 6px; } +.led-tight { font-size: 11.5px; color: var(--muted); } +.led-tight[hidden] { display: none; } .led .where { color: var(--fg-2); } .led .where-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .led .source-badges { display: flex; flex-wrap: nowrap; gap: 4px; margin-top: 4px; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index f4e78195..dddd51e7 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -12777,7 +12777,7 @@ const LED_PAGE = 100; let ledMetric = "tokens", ledSplit = "model"; try { const m = localStorage.getItem("magpie.ledMetric"); - if (["tokens", "cost", "calls"].includes(m)) ledMetric = m; + if (["tokens", "cost", "calls", "speed"].includes(m)) ledMetric = m; } catch {} let ledRouteInfo = null, ledBeforeRoute = null; @@ -12899,6 +12899,17 @@ function ledDetail(r, cols) { dl.append(el("dt", "", t("Routing")), dd); } if (r.computerName) add("Computer", r.computerName); + // the columns a narrow window leaves out, shown only while it does (#860) + const opt = (name, value) => { + if (!value) return; + add(name, value); + dl.lastChild.classList.add("lc-opt"); + dl.lastChild.previousSibling.classList.add("lc-opt"); + }; + opt("Sent", r.model); + opt("Effort", r.effort); + if (r.cache_write || r.cache_read) opt("Cache", t("{w} written · {r} read", { w: ledNum(r.cache_write || 0), r: ledNum(r.cache_read || 0) })); + if (ledRowSpeed(r)) add("Speed", t("{n} tok/s", { n: ledNum(Math.round(ledRowSpeed(r))) })); add("Request ID", r.rid); add("Endpoint", r.ep); add("Session ID", r.session); @@ -13052,16 +13063,23 @@ 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"]]; 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 +// a second after their first (usage.Totals.Speed): 0 for none timed +const ledSpeed = (x) => x?.decode_ms > 0 ? (1000 * x.decode_out) / x.decode_ms : 0; +// their mean wait for the first token, 0 for none +const ledTTFT = (x) => x?.timed > 0 ? x.ttft_ms / x.timed : 0; // a metric's value of a point or a share, as the chart counts it -const ledValue = (m, x) => m === "cost" ? +x.cost || 0 : m === "calls" ? +x.calls || 0 : allTokens(x); -// of a point's part, which has only its calls, tokens and cost -const ledPart = (m, x) => m === "cost" ? +x.cost || 0 : m === "calls" ? +x.calls || 0 : +x.tokens || 0; +const ledValue = (m, x) => m === "speed" ? ledSpeed(x) : m === "cost" ? +x.cost || 0 : m === "calls" ? +x.calls || 0 : allTokens(x); +// of a point's part, which has its calls, tokens, cost and speed +const ledPart = (m, x) => m === "speed" ? ledSpeed(x) : m === "cost" ? +x.cost || 0 : m === "calls" ? +x.calls || 0 : +x.tokens || 0; // a metric as its axis and its values say it -const ledFormat = (m, v) => m === "cost" ? ledMoney(v) : m === "calls" ? ledNum(Math.round(v)) : fmtN(Math.round(v)); -const ledFormatLong = (m, v) => m === "cost" ? (fmtCost({ cost: v, unpriced: 0 }) || "—") : ledNum(Math.round(v)); +const ledFormat = (m, v) => m === "cost" ? ledMoney(v) : m === "calls" || m === "speed" ? ledNum(Math.round(v)) : fmtN(Math.round(v)); +const ledFormatLong = (m, v) => m === "speed" ? (v > 0 ? t("{n} tok/s", { n: ledNum(Math.round(v)) }) : "—") : m === "cost" ? (fmtCost({ cost: v, unpriced: 0 }) || "—") : ledNum(Math.round(v)); // an axis's top with four steps under it: round numbers, the top at least the largest value function ledAxis(max) { @@ -13113,9 +13131,12 @@ function drawLedColumns(box, l, split, metric, compact, chooseDay, focusDay) { if (W < 100 || !n) return; const { top } = ledCategories(l.chartBy ? { by: l.chartBy } : l, split, metric); const totals = pts.map((p) => ledValue(metric, p)); - const max = Math.max(0, ...totals); + // speeds don't add up: a point's column is how fast all its replies + // wrote, and each one told apart is a mark at its own speed on it (#860) + const speed = metric === "speed"; + const max = Math.max(0, ...totals, ...(speed ? pts.flatMap((p) => top.map((c) => ledSpeed(p.by?.[split]?.[c.id]))) : [])); if (!(max > 0)) { - plot.append(el("div", "none", t(metric === "cost" ? "No known price for these requests" : "Nothing in this period"))); + plot.append(el("div", "none", t(metric === "cost" ? "No known price for these requests" : speed ? "No streamed replies timed in this period" : "Nothing in this period"))); return; } const topV = ledAxis(max); @@ -13167,6 +13188,17 @@ function drawLedColumns(box, l, split, metric, compact, chooseDay, focusDay) { y0 += v; }; const by = p.by?.[split] || {}; + if (speed) { + if (totals[i] > 0) { draw("\0all", "var(--faint)", totals[i]); segs.at(-1).classList.add("all"); } + for (const c of top) { + const v = ledSpeed(by[c.id]); + if (!(v > 0)) continue; + const r = sv("rect", { x, width: bw, y: Y(v) - 1.5, height: 3, rx: 1.5, class: "col mark", "data-k": c.id, "data-day": p.time.slice(0, 10), "data-color": c.color }, { fill: c.color }); + g.append(r); + segs.push(r); + } + return; + } let stacked = 0; for (const c of top) { const v = ledPart(metric, by[c.id] || {}); stacked += v; draw(c.id, c.color, v); } draw("\0other", "var(--faint)", Math.max(0, totals[i] - stacked)); @@ -13192,17 +13224,20 @@ function drawLedColumns(box, l, split, metric, compact, chooseDay, focusDay) { hot.style.display = ""; tip.replaceChildren(el("b", "", ledWhen(p, l.bucket))); const by = p.by?.[split] || {}; - const rows = top.map((c) => [name(c), c.color, ledPart(metric, by[c.id] || {})]); - rows.push([t("Other"), "var(--faint)", Math.max(0, totals[i] - rows.reduce((a, x) => a + x[2], 0))]); - for (const [nm, color, v] of rows.filter((x) => x[2] > 0).sort((a, b) => b[2] - a[2])) { + const rows = top.map((c) => [name(c), c.color, ledPart(metric, by[c.id] || {}), by[c.id]]); + if (!speed) rows.push([t("Other"), "var(--faint)", Math.max(0, totals[i] - rows.reduce((a, x) => a + x[2], 0))]); + for (const [nm, color, v, part] of rows.filter((x) => x[2] > 0).sort((a, b) => b[2] - a[2])) { const row = el("div"); const sw = el("i"); sw.style.background = color; - row.append(sw, el("span", "", nm), el("em", "", ledFormatLong(metric, v))); + const said = el("em", "", ledFormatLong(metric, v)); + if (speed && ledTTFT(part)) said.append(" · " + t("TTFT {ms}", { ms: ledTook(ledTTFT(part)) })); + row.append(sw, el("span", "", nm), said); tip.append(row); } const sum = el("div", "sum"); - sum.append(el("span", "", t("Total")), el("em", "", ledFormatLong(metric, totals[i]))); + sum.append(el("span", "", t(speed ? "All" : "Total")), el("em", "", ledFormatLong(metric, totals[i]))); + if (speed && ledTTFT(p)) sum.lastChild.append(" · " + t("TTFT {ms}", { ms: ledTook(ledTTFT(p)) })); if (metric !== "calls" && p.calls) sum.lastChild.append(" · " + t(p.calls === 1 ? "{n} request" : "{n} requests", { n: ledNum(p.calls) })); tip.append(sum); tip.hidden = false; @@ -13261,9 +13296,13 @@ function drawLedRank(box, l, split, metric, picked, choose, compact) { if (metric !== "calls") more.push(t(x.calls === 1 ? "{n} request" : "{n} requests", { n: ledNum(x.calls) })); if (metric !== "tokens") more.push(t("{n} tokens", { n: fmtN(allTokens(x)) })); if (metric !== "cost" && x.cost) more.push("≈" + fmtCost(x)); + // how fast its replies came, beside what they cost (#860) + if (metric !== "speed" && ledSpeed(x)) more.push(t("{n} tok/s", { n: ledNum(Math.round(ledSpeed(x))) })); + if (ledTTFT(x)) more.push(t("TTFT {ms}", { ms: ledTook(ledTTFT(x)) })); const prompt = x.input + x.cache_write + x.cache_read; if (prompt && !compact) more.push(t("hit rate {p}", { p: Math.round((100 * x.cache_read) / prompt) + "%" })); - more.push(Math.round((100 * v) / sum) + "%"); + // a speed is no one's share of all + if (metric !== "speed") more.push(Math.round((100 * v) / sum) + "%"); const l2 = el("div", "rk-b"); l2.append(more.join(" · ")); if (x.errors) l2.append(" · ", Object.assign(el("span", "rk-bad"), { textContent: t("{n} failed", { n: x.errors }) })); @@ -13276,7 +13315,7 @@ function drawLedRank(box, l, split, metric, picked, choose, compact) { for (const x of top) box.append(one(x, x.color)); if (rest.length) { const tot = rest.reduce((acc, x) => { - for (const k of ["calls", "errors", "input", "output", "cache_read", "cache_write", "cost"]) acc[k] = (acc[k] || 0) + (x[k] || 0); + for (const k of ["calls", "errors", "input", "output", "cache_read", "cache_write", "cost", "timed", "ttft_ms", "decode_ms", "decode_out"]) acc[k] = (acc[k] || 0) + (x[k] || 0); return acc; }, { id: "\0other", name: t("Other ({n})", { n: rest.length }) }); box.append(one(tot, "var(--faint)", true)); @@ -13354,6 +13393,11 @@ function renderLedgerDash(l) { fill.style.width = (100 * rate).toFixed(1) + "%"; meter.append(fill); hit.append(meter); + // how fast the streamed replies wrote, and how long they took to begin (#860) + const speed = ledSpeed(l); + block("Output speed", speed ? t("{n} tok/s", { n: ledNum(Math.round(speed)) }) : "—", + line(ledTTFT(l) ? t("first token in {ms} on average", { ms: ledTook(ledTTFT(l)) }) : t("no streamed replies timed")), + "", t("Output tokens a second after the first, over the streamed replies")); drawLedTrend(); } @@ -13370,7 +13414,7 @@ function drawLedTrend() { } slide(host, key); }; - pill($("#ledMetric"), "ledMetric", LED_METRICS, ledMetric, (id) => { ledMetric = id; try { localStorage.setItem("magpie.ledMetric", id); } catch {} drawLedTrend(); }); + pill($("#ledMetric"), "ledMetric", LED_TREND, ledMetric, (id) => { ledMetric = id; try { localStorage.setItem("magpie.ledMetric", id); } catch {} drawLedTrend(); }); pill($("#ledSplit"), "ledSplit", LED_SPLITS, ledSplit, (id) => { ledSplit = id; drawLedTrend(); }); const chart = $("#ledChart"), rank = $("#ledRank"); drawLedColumns(chart, l, ledSplit, ledMetric, false, (day) => { @@ -13403,18 +13447,61 @@ function ledHScroll() { $("#ledWrap").addEventListener("scroll", () => { const bar = $("#ledHScroll"); if (bar.scrollLeft !== $("#ledWrap").scrollLeft) bar.scrollLeft = $("#ledWrap").scrollLeft; }, { passive: true }); $("#ledHScroll").addEventListener("scroll", () => { const wrap = $("#ledWrap"); if (wrap.scrollLeft !== $("#ledHScroll").scrollLeft) wrap.scrollLeft = $("#ledHScroll").scrollLeft; }, { passive: true }); // a window that changes size redraws the chart at its new width -new ResizeObserver(() => { $("#ledWrap").style.setProperty("--ledw", $("#ledWrap").clientWidth + "px"); ledHScroll(); }).observe($("#ledWrap")); +// a new width fits the table again, on the next frame: leaving columns out +// changes the table's height, which this would be told of in its own call +let ledFitW = 0, ledFitFrame = 0; +new ResizeObserver(() => { + const wrap = $("#ledWrap"); + wrap.style.setProperty("--ledw", wrap.clientWidth + "px"); + if (wrap.clientWidth !== ledFitW && !ledFitFrame) ledFitFrame = requestAnimationFrame(() => { ledFitFrame = 0; ledFitW = wrap.clientWidth; ledFit(); ledHScroll(); }); + ledHScroll(); +}).observe($("#ledWrap")); +// a table that grows past the window when its width didn't change (a font +// arriving, a redraw while out of sight) leaves its columns out then too +let ledGrowFrame = 0; +const ledGrow = new ResizeObserver(() => { + if (ledGrowFrame) return; + ledGrowFrame = requestAnimationFrame(() => { + ledGrowFrame = 0; + const wrap = $("#ledWrap"), table = wrap.querySelector("table.led"); + if (table && !table.classList.contains("tight") && wrap.clientWidth && wrap.scrollWidth > wrap.clientWidth + 1) { ledFit(); ledHScroll(); } + }); +}); new ResizeObserver(() => { if (!ledger || usageTab !== "requests" || view !== "usage" || $("#ledDash").hidden) return; drawLedColumns($("#ledChart"), ledger, ledSplit, ledMetric, false); ledRankEdges($("#ledRank")); }).observe($("#ledChart")); +// the third: a column a narrow window leaves out, its value in the row's +// details (#860) const LED_COLS = [ - ["Time"], ["Agent"], ["Requested"], ["Provider · account"], ["Sent"], ["Served"], ["Effort"], - ["In", "n"], ["Out", "n"], ["Cache write", "n"], ["Cache read", "n"], ["Cost", "n"], ["Duration", "n"], ["Status"], + ["Time"], ["Agent"], ["Requested"], ["Provider · account"], ["Sent", "", true], ["Served"], ["Effort", "", true], + ["In", "n"], ["Out", "n"], ["Cache write", "n", true], ["Cache read", "n", true], ["Cost", "n"], ["Duration", "n"], ["Speed", "n"], ["Status"], ]; +// how fast a reply wrote, in tokens a second after its first: as +// usage.DecodeWindow and routing.js's speedOf tell it, 0 when it can't +const ledRowSpeed = (r) => !ledFailed_(r) && r.out > 0 && r.ttft_ms > 0 && r.ms - r.ttft_ms >= 100 && r.out * 1000 <= 10000 * (r.ms - r.ttft_ms) ? r.out / ((r.ms - r.ttft_ms) / 1000) : 0; + +// the table as wide as the window, when leaving out what the row's details +// say anyway makes it so: else it scrolls sideways (#799) +function ledFit() { + const wrap = $("#ledWrap"), table = wrap.querySelector("table.led"); + let hint = $("#ledTight"); + if (!table) { if (hint) hint.hidden = true; return; } + table.classList.remove("tight"); + const tight = wrap.scrollWidth > wrap.clientWidth + 1; + table.classList.toggle("tight", tight); + if (!hint) { + hint = el("span", "led-tight"); + hint.id = "ledTight"; + $("#ledSum").after(hint); + } + hint.textContent = t("Sent, Effort and the cache are in each row's details"); + hint.hidden = !tight; +} + function renderLedger() { const l = ledger; const callers = l.callerKeys || []; @@ -13505,6 +13592,7 @@ function renderLedger() { const filtered = ledPurpose || ledDay || ledRoute || ledAgent || ledProvider || ledAccount || ledComputer || ledCallerKey || ledModel || ledFailed || ledQuery.trim(); const none = { today: "No calls today.", "7d": "No calls in the last 7 days.", "30d": "No calls in the last 30 days.", all: "No calls yet." }[period]; wrap.replaceChildren(el("div", "led-none", filtered ? t("No requests match these filters.") : t(none))); + ledFit(); ledHScroll(); pager.hidden = true; $("#ledNote").textContent = ""; @@ -13513,7 +13601,7 @@ function renderLedger() { wrap.classList.remove("none"); const table = el("table", "led"); const head = el("tr"); - for (const [name, cls] of LED_COLS) head.append(el("th", cls || "", t(name))); + for (const [name, cls, opt] of LED_COLS) head.append(el("th", (cls || "") + (opt ? " lc-opt" : ""), t(name))); table.append(el("thead"), el("tbody")); table.tHead.append(head); for (const r of l.rows) { @@ -13523,7 +13611,7 @@ function renderLedger() { tr.tabIndex = 0; tr.setAttribute("aria-expanded", ledOpen.has(key) ? "true" : "false"); const td = (child, cls, title) => { - const c = el("td", cls || ""); + const c = el("td", (cls || "") + (LED_COLS[tr.childElementCount]?.[2] ? " lc-opt" : "")); if (typeof child === "string") c.textContent = child; else c.append(child); if (title) c.title = title; tr.append(c); @@ -13589,6 +13677,11 @@ function renderLedger() { const durationBand = untimed ? "" : r.ms <= 10000 ? "fast" : r.ms <= 30000 ? "slow" : "long"; td(untimed ? "—" : (timed ? "≈" : "") + ledTook(r.ms), "n duration " + (untimed ? "faint" : "duration-" + durationBand), [!untimed ? t("{n} ms", { n: ledNum(r.ms) }) : "", !untimed ? t("Duration colors: ≤10 s green · 10–30 s amber · >30 s red") : "", timed && !untimed ? t("About: told from the session file's times, a little more or less than it took") : "", r.ttft_ms ? t("TTFT {ms}", { ms: ledTook(r.ttft_ms) }) : ""].filter(Boolean).join(" · ")); + // how fast it wrote once it began, as CC Switch's log has it (#860) + const v = ledRowSpeed(r); + td(v ? t("{n} tok/s", { n: ledNum(Math.round(v)) }) : "—", "n speed" + (v ? "" : " faint"), + v ? t("{n} output tokens in {ms} after the first", { n: ledNum(r.out), ms: ledTook(r.ms - r.ttft_ms) }) + " · " + t("TTFT {ms}", { ms: ledTook(r.ttft_ms) }) + : r.ttft_ms || untimed || ledFailed_(r) ? "" : t("Not streamed: no first token to time a speed from")); const st = el("span", "st"); // a status when the gateway logged the call; a session file has none, // and says only whether it went well @@ -13612,7 +13705,10 @@ function renderLedger() { } wrap.replaceChildren(table); wrap.style.setProperty("--ledw", wrap.clientWidth + "px"); + ledFit(); ledHScroll(); + ledGrow.disconnect(); + ledGrow.observe(table); // a page at a time: the newest first pager.hidden = l.total <= LED_PAGE; diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index ccda29e8..5654313c 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -2579,6 +2579,17 @@ const I18N = { "{n} s": "{n} 秒", "{n} ms": "{n} 毫秒", "TTFT {ms}": "首字 {ms}", + "Speed": "速度", + "Output speed": "输出速度", + "first token in {ms} on average": "首字平均 {ms}", + "no streamed replies timed": "没有可计时的流式回复", + "Output tokens a second after the first, over the streamed replies": "流式回复从首字起每秒输出的 token 数", + "No streamed replies timed in this period": "这段时间没有可计时的流式回复", + "Sent, Effort and the cache are in each row's details": "窗口较窄:发送模型、推理强度和缓存在每行的详情里", + "{n} output tokens in {ms} after the first": "首字后 {ms} 输出 {n} 个 token", + "Not streamed: no first token to time a speed from": "非流式:没有首字时间,算不出速度", + "Cache": "缓存", + "{w} written · {r} read": "写入 {w} · 读取 {r}", "first token in {ms}": "{ms} 后出首个 token", "first text in {ms}": "{ms} 后出首字", "{n} tok/s": "{n} token/秒", @@ -5691,6 +5702,17 @@ const I18N = { "{n} s": "{n} 秒", "{n} ms": "{n} ミリ秒", "TTFT {ms}": "TTFT {ms}", + "Speed": "速度", + "Output speed": "出力速度", + "first token in {ms} on average": "最初のトークンまで平均 {ms}", + "no streamed replies timed": "計測できたストリーミング応答なし", + "Output tokens a second after the first, over the streamed replies": "ストリーミング応答で、最初のトークン以降に毎秒出力されたトークン数", + "No streamed replies timed in this period": "この期間に計測できたストリーミング応答はありません", + "Sent, Effort and the cache are in each row's details": "ウィンドウが狭いため、送信したモデル・推論レベル・キャッシュは各行の詳細にあります", + "{n} output tokens in {ms} after the first": "最初のトークン後 {ms} で {n} トークンを出力", + "Not streamed: no first token to time a speed from": "ストリーミングではないため、速度を計れる最初のトークンの時刻がありません", + "Cache": "キャッシュ", + "{w} written · {r} read": "書き込み {w} · 読み取り {r}", "first token in {ms}": "最初のトークンまで {ms}", "first text in {ms}": "最初のテキストまで {ms}", "{n} tok/s": "{n} トークン/秒", @@ -8803,6 +8825,17 @@ const I18N = { "{n} s": "{n} s", "{n} ms": "{n} ms", "TTFT {ms}": "TTFT {ms}", + "Speed": "Tempo", + "Output speed": "Ausgabetempo", + "first token in {ms} on average": "erstes Token im Schnitt nach {ms}", + "no streamed replies timed": "keine gestreamte Antwort gemessen", + "Output tokens a second after the first, over the streamed replies": "Ausgabe-Token pro Sekunde nach dem ersten, über die gestreamten Antworten", + "No streamed replies timed in this period": "In diesem Zeitraum wurde keine gestreamte Antwort gemessen", + "Sent, Effort and the cache are in each row's details": "Schmales Fenster: Gesendet, Effort und Cache stehen in den Details jeder Zeile", + "{n} output tokens in {ms} after the first": "{n} Ausgabe-Token in {ms} nach dem ersten", + "Not streamed: no first token to time a speed from": "Nicht gestreamt: kein erstes Token, an dem sich ein Tempo messen ließe", + "Cache": "Cache", + "{w} written · {r} read": "{w} geschrieben · {r} gelesen", "first token in {ms}": "erstes Token nach {ms}", "first text in {ms}": "erster Text nach {ms}", "{n} tok/s": "{n} Token/s", diff --git a/internal/gui/tests/ledger-hscroll.test.cjs b/internal/gui/tests/ledger-hscroll.test.cjs index 206a85e0..7e2ac06c 100644 --- a/internal/gui/tests/ledger-hscroll.test.cjs +++ b/internal/gui/tests/ledger-hscroll.test.cjs @@ -57,7 +57,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const w = L[lang]; await t.test(lang, async () => { const errors = [], asked = []; - const ctx = await browser.newContext({ viewport: { width: 900, height: 640 }, reducedMotion: "reduce" }); + // narrower than the table even with the columns a narrow window + // leaves out (#860) + const ctx = await browser.newContext({ viewport: { width: 700, height: 640 }, reducedMotion: "reduce" }); const p = await ctx.newPage(); p.setDefaultTimeout(5000); p.on("pageerror", (e) => errors.push(e.message)); diff --git a/internal/gui/tests/usage-ledger-detail.test.cjs b/internal/gui/tests/usage-ledger-detail.test.cjs index 42c661a9..0b1e7cce 100644 --- a/internal/gui/tests/usage-ledger-detail.test.cjs +++ b/internal/gui/tests/usage-ledger-detail.test.cjs @@ -63,13 +63,13 @@ function server(lang, refreshed) { const L = { en: { statuses: ["429 · rate_limit_error", "200", "Succeeded", "rate_limit"], badge: "Local session", - labels: { fail: ["Status", "Error type", "Upstream said", "Duration", "Request ID", "Endpoint", "Session ID"], ok: ["Status", "Duration", "Request ID", "Endpoint", "Called for", "First token", "In magpie", "Vendor's first token"], log: ["Request ID", "Session ID", "Data source"], logFail: ["Status", "Error type", "Error", "Request ID", "Session ID", "Data source"] }, + speed: "24 tok/s", labels: { fail: ["Status", "Error type", "Upstream said", "Duration", "Request ID", "Endpoint", "Session ID"], ok: ["Status", "Duration", "Speed", "Request ID", "Endpoint", "Called for", "First token", "In magpie", "Vendor's first token"], log: ["Request ID", "Session ID", "Data source"], logFail: ["Status", "Error type", "Error", "Request ID", "Session ID", "Data source"] }, subagent: "Subagent", noStatus: "Read from the agent's session file. The account is shown only when local metadata identifies it; no service provider is inferred.", }, zh: { statuses: ["429 · rate_limit_error", "200", "成功", "rate_limit"], badge: "本地会话", - labels: { fail: ["状态", "错误类型", "上游返回", "耗时", "请求 ID", "终结点", "会话 ID"], ok: ["状态", "耗时", "请求 ID", "终结点", "用途", "首响", "magpie 内耗时", "厂商首字等待"], log: ["请求 ID", "会话 ID", "数据来源"], logFail: ["状态", "错误类型", "错误", "请求 ID", "会话 ID", "数据来源"] }, + speed: "24 token/秒", labels: { fail: ["状态", "错误类型", "上游返回", "耗时", "请求 ID", "终结点", "会话 ID"], ok: ["状态", "耗时", "速度", "请求 ID", "终结点", "用途", "首响", "magpie 内耗时", "厂商首字等待"], log: ["请求 ID", "会话 ID", "数据来源"], logFail: ["状态", "错误类型", "错误", "请求 ID", "会话 ID", "数据来源"] }, subagent: "子代理", noStatus: "读自 Agent 的会话文件;仅在本地元数据能够明确识别时显示账号,不推断供应商。", }, @@ -117,8 +117,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert.deepEqual(await rows.evaluateAll((trs) => trs.map((tr) => tr.querySelector(".src")?.textContent || "")), ["", "", w.badge, ""]); assert.equal(await p.locator(".led tbody tr.led-detail").count(), 0, "closed to begin with"); - const labels = async () => p.locator(".led tbody tr.led-detail dt").allTextContents(); - const values = async () => p.locator(".led tbody tr.led-detail dd").allTextContents(); + const labels = async () => p.locator(".led tbody tr.led-detail dt:not(.lc-opt)").allTextContents(); + const values = async () => p.locator(".led tbody tr.led-detail dd:not(.lc-opt)").allTextContents(); const top = async () => p.locator("#view-usage").evaluate((v) => v.scrollTop); // scrolled by the wheel, as a reader does: the page puts back any scroll // that no wheel, key or drag asked for @@ -153,15 +153,17 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert.deepEqual(await labels(), w.labels.ok); // how it went and how long, which the row says only in its last columns (#799) assert.deepEqual((await values()).slice(0, 2).map((v) => v.replace(/[^\d.]/g, "")), ["200", "2.4"]); - assert.deepEqual((await values()).slice(2, 5), ["chatcmpl-77", "/v1/messages → /v1/chat/completions", w.subagent]); + // how fast it wrote (#860) + assert.equal((await values())[2], w.speed); + assert.deepEqual((await values()).slice(3, 6), ["chatcmpl-77", "/v1/messages → /v1/chat/completions", w.subagent]); // the first token split: magpie's own 40 ms, the vendor's 660 ms - assert.deepEqual((await values()).slice(6, 8).map((v) => v.match(/^\d+/)?.[0]), ["40", "660"]); + assert.deepEqual((await values()).slice(7, 9).map((v) => v.match(/^\d+/)?.[0]), ["40", "660"]); await reader.click(p, rows.nth(2)); assert.deepEqual(await p.locator(".led tbody tr.led-detail").count(), 2); assert(!(await p.locator(".led-detail").nth(1).locator("dd.bad").count()), "a session file's success has nothing in red"); assert.equal((await p.locator(".led-detail").nth(1).locator("dd").last().textContent()), w.noStatus); await reader.click(p, rows.nth(3)); - assert.deepEqual(await p.locator(".led-detail").nth(2).locator("dt").allTextContents(), w.labels.logFail); + assert.deepEqual(await p.locator(".led-detail").nth(2).locator("dt:not(.lc-opt)").allTextContents(), w.labels.logFail); assert.equal(await p.locator(".led-detail").nth(2).locator("dd").nth(2).textContent(), "You've hit your limit · resets 3am"); // a refresh of the list keeps the rows open diff --git a/internal/gui/tests/usage-ledger.test.cjs b/internal/gui/tests/usage-ledger.test.cjs index 09f7a83a..13ed0393 100644 --- a/internal/gui/tests/usage-ledger.test.cjs +++ b/internal/gui/tests/usage-ledger.test.cjs @@ -101,14 +101,14 @@ function server(lang, theme, asked) { const L = { en: { tabs: ["Overview", "Requests", "Sessions"], - cols: ["Time", "Agent", "Requested", "Provider · account", "Sent", "Served", "Effort", "In", "Out", "Cache write", "Cache read", "Cost", "Duration", "Status"], + cols: ["Time", "Agent", "Requested", "Provider · account", "Sent", "Served", "Effort", "In", "Out", "Cache write", "Cache read", "Cost", "Duration", "Speed", "Status"], sum: "130 requests", pager: "1–100 of 130", older: "Older", newer: "Newer", failed: "Failed", export: "Export CSV", why: "The vendor was asked for gpt-6-sol, but its reply says gpt-6-luna answered",saved: "Saved 130 requests to ~/Downloads/magpie-requests-30d-2026-09-29.csv", none: "No requests match these filters.", bad: "Failed: the agent was answered 429", via: "Codex · via office-mac", }, zh: { tabs: ["概览", "请求", "会话"], - cols: ["时间", "Agent", "请求模型", "供应商 · 账号", "发送模型", "实际模型", "推理强度", "输入", "输出", "缓存写入", "缓存读取", "费用", "耗时", "状态"], + cols: ["时间", "Agent", "请求模型", "供应商 · 账号", "发送模型", "实际模型", "推理强度", "输入", "输出", "缓存写入", "缓存读取", "费用", "耗时", "速度", "状态"], sum: "130 个请求", pager: "第 1–100 条,共 130 条", older: "较早", newer: "较新", failed: "失败", export: "导出 CSV", why: null, saved: "已将 130 个请求保存到 ~/Downloads/magpie-requests-30d-2026-09-29.csv", none: "没有符合这些筛选条件的请求。", bad: "失败:Agent 收到的是 429", via: "Codex · 来自 office-mac", diff --git a/internal/gui/tests/usage-speed.test.cjs b/internal/gui/tests/usage-speed.test.cjs new file mode 100644 index 00000000..f87b7d88 --- /dev/null +++ b/internal/gui/tests/usage-speed.test.cjs @@ -0,0 +1,168 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// How fast the replies came, on the Usage page's Requests tab (huoranxuanyuan, +// #860): each row's speed in tokens a second after its first, a KPI of the +// period's with its first-token time, a Speed trend that sets each model's +// beside the others', and the ranking's tok/s. At the reporter's own window +// (975px, 125%) the table fits without scrolling sideways, the columns it +// leaves out in each row's details; and a wide window keeps the Agents and +// Settings rows a readable width rather than stretched across it. 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, model, out, ttft) => ({ + route_id: 700 + i, t: new Date(now - (i + 1) * 60e3).toISOString(), agent: "claude", agentName: "Claude Code", icon: "claudecode-color", + provider: "zai", providerName: "Z.ai Coding Plan", host: "me@example.com", req: "claude-opus-4-1-20250805", model, served: model + "-2026", + effort: "high", in: 41200 + i, out, cache_read: 30000, cache_write: 2000, ms: 600 + 1800 * (model === "glm-fast" ? 1 : 0) + (model === "kimi-slow" ? 2000 : 0), + ttft_ms: ttft, status: 200, rid: "req_" + i, ep: "/v1/messages", cost: 0.0123, priced: true, +}); +// glm-fast: 135 tokens in the 1.8 s after its first, 75 tok/s; kimi-slow: 20 +// in 2 s, 10 tok/s; the last wasn't streamed, so it has no speed +const ROWS = [row(0, "glm-fast", 135, 600), row(1, "kimi-slow", 20, 600), row(2, "glm-fast", 135, 600)]; +ROWS.push({ ...row(3, "glm-fast", 135, 0), ttft_ms: undefined, ms: 3000 }); +for (let i = 4; i < 30; i++) ROWS.push(row(i, "glm-fast", 135, 600)); + +const FAST = { id: "glm-fast", calls: 28, input: 1, output: 3780, cache_read: 0, cache_write: 0, cost: 0.3, timed: 27, ttft_ms: 27 * 600, decode_ms: 27 * 1800, decode_out: 27 * 135 }; +const SLOW = { id: "kimi-slow", calls: 1, input: 1, output: 20, cache_read: 0, cache_write: 0, cost: 0.01, timed: 1, ttft_ms: 600, decode_ms: 2000, decode_out: 20 }; +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 sum = (k) => FAST[k] + SLOW[k]; + +function ledger(period) { + return { + period, rows: ROWS, offset: 0, total: ROWS.length, calls: ROWS.length, errors: 0, + input: sum("input"), output: sum("output"), cache_read: 0, cache_write: 0, cost: sum("cost"), unpriced: 0, + // 3665 tokens in 50.6 s after the first: 72 tok/s + timed: sum("timed"), ttft_ms: sum("ttft_ms"), decode_ms: sum("decode_ms"), decode_out: sum("decode_out"), + by: { model: [FAST, SLOW] }, bucket: "hour", + series: [ + { label: "", time: hour(1), calls: 14, input: 1, output: 1890, ...{ timed: 14, ttft_ms: 8400, decode_ms: 25200, decode_out: 1890 }, by: { model: { "glm-fast": part({ ...FAST, calls: 14, output: 1890, timed: 14, ttft_ms: 8400, decode_ms: 25200, decode_out: 1890 }) } } }, + { label: "", time: hour(0), calls: 15, input: 1, output: 1910, ...{ timed: 14, ttft_ms: 8400, decode_ms: 25400, decode_out: 1910 }, by: { model: { "glm-fast": part({ ...FAST, calls: 14, output: 1890, timed: 13, ttft_ms: 7800, decode_ms: 23400, decode_out: 1755 }), "kimi-slow": part(SLOW) } } }, + ], + agents: [{ id: "claude", name: "Claude Code", icon: "claudecode-color" }], + }; +} + +const STATE = (lang) => ({ + agents: [ + { id: "claude", name: "Claude Code", path: "/t/c.json", icon: "claudecode-color", wired: true, fields: [{ key: "model", label: "model", value: "a", options: [{ value: "a", label: "Claude Sonnet 4.5", ref: "claude/a" }] }] }, + { id: "codex", name: "Codex", path: "/t/c.toml", icon: "codex-color", wired: true, fields: [{ key: "model", label: "model", value: "b", options: [{ value: "b", label: "GPT", ref: "openai/b" }] }] }, + ], + profiles: [], settings: { lang, theme: "light" }, fx: { rate: 7.2, at: new Date().toISOString() }, +}); + +function server(lang) { + 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(STATE(lang)); + if (url.pathname === "/api/usage/requests") return json(ledger(url.searchParams.get("period"))); + 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: { speed: "Speed", kpi: "Output speed", fast: "75 tok/s", slow: "10 tok/s", all: "72 tok/s", first: "first token in 600 ms on average", sent: "Sent", hint: "Sent, Effort and the cache are in each row's details" }, + zh: { speed: "速度", kpi: "输出速度", fast: "75 token/秒", slow: "10 token/秒", all: "72 token/秒", first: "首字平均 600 毫秒", sent: "发送模型", hint: "窗口较窄:发送模型、推理强度和缓存在每行的详情里" }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": how fast the replies came, and the window's widths", async (t) => { + assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit"); + 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 = []; + // the reporter's window: 1220×973 at 125% + const ctx = await browser.newContext({ viewport: { width: 975, height: 778 }, reducedMotion: "reduce" }); + const p = await ctx.newPage(); + p.setDefaultTimeout(5000); + p.on("pageerror", (e) => errors.push(e.message)); + await p.route("**/*", server(lang)); + await p.goto("http://magpie.test/"); + assert(await p.evaluate(() => document.body.classList.contains("window")), "the app's window"); + 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); + + // each row's speed, a column of its own; "—" for one not streamed + const heads = await p.locator(".led thead th").evaluateAll((ths) => ths.filter((th) => th.offsetParent).map((th) => th.textContent)); + const at = heads.indexOf(w.speed); + assert(at > 0, "a Speed column: " + heads.join(", ")); + const cell = (i) => p.locator(".led tbody tr.led-row").nth(i).locator("td").filter({ visible: true }).nth(at); + assert.equal((await cell(0).textContent()).trim(), w.fast); + assert.equal((await cell(1).textContent()).trim(), w.slow); + assert.equal((await cell(3).textContent()).trim(), "—"); + + // the table fits the window; what it leaves out is in the details + const wrap = p.locator("#ledWrap"); + assert(await wrap.evaluate((e) => e.scrollWidth <= e.clientWidth + 1), "the table fits: " + JSON.stringify(await wrap.evaluate((e) => [e.scrollWidth, e.clientWidth]))); + assert.equal(await p.locator("#ledHScroll").isVisible(), false, "no sideways scrollbar"); + assert.equal((await p.locator("#ledTight").textContent()).trim(), w.hint); + assert(await p.locator("#ledTight").isVisible(), "says where the columns went"); + await reader.click(p, p.locator(".led tbody tr.led-row").nth(0).locator("td").nth(2)); + const d = p.locator(".led tbody tr.led-detail"); + const shown = await d.locator("dt").evaluateAll((dts) => dts.filter((x) => x.offsetParent).map((x) => x.textContent)); + assert(shown.includes(w.sent), "the details say what was sent: " + shown.join(", ")); + assert(shown.includes(w.speed), "and the speed: " + shown.join(", ")); + await reader.click(p, p.locator(".led tbody tr.led-row").nth(0).locator("td").nth(2)); + + // the period's speed and its first-token time + const kpi = p.locator("#ledKpi .blk").filter({ hasText: w.kpi }); + assert.equal((await kpi.locator(".v").textContent()).trim(), w.all); + assert.equal((await kpi.locator(".sub").textContent()).trim(), w.first); + // the ranking says each one's speed + assert((await p.locator("#ledRank .rk").first().locator(".rk-b").textContent()).includes(w.fast), "the ranking's tok/s"); + + // the trend by speed: each model's mark, the fastest ranked first + await reader.click(p, p.locator("#ledMetric .opt").filter({ hasText: w.speed })); + await p.waitForTimeout(150); + assert.equal((await p.locator("#ledRank .rk").first().locator(".rk-nm").textContent()).trim(), "glm-fast"); + assert.equal((await p.locator("#ledRank .rk").first().locator(".rk-val").textContent()).trim(), w.fast); + assert.equal((await p.locator("#ledRank .rk").nth(1).locator(".rk-val").textContent()).trim(), w.slow); + assert.equal(await p.locator("#ledChart rect.col.mark").count(), 3, "a mark for each model at each hour it answered"); + assert.equal(await p.locator("#ledChart rect.col.all").count(), 2, "and the hour's own"); + + // a wide window: the Agents and Settings rows don't stretch across it + await p.setViewportSize({ width: 1670, height: 1060 }); + await p.locator('[data-view="agents"]').first().click(); + await p.locator(".row.agent").first().waitFor(); + await p.waitForTimeout(200); + const centred = async (sel, what) => { + const view = await p.locator(sel).first().evaluate((e) => { const v = e.closest(".view").getBoundingClientRect(), r = e.getBoundingClientRect(); return { w: r.width, l: r.left - v.left, r: v.right - r.right }; }); + assert(view.w <= 1200, what + " at most 1200 wide: " + JSON.stringify(view)); + assert(Math.abs(view.l - view.r) < 24, what + " in the middle: " + JSON.stringify(view)); + }; + await centred(".row.agent", "an agent's row"); + await p.locator("#prefs").click(); + await p.locator("#view-settings .row.pref").first().waitFor(); + await p.waitForTimeout(200); + await centred("#view-settings .row.pref", "a setting's row"); + assert.deepEqual(errors, []); + await ctx.close(); + }); + } + }); +} diff --git a/internal/usage/ledger.go b/internal/usage/ledger.go index b5d073ff..a8341038 100644 --- a/internal/usage/ledger.go +++ b/internal/usage/ledger.go @@ -582,6 +582,29 @@ type Part struct { Calls int `json:"calls"` Tokens int `json:"tokens"` Cost float64 `json:"cost"` + // how fast its replies came, as Totals has it: a chart of speed says + // each one's at each point (#860) + Timed int `json:"timed,omitempty"` + TTFT int64 `json:"ttft_ms,omitempty"` + DecodeMs int64 `json:"decode_ms,omitempty"` + DecodeOut int `json:"decode_out,omitempty"` +} + +// add counts a row in the part. +func (p *Part) add(r Row) { + p.Calls++ + p.Tokens += r.Input + r.Output + r.CacheRead + r.CacheWrite + if r.Priced { + p.Cost += r.Cost + } + if r.TTFT > 0 && !r.Failed() { + p.Timed++ + p.TTFT += r.TTFT + if w := DecodeWindow(r.Output, r.Millis, r.TTFT); w > 0 { + p.DecodeMs += w + p.DecodeOut += r.Output + } + } } // SeriesPoint is a point of the timeline with its calls told apart, by each @@ -640,15 +663,10 @@ func LedgerSeries(p Period, rows []Row) (bucket string, pts []SeriesPoint) { continue } pts[i].addRow(r) - tokens := r.Input + r.Output + r.CacheRead + r.CacheWrite for _, d := range Dimensions { if k := r.key(d); kept[d][k] { part := pts[i].By[d][k] - part.Calls++ - part.Tokens += tokens - if r.Priced { - part.Cost += r.Cost - } + part.add(r) pts[i].By[d][k] = part } } diff --git a/internal/usage/ledger_test.go b/internal/usage/ledger_test.go index 20cc4f1f..24b815f2 100644 --- a/internal/usage/ledger_test.go +++ b/internal/usage/ledger_test.go @@ -325,6 +325,36 @@ func TestLedgerSeries(t *testing.T) { } } +// A point's part of each model says how fast that model's replies came, +// as the point's own sums do, for a chart of speed to tell the models +// apart (#860): a failed call and one too fast to time count in neither. +func TestLedgerSeriesSpeed(t *testing.T) { + now := time.Now() + at := time.Date(now.Year(), now.Month(), now.Day(), 0, 10, 0, 0, now.Location()) + row := func(model string, out int, ms, ttft int64, status int) Row { + return Row{Record: Record{Time: at, Provider: "p", Model: model, Output: out, Millis: ms, TTFT: ttft, Status: status}} + } + rows := []Row{ + row("fast", 1000, 2500, 500, 200), // 1000 tokens in 2 s + row("fast", 300, 1500, 500, 200), // 300 in 1 s + row("slow", 100, 6000, 1000, 200), // 100 in 5 s + row("slow", 900, 9000, 2000, 500), // failed + row("slow", 50, 1001, 1000, 200), // 1 ms: no speed, its TTFT counts + } + _, pts := LedgerSeries(Today, rows) + h := pts[0] + fast, slow := h.By["model"]["fast"], h.By["model"]["slow"] + if fast.Timed != 2 || fast.TTFT != 1000 || fast.DecodeMs != 3000 || fast.DecodeOut != 1300 { + t.Fatalf("fast: %+v", fast) + } + if slow.Calls != 3 || slow.Timed != 2 || slow.TTFT != 2000 || slow.DecodeMs != 5000 || slow.DecodeOut != 100 { + t.Fatalf("slow: %+v", slow) + } + if h.DecodeMs != fast.DecodeMs+slow.DecodeMs || h.DecodeOut != fast.DecodeOut+slow.DecodeOut || h.Timed != 4 { + t.Fatalf("the point's own: %+v", h.Totals) + } +} + // Calls are told apart by provider, agent and model: a filter to one // provider, the providers that had calls (whatever the filter), the sums of // each with the most tokens first, and the parts of each point of the diff --git a/internal/usage/request_page.go b/internal/usage/request_page.go index 93008ebe..a717c031 100644 --- a/internal/usage/request_page.go +++ b/internal/usage/request_page.go @@ -911,11 +911,7 @@ func buildRequestBlocks(p Period, f Filter, offset, limit int, gateways, chunks, for _, d := range Dimensions { k := r.key(d) part := pt.By[d][k] - part.Calls++ - part.Tokens += r.Input + r.Output + r.CacheRead + r.CacheWrite - if r.Priced { - part.Cost += r.Cost - } + part.add(r) pt.By[d][k] = part } })