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 0000000000..2110f1f8ed Binary files /dev/null and b/devlog/_plan/260912_devin_hardening/cached-token-companion.jpg differ diff --git a/gui/src/format-tokens.ts b/gui/src/format-tokens.ts index bc53af6ae5..737a8dae6e 100644 --- a/gui/src/format-tokens.ts +++ b/gui/src/format-tokens.ts @@ -32,3 +32,24 @@ export function formatTokens(n: number, locale: string): string { if (n < 1_000_000_000_000) return `${trim((n / 1_000_000_000).toFixed(1))}B`; return `${trim((n / 1_000_000_000_000).toFixed(1))}T`; } + +/** + * A token total with its cached subset beside it: `5.8만 c5.7만`, `58K c57K`. + * + * A cached request's total is mostly cache. Printing the total alone makes a + * 58,000-token prompt that is 57,000 cache read and 1,000 fresh look like an + * ordinary 58,000-token prompt, and it reads as a different, smaller request + * than the log row directly below it, which already shows the companion. The + * `c` marker matches the `logs.tokens.cacheRead` label, which reads + * "cache read (c)". + * + * The companion is omitted only when there is no cache to report. It is NOT + * omitted when the cached subset equals the total: a turn served entirely from + * cache is the most interesting row on the page, and hiding its marker there + * would blank exactly the case this exists for. + */ +export function formatTokensWithCache(total: number, cached: number | undefined, locale: string): string { + const base = formatTokens(total, locale); + if (cached === undefined || !Number.isFinite(cached) || cached <= 0) return base; + return `${base} c${formatTokens(cached, locale)}`; +} diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 774efc455a..b3fa00b332 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -3,6 +3,7 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { useI18n, LOCALES, type TFn } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; import { formatTokens } from "../format-tokens"; +import { formatTokensWithCache } from "../format-tokens"; import { hashLogConversationQuery } from "../log-conversation-id"; import { statusCodeInfo } from "../status-codes"; import { IconX } from "../icons"; @@ -363,19 +364,27 @@ function formatLogDateTime(ts: number, localeTag?: string, timeZone?: string): s function summarizeFilteredLogs(entries: LogEntry[]): { requests: number; totalTokens: number; + cachedInputTokens: number; estimatedCostUsd: number; priorityLowerBound: boolean; unpricedRequests: number; unmeteredRequests: number; } { let totalTokens = 0; + let cachedInputTokens = 0; for (const entry of entries) { const tokens = displayTokenTotal(entry); if (tokens !== undefined) totalTokens += tokens; + // The banner sits directly above rows that already print `c `, 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"); + }); +});