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 } })