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/tests/llm | |
| 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/tests/llm')
| -rw-r--r-- | packages/core/tests/llm/anthropic-oauth-transform.test.ts | 137 | ||||
| -rw-r--r-- | packages/core/tests/llm/provider.test.ts | 88 |
2 files changed, 225 insertions, 0 deletions
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(); |
