diff --git a/changelog.d/features/9620-cache-read-write-logs.md b/changelog.d/features/9620-cache-read-write-logs.md
new file mode 100644
index 0000000000..f92846262d
--- /dev/null
+++ b/changelog.d/features/9620-cache-read-write-logs.md
@@ -0,0 +1,2 @@
+- Show cache-read and cache-write token counts in request log rows and details when providers
+ report them.
diff --git a/src/shared/components/RequestLoggerDetail.tsx b/src/shared/components/RequestLoggerDetail.tsx
index 4cf4bf00c8..bd64b75dde 100644
--- a/src/shared/components/RequestLoggerDetail.tsx
+++ b/src/shared/components/RequestLoggerDetail.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useRef } from "react";
+import { useTranslations } from "next-intl";
import {
PROVIDER_COLORS,
getHttpStatusStyle as getStatusStyle,
@@ -196,6 +197,8 @@ export default function RequestLoggerDetail({
relatedLogs = [],
onSelectRelated,
}) {
+ const tCache = useTranslations("cache");
+
// Close on Escape key
useEffect(() => {
const handler = (e) => {
@@ -519,12 +522,16 @@ export default function RequestLoggerDetail({
Total In: {formatTokenValue(tokenStats.totalIn)}
-
- Cache Read: {formatTokenValue(tokenStats.cacheRead)}
-
-
- Cache Write: {formatTokenValue(tokenStats.cacheWrite)}
-
+ {tokenStats.cacheRead != null && tokenStats.cacheRead > 0 && (
+
+ {tCache("cachedTokensCol")}: {formatTokenValue(tokenStats.cacheRead)}
+
+ )}
+ {tokenStats.cacheWrite != null && tokenStats.cacheWrite > 0 && (
+
+ {tCache("cacheCreation")}: {formatTokenValue(tokenStats.cacheWrite)}
+
+ )}
{tokenStats.compressed != null &&
tokenStats.compressed > 0 &&
(() => {
diff --git a/src/shared/components/RequestLoggerV2.tsx b/src/shared/components/RequestLoggerV2.tsx
index 0942e9b48a..f5c64dfa4e 100644
--- a/src/shared/components/RequestLoggerV2.tsx
+++ b/src/shared/components/RequestLoggerV2.tsx
@@ -95,6 +95,7 @@ const RequestLoggerV2 = forwardRef {
const { initialSelectedId } = props as any;
const t = useTranslations("requestLogger");
+ const tCache = useTranslations("cache");
const { emailsVisible } = useEmailPrivacyStore();
// Get translated status filters
@@ -1514,6 +1515,30 @@ const RequestLoggerV2 = forwardRef
{log.tokens?.out?.toLocaleString() || 0}
+ {log.tokens?.cacheRead != null && log.tokens.cacheRead > 0 && (
+ <>
+ |
+ CR:{" "}
+
+ {log.tokens.cacheRead.toLocaleString()}
+
+ >
+ )}
+ {log.tokens?.cacheWrite != null && log.tokens.cacheWrite > 0 && (
+ <>
+ |
+ CW:{" "}
+
+ {log.tokens.cacheWrite.toLocaleString()}
+
+ >
+ )}
{log.tokens?.compressed != null && log.tokens.compressed > 0 && (
<>
|
diff --git a/tests/unit/ui/request-logger-cache-tokens.test.tsx b/tests/unit/ui/request-logger-cache-tokens.test.tsx
new file mode 100644
index 0000000000..46e0d202c1
--- /dev/null
+++ b/tests/unit/ui/request-logger-cache-tokens.test.tsx
@@ -0,0 +1,160 @@
+// @vitest-environment jsdom
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next-intl", () => ({
+ useTranslations: (namespace?: string) => (key: string) =>
+ namespace === "cache"
+ ? ({ cachedTokensCol: "Cache Read", cacheCreation: "Cache Write" }[key] ?? key)
+ : key,
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
+}));
+
+vi.mock("@/store/emailPrivacyStore", () => ({
+ default: () => ({ emailsVisible: true }),
+}));
+
+const RequestLoggerV2 = (await import("../../../src/shared/components/RequestLoggerV2.tsx"))
+ .default;
+const RequestLoggerDetail = (await import("../../../src/shared/components/RequestLoggerDetail.tsx"))
+ .default;
+
+let container: HTMLElement;
+let root: Root;
+
+const populatedLog = {
+ id: "log-cache",
+ status: 200,
+ method: "POST",
+ path: "/v1/chat/completions",
+ model: "gpt-cache",
+ provider: "openai",
+ timestamp: "2026-08-10T12:00:00.000Z",
+ duration: 1_000,
+ tokens: {
+ in: 1_000,
+ out: 250,
+ cacheRead: 800,
+ cacheWrite: 120,
+ reasoning: 50,
+ compressed: 20,
+ },
+};
+
+const noop = () => {};
+
+async function render(component: React.ReactNode) {
+ await act(async () => {
+ root.render(component);
+ });
+}
+
+beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(async () => {
+ await act(async () => {
+ root.unmount();
+ });
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
+describe("request log cache token metrics (#9620)", () => {
+ it("renders cache read/write beside the existing row token metrics", async () => {
+ const emptyCacheLog = {
+ ...populatedLog,
+ id: "log-no-cache",
+ model: "gpt-no-cache",
+ timestamp: "2026-08-10T11:59:00.000Z",
+ tokens: { ...populatedLog.tokens, cacheRead: null, cacheWrite: 0 },
+ };
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.startsWith("/api/usage/call-logs")) {
+ return Response.json([populatedLog, emptyCacheLog]);
+ }
+ if (url.startsWith("/api/provider-nodes")) return Response.json({ nodes: [] });
+ if (url.startsWith("/api/logs/detail")) return Response.json({ enabled: false });
+ return Response.json({});
+ })
+ );
+
+ await render();
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ const row = Array.from(container.querySelectorAll("tbody tr")).find((candidate) =>
+ candidate.textContent?.includes("gpt-cache")
+ );
+ expect(row?.textContent).toContain("TI: 1,000");
+ expect(row?.textContent).toContain("TO: 250");
+ expect(row?.textContent).toContain("CR: 800");
+ expect(row?.textContent).toContain("CW: 120");
+ expect(row?.textContent).toContain("↓20");
+
+ const emptyRow = Array.from(container.querySelectorAll("tbody tr")).find((candidate) =>
+ candidate.textContent?.includes("gpt-no-cache")
+ );
+ expect(emptyRow?.textContent).toContain("TI: 1,000");
+ expect(emptyRow?.textContent).toContain("TO: 250");
+ expect(emptyRow?.textContent).not.toContain("CR:");
+ expect(emptyRow?.textContent).not.toContain("CW:");
+ });
+
+ it("distinguishes cache read from cache write in the detail view", async () => {
+ await render(
+ true}
+ />
+ );
+
+ const inputGroup = container.querySelector('[data-testid="token-group-input"]');
+ const outputGroup = container.querySelector('[data-testid="token-group-output"]');
+ expect(inputGroup?.textContent).toContain("Total In: 1,000");
+ expect(inputGroup?.textContent).toContain("Cache Read: 800");
+ expect(inputGroup?.textContent).toContain("Cache Write: 120");
+ expect(inputGroup?.textContent).toContain("Compressed:");
+ expect(outputGroup?.textContent).toContain("Total Out: 250");
+ expect(outputGroup?.textContent).toContain("Reasoning: 50");
+ });
+
+ it("omits cache metrics when historical logs contain zero or null values", async () => {
+ const emptyCacheLog = {
+ ...populatedLog,
+ id: "log-no-cache",
+ tokens: { ...populatedLog.tokens, cacheRead: null, cacheWrite: 0 },
+ };
+
+ await render(
+ true}
+ />
+ );
+
+ const inputGroup = container.querySelector('[data-testid="token-group-input"]');
+ expect(inputGroup?.textContent).not.toContain("Cache Read");
+ expect(inputGroup?.textContent).not.toContain("Cache Write");
+ expect(inputGroup?.textContent).toContain("Total In: 1,000");
+ });
+});