248 lines
7.5 KiB
TypeScript
248 lines
7.5 KiB
TypeScript
/**
|
|
* 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<string, string>
|
|
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<string, string>,
|
|
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<string, string>
|
|
body?: any
|
|
timeout?: number
|
|
signal?: AbortSignal
|
|
responseType?: 'json' | 'arraybuffer' | 'text' | 'none'
|
|
dispatcher?: any // undici.Dispatcher
|
|
}
|
|
|
|
export async function nativeRequest<T>(
|
|
url: string,
|
|
options: NativeRequestOptions = {},
|
|
): Promise<{ data: T; status: number; headers: Record<string, string> }> {
|
|
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<string, string> = {}
|
|
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<T>(
|
|
request: () => Promise<T>,
|
|
opts?: { also403Revoked?: boolean },
|
|
): Promise<T> {
|
|
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()
|
|
}
|
|
}
|