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:
can1357
2026-07-30 17:02:26 +02:00
parent 14dc5d4fcc
commit 642892ae5c
7 changed files with 350 additions and 127 deletions
+4
View File
@@ -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
+51 -71
View File
@@ -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;
+201
View File
@@ -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);
};
+12 -4
View File
@@ -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;
+6 -2
View File
@@ -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);
+64 -39
View File
@@ -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",