summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/llm
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/llm
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/llm')
-rw-r--r--packages/core/src/llm/anthropic-oauth-transform.ts153
-rw-r--r--packages/core/src/llm/provider.ts44
2 files changed, 197 insertions, 0 deletions
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)",