summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests/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/tests/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/tests/llm')
-rw-r--r--packages/core/tests/llm/anthropic-oauth-transform.test.ts137
-rw-r--r--packages/core/tests/llm/provider.test.ts88
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();