feat(ai,coding-agent): Claude fingerprint hardening
providers/anthropic: - Bump claudeCodeVersion to 2.1.63; system instruction identifies as Claude Agent SDK - X-Stainless-Os and X-Stainless-Arch now runtime-computed via mapStainlessOs/mapStainlessArch - Remove X-Stainless-Helper-Method; update package version to 0.74.0, runtime to v24.3.0 - Remove fine-grained-tool-streaming-2025-05-14 from default beta set; add context-management-2025-06-27 and prompt-caching-scope-2026-01-05 - Accept-Encoding updated to 'gzip, deflate, br, zstd' - Inject x-anthropic-billing-header block (SHA-256 payload fingerprint) and Claude Agent SDK identity block with ephemeral 1h cache-control for OAuth requests - Auto-generate cloaking user IDs for OAuth metadata.user_id when absent/invalid - applyClaudeToolPrefix / stripClaudeToolPrefix skip Anthropic built-in tool names - buildClaudeCodeTlsFetchOptions attaches SNI + default TLS ciphers for api.anthropic.com - Non-Anthropic base URLs now use Bearer auth regardless of OAuth status - Prompt-caching no longer strips then re-applies; skips if blocks already have cache_control oauth/anthropic: - Token URL changed from platform.claude.com to api.anthropic.com - OAuth scopes trimmed to org:create_api_key user:profile user:inference - Code exchange strips URL fragment from callback code (fragment used as state override) - AnthropicOAuthFlow exported - OAuth callback server timeout extended from 2 min to 5 min usage/claude: - user-agent updated to claude-cli/2.1.63 (external, cli) - anthropic-beta header extended with full production beta set coding-agent web search: - Anthropic provider uses buildAnthropicSearchHeaders instead of buildAnthropicHeaders Tests: anthropic-alignment, anthropic-oauth, claude-usage-headers, web-search-anthropic
This commit is contained in:
@@ -5,10 +5,30 @@
|
||||
### Added
|
||||
|
||||
- `hasUnrepresentableStrictObjectMap()` pre-flight check in `tryEnforceStrictSchema`: schemas with `patternProperties` or schema-valued `additionalProperties` now degrade gracefully to non-strict mode instead of throwing during enforcement
|
||||
- `generateClaudeCloakingUserId()` generates structured user IDs for Anthropic OAuth metadata (`user_{hex64}_account_{uuid}_session_{uuid}`)
|
||||
- `isClaudeCloakingUserId()` validates whether a string matches the cloaking user-ID format
|
||||
- `mapStainlessOs()` and `mapStainlessArch()` map `process.platform`/`process.arch` to Stainless header values; X-Stainless-Os and X-Stainless-Arch in `claudeCodeHeaders` are now runtime-computed
|
||||
- `buildClaudeCodeTlsFetchOptions()` attaches SNI and default TLS ciphers for direct `api.anthropic.com` connections
|
||||
- `createClaudeBillingHeader()` generates the `x-anthropic-billing-header` block (SHA-256 payload fingerprint + random build hash)
|
||||
- `buildAnthropicSystemBlocks()` now injects a billing header block and the Claude Agent SDK identity block with `ephemeral` 1h cache-control when `includeClaudeCodeInstruction` is set
|
||||
- `resolveAnthropicMetadataUserId()` auto-generates a cloaking user ID for OAuth requests when `metadata.user_id` is absent or invalid
|
||||
- `AnthropicOAuthFlow` is now exported for direct use
|
||||
- OAuth callback server timeout extended from 2 min to 5 min
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended `ANTHROPIC_OAUTH_BETA` constant in the OpenAI-compat Anthropic route with `interleaved-thinking-2025-05-14`, `context-management-2025-06-27`, and `prompt-caching-scope-2026-01-05` beta flags
|
||||
- `claudeCodeVersion` bumped to `2.1.63`; `claudeCodeSystemInstruction` updated to identify as Claude Agent SDK
|
||||
- `claudeCodeHeaders`: removed `X-Stainless-Helper-Method`, updated package version to `0.74.0`, runtime version to `v24.3.0`
|
||||
- `applyClaudeToolPrefix` / `stripClaudeToolPrefix` now accept an optional prefix override and skip Anthropic built-in tool names (`web_search`, `code_execution`, `text_editor`, `computer`)
|
||||
- Accept-Encoding header updated to `gzip, deflate, br, zstd`
|
||||
- Non-Anthropic base URLs now receive `Authorization: Bearer` regardless of OAuth status
|
||||
- Prompt-caching logic now skips applying breakpoints when any block already carries `cache_control`, instead of stripping then re-applying
|
||||
- `fine-grained-tool-streaming-2025-05-14` removed from default beta set
|
||||
- Anthropic OAuth token URL changed from `platform.claude.com` to `api.anthropic.com`
|
||||
- Anthropic OAuth scopes reduced to `org:create_api_key user:profile user:inference`
|
||||
- OAuth code exchange now strips URL fragment from callback code, using the fragment as state override when present
|
||||
- Claude usage headers aligned: user-agent updated to `claude-cli/2.1.63 (external, cli)`, anthropic-beta extended with full beta set
|
||||
|
||||
## [13.3.14] - 2026-02-28
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import * as nodeCrypto from "node:crypto";
|
||||
import * as tls from "node:tls";
|
||||
import Anthropic, { type ClientOptions as AnthropicSdkClientOptions } from "@anthropic-ai/sdk";
|
||||
import type {
|
||||
ContentBlockParam,
|
||||
MessageCreateParamsStreaming,
|
||||
@@ -64,14 +66,42 @@ const claudeCodeBetaDefaults = [
|
||||
"claude-code-20250219",
|
||||
"oauth-2025-04-20",
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"context-management-2025-06-27",
|
||||
"prompt-caching-scope-2026-01-05",
|
||||
];
|
||||
function getHeaderCaseInsensitive(headers: Record<string, string> | undefined, headerName: string): string | undefined {
|
||||
if (!headers) return undefined;
|
||||
const normalizedName = headerName.toLowerCase();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === normalizedName) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isClaudeCodeClientUserAgent(userAgent: string | undefined): userAgent is string {
|
||||
if (!userAgent) return false;
|
||||
return userAgent.toLowerCase().startsWith("claude-cli");
|
||||
}
|
||||
|
||||
function isAnthropicApiBaseUrl(baseUrl?: string): boolean {
|
||||
if (!baseUrl) return true;
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
return url.protocol.toLowerCase() === "https:" && url.hostname.toLowerCase() === "api.anthropic.com";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<string, string> {
|
||||
const oauthToken = options.isOAuth ?? isAnthropicOAuthToken(options.apiKey);
|
||||
const extraBetas = options.extraBetas ?? [];
|
||||
const stream = options.stream ?? false;
|
||||
const betaHeader = buildBetaHeader(claudeCodeBetaDefaults, extraBetas);
|
||||
const acceptHeader = stream ? "text/event-stream" : "application/json";
|
||||
const incomingUserAgent = getHeaderCaseInsensitive(options.modelHeaders, "User-Agent");
|
||||
const userAgent = isClaudeCodeClientUserAgent(incomingUserAgent)
|
||||
? incomingUserAgent
|
||||
: `claude-cli/${claudeCodeVersion} (external, cli)`;
|
||||
const enforcedHeaderKeys = new Set(
|
||||
[
|
||||
...Object.keys(claudeCodeHeaders),
|
||||
@@ -95,17 +125,17 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
|
||||
...modelHeaders,
|
||||
...claudeCodeHeaders,
|
||||
Accept: acceptHeader,
|
||||
"Accept-Encoding": "br, gzip, deflate",
|
||||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||||
Connection: "keep-alive",
|
||||
"Content-Type": "application/json",
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Anthropic-Dangerous-Direct-Browser-Access": "true",
|
||||
"Anthropic-Beta": betaHeader,
|
||||
"User-Agent": `claude-cli/${claudeCodeVersion} (external, cli)`,
|
||||
"User-Agent": userAgent,
|
||||
"X-App": "cli",
|
||||
};
|
||||
|
||||
if (oauthToken) {
|
||||
if (oauthToken || !isAnthropicApiBaseUrl(options.baseUrl)) {
|
||||
headers.Authorization = `Bearer ${options.apiKey}`;
|
||||
} else {
|
||||
headers["X-Api-Key"] = options.apiKey;
|
||||
@@ -136,41 +166,107 @@ function getCacheControl(
|
||||
}
|
||||
|
||||
// Stealth mode: Mimic Claude Code headers and tool prefixing.
|
||||
export const claudeCodeVersion = "2.1.39";
|
||||
export const claudeToolPrefix = "proxy_";
|
||||
export const claudeCodeSystemInstruction = "You are Claude Code, Anthropic's official CLI for Claude.";
|
||||
export const claudeCodeVersion = "2.1.63";
|
||||
export const claudeToolPrefix: string = "proxy_";
|
||||
export const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
||||
|
||||
export function mapStainlessOs(platform: string): "MacOS" | "Windows" | "Linux" | "FreeBSD" | `Other::${string}` {
|
||||
switch (platform.toLowerCase()) {
|
||||
case "darwin":
|
||||
return "MacOS";
|
||||
case "windows":
|
||||
case "win32":
|
||||
return "Windows";
|
||||
case "linux":
|
||||
return "Linux";
|
||||
case "freebsd":
|
||||
return "FreeBSD";
|
||||
default:
|
||||
return `Other::${platform.toLowerCase()}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function mapStainlessArch(arch: string): "x64" | "arm64" | "x86" | `other::${string}` {
|
||||
switch (arch.toLowerCase()) {
|
||||
case "amd64":
|
||||
case "x64":
|
||||
return "x64";
|
||||
case "arm64":
|
||||
case "aarch64":
|
||||
return "arm64";
|
||||
case "386":
|
||||
case "x86":
|
||||
case "ia32":
|
||||
return "x86";
|
||||
default:
|
||||
return `other::${arch.toLowerCase()}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const claudeCodeHeaders = {
|
||||
"X-Stainless-Helper-Method": "stream",
|
||||
"X-Stainless-Retry-Count": "0",
|
||||
"X-Stainless-Runtime-Version": "v24.13.1",
|
||||
"X-Stainless-Package-Version": "0.73.0",
|
||||
"X-Stainless-Runtime-Version": "v24.3.0",
|
||||
"X-Stainless-Package-Version": "0.74.0",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Arch": "arm64",
|
||||
"X-Stainless-Os": "MacOS",
|
||||
"X-Stainless-Arch": mapStainlessArch(process.arch),
|
||||
"X-Stainless-Os": mapStainlessOs(process.platform),
|
||||
"X-Stainless-Timeout": "600",
|
||||
} as const;
|
||||
|
||||
export const applyClaudeToolPrefix = (name: string) => {
|
||||
if (!claudeToolPrefix) return name;
|
||||
const prefix = claudeToolPrefix.toLowerCase();
|
||||
const CLAUDE_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
|
||||
|
||||
function createClaudeBillingHeader(payload: unknown): string {
|
||||
const payloadJson = JSON.stringify(payload) ?? "";
|
||||
const cch = nodeCrypto.createHash("sha256").update(payloadJson).digest("hex").slice(0, 5);
|
||||
const randomBytes = new Uint8Array(2);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
const buildHash = Array.from(randomBytes, byte => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.slice(0, 3);
|
||||
return `${CLAUDE_BILLING_HEADER_PREFIX} cc_version=${claudeCodeVersion}.${buildHash}; cc_entrypoint=cli; cch=${cch};`;
|
||||
}
|
||||
|
||||
const CLAUDE_CLOAKING_USER_ID_REGEX =
|
||||
/^user_[0-9a-fA-F]{64}_account_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
export function isClaudeCloakingUserId(userId: string): boolean {
|
||||
return CLAUDE_CLOAKING_USER_ID_REGEX.test(userId);
|
||||
}
|
||||
|
||||
export function generateClaudeCloakingUserId(): string {
|
||||
const userHash = nodeCrypto.randomBytes(32).toString("hex");
|
||||
const accountId = nodeCrypto.randomUUID().toLowerCase();
|
||||
const sessionId = nodeCrypto.randomUUID().toLowerCase();
|
||||
return `user_${userHash}_account_${accountId}_session_${sessionId}`;
|
||||
}
|
||||
|
||||
function resolveAnthropicMetadataUserId(userId: unknown, isOAuthToken: boolean): string | undefined {
|
||||
if (typeof userId === "string") {
|
||||
if (!isOAuthToken || isClaudeCloakingUserId(userId)) {
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOAuthToken) return undefined;
|
||||
return generateClaudeCloakingUserId();
|
||||
}
|
||||
const ANTHROPIC_BUILTIN_TOOL_NAMES = new Set(["web_search", "code_execution", "text_editor", "computer"]);
|
||||
export const applyClaudeToolPrefix = (name: string, prefixOverride: string = claudeToolPrefix) => {
|
||||
if (!prefixOverride) return name;
|
||||
if (ANTHROPIC_BUILTIN_TOOL_NAMES.has(name.toLowerCase())) return name;
|
||||
const prefix = prefixOverride.toLowerCase();
|
||||
if (name.toLowerCase().startsWith(prefix)) return name;
|
||||
return `${claudeToolPrefix}${name}`;
|
||||
return `${prefixOverride}${name}`;
|
||||
};
|
||||
|
||||
export const stripClaudeToolPrefix = (name: string) => {
|
||||
if (!claudeToolPrefix) return name;
|
||||
const prefix = claudeToolPrefix.toLowerCase();
|
||||
export const stripClaudeToolPrefix = (name: string, prefixOverride: string = claudeToolPrefix) => {
|
||||
if (!prefixOverride) return name;
|
||||
const prefix = prefixOverride.toLowerCase();
|
||||
if (!name.toLowerCase().startsWith(prefix)) return name;
|
||||
return name.slice(claudeToolPrefix.length);
|
||||
return name.slice(prefixOverride.length);
|
||||
};
|
||||
|
||||
// Prefix tool names for OAuth traffic.
|
||||
const toClaudeCodeName = (name: string) => applyClaudeToolPrefix(name);
|
||||
|
||||
// Strip Claude Code tool prefix on response.
|
||||
const fromClaudeCodeName = (name: string) => stripClaudeToolPrefix(name);
|
||||
|
||||
/**
|
||||
* Convert content blocks to Anthropic API format
|
||||
*/
|
||||
@@ -278,8 +374,34 @@ export type AnthropicClientOptionsResult = {
|
||||
maxRetries: number;
|
||||
dangerouslyAllowBrowser: boolean;
|
||||
defaultHeaders: Record<string, string>;
|
||||
fetchOptions?: AnthropicSdkClientOptions["fetchOptions"];
|
||||
};
|
||||
|
||||
const CLAUDE_CODE_TLS_CIPHERS = tls.DEFAULT_CIPHERS;
|
||||
|
||||
function buildClaudeCodeTlsFetchOptions(
|
||||
model: Model<"anthropic-messages">,
|
||||
): AnthropicSdkClientOptions["fetchOptions"] | undefined {
|
||||
if (model.provider !== "anthropic") return undefined;
|
||||
if (!model.baseUrl) return undefined;
|
||||
|
||||
let serverName: string;
|
||||
try {
|
||||
serverName = new URL(model.baseUrl).hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!serverName) return undefined;
|
||||
|
||||
return {
|
||||
tls: {
|
||||
rejectUnauthorized: true,
|
||||
serverName,
|
||||
...(CLAUDE_CODE_TLS_CIPHERS ? { ciphers: CLAUDE_CODE_TLS_CIPHERS } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
function mergeHeaders(...headerSources: (Record<string, string> | undefined)[]): Record<string, string> {
|
||||
const merged: Record<string, string> = {};
|
||||
for (const headers of headerSources) {
|
||||
@@ -435,7 +557,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
||||
const block: Block = {
|
||||
type: "toolCall",
|
||||
id: event.content_block.id,
|
||||
name: isOAuthToken ? fromClaudeCodeName(event.content_block.name) : event.content_block.name,
|
||||
name: isOAuthToken
|
||||
? stripClaudeToolPrefix(event.content_block.name)
|
||||
: event.content_block.name,
|
||||
arguments: (event.content_block.input as Record<string, unknown>) ?? {},
|
||||
partialJson: "",
|
||||
index: event.index,
|
||||
@@ -619,30 +743,40 @@ export type AnthropicSystemBlock = {
|
||||
type SystemBlockOptions = {
|
||||
includeClaudeCodeInstruction?: boolean;
|
||||
extraInstructions?: string[];
|
||||
billingPayload?: unknown;
|
||||
};
|
||||
|
||||
export function buildAnthropicSystemBlocks(
|
||||
systemPrompt: string | undefined,
|
||||
options: SystemBlockOptions = {},
|
||||
): AnthropicSystemBlock[] | undefined {
|
||||
const { includeClaudeCodeInstruction = false, extraInstructions = [] } = options;
|
||||
const { includeClaudeCodeInstruction = false, extraInstructions = [], billingPayload } = options;
|
||||
const blocks: AnthropicSystemBlock[] = [];
|
||||
const sanitizedPrompt = systemPrompt ? sanitizeSurrogates(systemPrompt) : "";
|
||||
const hasClaudeCodeInstruction = sanitizedPrompt.includes(claudeCodeSystemInstruction);
|
||||
const trimmedInstructions = extraInstructions.map(instruction => instruction.trim()).filter(Boolean);
|
||||
const hasBillingHeader = sanitizedPrompt.includes(CLAUDE_BILLING_HEADER_PREFIX);
|
||||
const claudeCodeSystemCacheControl: AnthropicCacheControl = { type: "ephemeral", ttl: "1h" };
|
||||
|
||||
if (includeClaudeCodeInstruction && !hasClaudeCodeInstruction) {
|
||||
blocks.push({
|
||||
type: "text",
|
||||
text: claudeCodeSystemInstruction,
|
||||
});
|
||||
if (includeClaudeCodeInstruction && !hasBillingHeader) {
|
||||
const payloadSeed = billingPayload ?? {
|
||||
system: sanitizedPrompt,
|
||||
extraInstructions: trimmedInstructions,
|
||||
};
|
||||
blocks.push(
|
||||
{ type: "text", text: createClaudeBillingHeader(payloadSeed) },
|
||||
{
|
||||
type: "text",
|
||||
text: claudeCodeSystemInstruction,
|
||||
cache_control: claudeCodeSystemCacheControl,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for (const instruction of extraInstructions) {
|
||||
const trimmed = instruction.trim();
|
||||
if (!trimmed) continue;
|
||||
for (const instruction of trimmedInstructions) {
|
||||
blocks.push({
|
||||
type: "text",
|
||||
text: trimmed,
|
||||
text: instruction,
|
||||
...(includeClaudeCodeInstruction ? { cache_control: claudeCodeSystemCacheControl } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -650,6 +784,7 @@ export function buildAnthropicSystemBlocks(
|
||||
blocks.push({
|
||||
type: "text",
|
||||
text: sanitizedPrompt,
|
||||
...(includeClaudeCodeInstruction ? { cache_control: claudeCodeSystemCacheControl } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -695,6 +830,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
||||
} = args;
|
||||
const oauthToken = isOAuth ?? isAnthropicOAuthToken(apiKey);
|
||||
|
||||
const tlsFetchOptions = buildClaudeCodeTlsFetchOptions(model);
|
||||
if (model.provider === "github-copilot") {
|
||||
const betaFeatures = [...extraBetas];
|
||||
if (interleavedThinking) {
|
||||
@@ -720,10 +856,11 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
||||
maxRetries: 5,
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders,
|
||||
...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const betaFeatures = ["fine-grained-tool-streaming-2025-05-14", ...extraBetas];
|
||||
const betaFeatures = [...extraBetas];
|
||||
if (interleavedThinking) {
|
||||
betaFeatures.push("interleaved-thinking-2025-05-14");
|
||||
}
|
||||
@@ -745,6 +882,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
||||
maxRetries: 5,
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders,
|
||||
...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -784,12 +922,8 @@ type CacheControlBlock = {
|
||||
cache_control?: AnthropicCacheControl | null;
|
||||
};
|
||||
|
||||
function stripCacheControl<T extends CacheControlBlock>(blocks: T[]): void {
|
||||
for (const block of blocks) {
|
||||
if ("cache_control" in block) {
|
||||
delete block.cache_control;
|
||||
}
|
||||
}
|
||||
function hasCacheControlInBlocks<T extends CacheControlBlock>(blocks: T[]): boolean {
|
||||
return blocks.some(block => "cache_control" in block && block.cache_control != null);
|
||||
}
|
||||
|
||||
function applyCacheControlToLastBlock<T extends CacheControlBlock>(
|
||||
@@ -817,25 +951,15 @@ function applyCacheControlToLastTextBlock(
|
||||
|
||||
function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?: AnthropicCacheControl): void {
|
||||
if (!cacheControl) return;
|
||||
|
||||
const MAX_CACHE_BREAKPOINTS = 4;
|
||||
|
||||
if (params.tools) {
|
||||
for (const tool of params.tools) {
|
||||
delete (tool as CacheControlBlock).cache_control;
|
||||
}
|
||||
}
|
||||
|
||||
if (params.system && Array.isArray(params.system)) {
|
||||
stripCacheControl(params.system);
|
||||
}
|
||||
|
||||
if (params.tools && hasCacheControlInBlocks(params.tools as Array<CacheControlBlock>)) return;
|
||||
if (params.system && Array.isArray(params.system) && hasCacheControlInBlocks(params.system)) return;
|
||||
for (const message of params.messages) {
|
||||
if (Array.isArray(message.content)) {
|
||||
stripCacheControl(message.content as Array<ContentBlockParam & CacheControlBlock>);
|
||||
if (hasCacheControlInBlocks(message.content as Array<ContentBlockParam & CacheControlBlock>)) return;
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_CACHE_BREAKPOINTS = 4;
|
||||
let cacheBreakpointsUsed = 0;
|
||||
|
||||
if (params.tools && params.tools.length > 0) {
|
||||
@@ -907,32 +1031,6 @@ function buildParams(
|
||||
stream: true,
|
||||
};
|
||||
|
||||
// For OAuth tokens, we MUST include Claude Code identity
|
||||
if (isOAuthToken) {
|
||||
params.system = [
|
||||
{
|
||||
type: "text",
|
||||
text: "You are Claude Code, Anthropic's official CLI for Claude.",
|
||||
...(cacheControl ? { cache_control: cacheControl } : {}),
|
||||
},
|
||||
];
|
||||
if (context.systemPrompt) {
|
||||
params.system.push({
|
||||
type: "text",
|
||||
text: sanitizeSurrogates(context.systemPrompt),
|
||||
...(cacheControl ? { cache_control: cacheControl } : {}),
|
||||
});
|
||||
}
|
||||
} else if (context.systemPrompt) {
|
||||
params.system = [
|
||||
{
|
||||
type: "text",
|
||||
text: sanitizeSurrogates(context.systemPrompt),
|
||||
...(cacheControl ? { cache_control: cacheControl } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (options?.temperature !== undefined) {
|
||||
params.temperature = options.temperature;
|
||||
}
|
||||
@@ -969,11 +1067,9 @@ function buildParams(
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.metadata) {
|
||||
const userId = options.metadata.user_id;
|
||||
if (typeof userId === "string") {
|
||||
params.metadata = { user_id: userId };
|
||||
}
|
||||
const metadataUserId = resolveAnthropicMetadataUserId(options?.metadata?.user_id, isOAuthToken);
|
||||
if (metadataUserId) {
|
||||
params.metadata = { user_id: metadataUserId };
|
||||
}
|
||||
|
||||
if (options?.toolChoice) {
|
||||
@@ -986,6 +1082,20 @@ function buildParams(
|
||||
}
|
||||
}
|
||||
|
||||
const shouldInjectClaudeCodeInstruction = isOAuthToken && !model.id.startsWith("claude-3-5-haiku");
|
||||
const billingPayload = shouldInjectClaudeCodeInstruction
|
||||
? {
|
||||
...params,
|
||||
...(context.systemPrompt ? { system: sanitizeSurrogates(context.systemPrompt) } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const systemBlocks = buildAnthropicSystemBlocks(context.systemPrompt, {
|
||||
includeClaudeCodeInstruction: shouldInjectClaudeCodeInstruction,
|
||||
billingPayload,
|
||||
});
|
||||
if (systemBlocks) {
|
||||
params.system = systemBlocks;
|
||||
}
|
||||
disableThinkingIfToolChoiceForced(params);
|
||||
ensureMaxTokensForThinking(params, model);
|
||||
applyPromptCaching(params, cacheControl);
|
||||
@@ -1073,7 +1183,7 @@ export function convertAnthropicMessages(
|
||||
blocks.push({
|
||||
type: "tool_use",
|
||||
id: block.id,
|
||||
name: isOAuthToken ? toClaudeCodeName(block.name) : block.name,
|
||||
name: isOAuthToken ? applyClaudeToolPrefix(block.name) : block.name,
|
||||
input: block.arguments ?? {},
|
||||
});
|
||||
}
|
||||
@@ -1133,7 +1243,7 @@ function convertTools(tools: Tool[], isOAuthToken: boolean): Anthropic.Messages.
|
||||
const jsonSchema = tool.parameters as any; // TypeBox already generates JSON Schema
|
||||
|
||||
return {
|
||||
name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name,
|
||||
name: isOAuthToken ? applyClaudeToolPrefix(tool.name) : tool.name,
|
||||
description: tool.description || "",
|
||||
input_schema: {
|
||||
type: "object" as const,
|
||||
|
||||
@@ -22,9 +22,10 @@ const PROFILE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const CLAUDE_HEADERS = {
|
||||
accept: "application/json, text/plain, */*",
|
||||
"accept-encoding": "gzip, compress, deflate, br",
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
"anthropic-beta":
|
||||
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "claude-code/2.0.20",
|
||||
"user-agent": "claude-cli/2.1.63 (external, cli)",
|
||||
connection: "keep-alive",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ import type { OAuthController, OAuthCredentials } from "./types";
|
||||
const decode = (s: string) => atob(s);
|
||||
const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
||||
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
||||
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
||||
const TOKEN_URL = "https://api.anthropic.com/v1/oauth/token";
|
||||
const CALLBACK_PORT = 54545;
|
||||
const CALLBACK_PATH = "/callback";
|
||||
const SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers";
|
||||
const SCOPES = "org:create_api_key user:profile user:inference";
|
||||
|
||||
class AnthropicOAuthFlow extends OAuthCallbackFlow {
|
||||
export class AnthropicOAuthFlow extends OAuthCallbackFlow {
|
||||
#verifier: string = "";
|
||||
#challenge: string = "";
|
||||
|
||||
@@ -42,6 +42,17 @@ class AnthropicOAuthFlow extends OAuthCallbackFlow {
|
||||
}
|
||||
|
||||
async exchangeToken(code: string, state: string, redirectUri: string): Promise<OAuthCredentials> {
|
||||
let exchangeCode = code;
|
||||
let exchangeState = state;
|
||||
const codeFragmentIndex = code.indexOf("#");
|
||||
if (codeFragmentIndex >= 0) {
|
||||
exchangeCode = code.slice(0, codeFragmentIndex);
|
||||
const codeFragmentState = code.slice(codeFragmentIndex + 1);
|
||||
if (codeFragmentState.length > 0) {
|
||||
exchangeState = codeFragmentState;
|
||||
}
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -51,8 +62,8 @@ class AnthropicOAuthFlow extends OAuthCallbackFlow {
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
state,
|
||||
code: exchangeCode,
|
||||
state: exchangeState,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: this.#verifier,
|
||||
}),
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import templateHtml from "./oauth.html" with { type: "text" };
|
||||
import type { OAuthController, OAuthCredentials } from "./types";
|
||||
|
||||
const DEFAULT_TIMEOUT = 120_000;
|
||||
const DEFAULT_TIMEOUT = 300_000;
|
||||
const DEFAULT_HOSTNAME = "localhost";
|
||||
const CALLBACK_PATH = "/callback";
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import * as tls from "node:tls";
|
||||
import {
|
||||
applyClaudeToolPrefix,
|
||||
buildAnthropicClientOptions,
|
||||
buildAnthropicHeaders,
|
||||
buildAnthropicSystemBlocks,
|
||||
claudeCodeHeaders,
|
||||
claudeCodeSystemInstruction,
|
||||
claudeCodeVersion,
|
||||
generateClaudeCloakingUserId,
|
||||
isClaudeCloakingUserId,
|
||||
mapStainlessArch,
|
||||
mapStainlessOs,
|
||||
streamAnthropic,
|
||||
stripClaudeToolPrefix,
|
||||
} from "@oh-my-pi/pi-ai/providers/anthropic";
|
||||
import type { Context, Model } from "@oh-my-pi/pi-ai/types";
|
||||
|
||||
const ANTHROPIC_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: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8_192,
|
||||
};
|
||||
|
||||
function createAbortedSignal(): AbortSignal {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function captureAnthropicPayload(
|
||||
model: Model<"anthropic-messages">,
|
||||
context: Context,
|
||||
options?: { isOAuth?: boolean; metadata?: { user_id?: string } },
|
||||
): Promise<unknown> {
|
||||
const { promise, resolve } = Promise.withResolvers<unknown>();
|
||||
streamAnthropic(model, context, {
|
||||
apiKey: "sk-ant-oat-test",
|
||||
isOAuth: options?.isOAuth ?? true,
|
||||
signal: createAbortedSignal(),
|
||||
metadata: options?.metadata,
|
||||
onPayload: payload => resolve(payload),
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
describe("Anthropic request fingerprint alignment", () => {
|
||||
it("uses updated Claude Code header defaults", () => {
|
||||
const headers = buildAnthropicHeaders({
|
||||
apiKey: "sk-ant-oat-test",
|
||||
isOAuth: true,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
expect(headers["Anthropic-Beta"]).toContain("context-management-2025-06-27");
|
||||
expect(headers["Anthropic-Beta"]).toContain("prompt-caching-scope-2026-01-05");
|
||||
expect(headers["Anthropic-Beta"]).not.toContain("fine-grained-tool-streaming-2025-05-14");
|
||||
expect(headers["User-Agent"]).toBe(`claude-cli/${claudeCodeVersion} (external, cli)`);
|
||||
expect(claudeCodeHeaders["X-Stainless-Package-Version"]).toBe("0.74.0");
|
||||
expect("X-Stainless-Helper-Method" in claudeCodeHeaders).toBe(false);
|
||||
});
|
||||
|
||||
it("maps Stainless OS and arch values from explicit inputs", () => {
|
||||
expect(mapStainlessOs("darwin")).toBe("MacOS");
|
||||
expect(mapStainlessOs("windows")).toBe("Windows");
|
||||
expect(mapStainlessOs("linux")).toBe("Linux");
|
||||
expect(mapStainlessOs("freebsd")).toBe("FreeBSD");
|
||||
expect(mapStainlessOs("solaris")).toBe("Other::solaris");
|
||||
|
||||
expect(mapStainlessArch("x64")).toBe("x64");
|
||||
expect(mapStainlessArch("amd64")).toBe("x64");
|
||||
expect(mapStainlessArch("arm64")).toBe("arm64");
|
||||
expect(mapStainlessArch("386")).toBe("x86");
|
||||
expect(mapStainlessArch("x86")).toBe("x86");
|
||||
expect(mapStainlessArch("sparc64")).toBe("other::sparc64");
|
||||
});
|
||||
|
||||
it("uses runtime Stainless OS and arch mappings in Anthropic headers", () => {
|
||||
const headers = buildAnthropicHeaders({
|
||||
apiKey: "sk-ant-oat-test",
|
||||
isOAuth: true,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
expect(headers["X-Stainless-Os"]).toBe(mapStainlessOs(process.platform));
|
||||
expect(headers["X-Stainless-Arch"]).toBe(mapStainlessArch(process.arch));
|
||||
});
|
||||
|
||||
it("injects billing header and Claude Agent SDK identity block", () => {
|
||||
const blocks = buildAnthropicSystemBlocks("Stay concise.", {
|
||||
includeClaudeCodeInstruction: true,
|
||||
extraInstructions: ["Use citations when possible"],
|
||||
});
|
||||
|
||||
expect(blocks).toBeDefined();
|
||||
expect(blocks?.[0]?.text.startsWith(`x-anthropic-billing-header: cc_version=${claudeCodeVersion}.`)).toBe(true);
|
||||
expect(blocks?.[0]?.text).toMatch(/cc_entrypoint=cli; cch=[0-9a-f]{5};$/);
|
||||
expect(blocks?.[1]).toEqual({
|
||||
type: "text",
|
||||
text: claudeCodeSystemInstruction,
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
});
|
||||
expect(blocks?.[2]).toEqual({
|
||||
type: "text",
|
||||
text: "Use citations when possible",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
});
|
||||
expect(blocks?.[3]).toEqual({
|
||||
type: "text",
|
||||
text: "Stay concise.",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Bearer auth for non-Anthropic API bases with api-key credentials", () => {
|
||||
const headers = buildAnthropicHeaders({
|
||||
apiKey: "sk-ant-api-test",
|
||||
baseUrl: "https://proxy.example.com",
|
||||
stream: true,
|
||||
});
|
||||
|
||||
expect(headers.Authorization).toBe("Bearer sk-ant-api-test");
|
||||
expect(headers["X-Api-Key"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards only prefix-matching Claude Code User-Agent values", () => {
|
||||
const forwardedHeaders = buildAnthropicHeaders({
|
||||
apiKey: "sk-ant-oat-test",
|
||||
isOAuth: true,
|
||||
stream: true,
|
||||
modelHeaders: { "User-Agent": "claude-cli/2.1.63 (external, cli)" },
|
||||
});
|
||||
expect(forwardedHeaders["User-Agent"]).toBe("claude-cli/2.1.63 (external, cli)");
|
||||
|
||||
// Test variant without slash
|
||||
const forwardedNoSlashHeaders = buildAnthropicHeaders({
|
||||
apiKey: "sk-ant-oat-test",
|
||||
isOAuth: true,
|
||||
stream: true,
|
||||
modelHeaders: { "User-Agent": "claude-cli-dev" },
|
||||
});
|
||||
expect(forwardedNoSlashHeaders["User-Agent"]).toBe("claude-cli-dev");
|
||||
|
||||
const normalizedHeaders = buildAnthropicHeaders({
|
||||
apiKey: "sk-ant-oat-test",
|
||||
isOAuth: true,
|
||||
stream: true,
|
||||
modelHeaders: { "User-Agent": "curl/8.7.1" },
|
||||
});
|
||||
expect(normalizedHeaders["User-Agent"]).toBe(`claude-cli/${claudeCodeVersion} (external, cli)`);
|
||||
|
||||
const embeddedClaudeCliHeaders = buildAnthropicHeaders({
|
||||
apiKey: "sk-ant-oat-test",
|
||||
isOAuth: true,
|
||||
stream: true,
|
||||
modelHeaders: { "User-Agent": "my-client claude-cli/2.1.63" },
|
||||
});
|
||||
expect(embeddedClaudeCliHeaders["User-Agent"]).toBe(`claude-cli/${claudeCodeVersion} (external, cli)`);
|
||||
});
|
||||
|
||||
it("skips Claude Code instruction injection for claude-3-5-haiku models", async () => {
|
||||
const payload = (await captureAnthropicPayload(
|
||||
{ ...ANTHROPIC_MODEL, id: "claude-3-5-haiku-20241022", name: "Claude 3.5 Haiku" },
|
||||
{
|
||||
systemPrompt: "Stay concise.",
|
||||
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
|
||||
},
|
||||
)) as { system?: Array<{ type: string; text?: string }> };
|
||||
|
||||
expect(Array.isArray(payload.system)).toBe(true);
|
||||
const systemBlocks = payload.system ?? [];
|
||||
expect(systemBlocks.some(block => block.text?.startsWith("x-anthropic-billing-header:"))).toBe(false);
|
||||
expect(systemBlocks[0]?.text).toBe("Stay concise.");
|
||||
});
|
||||
|
||||
it("accepts uppercase hex in the user hash segment", () => {
|
||||
const userId =
|
||||
"user_ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD_account_12345678-1234-1234-1234-1234567890ab_session_abcdefab-cdef-abcd-efab-cdefabcdef12";
|
||||
expect(isClaudeCloakingUserId(userId)).toBe(true);
|
||||
});
|
||||
|
||||
it("generates cloaking-compatible user IDs", () => {
|
||||
const userId = generateClaudeCloakingUserId();
|
||||
expect(isClaudeCloakingUserId(userId)).toBe(true);
|
||||
});
|
||||
|
||||
it("injects generated metadata.user_id for OAuth requests when missing", async () => {
|
||||
const payload = (await captureAnthropicPayload(ANTHROPIC_MODEL, {
|
||||
systemPrompt: "Stay concise.",
|
||||
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
|
||||
})) as { metadata?: { user_id?: string } };
|
||||
const userId = payload.metadata?.user_id;
|
||||
expect(typeof userId).toBe("string");
|
||||
expect(isClaudeCloakingUserId(userId ?? "")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not inject metadata.user_id for non-OAuth requests without caller metadata", async () => {
|
||||
const payload = (await captureAnthropicPayload(
|
||||
ANTHROPIC_MODEL,
|
||||
{
|
||||
systemPrompt: "Stay concise.",
|
||||
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
|
||||
},
|
||||
{ isOAuth: false },
|
||||
)) as { metadata?: { user_id?: string } };
|
||||
expect(payload.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves valid caller metadata.user_id for OAuth requests", async () => {
|
||||
const userId = generateClaudeCloakingUserId();
|
||||
const payload = (await captureAnthropicPayload(
|
||||
ANTHROPIC_MODEL,
|
||||
{
|
||||
systemPrompt: "Stay concise.",
|
||||
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
|
||||
},
|
||||
{ metadata: { user_id: userId } },
|
||||
)) as { metadata?: { user_id?: string } };
|
||||
|
||||
expect(payload.metadata?.user_id).toBe(userId);
|
||||
});
|
||||
|
||||
it("replaces invalid caller metadata.user_id for OAuth requests", async () => {
|
||||
const payload = (await captureAnthropicPayload(
|
||||
ANTHROPIC_MODEL,
|
||||
{
|
||||
systemPrompt: "Stay concise.",
|
||||
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
|
||||
},
|
||||
{ metadata: { user_id: "invalid-user-id" } },
|
||||
)) as { metadata?: { user_id?: string } };
|
||||
|
||||
expect(payload.metadata?.user_id).not.toBe("invalid-user-id");
|
||||
expect(isClaudeCloakingUserId(payload.metadata?.user_id ?? "")).toBe(true);
|
||||
});
|
||||
it("drops fine-grained tool-streaming beta from default Anthropic client options", () => {
|
||||
const options = buildAnthropicClientOptions({
|
||||
model: ANTHROPIC_MODEL,
|
||||
apiKey: "sk-ant-oat-test",
|
||||
extraBetas: [],
|
||||
stream: true,
|
||||
interleavedThinking: false,
|
||||
dynamicHeaders: {},
|
||||
});
|
||||
|
||||
const beta = options.defaultHeaders["Anthropic-Beta"];
|
||||
expect(beta).toContain("context-management-2025-06-27");
|
||||
expect(beta).not.toContain("fine-grained-tool-streaming-2025-05-14");
|
||||
});
|
||||
|
||||
it("applies Claude Code TLS profile for direct Anthropic transport", () => {
|
||||
const options = buildAnthropicClientOptions({
|
||||
model: ANTHROPIC_MODEL,
|
||||
apiKey: "sk-ant-oat-test",
|
||||
extraBetas: [],
|
||||
stream: true,
|
||||
interleavedThinking: false,
|
||||
dynamicHeaders: {},
|
||||
});
|
||||
|
||||
const tlsOptions = (
|
||||
options.fetchOptions as
|
||||
| {
|
||||
tls?: {
|
||||
rejectUnauthorized?: boolean;
|
||||
serverName?: string;
|
||||
ciphers?: string;
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
)?.tls;
|
||||
expect(tlsOptions).toBeDefined();
|
||||
expect(tlsOptions?.rejectUnauthorized).toBe(true);
|
||||
expect(tlsOptions?.serverName).toBe("api.anthropic.com");
|
||||
expect(tlsOptions?.ciphers).toBe(tls.DEFAULT_CIPHERS);
|
||||
});
|
||||
|
||||
it("treats tool prefix helpers as no-ops when prefix is empty", () => {
|
||||
expect(applyClaudeToolPrefix("Read")).toBe("Read");
|
||||
expect(stripClaudeToolPrefix("proxy_Read")).toBe("proxy_Read");
|
||||
});
|
||||
|
||||
it("does not prefix built-in Anthropic tool names when prefix is configured", () => {
|
||||
expect(applyClaudeToolPrefix("web_search", "proxy_")).toBe("web_search");
|
||||
expect(applyClaudeToolPrefix("CODE_EXECUTION", "proxy_")).toBe("CODE_EXECUTION");
|
||||
expect(applyClaudeToolPrefix("Text_Editor", "proxy_")).toBe("Text_Editor");
|
||||
expect(applyClaudeToolPrefix("computer", "proxy_")).toBe("computer");
|
||||
});
|
||||
|
||||
it("prefixes custom tool names when prefix is configured", () => {
|
||||
expect(applyClaudeToolPrefix("Read", "proxy_")).toBe("proxy_Read");
|
||||
expect(applyClaudeToolPrefix("proxy_Read", "proxy_")).toBe("proxy_Read");
|
||||
expect(stripClaudeToolPrefix("proxy_Read", "proxy_")).toBe("Read");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { AnthropicOAuthFlow, refreshAnthropicToken } from "../src/utils/oauth/anthropic";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("anthropic oauth alignment", () => {
|
||||
it("generates auth URL with expected scope set", async () => {
|
||||
const flow = new AnthropicOAuthFlow({});
|
||||
const state = "state-123";
|
||||
const redirectUri = "http://localhost:54545/callback";
|
||||
|
||||
const { url } = await flow.generateAuthUrl(state, redirectUri);
|
||||
const authUrl = new URL(url);
|
||||
|
||||
expect(authUrl.origin + authUrl.pathname).toBe("https://claude.ai/oauth/authorize");
|
||||
expect(authUrl.searchParams.get("scope")).toBe("org:create_api_key user:profile user:inference");
|
||||
expect(authUrl.searchParams.get("state")).toBe(state);
|
||||
expect(authUrl.searchParams.get("redirect_uri")).toBe(redirectUri);
|
||||
expect(authUrl.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
|
||||
it("uses api.anthropic.com token URL for code exchange", async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
||||
expect(typeof input === "string" ? input : input.toString()).toBe("https://api.anthropic.com/v1/oauth/token");
|
||||
expect(init?.method).toBe("POST");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const flow = new AnthropicOAuthFlow({});
|
||||
await flow.generateAuthUrl("state-123", "http://localhost:54545/callback");
|
||||
|
||||
const result = await flow.exchangeToken("code-123", "state-123", "http://localhost:54545/callback");
|
||||
|
||||
expect(result.access).toBe("access-token");
|
||||
expect(result.refresh).toBe("refresh-token");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("parses callback code fragments into token exchange code/state", async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
||||
expect(typeof input === "string" ? input : input.toString()).toBe("https://api.anthropic.com/v1/oauth/token");
|
||||
const payload = JSON.parse(String(init?.body));
|
||||
expect(payload.code).toBe("code-123");
|
||||
expect(payload.state).toBe("state-override");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const flow = new AnthropicOAuthFlow({});
|
||||
await flow.generateAuthUrl("state-123", "http://localhost:54545/callback");
|
||||
await flow.exchangeToken("code-123#state-override", "state-123", "http://localhost:54545/callback");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps explicit state when callback code fragment state is empty", async () => {
|
||||
const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
||||
const payload = JSON.parse(String(init?.body));
|
||||
expect(payload.code).toBe("code-123");
|
||||
expect(payload.state).toBe("state-explicit");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const flow = new AnthropicOAuthFlow({});
|
||||
await flow.generateAuthUrl("state-123", "http://localhost:54545/callback");
|
||||
await flow.exchangeToken("code-123#", "state-explicit", "http://localhost:54545/callback");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("uses api.anthropic.com token URL for refresh", async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
||||
expect(typeof input === "string" ? input : input.toString()).toBe("https://api.anthropic.com/v1/oauth/token");
|
||||
expect(init?.method).toBe("POST");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-access-token",
|
||||
refresh_token: "new-refresh-token",
|
||||
expires_in: 7200,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const result = await refreshAnthropicToken("refresh-123");
|
||||
|
||||
expect(result.access).toBe("new-access-token");
|
||||
expect(result.refresh).toBe("new-refresh-token");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { UsageCache, UsageFetchContext, UsageReport } from "../src/usage";
|
||||
import { claudeUsageProvider } from "../src/usage/claude";
|
||||
|
||||
function createMemoryCache(): UsageCache {
|
||||
const entries = new Map<string, { value: UsageReport | null; expiresAt: number }>();
|
||||
return {
|
||||
get(key) {
|
||||
return entries.get(key);
|
||||
},
|
||||
set(key, entry) {
|
||||
entries.set(key, entry);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getHeaderCaseInsensitive(headers: HeadersInit | undefined, name: string): string | undefined {
|
||||
if (!headers) return undefined;
|
||||
const target = name.toLowerCase();
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
for (const [key, value] of headers.entries()) {
|
||||
if (key.toLowerCase() === target) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
const match = headers.find(([key]) => key.toLowerCase() === target);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === target) return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe("claude usage request headers", () => {
|
||||
it("sends aligned anthropic fingerprint and bearer auth headers", async () => {
|
||||
const now = Date.now();
|
||||
const token = "oat-test-access-token";
|
||||
const calls: Array<{ input: string; init?: RequestInit }> = [];
|
||||
const fetchMock = (async (input: string | URL, init?: RequestInit) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 42,
|
||||
resets_at: new Date(now + 10 * 60 * 1000).toISOString(),
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"anthropic-organization-id": "org_test",
|
||||
},
|
||||
},
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const ctx: UsageFetchContext = {
|
||||
cache: createMemoryCache(),
|
||||
fetch: fetchMock,
|
||||
now: () => now,
|
||||
};
|
||||
|
||||
const report = await claudeUsageProvider.fetchUsage(
|
||||
{
|
||||
provider: "anthropic",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
accessToken: token,
|
||||
accountId: "org_test",
|
||||
email: "user@example.com",
|
||||
expiresAt: now + 60_000,
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(report).not.toBeNull();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.input).toBe("https://api.anthropic.com/api/oauth/usage");
|
||||
|
||||
const headers = calls[0]?.init?.headers;
|
||||
expect(getHeaderCaseInsensitive(headers, "authorization")).toBe(`Bearer ${token}`);
|
||||
expect(getHeaderCaseInsensitive(headers, "user-agent")).toBe("claude-cli/2.1.63 (external, cli)");
|
||||
|
||||
const beta = getHeaderCaseInsensitive(headers, "anthropic-beta");
|
||||
expect(beta).toBeDefined();
|
||||
const betaTokens = beta?.split(",").map(tokenValue => tokenValue.trim()) ?? [];
|
||||
expect(betaTokens).toContain("claude-code-20250219");
|
||||
expect(betaTokens).toContain("oauth-2025-04-20");
|
||||
expect(betaTokens).toContain("interleaved-thinking-2025-05-14");
|
||||
expect(betaTokens).toContain("context-management-2025-06-27");
|
||||
expect(betaTokens).toContain("prompt-caching-scope-2026-01-05");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
- AST replace output now renders diff-style (`-before` / `+after`) change previews grouped by directory
|
||||
- Both AST tools now report `scopePath`, `files`, and per-file match/replacement counts in tool details
|
||||
- Task item `id` max length raised from 32 to 48 characters
|
||||
- Anthropic web search provider now uses `buildAnthropicSearchHeaders` (dedicated search header builder separate from inference headers)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import {
|
||||
type AnthropicAuthConfig,
|
||||
type AnthropicSystemBlock,
|
||||
buildAnthropicHeaders,
|
||||
buildAnthropicSearchHeaders,
|
||||
buildAnthropicSystemBlocks,
|
||||
buildAnthropicUrl,
|
||||
findAnthropicAuth,
|
||||
@@ -87,7 +87,7 @@ async function callSearch(
|
||||
temperature?: number,
|
||||
): Promise<AnthropicApiResponse> {
|
||||
const url = buildAnthropicUrl(auth);
|
||||
const headers = buildAnthropicHeaders(auth);
|
||||
const headers = buildAnthropicSearchHeaders(auth);
|
||||
|
||||
const systemBlocks = buildSystemBlocks(auth, model, systemPrompt);
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
import { searchAnthropic } from "../../src/web/search/providers/anthropic";
|
||||
|
||||
type CapturedRequest = {
|
||||
url: string;
|
||||
headers: HeadersInit | undefined;
|
||||
body: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const WEB_SEARCH_BETA = "web-search-2025-03-05";
|
||||
const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
||||
|
||||
function makeAnthropicResponse() {
|
||||
return {
|
||||
id: "msg_test_123",
|
||||
model: "claude-haiku-4-5",
|
||||
content: [{ type: "text", text: "Test answer" }],
|
||||
usage: {
|
||||
input_tokens: 12,
|
||||
output_tokens: 7,
|
||||
server_tool_use: { web_search_requests: 1 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getHeaderCaseInsensitive(headers: HeadersInit | undefined, name: string): string | undefined {
|
||||
if (!headers) return undefined;
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
return headers.get(name) ?? undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
const match = headers.find(([key]) => key.toLowerCase() === name.toLowerCase());
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === name.toLowerCase()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe("searchAnthropic headers", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalSearchApiKey = process.env.ANTHROPIC_SEARCH_API_KEY;
|
||||
const originalSearchBaseUrl = process.env.ANTHROPIC_SEARCH_BASE_URL;
|
||||
const originalApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL;
|
||||
|
||||
let capturedRequest: CapturedRequest | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedRequest = null;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ANTHROPIC_BASE_URL;
|
||||
process.env.ANTHROPIC_SEARCH_BASE_URL = ANTHROPIC_BASE_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
capturedRequest = null;
|
||||
|
||||
if (originalSearchApiKey === undefined) {
|
||||
delete process.env.ANTHROPIC_SEARCH_API_KEY;
|
||||
} else {
|
||||
process.env.ANTHROPIC_SEARCH_API_KEY = originalSearchApiKey;
|
||||
}
|
||||
|
||||
if (originalSearchBaseUrl === undefined) {
|
||||
delete process.env.ANTHROPIC_SEARCH_BASE_URL;
|
||||
} else {
|
||||
process.env.ANTHROPIC_SEARCH_BASE_URL = originalSearchBaseUrl;
|
||||
}
|
||||
|
||||
if (originalApiKey === undefined) {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
} else {
|
||||
process.env.ANTHROPIC_API_KEY = originalApiKey;
|
||||
}
|
||||
|
||||
if (originalBaseUrl === undefined) {
|
||||
delete process.env.ANTHROPIC_BASE_URL;
|
||||
} else {
|
||||
process.env.ANTHROPIC_BASE_URL = originalBaseUrl;
|
||||
}
|
||||
});
|
||||
|
||||
function mockFetch(responseBody: unknown) {
|
||||
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
|
||||
capturedRequest = {
|
||||
url: typeof url === "string" ? url : url.toString(),
|
||||
headers: init?.headers,
|
||||
body: init?.body ? JSON.parse(init.body as string) : null,
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(responseBody), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
it("includes web-search beta header and sends API key in X-Api-Key mode", async () => {
|
||||
process.env.ANTHROPIC_SEARCH_API_KEY = "sk-ant-api-test";
|
||||
mockFetch(makeAnthropicResponse());
|
||||
|
||||
await searchAnthropic({ query: "test api key mode" });
|
||||
|
||||
expect(capturedRequest).not.toBeNull();
|
||||
expect(capturedRequest?.url).toBe(`${ANTHROPIC_BASE_URL}/v1/messages?beta=true`);
|
||||
expect(getHeaderCaseInsensitive(capturedRequest?.headers, "anthropic-beta")).toContain(WEB_SEARCH_BETA);
|
||||
expect(getHeaderCaseInsensitive(capturedRequest?.headers, "x-api-key")).toBe("sk-ant-api-test");
|
||||
expect(getHeaderCaseInsensitive(capturedRequest?.headers, "authorization")).toBeUndefined();
|
||||
expect(capturedRequest?.body?.tools).toEqual([{ type: "web_search_20250305", name: "web_search" }]);
|
||||
});
|
||||
|
||||
it("includes web-search beta header and sends OAuth token in Authorization mode", async () => {
|
||||
process.env.ANTHROPIC_SEARCH_API_KEY = "sk-ant-oat-test";
|
||||
mockFetch(makeAnthropicResponse());
|
||||
|
||||
await searchAnthropic({ query: "test oauth mode" });
|
||||
|
||||
expect(capturedRequest).not.toBeNull();
|
||||
expect(getHeaderCaseInsensitive(capturedRequest?.headers, "anthropic-beta")).toContain(WEB_SEARCH_BETA);
|
||||
expect(getHeaderCaseInsensitive(capturedRequest?.headers, "authorization")).toBe("Bearer sk-ant-oat-test");
|
||||
expect(getHeaderCaseInsensitive(capturedRequest?.headers, "x-api-key")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user