/ utils / http.ts
http.ts
  1  /**
  2   * HTTP utility constants and helpers
  3   */
  4  
  5  import axios from 'axios'
  6  import { OAUTH_BETA_HEADER } from '../constants/oauth.js'
  7  import {
  8    getAnthropicApiKey,
  9    getClaudeAIOAuthTokens,
 10    handleOAuth401Error,
 11    isClaudeAISubscriber,
 12  } from './auth.js'
 13  import { getClaudeCodeUserAgent } from './userAgent.js'
 14  import { getWorkload } from './workloadContext.js'
 15  
 16  // WARNING: We rely on `claude-cli` in the user agent for log filtering.
 17  // Please do NOT change this without making sure that logging also gets updated!
 18  export function getUserAgent(): string {
 19    const agentSdkVersion = process.env.CLAUDE_AGENT_SDK_VERSION
 20      ? `, agent-sdk/${process.env.CLAUDE_AGENT_SDK_VERSION}`
 21      : ''
 22    // SDK consumers can identify their app/library via CLAUDE_AGENT_SDK_CLIENT_APP
 23    // e.g., "my-app/1.0.0" or "my-library/2.1"
 24    const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP
 25      ? `, client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}`
 26      : ''
 27    // Turn-/process-scoped workload tag for cron-initiated requests. 1P-only
 28    // observability — proxies strip HTTP headers; QoS routing uses cc_workload
 29    // in the billing-header attribution block instead (see constants/system.ts).
 30    // getAnthropicClient (client.ts:98) calls this per-request inside withRetry,
 31    // so the read picks up the same setWorkload() value as getAttributionHeader.
 32    const workload = getWorkload()
 33    const workloadSuffix = workload ? `, workload/${workload}` : ''
 34    return `claude-cli/${MACRO.VERSION} (${process.env.USER_TYPE}, ${process.env.CLAUDE_CODE_ENTRYPOINT ?? 'cli'}${agentSdkVersion}${clientApp}${workloadSuffix})`
 35  }
 36  
 37  export function getMCPUserAgent(): string {
 38    const parts: string[] = []
 39    if (process.env.CLAUDE_CODE_ENTRYPOINT) {
 40      parts.push(process.env.CLAUDE_CODE_ENTRYPOINT)
 41    }
 42    if (process.env.CLAUDE_AGENT_SDK_VERSION) {
 43      parts.push(`agent-sdk/${process.env.CLAUDE_AGENT_SDK_VERSION}`)
 44    }
 45    if (process.env.CLAUDE_AGENT_SDK_CLIENT_APP) {
 46      parts.push(`client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}`)
 47    }
 48    const suffix = parts.length > 0 ? ` (${parts.join(', ')})` : ''
 49    return `claude-code/${MACRO.VERSION}${suffix}`
 50  }
 51  
 52  // User-Agent for WebFetch requests to arbitrary sites. `Claude-User` is
 53  // Anthropic's publicly documented agent for user-initiated fetches (what site
 54  // operators match in robots.txt); the claude-code suffix lets them distinguish
 55  // local CLI traffic from claude.ai server-side fetches.
 56  export function getWebFetchUserAgent(): string {
 57    return `Claude-User (${getClaudeCodeUserAgent()}; +https://support.anthropic.com/)`
 58  }
 59  
 60  export type AuthHeaders = {
 61    headers: Record<string, string>
 62    error?: string
 63  }
 64  
 65  /**
 66   * Get authentication headers for API requests
 67   * Returns either OAuth headers for Max/Pro users or API key headers for regular users
 68   */
 69  export function getAuthHeaders(): AuthHeaders {
 70    if (isClaudeAISubscriber()) {
 71      const oauthTokens = getClaudeAIOAuthTokens()
 72      if (!oauthTokens?.accessToken) {
 73        return {
 74          headers: {},
 75          error: 'No OAuth token available',
 76        }
 77      }
 78      return {
 79        headers: {
 80          Authorization: `Bearer ${oauthTokens.accessToken}`,
 81          'anthropic-beta': OAUTH_BETA_HEADER,
 82        },
 83      }
 84    }
 85    // TODO: this will fail if the API key is being set to an LLM Gateway key
 86    // should we try to query keychain / credentials for a valid Anthropic key?
 87    const apiKey = getAnthropicApiKey()
 88    if (!apiKey) {
 89      return {
 90        headers: {},
 91        error: 'No API key available',
 92      }
 93    }
 94    return {
 95      headers: {
 96        'x-api-key': apiKey,
 97      },
 98    }
 99  }
100  
101  /**
102   * Wrapper that handles OAuth 401 errors by force-refreshing the token and
103   * retrying once. Addresses clock drift scenarios where the local expiration
104   * check disagrees with the server.
105   *
106   * The request closure is called again on retry, so it should re-read auth
107   * (e.g., via getAuthHeaders()) to pick up the refreshed token.
108   *
109   * Note: bridgeApi.ts has its own DI-injected version — handleOAuth401Error
110   * transitively pulls in config.ts (~1300 modules), which breaks the SDK bundle.
111   *
112   * @param opts.also403Revoked - Also retry on 403 with "OAuth token has been
113   *   revoked" body (some endpoints signal revocation this way instead of 401).
114   */
115  export async function withOAuth401Retry<T>(
116    request: () => Promise<T>,
117    opts?: { also403Revoked?: boolean },
118  ): Promise<T> {
119    try {
120      return await request()
121    } catch (err) {
122      if (!axios.isAxiosError(err)) throw err
123      const status = err.response?.status
124      const isAuthError =
125        status === 401 ||
126        (opts?.also403Revoked &&
127          status === 403 &&
128          typeof err.response?.data === 'string' &&
129          err.response.data.includes('OAuth token has been revoked'))
130      if (!isAuthError) throw err
131      const failedAccessToken = getClaudeAIOAuthTokens()?.accessToken
132      if (!failedAccessToken) throw err
133      await handleOAuth401Error(failedAccessToken)
134      return await request()
135    }
136  }