diff options
| author | Adam Malczewski <[email protected]> | 2026-05-30 18:53:42 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-30 18:53:42 +0900 |
| commit | 2228691e14be2368394e38e600bfa2ce227487b1 (patch) | |
| tree | d6b3cfffd11dc9f7eec8c88f5fc97a7ec81e61a0 /packages/core/src | |
| parent | 497b397e873f96d6fde3d8a44b3318e1ee1cbef4 (diff) | |
| download | dispatch-2228691e14be2368394e38e600bfa2ce227487b1.tar.gz dispatch-2228691e14be2368394e38e600bfa2ce227487b1.zip | |
feat(cache): Anthropic prompt caching, usage telemetry, and Cache Rate view
- send prompt-caching + oauth anthropic-beta headers on the Claude OAuth provider
- restructure the OAuth request body (billing header, identity split, relocate
third-party system prompt to the first user message) to match Claude Code
- apply rolling cache_control breakpoints and group a turn's tool results into a
single role:tool message for correct breakpoint placement
- emit per-step usage events (cache read/write split) and add the Cache Rate
sidebar panel
- dedup byte-identical tool calls within a single batch
Diffstat (limited to 'packages/core/src')
| -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 |
7 files changed, 249 insertions, 18 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 } |
