/ services / api / client.ts
client.ts
  1  import Anthropic, { type ClientOptions } from '@anthropic-ai/sdk'
  2  import { randomUUID } from 'crypto'
  3  import type { GoogleAuth } from 'google-auth-library'
  4  import {
  5    checkAndRefreshOAuthTokenIfNeeded,
  6    getAnthropicApiKey,
  7    getApiKeyFromApiKeyHelper,
  8    getClaudeAIOAuthTokens,
  9    isClaudeAISubscriber,
 10    refreshAndGetAwsCredentials,
 11    refreshGcpCredentialsIfNeeded,
 12  } from 'src/utils/auth.js'
 13  import { getUserAgent } from 'src/utils/http.js'
 14  import { getSmallFastModel } from 'src/utils/model/model.js'
 15  import {
 16    getAPIProvider,
 17    isFirstPartyAnthropicBaseUrl,
 18  } from 'src/utils/model/providers.js'
 19  import { getProxyFetchOptions } from 'src/utils/proxy.js'
 20  import {
 21    getIsNonInteractiveSession,
 22    getSessionId,
 23  } from '../../bootstrap/state.js'
 24  import { getOauthConfig } from '../../constants/oauth.js'
 25  import { isDebugToStdErr, logForDebugging } from '../../utils/debug.js'
 26  import {
 27    getAWSRegion,
 28    getVertexRegionForModel,
 29    isEnvTruthy,
 30  } from '../../utils/envUtils.js'
 31  
 32  /**
 33   * Environment variables for different client types:
 34   *
 35   * Direct API:
 36   * - ANTHROPIC_API_KEY: Required for direct API access
 37   *
 38   * AWS Bedrock:
 39   * - AWS credentials configured via aws-sdk defaults
 40   * - AWS_REGION or AWS_DEFAULT_REGION: Sets the AWS region for all models (default: us-east-1)
 41   * - ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION: Optional. Override AWS region specifically for the small fast model (Haiku)
 42   *
 43   * Foundry (Azure):
 44   * - ANTHROPIC_FOUNDRY_RESOURCE: Your Azure resource name (e.g., 'my-resource')
 45   *   For the full endpoint: https://{resource}.services.ai.azure.com/anthropic/v1/messages
 46   * - ANTHROPIC_FOUNDRY_BASE_URL: Optional. Alternative to resource - provide full base URL directly
 47   *   (e.g., 'https://my-resource.services.ai.azure.com')
 48   *
 49   * Authentication (one of the following):
 50   * - ANTHROPIC_FOUNDRY_API_KEY: Your Microsoft Foundry API key (if using API key auth)
 51   * - Azure AD authentication: If no API key is provided, uses DefaultAzureCredential
 52   *   which supports multiple auth methods (environment variables, managed identity,
 53   *   Azure CLI, etc.). See: https://docs.microsoft.com/en-us/javascript/api/@azure/identity
 54   *
 55   * Vertex AI:
 56   * - Model-specific region variables (highest priority):
 57   *   - VERTEX_REGION_CLAUDE_3_5_HAIKU: Region for Claude 3.5 Haiku model
 58   *   - VERTEX_REGION_CLAUDE_HAIKU_4_5: Region for Claude Haiku 4.5 model
 59   *   - VERTEX_REGION_CLAUDE_3_5_SONNET: Region for Claude 3.5 Sonnet model
 60   *   - VERTEX_REGION_CLAUDE_3_7_SONNET: Region for Claude 3.7 Sonnet model
 61   * - CLOUD_ML_REGION: Optional. The default GCP region to use for all models
 62   *   If specific model region not specified above
 63   * - ANTHROPIC_VERTEX_PROJECT_ID: Required. Your GCP project ID
 64   * - Standard GCP credentials configured via google-auth-library
 65   *
 66   * Priority for determining region:
 67   * 1. Hardcoded model-specific environment variables
 68   * 2. Global CLOUD_ML_REGION variable
 69   * 3. Default region from config
 70   * 4. Fallback region (us-east5)
 71   */
 72  
 73  function createStderrLogger(): ClientOptions['logger'] {
 74    return {
 75      error: (msg, ...args) =>
 76        // biome-ignore lint/suspicious/noConsole:: intentional console output -- SDK logger must use console
 77        console.error('[Anthropic SDK ERROR]', msg, ...args),
 78      // biome-ignore lint/suspicious/noConsole:: intentional console output -- SDK logger must use console
 79      warn: (msg, ...args) => console.error('[Anthropic SDK WARN]', msg, ...args),
 80      // biome-ignore lint/suspicious/noConsole:: intentional console output -- SDK logger must use console
 81      info: (msg, ...args) => console.error('[Anthropic SDK INFO]', msg, ...args),
 82      debug: (msg, ...args) =>
 83        // biome-ignore lint/suspicious/noConsole:: intentional console output -- SDK logger must use console
 84        console.error('[Anthropic SDK DEBUG]', msg, ...args),
 85    }
 86  }
 87  
 88  export async function getAnthropicClient({
 89    apiKey,
 90    maxRetries,
 91    model,
 92    fetchOverride,
 93    source,
 94  }: {
 95    apiKey?: string
 96    maxRetries: number
 97    model?: string
 98    fetchOverride?: ClientOptions['fetch']
 99    source?: string
100  }): Promise<Anthropic> {
101    const containerId = process.env.CLAUDE_CODE_CONTAINER_ID
102    const remoteSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID
103    const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP
104    const customHeaders = getCustomHeaders()
105    const defaultHeaders: { [key: string]: string } = {
106      'x-app': 'cli',
107      'User-Agent': getUserAgent(),
108      'X-Claude-Code-Session-Id': getSessionId(),
109      ...customHeaders,
110      ...(containerId ? { 'x-claude-remote-container-id': containerId } : {}),
111      ...(remoteSessionId
112        ? { 'x-claude-remote-session-id': remoteSessionId }
113        : {}),
114      // SDK consumers can identify their app/library for backend analytics
115      ...(clientApp ? { 'x-client-app': clientApp } : {}),
116    }
117  
118    // Log API client configuration for HFI debugging
119    logForDebugging(
120      `[API:request] Creating client, ANTHROPIC_CUSTOM_HEADERS present: ${!!process.env.ANTHROPIC_CUSTOM_HEADERS}, has Authorization header: ${!!customHeaders['Authorization']}`,
121    )
122  
123    // Add additional protection header if enabled via env var
124    const additionalProtectionEnabled = isEnvTruthy(
125      process.env.CLAUDE_CODE_ADDITIONAL_PROTECTION,
126    )
127    if (additionalProtectionEnabled) {
128      defaultHeaders['x-anthropic-additional-protection'] = 'true'
129    }
130  
131    logForDebugging('[API:auth] OAuth token check starting')
132    await checkAndRefreshOAuthTokenIfNeeded()
133    logForDebugging('[API:auth] OAuth token check complete')
134  
135    if (!isClaudeAISubscriber()) {
136      await configureApiKeyHeaders(defaultHeaders, getIsNonInteractiveSession())
137    }
138  
139    const resolvedFetch = buildFetch(fetchOverride, source)
140  
141    const ARGS = {
142      defaultHeaders,
143      maxRetries,
144      timeout: parseInt(process.env.API_TIMEOUT_MS || String(600 * 1000), 10),
145      dangerouslyAllowBrowser: true,
146      fetchOptions: getProxyFetchOptions({
147        forAnthropicAPI: true,
148      }) as ClientOptions['fetchOptions'],
149      ...(resolvedFetch && {
150        fetch: resolvedFetch,
151      }),
152    }
153    if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)) {
154      const { AnthropicBedrock } = await import('@anthropic-ai/bedrock-sdk')
155      // Use region override for small fast model if specified
156      const awsRegion =
157        model === getSmallFastModel() &&
158        process.env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION
159          ? process.env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION
160          : getAWSRegion()
161  
162      const bedrockArgs: ConstructorParameters<typeof AnthropicBedrock>[0] = {
163        ...ARGS,
164        awsRegion,
165        ...(isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH) && {
166          skipAuth: true,
167        }),
168        ...(isDebugToStdErr() && { logger: createStderrLogger() }),
169      }
170  
171      // Add API key authentication if available
172      if (process.env.AWS_BEARER_TOKEN_BEDROCK) {
173        bedrockArgs.skipAuth = true
174        // Add the Bearer token for Bedrock API key authentication
175        bedrockArgs.defaultHeaders = {
176          ...bedrockArgs.defaultHeaders,
177          Authorization: `Bearer ${process.env.AWS_BEARER_TOKEN_BEDROCK}`,
178        }
179      } else if (!isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)) {
180        // Refresh auth and get credentials with cache clearing
181        const cachedCredentials = await refreshAndGetAwsCredentials()
182        if (cachedCredentials) {
183          bedrockArgs.awsAccessKey = cachedCredentials.accessKeyId
184          bedrockArgs.awsSecretKey = cachedCredentials.secretAccessKey
185          bedrockArgs.awsSessionToken = cachedCredentials.sessionToken
186        }
187      }
188      // we have always been lying about the return type - this doesn't support batching or models
189      return new AnthropicBedrock(bedrockArgs) as unknown as Anthropic
190    }
191    if (isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)) {
192      const { AnthropicFoundry } = await import('@anthropic-ai/foundry-sdk')
193      // Determine Azure AD token provider based on configuration
194      // SDK reads ANTHROPIC_FOUNDRY_API_KEY by default
195      let azureADTokenProvider: (() => Promise<string>) | undefined
196      if (!process.env.ANTHROPIC_FOUNDRY_API_KEY) {
197        if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_FOUNDRY_AUTH)) {
198          // Mock token provider for testing/proxy scenarios (similar to Vertex mock GoogleAuth)
199          azureADTokenProvider = () => Promise.resolve('')
200        } else {
201          // Use real Azure AD authentication with DefaultAzureCredential
202          const {
203            DefaultAzureCredential: AzureCredential,
204            getBearerTokenProvider,
205          } = await import('@azure/identity')
206          azureADTokenProvider = getBearerTokenProvider(
207            new AzureCredential(),
208            'https://cognitiveservices.azure.com/.default',
209          )
210        }
211      }
212  
213      const foundryArgs: ConstructorParameters<typeof AnthropicFoundry>[0] = {
214        ...ARGS,
215        ...(azureADTokenProvider && { azureADTokenProvider }),
216        ...(isDebugToStdErr() && { logger: createStderrLogger() }),
217      }
218      // we have always been lying about the return type - this doesn't support batching or models
219      return new AnthropicFoundry(foundryArgs) as unknown as Anthropic
220    }
221    if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)) {
222      // Refresh GCP credentials if gcpAuthRefresh is configured and credentials are expired
223      // This is similar to how we handle AWS credential refresh for Bedrock
224      if (!isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) {
225        await refreshGcpCredentialsIfNeeded()
226      }
227  
228      const [{ AnthropicVertex }, { GoogleAuth }] = await Promise.all([
229        import('@anthropic-ai/vertex-sdk'),
230        import('google-auth-library'),
231      ])
232      // TODO: Cache either GoogleAuth instance or AuthClient to improve performance
233      // Currently we create a new GoogleAuth instance for every getAnthropicClient() call
234      // This could cause repeated authentication flows and metadata server checks
235      // However, caching needs careful handling of:
236      // - Credential refresh/expiration
237      // - Environment variable changes (GOOGLE_APPLICATION_CREDENTIALS, project vars)
238      // - Cross-request auth state management
239      // See: https://github.com/googleapis/google-auth-library-nodejs/issues/390 for caching challenges
240  
241      // Prevent metadata server timeout by providing projectId as fallback
242      // google-auth-library checks project ID in this order:
243      // 1. Environment variables (GCLOUD_PROJECT, GOOGLE_CLOUD_PROJECT, etc.)
244      // 2. Credential files (service account JSON, ADC file)
245      // 3. gcloud config
246      // 4. GCE metadata server (causes 12s timeout outside GCP)
247      //
248      // We only set projectId if user hasn't configured other discovery methods
249      // to avoid interfering with their existing auth setup
250  
251      // Check project environment variables in same order as google-auth-library
252      // See: https://github.com/googleapis/google-auth-library-nodejs/blob/main/src/auth/googleauth.ts
253      const hasProjectEnvVar =
254        process.env['GCLOUD_PROJECT'] ||
255        process.env['GOOGLE_CLOUD_PROJECT'] ||
256        process.env['gcloud_project'] ||
257        process.env['google_cloud_project']
258  
259      // Check for credential file paths (service account or ADC)
260      // Note: We're checking both standard and lowercase variants to be safe,
261      // though we should verify what google-auth-library actually checks
262      const hasKeyFile =
263        process.env['GOOGLE_APPLICATION_CREDENTIALS'] ||
264        process.env['google_application_credentials']
265  
266      const googleAuth = isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)
267        ? ({
268            // Mock GoogleAuth for testing/proxy scenarios
269            getClient: () => ({
270              getRequestHeaders: () => ({}),
271            }),
272          } as unknown as GoogleAuth)
273        : new GoogleAuth({
274            scopes: ['https://www.googleapis.com/auth/cloud-platform'],
275            // Only use ANTHROPIC_VERTEX_PROJECT_ID as last resort fallback
276            // This prevents the 12-second metadata server timeout when:
277            // - No project env vars are set AND
278            // - No credential keyfile is specified AND
279            // - ADC file exists but lacks project_id field
280            //
281            // Risk: If auth project != API target project, this could cause billing/audit issues
282            // Mitigation: Users can set GOOGLE_CLOUD_PROJECT to override
283            ...(hasProjectEnvVar || hasKeyFile
284              ? {}
285              : {
286                  projectId: process.env.ANTHROPIC_VERTEX_PROJECT_ID,
287                }),
288          })
289  
290      const vertexArgs: ConstructorParameters<typeof AnthropicVertex>[0] = {
291        ...ARGS,
292        region: getVertexRegionForModel(model),
293        googleAuth,
294        ...(isDebugToStdErr() && { logger: createStderrLogger() }),
295      }
296      // we have always been lying about the return type - this doesn't support batching or models
297      return new AnthropicVertex(vertexArgs) as unknown as Anthropic
298    }
299  
300    // Determine authentication method based on available tokens
301    const clientConfig: ConstructorParameters<typeof Anthropic>[0] = {
302      apiKey: isClaudeAISubscriber() ? null : apiKey || getAnthropicApiKey(),
303      authToken: isClaudeAISubscriber()
304        ? getClaudeAIOAuthTokens()?.accessToken
305        : undefined,
306      // Set baseURL from OAuth config when using staging OAuth
307      ...(process.env.USER_TYPE === 'ant' &&
308      isEnvTruthy(process.env.USE_STAGING_OAUTH)
309        ? { baseURL: getOauthConfig().BASE_API_URL }
310        : {}),
311      ...ARGS,
312      ...(isDebugToStdErr() && { logger: createStderrLogger() }),
313    }
314  
315    return new Anthropic(clientConfig)
316  }
317  
318  async function configureApiKeyHeaders(
319    headers: Record<string, string>,
320    isNonInteractiveSession: boolean,
321  ): Promise<void> {
322    const token =
323      process.env.ANTHROPIC_AUTH_TOKEN ||
324      (await getApiKeyFromApiKeyHelper(isNonInteractiveSession))
325    if (token) {
326      headers['Authorization'] = `Bearer ${token}`
327    }
328  }
329  
330  function getCustomHeaders(): Record<string, string> {
331    const customHeaders: Record<string, string> = {}
332    const customHeadersEnv = process.env.ANTHROPIC_CUSTOM_HEADERS
333  
334    if (!customHeadersEnv) return customHeaders
335  
336    // Split by newlines to support multiple headers
337    const headerStrings = customHeadersEnv.split(/\n|\r\n/)
338  
339    for (const headerString of headerStrings) {
340      if (!headerString.trim()) continue
341  
342      // Parse header in format "Name: Value" (curl style). Split on first `:`
343      // then trim — avoids regex backtracking on malformed long header lines.
344      const colonIdx = headerString.indexOf(':')
345      if (colonIdx === -1) continue
346      const name = headerString.slice(0, colonIdx).trim()
347      const value = headerString.slice(colonIdx + 1).trim()
348      if (name) {
349        customHeaders[name] = value
350      }
351    }
352  
353    return customHeaders
354  }
355  
356  export const CLIENT_REQUEST_ID_HEADER = 'x-client-request-id'
357  
358  function buildFetch(
359    fetchOverride: ClientOptions['fetch'],
360    source: string | undefined,
361  ): ClientOptions['fetch'] {
362    // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
363    const inner = fetchOverride ?? globalThis.fetch
364    // Only send to the first-party API — Bedrock/Vertex/Foundry don't log it
365    // and unknown headers risk rejection by strict proxies (inc-4029 class).
366    const injectClientRequestId =
367      getAPIProvider() === 'firstParty' && isFirstPartyAnthropicBaseUrl()
368    return (input, init) => {
369      // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
370      const headers = new Headers(init?.headers)
371      // Generate a client-side request ID so timeouts (which return no server
372      // request ID) can still be correlated with server logs by the API team.
373      // Callers that want to track the ID themselves can pre-set the header.
374      if (injectClientRequestId && !headers.has(CLIENT_REQUEST_ID_HEADER)) {
375        headers.set(CLIENT_REQUEST_ID_HEADER, randomUUID())
376      }
377      try {
378        // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
379        const url = input instanceof Request ? input.url : String(input)
380        const id = headers.get(CLIENT_REQUEST_ID_HEADER)
381        logForDebugging(
382          `[API REQUEST] ${new URL(url).pathname}${id ? ` ${CLIENT_REQUEST_ID_HEADER}=${id}` : ''} source=${source ?? 'unknown'}`,
383        )
384      } catch {
385        // never let logging crash the fetch
386      }
387      return inner(input, { ...init, headers })
388    }
389  }