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/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..21f2797fa6 --- /dev/null +++ b/tests/unit/ui/request-logger-cache-tokens.test.tsx @@ -0,0 +1,158 @@ +// @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("@/shared/components/RequestLoggerV2")).default; +const RequestLoggerDetail = (await import("@/shared/components/RequestLoggerDetail")).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("handles historical null and zero cache values without inventing usage", 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).toContain("Cache Read: N/A"); + expect(inputGroup?.textContent).toContain("Cache Write: 0"); + expect(inputGroup?.textContent).toContain("Total In: 1,000"); + }); +});