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