Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 31 additions & 2 deletions packages/ai/src/api/anthropic-messages.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Anthropic from "@anthropic-ai/sdk";
import type {
BetaStopReason,
BetaTextBlockParam,
BetaThinkingDroppedInputTransformation,
BetaTool,
BetaCacheControlEphemeral as CacheControlEphemeral,
Expand Down Expand Up @@ -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<BetaTextBlockParam> {
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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 } : {}),
},
];
Expand Down
18 changes: 18 additions & 0 deletions packages/ai/src/changes.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 10 additions & 6 deletions packages/ai/test/anthropic-oauth-claude-code-version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,20 @@ vi.mock("@anthropic-ai/sdk", () => {
mockState.constructorOptions = options;
}

messages = {
create: () => ({
asResponse: async () => createSseResponse(),
}),
beta = {
messages: {
create: () => ({
asResponse: async () => createSseResponse(),
}),
},
};
}

return { default: FakeAnthropic };
});

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])];
}
Expand All @@ -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");
Expand Down
262 changes: 262 additions & 0 deletions packages/ai/test/anthropic-oauth-fingerprint.test.ts
Original file line number Diff line number Diff line change
@@ -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<CapturedRequest[]> {
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<string, string> {
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);
}
},
);
});
Loading