/** * HTTP utility constants and helpers */ import { OAUTH_BETA_HEADER } from '../constants/oauth.js' import { getAnthropicApiKey, getClaudeAIOAuthTokens, handleOAuth401Error, isClaudeAISubscriber, } from './auth.js' import { getClaudeCodeUserAgent } from './userAgent.js' import { getWorkload } from './workloadContext.js' // WARNING: We rely on `claude-cli` in the user agent for log filtering. // Please do NOT change this without making sure that logging also gets updated! export function getUserAgent(): string { const agentSdkVersion = process.env.CLAUDE_AGENT_SDK_VERSION ? `, agent-sdk/${process.env.CLAUDE_AGENT_SDK_VERSION}` : '' // SDK consumers can identify their app/library via CLAUDE_AGENT_SDK_CLIENT_APP // e.g., "my-app/1.0.0" or "my-library/2.1" const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP ? `, client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}` : '' // Turn-/process-scoped workload tag for cron-initiated requests. 1P-only // observability — proxies strip HTTP headers; QoS routing uses cc_workload // in the billing-header attribution block instead (see constants/system.ts). // getAnthropicClient (client.ts:98) calls this per-request inside withRetry, // so the read picks up the same setWorkload() value as getAttributionHeader. const workload = getWorkload() const workloadSuffix = workload ? `, workload/${workload}` : '' return `claude-cli/0.1.0-alpha (${process.env.USER_TYPE}, ${process.env.CLAUDE_CODE_ENTRYPOINT ?? 'cli'}${agentSdkVersion}${clientApp}${workloadSuffix})` } export function getMCPUserAgent(): string { const parts: string[] = [] if (process.env.CLAUDE_CODE_ENTRYPOINT) { parts.push(process.env.CLAUDE_CODE_ENTRYPOINT) } if (process.env.CLAUDE_AGENT_SDK_VERSION) { parts.push(`agent-sdk/${process.env.CLAUDE_AGENT_SDK_VERSION}`) } if (process.env.CLAUDE_AGENT_SDK_CLIENT_APP) { parts.push(`client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}`) } const suffix = parts.length > 0 ? ` (${parts.join(', ')})` : '' return `claude-code/0.1.0-alpha${suffix}` } // User-Agent for WebFetch requests to arbitrary sites. `Claude-User` is // Anthropic's publicly documented agent for user-initiated fetches (what site // operators match in robots.txt); the claude-code suffix lets them distinguish // local CLI traffic from claude.ai server-side fetches. export function getWebFetchUserAgent(): string { return `Claude-User (${getClaudeCodeUserAgent()}; +https://support.anthropic.com/)` } export type AuthHeaders = { headers: Record error?: string } /** * Get authentication headers for API requests * Returns either OAuth headers for Max/Pro users or API key headers for regular users */ export function getAuthHeaders(): AuthHeaders { if (isClaudeAISubscriber()) { const oauthTokens = getClaudeAIOAuthTokens() if (!oauthTokens?.accessToken) { return { headers: {}, error: 'No OAuth token available', } } return { headers: { Authorization: `Bearer ${oauthTokens.accessToken}`, 'anthropic-beta': OAUTH_BETA_HEADER, }, } } // TODO: this will fail if the API key is being set to an LLM Gateway key // should we try to query keychain / credentials for a valid Anthropic key? const apiKey = getAnthropicApiKey() if (!apiKey) { return { headers: {}, error: 'No API key available', } } return { headers: { 'x-api-key': apiKey, }, } } export class HttpError extends Error { constructor( message: string, public status?: number, public data?: any, public headers?: Record, public code?: string, ) { super(message) this.name = 'HttpError' } } export function isHttpError(error: unknown): error is HttpError { return error instanceof HttpError } export type NativeRequestOptions = { method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' headers?: Record body?: any timeout?: number signal?: AbortSignal responseType?: 'json' | 'arraybuffer' | 'text' | 'none' dispatcher?: any // undici.Dispatcher } export async function nativeRequest( url: string, options: NativeRequestOptions = {}, ): Promise<{ data: T; status: number; headers: Record }> { const { method = 'GET', headers = {}, body, timeout, responseType = 'json', dispatcher, } = options const controller = new AbortController() const timeoutId = timeout ? setTimeout(() => controller.abort(), timeout) : null try { const fetchOptions: RequestInit = { method, headers: { ...headers, }, signal: options.signal || controller.signal, ...(dispatcher ? { dispatcher } : {}), } as RequestInit if (body) { if ( body instanceof Buffer || body instanceof Uint8Array || body instanceof Blob || body instanceof FormData ) { fetchOptions.body = body as any } else { if (!fetchOptions.headers) fetchOptions.headers = {} ;(fetchOptions.headers as any)['Content-Type'] = 'application/json' fetchOptions.body = JSON.stringify(body) } } const response = await fetch(url, fetchOptions) let responseData: any if (responseType === 'arraybuffer') { responseData = await response.arrayBuffer() } else if (responseType === 'text') { responseData = await response.text() } else if (responseType === 'none') { responseData = null } else { responseData = await response.json().catch(() => null) } const responseHeaders: Record = {} response.headers.forEach((value, key) => { responseHeaders[key] = value }) if (!response.ok) { throw new HttpError( `HTTP Error ${response.status}`, response.status, responseData, responseHeaders, ) } return { data: responseData as T, status: response.status, headers: responseHeaders, } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw new HttpError('Request aborted/timeout', 408, null, {}, 'ECONNABORTED') } throw error } finally { if (timeoutId) clearTimeout(timeoutId) } } /** * Wrapper that handles OAuth 401 errors by force-refreshing the token and * retrying once. Addresses clock drift scenarios where the local expiration * check disagrees with the server. * * The request closure is called again on retry, so it should re-read auth * (e.g., via getAuthHeaders()) to pick up the refreshed token. * * Note: bridgeApi.ts has its own DI-injected version — handleOAuth401Error * transitively pulls in config.ts (~1300 modules), which breaks the SDK bundle. * * @param opts.also403Revoked - Also retry on 403 with "OAuth token has been * revoked" body (some endpoints signal revocation this way instead of 401). */ export async function withOAuth401Retry( request: () => Promise, opts?: { also403Revoked?: boolean }, ): Promise { try { return await request() } catch (err) { if (!isHttpError(err)) throw err const status = err.status const isAuthError = status === 401 || (opts?.also403Revoked && status === 403 && typeof err.data === 'string' && err.data.includes('OAuth token has been revoked')) if (!isAuthError) throw err const failedAccessToken = getClaudeAIOAuthTokens()?.accessToken if (!failedAccessToken) throw err await handleOAuth401Error(failedAccessToken) return await request() } }