diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 204e77408..adf303aa4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 4c5646e82..cc889363c 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -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 | 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 { 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 { - 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; + 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 | undefined)[]): Record { const merged: Record = {}; 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) ?? {}, 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(blocks: T[]): void { - for (const block of blocks) { - if ("cache_control" in block) { - delete block.cache_control; - } - } +function hasCacheControlInBlocks(blocks: T[]): boolean { + return blocks.some(block => "cache_control" in block && block.cache_control != null); } function applyCacheControlToLastBlock( @@ -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)) 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); + if (hasCacheControlInBlocks(message.content as Array)) 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, diff --git a/packages/ai/src/usage/claude.ts b/packages/ai/src/usage/claude.ts index cb416a228..c49a39b91 100644 --- a/packages/ai/src/usage/claude.ts +++ b/packages/ai/src/usage/claude.ts @@ -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; diff --git a/packages/ai/src/utils/oauth/anthropic.ts b/packages/ai/src/utils/oauth/anthropic.ts index 98d4ae138..14ff8e9dd 100644 --- a/packages/ai/src/utils/oauth/anthropic.ts +++ b/packages/ai/src/utils/oauth/anthropic.ts @@ -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 { + 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, }), diff --git a/packages/ai/src/utils/oauth/callback-server.ts b/packages/ai/src/utils/oauth/callback-server.ts index 860ae9ad7..683d08ea6 100644 --- a/packages/ai/src/utils/oauth/callback-server.ts +++ b/packages/ai/src/utils/oauth/callback-server.ts @@ -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"; diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts new file mode 100644 index 000000000..c190e610e --- /dev/null +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -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 { + const { promise, resolve } = Promise.withResolvers(); + 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"); + }); +}); diff --git a/packages/ai/test/anthropic-oauth.test.ts b/packages/ai/test/anthropic-oauth.test.ts new file mode 100644 index 000000000..cf6460dd8 --- /dev/null +++ b/packages/ai/test/anthropic-oauth.test.ts @@ -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); + }); +}); diff --git a/packages/ai/test/claude-usage-headers.test.ts b/packages/ai/test/claude-usage-headers.test.ts new file mode 100644 index 000000000..ac1fcf917 --- /dev/null +++ b/packages/ai/test/claude-usage-headers.test.ts @@ -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(); + 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"); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 298d1b18d..4ad014742 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/web/search/providers/anthropic.ts b/packages/coding-agent/src/web/search/providers/anthropic.ts index 4b9ff547c..9592635a7 100644 --- a/packages/coding-agent/src/web/search/providers/anthropic.ts +++ b/packages/coding-agent/src/web/search/providers/anthropic.ts @@ -7,7 +7,7 @@ import { type AnthropicAuthConfig, type AnthropicSystemBlock, - buildAnthropicHeaders, + buildAnthropicSearchHeaders, buildAnthropicSystemBlocks, buildAnthropicUrl, findAnthropicAuth, @@ -87,7 +87,7 @@ async function callSearch( temperature?: number, ): Promise { const url = buildAnthropicUrl(auth); - const headers = buildAnthropicHeaders(auth); + const headers = buildAnthropicSearchHeaders(auth); const systemBlocks = buildSystemBlocks(auth, model, systemPrompt); diff --git a/packages/coding-agent/test/tools/web-search-anthropic.test.ts b/packages/coding-agent/test/tools/web-search-anthropic.test.ts new file mode 100644 index 000000000..f5f220e7e --- /dev/null +++ b/packages/coding-agent/test/tools/web-search-anthropic.test.ts @@ -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 | 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(); + }); +});