diff options
| -rw-r--r-- | packages/core/src/agent/agent.ts | bin | 48635 -> 54066 bytes | |||
| -rw-r--r-- | packages/core/src/chunks/append.ts | 7 | ||||
| -rw-r--r-- | packages/core/src/credentials/anthropic-betas.ts | 24 | ||||
| -rw-r--r-- | packages/core/src/credentials/claude.ts | 21 | ||||
| -rw-r--r-- | packages/core/src/llm/anthropic-oauth-transform.ts | 153 | ||||
| -rw-r--r-- | packages/core/src/llm/provider.ts | 44 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 18 | ||||
| -rw-r--r-- | packages/core/tests/agent/agent.test.ts | 286 | ||||
| -rw-r--r-- | packages/core/tests/llm/anthropic-oauth-transform.test.ts | 137 | ||||
| -rw-r--r-- | packages/core/tests/llm/provider.test.ts | 88 | ||||
| -rw-r--r-- | packages/core/tests/tools/summon.test.ts | 6 | ||||
| -rw-r--r-- | packages/frontend/src/App.svelte | 2 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/CacheRatePanel.svelte | 129 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SidebarPanel.svelte | 14 | ||||
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 30 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 32 | ||||
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 47 |
17 files changed, 1016 insertions, 22 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts Binary files differindex 82b98df..439b436 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts 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(); + }); +}); |
