summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-30 18:53:42 +0900
committerAdam Malczewski <[email protected]>2026-05-30 18:53:42 +0900
commit2228691e14be2368394e38e600bfa2ce227487b1 (patch)
treed6b3cfffd11dc9f7eec8c88f5fc97a7ec81e61a0 /packages/core/src
parent497b397e873f96d6fde3d8a44b3318e1ee1cbef4 (diff)
downloaddispatch-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.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
7 files changed, 249 insertions, 18 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 }