From 770262f08874e7e4403edd9aee82e4d32f523033 Mon Sep 17 00:00:00 2001 From: chupe Date: Sat, 5 Sep 2026 21:49:46 +0200 Subject: [PATCH] fix(ai): include native Anthropic OAuth billing fingerprint --- packages/ai/CHANGELOG.md | 2 + packages/ai/src/api/anthropic-messages.ts | 33 ++- packages/ai/src/changes.md | 18 ++ ...nthropic-oauth-claude-code-version.test.ts | 16 +- .../test/anthropic-oauth-fingerprint.test.ts | 262 ++++++++++++++++++ packages/coding-agent/CHANGELOG.md | 2 + 6 files changed, 325 insertions(+), 8 deletions(-) create mode 100644 packages/ai/test/anthropic-oauth-fingerprint.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1d8c5a31c7..15c7892f97 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- Fixed native Anthropic OAuth requests missing the deterministic billing fingerprint and external CLI/Agent SDK identity, while preserving API-key behavior and prompt-cache metadata. + ### Removed ## [2026.9.5-3] - 2026-09-05 diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 2cb3a92419..3b0440822e 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -1,6 +1,7 @@ import Anthropic from "@anthropic-ai/sdk"; import type { BetaStopReason, + BetaTextBlockParam, BetaThinkingDroppedInputTransformation, BetaTool, BetaCacheControlEphemeral as CacheControlEphemeral, @@ -119,6 +120,27 @@ function getCacheControl( // Stealth mode: Mimic Claude Code's tool naming exactly const claudeCodeVersion = "2.1.251"; +async function buildOAuthBillingBlock(messages: MessageParam[]): Promise { + const firstUser = messages.find((message) => message.role === "user"); + const text = + typeof firstUser?.content === "string" + ? firstUser.content + : (firstUser?.content.find((block) => block.type === "text")?.text ?? ""); + // Claude Code samples UTF-16 positions from the first serialized user text block. + const chars = [4, 7, 20].map((index) => text[index] || "0").join(""); + const encoder = new TextEncoder(); + const hashes = await Promise.all( + [`59cf53e54c78${chars}${claudeCodeVersion}`, text].map(async (value) => { + const digest = await globalThis.crypto.subtle.digest("SHA-256", encoder.encode(value)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + }), + ); + return { + type: "text", + text: `x-anthropic-billing-header: cc_version=${claudeCodeVersion}.${hashes[0].slice(0, 3)}; cc_entrypoint=sdk-cli; cch=${hashes[1].slice(0, 5)};`, + }; +} + // Claude Code 2.x tool names (canonical casing) // Source: https://cchistory.mariozechner.at/data/prompts-2.1.11.md // To update: https://github.com/badlogic/cchistory @@ -1372,6 +1394,13 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( : getAnthropicCompat(model).unsignedThinkingReplay; const createRequest = async (): Promise<{ params: MessageCreateParamsStreaming; response: Response }> => { let params = buildParams(model, context, isOAuth, options, unsignedThinkingReplay); + if (isOAuth) { + // buildParams always supplies an identity/system block array for native OAuth. + params.system = [ + await buildOAuthBillingBlock(params.messages), + ...(params.system as BetaTextBlockParam[]), + ]; + } const nextParams = await options?.onPayload?.(params, model); if (nextParams !== undefined) { params = nextParams as MessageCreateParamsStreaming; @@ -1984,7 +2013,7 @@ function createClient( accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", "anthropic-beta": ["claude-code-20250219", "oauth-2025-04-20", ...betaFeatures].join(","), - "user-agent": `claude-cli/${claudeCodeVersion}`, + "user-agent": `claude-cli/${claudeCodeVersion} (external, cli)`, "x-app": "cli", }, model.headers, @@ -2161,7 +2190,7 @@ function buildParams( params.system = [ { type: "text", - text: "You are Claude Code, Anthropic's official CLI for Claude.", + text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", ...(!context.systemPrompt && cacheControl ? { cache_control: cacheControl } : {}), }, ]; diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index 41d39a530c..a0ba3f6fc7 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -1,4 +1,22 @@ +## 2026-09-05 - Native Anthropic OAuth request fingerprint + +### What changed + +- `packages/ai/src/api/anthropic-messages.ts`: native OAuth requests advertise the external CLI user-agent suffix and Agent SDK identity, and prepend a deterministic billing block derived from the first serialized user text block. Private Web Crypto SHA-256 hashing retains the existing Claude Code 2.1.251 floor, caller system/cache metadata, payload hooks, and retry behavior. API-key, Cloudflare, and Copilot paths are unchanged. + +### Why + +- `packages/ai/src/api/anthropic-messages.ts`: native OAuth requests lacked the billing fingerprint and external CLI identity used by the subscription request path, causing request rejection despite advertising a supported client version. + +### Why an extension could not handle it + +- `packages/ai/src/api/anthropic-messages.ts`: native OAuth detection, SDK client headers, serialized first-user input, and retry payload construction are adapter-owned; an extension cannot supply a consistent default for all native callers. + +### Expected merge conflict zones + +- LOW: `packages/ai/src/api/anthropic-messages.ts` around `claudeCodeVersion`, `createClient`, OAuth system blocks in `buildParams`, and pre-hook request preparation in `createRequest`. + ## 2026-09-05 - Project Astra configuration updates at the Responses wire ### What changed diff --git a/packages/ai/test/anthropic-oauth-claude-code-version.test.ts b/packages/ai/test/anthropic-oauth-claude-code-version.test.ts index 746ccf2fec..97d7908a2b 100644 --- a/packages/ai/test/anthropic-oauth-claude-code-version.test.ts +++ b/packages/ai/test/anthropic-oauth-claude-code-version.test.ts @@ -41,10 +41,12 @@ vi.mock("@anthropic-ai/sdk", () => { mockState.constructorOptions = options; } - messages = { - create: () => ({ - asResponse: async () => createSseResponse(), - }), + beta = { + messages: { + create: () => ({ + asResponse: async () => createSseResponse(), + }), + }, }; } @@ -52,7 +54,7 @@ vi.mock("@anthropic-ai/sdk", () => { }); function parseClaudeCliVersion(userAgent: string | null | undefined): readonly [number, number, number] { - const match = /^claude-cli\/(\d+)\.(\d+)\.(\d+)$/.exec(userAgent ?? ""); + const match = /^claude-cli\/(\d+)\.(\d+)\.(\d+) \(external, cli\)$/.exec(userAgent ?? ""); if (!match) throw new Error(`user-agent is not a claude-cli version: ${String(userAgent)}`); return [Number(match[1]), Number(match[2]), Number(match[3])]; } @@ -77,7 +79,9 @@ describe("Anthropic OAuth Claude Code identity headers", () => { const model = getModel("anthropic", "claude-sonnet-4-5"); const stream = streamAnthropic(model, context, { apiKey: "sk-ant-oat01-test-token" }); - await stream.result(); + const result = await stream.result(); + expect(result.errorMessage).toBeUndefined(); + expect(result.stopReason).toBe("stop"); const userAgent = mockState.constructorOptions?.defaultHeaders?.["user-agent"]; expect(mockState.constructorOptions?.defaultHeaders?.["x-app"]).toBe("cli"); diff --git a/packages/ai/test/anthropic-oauth-fingerprint.test.ts b/packages/ai/test/anthropic-oauth-fingerprint.test.ts new file mode 100644 index 0000000000..4f0f23bc44 --- /dev/null +++ b/packages/ai/test/anthropic-oauth-fingerprint.test.ts @@ -0,0 +1,262 @@ +import type { + BetaTextBlockParam, + MessageCreateParamsStreaming, +} from "@anthropic-ai/sdk/resources/beta/messages/messages.js"; +import { describe, expect, it } from "vitest"; +import type { AnthropicOptions } from "../src/api/anthropic-messages.ts"; +import { streamAnthropic } from "../src/providers/anthropic.ts"; +import type { Context, Model, UserMessage } from "../src/types.ts"; +import { getPiUserAgent } from "../src/utils/pi-user-agent.ts"; + +const model: Model<"anthropic-messages"> = { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 4096, +}; + +function user(content: UserMessage["content"]): UserMessage { + return { role: "user", content, timestamp: 0 }; +} + +function sseResponse(): Response { + const events = [ + { + type: "message_start", + message: { id: "msg_test", model: model.id, usage: { input_tokens: 1, output_tokens: 0 } }, + }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ]; + return new Response(events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }); +} + +interface CapturedRequest { + body: MessageCreateParamsStreaming; + headers: Headers; +} + +async function capture( + context: Context, + options: AnthropicOptions = {}, + requestModel = model, + rejectForcedToolChoice = false, +): Promise { + const requests: CapturedRequest[] = []; + // Keep the real SDK and provider path; only the final HTTP transport is replaced. + const fetch: typeof globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + expect(request.method).toBe("POST"); + expect(new URL(request.url).pathname).toBe("/v1/messages"); + requests.push({ body: (await request.json()) as MessageCreateParamsStreaming, headers: request.headers }); + if (rejectForcedToolChoice && requests.length === 1) { + return Response.json( + { type: "error", error: { type: "invalid_request_error", message: "tool_choice is not supported" } }, + { status: 400 }, + ); + } + return sseResponse(); + }; + const result = await streamAnthropic(requestModel, context, { + apiKey: "sk-ant-oat01-offline-fixture", + cacheRetention: "none", + maxRetries: 0, + ...options, + fetch, + }).result(); + expect(result.errorMessage).toBeUndefined(); + expect(result.stopReason).toBe("stop"); + expect(requests).toHaveLength(rejectForcedToolChoice ? 2 : 1); + return requests; +} + +function systemBlocks(body: MessageCreateParamsStreaming): BetaTextBlockParam[] { + if (!Array.isArray(body.system)) throw new Error("Expected system blocks"); + return body.system; +} + +function billingFields(body: MessageCreateParamsStreaming): Record { + const block = systemBlocks(body)[0]; + expect(block.type).toBe("text"); + expect(block.cache_control).toBeUndefined(); + const match = + /^x-anthropic-billing-header: cc_version=(\d+\.\d+\.\d+\.[a-f0-9]{3}); cc_entrypoint=([^;]+); cch=([a-f0-9]{5});$/.exec( + block.text, + ); + expect(match).not.toBeNull(); + return { cc_version: match![1], cc_entrypoint: match![2], cch: match![3] }; +} + +describe("Anthropic native OAuth request fingerprint", () => { + // Golden SHA-256 prefixes from the local patch's salt, UTF-8 input and 2.1.251 version. + const cases: { name: string; content: UserMessage["content"]; suffix: string; cch: string }[] = [ + { name: "string", content: "First user fingerprint input.", suffix: "050", cch: "e1cb1" }, + { + name: "text blocks (first block only)", + content: [ + { type: "text", text: "First text block fingerprint." }, + { type: "text", text: "Ignored second block" }, + ], + suffix: "02e", + cch: "7fd95", + }, + { + name: "image before text", + content: [ + { type: "image", data: "AA==", mimeType: "image/png" }, + { type: "text", text: "First text block fingerprint." }, + ], + suffix: "02e", + cch: "7fd95", + }, + { name: "short text", content: "Hi", suffix: "76b", cch: "3639e" }, + { name: "empty string", content: "", suffix: "76b", cch: "e3b0c" }, + { name: "empty text blocks", content: [{ type: "text", text: "" }], suffix: "76b", cch: "e3b0c" }, + { + name: "image only", + content: [{ type: "image", data: "AA==", mimeType: "image/png" }], + suffix: "76b", + cch: "e3b0c", + }, + ]; + it.each(cases)("fingerprints $name at the final fetch", async ({ content, suffix, cch }) => { + const [request] = await capture({ messages: [user(content)] }); + expect(billingFields(request.body)).toEqual({ cc_version: `2.1.251.${suffix}`, cc_entrypoint: "sdk-cli", cch }); + expect(request.headers.get("user-agent")).toBe("claude-cli/2.1.251 (external, cli)"); + expect(request.headers.get("x-app")).toBe("cli"); + expect( + request.headers + .get("anthropic-beta") + ?.split(",") + .map((value) => value.trim()), + ).toContain("oauth-2025-04-20"); + expect(request.headers.has("authorization")).toBe(true); + expect(request.headers.has("x-api-key")).toBe(false); + expect(systemBlocks(request.body)).toHaveLength(2); + }); + + it("is deterministic across later input and uses the first serialized user message", async () => { + const [first] = await capture({ messages: [user("First user fingerprint input.")] }); + const [later] = await capture({ + messages: [user("First user fingerprint input."), user("Later visible user input.")], + }); + const [changed] = await capture({ messages: [user(""), user("Changed first user input.")] }); + expect(billingFields(later.body)).toEqual(billingFields(first.body)); + expect(billingFields(changed.body)).toEqual({ + cc_version: "2.1.251.ee3", + cc_entrypoint: "sdk-cli", + cch: "2ed02", + }); + }); + + it.each(["none", "short", "long"] as const)( + "preserves caller system and cache metadata with %s retention", + async (cacheRetention) => { + const context: Context = { + systemPrompt: "Caller-owned system", + messages: [user("First user fingerprint input.")], + }; + const original = structuredClone(context); + const [request] = await capture(context, { cacheRetention, metadata: { user_id: "offline-user" } }); + const cacheControl = + cacheRetention === "none" + ? undefined + : { type: "ephemeral", ...(cacheRetention === "long" ? { ttl: "1h" } : {}) }; + const blocks = systemBlocks(request.body); + expect(billingFields(request.body).cch).toBe("e1cb1"); + expect(blocks).toHaveLength(3); + expect(blocks[1].cache_control).toBeUndefined(); + expect(blocks[2]).toEqual({ + type: "text", + text: context.systemPrompt, + ...(cacheControl ? { cache_control: cacheControl } : {}), + }); + expect(request.body.metadata).toEqual({ user_id: "offline-user" }); + if (cacheControl) + expect(request.body.messages[0].content).toEqual([ + { type: "text", text: "First user fingerprint input.", cache_control: cacheControl }, + ]); + expect(context).toEqual(original); + }, + ); + + it("retains the identity cache checkpoint when no caller system prompt exists", async () => { + const [request] = await capture({ messages: [user("Hi")] }, { cacheRetention: "short" }); + const blocks = systemBlocks(request.body); + expect(billingFields(request.body).cch).toBe("3639e"); + expect(blocks).toHaveLength(2); + expect(blocks[1].cache_control).toEqual({ type: "ephemeral" }); + }); + + it("leaves payload-hook system blocks and their metadata intact", async () => { + const callerBlock: BetaTextBlockParam = { + type: "text", + text: "Hook-owned system", + cache_control: { type: "ephemeral", ttl: "1h" }, + citations: [], + }; + const original = structuredClone(callerBlock); + const [request] = await capture( + { messages: [user("Hi")] }, + { + onPayload: (payload) => { + const params = payload as MessageCreateParamsStreaming; + expect(billingFields(params).cc_entrypoint).toBe("sdk-cli"); + return { ...params, system: [...systemBlocks(params), callerBlock] }; + }, + }, + ); + expect(systemBlocks(request.body)[2]).toEqual(original); + expect(callerBlock).toEqual(original); + }); + + it("preserves the fingerprint through forced-tool-choice fallback without rerunning the hook", async () => { + let hookCalls = 0; + const requests = await capture( + { messages: [user("Hi")] }, + { + toolChoice: "any", + onPayload: () => { + hookCalls++; + }, + }, + model, + true, + ); + expect(requests[0].body.tool_choice).toEqual({ type: "any" }); + expect(requests[1].body.tool_choice).toBeUndefined(); + expect(billingFields(requests[1].body)).toEqual(billingFields(requests[0].body)); + expect(requests[1].body.system).toEqual(requests[0].body.system); + expect(hookCalls).toBe(1); + }); + + it.each(["anthropic", "cloudflare-ai-gateway", "github-copilot"])( + "does not add OAuth fingerprint fields to the %s non-native path", + async (provider) => { + const [request] = await capture( + { systemPrompt: "Caller-owned system", messages: [user("Hi")] }, + { + apiKey: provider === "anthropic" ? "sk-ant-api03-offline-fixture" : "sk-ant-oat01-offline-fixture", + }, + { ...model, provider }, + ); + expect(request.body.system).toEqual([{ type: "text", text: "Caller-owned system" }]); + expect(request.headers.get("user-agent")).not.toContain("claude-cli/"); + expect(request.headers.has("x-app")).toBe(false); + expect(request.headers.get("anthropic-beta")).not.toContain("oauth-2025-04-20"); + if (provider === "anthropic") { + expect(request.headers.get("user-agent")).toBe(getPiUserAgent()); + expect(request.headers.has("x-api-key")).toBe(true); + expect(request.headers.has("authorization")).toBe(false); + } + }, + ); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1ef92f4c3e..72d75ce9f7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- Fixed native Anthropic OAuth requests missing the deterministic billing fingerprint and external CLI/Agent SDK identity, while preserving API-key behavior and prompt-cache metadata. + ### Removed ## [2026.9.5-3] - 2026-09-05