usage, gui: 用量页补上各 agent 会话文件里的用量,请求页按「经过 / 不经过 magpie」拆开 (#948)

* sessions: dsh、ZCode、WorkBuddy 的会话文件里有各自花掉的 token,用量台账从来不读

Sessions 页面一直都在读这些 agent 自己的会话文件,用量台账没有:用量页只显示网关本
身应答过的调用——也就是装上 magpie 之前的什么都没有,而一个直接指向厂商的 dsh 更
是什么都没有。本机上装 magpie 之前就已经在用这些 agent(用它们自带的模型),那段
用量一直对不上账。

  * 新增三类"整文件读取"的调用来源,走 wholeCallReaders(它们的调用不是一行一条,
    或者文件不能从中间接着读):dsh 的会话是 zstd 分帧追加的;ZCode 用的是 OpenCode
    那几张表;WorkBuddy 的同一句回复会在多行里重复同一个 messageId。三者的 token
    语义各自换算成 Call 的 Input/Output/CacheRead/CacheWrite,调用缓存跟着记一份(v9)。
  * 每个来源都跳过经由 magpie 网关发出的调用:网关自己记过那一本账,还带着真正应答
    的供应商和账号;dsh 发往网关的请求不带会话头,两边事后对不上(token 数每次不同、
    又没有可配对的 id),硬读就是同一通调用记两次。dsh 认它被配置去的那个 provider
    (dshGatewayProvider:magpie / dial),ZCode 和 OpenCode 认路由里写的 provider
    (ocGatewayProvider)。WorkBuddy 那部分见下一个提交。
  * WorkBuddy 的调用里,同一句回复的多行只记最后一次(它带这个回复的总量);而它从
    旧历史搬过来的回复一条 messageId 都没有,每一条都是独立的一通调用——会话汇总的
    读法(workbuddyLine)就是这么数的,这里照它来,否则台账会比 Sessions 页面少算,
    正是本次要消灭的那类"两页对不上",只是方向相反。
  * dsh 的一通调用保留它文件里记的两样东西:被配置去的供应商(Upstream,作为历史,
    从来不当成路由的证据),以及厂商真正应答的模型——回复的 replayState 里那个和
    请求模型不一致时,Model 记应答的、Requested 记被请求的,和 Claude Code 的文件
    一样的读法,好让请求表的若干列有东西可填。
  * 会话的 fork:dsh 的会话从另一个会话续起来时会带上对方已写入的事件(isSeeded /
    seedLength,session/end-seed 标记之后的不算),parser 跳过的这里同样跳过,否则
    同一通调用会在每个 fork 上再花一次。
  * WorkBuddy 的 subagents/ 子目录是父会话自己的调用来源(它带自己的消息 id,会话
    归到父会话名下);调用这条路不读 file.main,那个标记是 Sessions 页面给"会话自己的
    文件、不是子代理的"用的。

夹具:dsh 的三个会话(普通、带供应商与应答模型、以及一通经网关的),ZCode 的两条
(其中 ses_zcA 那条的 providerID 是 magpie,应被跳过),WorkBuddy 的既有会话(m1 的
usage 同时落在 assistant message 和它最后一个 function_call 上,只能记一次;旧历史
三条无 messageId 的回复各记一次)和一通经网关、一通走用户自建端点的会话。
调用缓存升到 v9:v8 里既没有 dsh 的供应商和应答模型,也没有"跳过网关调用"这件事,
旧缓存会继续把旧账 Serve 出来。

TestDshCalls、TestZCodeCalls、TestWorkBuddyCalls 是新的;把跳过网关调用那几行、以及
WorkBuddy 旧历史那条判据去掉,它们必挂、加上必过(都实际跑过反向验证)。

验证:go vet;go test ./internal/sessions(只余 TestCursor,Windows 路径前缀问题,
改动前同样失败);go build(windows)。

* usage: 请求页把"magpie 转的"和"它只读到的"分成两个数,GUI 跟着拆成两栏

用量页一直只显示网关自己应答过的调用,而现在它还要读 agent 自己会话文件里的调用:
两本账混在一行里,读者分不清哪一段是 magpie 转的、哪一段只是它从文件里读到的,
而总数事实上几乎全部来自后者(本机 30 天 847M token 里,网关只转了 2.4%)。Summary
里的两个来源卡片也加不出这个数来:它们只算 input 和 output,上面的 KPI 把缓存的
token 也算在内,于是"全部请求"读作 13.6M,而"Tokens"是 847M。

  * usage.RequestPage 和 usage.Filter 各多一样东西:前者在 Sum 之外带上 Through 和
    Direct(网关应答的,和从会话文件读到的),后者多一个 Through 维度。两种建页路径
    (流式的 queryPage 和治疗桌用的 pageFromLedger)都做同一个拆分,两边一致——
    TestCompactPageMatchesLedger 现在也覆盖两个来源筛选。
  * 每一行本来就是按 Source 区分这两类的("log" 是会话文件里的调用,其余走的网关),
    拆分就落在它上面;网关自己拒掉的调用两边都不算(页面在总数下面也这么说),所以
    SourceOf 对它返回 "",点哪一栏都同样把它排除,两栏自己的计数本来也不含它。
  * 两栏自己的数字是在"来源"这个挑选被清空的情况下数的,和各维度分组一样:三栏是读者
    来回切换的地方,各自得一直报着自己的数,否则点了其中一栏另一栏读作 0,且没有回
    到全部的路。
  * /api/usage/requests 支持 via=through|direct;GUI 那一侧的三个格子、计数单位、
    方块样式在下一个提交里。
  * Filter.Via 这个名字和 Record.Via(转发调用的那台电脑,#542)重了,改叫 Filter.Through。

Sessions 页列表长度那件事(sessionListLimit)拆到单独的 PR 里,不在这里。

验证:go vet;go test ./internal/usage(只余 TestOTelRetryAfterBound,计时的 ±30ms,
origin/main 同样失败);go build(windows)。

* gui: 请求页把来源拆成三栏,汇总那一排也改成方块

用途页的请求栏一直只有一本账(Sum),而现在它既显示网关应答的调用,也显示从各 agent
自己会话文件里读到的调用。三个格子画在上方汇总那一排方块里,样式一致:三张各自带
边框、彼此留缝的卡片,窄屏(≤620px)下竖排。

  * 每栏是一个 button:点一下就只列这一来源的请求,再点一下、或点"全部请求"回到
    全部;被选中的那一栏底下有一道本主题色的线。aria-pressed 标出当前选中的是哪一栏。
  * 计数后面跟单位("147 请求"),免得一个光秃秃的数字被读成下面的 token;token 是
    四项合计,和上面 KPI 的算法一致。
  * 三个格子在重绘之间是同一批元素、原地改(class / aria / 文本 / onclick),不是每次
    replaceChildren 重建——同一份 app.js 里 5810 行的规定就是这么写的("The switch is
    the same element across redraws, changed in place, so one being pressed or focused is
    never swapped out"),正在点的那个不该被换掉。
  * 汇总那一排本身(五张 KPI 卡)改成同样的方块:各自一张卡、有缝,边框写成
    var(--line) 而不是 --card-edge(那是面板用的变量,这些卡在独立窗口里也有)。
    ≤860px 两列排布时卡片之间的那几条线照旧:奇数位去掉左边框、第三张起加上边框,
    是 e0eaf5b4 带 #799 的老规则,这里只是跟着搬了位置,没有删。三张来源格是 button,
    不参与这套分隔线。
  * 供应商·账号 一栏:来自会话文件的行显示它文件里记的那个供应商 id(Codex 一向
    如此),有账号时仍是账号优先,什么都没有才说"本地会话"。"本地会话"角标的条件
    跟着放宽到账号或供应商任一存在,提示分两种说法:文件记了供应商的,说明这是它
    被配置去调用的那个记录、不是 magpie 看到的路由(不等于官方供应商);只认得出
    账号的,沿用原来那句"仅当本地元数据能明确识别时显示账号"。
  * 窗口参数支持 via,用后从地址栏清掉,和 tab/provider/agent 等一致。
  * 新增的串子在 en/zh/ja/de 四种语言里都有("{n} tokens" 上游已有,不重复加,
    i18n-keys 绿)。

验证:node --check(app.js、i18n.js);go vet;go build(windows)。
界面效果在本机用改动后的源码构建的二进制上过目:三栏的切换与计数、方块样式、
窄屏竖排、请求表新增的会话文件行(含供应商 id)。
浏览器套件(chromium 与 webkit、en/zh):session-identity、usage-ledger、
ledger-protocol、number-units、usage-ledger-chart、usage-ttft-rounding、
usage-chart-axis、usage-fold、web-page、usage-accounts、i18n-keys 全绿;
ledger-hscroll、usage-speed、click-scroll 全绿;gui-ja、gui-de 各一条失败、
usage-ledger-detail 六条失败,均在本机 origin/main 上同样失败,与本次改动无关。
usage-ledger-chart 新钉了 ≤620px 下 KPI 卡与来源格的分隔线(把 app.css 换回上游版本
它必挂,实测过)。

---------

Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
This commit is contained in:
wizzy-yangandwizzy-yang authored and GitHub committed 2026-10-07 19:43:01 +08:00
1 parent cd8d64a9fc
commit e95852e301
28 files changed
+1388 -46

No files matched your search

+17
View File
@@ -782,6 +782,8 @@ 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; }
/* the totals and the two sources: one block of cells, a line between them, as
the totals above have it */
.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); }
@@ -868,6 +870,21 @@ body.window .field.solo { grid-column: auto; }
/* 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; }
.led-head .led-sum { margin: 0; min-width: 0; }
/* the two sources the request page counts and their total, in the same block
of cells as the totals above them, each one clickable to filter by it */
.led-vias { grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0 0 9px; }
.led-vias[hidden] { display: none; }
.led-vias .blk { cursor: pointer; text-align: left; border: 0; font: inherit; color: inherit; background: none; }
.led-vias .blk:hover { background: var(--pill-hover); }
.led-vias .blk:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
/* the one being filtered by: its own colour under the count, no blue text */
.led-vias .blk.on { box-shadow: inset 0 -2px 0 var(--accent); }
/* the count's unit sits after the number, small, as its label is under it */
.led-vias .v em { margin-left: 3px; font-size: 12.5px; font-weight: 600; font-style: normal; color: var(--muted); }
@media (max-width: 620px) {
.led-vias { grid-template-columns: minmax(0, 1fr); }
.led-vias .blk + .blk { border-left: 0; border-top: 1px solid var(--line-2); }
}
.led-arch { display: inline-flex; align-items: center; gap: 8px; flex: none; margin-left: auto; }
.led-arch-sw { display: inline-flex; align-items: center; gap: 7px; padding: 2px 0; background: none; border: 0; font: inherit; font-size: 11.5px; color: var(--muted); cursor: pointer; }
.led-arch-sw:hover, .led-arch-sw.on { color: var(--fg); }
+74 -9
View File
@@ -14586,7 +14586,7 @@ function renderUsage() {
// server pages it (/api/usage/requests) and saves it whole as CSV.
let ledger = null; // the page shown: { rows, offset, total, agents, …totals }
let ledOffset = 0, ledPurpose = "", ledAgent = "", ledProvider = "", ledAccount = "", ledCallerKey = "", ledFailed = false, ledQuery = "", ledModel = "", ledRoute = 0, ledComputer = "", ledDay = "";
let ledOffset = 0, ledPurpose = "", ledAgent = "", ledProvider = "", ledAccount = "", ledCallerKey = "", ledFailed = false, ledQuery = "", ledModel = "", ledRoute = 0, ledComputer = "", ledDay = "", ledVia = "";
// computerOpts are the Computer filter's choices: this computer, the others
// together, and each other one, as sync shares their usage (#542); none
@@ -14608,10 +14608,10 @@ try {
let ledRouteInfo = null, ledBeforeRoute = null;
window.openUsageRoute = (route) => {
if (!ledBeforeRoute) ledBeforeRoute = { period, ledOffset, ledPurpose, ledAgent, ledProvider, ledAccount, ledCallerKey, ledFailed, ledQuery, ledModel, ledComputer, ledDay };
if (!ledBeforeRoute) ledBeforeRoute = { period, ledOffset, ledPurpose, ledAgent, ledProvider, ledAccount, ledCallerKey, ledFailed, ledQuery, ledModel, ledComputer, ledDay, ledVia };
ledRoute = route.id;
ledRouteInfo = route;
ledOffset = 0; ledPurpose = ""; ledAgent = ""; ledProvider = ""; ledAccount = ""; ledCallerKey = ""; ledFailed = false; ledQuery = ""; ledModel = ""; ledComputer = "";
ledOffset = 0; ledPurpose = ""; ledAgent = ""; ledProvider = ""; ledAccount = ""; ledCallerKey = ""; ledFailed = false; ledQuery = ""; ledModel = ""; ledComputer = ""; ledVia = "";
ledDay = "";
$("#ledQ").value = "";
period = "all";
@@ -14629,6 +14629,7 @@ function ledParams(extra) {
if (ledAccount) q.set("account", ledAccount);
if (ledComputer) q.set("computer", ledComputer);
if (ledCallerKey) q.set("callerKey", ledCallerKey);
if (ledVia) q.set("via", ledVia);
if (ledRoute) q.set("route", ledRoute);
if (ledFailed) q.set("failed", "1");
if (ledModel) q.set("model", ledModel);
@@ -15482,6 +15483,57 @@ function renderLedger() {
if (l.errors) sum.push(t("{n} failed", { n: l.errors }));
}
$("#ledSum").textContent = sum.join(" · ");
// the two sources and their total, in the same block of cells as the totals
// above them: what magpie carried, what it only read about in the agents'
// own session files, and the two together. Each is a filter: picking one
// lists only its requests, and the total is the way back. Its own numbers
// are counted with the pick cleared (usage.RequestPage), so all three stay
// switchable whichever is on.
const via = $("#ledVia");
const thru = l.through || { calls: 0 }, dir = l.direct || { calls: 0 };
if (thru.calls + dir.calls > 0) {
// the total is the two sources added up, not the page's own totals: those
// are the rows the filter keeps, so with one source picked the total cell
// would read that one's figures under the whole period's count
const all = {};
for (const k of ["calls", "errors", "input", "output", "cache_read", "cache_write", "cost", "timed", "ttft_ms", "decode_ms", "decode_out"]) {
all[k] = (thru[k] || 0) + (dir[k] || 0);
}
const tile = (b, label, calls, tot, pick) => {
const on = ledVia === pick;
b.className = "blk" + (on ? " on" : "");
b.type = "button";
b.setAttribute("aria-pressed", on ? "true" : "false");
b.title = pick ? t("Show only these requests") : t("Show every request again");
const v = el("span", "v", ledNum(calls));
// the unit, so a bare number isn't read as the tokens below it
v.append(el("em", "", t(calls === 1 ? "request" : "requests")));
b.replaceChildren(el("span", "k", t(label)), v,
el("span", "sub", t("{n} tokens", { n: fmtN(allTokens(tot)) })));
b.onclick = () => {
const next = ledVia === pick ? "" : pick;
if (next === ledVia) return;
ledVia = next;
ledOffset = 0;
loadLedger().catch((e) => status(e.message, "err"));
};
return b;
};
// the three cells are the same elements from one redraw to the next, so
// one being pressed or focused is never swapped out from under a reader
let cells = [...via.children];
if (cells.length !== 3) {
cells = [el("button"), el("button"), el("button")];
via.replaceChildren(...cells);
}
tile(cells[0], "all requests", thru.calls + dir.calls, all, "");
tile(cells[1], "through magpie", thru.calls, thru, "through");
tile(cells[2], "not through magpie", dir.calls, dir, "direct");
via.hidden = false;
} else {
via.replaceChildren();
via.hidden = true;
}
const routeFilter = $("#ledRoute");
routeFilter.hidden = !ledRoute;
$("#ledRouteLabel").textContent = ledRouteInfo ? t("Request: {what}", { what: new Date(ledRouteInfo.time).toLocaleString(intlLang() || "en") + " · " + ledRouteInfo.model }) : "";
@@ -15489,7 +15541,7 @@ function renderLedger() {
$("#ledRouteClear").setAttribute("aria-label", t("Clear filter"));
$("#ledRouteClear").onclick = () => {
ledRoute = 0; ledRouteInfo = null;
if (ledBeforeRoute) ({ period, ledOffset, ledPurpose = "", ledAgent, ledProvider, ledAccount = "", ledCallerKey, ledFailed, ledQuery, ledModel, ledComputer = "", ledDay = "" } = ledBeforeRoute);
if (ledBeforeRoute) ({ period, ledOffset, ledPurpose = "", ledAgent, ledProvider, ledAccount = "", ledCallerKey, ledFailed, ledQuery, ledModel, ledComputer = "", ledDay = "", ledVia = "" } = ledBeforeRoute);
ledBeforeRoute = null;
$("#ledQ").value = ledQuery;
loadLedger().catch((e) => status(e.message, "err"));
@@ -15500,7 +15552,7 @@ function renderLedger() {
const pager = $("#ledPager");
if (!l.total) {
wrap.classList.add("none");
const filtered = ledPurpose || ledDay || ledRoute || ledAgent || ledProvider || ledAccount || ledComputer || ledCallerKey || ledModel || ledFailed || ledQuery.trim();
const filtered = ledPurpose || ledDay || ledRoute || ledAgent || ledProvider || ledAccount || ledComputer || ledCallerKey || ledModel || ledVia || 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();
@@ -15544,7 +15596,14 @@ function renderLedger() {
td(who, "", [r.kind, r.session && t("session {id}", { id: r.session })].filter(Boolean).join(" · "));
td(r.req || "—", "model" + (r.req ? "" : " faint"), r.req || t("Not kept for requests before this version"));
const local = r.source === "log";
const where = local ? (r.session_account || t("Local session")) : t(r.providerName) + (r.host ? " · " + r.host : "");
// A session file names the provider it was set up to call, which is
// where a call that went straight to a vendor went: nothing stood
// between the agent and it. It is the file's own record and not a route
// magpie saw, so the id the file carries is shown as it is — never
// resolved to a provider's name, which would promote an unknown request
// to an official provider — and the row keeps saying it is a local
// session. A row whose file names none says only that.
const where = local ? (r.session_account || r.session_provider || t("Local session")) : t(r.providerName) + (r.host ? " · " + r.host : "");
const wc = td(el("div", "where-name", where), "where", where);
const badges = el("div", "source-badges");
if (local && r.session_account && r.session_official_login) {
@@ -15562,9 +15621,13 @@ function renderLedger() {
}
const proto = ledProtoBadge(r);
if (proto) badges.append(proto);
if (local && r.session_account) {
// the badge says where the row came from, beside a name that is not
// that: a row whose name already says it has nothing to add
if (local && (r.session_account || r.session_provider)) {
const k = el("span", "src local", t("Local session"));
k.title = t("Read from the agent's session file. The account is shown only when local metadata identifies it; no service provider is inferred.");
k.title = r.session_provider
? t("Read from the agent's session file, which names the provider it was set up to call. The name shown is that record, not a route magpie saw.")
: t("Read from the agent's session file. The account is shown only when local metadata identifies it; no service provider is inferred.");
badges.append(k);
}
if (badges.childElementCount) wc.append(badges);
@@ -20290,8 +20353,10 @@ if (mode === "window" && params.get("view") === "usage") {
ledAgent = params.get("agent") || "";
ledPurpose = params.get("purpose") || "";
ledComputer = params.get("computer") || "";
const v = params.get("via") || "";
ledVia = v === "through" || v === "direct" ? v : "";
const u = new URL(location.href);
for (const k of ["tab", "provider", "agent", "computer", "card", "purpose"]) u.searchParams.delete(k);
for (const k of ["tab", "provider", "agent", "computer", "card", "purpose", "via"]) u.searchParams.delete(k);
history.replaceState(null, "", u);
}
if (mode === "window" && ["providers", "gateway", "routing", "usage", "sessions", "library", "plugins", "settings"].includes(params.get("view"))) show(params.get("view"));
+21
View File
@@ -1864,6 +1864,11 @@ const I18N = {
"{n} out": "输出 {n}",
"{n} cache write": "缓存写入 {n}",
"{n} cache read": "缓存读取 {n}",
"through magpie": "经过 magpie",
"not through magpie": "不经过 magpie",
"all requests": "全部请求",
"Show only these requests": "只看这些请求",
"Show every request again": "重新显示全部请求",
"View routing": "查看路由",
"View usage": "查看用量",
"Clear filter": "清除筛选",
@@ -1989,6 +1994,7 @@ const I18N = {
"Price reference: {model}": "估价参考:{model}",
"Model used for the API price estimate; this is not an observed model forwarding event.": "用于 API 费用估算的参考模型;并非实际观测到的模型转发记录。",
"Read from the agent's session file. The account is shown only when local metadata identifies it; no service provider is inferred.": "读自 Agent 的会话文件;仅在本地元数据能够明确识别时显示账号,不推断供应商。",
"Read from the agent's session file, which names the provider it was set up to call. The name shown is that record, not a route magpie saw.": "读自 Agent 的会话文件,其中记录了它被配置去调用的供应商。此处显示的就是那条记录,不是 magpie 看到的路由。",
"Recorded provider ID": "日志中的供应商标识",
"Session account": "会话账号",
"Gateway calls only; session-log calls are in Requests. Local rejections are excluded from totals.": "仅统计网关调用;会话日志调用请查看「请求」。本地拒绝的请求不计入汇总。",
@@ -2684,6 +2690,7 @@ const I18N = {
"Refresh now, the allowances too; a Claude account's is read by running Claude Code's own /usage": "立即刷新,含额度;Claude 账号的额度通过运行 Claude Code 自带的 /usage 读取",
"Least used": "最少使用",
"requests": "请求",
"request": "请求",
"rerouted": "改道",
"errors your agent saw": "Agent 收到的错误",
"It said: {error}": "原话:{error}",
@@ -5458,6 +5465,11 @@ const I18N = {
"{n} out": "出力 {n}",
"{n} cache write": "キャッシュ書き込み {n}",
"{n} cache read": "キャッシュ読み取り {n}",
"through magpie": "magpie 経由",
"not through magpie": "magpie を経由しない",
"all requests": "すべてのリクエスト",
"Show only these requests": "これらのリクエストだけを表示",
"Show every request again": "すべてのリクエストを再表示",
"View routing": "ルーティングを表示",
"View usage": "使用量を表示",
"Clear filter": "フィルタをクリア",
@@ -5581,6 +5593,7 @@ const I18N = {
"Price reference: {model}": "価格の参考:{model}",
"Model used for the API price estimate; this is not an observed model forwarding event.": "API 費用の見積もりに使った参考モデルです。実際に観測されたモデル転送ではありません。",
"Read from the agent's session file. The account is shown only when local metadata identifies it; no service provider is inferred.": "エージェントのセッションファイルから読み取り。アカウントはローカルのメタデータで特定できる場合のみ表示し、プロバイダは推測しません。",
"Read from the agent's session file, which names the provider it was set up to call. The name shown is that record, not a route magpie saw.": "エージェントのセッションファイルから読み取り。呼び出すよう設定されたプロバイダが記録されています。ここに表示されるのはその記録であり、magpie が確認したルートではありません。",
"Recorded provider ID": "記録されたプロバイダ ID",
"Session account": "セッションのアカウント",
"Gateway calls only; session-log calls are in Requests. Local rejections are excluded from totals.": "ゲートウェイ経由の呼び出しのみ。セッションログの呼び出しは「リクエスト」にあります。ローカルで拒否されたものは合計に含みません。",
@@ -6266,6 +6279,7 @@ const I18N = {
"Refresh now, the allowances too; a Claude account's is read by running Claude Code's own /usage": "今すぐ更新し、利用枠も読み込み直します。Claude アカウントの利用枠は Claude Code 自身の /usage を実行して読み取ります",
"Least used": "使用量が最少",
"requests": "リクエスト",
"request": "リクエスト",
"rerouted": "再ルーティング",
"errors your agent saw": "エージェントが受けたエラー",
"It said: {error}": "メッセージ:{error}",
@@ -9047,6 +9061,11 @@ const I18N = {
"{n} out": "{n} aus",
"{n} cache write": "{n} Cache-Schreiben",
"{n} cache read": "{n} Cache-Lesen",
"through magpie": "über magpie",
"not through magpie": "nicht über magpie",
"all requests": "alle Anfragen",
"Show only these requests": "Nur diese Anfragen zeigen",
"Show every request again": "Wieder alle Anfragen zeigen",
"View routing": "Weiterleitung anzeigen",
"View usage": "Nutzung anzeigen",
"Clear filter": "Filter zurücksetzen",
@@ -9170,6 +9189,7 @@ const I18N = {
"Price reference: {model}": "Preisreferenz: {model}",
"Model used for the API price estimate; this is not an observed model forwarding event.": "Für die API-Preisschätzung verwendetes Modell; keine beobachtete Weiterleitung an ein Modell.",
"Read from the agent's session file. The account is shown only when local metadata identifies it; no service provider is inferred.": "Aus der Sitzungsdatei des Agenten gelesen. Das Konto wird nur angezeigt, wenn lokale Metadaten es identifizieren; ein Dienstanbieter wird nicht abgeleitet.",
"Read from the agent's session file, which names the provider it was set up to call. The name shown is that record, not a route magpie saw.": "Aus der Sitzungsdatei des Agenten gelesen, die den Anbieter nennt, für den er eingerichtet wurde. Der angezeigte Name ist diese Aufzeichnung, keine von magpie gesehene Route.",
"Recorded provider ID": "Erfasste Anbieter-ID",
"Session account": "Sitzungskonto",
"Gateway calls only; session-log calls are in Requests. Local rejections are excluded from totals.": "Nur Aufrufe über den Zugangspunkt; Aufrufe aus Sitzungslogs stehen unter „Anfragen“. Lokale Ablehnungen sind nicht in den Summen enthalten.",
@@ -9855,6 +9875,7 @@ const I18N = {
"Refresh now, the allowances too; a Claude account's is read by running Claude Code's own /usage": "Jetzt aktualisieren, auch die Kontingente; das eines Claude-Kontos wird über das eigene /usage von Claude Code gelesen",
"Least used": "Am wenigsten genutzt",
"requests": "Anfragen",
"request": "Anfrage",
"rerouted": "umgeleitet",
"errors your agent saw": "Fehler, die Ihr Agent sah",
"It said: {error}": "Meldung: {error}",
+1
View File
@@ -186,6 +186,7 @@
</section>
</div>
<div class="led-head"><p class="led-sum" id="ledSum"></p><span class="grow"></span><span class="led-arch" id="ledArchive"></span></div>
<div class="led-strip led-vias" id="ledVia" hidden></div>
<div class="led-wrap" id="ledWrap"></div>
<div class="led-hscroll" id="ledHScroll" hidden><div></div></div>
<div class="led-pager" id="ledPager" hidden></div>
+20 -2
View File
@@ -64,8 +64,26 @@ for (const engine of ["chromium", "webkit"]) {
assert.equal(await cells.nth(i).locator(".access").count(),0,"account login does not establish a supplier route");
for (const inferred of ["OpenAI","Anthropic","custom","relay","官方登录","ChatGPT login"]) assert(!text.includes(inferred));
}
for (const i of [3,5,8]) {
assert.equal(await cells.nth(i).textContent(),localName,"no account means only Local session, regardless of model/provider ID");
// A row whose file names both an account and a provider shows the
// account: local metadata identifies it, and the provider id is not
// read as the row's supplier (#557)
assert.equal(await cells.nth(7).locator(".where-name").textContent(),"reviewer@example.com");
assert((await cells.nth(7).textContent()).includes(localName));
for (const i of [3,5]) {
assert.equal(await cells.nth(i).textContent(),localName,"a file that names no provider says only Local session");
}
// A session file names the provider it was set up to call. That is
// the file's own record, not a route magpie saw, so the id is shown
// as the file carries it — never resolved to a provider's name,
// which would promote an unknown request to an official provider —
// and the row keeps saying it is a local session. An account, when
// local metadata identifies one, is shown instead (rows 0,1,2,7).
{
const cell = cells.nth(8);
assert.equal(await cell.locator(".where-name").textContent(),"my-custom-route","the recorded provider ID, as the file carries it");
assert((await cell.textContent()).includes(localName),"and it is still a local session");
assert.equal(await cell.locator(".local").count(),1);
assert((await cell.locator(".local").getAttribute("title")).includes(lang === "zh" ? "不是 magpie 看到的路由" : "not a route magpie saw"));
}
assert((await cells.nth(4).textContent()).includes("My Relay · relay.example"));
assert.equal(await cells.nth(4).locator(".access").textContent(),"API");
@@ -85,6 +85,11 @@ function page(q, variant) {
...(daily ? { rows: series.filter((p) => p.calls).map((p) => ({ ...ROWS[0], t: p.time })), total: 6 } : {}),
bucket: daily ? "day" : "hour", series: none ? [] : series, by: none ? { provider: [], agent: [], model: [] } : many ? { ...BY, provider: many } : BY,
agents: Object.entries(AGENTS).map(([id, a]) => ({ id, ...a })), providers: (many || WHO).map((w) => ({ id: w.id, name: w.name, icon: w.icon })),
// the two sources and their total, as the Requests tab's three cells count
// them: the calls the gateway served, the ones read from an agent's own
// session file, and both together
through: { calls: 8, errors: 1, input: TOTALS.input * .4, output: TOTALS.output * .4, cache_write: TOTALS.cache_write * .4, cache_read: TOTALS.cache_read * .4, cost: TOTALS.cost * .4 },
direct: { calls: 12, errors: 2, input: TOTALS.input * .6, output: TOTALS.output * .6, cache_write: TOTALS.cache_write * .6, cache_read: TOTALS.cache_read * .6, cost: TOTALS.cost * .6 },
};
}
@@ -400,6 +405,14 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
assert(svg.width > 300 && svg.x + svg.width <= chart.x + chart.width + 1, "the chart fits at 560");
assert(rank.y >= chart.y + chart.height - 1, "the ranking goes under the chart when there is no room beside it");
assert.equal(await p.locator("#ledKpi").evaluate((k) => getComputedStyle(k).gridTemplateColumns.split(" ").length), 2, "two totals to a row");
// the lines between the columns of one bar belong to the totals' cards:
// a card starting a row has no left line, and the rows under the first
// are divided — the two sources the Requests tab draws keep none of it
assert.equal(await p.locator("#ledKpi .blk").nth(2).evaluate((b) => getComputedStyle(b).borderLeftWidth), "0px", "a totals card starting a row carries no left line");
assert.notEqual(await p.locator("#ledKpi .blk").nth(2).evaluate((b) => getComputedStyle(b).borderTopWidth), "0px", "and the rows are divided");
await p.locator("#usageTab .opt").nth(1).click();
await p.locator("#ledVia .blk").first().waitFor();
assert.equal(await p.locator("#ledVia .blk").nth(1).evaluate((b) => getComputedStyle(b).borderLeftWidth), "0px", "a source cell carries no line between cells of one bar");
// the chart follows the window
await p.setViewportSize({ width: 1100, height: 760 });
await p.waitForTimeout(300);
+16 -1
View File
@@ -127,7 +127,16 @@ func csvStamp(p usage.Period, day string, now time.Time) string {
func ledgerFilter(q url.Values) usage.Filter {
id, _ := strconv.ParseInt(q.Get("route"), 10, 64)
return usage.Filter{Day: q.Get("day"), RouteID: id, Model: q.Get("model"), Agent: q.Get("agent"), Provider: q.Get("provider"), Purpose: q.Get("purpose"), Account: q.Get("account"), CallerKey: q.Get("callerKey"), Failed: q.Get("failed") == "1", Query: q.Get("q"), Computer: q.Get("computer")}
return usage.Filter{Day: q.Get("day"), RouteID: id, Model: q.Get("model"), Agent: q.Get("agent"), Provider: q.Get("provider"), Purpose: q.Get("purpose"), Account: q.Get("account"), CallerKey: q.Get("callerKey"), Failed: q.Get("failed") == "1", Query: q.Get("q"), Computer: q.Get("computer"), Through: viaOf(q.Get("via"))}
}
// viaOf is the source a request's ?via= names, "" for both.
func viaOf(s string) string {
switch s {
case usage.SourceGateway, usage.SourceSession:
return s
}
return ""
}
// ledgerRow is a usage.Row with the names the page shows it by.
@@ -160,6 +169,11 @@ type ledgerJSON struct {
ChartBy map[string][]ledgerShare `json:"chartBy,omitempty"`
Day string `json:"day,omitempty"`
usage.Totals
// Through and Direct: the rows the gateway served, and the rows read
// from the agents' own session files, which it never saw. Totals is
// the two together, so the page can show all three (#the usage report).
Through usage.Totals `json:"through"`
Direct usage.Totals `json:"direct"`
// Agents, Providers and Purposes: those with calls in the period, for the filters
Agents []ledgerAgent `json:"agents"`
Providers []ledgerAgent `json:"providers"`
@@ -231,6 +245,7 @@ func ledgerPage(p usage.Period, f usage.Filter, offset, limit int) ledgerJSON {
return a
}
out := ledgerJSON{Period: p, Rows: make([]ledgerRow, 0, len(page)), Offset: offset, Total: l.Total, Totals: l.Sum, Agents: []ledgerAgent{}, Providers: []ledgerAgent{}, Accounts: []ledgerAccount{}}
out.Through, out.Direct = l.Through, l.Direct
out.Bucket, out.Series = l.Bucket, l.Series
out.Purposes = l.Purposes
out.Day = f.Day
+4 -4
View File
@@ -58,7 +58,7 @@ func zcMakeDB(t *testing.T, path string) {
// the first user message ZCode's own, a reminder; the second typed
ocInsert(t, db, "ses_zcA", "msg_z1", 1790496001000, 1790496001000, `{"id":"msg_z1","role":"user","time":{"created":1790496001000},"semantics":{"origin":"system_reminder"}}`)
ocInsert(t, db, "ses_zcA", "msg_z2", 1790496002000, 1790496002000, `{"id":"msg_z2","role":"user","time":{"created":1790496002000},"semantics":{"origin":"real_user"}}`)
ocInsert(t, db, "ses_zcA", "msg_z3", 1790496003000, 1790496030000, `{"id":"msg_z3","role":"assistant","modelId":"glm-5.1","tokens":{"total":1540,"input":1500,"output":40,"reasoning":10,"cache":{"read":1000,"write":200}},"time":{"created":1790496003000,"completed":1790496030000}}`)
ocInsert(t, db, "ses_zcA", "msg_z3", 1790496003000, 1790496030000, `{"id":"msg_z3","role":"assistant","modelId":"glm-5.1","providerID":"magpie","tokens":{"total":1540,"input":1500,"output":40,"reasoning":10,"cache":{"read":1000,"write":200}},"time":{"created":1790496003000,"completed":1790496030000}}`)
ocInsert(t, db, "ses_zcB", "msg_z4", 1790496010000, 1790496020000, `{"id":"msg_z4","role":"assistant","modelID":"glm-5.1-air","tokens":{"input":50,"output":5,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1790496010000,"completed":1790496020000}}`)
ocInsert(t, db, "ses_zcC", "msg_z5", 1790496100000, 1790496110000, `{"id":"msg_z5","role":"assistant","modelId":"glm-5.1","tokens":{"input":10,"output":1,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1790496100000,"completed":1790496110000}}`)
for _, p := range [][3]string{
@@ -113,8 +113,8 @@ func TestZCode(t *testing.T) {
func checkDsh(t *testing.T, ss []Session) {
t.Helper()
if n := count(ss, "dsh"); n != 3 {
t.Fatalf("want 3 dsh sessions (the subagent in its parent), got %d", n)
if n := count(ss, "dsh"); n != 4 {
t.Fatalf("want 4 dsh sessions (the subagent in its parent), got %d", n)
}
a := find(t, ss, "dsh", "dsh-a")
if a.Cwd != "/work/dsh" || a.Title != "Build script tidy" || a.Resume != "" {
@@ -142,7 +142,7 @@ func checkDsh(t *testing.T, ss []Session) {
t.Fatalf("format 0 fork: %+v", d)
}
s := statsAt(0, statsNow)
if got, _ := usageOn(s, "2026-09-27", "/work/dsh"); got != (Tokens{168, 40, 1200, 0, 0}) {
if got, _ := usageOn(s, "2026-09-27", "/work/dsh"); got != (Tokens{898, 137, 6200, 0, 0}) {
t.Fatalf("dsh on the 27th: %+v", got)
}
}
+40 -7
View File
@@ -25,7 +25,16 @@ import (
// v6: Codex response records and compact disk-only parser state.
// v7: reconcile recent Claude message revisions.
// v8: a Claude Code call's 1-hour cache writes.
const callCacheVersion = "calls-v8"
// v9: a dsh call carries the provider it named and the model that answered,
// and a call through magpie's own gateway is left out of dsh's (the gateway
// logs it itself).
// v10: the calls ZCode's and WorkBuddy's session files hold are read too, and
// WorkBuddy's that went through the gateway are left out of them as a dsh
// one is.
// What each version names is what a cache of the one before it cannot serve:
// the version is part of the directory's name, so a cache written by an older
// one is never read (pruneCalls).
const callCacheVersion = "calls-v10"
const maxKeptCalls = 131072
const maxKeptFiles = 64
@@ -49,6 +58,16 @@ func resetCalls() {
callRoot = ""
callGeneration++
resetOCCalls()
// ZCode's listing and WorkBuddy's gateway ids name another HOME's files
// and models.json: a read after a Reset() would otherwise hand back
// sources that are not here. Both are stamped, so the stale stamp alone
// would miss; clearing them makes a new HOME read its own
zcCallList.Lock()
zcCallList.stamp, zcCallList.files = "", nil
zcCallList.Unlock()
wbGateway.Lock()
wbGateway.stamp, wbGateway.ids = "", nil
wbGateway.Unlock()
}
func pruneCalls(files []file) {
@@ -147,6 +166,17 @@ func keepCalls(path string, st *callFile) {
callOrder = append(callOrder, path)
}
// wholeCallReaders read an agent's calls from one session file at a time,
// rather than a line at a time: its calls are not lines, or its file cannot
// be read on from the middle of one. Every agent in callSources that is not
// in callFiles needs an entry here, or its calls are never read.
var wholeCallReaders = map[string]func(file) (*callFile, bool){
"opencode": readOpenCodeCalls,
"zcode": readZCodeCalls,
"dsh": readDshCalls,
"workbuddy": readWorkBuddyCalls,
}
func readCalls(f file) *callFile {
h := fnv.New32a()
_, _ = h.Write([]byte(f.path))
@@ -174,18 +204,21 @@ func readCalls(f file) *callFile {
}
return &callFile{Path: f.path, Agent: f.agent}
}
if f.agent == "opencode" {
// rows, not lines: a session that changed is read again whole
st, ok := readOpenCodeCalls(f)
if !ok && old != nil {
// Agents whose calls are read whole rather than line by line: a session
// that changed is read again, and one that did not is kept. OpenCode's
// and ZCode's are rows of a database or JSON documents; dsh's file is
// packed in frames, so it cannot be read on from the middle of one.
if read, ok := wholeCallReaders[f.agent]; ok {
st, readOK := read(f)
if !readOK && old != nil {
return old // not readable now: what was read before
}
if ok {
if readOK {
st.Size, st.Mod = f.size, f.mod.UnixNano()
writeCalls(st, 0, false)
}
callsMu.Lock()
if ok && generation == callGeneration {
if readOK && generation == callGeneration {
keepCalls(f.path, st)
}
callsMu.Unlock()
+13 -3
View File
@@ -207,9 +207,19 @@ func callFiles() []file {
return out
}
// callSources are callFiles and OpenCode's sessions (#680), whose calls are
// rows of its database or its JSON files rather than lines.
func callSources() []file { return append(callFiles(), openCodeCallFiles()...) }
// callSources are callFiles and the agents whose calls are read whole
// rather than line by line: OpenCode's (#680) and ZCode's, whose calls are
// rows of their database or their JSON files; DeepSeek Harness's, whose
// session file is packed in frames and so cannot be read on from the middle
// of one; and WorkBuddy's, whose usage lines repeat a reply's id as it goes
// on. Every agent here needs an entry in wholeCallReaders.
func callSources() []file {
out := callFiles()
out = append(out, openCodeCallFiles()...)
out = append(out, zcodeCallFiles()...)
out = append(out, dshCallFiles()...)
return append(out, workbuddyCallFiles()...)
}
// desktopDataDirs are Claude Desktop's Claude and Claude-3p folders on this
// computer, found as desktopDirs in internal/agent's claudedesktop.go does
+209
View File
@@ -0,0 +1,209 @@
package sessions
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"io"
"strings"
)
// DeepSeek Harness's calls, read as its session summaries are (dsh.go): an
// "assistant/message" carries the model and the usage of the request that
// answered with it, one per line, so every such line is one call.
//
// The Sessions page has read these files all along — dsh.go parses the same
// event for its summary — but the usage ledger never asked for them, so dsh's
// tokens reached the Sessions page and nothing else: the Usage page showed
// only what the gateway itself served, which is nothing before magpie was
// installed and nothing at all for a dsh pointed straight at a vendor.
//
// A call through magpie's gateway is logged by the gateway as well, and dsh
// sends it no session header, so the two records cannot be paired up
// afterwards: it is left out here as OpenCode's and ZCode's are
// (dshGatewayProvider), or it would be counted twice — once by the gateway
// with the provider and account that really answered, and once here as a
// nameless local session. The provider a session's record names is kept as
// history (Upstream), never as proof of the route.
// dshCallFiles are dsh's sessions as call sources. It is dshFiles with the
// size and time its summary reader already keeps, so a session that has not
// changed is not read again. A subagent's file is a source of its own, as
// WorkBuddy's subagents' are: nothing in this path reads a file's main (the
// Sessions page's mark for a session's own file), so it is left as dshFiles
// set it.
func dshCallFiles() []file {
return dshFiles()
}
// readDshCalls reads a dsh session whole: packed in frames, it can't be read
// on from the middle of one (as parseDsh says).
func readDshCalls(f file) (*callFile, bool) {
r, err := dshOpen(f.path)
if err != nil {
return &callFile{Path: f.path, Agent: "dsh"}, false
}
defer r.Close()
st := &callFile{Agent: "dsh", Path: f.path, Session: strings.TrimPrefix(f.key, "dsh:")}
br := bufio.NewReaderSize(r, 1<<20)
var h dshHead
// the events a fork was seeded with are the session it came from, not
// this one: parseDsh skips them, and counting them here would spend the
// same request once per fork (dsh-a's first reply is also dsh-c's and
// dsh-d's first lines)
var evs []dshEvent
var seeds []bool
cut := -1
for first := true; ; first = false {
b, err := br.ReadBytes('\n')
if err != nil {
// a line still being written, or a frame cut short
if !errors.Is(err, io.EOF) || len(bytes.TrimSpace(b)) == 0 {
break
}
}
b = bytes.TrimSpace(b)
if len(b) == 0 || len(b) > maxLine {
if err != nil {
break
}
continue
}
if first {
if json.Unmarshal(b, &h) != nil || h.Type != "session" {
return st, false
}
if st.Session == "" {
st.Session = h.ID
}
continue
}
// only an assistant/message carries a call, and only the ones a
// seed ends: every other line is passed over undecoded
if bytes.Contains(b, []byte(`"assistant/message"`)) || bytes.Contains(b, []byte(`"session/end-seed"`)) {
var e dshEvent
if json.Unmarshal(b, &e) == nil && e.Type != "" {
evs = append(evs, e)
seeds = append(seeds, e.Type == "session/end-seed" && e.Data.Inherited)
}
}
if err != nil {
break
}
}
if h.Version >= 2 && h.IsSeeded {
for i, seed := range seeds {
if seed {
cut = i
}
}
}
for i, e := range evs {
if e.Type != "assistant/message" {
continue
}
// before format 2 the seed is counted in events, after it by the
// marker it ends with
if i <= cut || h.Version < 2 && e.Seq != nil && *e.Seq < h.SeedLength {
continue
}
if c, ok := dshCall(st, e); ok {
st.Calls = append(st.Calls, c)
}
}
return st, true
}
// dshCall is an assistant/message as a call: its model, its tokens and the
// time it was written.
func dshCall(st *callFile, e dshEvent) (Call, bool) {
u := e.Data.Usage
if u == nil {
return Call{}, false
}
at := ms(e.Time)
if at.IsZero() {
return Call{}, false
}
src := e.Data.Message.Source
// a call dsh sent through magpie's own gateway is the gateway's record
// to keep, not this one's: see dshGatewayProvider
if dshGatewayProvider(src) {
return Call{}, false
}
// dsh names the model it was told to use, and its reply's replay state
// names the one the vendor answered with when the two differ. Call
// keeps them as Claude Code's file does: Model is what answered (the
// vendor's own name for it, which is what the served column reads) and
// Requested what was asked for. The ledger turns the two into a row's
// requested, sent and served models (#the usage report).
answered := dshServed(src)
if answered == "" {
answered = src.Model
}
c := Call{
Agent: "dsh",
Time: at,
Session: st.Session,
Model: answered,
Requested: src.Model,
Upstream: dshUpstream(src),
Tokens: Tokens{
Input: u.Input,
Output: u.Output,
CacheRead: u.CacheRead,
CacheWrite: u.CacheWrite,
},
}
if c.Tokens.zero() {
return Call{}, false
}
return c, true
}
// dshServed is the model the vendor's own reply named, "" when the file does
// not say or says the one that was asked for (nothing was swapped).
func dshServed(src dshSource) string {
answered := src.ReplayState.Response.ResponseModel
if answered == "" {
answered = src.ReplayState.Response.Model
}
if answered == src.Model {
return ""
}
return answered
}
// dshUpstream is the provider dsh was told to use, "" when it names none.
// It is what the file recorded, not proof the request went there: the
// ledger keeps it as the session's own record (SessionProvider), as Codex's
// is kept, and never lets it stand in for the route a row took.
func dshUpstream(src dshSource) string {
if p := src.Provider; p != "" {
return p
}
return src.ReplayState.Response.Provider
}
// dshGatewayProvider is whether a dsh provider is magpie's own gateway, by the
// names magpie writes it under ("magpie", and "dial" from before). A call
// through it is logged by the gateway itself, and dsh sends the gateway no
// session header (unlike Codex and OpenCode), so the two records cannot be
// paired afterwards: the token counts differ call to call and there is no id
// to match on. Such a call is therefore left out here, as OpenCode's and
// ZCode's are (ocGatewayProvider), or it would be counted twice — once by the
// gateway, with the provider and account that really answered, and once
// here as a nameless local session.
//
// It is the name alone: dsh's file records no baseURL, so a provider the user
// added under one of those names, pointing at a vendor of their own, is taken
// for the gateway too and its calls are read as neither source's. Adding the
// URL to the read would tell the two apart.
func dshGatewayProvider(src dshSource) bool {
p := dshUpstream(src)
if p == "" {
return false
}
return p == "magpie" || p == "dial"
}
+117
View File
@@ -0,0 +1,117 @@
package sessions
import (
"testing"
"time"
)
// dshCalls are the DeepSeek Harness calls Calls gives.
func dshCalls(t *testing.T) []Call {
t.Helper()
var out []Call
for _, c := range Calls(time.Time{}) {
if c.Agent == "dsh" {
out = append(out, c)
}
}
return out
}
// The Sessions page has read dsh's session files all along; the usage ledger
// had not, so dsh's tokens never reached the Usage page — which showed only
// what magpie's own gateway served. Every assistant/message is one call, with
// the model and the usage of the request that answered with it.
func TestDshCalls(t *testing.T) {
setupAgents(t)
cs := dshCalls(t)
by := map[string][]Call{}
for _, c := range cs {
by[c.Session] = append(by[c.Session], c)
}
// dsh-a's subagent (dsh-b) counts in the session it ran under, as
// OpenCode's does; dsh-c and dsh-d are forks, whose seeded events are
// the session they came from and are not spent again here
if len(cs) != 6 {
t.Fatalf("want 6 dsh calls, got %d: %+v", len(cs), cs)
}
// a call dsh sent through magpie's own gateway is the gateway's record
// to keep, as OpenCode's and ZCode's are: dsh sends the gateway no
// session header, so the two records cannot be paired afterwards (the
// token counts differ call to call) and the call would be counted
// twice — once by the gateway with the provider and account that
// really answered, once here as a nameless local session. dsh-gw holds
// one such call and one of its own.
// dsh-gw's own call is kept; only its gateway one is left out
if gw := by["dsh-gw"]; len(gw) != 1 || gw[0].Upstream != "jieyue" || gw[0].Tokens != (Tokens{30, 7, 0, 0, 0}) {
t.Fatalf("dsh-gw's own call: %+v", gw)
}
for _, c := range cs {
if c.Upstream == "magpie" || c.Upstream == "dial" {
t.Fatalf("a call through magpie's gateway was counted here: %+v", c)
}
if c.Tokens == (Tokens{700, 90, 5000, 0, 0}) {
t.Fatalf("the gateway's own call was counted here: %+v", c)
}
}
if len(by["dsh-a"]) != 3 || len(by["dsh-c"]) != 1 || len(by["dsh-d"]) != 1 {
t.Fatalf("by session: %d a, %d c, %d d (want 3, 1, 1)", len(by["dsh-a"]), len(by["dsh-c"]), len(by["dsh-d"]))
}
// the 999s are the older session.jsonl beside dsh-a's newest, not read
for _, c := range cs {
if c.Input == 999 {
t.Fatalf("the older file beside the newest was read: %+v", c)
}
}
// the seeded copy of dsh-a's first reply, which dsh-c and dsh-d each
// carry: spent once, in dsh-a
seeded := 0
for _, c := range cs {
if c.Tokens == (Tokens{100, 20, 1000, 0, 0}) {
seeded++
}
}
if seeded != 1 {
t.Fatalf("a seeded reply counted %d times, want once", seeded)
}
a := by["dsh-a"]
// newest first. dsh names the model it was told to use, and its reply's
// replay state names the one the vendor answered with when the two
// differ: Call.Model is what answered (as Claude Code's file has it),
// Call.Requested what was asked for, and Call.Upstream the provider the
// file recorded — all three are what the Requests table's columns read.
if a[0].Model != "deepseek-v4-flash-sg" || a[0].Requested != "deepseek-v4-flash" ||
a[0].Upstream != "workbuddy-ai" || a[0].Tokens != (Tokens{10, 5, 200, 0, 0}) {
t.Fatalf("dsh-a's newest call (asked deepseek-v4-flash, answered deepseek-v4-flash-sg): %+v", a[0])
}
if a[1].Tokens != (Tokens{7, 3, 0, 0, 0}) {
t.Fatalf("the subagent's call: %+v", a[1])
}
// answered with the very model asked for: not a swap, and the two agree
if a[2].Model != "deepseek-v4-pro" || a[2].Requested != "deepseek-v4-pro" ||
a[2].Upstream != "deepseek" || a[2].Tokens != (Tokens{100, 20, 1000, 0, 0}) ||
!a[2].Time.Equal(ms(1790503205000)) {
t.Fatalf("dsh-a's first call: %+v", a[2])
}
if c := by["dsh-c"][0]; c.Tokens != (Tokens{50, 10, 0, 0, 0}) {
t.Fatalf("dsh-c's own call: %+v", c)
}
if d := by["dsh-d"][0]; d.Tokens != (Tokens{1, 2, 0, 0, 0}) {
t.Fatalf("dsh-d's own call: %+v", d)
}
}
// A session that has not changed is not read again: what was read is kept,
// and a restart keeps it too.
func TestDshCallsKept(t *testing.T) {
setupAgents(t)
first := dshCalls(t)
if len(first) == 0 {
t.Fatal("no dsh calls read")
}
resetCalls()
again := dshCalls(t)
if len(again) != len(first) {
t.Fatalf("after a restart: %d calls, want %d", len(again), len(first))
}
}
+197
View File
@@ -0,0 +1,197 @@
package sessions
import (
"bufio"
"bytes"
"cmp"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"sync"
"github.com/yetone/magpie/internal/provider"
)
// WorkBuddy's calls, read from the same lines its session summary is
// (workbuddy.go): a reply's usage is in providerData.usage of its assistant
// message (or its last function call), named by that reply's
// providerData.messageId. One line per reply carries it.
//
// As with dsh and ZCode, the Sessions page read these all along and the
// usage ledger did not, so WorkBuddy's tokens never reached the Usage page.
//
// A call WorkBuddy made to magpie's own gateway is not read here: the
// gateway logs it itself, with the provider and account that really
// answered, and this file has neither to pair it with (workbuddyGateway).
// workbuddyCallFiles are WorkBuddy's sessions as call sources. The subagent
// files under a session's subagents/ are its own calls to keep, as dsh's are
// (dshCallFiles): a call carries its id whatever file it was written in.
// Nothing in this path reads a file's main — that is the Sessions page's own
// mark for a session's file rather than a subagent's — so it is left as
// workbuddyFiles set it.
func workbuddyCallFiles() []file {
return workbuddyFiles()
}
// wbGateway are the ids WorkBuddy has of magpie's gateway, kept while
// models.json is as it was, as zcodeCallFiles keeps its listing.
var wbGateway struct {
sync.Mutex
stamp string
ids map[string]bool
}
// workbuddyGateway is whether a model id is one WorkBuddy asks magpie's
// gateway for. A routing group's id says so on its own: "group/<id>" is a
// name only a magpie answers to (provider.GroupPrefix), so a call asked for
// under it went to a gateway whichever machine that was, and this file has
// nothing to pair it with. The rest are read from models.json, whose entries
// for magpie (internal/agent's workbuddy.go writes them: vendor "magpie",
// url this gateway's) are the ones WorkBuddy shows in its picker as
// "custom-local:<id>". An entry the user put there for another vendor keeps
// its own usage, so the vendor is read too and not only the prefix.
//
// models.json is the user's own configuration, not a record of the call: take
// magpie's models out of it and the calls made while they were in stop being
// read as the gateway's here, and are counted as the agent's own beside the
// gateway's rows for the same calls. It is the one shape this cannot tell
// from the file alone — WorkBuddy names no provider of its own, as dsh, ZCode
// and OpenCode do — so it is said here and pinned by a test.
func workbuddyGateway(id string) bool {
const local = "custom-local:"
if !strings.HasPrefix(id, local) {
return false
}
id = id[len(local):]
return strings.HasPrefix(id, provider.GroupPrefix) || workbuddyGatewayIDs()[id]
}
// wbEntry is what is read of a models.json entry: its id and whose it is.
type wbEntry struct {
ID string `json:"id"`
Vendor string `json:"vendor"`
}
// workbuddyGatewayIDs are the ids of the models WorkBuddy's models.json names
// as magpie's, an empty set when it names none (or cannot be read).
func workbuddyGatewayIDs() map[string]bool {
path := filepath.Join(WorkBuddyDir(), "models.json")
stamp := ocStamp(path)
wbGateway.Lock()
defer wbGateway.Unlock()
if stamp == wbGateway.stamp {
return wbGateway.ids
}
ids := map[string]bool{}
// a bare list of entries, or an object with a "models" list, as
// workbuddyRead in internal/agent reads both; that package cannot be
// imported from here (it imports this one)
b, err := os.ReadFile(path)
if err == nil {
var list []wbEntry
var doc struct{ Models []wbEntry }
if json.Unmarshal(b, &list) == nil {
for _, e := range list {
if e.Vendor == "magpie" && e.ID != "" {
ids[e.ID] = true
}
}
} else if json.Unmarshal(b, &doc) == nil {
for _, e := range doc.Models {
if e.Vendor == "magpie" && e.ID != "" {
ids[e.ID] = true
}
}
}
}
wbGateway.stamp, wbGateway.ids = stamp, ids
return ids
}
// readWorkBuddyCalls reads a session's calls whole: its usage lines repeat
// the same messageId as a reply goes on, so the last line for a reply is the
// one that counts. A reply WorkBuddy brought over from its older history
// names no messageId at all, and each of those is a call of its own, as the
// summary reader counts them (workbuddyLine replaces the call before it only
// when the id is the same non-empty one).
func readWorkBuddyCalls(f file) (*callFile, bool) {
r, err := openLines(f.path)
if err != nil {
return &callFile{Path: f.path, Agent: "workbuddy"}, false
}
defer r.Close()
st := &callFile{Agent: "workbuddy", Path: f.path, Session: strings.TrimPrefix(f.key, "workbuddy:")}
br := bufio.NewReaderSize(r, 1<<20)
last := "" // the messageId the call before this line carried
for {
b, err := br.ReadBytes('\n')
if err != nil {
if !errors.Is(err, io.EOF) || len(bytes.TrimSpace(b)) == 0 {
break
}
}
b = bytes.TrimSpace(b)
if len(b) > 0 && len(b) <= maxLine && bytes.Contains(b, wbUsage) {
var l wbLine
if json.Unmarshal(b, &l) == nil && l.ProviderData.Usage != nil {
// the id this line carried, as the summary reader keeps it:
// a zero-token line is a call the same, and the reply after
// it is a new one, so both readers end on the same call
id := st.str(l.ProviderData.MessageID)
if c, ok := workbuddyCall(st, l, b); ok {
if c.Msg != "" && c.Msg == last {
st.Calls[len(st.Calls)-1] = c // another line of the reply before it
} else {
st.Calls = append(st.Calls, c)
}
}
last = id
}
}
if err != nil {
break
}
}
return st, true
}
// workbuddyCall is a line's providerData.usage as a call: the reply's model,
// its tokens (input with the cache read and written taken out of it, as its
// summary counts them) and the time it was written.
func workbuddyCall(st *callFile, l wbLine, b []byte) (Call, bool) {
// a call made to magpie's gateway is the gateway's record to keep, not
// this one's: see workbuddyGateway
if workbuddyGateway(l.ProviderData.RequestModel) {
return Call{}, false
}
u := l.ProviderData.Usage
if u == nil {
return Call{}, false
}
t := numTS(b)
if t.IsZero() {
return Call{}, false
}
in, out := cmp.Or(u.Input, u.OldInput), cmp.Or(u.Output, u.OldOutput)
read := u.InputDetails.sum("cached_tokens") + u.OldDetails.sum("cached_tokens")
write := cmp.Or(u.CacheWrite, u.OldCacheWrite)
c := Call{
Agent: "workbuddy",
Time: t,
Session: st.Session,
Model: st.str(cmp.Or(l.ProviderData.Model, l.ProviderData.RequestModel)),
Msg: st.str(l.ProviderData.MessageID),
Tokens: Tokens{Input: max(0, in-read-write), Output: out, CacheRead: read, CacheWrite: write},
}
if c.Session == "" {
c.Session = st.str(l.SessionID)
}
if c.Tokens.zero() {
return Call{}, false
}
return c, true
}
+169
View File
@@ -0,0 +1,169 @@
package sessions
import (
"os"
"path/filepath"
"testing"
"time"
)
// workbuddyCalls are the WorkBuddy calls Calls gives, by message id. Calls
// with no id (WorkBuddy's older history) are keyed by their time.
func workbuddyCalls(t *testing.T) map[string]Call {
t.Helper()
out := map[string]Call{}
for _, c := range Calls(time.Time{}) {
if c.Agent != "workbuddy" {
continue
}
k := c.Msg
if k == "" {
k = c.Time.Format("15:04:05.000")
}
if _, dup := out[k]; dup {
t.Fatalf("message %s counted twice", c.Msg)
}
out[k] = c
}
return out
}
// workbuddyOld are the older history's calls, which name no messageId: each
// reply is a call of its own, as the Sessions page counts them (workbuddyLine
// replaces the call before it only when the id is the same non-empty one).
func workbuddyOld(cs map[string]Call) []Call {
var out []Call
for _, c := range cs {
if c.Model == "auto" && c.Msg == "" {
out = append(out, c)
}
}
return out
}
// WorkBuddy's calls reach the usage ledger too: the Sessions page read its
// session files all along, and the Usage page showed none of them.
func TestWorkBuddyCalls(t *testing.T) {
setupAgents(t)
cs := workbuddyCalls(t)
// m1 (its assistant message and its last function call both carry the
// usage, so it must count once), m2, the reply the user's own custom
// provider answered, and three brought over from the older history. The
// one magpie's gateway answered is the gateway's to count
// (TestWorkBuddyCallsSkipsMagpiesGateway), not read here as well.
if len(cs) != 6 {
t.Fatalf("want 6 WorkBuddy calls, got %d: %+v", len(cs), cs)
}
m, ok := cs["m1"]
if !ok {
t.Fatalf("no call for WorkBuddy's reply: %+v", cs)
}
// input with the cache read taken out of it, as its summary counts it:
// 1200 - 1000
if m.Model != "hy3" || m.Tokens != (Tokens{200, 80, 1000, 0, 0}) || !m.Time.Equal(ms(1790503205000)) {
t.Fatalf("the reply: %+v", m)
}
// the second reply's own total is its last line's: 9999 − 7777 cached
if n := cs["m2"]; n.Tokens != (Tokens{2222, 888, 7777, 0, 0}) {
t.Fatalf("the second reply: %+v", n)
}
// three brought over from its older history: input_tokens, no cache, and
// no messageId — each one a call of its own
old := workbuddyOld(cs)
if len(old) != 3 {
t.Fatalf("want 3 older-history calls, got %d: %+v", len(old), old)
}
var in, out int
for _, c := range old {
in, out = in+c.Tokens.Input, out+c.Tokens.Output
if c.Session != "0123456789abcdef0123456789abcdef" {
t.Fatalf("an older-history reply's session: %+v", c)
}
}
if in != 5500 || out != 130 {
t.Fatalf("the older history's tokens: in %d out %d, want 5500 130 (%+v)", in, out, old)
}
}
// A call WorkBuddy made to magpie's gateway is the gateway's record to keep,
// not this one's: the gateway logs it with the provider and account that
// really answered, and nothing here can pair the two, so reading it would
// count the call twice — once as the gateway's and once as a nameless local
// session's. A model the user pointed at another address in the same picker
// ("custom-local:…" as well) is still read: only a routing group's name, or
// magpie's own entry in models.json, says a call went to the gateway.
func TestWorkBuddyCallsSkipsMagpiesGateway(t *testing.T) {
setupAgents(t)
cs := workbuddyCalls(t)
if _, dup := cs["wm1"]; dup {
t.Fatalf("a call made to magpie's gateway was read from the file too: %+v", cs["wm1"])
}
m, ok := cs["wm2"]
if !ok {
t.Fatalf("no call for the user's own custom provider: %+v", cs)
}
if m.Model != "glm-5" || m.Tokens != (Tokens{1200, 80, 0, 0, 0}) {
t.Fatalf("the user's own: %+v", m)
}
}
// workbuddyGateway reads models.json whichever shape WorkBuddy wrote it in,
// and names a routing group on its own: "group/<id>" is a name only a magpie
// answers to, so it needs no configuration behind it.
func TestWorkBuddyGateway(t *testing.T) {
list := `[{"id":"hy3","vendor":"workbuddy"},{"id":"deepseek/v4","vendor":"magpie"}]`
obj := `{"models":[{"id":"hy3","vendor":"workbuddy"},{"id":"deepseek/v4","vendor":"magpie"},{"id":"own-proxy/glm-5","vendor":"openai"}]}`
for _, c := range []struct {
shape, id string
want bool
}{
{"", "custom-local:group/auto-deepseek-v4-1-flash", true}, // the group's name, whatever models.json says
{"", "custom-local:deepseek/v4", false}, // no models.json: nothing else is magpie's
{"[]", "custom-local:deepseek/v4", false}, //
{list, "custom-local:deepseek/v4", true}, // magpie's entry in the picker
{list, "deepseek/v4", false}, // asked for without the picker's prefix
{list, "custom-local:hy3", false}, // another vendor's entry
{list, "custom-local:own-proxy/glm-5", false}, // the user's own, pointed elsewhere
{list, "", false}, // WorkBuddy's own models carry no prefix
{obj, "custom-local:deepseek/v4", true}, // an object with a "models" list
{obj, "custom-local:own-proxy/glm-5", false}, //
} {
dir := t.TempDir()
t.Setenv("WORKBUDDY_CONFIG_DIR", dir)
if c.shape != "" {
if err := os.WriteFile(filepath.Join(dir, "models.json"), []byte(c.shape), 0o644); err != nil {
t.Fatal(err)
}
}
if got := workbuddyGateway(c.id); got != c.want {
t.Fatalf("workbuddyGateway(%q) with %s = %v, want %v", c.id, c.shape, got, c.want)
}
}
}
// What the models.json half of that judgement depends on: taking magpie's
// models out of WorkBuddy's own configuration stops the calls made while they
// were in being read as the gateway's, and they are counted as the agent's
// own beside the gateway's rows for the same calls. The routing group's name
// keeps being the gateway's whichever way models.json stands.
func TestWorkBuddyGatewayFollowsModelsJSON(t *testing.T) {
dir := t.TempDir()
t.Setenv("WORKBUDDY_CONFIG_DIR", dir)
with := filepath.Join(dir, "models.json")
if err := os.WriteFile(with, []byte(`[{"id":"deepseek/v4","vendor":"magpie"}]`), 0o644); err != nil {
t.Fatal(err)
}
if !workbuddyGateway("custom-local:deepseek/v4") {
t.Fatal("magpie's entry in models.json is not the gateway's")
}
// magpie taken out of WorkBuddy's picker (agent.workbuddyWrite, off)
if err := os.WriteFile(with, []byte(`[{"id":"hy3","vendor":"workbuddy"}]`), 0o644); err != nil {
t.Fatal(err)
}
if workbuddyGateway("custom-local:deepseek/v4") {
t.Fatal("a call asked for by an id models.json no longer holds is still the gateway's")
}
if !workbuddyGateway("custom-local:group/auto-deepseek-v4-1-flash") {
t.Fatal("a routing group's name stopped being the gateway's")
}
}
+107
View File
@@ -0,0 +1,107 @@
package sessions
import (
"database/sql"
"encoding/json"
"sort"
"strings"
"sync"
)
// ZCode's calls: the same OpenCode tables its reader uses (zcode.go), one
// call per assistant message, with the AI SDK's tokens — input with the
// cache in it, output with the reasoning. A message's model is read the way
// the tables hold it: modelID, or model.id under a message written by a newer
// ZCode, and the provider the reply came from beside it. The rows are read
// here rather than taken from the session summary, because the summary counts
// a reply once however many of its lines carry it, while each assistant
// message is a call of its own.
//
// As with dsh, the Sessions page read these all along and the usage ledger
// did not, so ZCode's tokens never reached the Usage page.
//
// A reply through magpie's gateway is left out here as OpenCode's is
// (ocGatewayProvider): ZCode records the provider it was told to use, and
// the route magpie writes says "magpie", so unlike dsh's this one is a
// provider id magpie itself chose and can be trusted to mean the gateway.
// zcCallList is the last listing of ZCode's database, kept while the
// database and its WAL are as they were. Its lock is the one ocCallList
// uses: callSources runs on more than one read at a time, and two of them
// finding the stamp changed would write this at once.
var zcCallList struct {
sync.Mutex
stamp string
files []file
}
// zcodeCallFiles are ZCode's sessions as call sources.
func zcodeCallFiles() []file {
path := zcodeDB()
if !fileExists(path) {
return nil
}
stamp := ocStamp(path)
zcCallList.Lock()
defer zcCallList.Unlock()
if stamp != "" && stamp == zcCallList.stamp {
return append([]file(nil), zcCallList.files...)
}
var out []file
withOCCallDB(path, func(db *sql.DB) {
out = openCodeDBFilesIn("zcode", path, db, "COUNT(m.id) + COALESCE(SUM(m.time_updated), 0)")
})
for i := range out {
out[i].oc = nil // the handle is not kept; a read opens it again
}
zcCallList.stamp, zcCallList.files = stamp, out
return append([]file(nil), out...)
}
// readZCodeCalls reads a session's calls whole, as readOpenCodeCalls does.
// ZCode is OpenCode's tables with a title source of its own, so its store is
// zcDB: the model id is modelId or the older modelID, and its tokens the AI
// SDK's.
func readZCodeCalls(f file) (*callFile, bool) {
st := &callFile{Agent: "zcode", Path: f.path, Session: strings.TrimPrefix(f.key, "zcode:")}
i := strings.LastIndex(f.path, "#")
if i < 0 {
return st, false
}
path, sid := f.path[:i], f.path[i+1:]
var info ocInfo
var msgs [][]byte
if !withOCCallDB(path, func(db *sql.DB) {
var store ocStore = zcDB{ocDB{db}}
if ocHasTable(db, "session_v2") {
var n int
if db.QueryRow(`SELECT COUNT(*) FROM session_v2 WHERE id = ?`, sid).Scan(&n) == nil && n > 0 {
store = ocV2DB{db}
}
}
info, _ = store.info(sid)
msgs = store.messages(sid)
}) {
return st, false
}
if st.Session == "" {
st.Session = info.ID
}
for _, b := range msgs {
var m ocMessage
if json.Unmarshal(b, &m) != nil || m.Role != "assistant" || ocGatewayProvider(m.ProviderID) {
continue
}
if c, ok := ocCall(st, m, info.Directory); ok {
c.Agent = "zcode"
st.Calls = append(st.Calls, c)
}
}
sort.SliceStable(st.Calls, func(i, j int) bool {
if !st.Calls[i].Time.Equal(st.Calls[j].Time) {
return st.Calls[i].Time.Before(st.Calls[j].Time)
}
return st.Calls[i].Msg < st.Calls[j].Msg
})
return st, true
}
+44
View File
@@ -0,0 +1,44 @@
package sessions
import (
"testing"
"time"
)
// zcodeCalls are the ZCode calls Calls gives, by message id.
func zcodeCalls(t *testing.T) map[string]Call {
t.Helper()
out := map[string]Call{}
for _, c := range Calls(time.Time{}) {
if c.Agent == "zcode" {
if _, dup := out[c.Msg]; dup {
t.Fatalf("message %s counted twice", c.Msg)
}
out[c.Msg] = c
}
}
return out
}
// ZCode's calls reach the usage ledger too: the Sessions page read its
// database all along, and the Usage page showed none of it.
func TestZCodeCalls(t *testing.T) {
setupAgents(t)
cs := zcodeCalls(t)
// ses_zcA's own reply went through magpie's gateway (the route magpie
// writes names it "magpie"), so it is the gateway's record to keep; the
// others are read here: ses_zcB's (a subagent, counted under the session
// it ran in) and ses_zcC's
if len(cs) != 2 {
t.Fatalf("want 2 ZCode calls, got %d: %+v", len(cs), cs)
}
if _, dup := cs["msg_z3"]; dup {
t.Fatalf("a call made to magpie's gateway was read from the database too: %+v", cs["msg_z3"])
}
if b := cs["msg_z4"]; b.Model != "glm-5.1-air" || b.Tokens != (Tokens{50, 5, 0, 0, 0}) {
t.Fatalf("the subagent's reply: %+v", b)
}
if c := cs["msg_z5"]; c.Tokens != (Tokens{10, 1, 0, 0, 0}) {
t.Fatalf("ses_zcC's reply: %+v", c)
}
}
+25 -3
View File
@@ -191,6 +191,30 @@ func dshReadHead(path string) (dshHead, bool) {
return h, true
}
// dshResponse is what the vendor's own reply named: the model and provider
// that answered, under a streamed reply's replay state.
type dshResponse struct {
Model string `json:"model"`
Provider string `json:"provider"`
ResponseModel string `json:"responseModel"`
}
// dshReplay is a reply's replay state, where dsh keeps what the vendor's own
// reply named: it is the only place a swapped model shows (the provider
// answered with another).
type dshReplay struct {
Response dshResponse `json:"response"`
}
// dshSource is the source of an assistant/message: the model and provider dsh
// was told to use, and, under ReplayState, the replay state's record of the
// response.
type dshSource struct {
Model string `json:"model"`
Provider string `json:"provider"`
ReplayState dshReplay `json:"replayState"`
}
// dshEvent is an event line: packed rows of a streamed reply's chunks carry
// neither type nor time of their own and are passed over.
type dshEvent struct {
@@ -205,9 +229,7 @@ type dshEvent struct {
Title string `json:"title"`
Inherited bool `json:"inherited"`
Message struct {
Source struct {
Model string `json:"model"`
} `json:"source"`
Source dshSource `json:"source"`
} `json:"message"`
Usage *struct {
Input int `json:"inputTokens"`
+10 -6
View File
@@ -77,26 +77,30 @@ func TestWorkBuddy(t *testing.T) {
inZone(t, 0)
home := setupAgents(t)
ss := List(0)
if n := count(ss, "workbuddy"); n != 2 {
t.Fatalf("want 2 WorkBuddy sessions, got %d", n)
// the launch session, the one whose calls went through magpie's gateway,
// and the one brought over from the older history
if n := count(ss, "workbuddy"); n != 3 {
t.Fatalf("want 3 WorkBuddy sessions, got %d", n)
}
w := find(t, ss, "workbuddy", wbID)
if w.Cwd != "/work/wb" || w.Title != "Launch notes draft" || w.Resume != "" {
t.Fatalf("workbuddy: %+v", w)
}
// a reply's usage counted once, however many of its lines carry it
if m := model(w, "hy3"); m.Tokens != (Tokens{400, 120, 2200, 0, 0}) {
// a reply's usage counted once, however many of its lines carry it: the
// last line's is the reply's own total, here m2's (9999 − 7777 cached)
if m := model(w, "hy3"); m.Tokens != (Tokens{2422, 968, 8777, 0, 0}) {
t.Fatalf("hy3: %+v", m)
}
if !w.Start.Equal(at("2026-09-27T10:00:00Z")) || !w.Last.Equal(at("2026-09-27T10:00:10Z")) {
t.Fatalf("workbuddy times %s %s", w.Start, w.Last)
}
// brought over from its older history: the cwd in its meta file
// brought over from its older history: the cwd in its meta file, and
// each of its three replies counted once — they name no messageId
o := find(t, ss, "workbuddy", wbOldID)
if o.Cwd != "/work/wb-old" || o.Title != "Quarterly report" {
t.Fatalf("old: %+v", o)
}
if m := model(o, "auto"); m.Tokens != (Tokens{5000, 100, 0, 0, 0}) {
if m := model(o, "auto"); m.Tokens != (Tokens{5500, 130, 0, 0, 0}) {
t.Fatalf("auto: %+v", m)
}
@@ -2,7 +2,7 @@
{"type":"user/message","seq":0,"time":1790503200500,"data":{"content":"Follow the house rules","source":{"kind":"instructions"}}}
{"type":"user/message","seq":1,"time":1790503201000,"data":{"content":"Tidy the build\nscript","source":{"kind":"user"}}}
{"type":"assistant/delta","seq":2,"time":1790503202000,"data":{"text":"..."}}
{"type":"assistant/message","seq":3,"time":1790503205000,"data":{"message":{"role":"assistant","source":{"model":"deepseek-v4-pro"}},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":1000,"cacheWriteTokens":0,"reasoningTokens":1}}}
{"type":"assistant/message","seq":3,"time":1790503205000,"data":{"message":{"role":"assistant","source":{"model":"deepseek-v4-pro","provider":"deepseek","replayState":{"response":{"provider":"deepseek","model":"deepseek-v4-pro","responseModel":"deepseek-v4-pro"}}}},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":1000,"cacheWriteTokens":0,"reasoningTokens":1}}}
{"type":"session/title","seq":4,"time":1790503206000,"data":{"title":"Build script tidy"}}
{"rows":[[1,"x"],[2,"y"]]}
{"type":"assistant/message","seq":5,"time":1790503260000,"data":{"message":{"role":"assistant","source":{"model":"deepseek-v4-flash"}},"usage":{"inputTokens":10,"outputTokens":5,"cacheReadTokens":200,"cacheWriteTokens":0,"reasoningTokens":1}}}
{"type":"assistant/message","seq":5,"time":1790503260000,"data":{"message":{"role":"assistant","source":{"model":"deepseek-v4-flash","provider":"workbuddy-ai","replayState":{"response":{"provider":"workbuddy-ai","model":"deepseek-v4-flash","responseModel":"deepseek-v4-flash-sg"}}}},"usage":{"inputTokens":10,"outputTokens":5,"cacheReadTokens":200,"cacheWriteTokens":0,"reasoningTokens":1}}}
@@ -0,0 +1,4 @@
{"type":"session","version":3,"id":"dsh-gw","cwd":"/work/dsh","createdAt":1790506800000}
{"type":"user/message","seq":0,"time":1790506801000,"data":{"content":"Check the build","source":{"kind":"user"}}}
{"type":"assistant/message","seq":1,"time":1790506805000,"data":{"message":{"role":"assistant","source":{"model":"group/auto-deepseek-v4-1-flash","provider":"magpie","replayState":{"response":{"provider":"magpie","model":"group/auto-deepseek-v4-1-flash","responseModel":"deepseek-v4.1-flash"}}}},"usage":{"inputTokens":700,"outputTokens":90,"cacheReadTokens":5000,"cacheWriteTokens":0,"reasoningTokens":4}}}
{"type":"assistant/message","seq":2,"time":1790506860000,"data":{"message":{"role":"assistant","source":{"model":"deepseek-v4-flash","provider":"jieyue","replayState":{"response":{"provider":"jieyue","model":"deepseek-v4-flash","responseModel":"deepseek-v4-flash"}}}},"usage":{"inputTokens":30,"outputTokens":7,"cacheReadTokens":0,"cacheWriteTokens":0,"reasoningTokens":0}}}
+12
View File
@@ -0,0 +1,12 @@
[
{"id": "hy3", "name": "Hy3", "vendor": "workbuddy"},
{
"id": "group/auto-deepseek-v4-1-flash",
"name": "Auto DeepSeek V4.1 Flash",
"vendor": "magpie",
"apiKey": "magpie-workbuddy",
"url": "http://127.0.0.1:3425/v1/chat/completions",
"maxInputTokens": 200000,
"supportsToolCall": true
}
]
@@ -7,4 +7,4 @@
{"id":"a1","parentId":"r1","timestamp":1790503205000,"type":"function_call","name":"Read","callId":"c2","arguments":"{}","providerData":{"messageId":"m1","model":"hy3","requestModelId":"hy3","requestModelName":"Hy3","agent":"cli","usage":{"requests":1,"inputTokens":1200,"outputTokens":80,"totalTokens":1280,"inputTokensDetails":[{"cached_tokens":1000}],"outputTokensDetails":[{"reasoning_tokens":50}]}},"sessionId":"22222222-aaaa-4bbb-8ccc-000000000001","cwd":"/work/wb"}
{"id":"t1","parentId":"a1","timestamp":1790503206000,"type":"function_call_result","name":"Read","callId":"c1","status":"completed","output":{"type":"text","text":"notes"},"sessionId":"22222222-aaaa-4bbb-8ccc-000000000001","cwd":"/work/wb"}
{"id":"a2","parentId":"t1","timestamp":1790503210000,"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Here they are."}],"providerData":{"messageId":"m2","model":"hy3","requestModelId":"hy3","requestModelName":"Hy3","agent":"cli","usage":{"requests":1,"inputTokens":1400,"outputTokens":40,"totalTokens":1440,"inputTokensDetails":[{"cached_tokens":1200}],"outputTokensDetails":[{"reasoning_tokens":0}]}},"sessionId":"22222222-aaaa-4bbb-8ccc-000000000001","cwd":"/work/wb"}
{"id":"a2","parentId":"t1","timestamp":1790503210000,"type":"function_call","name":"Write","callId":"c3","arguments":"{}","providerData":{"messageId":"m2","model":"hy3","requestModelId":"hy3","requestModelName":"Hy3","agent":"cli","usage":{"requests":1,"inputTokens":1400,"outputTokens":40,"totalTokens":1440,"inputTokensDetails":[{"cached_tokens":1200}],"outputTokensDetails":[{"reasoning_tokens":0}]}},"sessionId":"22222222-aaaa-4bbb-8ccc-000000000001","cwd":"/work/wb"}
{"id":"a2","parentId":"t1","timestamp":1790503210000,"type":"function_call","name":"Write","callId":"c3","arguments":"{}","providerData":{"messageId":"m2","model":"hy3","requestModelId":"hy3","requestModelName":"Hy3","agent":"cli","usage":{"requests":1,"inputTokens":9999,"outputTokens":888,"totalTokens":10887,"inputTokensDetails":[{"cached_tokens":7777}],"outputTokensDetails":[{"reasoning_tokens":0}]}},"sessionId":"22222222-aaaa-4bbb-8ccc-000000000001","cwd":"/work/wb"}
@@ -0,0 +1,4 @@
{"id":"u1","timestamp":1790504000000,"type":"message","role":"user","content":[{"type":"input_text","text":"How much did that cost me?"}],"providerData":{"agent":"cli"},"sessionId":"33333333-aaaa-4bbb-8ccc-000000000002","cwd":"/work/wb-magpie"}
{"id":"t1","timestamp":1790504001000,"type":"ai-title","aiTitle":"Usage through magpie","sessionId":"33333333-aaaa-4bbb-8ccc-000000000002","cwd":"/work/wb-magpie"}
{"id":"a1","parentId":"u1","timestamp":1790504010000,"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Counting them."}],"providerData":{"messageId":"wm1","model":"deepseek-v4.1-flash","requestModelId":"custom-local:group/auto-deepseek-v4-1-flash","requestModelName":"Auto DeepSeek V4.1 Flash","agent":"cli","usage":{"requests":1,"inputTokens":33110,"outputTokens":222,"totalTokens":33332,"inputTokensDetails":[{"cached_tokens":32000}],"outputTokensDetails":[{"reasoning_tokens":0}]}},"sessionId":"33333333-aaaa-4bbb-8ccc-000000000002","cwd":"/work/wb-magpie"}
{"id":"a2","parentId":"a1","timestamp":1790504015000,"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"And again."}],"providerData":{"messageId":"wm2","model":"glm-5","requestModelId":"custom-local:own-proxy/glm-5","requestModelName":"GLM-5","agent":"cli","usage":{"requests":1,"inputTokens":1200,"outputTokens":80,"totalTokens":1280,"inputTokensDetails":[{"cached_tokens":0}]}},"sessionId":"33333333-aaaa-4bbb-8ccc-000000000002","cwd":"/work/wb-magpie"}
@@ -1,3 +1,5 @@
{"timestamp":1790510400000,"type":"custom-title","customTitle":"Quarterly report","id":"ct","sessionId":"0123456789abcdef0123456789abcdef"}
{"id":"u1","timestamp":1790510400000,"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}],"providerData":{},"sessionId":"0123456789abcdef0123456789abcdef"}
{"id":"a1","parentId":"u1","timestamp":1790510460000,"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hi."}],"providerData":{"model":"auto","usage":{"input_tokens":5000,"output_tokens":100,"total_tokens":5100}},"sessionId":"0123456789abcdef0123456789abcdef"}
{"id":"a2","parentId":"u1","timestamp":1790510520000,"type":"message","role":"assistant","content":[{"type":"output_text","text":"And again."}],"providerData":{"model":"auto","usage":{"input_tokens":300,"output_tokens":20,"total_tokens":320}},"sessionId":"0123456789abcdef0123456789abcdef"}
{"id":"a3","parentId":"u1","timestamp":1790510580000,"type":"message","role":"assistant","content":[{"type":"output_text","text":"Once more."}],"providerData":{"model":"auto","usage":{"input_tokens":200,"output_tokens":10,"total_tokens":210}},"sessionId":"0123456789abcdef0123456789abcdef"}
+42 -1
View File
@@ -59,6 +59,47 @@ type Filter struct {
// Computer narrows to the calls of one computer (#542): ThisComputer,
// OtherComputers, or another's id; "" is every computer's
Computer string
// Through narrows to one of the two sources a row can be of
// (SourceGateway, SourceSession); "" keeps both. It is what the requests
// page's source cells filter by, and, as with the other dimensions, the
// cells themselves are counted with it cleared so all three stay
// switchable. Not Record.Via, which is the computer that forwarded the
// call (#542).
Through string
}
// SourceGateway and SourceSession are the two sources a request row is of,
// which Row.Source spells "log" and "": the calls magpie's own gateway
// served, and the ones read from the agents' own session files, which it
// never saw. They are the requests page's source cells, and the values its
// via= takes.
const (
SourceGateway = "through"
SourceSession = "direct"
)
// SourceOf is the source a row is of, as the page's cells name them: the
// calls magpie's gateway served and the ones read from the agents' session
// files. A call the gateway turned away itself is neither — the page says
// under its totals that local rejections are not counted — so it is "" and
// picking either cell leaves it out, as the cells' own counts already do.
func SourceOf(r Row) string {
if r.IsRejected() {
return ""
}
if r.Source == "log" {
return SourceSession
}
return SourceGateway
}
// keepsRow is keeps with the one dimension a Record does not carry: which of
// the two sources the row is of (Row.Source, not the record's).
func (f Filter) keepsRow(r Row) bool {
if f.Through != "" && SourceOf(r) != f.Through {
return false
}
return f.keeps(r.Record)
}
// OtherComputers is the Filter.Computer of every other computer's calls.
@@ -180,7 +221,7 @@ func (l Ledgered) Filtered(f Filter) Ledgered {
}
out := Ledgered{Rows: []Row{}, Agents: l.Agents, Providers: l.Providers}
for _, r := range l.Rows {
if f.keeps(r.Record) {
if f.keepsRow(r) {
out.Rows = append(out.Rows, r)
if !r.IsRejected() {
out.Sum.addRow(r)
+43 -6
View File
@@ -34,6 +34,11 @@ type RequestPage struct {
Agents, Providers []string
Purposes []string // all purposes in the period, independent of the selected filter
CallerKeys []Group
// Through and Direct are the rows the gateway served and the rows read
// from the agents' own session files, which the gateway never saw. Sum
// is the two together; the page shows the three beside each other so a
// reader can tell what magpie carried from what it only read about.
Through, Direct Totals
// Accounts are the subscription accounts that answered calls in the
// period, by provider and account, for the Account filter (#557)
Accounts []Group
@@ -777,6 +782,9 @@ func buildRequestBlocks(p Period, f Filter, offset, limit int, now time.Time, ga
selected := newestHeap{}
chartFilter := f
chartFilter.Day = ""
// the two source cells' own numbers, counted without the source pick
viaFilter := f
viaFilter.Through = ""
var first time.Time
visit(func(ref rowRef, r Row) {
agents[r.Agent] = true
@@ -786,7 +794,7 @@ func buildRequestBlocks(p Period, f Filter, offset, limit int, now time.Time, ga
}
addCallerRow(callers, r)
addAccountRow(accounts, r)
keep := f.keeps(r.Record)
keep := f.keepsRow(r)
if keep {
out.Total++
if take > 0 && len(selected) < take {
@@ -799,10 +807,22 @@ func buildRequestBlocks(p Period, f Filter, offset, limit int, now time.Time, ga
out.Sum.addRow(r)
}
}
// The two sources are counted with the Via pick cleared, as the
// dimension groups above are: the cells are what the reader
// switches between, so each must go on saying its own number
// while another is picked, or one of them would read 0 and
// there would be no way back.
if !r.IsRejected() && viaFilter.keepsRow(r) {
if r.Source == "log" {
out.Direct.addRow(r)
} else {
out.Through.addRow(r)
}
}
if r.IsRejected() {
return
}
if keep || f.Day != "" && chartFilter.keeps(r.Record) {
if keep || f.Day != "" && chartFilter.keepsRow(r) {
for _, d := range Dimensions {
k := keys.key(r, d)
s := seriesGroups[d][k]
@@ -819,7 +839,7 @@ func buildRequestBlocks(p Period, f Filter, offset, limit int, now time.Time, ga
if g := f; names != nil {
g.Computer = ""
g.Day = ""
if g.keeps(r.Record) {
if g.keepsRow(r) {
k := r.key("computer")
if computers[k] == nil {
computers[k] = &Share{ID: k}
@@ -843,7 +863,7 @@ func buildRequestBlocks(p Period, f Filter, offset, limit int, now time.Time, ga
}
if f.Day != "" {
g.Day = ""
if g.keeps(r.Record) {
if g.keepsRow(r) {
k := keys.key(r, d)
if chartGroups[d][k] == nil {
chartGroups[d][k] = &Share{ID: k}
@@ -852,7 +872,7 @@ func buildRequestBlocks(p Period, f Filter, offset, limit int, now time.Time, ga
}
g.Day = f.Day
}
if !g.keeps(r.Record) {
if !g.keepsRow(r) {
continue
}
k := keys.key(r, d)
@@ -911,7 +931,7 @@ func buildRequestBlocks(p Period, f Filter, offset, limit int, now time.Time, ga
}
}
visit(func(_ rowRef, r Row) {
if !chartFilter.keeps(r.Record) || r.IsRejected() {
if !chartFilter.keepsRow(r) || r.IsRejected() {
return
}
t := r.Time.In(time.Local)
@@ -958,6 +978,23 @@ func pageFromLedger(p Period, f Filter, offset, limit int, all Ledgered) Request
func pageFromLedgerAt(p Period, f Filter, offset, limit int, all Ledgered, now time.Time) RequestPage {
l := all.Filtered(f)
out := RequestPage{Sum: l.Sum, Total: len(l.Rows), Agents: l.Agents, Providers: l.Providers, Purposes: []string{}, By: map[string][]Share{}}
// the same split the streaming path makes, so both pages of the ledger
// agree: "log" is a call read from an agent's own session file, which
// the gateway never saw; the rest went through it. Counted with the source
// pick cleared, as the streaming path does, so the three cells stay
// switchable whichever one is picked.
viaFilter := f
viaFilter.Through = ""
for _, r := range all.Rows {
if r.IsRejected() || !viaFilter.keepsRow(r) {
continue
}
if r.Source == "log" {
out.Direct.addRow(r)
} else {
out.Through.addRow(r)
}
}
callers, accounts := map[string]*Group{}, map[string]*Group{}
purposes := map[string]bool{}
for i := len(all.Rows) - 1; i >= 0; i-- {
+1 -1
View File
@@ -135,7 +135,7 @@ func TestCompactPageMatchesLedger(t *testing.T) {
rows, sum, agents, providers := ledgerWith(since, Filter{}, rs, cs)
all := Ledgered{rows, sum, agents, providers}
day := now.AddDate(0, 0, -1).Format(time.DateOnly)
for _, f := range []Filter{{}, {Agent: "claude"}, {Provider: "a"}, {Failed: true}, {Query: "LOCAL"}, {Agent: "codex", Provider: UnknownProvider}, {Query: "no match"}, {Day: day}, {Day: day, Provider: "a"}, {Day: day, Model: "m", Failed: true}, {Day: "1900-01-01"}} {
for _, f := range []Filter{{}, {Agent: "claude"}, {Provider: "a"}, {Failed: true}, {Query: "LOCAL"}, {Agent: "codex", Provider: UnknownProvider}, {Query: "no match"}, {Day: day}, {Day: day, Provider: "a"}, {Day: day, Model: "m", Failed: true}, {Day: "1900-01-01"}, {Through: SourceGateway}, {Through: SourceSession}} {
for _, offset := range []int{0, 7, 500, int(^uint(0) >> 1)} {
t.Run(fmt.Sprintf("%s/%+v/%d", period, f, offset), func(t *testing.T) {
equalPage(t, buildRequestPage(period, f, offset, 7, gateway, []*rowChunk{local}), pageFromLedger(period, f, offset, 7, all))
+180
View File
@@ -0,0 +1,180 @@
package usage
import (
"path/filepath"
"testing"
"time"
"github.com/yetone/magpie/internal/sessions"
)
// viaAll builds a ledger with two gateway rows and three session-file calls,
// as the requests page's two source cells count them, with the packed chunks
// the streaming path reads so both paths can be compared.
func viaAll(t *testing.T) (Ledgered, *rowChunk, []*rowChunk) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
at := time.Now().Add(-time.Hour)
recs := []Record{
{Time: at, Agent: "codex", Provider: "relay", Model: "gpt-6-sol", Input: 100, Output: 10, Status: 200},
{Time: at.Add(time.Minute), Agent: "codex", Provider: "relay", Model: "gpt-6-sol", Input: 200, Output: 20, Status: 200},
// a call the gateway turned away itself: it is one of the period's
// rows but neither source's, as the page says under its totals
// ("local rejections excluded"), so the two cells are the calls that
// were answered and not every row the period holds
{Time: at.Add(30 * time.Second), Agent: "codex", Provider: "relay", Model: "gpt-6-sol", Rejected: true, Status: 400},
}
logs := []sessions.Call{
{Time: at.Add(2 * time.Minute), Agent: "dsh", Session: "s", Model: "deepseek-v4", Tokens: sessions.Tokens{Input: 300, Output: 30}},
{Time: at.Add(3 * time.Minute), Agent: "dsh", Session: "s", Model: "deepseek-v4", Tokens: sessions.Tokens{Input: 400, Output: 40}},
{Time: at.Add(4 * time.Minute), Agent: "dsh", Session: "s", Model: "deepseek-v4", Tokens: sessions.Tokens{Input: 500, Output: 50}},
}
rows, sum, agents, providers := ledgerWith(Period(All).Since(time.Now()), Filter{}, recs, logs)
gateway := &rowChunk{}
for i, r := range recs {
r.Agent = AgentOf(r.Agent)
gateway.add(Row{Record: r}, "", int64(i), false)
}
local := &rowChunk{}
for i, c := range logs {
local.add(Row{Record: logRecord(c), Source: "log"}, "", int64(i), false)
}
return Ledgered{rows, sum, agents, providers}, gateway, []*rowChunk{local}
}
// The requests page's two source cells and their total: the calls magpie's
// gateway served, the ones read from the agents' own session files, and both
// together. Picking one narrows the page to it, and every cell keeps its own
// number while another is picked — counted with the pick cleared — or the
// cell that was picked would read 0 and there would be no way back to it.
func TestRequestPageViaSplit(t *testing.T) {
all, _, _ := viaAll(t)
whole := pageFromLedger(All, Filter{}, 0, 100, all)
if whole.Through.Calls != 2 || whole.Direct.Calls != 3 || whole.Sum.Calls != 5 {
t.Fatalf("the split: through %d, direct %d, all %d (want 2, 3, 5)",
whole.Through.Calls, whole.Direct.Calls, whole.Sum.Calls)
}
// the rejected call is one of the period's rows, and the two cells are
// the answered ones: through + direct is Sum, never Total
if whole.Total != 6 {
t.Fatalf("the period holds %d rows, want 6 (5 answered, 1 rejected)", whole.Total)
}
if got, want := whole.Through.AllTokens()+whole.Direct.AllTokens(), whole.Sum.AllTokens(); got != want {
t.Fatalf("through %d + direct %d = %d, want the total %d",
whole.Through.AllTokens(), whole.Direct.AllTokens(), got, want)
}
for _, tc := range []struct {
via string
want int
tokens int
}{
{SourceGateway, 2, whole.Through.AllTokens()},
{SourceSession, 3, whole.Direct.AllTokens()},
} {
page := pageFromLedger(All, Filter{Through: tc.via}, 0, 100, all)
if page.Total != tc.want || page.Sum.Calls != tc.want {
t.Fatalf("%s: %d rows, %d calls (want %d)", tc.via, page.Total, page.Sum.Calls, tc.want)
}
if page.Sum.AllTokens() != tc.tokens {
t.Fatalf("%s: %d tokens, want %d", tc.via, page.Sum.AllTokens(), tc.tokens)
}
// every cell goes on saying its own number, so the reader can switch
// to the other source or back to the total
if page.Through.Calls != whole.Through.Calls || page.Direct.Calls != whole.Direct.Calls {
t.Fatalf("%s: the cells read through %d, direct %d (want %d, %d, the pick cleared)",
tc.via, page.Through.Calls, page.Direct.Calls, whole.Through.Calls, whole.Direct.Calls)
}
if got := page.Through.AllTokens() + page.Direct.AllTokens(); got != whole.Sum.AllTokens() {
t.Fatalf("%s: the total cell reads %d tokens, want %d", tc.via, got, whole.Sum.AllTokens())
}
// the total cell is the two cells added up, which is what the page
// draws it from, not Total (that counts the rejected rows too)
if got := page.Through.Calls + page.Direct.Calls; got != 5 {
t.Fatalf("%s: the total cell reads %d calls, want 5", tc.via, got)
}
}
}
// The rows themselves are the picked source's, not just the totals: a reader
// who picks "not through magpie" must not be shown a gateway call.
func TestRequestPageViaPicksTheRows(t *testing.T) {
all, _, _ := viaAll(t)
for _, tc := range []struct {
via string
log bool
count int
}{
{SourceGateway, false, 2},
{SourceSession, true, 3},
} {
page := pageFromLedger(All, Filter{Through: tc.via}, 0, 100, all)
if len(page.Rows) != tc.count {
t.Fatalf("%s: %d rows, want %d", tc.via, len(page.Rows), tc.count)
}
for _, r := range page.Rows {
if got := r.Source == "log"; got != tc.log {
t.Fatalf("%s: a row of source %q was listed", tc.via, r.Source)
}
}
}
}
// The same three numbers and the same filtering, on the path a live page is
// built by: queryPage reads the two kinds of chunk, so the streaming builder
// must split and count them the way pageFromLedger does — or a page served
// from the gateway's own tail would disagree with one read from the ledger.
func TestRequestPageViaSplitStreams(t *testing.T) {
all, gateway, local := viaAll(t)
page := func(f Filter) RequestPage {
return buildRequestPage(All, f, 0, 100, gateway, local)
}
whole := page(Filter{})
if whole.Through.Calls != 2 || whole.Direct.Calls != 3 || whole.Sum.Calls != 5 {
t.Fatalf("the split: through %d, direct %d, all %d (want 2, 3, 5)",
whole.Through.Calls, whole.Direct.Calls, whole.Sum.Calls)
}
if whole.Total != 6 {
t.Fatalf("the period holds %d rows, want 6 (5 answered, 1 rejected)", whole.Total)
}
for _, tc := range []struct {
via string
rows int
tokens int
}{
{SourceGateway, 2, whole.Through.AllTokens()},
{SourceSession, 3, whole.Direct.AllTokens()},
} {
one := page(Filter{Through: tc.via})
if one.Total != tc.rows || len(one.Rows) != tc.rows || one.Sum.Calls != tc.rows {
t.Fatalf("%s: %d rows, %d calls (want %d)", tc.via, one.Total, one.Sum.Calls, tc.rows)
}
if one.Sum.AllTokens() != tc.tokens {
t.Fatalf("%s: %d tokens, want %d", tc.via, one.Sum.AllTokens(), tc.tokens)
}
for _, r := range one.Rows {
if got := r.Source == "log"; got != (tc.via == SourceSession) {
t.Fatalf("%s: a row of source %q was listed", tc.via, r.Source)
}
}
// the cells go on saying their own numbers with the pick cleared, so
// the reader can switch to the other source or back to the total
if one.Through.Calls != whole.Through.Calls || one.Direct.Calls != whole.Direct.Calls {
t.Fatalf("%s: the cells read through %d, direct %d (want %d, %d, the pick cleared)",
tc.via, one.Through.Calls, one.Direct.Calls, whole.Through.Calls, whole.Direct.Calls)
}
if got := one.Through.AllTokens() + one.Direct.AllTokens(); got != whole.Sum.AllTokens() {
t.Fatalf("%s: the total cell reads %d tokens, want %d", tc.via, got, whole.Sum.AllTokens())
}
}
// and the two builders agree on the same period, pick and all
for _, f := range []Filter{{}, {Through: SourceGateway}, {Through: SourceSession}} {
got, want := page(f), pageFromLedger(All, f, 0, 100, all)
equalPage(t, got, want)
}
}