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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<string, string>, 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<s
|
||||
const incomingUserAgent = getHeaderCaseInsensitive(options.modelHeaders, "User-Agent");
|
||||
const incomingAuthorization = getHeaderCaseInsensitive(options.modelHeaders, "Authorization");
|
||||
const incomingApiKey = getHeaderCaseInsensitive(options.modelHeaders, "X-Api-Key");
|
||||
// Claude Code betas (oauth-2025-04-20, claude-code-20250219, …) are part of
|
||||
// the OAuth fingerprint; API-key requests default to extras only, matching
|
||||
// the streaming path (buildAnthropicClientOptions passes [] for non-OAuth).
|
||||
// Cowork's beta profile is part of the OAuth fingerprint; API-key requests
|
||||
// default to extras only, matching the streaming path.
|
||||
const betaHeader = buildBetaHeader(
|
||||
options.claudeCodeBetas ?? (oauthToken ? buildClaudeCodeBetas(true, true, false) : []),
|
||||
options.coworkBetas ?? (oauthToken ? buildCoworkBetas(true, true, false) : []),
|
||||
extraBetas,
|
||||
);
|
||||
const acceptHeader = oauthToken ? "application/json" : stream ? "text/event-stream" : "application/json";
|
||||
@@ -308,19 +303,22 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
|
||||
}
|
||||
|
||||
if (oauthToken) {
|
||||
const userAgent = isClaudeCodeClientUserAgent(incomingUserAgent)
|
||||
? incomingUserAgent
|
||||
: `claude-cli/${claudeCodeVersion} (external, local-agent, agent-sdk/${claudeAgentSdkVersion})`;
|
||||
const userAgent = isClaudeCodeClientUserAgent(incomingUserAgent) ? incomingUserAgent : coworkUserAgent;
|
||||
const headers = {
|
||||
...modelHeaders,
|
||||
...claudeCodeHeaders,
|
||||
Accept: acceptHeader,
|
||||
Authorization: `Bearer ${options.apiKey}`,
|
||||
...sharedHeaders,
|
||||
...(betaHeader ? { "anthropic-beta": betaHeader } : {}),
|
||||
...(options.claudeCodeSessionId ? { "X-Claude-Code-Session-Id": options.claudeCodeSessionId } : {}),
|
||||
"x-client-request-id": nodeCrypto.randomUUID(),
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": userAgent,
|
||||
...(options.claudeCodeSessionId ? { "X-Claude-Code-Session-Id": options.claudeCodeSessionId } : {}),
|
||||
...coworkHeaders,
|
||||
...(betaHeader ? { "anthropic-beta": betaHeader } : {}),
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
"anthropic-version": "2023-06-01",
|
||||
Authorization: `Bearer ${options.apiKey}`,
|
||||
"x-app": "cli",
|
||||
"x-client-request-id": nodeCrypto.randomUUID(),
|
||||
Connection: "keep-alive",
|
||||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||||
...(incomingApiKey ? { "X-Api-Key": incomingApiKey } : {}),
|
||||
};
|
||||
return allowAnthropicHeaderOverrides ? mergeHeaders(headers, anthropicHeaderOverrides) : headers;
|
||||
@@ -503,26 +501,10 @@ function getCacheControl(
|
||||
};
|
||||
}
|
||||
|
||||
// Stealth mode: mimic Claude Code's request fingerprint. Constants live in the
|
||||
// leaf module so registry/usage consumers avoid an init cycle through this file.
|
||||
// Cowork mode: mimic the desktop agent's direct inference transport. Constants
|
||||
// live in the leaf module so registry/usage consumers avoid an init cycle.
|
||||
export * from "./claude-code-fingerprint";
|
||||
|
||||
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":
|
||||
@@ -540,22 +522,21 @@ export function mapStainlessArch(arch: string): "x64" | "arm64" | "x86" | `other
|
||||
}
|
||||
}
|
||||
|
||||
export const claudeCodeHeaders = {
|
||||
"X-Stainless-Retry-Count": "0",
|
||||
"X-Stainless-Runtime-Version": "v24.3.0",
|
||||
"X-Stainless-Package-Version": "0.94.0",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Lang": "js",
|
||||
/** Static headers emitted by Cowork's Linux Claude runtime. */
|
||||
export const coworkHeaders = {
|
||||
"X-Stainless-Arch": mapStainlessArch(process.arch),
|
||||
"X-Stainless-OS": mapStainlessOs(process.platform),
|
||||
"X-Stainless-Timeout": "900",
|
||||
"anthropic-client-platform": "desktop_app",
|
||||
"anthropic-client-version": claudeClientVersion,
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-OS": "Linux",
|
||||
"X-Stainless-Package-Version": "0.94.0",
|
||||
"X-Stainless-Retry-Count": "0",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Runtime-Version": "v26.3.0",
|
||||
"X-Stainless-Timeout": "600",
|
||||
};
|
||||
|
||||
const enforcedHeaderKeys = new Set(
|
||||
[
|
||||
...Object.keys(claudeCodeHeaders),
|
||||
...Object.keys(coworkHeaders),
|
||||
"Accept",
|
||||
"Accept-Encoding",
|
||||
"Connection",
|
||||
@@ -574,7 +555,7 @@ const enforcedHeaderKeys = new Set(
|
||||
);
|
||||
|
||||
const overridableAnthropicHeaderKeys = new Set(
|
||||
[...Object.keys(claudeCodeHeaders), "anthropic-beta", "User-Agent", "x-app"].map(key => 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,
|
||||
)
|
||||
: [],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string> {
|
||||
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<string, string>,
|
||||
body: RequestBody | undefined,
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
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<AgentLease> {
|
||||
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<string, string>,
|
||||
body: RequestBody | undefined,
|
||||
): Promise<Response> {
|
||||
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<Response>();
|
||||
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);
|
||||
};
|
||||
@@ -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<Api>): FetchImpl {
|
||||
if (model.provider === "anthropic" && model.api === "anthropic-messages") return coworkFetch;
|
||||
return globalThis.fetch;
|
||||
}
|
||||
|
||||
function isGoogleVertexAuthenticatedModel(model: Model<Api>): boolean {
|
||||
return (
|
||||
model.provider === "google-vertex" &&
|
||||
@@ -769,11 +775,12 @@ function streamDispatch<TApi extends Api>(
|
||||
context: Context,
|
||||
options?: OptionsForApi<TApi>,
|
||||
): 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<TApi>;
|
||||
assertExplicitOpenAIResponsesPromptCacheSupport(model, requestOptions);
|
||||
|
||||
@@ -1009,11 +1016,12 @@ export function streamSimple<TApi extends Api>(
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user