From 642892ae5ce5b76c65d8d5524ee338c8faec0dc3 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 30 Jul 2026 17:02:26 +0200 Subject: [PATCH] feat(ai/providers): implemented custom cowork transport for anthropic requests - Added custom `coworkFetch` transport for ordered headers and decompression support in Anthropic requests. - Updated Anthropic and Claude runtime versions along with request headers to replicate Cowork desktop profiles. - Configured default Anthropic message stream fetches to use the new cowork fetch transport and TLS profiles. - Updated alignment tests and documentation to reflect the new Cowork request configurations and header rules. --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/providers/anthropic.ts | 122 +++++------ .../src/providers/claude-code-fingerprint.ts | 23 +- packages/ai/src/providers/cowork-fetch.ts | 201 ++++++++++++++++++ packages/ai/src/stream.ts | 16 +- packages/ai/src/utils/proxy.ts | 8 +- packages/ai/test/anthropic-alignment.test.ts | 103 +++++---- 7 files changed, 350 insertions(+), 127 deletions(-) create mode 100644 packages/ai/src/providers/cowork-fetch.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 4464391c3..ec7fdcd32 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Anthropic OAuth requests now reproduce Cowork's current `claude-desktop` request profile, including client/runtime metadata, beta selection, system and billing attestation, the 64K output cap, and stable HTTP/1.1 header ordering. + ## [17.2.0] - 2026-07-30 ### Added diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 12c9c699f..db0281455 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -87,11 +87,10 @@ import { } from "./anthropic-wire"; import { CLAUDE_CODE_MAX_OUTPUT_TOKENS, - claudeAgentSdkVersion, - claudeClientVersion, claudeCodeSystemInstruction, claudeCodeVersion, claudeToolPrefix, + coworkUserAgent, } from "./claude-code-fingerprint"; import { buildCopilotDynamicHeaders, @@ -111,7 +110,7 @@ export type AnthropicHeaderOptions = { modelHeaders?: Record; isCloudflareAiGateway?: boolean; claudeCodeSessionId?: string; - claudeCodeBetas?: readonly string[]; + coworkBetas?: readonly string[]; /** Allow explicit fingerprint headers to replace OAuth defaults on non-official endpoints. */ allowAnthropicHeaderOverrides?: boolean; }; @@ -158,52 +157,49 @@ function mergeAnthropicBetaHeader(callerHeaders: Record, beta: s const midConversationSystemBeta = "mid-conversation-system-2026-04-07"; const contextManagementBeta = "context-management-2025-06-27"; const structuredOutputsBeta = "structured-outputs-2025-12-15"; -const claudeCodeUtilityBetaDefaults = [ - "oauth-2025-04-20", +const context1mBeta = "context-1m-2025-08-07"; +const thinkingTokenCountBeta = "thinking-token-count-2026-05-13"; +const fallbackCreditBeta = "fallback-credit-2026-06-01"; +const coworkUtilityBetaDefaults = [ "interleaved-thinking-2025-05-14", + thinkingTokenCountBeta, contextManagementBeta, "prompt-caching-scope-2026-01-05", structuredOutputsBeta, ] as const; -const claudeCodeAgentBetaDefaults = [ +const coworkAgentBetaDefaults = [ "claude-code-20250219", - "oauth-2025-04-20", "interleaved-thinking-2025-05-14", + thinkingTokenCountBeta, contextManagementBeta, "prompt-caching-scope-2026-01-05", midConversationSystemBeta, "advanced-tool-use-2025-11-20", ] as const; const extendedCacheTtlBeta = "extended-cache-ttl-2025-04-11"; -const claudeCodeAgentPostEffortBetas = [extendedCacheTtlBeta] as const; const fineGrainedToolStreamingBeta = "fine-grained-tool-streaming-2025-05-14"; const interleavedThinkingBeta = "interleaved-thinking-2025-05-14"; -// Asks the API to redact thinking blocks from responses. Only sent when the -// caller explicitly hides thinking (`thinkingDisplay: "omitted"`); sending it -// by default suppresses the thinking traces callers expect to stream. -const redactThinkingBeta = "redact-thinking-2026-02-12"; const fastModeBeta = "fast-mode-2026-02-01"; const taskBudgetBeta = "task-budgets-2026-03-13"; const effortBeta = "effort-2025-11-24"; const serverSideFallbackBeta = "server-side-fallback-2026-06-01"; -function buildClaudeCodeBetas( +function buildCoworkBetas( agentRequest: boolean, thinkingRequest: boolean, - redactThinking: boolean, + longContext: boolean, disableStrictTools = false, ): readonly string[] { - if (!agentRequest && !redactThinking && !disableStrictTools) return claudeCodeUtilityBetaDefaults; + if (!agentRequest && !disableStrictTools) return coworkUtilityBetaDefaults; const betas: string[] = []; - for (const beta of agentRequest ? claudeCodeAgentBetaDefaults : claudeCodeUtilityBetaDefaults) { + for (const beta of agentRequest ? coworkAgentBetaDefaults : coworkUtilityBetaDefaults) { if (disableStrictTools && beta === structuredOutputsBeta) continue; betas.push(beta); - // Match CC's header order: redact-thinking immediately follows interleaved-thinking. - if (redactThinking && beta === interleavedThinkingBeta) betas.push(redactThinkingBeta); + if (agentRequest && longContext && beta === "claude-code-20250219") betas.push(context1mBeta); } if (!agentRequest) return betas; if (thinkingRequest) betas.push(effortBeta); - betas.push(...claudeCodeAgentPostEffortBetas); + betas.push(fallbackCreditBeta); return betas; } @@ -246,11 +242,10 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record key.toLowerCase()), + [...Object.keys(coworkHeaders), "anthropic-beta", "User-Agent", "x-app"].map(key => key.toLowerCase()), ); const CLAUDE_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:"; @@ -591,7 +572,7 @@ function createClaudeBillingHeader(firstUserMessageText: string): string { .slice(0, 3); // cch=00000: placeholder replaced with the real attestation hash by wrapFetchForCch // before the request hits the wire (see below). - return `${CLAUDE_BILLING_HEADER_PREFIX} cc_version=${claudeCodeVersion}.${versionSuffix}; cc_entrypoint=local-agent; ${CCH_PLACEHOLDER_STR};`; + return `${CLAUDE_BILLING_HEADER_PREFIX} cc_version=${claudeCodeVersion}.${versionSuffix}; cc_entrypoint=claude-desktop; ${CCH_PLACEHOLDER_STR};`; } // cch attestation: XXHash64(body_with_placeholder, seed) low-20-bits, 5 hex chars. @@ -1184,7 +1165,7 @@ export type AnthropicClientOptionsResult = { fetchOptions?: AnthropicFetchOptions; }; -const CLAUDE_CODE_TLS_CIPHERS = tls.DEFAULT_CIPHERS; +const COWORK_TLS_CIPHERS = tls.DEFAULT_CIPHERS; type FoundryTlsOptions = { ca?: string | string[]; @@ -1347,7 +1328,7 @@ function resolveFoundryTlsOptions(model: Model<"anthropic-messages">): FoundryTl return resolved; } -function buildClaudeCodeTlsFetchOptions( +function buildCoworkTlsFetchOptions( model: Model<"anthropic-messages">, baseUrl: string | undefined, ): AnthropicFetchOptions | undefined { @@ -1369,7 +1350,7 @@ function buildClaudeCodeTlsFetchOptions( tls: { rejectUnauthorized: true, serverName, - ...(CLAUDE_CODE_TLS_CIPHERS ? { ciphers: CLAUDE_CODE_TLS_CIPHERS } : {}), + ...(COWORK_TLS_CIPHERS ? { ciphers: COWORK_TLS_CIPHERS } : {}), ...(foundryTlsOptions ?? {}), }, }; @@ -2865,7 +2846,6 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A dynamicHeaders, hasTools = false, thinkingEnabled = false, - thinkingDisplay, isOAuth, maxRetryDelayMs, claudeCodeSessionId, @@ -2903,7 +2883,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A const needsFineGrainedToolStreamingBeta = hasTools && isOfficialAnthropicApiUrl(baseUrl) && !supportsEagerToolInputStreaming; const foundryCustomHeaders = resolveAnthropicCustomHeaders(model); - const tlsFetchOptions = buildClaudeCodeTlsFetchOptions(model, baseUrl); + const tlsFetchOptions = buildCoworkTlsFetchOptions(model, baseUrl); // Disable Bun's native ~300s pre-response fetch timeout (issue #2422). // `AnthropicMessagesClient` already arms its own DEFAULT_TIMEOUT_MS timer // per request, so the native ceiling can only short-circuit slow-prefill @@ -2969,11 +2949,11 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A isCloudflareAiGateway: model.provider === "cloudflare-ai-gateway", allowAnthropicHeaderOverrides: model.compat.allowAnthropicHeaderOverrides, claudeCodeSessionId, - claudeCodeBetas: oauthToken - ? buildClaudeCodeBetas( + coworkBetas: oauthToken + ? buildCoworkBetas( hasTools || thinkingEnabled, thinkingEnabled, - thinkingDisplay === "omitted", + (model.contextWindow ?? 0) >= 1_000_000, disableStrictTools, ) : [], diff --git a/packages/ai/src/providers/claude-code-fingerprint.ts b/packages/ai/src/providers/claude-code-fingerprint.ts index c75cb5b0f..3263d3f04 100644 --- a/packages/ai/src/providers/claude-code-fingerprint.ts +++ b/packages/ai/src/providers/claude-code-fingerprint.ts @@ -1,19 +1,20 @@ /** - * Claude Code stealth-fingerprint constants, kept in a leaf module so - * fingerprint consumers outside the provider (`registry/oauth/anthropic`, - * `usage/claude`) don't import the heavy `providers/anthropic` module. + * Cowork inference-fingerprint constants, kept in a leaf module so consumers + * outside the provider (`registry/oauth/anthropic`, `usage/claude`) don't + * import the heavy `providers/anthropic` module. + * * That import edge was a live init cycle: `providers/anthropic` → `stream` → * `registry` → `registry/oauth/anthropic` → back into the still-initializing - * provider module, which threw a TDZ ReferenceError whenever - * `providers/anthropic` was the first module loaded. + * provider module. */ -export const claudeCodeVersion = "2.1.165"; -export const claudeAgentSdkVersion = "0.3.165"; -export const claudeClientVersion = "1.11187.4"; +/** Claude runtime version bundled by the current Cowork desktop release. */ +export const claudeCodeVersion = "2.1.220"; +/** User-Agent emitted by Cowork's `claude-desktop` inference entrypoint. */ +export const coworkUserAgent = `claude-cli/${claudeCodeVersion} (external, claude-desktop)`; +/** Prefix used to isolate custom Anthropic OAuth tools from built-in tools. */ export const claudeToolPrefix: string = "_"; +/** Identity block prepended by Cowork's Claude runtime. */ export const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK."; -// Claude Code caps requested output at 64k tokens even when the model ceiling is -// higher (e.g. Opus 4.8 supports 128k); OAuth requests clamp to match the wire -// fingerprint. API-key requests keep the full model ceiling. +/** Cowork's per-request output-token ceiling. */ export const CLAUDE_CODE_MAX_OUTPUT_TOKENS = 64000; diff --git a/packages/ai/src/providers/cowork-fetch.ts b/packages/ai/src/providers/cowork-fetch.ts new file mode 100644 index 000000000..896108e6e --- /dev/null +++ b/packages/ai/src/providers/cowork-fetch.ts @@ -0,0 +1,201 @@ +import type { ClientRequest, IncomingMessage } from "node:http"; +import * as https from "node:https"; +import * as stream from "node:stream"; +import * as tls from "node:tls"; +import * as zlib from "node:zlib"; +import type { FetchImpl } from "../types"; +import { connectProxiedSocket } from "../utils/proxy"; + +type CoworkTlsOptions = { + ca?: string | string[]; + cert?: string; + key?: string; + rejectUnauthorized?: boolean; + serverName?: string; + ciphers?: string; +}; + +type CoworkRequestInit = RequestInit & { + proxy?: string; + tls?: CoworkTlsOptions; +}; + +type RequestBody = string | Uint8Array; + +type AgentLease = { + agent: https.Agent; + release?: () => void; +}; + +const directAgent = new https.Agent({ keepAlive: true }); +const fallbackFetch: FetchImpl = globalThis.fetch; + +function isHeaderRecord(headers: RequestInit["headers"]): headers is Record { + return headers !== undefined && !(headers instanceof Headers) && !Array.isArray(headers); +} + +function resolveBody(body: RequestInit["body"]): RequestBody | undefined { + if (typeof body === "string" || body instanceof Uint8Array) return body; + return undefined; +} + +function buildOrderedHeaders( + url: URL, + source: Record, + body: RequestBody | undefined, +): Record { + const headers: Record = {}; + let hasHost = false; + let hasContentLength = false; + for (const name in source) { + const lowerName = name.toLowerCase(); + if (lowerName === "host") hasHost = true; + if (lowerName === "content-length") hasContentLength = true; + if (lowerName === "accept-encoding" && !hasHost) { + headers.Host = url.host; + hasHost = true; + } + headers[name] = source[name]; + } + if (!hasHost) headers.Host = url.host; + const length = typeof body === "string" ? Buffer.byteLength(body) : body?.byteLength; + if (!hasContentLength && length !== undefined) headers["Content-Length"] = String(length); + return headers; +} + +function resolveTlsOptions(url: URL, options: CoworkTlsOptions | undefined): tls.ConnectionOptions { + const resolved: tls.ConnectionOptions = { + ALPNProtocols: ["http/1.1"], + ciphers: options?.ciphers ?? tls.DEFAULT_CIPHERS, + rejectUnauthorized: options?.rejectUnauthorized ?? true, + servername: options?.serverName ?? url.hostname, + }; + if (options?.ca !== undefined) resolved.ca = options.ca; + if (options?.cert !== undefined) resolved.cert = options.cert; + if (options?.key !== undefined) resolved.key = options.key; + return resolved; +} + +async function acquireAgent( + url: URL, + proxy: string | undefined, + tlsOptions: tls.ConnectionOptions, + signal: AbortSignal | undefined, +): Promise { + if (!proxy) return { agent: directAgent }; + const socket = await connectProxiedSocket(proxy, url.origin, { signal, tls: tlsOptions }); + const agent = new https.Agent({ keepAlive: false }); + agent.createConnection = () => socket; + return { agent, release: () => agent.destroy() }; +} + +function responseHeaders(message: IncomingMessage): Headers { + const headers = new Headers(); + for (let index = 0; index < message.rawHeaders.length; index += 2) { + headers.append(message.rawHeaders[index], message.rawHeaders[index + 1]); + } + return headers; +} + +function decodedResponseStream(message: IncomingMessage): stream.Readable { + const rawEncoding = message.headers["content-encoding"]; + const encoding = (Array.isArray(rawEncoding) ? rawEncoding[0] : rawEncoding)?.trim().toLowerCase(); + switch (encoding) { + case "gzip": + return message.pipe(zlib.createGunzip()); + case "deflate": + return message.pipe(zlib.createInflate()); + case "br": + return message.pipe(zlib.createBrotliDecompress()); + case "zstd": + return message.pipe(zlib.createZstdDecompress()); + default: + return message; + } +} + +function createResponse(message: IncomingMessage, method: string): Response { + const status = message.statusCode; + if (status === undefined) throw new Error("Cowork transport received a response without an HTTP status."); + const hasBody = method !== "HEAD" && status !== 204 && status !== 304; + const body = hasBody ? stream.Readable.toWeb(decodedResponseStream(message)) : null; + return new Response(body, { + status, + statusText: message.statusMessage, + headers: responseHeaders(message), + }); +} + +async function sendCoworkRequest( + url: URL, + init: CoworkRequestInit, + sourceHeaders: Record, + body: RequestBody | undefined, +): Promise { + const method = init.method ?? "GET"; + const signal = init.signal ?? undefined; + const tlsOptions = resolveTlsOptions(url, init.tls); + const lease = await acquireAgent(url, init.proxy, tlsOptions, signal); + const headers = buildOrderedHeaders(url, sourceHeaders, body); + const result = Promise.withResolvers(); + let request: ClientRequest | undefined; + const release = (): void => { + signal?.removeEventListener("abort", abort); + lease.release?.(); + }; + const abort = (): void => { + const reason = signal?.reason; + request?.destroy(reason instanceof Error ? reason : new DOMException("The operation was aborted.", "AbortError")); + }; + if (signal?.aborted) { + release(); + signal.throwIfAborted(); + } + signal?.addEventListener("abort", abort, { once: true }); + request = https.request( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port || 443, + path: `${url.pathname}${url.search}`, + method, + headers, + agent: lease.agent, + ...tlsOptions, + }, + message => { + message.once("close", release); + try { + result.resolve(createResponse(message, method)); + } catch (error) { + message.destroy(); + release(); + result.reject(error); + } + }, + ); + request.once("error", error => { + release(); + result.reject(error); + }); + request.end(body); + return result.promise; +} + +/** Sends Cowork-profiled HTTPS requests with stable header order, HTTP/1.1, and streaming decompression. */ +export const coworkFetch: FetchImpl = async (input, init) => { + if (input instanceof Request || init === undefined || !isHeaderRecord(init.headers)) { + return fallbackFetch(input, init); + } + let url: URL; + try { + url = new URL(input); + } catch { + return fallbackFetch(input, init); + } + if (url.protocol !== "https:") return fallbackFetch(input, init); + const body = resolveBody(init.body); + if (init.body != null && body === undefined) return fallbackFetch(input, init); + const coworkInit: CoworkRequestInit = init; + return sendCoworkRequest(url, coworkInit, init.headers, body); +}; diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 1cd31d2a9..36213199b 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -24,6 +24,7 @@ import { isInvalidatedOAuthTokenError } from "./error/auth-classify"; import { isUsageLimitOutcome } from "./error/rate-limit"; import type { BedrockOptions } from "./providers/amazon-bedrock"; import type { AnthropicOptions } from "./providers/anthropic"; +import { coworkFetch } from "./providers/cowork-fetch"; import type { CursorOptions } from "./providers/cursor"; import type { DevinOptions } from "./providers/devin"; import { isGitLabDuoModel, streamGitLabDuo } from "./providers/gitlab-duo"; @@ -81,6 +82,11 @@ import { wrapFetchForProxy } from "./utils/proxy"; import { withRequestDebugFetch } from "./utils/request-debug"; import { withGeminiThinkingLoopGuard } from "./utils/thinking-loop"; +function defaultFetchForModel(model: Model): FetchImpl { + if (model.provider === "anthropic" && model.api === "anthropic-messages") return coworkFetch; + return globalThis.fetch; +} + function isGoogleVertexAuthenticatedModel(model: Model): boolean { return ( model.provider === "google-vertex" && @@ -769,11 +775,12 @@ function streamDispatch( context: Context, options?: OptionsForApi, ): AssistantMessageEventStream { - const baseOptions = (options || {}) as StreamOptions; + const inputOptions = (options || {}) as StreamOptions; + const baseOptions = { ...inputOptions, fetch: inputOptions.fetch ?? defaultFetchForModel(model) }; const debugOptions = withExtraCaFetch(withRequestDebugFetch(baseOptions)); const requestOptions = { ...debugOptions, - fetch: wrapFetchForProxy(debugOptions.fetch ?? (globalThis.fetch as FetchImpl), model.provider), + fetch: wrapFetchForProxy(debugOptions.fetch, model.provider), } as OptionsForApi; assertExplicitOpenAIResponsesPromptCacheSupport(model, requestOptions); @@ -1009,11 +1016,12 @@ export function streamSimple( context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream { - const baseOptions = (options || {}) as SimpleStreamOptions; + const inputOptions = (options || {}) as SimpleStreamOptions; + const baseOptions = { ...inputOptions, fetch: inputOptions.fetch ?? defaultFetchForModel(model) }; const debugOptions = withExtraCaFetch(withRequestDebugFetch(baseOptions)); const requestOptions = { ...debugOptions, - fetch: wrapFetchForProxy(debugOptions.fetch ?? (globalThis.fetch as FetchImpl), model.provider), + fetch: wrapFetchForProxy(debugOptions.fetch, model.provider), } as SimpleStreamOptions; const apiKeyResolver = isApiKeyResolver(requestOptions?.apiKey) ? requestOptions.apiKey : undefined; diff --git a/packages/ai/src/utils/proxy.ts b/packages/ai/src/utils/proxy.ts index c8157d196..7f3229a55 100644 --- a/packages/ai/src/utils/proxy.ts +++ b/packages/ai/src/utils/proxy.ts @@ -177,6 +177,8 @@ export interface ConnectProxiedSocketOptions { signal?: AbortSignal; /** Maximum wall-clock time to establish the final TLS tunnel. Disabled when absent or non-positive. */ timeoutMs?: number; + /** Target TLS profile. Cursor defaults to HTTP/2 when this is absent. */ + tls?: tls.ConnectionOptions; } /** @@ -261,10 +263,12 @@ export async function connectProxiedSocket( return; } + const tlsOptions = options?.tls; tunnelSocket = tls.connect({ + ...tlsOptions, socket: rawSocket, - servername: targetHost, - ALPNProtocols: ["h2"], + servername: tlsOptions?.servername ?? targetHost, + ALPNProtocols: tlsOptions?.ALPNProtocols ?? ["h2"], }); tunnelSocket.once("secureConnect", onTunnelReady); tunnelSocket.once("error", onTunnelError); diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index 0e88e5e7c..2dea5f3a6 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -9,14 +9,12 @@ import { buildAnthropicClientOptions, buildAnthropicHeaders, buildAnthropicSystemBlocks, - claudeAgentSdkVersion, claudeCodeSystemInstruction, claudeToolPrefix, deriveClaudeDeviceId, generateClaudeCloakingUserId, isClaudeCloakingUserId, mapStainlessArch, - mapStainlessOs, streamAnthropic, stripClaudeToolPrefix, } from "@oh-my-pi/pi-ai/providers/anthropic"; @@ -156,13 +154,7 @@ function expectClaudeMetadataUserId(userId: string | undefined, expectedSessionI } describe("Anthropic request fingerprint alignment", () => { - 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"); - + it("maps Stainless arch values from explicit inputs", () => { expect(mapStainlessArch("x64")).toBe("x64"); expect(mapStainlessArch("amd64")).toBe("x64"); expect(mapStainlessArch("arm64")).toBe("arm64"); @@ -171,18 +163,7 @@ describe("Anthropic request fingerprint alignment", () => { 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("matches Claude Code OAuth header defaults", () => { + it("matches Cowork OAuth header defaults", () => { const sessionId = "167ec5b4-e711-4169-879f-84fa52679d9c"; const headers = buildAnthropicHeaders({ apiKey: "sk-ant-oat-test", @@ -192,14 +173,43 @@ describe("Anthropic request fingerprint alignment", () => { }); expect(headers.Accept).toBe("application/json"); - expect(headers["User-Agent"]).toBe( - `claude-cli/${claudeCodeVersion} (external, local-agent, agent-sdk/${claudeAgentSdkVersion})`, - ); + expect(headers["User-Agent"]).toBe(`claude-cli/${claudeCodeVersion} (external, claude-desktop)`); expect(headers["X-Claude-Code-Session-Id"]).toBe(sessionId); + expect(headers["X-Stainless-Arch"]).toBe(mapStainlessArch(process.arch)); + expect(headers["X-Stainless-OS"]).toBe("Linux"); + expect(headers["X-Stainless-Runtime-Version"]).toBe("v26.3.0"); + expect(headers["X-Stainless-Timeout"]).toBe("600"); + expect(headers["anthropic-client-platform"]).toBeUndefined(); + expect(headers["anthropic-client-version"]).toBeUndefined(); + expect(Object.keys(headers)).toEqual([ + "Accept", + "Content-Type", + "User-Agent", + "X-Claude-Code-Session-Id", + "X-Stainless-Arch", + "X-Stainless-Lang", + "X-Stainless-OS", + "X-Stainless-Package-Version", + "X-Stainless-Retry-Count", + "X-Stainless-Runtime", + "X-Stainless-Runtime-Version", + "X-Stainless-Timeout", + "anthropic-beta", + "anthropic-dangerous-direct-browser-access", + "anthropic-version", + "Authorization", + "x-app", + "x-client-request-id", + "Connection", + "Accept-Encoding", + ]); + expect(headers["anthropic-beta"]).toBe( + "claude-code-20250219,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,fallback-credit-2026-06-01", + ); expect(headers["x-client-request-id"]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); }); - it("sends redact-thinking beta only when thinking display is omitted", () => { + it("omits the legacy redact-thinking beta from Cowork requests", () => { const baseArgs = { model: ANTHROPIC_MODEL, apiKey: "sk-ant-oat-test", @@ -213,7 +223,7 @@ describe("Anthropic request fingerprint alignment", () => { expect(visible.defaultHeaders["anthropic-beta"]).not.toContain("redact-thinking-2026-02-12"); const hidden = buildAnthropicClientOptions({ ...baseArgs, thinkingDisplay: "omitted" }); - expect(hidden.defaultHeaders["anthropic-beta"]).toContain("redact-thinking-2026-02-12"); + expect(hidden.defaultHeaders["anthropic-beta"]).not.toContain("redact-thinking-2026-02-12"); const hiddenUtility = buildAnthropicClientOptions({ ...baseArgs, @@ -221,12 +231,31 @@ describe("Anthropic request fingerprint alignment", () => { thinkingEnabled: false, thinkingDisplay: "omitted", }); - expect(hiddenUtility.defaultHeaders["anthropic-beta"]).toContain("redact-thinking-2026-02-12"); + expect(hiddenUtility.defaultHeaders["anthropic-beta"]).not.toContain("redact-thinking-2026-02-12"); }); - it("matches CC system-block layout: billing and instruction uncached, single breakpoint on the last context block", () => { - // We mimic Claude Code's billing+instruction system layout but do NOT emit - // the `scope: "global"` field that CC attaches to its middle breakpoint — + it("adds Cowork's context-1m beta for million-token models", () => { + const longContextModel = buildModel({ + ...ANTHROPIC_MODEL_SPEC, + id: "claude-opus-5", + name: "Claude Opus 5", + contextWindow: 1_000_000, + }); + const options = buildAnthropicClientOptions({ + model: longContextModel, + apiKey: "sk-ant-oat-test", + stream: true, + hasTools: true, + thinkingEnabled: true, + }); + + expect(options.defaultHeaders["anthropic-beta"]).toBe( + "claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,fallback-credit-2026-06-01", + ); + }); + + it("matches Cowork's system-block layout: billing and instruction uncached, single breakpoint on the last context block", () => { + // Cowork's billing+instruction system layout does not emit the // `prompt-caching-scope-2026-01-05` only works against canonical // `api.anthropic.com`, and third-party Anthropic-compatible proxies // (z.ai, openrouter, …) reject the unknown field outright. @@ -814,7 +843,7 @@ describe("Anthropic request fingerprint alignment", () => { expect(headers["anthropic-beta"]).not.toBe("custom-beta-token"); expect(headers["x-app"]).toBe("cli"); - expect(headers["X-Stainless-Runtime-Version"]).toBe("v24.3.0"); + expect(headers["X-Stainless-Runtime-Version"]).toBe("v26.3.0"); }); it("suppresses the client-level X-Api-Key when model.headers carries a custom Authorization (#3391)", () => { @@ -867,7 +896,7 @@ describe("Anthropic request fingerprint alignment", () => { }); }); - it("forwards only prefix-matching Claude Code User-Agent values", () => { + it("forwards only prefix-matching Cowork User-Agent values", () => { const forwardedHeaders = buildAnthropicHeaders({ apiKey: "sk-ant-oat-test", isOAuth: true, @@ -891,9 +920,7 @@ describe("Anthropic request fingerprint alignment", () => { stream: true, modelHeaders: { "User-Agent": "curl/8.7.1" }, }); - expect(normalizedHeaders["User-Agent"]).toBe( - `claude-cli/${claudeCodeVersion} (external, local-agent, agent-sdk/${claudeAgentSdkVersion})`, - ); + expect(normalizedHeaders["User-Agent"]).toBe(`claude-cli/${claudeCodeVersion} (external, claude-desktop)`); const embeddedClaudeCliHeaders = buildAnthropicHeaders({ apiKey: "sk-ant-oat-test", @@ -901,9 +928,7 @@ describe("Anthropic request fingerprint alignment", () => { stream: true, modelHeaders: { "User-Agent": "my-client claude-cli/2.1.63" }, }); - expect(embeddedClaudeCliHeaders["User-Agent"]).toBe( - `claude-cli/${claudeCodeVersion} (external, local-agent, agent-sdk/${claudeAgentSdkVersion})`, - ); + expect(embeddedClaudeCliHeaders["User-Agent"]).toBe(`claude-cli/${claudeCodeVersion} (external, claude-desktop)`); }); it("forwards model-supplied User-Agent on API-key requests", () => { @@ -1970,7 +1995,7 @@ describe("Anthropic request fingerprint alignment", () => { expect(options.defaultHeaders["X-Api-Key"]).toBeUndefined(); }); - it("applies Claude Code TLS profile for direct Anthropic transport", () => { + it("applies Cowork's TLS profile for direct Anthropic transport", () => { const options = buildAnthropicClientOptions({ model: ANTHROPIC_MODEL, apiKey: "sk-ant-oat-test",