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:
can1357
2026-02-28 20:49:35 +01:00
parent 60f8658933
commit e7daffdda7
11 changed files with 899 additions and 102 deletions
+20
View File
@@ -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
+202 -92
View File
@@ -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,
+3 -2
View File
@@ -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;
+16 -5
View File
@@ -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");
});
});
+119
View File
@@ -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");
});
});
+1
View File
@@ -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();
});
});