From 635725a9cfd8a766ea757f589803ede4addfdcb6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 00:01:58 +0900 Subject: [PATCH] feat(usage): show the cached subset beside every token total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cached request's total is mostly cache. A 58,000-token prompt that is 57,000 cache read and 1,000 fresh was printed as a bare 58,000 on every surface except the logs table row, so it read as a different, smaller request than the row directly beside it. formatTokensWithCache renders the total with its cached companion — 5.8만 c5.7만, 58K c57K — reusing the marker the logs.tokens.cacheRead label already documents as "cache read (c)", so no new i18n key is needed. A provider that reports no cache is untouched, and a turn served entirely from cache still shows the marker, since that is the row worth seeing. No backend change: /api/logs forwards the whole usage object and /api/usage already emits cache on summary, models and providers. The loss was purely client-side, in the row types and the aggregators, so the fix widens UsageModel, UsageProvider, UsageSummary30d, the CLI CostRow, and summarizeFilteredLogs to keep the fields that were already arriving. The log detail panel is deliberately left alone: it already has separate cache read and cache write cells, and stacking the companion onto its total would duplicate them. --- .../cached-token-companion.jpg | Bin 0 -> 18950 bytes gui/src/format-tokens.ts | 21 ++++++++++ gui/src/pages/Logs.tsx | 15 +++++++- gui/src/pages/Usage.tsx | 12 ++++-- gui/src/pages/dashboard-overview-head.tsx | 10 ++++- gui/src/pages/dashboard-shared.ts | 12 +++++- scripts/test-layout/layout.json | 1 + src/cli/usage-report.ts | 36 ++++++++++++++++-- tests/cli/cli-usage-report.test.ts | 25 ++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/gui/gui-format-tokens-cache.test.ts | 29 ++++++++++++++ 11 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 devlog/_plan/260912_devin_hardening/cached-token-companion.jpg create mode 100644 tests/gui/gui-format-tokens-cache.test.ts diff --git a/devlog/_plan/260912_devin_hardening/cached-token-companion.jpg b/devlog/_plan/260912_devin_hardening/cached-token-companion.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2110f1f8ed0d5a4c32265e344a509836c151305f GIT binary patch literal 18950 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, so a + // total with no companion read as a different, smaller figure than the rows + // it summarizes. + const read = cacheSplit(entry).read; + if (read !== undefined && read > 0) cachedInputTokens += read; } return { requests: entries.length, totalTokens, + cachedInputTokens, ...summarizeEstimatedCosts(entries), }; } @@ -687,7 +696,11 @@ export default function Logs({ apiBase }: { apiBase: string }) { {t("logs.conversation.totals", { requests: conversationTotals.requests, - tokens: formatTokens(conversationTotals.totalTokens, localeTag ?? locale), + tokens: formatTokensWithCache( + conversationTotals.totalTokens, + conversationTotals.cachedInputTokens, + localeTag ?? locale, + ), cost: formatEstimatedUsdValue( conversationTotals.estimatedCostUsd, t, diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 96f0f1db0c..44028824b2 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useI18n, type TFn, type Locale } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; -import { formatTokens } from "../format-tokens"; +import { formatTokens, formatTokensWithCache } from "../format-tokens"; import { formatEstimatedUsdValue as formatUsdEstimate } from "../intl-formatters"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { EmptyState, Notice } from "../ui"; @@ -64,6 +64,10 @@ interface UsageModel { totalTokens: number; inputTokens: number; outputTokens: number; + // /api/usage has carried these all along; dropping them from the row type is + // what left the token column without its cached companion. + cachedInputTokens?: number; + cacheReadInputTokens?: number; shareRatio: number; } @@ -74,6 +78,8 @@ interface UsageProvider { reportedRequests: number; estimatedRequests: number; totalTokens: number; + cachedInputTokens?: number; + cacheReadInputTokens?: number; shareRatio: number; } @@ -561,7 +567,7 @@ function UsageModelsTable({ {formatProviderDisplayName(model.provider, t)} {model.requests} {model.measuredRequests} - {formatTokens(model.totalTokens, locale)} + {formatTokensWithCache(model.totalTokens, model.cacheReadInputTokens ?? model.cachedInputTokens, locale)}
))} @@ -621,7 +627,7 @@ function UsageProvidersTable({ {formatProviderDisplayName(provider.provider, t)} {provider.requests} {provider.measuredRequests} - {formatTokens(provider.totalTokens, locale)} + {formatTokensWithCache(provider.totalTokens, provider.cacheReadInputTokens ?? provider.cachedInputTokens, locale)}
))} diff --git a/gui/src/pages/dashboard-overview-head.tsx b/gui/src/pages/dashboard-overview-head.tsx index 44b3f8ac3c..fea1ae94e9 100644 --- a/gui/src/pages/dashboard-overview-head.tsx +++ b/gui/src/pages/dashboard-overview-head.tsx @@ -1,6 +1,6 @@ import { IconAlert, IconInfo } from "../icons"; import { type TKey, useT } from "../i18n/shared"; -import { formatTokens } from "../format-tokens"; +import { formatTokensWithCache } from "../format-tokens"; import { formatUptime } from "../formatUptime"; import { navigateHash } from "../hash-routing"; import type { useDashboardData } from "./use-dashboard-data"; @@ -81,7 +81,13 @@ export function DashboardOverviewHead({
{t("dash.providers")}
{providers.length}
{t("dash.tokens30d")}
-
{usage30d && usage30d.summary.requests > 0 ? formatTokens(usage30d.summary.totalTokens, locale) : "—"}
+
{usage30d && usage30d.summary.requests > 0 + ? formatTokensWithCache( + usage30d.summary.totalTokens, + usage30d.summary.cacheReadInputTokens ?? usage30d.summary.cachedInputTokens, + locale, + ) + : "—"}
{usage30d && usage30d.summary.requests > 0 ? t("dash.coverage").replace("{pct}", `${Math.round(usage30d.summary.coverageRatio * 100)}%`) diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index 029e39b2da..cd0f7ac66d 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -123,7 +123,17 @@ export interface SidecarPatch { }; } export interface ShadowCallData { enabled: boolean; model: string; sourceModels?: string[] } -export interface UsageSummary30d { summary: { requests: number; totalTokens: number; coverageRatio: number } } +export interface UsageSummary30d { + summary: { + requests: number; + totalTokens: number; + coverageRatio: number; + // Already on /api/usage; the tile showed a bare total only because this + // type dropped them. + cachedInputTokens?: number; + cacheReadInputTokens?: number; + }; +} export type UpdateChannel = "latest" | "preview"; export type Installer = "npm" | "bun" | "source"; export type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed"; diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 33b960ac70..18fc2060c0 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -718,6 +718,7 @@ "grok-sync.test.ts": "providers/xai", "grok-writer-boundary.test.ts": "providers/xai", "gui-api-error.test.ts": "gui", + "gui-format-tokens-cache.test.ts": "gui", "gui-management-session.test.ts": "gui", "gui-pair-capability.test.ts": "gui", "gui-pair-client.test.ts": "gui", diff --git a/src/cli/usage-report.ts b/src/cli/usage-report.ts index 3311a781a1..2d2c525cde 100644 --- a/src/cli/usage-report.ts +++ b/src/cli/usage-report.ts @@ -17,6 +17,13 @@ interface CostRow { model?: string; requests: number; totalTokens: number; + /** + * Cache-read subset of the row's tokens. The API sends it; the CLI dropped it, + * so a mostly-cached provider's TOKENS column read as an ordinary total while + * the summary line two rows above already said `cached N`. + */ + cachedInputTokens?: number; + cacheReadInputTokens?: number; estimatedCostUsd?: number; } @@ -53,6 +60,8 @@ interface UsageReportInput { ambiguous?: boolean; requests: number; totalTokens: number; + cachedInputTokens?: number; + cacheReadInputTokens?: number; estimatedCostUsd?: number; }[]; } @@ -76,6 +85,16 @@ function count(value: number | undefined): string { return typeof value === "number" && Number.isFinite(value) ? value.toLocaleString("en-US") : "—"; } +/** + * A row's token total with its cache-read subset, matching the summary line's + * `cached N` wording rather than inventing a second vocabulary for the tables. + */ +function countWithCache(total: number | undefined, cached: number | undefined): string { + const base = count(total); + if (typeof cached !== "number" || !Number.isFinite(cached) || cached <= 0) return base; + return `${base} (cached ${count(cached)})`; +} + /** * Matches the dashboard's `~$` with four fraction digits. Estimates below a * hundredth of a cent still read as a number rather than collapsing to $0.00, @@ -141,7 +160,12 @@ export function formatUsageReport(data: UsageReportInput): string[] { lines.push(""); lines.push(...table( ["PROVIDER", "REQUESTS", "TOKENS", "EST. COST"], - providers.map(row => [row.provider, count(row.requests), count(row.totalTokens), usd(row.estimatedCostUsd)]), + providers.map(row => [ + row.provider, + count(row.requests), + countWithCache(row.totalTokens, row.cacheReadInputTokens ?? row.cachedInputTokens), + usd(row.estimatedCostUsd), + ]), )); } @@ -163,7 +187,7 @@ export function formatUsageReport(data: UsageReportInput): string[] { // wrong conclusion. Mark it rather than presenting it as a single identity. row.ambiguous ? `${terminalText(row.accountLogLabel)} (ambiguous)` : terminalText(row.accountLogLabel), count(row.requests), - count(row.totalTokens), + countWithCache(row.totalTokens, row.cacheReadInputTokens ?? row.cachedInputTokens), usd(row.estimatedCostUsd), ]), )); @@ -175,7 +199,13 @@ export function formatUsageReport(data: UsageReportInput): string[] { const shown = models.slice(0, MAX_MODEL_ROWS); lines.push(...table( ["MODEL", "PROVIDER", "REQUESTS", "TOKENS", "EST. COST"], - shown.map(row => [row.model ?? "-", row.provider, count(row.requests), count(row.totalTokens), usd(row.estimatedCostUsd)]), + shown.map(row => [ + row.model ?? "-", + row.provider, + count(row.requests), + countWithCache(row.totalTokens, row.cacheReadInputTokens ?? row.cachedInputTokens), + usd(row.estimatedCostUsd), + ]), )); if (models.length > shown.length) { lines.push(`... ${models.length - shown.length} more (use --json)`); diff --git a/tests/cli/cli-usage-report.test.ts b/tests/cli/cli-usage-report.test.ts index 5399137a57..1be03f68f2 100644 --- a/tests/cli/cli-usage-report.test.ts +++ b/tests/cli/cli-usage-report.test.ts @@ -404,3 +404,28 @@ describe("ocx logs --follow output contract", () => { } }); }); + +describe("cached tokens in the per-row tables", () => { + test("a provider and model row name their cache-read subset", () => { + // The summary line has always said "cached N". The tables below it printed a + // bare total, so a mostly-cached provider looked like an ordinary one. + const lines = formatUsageReport({ + range: "today", + summary: { requests: 1, totalTokens: 58_000, cachedInputTokens: 57_000 }, + providers: [{ provider: "devin-cli", requests: 1, totalTokens: 58_000, cacheReadInputTokens: 57_000 }], + models: [{ provider: "devin-cli", model: "swe-2", requests: 1, totalTokens: 58_000, cachedInputTokens: 57_000 }], + }).join("\n"); + expect(lines).toContain("58,000 (cached 57,000)"); + expect(lines.match(/cached 57,000/g)?.length).toBeGreaterThanOrEqual(2); + }); + + test("a provider that reports no cache keeps a bare total", () => { + const lines = formatUsageReport({ + range: "today", + summary: { requests: 1, totalTokens: 58_000 }, + providers: [{ provider: "xai", requests: 1, totalTokens: 58_000 }], + }).join("\n"); + expect(lines).toContain("58,000"); + expect(lines).not.toContain("cached"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 9ea5f32928..5e57040565 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -549,6 +549,7 @@ "grok-sync.test.ts": "providers/xai", "grok-writer-boundary.test.ts": "providers/xai", "gui-api-error.test.ts": "gui", + "gui-format-tokens-cache.test.ts": "gui", "gui-management-session.test.ts": "gui", "gui-pair-capability.test.ts": "gui", "gui-pair-client.test.ts": "gui", diff --git a/tests/gui/gui-format-tokens-cache.test.ts b/tests/gui/gui-format-tokens-cache.test.ts new file mode 100644 index 0000000000..fe38fb81da --- /dev/null +++ b/tests/gui/gui-format-tokens-cache.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { formatTokens, formatTokensWithCache } from "../../gui/src/format-tokens"; + +/** + * A cached request's total is mostly cache. The logs table has always shown the + * total with a stacked `c `; every other surface printed the total alone, + * which reads as a different, smaller request than the rows beside it. + */ +describe("formatTokensWithCache", () => { + test("renders the cached subset beside the total in both number scales", () => { + expect(formatTokensWithCache(58_000, 57_000, "ko")).toBe("5.8만 c5.7만"); + expect(formatTokensWithCache(58_000, 57_000, "en")).toBe("58K c57K"); + expect(formatTokensWithCache(58_000, 57_000, "zh")).toBe("5.8万 c5.7万"); + }); + + test("a provider that reports no cache is left exactly as it was", () => { + for (const cached of [undefined, 0, Number.NaN]) { + expect(formatTokensWithCache(58_000, cached, "ko")).toBe(formatTokens(58_000, "ko")); + } + // A negative count is nonsense rather than a cache miss; treat it as absent. + expect(formatTokensWithCache(58_000, -1, "en")).toBe("58K"); + }); + + test("a turn served entirely from cache still shows the marker", () => { + // This is the most cached row on the page. Hiding the companion when the + // subset equals the total would blank exactly the case worth showing. + expect(formatTokensWithCache(57_000, 57_000, "en")).toBe("57K c57K"); + }); +});