diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css
index 753f0d52..6a323940 100644
--- a/internal/gui/assets/app.css
+++ b/internal/gui/assets/app.css
@@ -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); }
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index 92cb21b2..58b0a28c 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -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"));
diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js
index 6fb9f089..0407b095 100644
--- a/internal/gui/assets/i18n.js
+++ b/internal/gui/assets/i18n.js
@@ -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}",
diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html
index ad4419dc..481c3d9c 100644
--- a/internal/gui/assets/index.html
+++ b/internal/gui/assets/index.html
@@ -186,6 +186,7 @@
+
diff --git a/internal/gui/tests/session-identity.test.cjs b/internal/gui/tests/session-identity.test.cjs
index b7a338ac..0e1b4a3d 100644
--- a/internal/gui/tests/session-identity.test.cjs
+++ b/internal/gui/tests/session-identity.test.cjs
@@ -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");
diff --git a/internal/gui/tests/usage-ledger-chart.test.cjs b/internal/gui/tests/usage-ledger-chart.test.cjs
index 52d06cce..6cbdba86 100644
--- a/internal/gui/tests/usage-ledger-chart.test.cjs
+++ b/internal/gui/tests/usage-ledger-chart.test.cjs
@@ -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);
diff --git a/internal/gui/usage.go b/internal/gui/usage.go
index 8fbe3def..1406d453 100644
--- a/internal/gui/usage.go
+++ b/internal/gui/usage.go
@@ -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
diff --git a/internal/sessions/agents_test.go b/internal/sessions/agents_test.go
index efe5e964..820390e4 100644
--- a/internal/sessions/agents_test.go
+++ b/internal/sessions/agents_test.go
@@ -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)
}
}
diff --git a/internal/sessions/call_cache.go b/internal/sessions/call_cache.go
index 910922c1..cd94f443 100644
--- a/internal/sessions/call_cache.go
+++ b/internal/sessions/call_cache.go
@@ -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()
diff --git a/internal/sessions/calls.go b/internal/sessions/calls.go
index fa19e059..0ecb6d91 100644
--- a/internal/sessions/calls.go
+++ b/internal/sessions/calls.go
@@ -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
diff --git a/internal/sessions/calls_dsh.go b/internal/sessions/calls_dsh.go
new file mode 100644
index 00000000..eeba01fe
--- /dev/null
+++ b/internal/sessions/calls_dsh.go
@@ -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"
+}
diff --git a/internal/sessions/calls_dsh_test.go b/internal/sessions/calls_dsh_test.go
new file mode 100644
index 00000000..e991d619
--- /dev/null
+++ b/internal/sessions/calls_dsh_test.go
@@ -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))
+ }
+}
diff --git a/internal/sessions/calls_workbuddy.go b/internal/sessions/calls_workbuddy.go
new file mode 100644
index 00000000..a3335afb
--- /dev/null
+++ b/internal/sessions/calls_workbuddy.go
@@ -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/" 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:". 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
+}
diff --git a/internal/sessions/calls_workbuddy_test.go b/internal/sessions/calls_workbuddy_test.go
new file mode 100644
index 00000000..73093eba
--- /dev/null
+++ b/internal/sessions/calls_workbuddy_test.go
@@ -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/" 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")
+ }
+}
diff --git a/internal/sessions/calls_zcode.go b/internal/sessions/calls_zcode.go
new file mode 100644
index 00000000..b4ae9951
--- /dev/null
+++ b/internal/sessions/calls_zcode.go
@@ -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
+}
diff --git a/internal/sessions/calls_zcode_test.go b/internal/sessions/calls_zcode_test.go
new file mode 100644
index 00000000..ed154d56
--- /dev/null
+++ b/internal/sessions/calls_zcode_test.go
@@ -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)
+ }
+}
diff --git a/internal/sessions/dsh.go b/internal/sessions/dsh.go
index 262460a6..55c0865d 100644
--- a/internal/sessions/dsh.go
+++ b/internal/sessions/dsh.go
@@ -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"`
diff --git a/internal/sessions/grok_workbuddy_test.go b/internal/sessions/grok_workbuddy_test.go
index d9161f98..1bcb1862 100644
--- a/internal/sessions/grok_workbuddy_test.go
+++ b/internal/sessions/grok_workbuddy_test.go
@@ -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)
}
diff --git a/internal/sessions/testdata/dsh/sessions/work-dsh-3f2a/dsh-a/session.v3.jsonl b/internal/sessions/testdata/dsh/sessions/work-dsh-3f2a/dsh-a/session.v3.jsonl
index ad35b2bb..a780ee98 100644
--- a/internal/sessions/testdata/dsh/sessions/work-dsh-3f2a/dsh-a/session.v3.jsonl
+++ b/internal/sessions/testdata/dsh/sessions/work-dsh-3f2a/dsh-a/session.v3.jsonl
@@ -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}}}
diff --git a/internal/sessions/testdata/dsh/sessions/work-dsh-3f2a/dsh-gw/session.v3.jsonl b/internal/sessions/testdata/dsh/sessions/work-dsh-3f2a/dsh-gw/session.v3.jsonl
new file mode 100644
index 00000000..f702b727
--- /dev/null
+++ b/internal/sessions/testdata/dsh/sessions/work-dsh-3f2a/dsh-gw/session.v3.jsonl
@@ -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}}}
diff --git a/internal/sessions/testdata/workbuddy/models.json b/internal/sessions/testdata/workbuddy/models.json
new file mode 100644
index 00000000..0e8c6de8
--- /dev/null
+++ b/internal/sessions/testdata/workbuddy/models.json
@@ -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
+ }
+]
diff --git a/internal/sessions/testdata/workbuddy/projects/Users-me-WorkBuddy-launch/22222222-aaaa-4bbb-8ccc-000000000001.jsonl b/internal/sessions/testdata/workbuddy/projects/Users-me-WorkBuddy-launch/22222222-aaaa-4bbb-8ccc-000000000001.jsonl
index b2b78f8a..7092736b 100644
--- a/internal/sessions/testdata/workbuddy/projects/Users-me-WorkBuddy-launch/22222222-aaaa-4bbb-8ccc-000000000001.jsonl
+++ b/internal/sessions/testdata/workbuddy/projects/Users-me-WorkBuddy-launch/22222222-aaaa-4bbb-8ccc-000000000001.jsonl
@@ -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"}
diff --git a/internal/sessions/testdata/workbuddy/projects/Users-me-WorkBuddy-magpie/33333333-aaaa-4bbb-8ccc-000000000002.jsonl b/internal/sessions/testdata/workbuddy/projects/Users-me-WorkBuddy-magpie/33333333-aaaa-4bbb-8ccc-000000000002.jsonl
new file mode 100644
index 00000000..c916cdb6
--- /dev/null
+++ b/internal/sessions/testdata/workbuddy/projects/Users-me-WorkBuddy-magpie/33333333-aaaa-4bbb-8ccc-000000000002.jsonl
@@ -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"}
diff --git a/internal/sessions/testdata/workbuddy/projects/Users-me-WorkBuddy-old/0123456789abcdef0123456789abcdef.jsonl b/internal/sessions/testdata/workbuddy/projects/Users-me-WorkBuddy-old/0123456789abcdef0123456789abcdef.jsonl
index 41230136..8eae5ff7 100644
--- a/internal/sessions/testdata/workbuddy/projects/Users-me-WorkBuddy-old/0123456789abcdef0123456789abcdef.jsonl
+++ b/internal/sessions/testdata/workbuddy/projects/Users-me-WorkBuddy-old/0123456789abcdef0123456789abcdef.jsonl
@@ -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"}
diff --git a/internal/usage/ledger.go b/internal/usage/ledger.go
index 67b1a3e6..7614234a 100644
--- a/internal/usage/ledger.go
+++ b/internal/usage/ledger.go
@@ -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)
diff --git a/internal/usage/request_page.go b/internal/usage/request_page.go
index 7560de7a..6f89f4bd 100644
--- a/internal/usage/request_page.go
+++ b/internal/usage/request_page.go
@@ -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-- {
diff --git a/internal/usage/request_page_test.go b/internal/usage/request_page_test.go
index c7d0b790..439adda4 100644
--- a/internal/usage/request_page_test.go
+++ b/internal/usage/request_page_test.go
@@ -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))
diff --git a/internal/usage/request_page_via_test.go b/internal/usage/request_page_via_test.go
new file mode 100644
index 00000000..613f91e4
--- /dev/null
+++ b/internal/usage/request_page_via_test.go
@@ -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)
+ }
+}