summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/core/src/agent/agent.tsbin48635 -> 54066 bytes
-rw-r--r--packages/core/src/chunks/append.ts7
-rw-r--r--packages/core/src/credentials/anthropic-betas.ts24
-rw-r--r--packages/core/src/credentials/claude.ts21
-rw-r--r--packages/core/src/llm/anthropic-oauth-transform.ts153
-rw-r--r--packages/core/src/llm/provider.ts44
-rw-r--r--packages/core/src/types/index.ts18
-rw-r--r--packages/core/tests/agent/agent.test.ts286
-rw-r--r--packages/core/tests/llm/anthropic-oauth-transform.test.ts137
-rw-r--r--packages/core/tests/llm/provider.test.ts88
-rw-r--r--packages/core/tests/tools/summon.test.ts6
-rw-r--r--packages/frontend/src/App.svelte2
-rw-r--r--packages/frontend/src/lib/components/CacheRatePanel.svelte129
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte14
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts30
-rw-r--r--packages/frontend/src/lib/types.ts32
-rw-r--r--packages/frontend/tests/chat-store.test.ts47
17 files changed, 1016 insertions, 22 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index 82b98df..439b436 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
Binary files differ
diff --git a/packages/core/src/chunks/append.ts b/packages/core/src/chunks/append.ts
index fe384bd..baccd10 100644
--- a/packages/core/src/chunks/append.ts
+++ b/packages/core/src/chunks/append.ts
@@ -35,9 +35,9 @@
* (no unsealed thinking chunk) are dropped.
*
* Ignored events:
- * - `status`, `done`, `task-list-update`, `tab-created`, `message-queued`,
- * `message-consumed`, `message-cancelled` — these are control / lifecycle
- * events, not message content.
+ * - `status`, `done`, `usage`, `task-list-update`, `tab-created`,
+ * `message-queued`, `message-consumed`, `message-cancelled` — these are
+ * control / lifecycle events, not message content.
*/
import type {
@@ -201,6 +201,7 @@ export function appendEventToChunks(chunks: Chunk[], event: AgentEvent): void {
// Lifecycle / control events — no chunk emitted.
case "status":
case "done":
+ case "usage":
case "task-list-update":
case "tab-created":
case "message-queued":
diff --git a/packages/core/src/credentials/anthropic-betas.ts b/packages/core/src/credentials/anthropic-betas.ts
new file mode 100644
index 0000000..f2ca7a6
--- /dev/null
+++ b/packages/core/src/credentials/anthropic-betas.ts
@@ -0,0 +1,24 @@
+// ─── Anthropic Beta Headers ───────────────────────────────────
+//
+// The set of `anthropic-beta` features the official Claude Code CLI sends on
+// every request. Kept in a dependency-free module (no DB / `bun:sqlite`
+// import) so the LLM provider layer (`llm/provider.ts`) can pull the list in
+// without dragging the whole credentials/DB stack into its import graph.
+//
+// `prompt-caching-scope-2026-01-05` is load-bearing for cost: without it the
+// Anthropic API silently ignores every `cache_control` breakpoint we place,
+// producing a 0% cache hit rate (see claude-report.md). `oauth-2025-04-20`
+// gates the Bearer/OAuth flow used by Claude Pro/Max subscriptions.
+
+const BASE_BETAS = [
+ "claude-code-20250219",
+ "oauth-2025-04-20",
+ "interleaved-thinking-2025-05-14",
+ "prompt-caching-scope-2026-01-05",
+ "context-management-2025-06-27",
+ "advisor-tool-2026-03-01",
+];
+
+export function getAnthropicBetas(): string[] {
+ return [...BASE_BETAS];
+}
diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts
index 6018207..168d544 100644
--- a/packages/core/src/credentials/claude.ts
+++ b/packages/core/src/credentials/claude.ts
@@ -10,8 +10,14 @@ import {
import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { getDatabase } from "../db/index.js";
+import { getAnthropicBetas } from "./anthropic-betas.js";
import { getStoredCredentials, listStoredCredentials, updateStoredTokens } from "./store.js";
+// Re-exported for backward compatibility — `getAnthropicBetas` historically
+// lived here and is surfaced through `credentials/index.ts`. The definition
+// now lives in the dependency-free `anthropic-betas.ts` module.
+export { getAnthropicBetas };
+
export interface ClaudeCredentials {
accessToken: string;
refreshToken: string;
@@ -367,21 +373,6 @@ export function buildBillingHeaderValue(
export const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
-// ─── Anthropic Beta Headers ───────────────────────────────────
-
-const BASE_BETAS = [
- "claude-code-20250219",
- "oauth-2025-04-20",
- "interleaved-thinking-2025-05-14",
- "prompt-caching-scope-2026-01-05",
- "context-management-2025-06-27",
- "advisor-tool-2026-03-01",
-];
-
-export function getAnthropicBetas(): string[] {
- return [...BASE_BETAS];
-}
-
// ─── Anthropic Request Headers ────────────────────────────────
export function getAnthropicHeaders(accessToken: string): Record<string, string> {
diff --git a/packages/core/src/llm/anthropic-oauth-transform.ts b/packages/core/src/llm/anthropic-oauth-transform.ts
new file mode 100644
index 0000000..467a307
--- /dev/null
+++ b/packages/core/src/llm/anthropic-oauth-transform.ts
@@ -0,0 +1,153 @@
+/**
+ * Wire-level request restructuring for the Claude OAuth (Pro/Max) flow.
+ *
+ * Anthropic validates the `system` array on OAuth-authenticated, Claude-Code-
+ * billed requests. A genuine Claude Code request looks like:
+ *
+ * system: [
+ * { type: "text", text: "x-anthropic-billing-header: ..." }, // system[0], NO cache_control
+ * { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude.",
+ * cache_control: { type: "ephemeral" } }, // identity, separate block
+ * ]
+ * messages: [ { role: "user", content: "<the real system prompt>\n\n<user text>" }, ... ]
+ *
+ * i.e. ONLY the billing header and the verbatim identity string may live in
+ * `system[]`. Any third-party system prompt (Dispatch's tool/agent instructions)
+ * MUST be relocated into the first user message. When third-party content stays
+ * in `system[]` next to the identity, Anthropic bills it as premium "extra
+ * usage" (token burn) and refuses to apply the Claude Code prompt-cache scope —
+ * producing the 0% cache hit rate and ballooning cost we were seeing.
+ *
+ * Dispatch builds its system prompt as ONE concatenated block
+ * (`<billing>\n<identity>\n\n<systemPrompt>`); `@ai-sdk/anthropic` serializes
+ * that into a single `system[]` text entry. This transform runs at fetch time
+ * on the already-serialized JSON body and reshapes it into the structure above.
+ *
+ * Mirrors `references/opencode-claude-auth/src/transforms.ts` (`transformBody`),
+ * adapted to Dispatch: tool names are already PascalCase-`mcp_`-prefixed by the
+ * agent, so this transform leaves tools and messages (other than the relocation)
+ * untouched. It is defensive: any parse/shape surprise returns the body
+ * unchanged so a transform bug can never break a request.
+ */
+
+const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
+const BILLING_PREFIX = "x-anthropic-billing-header";
+
+type SystemBlock = { type: "text"; text: string; cache_control?: unknown } & Record<
+ string,
+ unknown
+>;
+
+interface AnthropicRequestBody {
+ system?: string | Array<{ type?: string; text?: string } & Record<string, unknown>>;
+ messages?: Array<{
+ role?: string;
+ content?: string | Array<{ type?: string; text?: string } & Record<string, unknown>>;
+ }>;
+ [key: string]: unknown;
+}
+
+/**
+ * Restructure a serialized Anthropic request body string for the Claude Code
+ * OAuth flow. Returns the (possibly rewritten) body, or the original input
+ * unchanged when it isn't a JSON string we recognize.
+ */
+export function transformClaudeOAuthBody(
+ body: BodyInit | null | undefined,
+): BodyInit | null | undefined {
+ if (typeof body !== "string") return body;
+ let parsed: AnthropicRequestBody;
+ try {
+ parsed = JSON.parse(body) as AnthropicRequestBody;
+ } catch {
+ return body;
+ }
+ if (!parsed || typeof parsed !== "object") return body;
+ try {
+ const changed = restructureSystem(parsed);
+ return changed ? JSON.stringify(parsed) : body;
+ } catch {
+ // Never let a transform bug break a real request.
+ return body;
+ }
+}
+
+/**
+ * In-place restructure of `parsed.system` / `parsed.messages`. Returns true if
+ * anything changed (so the caller knows to re-serialize).
+ */
+function restructureSystem(parsed: AnthropicRequestBody): boolean {
+ const raw = parsed.system;
+ let entries: SystemBlock[];
+ if (typeof raw === "string") {
+ entries = [{ type: "text", text: raw }];
+ } else if (Array.isArray(raw)) {
+ entries = raw.map((e) =>
+ typeof e === "string"
+ ? { type: "text", text: e }
+ : ({ ...e, type: "text", text: typeof e.text === "string" ? e.text : "" } as SystemBlock),
+ );
+ } else {
+ return false; // no system field — nothing to do
+ }
+
+ const combined = entries.map((e) => e.text).join("\n\n");
+
+ // Only act on Claude Code shaped requests (must carry the identity string).
+ if (!combined.includes(SYSTEM_IDENTITY)) return false;
+
+ const hadCacheControl = entries.some((e) => e.cache_control != null);
+
+ // Peel the billing-header line out (it is a single line with no newlines).
+ const lines = combined.split("\n");
+ const billingIdx = lines.findIndex((l) => l.startsWith(BILLING_PREFIX));
+ let billingLine: string | null = null;
+ if (billingIdx !== -1) {
+ billingLine = lines[billingIdx] ?? null;
+ lines.splice(billingIdx, 1);
+ }
+ const afterBilling = lines.join("\n").replace(/^\n+/, "");
+
+ // Split the identity prefix from the rest (Dispatch's real system prompt).
+ let rest = "";
+ if (afterBilling.startsWith(SYSTEM_IDENTITY)) {
+ rest = afterBilling.slice(SYSTEM_IDENTITY.length).replace(/^\n+/, "");
+ } else {
+ // Identity is present but not at the front (unexpected) — still isolate it.
+ rest = afterBilling.replace(SYSTEM_IDENTITY, "").replace(/^\n+/, "");
+ }
+
+ // Rebuild system[]: billing (no cache_control) then identity (cached).
+ const newSystem: SystemBlock[] = [];
+ if (billingLine) newSystem.push({ type: "text", text: billingLine });
+ const identityBlock: SystemBlock = { type: "text", text: SYSTEM_IDENTITY };
+ if (hadCacheControl) identityBlock.cache_control = { type: "ephemeral" };
+ newSystem.push(identityBlock);
+
+ // Relocate the third-party system prompt into the first user message.
+ if (rest.length > 0) {
+ const firstUser = Array.isArray(parsed.messages)
+ ? parsed.messages.find((m) => m.role === "user")
+ : undefined;
+ if (firstUser) {
+ if (typeof firstUser.content === "string") {
+ firstUser.content = `${rest}\n\n${firstUser.content}`;
+ } else if (Array.isArray(firstUser.content)) {
+ firstUser.content.unshift({ type: "text", text: rest });
+ } else {
+ firstUser.content = rest;
+ }
+ } else {
+ // No user message to host it — keep it as a (cached) system block so
+ // the request still carries the instructions.
+ const restBlock: SystemBlock = { type: "text", text: rest };
+ if (hadCacheControl) restBlock.cache_control = { type: "ephemeral" };
+ newSystem.push(restBlock);
+ }
+ }
+
+ parsed.system = newSystem;
+ return true;
+}
+
+export const __test = { restructureSystem, SYSTEM_IDENTITY, BILLING_PREFIX };
diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts
index 81f7f51..5978196 100644
--- a/packages/core/src/llm/provider.ts
+++ b/packages/core/src/llm/provider.ts
@@ -1,6 +1,10 @@
+import { randomUUID } from "node:crypto";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LanguageModelV3 } from "@ai-sdk/provider";
+import type { FetchFunction } from "@ai-sdk/provider-utils";
+import { getAnthropicBetas } from "../credentials/anthropic-betas.js";
+import { transformClaudeOAuthBody } from "./anthropic-oauth-transform.js";
export interface ProviderConfig {
apiKey: string;
@@ -71,12 +75,52 @@ export function createProvider(config: ProviderConfig): ModelFactory {
* (claude-pro, claude-max). Uses `authToken` to send `Authorization: Bearer`
* (natively supported by `@ai-sdk/anthropic` v3.x), and mimics Claude Code CLI
* request headers so the request bills against the user's Claude subscription.
+ *
+ * The `anthropic-beta` header is REQUIRED here. `@ai-sdk/anthropic` only emits
+ * an `anthropic-beta` header for betas it auto-derives from tool definitions
+ * (computer-use, structured-outputs, etc.) — it does NOT add the prompt-caching
+ * or oauth betas on its own. Without `prompt-caching-scope-2026-01-05` the API
+ * silently ignores every `cache_control` breakpoint we attach to messages,
+ * giving a 0% cache hit rate and a massive token burn (see claude-report.md).
+ * The SDK folds any `anthropic-beta` it finds on the provider's config headers
+ * back into its own beta set (via `getBetasFromHeaders`), so the values here
+ * are merged — not overwritten — with any tool-derived betas.
*/
function createClaudeOAuthProvider(config: ProviderConfig): ModelFactory {
+ // Stable per-provider session id — mirrors the Claude Code CLI, which sends
+ // the same `X-Claude-Code-Session-Id` across a session's requests.
+ const sessionId = randomUUID();
+ const baseFetch = globalThis.fetch;
+
+ // Custom fetch that (1) restructures the request body into the genuine
+ // Claude Code system layout — required for Anthropic to bill correctly and
+ // apply the prompt-cache scope (see anthropic-oauth-transform.ts) — and
+ // (2) stamps the Claude Code session/request id headers the real CLI sends.
+ // Cast through `unknown`: `FetchFunction` is `typeof globalThis.fetch`, whose
+ // (Bun) type carries a `preconnect` member a plain wrapper can't satisfy.
+ const oauthFetch = (async (
+ input: Parameters<FetchFunction>[0],
+ init?: Parameters<FetchFunction>[1],
+ ) => {
+ const nextInit: RequestInit = { ...init };
+ if (init?.body != null) {
+ nextInit.body = transformClaudeOAuthBody(init.body) ?? init.body;
+ }
+ const headers = new Headers(init?.headers);
+ headers.set("X-Claude-Code-Session-Id", sessionId);
+ if (!headers.has("x-client-request-id")) {
+ headers.set("x-client-request-id", randomUUID());
+ }
+ nextInit.headers = headers;
+ return baseFetch(input, nextInit);
+ }) as unknown as FetchFunction;
+
const anthropic = createAnthropic({
baseURL: config.baseURL || "https://api.anthropic.com/v1",
authToken: config.claudeCredentials?.accessToken ?? config.apiKey,
+ fetch: oauthFetch,
headers: {
+ "anthropic-beta": getAnthropicBetas().join(","),
"anthropic-dangerous-direct-browser-access": "true",
"x-app": "cli",
"user-agent": "claude-cli/2.1.112 (external, sdk-cli)",
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index 4148498..7e7460b 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -136,6 +136,24 @@ export type AgentEvent =
| { type: "tool-call"; toolCall: ToolCall }
| { type: "tool-result"; toolResult: ToolResult }
| { type: "shell-output"; data: string; stream: "stdout" | "stderr" }
+ /**
+ * Per-request token usage, emitted once per LLM round-trip (each
+ * `streamText` step) from the AI SDK `finish` stream event. `inputTokens`
+ * is the TOTAL prompt size including cached tokens; `cacheReadTokens` is
+ * the portion served from Anthropic's prompt cache (a cache HIT) and
+ * `cacheWriteTokens` the portion written to it (a cache seed). The "Cache
+ * Rate" view aggregates these to show the prompt-cache hit rate. Non-
+ * caching providers report zero for the cache fields.
+ */
+ | {
+ type: "usage";
+ usage: {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ };
+ }
| { type: "error"; error: string; statusCode?: number }
| { type: "notice"; message: string }
| { type: "model-changed"; keyId: string; modelId: string }
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts
index e0a999d..d6daac6 100644
--- a/packages/core/tests/agent/agent.test.ts
+++ b/packages/core/tests/agent/agent.test.ts
@@ -1028,4 +1028,290 @@ describe("Agent", () => {
const rc = assistantMsg?.providerOptions?.openaiCompatible?.reasoning_content;
expect(rc).toBeUndefined();
});
+
+ // ─── Prompt-caching: tool-result grouping & breakpoints (claude-report.md) ──
+
+ it("groups a turn's tool results into a SINGLE role:'tool' message (Root Cause 2)", async () => {
+ // The agent batches three distinct read_file calls in one step. The
+ // rebuilt ModelMessage[] must contain exactly ONE `role: "tool"` message
+ // holding all three results (not three separate tool messages). Per-
+ // result messages would strand the rolling cache breakpoints on the last
+ // two adjacent tool results, wasting a breakpoint.
+ const toolDef = {
+ name: "read_file",
+ description: "reads a file",
+ parameters: z.object({ path: z.string() }),
+ execute: async (args: Record<string, unknown>) => `contents of ${String(args.path)}`,
+ };
+ vi.mocked(streamText)
+ .mockReturnValueOnce(
+ makeMockStreamResult([
+ { type: "tool-call", toolCallId: "b1", toolName: "read_file", input: { path: "a.txt" } },
+ { type: "tool-call", toolCallId: "b2", toolName: "read_file", input: { path: "b.txt" } },
+ { type: "tool-call", toolCallId: "b3", toolName: "read_file", input: { path: "c.txt" } },
+ finishToolCalls,
+ ]),
+ )
+ .mockReturnValueOnce(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
+ );
+
+ const agent = new Agent(makeConfig({ provider: "opencode-anthropic", tools: [toolDef] }));
+ for await (const _ of agent.run("read three files")) {
+ /* consume */
+ }
+
+ // Inspect the step-1 request (sent after the batch executed) — its tail
+ // is the assistant tool-calls + the grouped tool results.
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
+
+ const toolMsgs = messages.filter((m) => m.role === "tool");
+ expect(toolMsgs).toHaveLength(1);
+ const toolContent = toolMsgs[0]?.content as Array<Record<string, unknown>>;
+ expect(toolContent).toHaveLength(3);
+ expect(toolContent.every((p) => p.type === "tool-result")).toBe(true);
+ // IDs preserved per result.
+ expect(toolContent.map((p) => p.toolCallId)).toEqual(["b1", "b2", "b3"]);
+ });
+
+ it("places cache breakpoints on [assistant, grouped-tool], not adjacent tool results (Root Cause 2)", async () => {
+ // With grouping, the last two non-system messages of a mid-turn request
+ // are [assistant(tool-calls), tool(all results)]. Both — plus the system
+ // message — must carry an ephemeral cacheControl marker. The pre-fix bug
+ // put both rolling breakpoints on two adjacent tool-result messages and
+ // never marked the assistant turn.
+ const toolDef = {
+ name: "read_file",
+ description: "reads a file",
+ parameters: z.object({ path: z.string() }),
+ execute: async (args: Record<string, unknown>) => `contents of ${String(args.path)}`,
+ };
+ vi.mocked(streamText)
+ .mockReturnValueOnce(
+ makeMockStreamResult([
+ { type: "tool-call", toolCallId: "c1", toolName: "read_file", input: { path: "a.txt" } },
+ { type: "tool-call", toolCallId: "c2", toolName: "read_file", input: { path: "b.txt" } },
+ { type: "tool-call", toolCallId: "c3", toolName: "read_file", input: { path: "c.txt" } },
+ finishToolCalls,
+ ]),
+ )
+ .mockReturnValueOnce(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
+ );
+
+ const agent = new Agent(makeConfig({ provider: "opencode-anthropic", tools: [toolDef] }));
+ for await (const _ of agent.run("read three files")) {
+ /* consume */
+ }
+
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{
+ role: string;
+ content: unknown;
+ providerOptions?: { anthropic?: { cacheControl?: { type?: string } } };
+ }>;
+
+ const isCached = (m?: {
+ providerOptions?: { anthropic?: { cacheControl?: { type?: string } } };
+ }) => m?.providerOptions?.anthropic?.cacheControl?.type === "ephemeral";
+
+ // Exactly one tool message — no adjacent tool-result breakpoints.
+ expect(messages.filter((m) => m.role === "tool")).toHaveLength(1);
+
+ const systemMsg = messages.find((m) => m.role === "system");
+ const assistantMsg = messages.find((m) => m.role === "assistant");
+ const toolMsg = messages.find((m) => m.role === "tool");
+
+ expect(isCached(systemMsg)).toBe(true);
+ expect(isCached(assistantMsg)).toBe(true);
+ expect(isCached(toolMsg)).toBe(true);
+ });
+
+ it("does NOT attach cacheControl for the openai-compatible (non-Anthropic) path", async () => {
+ // Sanity check: caching markers are Anthropic-only. The OpenAI-compatible
+ // endpoints do automatic server-side prefix caching and reject explicit
+ // cache_control, so no marker should be attached.
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "hi" }, finishStop]),
+ );
+ const agent = new Agent(makeConfig()); // default → openai-compatible
+ for await (const _ of agent.run("hello")) {
+ /* consume */
+ }
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{
+ role: string;
+ providerOptions?: { anthropic?: { cacheControl?: unknown } };
+ }>;
+ for (const m of messages) {
+ expect(m.providerOptions?.anthropic?.cacheControl).toBeUndefined();
+ }
+ });
+
+ // ─── Tool-call dedup (tool-runner-duplication-incident.md) ─────────────────
+
+ it("deduplicates byte-identical tool calls within a single batch", async () => {
+ // Claude can degenerate and emit the same tool call (same name + args)
+ // many times in one batch. Each copy keeps its own id (and still gets its
+ // own result), but the tool must execute only ONCE — re-running identical
+ // idempotent reads wastes time/money and floods the context.
+ let execCount = 0;
+ const toolDef = {
+ name: "read_file",
+ description: "reads a file",
+ parameters: z.object({ path: z.string() }),
+ execute: async (args: Record<string, unknown>) => {
+ execCount++;
+ return `contents of ${String(args.path)}`;
+ },
+ };
+ vi.mocked(streamText)
+ .mockReturnValueOnce(
+ makeMockStreamResult([
+ {
+ type: "tool-call",
+ toolCallId: "d1",
+ toolName: "read_file",
+ input: { path: "package.json" },
+ },
+ {
+ type: "tool-call",
+ toolCallId: "d2",
+ toolName: "read_file",
+ input: { path: "package.json" },
+ },
+ {
+ type: "tool-call",
+ toolCallId: "d3",
+ toolName: "read_file",
+ input: { path: "package.json" },
+ },
+ finishToolCalls,
+ ]),
+ )
+ .mockReturnValueOnce(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
+ );
+
+ const agent = new Agent(makeConfig({ tools: [toolDef] }));
+ const events: AgentEvent[] = [];
+ for await (const e of agent.run("read it thrice")) {
+ events.push(e);
+ }
+
+ // Executed exactly once despite three identical calls.
+ expect(execCount).toBe(1);
+
+ // Every call id still received its own result, all with identical content.
+ const results = events.filter(
+ (e): e is Extract<AgentEvent, { type: "tool-result" }> => e.type === "tool-result",
+ );
+ expect(results).toHaveLength(3);
+ expect(results.map((e) => e.toolResult.toolCallId).sort()).toEqual(["d1", "d2", "d3"]);
+ for (const r of results) {
+ expect(r.toolResult.result).toBe("contents of package.json");
+ }
+ });
+
+ it("does NOT deduplicate tool calls with differing arguments", async () => {
+ // Dedup is keyed on name + serialized arguments. Distinct args must each
+ // execute — only byte-identical calls collapse.
+ let execCount = 0;
+ const toolDef = {
+ name: "read_file",
+ description: "reads a file",
+ parameters: z.object({ path: z.string() }),
+ execute: async (args: Record<string, unknown>) => {
+ execCount++;
+ return `contents of ${String(args.path)}`;
+ },
+ };
+ vi.mocked(streamText)
+ .mockReturnValueOnce(
+ makeMockStreamResult([
+ { type: "tool-call", toolCallId: "e1", toolName: "read_file", input: { path: "a.txt" } },
+ { type: "tool-call", toolCallId: "e2", toolName: "read_file", input: { path: "b.txt" } },
+ { type: "tool-call", toolCallId: "e3", toolName: "read_file", input: { path: "a.txt" } },
+ finishToolCalls,
+ ]),
+ )
+ .mockReturnValueOnce(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
+ );
+
+ const agent = new Agent(makeConfig({ tools: [toolDef] }));
+ for await (const _ of agent.run("read a, b, a")) {
+ /* consume */
+ }
+
+ // a.txt + b.txt are distinct → two executions; the repeated a.txt reuses
+ // the first result.
+ expect(execCount).toBe(2);
+ });
+
+ // ─── Usage / cache-rate telemetry ──────────────────────────────────────────
+
+ it("emits a usage event from the finish-step part with the cache read/write split", async () => {
+ // The per-step `usage` (with Anthropic's cache read/write split in
+ // `inputTokenDetails`) rides on the `finish-step` part — NOT the terminal
+ // `finish` part, which only carries the aggregate `totalUsage`. The agent
+ // re-emits it as a `usage` AgentEvent that powers the Cache Rate view.
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([
+ { type: "text-delta", id: "t0", text: "hi" },
+ {
+ type: "finish-step",
+ finishReason: "stop",
+ rawFinishReason: "stop",
+ usage: {
+ inputTokens: 1000,
+ outputTokens: 50,
+ inputTokenDetails: {
+ noCacheTokens: 200,
+ cacheReadTokens: 750,
+ cacheWriteTokens: 50,
+ },
+ },
+ },
+ finishStop,
+ ]),
+ );
+
+ const agent = new Agent(makeConfig());
+ const events: AgentEvent[] = [];
+ for await (const e of agent.run("hi")) {
+ events.push(e);
+ }
+
+ const usageEvents = events.filter(
+ (e): e is Extract<AgentEvent, { type: "usage" }> => e.type === "usage",
+ );
+ // Exactly one usage event (from finish-step) — the terminal `finish`
+ // part must NOT double-count.
+ expect(usageEvents).toHaveLength(1);
+ expect(usageEvents[0]?.usage).toEqual({
+ inputTokens: 1000,
+ outputTokens: 50,
+ cacheReadTokens: 750,
+ cacheWriteTokens: 50,
+ });
+ });
+
+ it("does NOT emit a usage event when no finish-step usage is present", async () => {
+ // `finishStop` (type `finish`, aggregate `totalUsage` only) must not
+ // trigger a usage event — and with no `finish-step` part there is no
+ // per-step usage to emit.
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "hi" }, finishStop]),
+ );
+
+ const agent = new Agent(makeConfig());
+ const events: AgentEvent[] = [];
+ for await (const e of agent.run("hi")) {
+ events.push(e);
+ }
+
+ expect(events.some((e) => e.type === "usage")).toBe(false);
+ });
});
diff --git a/packages/core/tests/llm/anthropic-oauth-transform.test.ts b/packages/core/tests/llm/anthropic-oauth-transform.test.ts
new file mode 100644
index 0000000..a8bb156
--- /dev/null
+++ b/packages/core/tests/llm/anthropic-oauth-transform.test.ts
@@ -0,0 +1,137 @@
+import { describe, expect, it } from "vitest";
+import { transformClaudeOAuthBody } from "../../src/llm/anthropic-oauth-transform.js";
+
+const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
+const BILLING =
+ "x-anthropic-billing-header: cc_version=2.1.112.abc; cc_entrypoint=sdk-cli; cch=12345;";
+
+interface WireBody {
+ system?: Array<{ type: string; text: string; cache_control?: unknown }>;
+ messages?: Array<{ role: string; content: unknown }>;
+}
+
+/**
+ * Build the body shape Dispatch produces: ONE system block holding
+ * `<billing>\n<identity>\n\n<systemPrompt>`, marked with cache_control by
+ * `applyAnthropicCaching`.
+ */
+function dispatchBody(systemPrompt: string, firstUser = "hello"): string {
+ return JSON.stringify({
+ model: "claude-opus-4-8",
+ system: [
+ {
+ type: "text",
+ text: `${BILLING}\n${IDENTITY}\n\n${systemPrompt}`,
+ cache_control: { type: "ephemeral" },
+ },
+ ],
+ messages: [{ role: "user", content: firstUser }],
+ });
+}
+
+function parse(out: BodyInit | null | undefined): WireBody {
+ return JSON.parse(out as string) as WireBody;
+}
+
+describe("transformClaudeOAuthBody", () => {
+ it("isolates the billing header as system[0] WITHOUT cache_control", () => {
+ const result = parse(transformClaudeOAuthBody(dispatchBody("You are Dispatch. Use tools.")));
+ const sys = result.system ?? [];
+ expect(sys[0]?.text).toBe(BILLING);
+ expect(sys[0]?.cache_control).toBeUndefined();
+ });
+
+ it("keeps the identity as a separate system block and carries the cache_control there", () => {
+ const result = parse(transformClaudeOAuthBody(dispatchBody("You are Dispatch. Use tools.")));
+ const sys = result.system ?? [];
+ expect(sys[1]?.text).toBe(IDENTITY);
+ expect(sys[1]?.cache_control).toEqual({ type: "ephemeral" });
+ // Only billing + identity remain in system[] — the third-party prompt was moved out.
+ expect(sys).toHaveLength(2);
+ });
+
+ it("relocates the third-party system prompt into the first user message", () => {
+ const result = parse(
+ transformClaudeOAuthBody(dispatchBody("You are Dispatch. Use tools.", "do the thing")),
+ );
+ const sys = result.system ?? [];
+ // The system prompt must NOT appear anywhere in system[].
+ expect(sys.some((b) => b.text.includes("You are Dispatch"))).toBe(false);
+ // It is prepended to the first user message.
+ expect(result.messages?.[0]?.content).toBe("You are Dispatch. Use tools.\n\ndo the thing");
+ });
+
+ it("prepends a text block when the first user message uses array content", () => {
+ const body = JSON.stringify({
+ system: [
+ {
+ type: "text",
+ text: `${BILLING}\n${IDENTITY}\n\nDispatch instructions here.`,
+ cache_control: { type: "ephemeral" },
+ },
+ ],
+ messages: [{ role: "user", content: [{ type: "text", text: "user text" }] }],
+ });
+ const result = parse(transformClaudeOAuthBody(body));
+ const content = result.messages?.[0]?.content as Array<{ type: string; text: string }>;
+ expect(content[0]).toEqual({ type: "text", text: "Dispatch instructions here." });
+ expect(content[1]).toEqual({ type: "text", text: "user text" });
+ });
+
+ it("does not carry cache_control to the identity when the source had none", () => {
+ const body = JSON.stringify({
+ system: [{ type: "text", text: `${BILLING}\n${IDENTITY}\n\nInstr.` }],
+ messages: [{ role: "user", content: "hi" }],
+ });
+ const result = parse(transformClaudeOAuthBody(body));
+ expect(result.system?.[1]?.cache_control).toBeUndefined();
+ });
+
+ it("keeps the prompt as a cached system block when there is no user message", () => {
+ const body = JSON.stringify({
+ system: [
+ {
+ type: "text",
+ text: `${BILLING}\n${IDENTITY}\n\nInstr only.`,
+ cache_control: { type: "ephemeral" },
+ },
+ ],
+ messages: [],
+ });
+ const result = parse(transformClaudeOAuthBody(body));
+ const sys = result.system ?? [];
+ expect(sys[0]?.text).toBe(BILLING);
+ expect(sys[1]?.text).toBe(IDENTITY);
+ expect(sys[2]?.text).toBe("Instr only.");
+ expect(sys[2]?.cache_control).toEqual({ type: "ephemeral" });
+ });
+
+ it("leaves non-Claude-Code bodies (no identity string) untouched", () => {
+ const body = JSON.stringify({
+ system: [{ type: "text", text: "Some unrelated system prompt." }],
+ messages: [{ role: "user", content: "hi" }],
+ });
+ // Returned unchanged (same reference string, byte-identical).
+ expect(transformClaudeOAuthBody(body)).toBe(body);
+ });
+
+ it("returns non-string bodies unchanged", () => {
+ const buf = new Uint8Array([1, 2, 3]);
+ expect(transformClaudeOAuthBody(buf)).toBe(buf);
+ expect(transformClaudeOAuthBody(undefined)).toBeUndefined();
+ expect(transformClaudeOAuthBody(null)).toBeNull();
+ });
+
+ it("returns invalid JSON unchanged", () => {
+ const garbage = "{not json";
+ expect(transformClaudeOAuthBody(garbage)).toBe(garbage);
+ });
+
+ it("never emits more than the 4 cache_control breakpoints Anthropic allows", () => {
+ const result = parse(transformClaudeOAuthBody(dispatchBody("Big system prompt.")));
+ const all = result.system ?? [];
+ const cacheBlocks = all.filter((b) => b.cache_control != null);
+ // Only the identity block is marked here — well under the limit of 4.
+ expect(cacheBlocks.length).toBeLessThanOrEqual(4);
+ });
+});
diff --git a/packages/core/tests/llm/provider.test.ts b/packages/core/tests/llm/provider.test.ts
index 6171e6b..9e6b2ad 100644
--- a/packages/core/tests/llm/provider.test.ts
+++ b/packages/core/tests/llm/provider.test.ts
@@ -118,6 +118,94 @@ describe("createClaudeOAuthProvider", () => {
expect(callArgs.headers?.["user-agent"]).toMatch(/claude-cli/);
});
+ it("installs a fetch wrapper that restructures the body and stamps Claude Code session headers", async () => {
+ mockCreateAnthropic.mockClear();
+
+ // Capture what global fetch receives after the wrapper runs.
+ const globalFetchMock = vi.fn(async () => new Response("{}", { status: 200 }));
+ const prevFetch = globalThis.fetch;
+ globalThis.fetch = globalFetchMock as unknown as typeof fetch;
+ try {
+ createProvider({
+ provider: "anthropic",
+ apiKey: "test-key",
+ baseURL: "",
+ claudeCredentials: { accessToken: "tok" },
+ })("claude-opus-4-8");
+
+ const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as { fetch?: typeof fetch };
+ expect(typeof callArgs.fetch).toBe("function");
+
+ const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
+ const BILLING =
+ "x-anthropic-billing-header: cc_version=2.1.112.x; cc_entrypoint=sdk-cli; cch=abcde;";
+ const body = JSON.stringify({
+ system: [
+ {
+ type: "text",
+ text: `${BILLING}\n${IDENTITY}\n\nDispatch system prompt.`,
+ cache_control: { type: "ephemeral" },
+ },
+ ],
+ messages: [{ role: "user", content: "hi there" }],
+ });
+
+ await callArgs.fetch?.("https://api.anthropic.com/v1/messages", {
+ method: "POST",
+ body,
+ headers: { "content-type": "application/json" },
+ });
+
+ expect(globalFetchMock).toHaveBeenCalledOnce();
+ const [, init] = globalFetchMock.mock.calls[0] as unknown as [unknown, RequestInit];
+
+ // Body was restructured: billing isolated, third-party prompt moved to user msg.
+ const sent = JSON.parse(init.body as string) as {
+ system: Array<{ text: string; cache_control?: unknown }>;
+ messages: Array<{ content: string }>;
+ };
+ expect(sent.system).toHaveLength(2);
+ expect(sent.system[0]?.text).toBe(BILLING);
+ expect(sent.system[0]?.cache_control).toBeUndefined();
+ expect(sent.system[1]?.text).toBe(IDENTITY);
+ expect(sent.messages[0]?.content).toBe("Dispatch system prompt.\n\nhi there");
+
+ // Claude Code session headers were stamped.
+ const headers = new Headers(init.headers);
+ expect(headers.get("X-Claude-Code-Session-Id")).toBeTruthy();
+ expect(headers.get("x-client-request-id")).toBeTruthy();
+ } finally {
+ globalThis.fetch = prevFetch;
+ }
+ });
+
+ it("sends the anthropic-beta header so prompt-caching is honored (claude-report.md Root Cause 1)", () => {
+ // Without `anthropic-beta: ...,prompt-caching-scope-2026-01-05,...` the
+ // Anthropic API silently ignores every `cache_control` marker we attach
+ // to messages, producing a 0% cache hit rate. `@ai-sdk/anthropic` does
+ // NOT inject this beta on its own — it only derives betas from tool
+ // definitions — so the OAuth provider MUST set it on its config headers.
+ mockCreateAnthropic.mockClear();
+
+ createProvider({
+ provider: "anthropic",
+ apiKey: "test-key",
+ baseURL: "",
+ claudeCredentials: { accessToken: "tok" },
+ })("claude-opus-4-5");
+
+ const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<
+ string,
+ Record<string, string>
+ >;
+ const betaHeader = callArgs.headers?.["anthropic-beta"];
+ expect(betaHeader).toBeDefined();
+ const betas = (betaHeader ?? "").split(",").map((b) => b.trim());
+ // The load-bearing caching + oauth betas must be present.
+ expect(betas).toContain("prompt-caching-scope-2026-01-05");
+ expect(betas).toContain("oauth-2025-04-20");
+ });
+
it("uses default Anthropic baseURL when none provided", () => {
mockCreateAnthropic.mockClear();
diff --git a/packages/core/tests/tools/summon.test.ts b/packages/core/tests/tools/summon.test.ts
index 3909e48..722748c 100644
--- a/packages/core/tests/tools/summon.test.ts
+++ b/packages/core/tests/tools/summon.test.ts
@@ -118,7 +118,11 @@ describe("createSummonTool — execute() argument forwarding", () => {
[],
);
const out = await tool.execute({ task: "x" });
- expect(out).toBe("child-output");
+ // Foreground summons prefix the blocked result with `agent_id: <id>` so
+ // the frontend's ToolCallDisplay regex can surface the "Open Tab" button
+ // (see summon.ts). Assert both the prefix and the child output survive.
+ expect(out).toContain("agent_id: id-1");
+ expect(out).toBe("agent_id: id-1\n\nchild-output");
});
it("surfaces child errors when blocking", async () => {
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
index bc6cd02..1bae000 100644
--- a/packages/frontend/src/App.svelte
+++ b/packages/frontend/src/App.svelte
@@ -136,6 +136,8 @@ onMount(() => {
<SidebarPanel
keys={modelsData.keys}
tasks={tabStore.activeTab?.tasks ?? []}
+ cacheStats={tabStore.activeTab?.cacheStats ?? null}
+ cacheTabTitle={tabStore.activeTab?.title ?? null}
permissionLog={tabStore.permissionLog}
apiBase={config.apiBase}
activeKeyId={tabStore.activeTab?.keyId ?? null}
diff --git a/packages/frontend/src/lib/components/CacheRatePanel.svelte b/packages/frontend/src/lib/components/CacheRatePanel.svelte
new file mode 100644
index 0000000..c35cbb5
--- /dev/null
+++ b/packages/frontend/src/lib/components/CacheRatePanel.svelte
@@ -0,0 +1,129 @@
+<script lang="ts">
+import type { CacheStats } from "../types.js";
+
+const {
+ cacheStats = null,
+ tabTitle = null,
+}: {
+ cacheStats?: CacheStats | null;
+ tabTitle?: string | null;
+} = $props();
+
+// Cache hit rate = cached-read tokens / total prompt tokens. `inputTokens` is
+// the TOTAL prompt (fresh + cache read + cache write), so this is the share of
+// the prompt that was served from Anthropic's prompt cache.
+function rate(read: number, totalInput: number): number {
+ if (totalInput <= 0) return 0;
+ return Math.max(0, Math.min(1, read / totalInput));
+}
+
+// For caching, a HIGH hit rate is GOOD — invert the usual color thresholds.
+function rateClass(r: number): string {
+ if (r >= 0.7) return "progress-success";
+ if (r >= 0.3) return "progress-warning";
+ return "progress-error";
+}
+
+function fmt(n: number): string {
+ return n.toLocaleString();
+}
+
+const hitRate = $derived(cacheStats ? rate(cacheStats.cacheReadTokens, cacheStats.inputTokens) : 0);
+const hitPct = $derived(Math.round(hitRate * 100));
+const uncached = $derived(
+ cacheStats
+ ? Math.max(0, cacheStats.inputTokens - cacheStats.cacheReadTokens - cacheStats.cacheWriteTokens)
+ : 0,
+);
+const lastHitPct = $derived(
+ cacheStats?.last
+ ? Math.round(rate(cacheStats.last.cacheReadTokens, cacheStats.last.inputTokens) * 100)
+ : 0,
+);
+</script>
+
+<div class="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto">
+ {#if !cacheStats || cacheStats.requests === 0}
+ <p class="text-xs text-base-content/50">
+ No cache data yet. Send a message to a Claude model — prompt-cache usage
+ appears here after the first response.
+ </p>
+ {:else}
+ <div class="bg-base-200 rounded-lg p-2">
+ <div class="flex items-center gap-1.5 mb-2">
+ <span class="text-xs font-semibold">Cache Hit Rate</span>
+ {#if tabTitle}
+ <span class="badge badge-xs badge-ghost">{tabTitle}</span>
+ {/if}
+ <span class="badge badge-xs ml-auto">{cacheStats.requests} req</span>
+ </div>
+
+ <!-- Headline cumulative hit rate -->
+ <div class="flex flex-col gap-0.5">
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">Session (this tab)</span>
+ <span class="text-xs font-mono">{hitPct}%</span>
+ </div>
+ <progress
+ class="progress w-full h-2 {rateClass(hitRate)}"
+ value={hitPct}
+ max="100"
+ ></progress>
+ </div>
+
+ <!-- Most recent request -->
+ {#if cacheStats.last}
+ <div class="flex flex-col gap-0.5 mt-2">
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">Last request</span>
+ <span class="text-xs font-mono">{lastHitPct}%</span>
+ </div>
+ <progress
+ class="progress w-full h-2 {rateClass(lastHitPct / 100)}"
+ value={lastHitPct}
+ max="100"
+ ></progress>
+ </div>
+ {/if}
+ </div>
+
+ <!-- Token breakdown (cumulative, this tab) -->
+ <div class="bg-base-200 rounded-lg p-2">
+ <div class="text-xs font-semibold mb-1.5">Tokens (cumulative)</div>
+ <div class="flex flex-col gap-1 pl-1">
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">
+ <span class="badge badge-xs badge-success badge-soft mr-1">read</span>Cache hits
+ </span>
+ <span class="text-xs font-mono">{fmt(cacheStats.cacheReadTokens)}</span>
+ </div>
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">
+ <span class="badge badge-xs badge-warning badge-soft mr-1">write</span>Cache writes
+ </span>
+ <span class="text-xs font-mono">{fmt(cacheStats.cacheWriteTokens)}</span>
+ </div>
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">
+ <span class="badge badge-xs badge-error badge-soft mr-1">fresh</span>Uncached input
+ </span>
+ <span class="text-xs font-mono">{fmt(uncached)}</span>
+ </div>
+ <div class="border-t border-base-300 my-0.5"></div>
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">Total input</span>
+ <span class="text-xs font-mono">{fmt(cacheStats.inputTokens)}</span>
+ </div>
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">Output</span>
+ <span class="text-xs font-mono">{fmt(cacheStats.outputTokens)}</span>
+ </div>
+ </div>
+ </div>
+
+ <p class="text-xs text-base-content/40">
+ Cache reads cost ~10% of fresh input; writes cost ~25% more. A high hit
+ rate after the first turn means caching is working. Resets on reload.
+ </p>
+ {/if}
+</div>
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
index fca53b7..33b2158 100644
--- a/packages/frontend/src/lib/components/SidebarPanel.svelte
+++ b/packages/frontend/src/lib/components/SidebarPanel.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import { loadSidebarPanels, saveSidebarPanels } from "../sidebar-storage.js";
-import type { KeyInfo, LogEntry, TaskItem } from "../types.js";
+import type { CacheStats, KeyInfo, LogEntry, TaskItem } from "../types.js";
+import CacheRatePanel from "./CacheRatePanel.svelte";
import ClaudeReset from "./ClaudeReset.svelte";
import ConfigPanel from "./ConfigPanel.svelte";
import KeyUsage from "./KeyUsage.svelte";
@@ -23,6 +24,8 @@ interface AgentInfo {
const {
keys = [],
tasks = [],
+ cacheStats = null,
+ cacheTabTitle = null,
permissionLog = [],
apiBase = "",
activeKeyId = null,
@@ -41,6 +44,8 @@ const {
}: {
keys?: KeyInfo[];
tasks?: TaskItem[];
+ cacheStats?: CacheStats | null;
+ cacheTabTitle?: string | null;
permissionLog?: LogEntry[];
apiBase?: string;
activeKeyId?: string | null;
@@ -82,6 +87,7 @@ const viewOptions = [
"Select a view",
"Chat Settings",
"Key Usage",
+ "Cache Rate",
"Claude Reset",
"Model Status",
"Tasks",
@@ -97,12 +103,12 @@ function addPanel() {
function panelClass(selected: string): string {
const base = "bg-base-200 rounded-lg p-3 flex flex-col min-h-0";
- const fill = selected === "Key Usage" || selected === "Tasks";
+ const fill = selected === "Key Usage" || selected === "Tasks" || selected === "Cache Rate";
return fill ? `${base} flex-1` : base;
}
function contentClass(selected: string): string {
- const fill = selected === "Key Usage" || selected === "Tasks";
+ const fill = selected === "Key Usage" || selected === "Tasks" || selected === "Cache Rate";
return fill ? "mt-2 flex-1 min-h-0" : "mt-2";
}
</script>
@@ -156,6 +162,8 @@ function contentClass(selected: string): string {
/>
{:else if panel.selected === "Key Usage"}
<KeyUsage {keys} {apiBase} />
+ {:else if panel.selected === "Cache Rate"}
+ <CacheRatePanel {cacheStats} tabTitle={cacheTabTitle} />
{:else if panel.selected === "Claude Reset"}
<ClaudeReset {apiBase} />
{:else if panel.selected === "Model Status"}
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
index bb8f4cf..6e2d157 100644
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ b/packages/frontend/src/lib/tabs.svelte.ts
@@ -12,6 +12,7 @@ import { config } from "./config.js";
import { appSettings } from "./settings.svelte.js";
import type {
AgentEvent,
+ CacheStats,
ChatMessage,
Chunk,
DebugInfo,
@@ -89,6 +90,12 @@ export interface Tab {
oldestLoadedSeq: number | null;
/** Total number of messages for this tab on the backend */
totalMessages: number;
+ /**
+ * Cumulative prompt-cache token telemetry for this tab since the page
+ * loaded (in-memory only — resets on reload). Undefined until the first
+ * `usage` event arrives. Drives the "Cache Rate" sidebar view.
+ */
+ cacheStats?: CacheStats;
}
/**
@@ -846,6 +853,29 @@ export function createTabStore() {
applyChunkEvent(tabId, event);
break;
}
+ case "usage": {
+ if (!tabId) break;
+ const tab = getTabById(tabId);
+ if (!tab) break;
+ const u = event.usage;
+ const prev = tab.cacheStats;
+ updateTab(tabId, {
+ cacheStats: {
+ inputTokens: (prev?.inputTokens ?? 0) + u.inputTokens,
+ outputTokens: (prev?.outputTokens ?? 0) + u.outputTokens,
+ cacheReadTokens: (prev?.cacheReadTokens ?? 0) + u.cacheReadTokens,
+ cacheWriteTokens: (prev?.cacheWriteTokens ?? 0) + u.cacheWriteTokens,
+ requests: (prev?.requests ?? 0) + 1,
+ last: {
+ inputTokens: u.inputTokens,
+ outputTokens: u.outputTokens,
+ cacheReadTokens: u.cacheReadTokens,
+ cacheWriteTokens: u.cacheWriteTokens,
+ },
+ },
+ });
+ break;
+ }
case "done": {
if (!tabId) break;
const tab5 = getTabById(tabId);
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
index f9add94..8c34d69 100644
--- a/packages/frontend/src/lib/types.ts
+++ b/packages/frontend/src/lib/types.ts
@@ -12,6 +12,29 @@ export interface DebugInfo {
}
/**
+ * Per-tab prompt-cache telemetry, accumulated from the `usage` AgentEvent
+ * (one per LLM round-trip). Token counts are cumulative across the session
+ * since the page loaded; `last` holds the most recent request's split. Powers
+ * the "Cache Rate" sidebar view. The cache hit rate is
+ * `cacheReadTokens / inputTokens` (inputTokens is the TOTAL prompt, including
+ * cached tokens).
+ */
+export interface CacheStats {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ /** Number of LLM requests (usage events) counted. */
+ requests: number;
+ last: {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ } | null;
+}
+
+/**
* Mirror of the core `Chunk` union (see packages/core/src/types/index.ts).
*
* Wire-format symmetry MUST be kept with core. If you change one, change
@@ -119,6 +142,15 @@ export type AgentEvent =
type: "tool-result";
toolResult: { toolCallId: string; result: string; isError: boolean };
}
+ | {
+ type: "usage";
+ usage: {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ };
+ }
| { type: "error"; error: string }
| { type: "notice"; message: string }
| { type: "model-changed"; keyId: string; modelId: string }
diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts
index bafd79e..c485fc7 100644
--- a/packages/frontend/tests/chat-store.test.ts
+++ b/packages/frontend/tests/chat-store.test.ts
@@ -1050,3 +1050,50 @@ describe("handleEvent statuses with TabStatusSnapshot", () => {
expect(msgA?.isStreaming).toBe(false);
});
});
+
+describe("tabStore — cache rate (usage events)", () => {
+ it("accumulates usage events into per-tab cacheStats and tracks the last request", async () => {
+ const { store, tabId } = await setupStoreWithTab();
+
+ // No usage yet.
+ expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toBeUndefined();
+
+ // First request: mostly a cache write (cold prefix).
+ store.handleEvent({
+ type: "usage",
+ tabId,
+ usage: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 },
+ });
+ // Second request: mostly a cache hit.
+ store.handleEvent({
+ type: "usage",
+ tabId,
+ usage: { inputTokens: 1200, outputTokens: 60, cacheReadTokens: 1000, cacheWriteTokens: 100 },
+ });
+
+ const stats = store.tabs.find((t) => t.id === tabId)?.cacheStats;
+ expect(stats).toBeDefined();
+ expect(stats?.requests).toBe(2);
+ // Cumulative totals.
+ expect(stats?.inputTokens).toBe(2200);
+ expect(stats?.outputTokens).toBe(100);
+ expect(stats?.cacheReadTokens).toBe(1000);
+ expect(stats?.cacheWriteTokens).toBe(1000);
+ // `last` reflects only the most recent request.
+ expect(stats?.last).toEqual({
+ inputTokens: 1200,
+ outputTokens: 60,
+ cacheReadTokens: 1000,
+ cacheWriteTokens: 100,
+ });
+ });
+
+ it("ignores usage events with no tabId", async () => {
+ const { store } = await setupStoreWithTab();
+ store.handleEvent({
+ type: "usage",
+ usage: { inputTokens: 10, outputTokens: 1, cacheReadTokens: 5, cacheWriteTokens: 0 },
+ });
+ expect(store.tabs[0]?.cacheStats).toBeUndefined();
+ });
+});