/ services / api / bootstrap.ts
bootstrap.ts
  1  import axios from 'axios'
  2  import isEqual from 'lodash-es/isEqual.js'
  3  import {
  4    getAnthropicApiKey,
  5    getClaudeAIOAuthTokens,
  6    hasProfileScope,
  7  } from 'src/utils/auth.js'
  8  import { z } from 'zod'
  9  import { getOauthConfig, OAUTH_BETA_HEADER } from '../../constants/oauth.js'
 10  import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
 11  import { logForDebugging } from '../../utils/debug.js'
 12  import { withOAuth401Retry } from '../../utils/http.js'
 13  import { lazySchema } from '../../utils/lazySchema.js'
 14  import { logError } from '../../utils/log.js'
 15  import { getAPIProvider } from '../../utils/model/providers.js'
 16  import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js'
 17  import { getClaudeCodeUserAgent } from '../../utils/userAgent.js'
 18  
 19  const bootstrapResponseSchema = lazySchema(() =>
 20    z.object({
 21      client_data: z.record(z.unknown()).nullish(),
 22      additional_model_options: z
 23        .array(
 24          z
 25            .object({
 26              model: z.string(),
 27              name: z.string(),
 28              description: z.string(),
 29            })
 30            .transform(({ model, name, description }) => ({
 31              value: model,
 32              label: name,
 33              description,
 34            })),
 35        )
 36        .nullish(),
 37    }),
 38  )
 39  
 40  type BootstrapResponse = z.infer<ReturnType<typeof bootstrapResponseSchema>>
 41  
 42  async function fetchBootstrapAPI(): Promise<BootstrapResponse | null> {
 43    if (isEssentialTrafficOnly()) {
 44      logForDebugging('[Bootstrap] Skipped: Nonessential traffic disabled')
 45      return null
 46    }
 47  
 48    if (getAPIProvider() !== 'firstParty') {
 49      logForDebugging('[Bootstrap] Skipped: 3P provider')
 50      return null
 51    }
 52  
 53    // OAuth preferred (requires user:profile scope — service-key OAuth tokens
 54    // lack it and would 403). Fall back to API key auth for console users.
 55    const apiKey = getAnthropicApiKey()
 56    const hasUsableOAuth =
 57      getClaudeAIOAuthTokens()?.accessToken && hasProfileScope()
 58    if (!hasUsableOAuth && !apiKey) {
 59      logForDebugging('[Bootstrap] Skipped: no usable OAuth or API key')
 60      return null
 61    }
 62  
 63    const endpoint = `${getOauthConfig().BASE_API_URL}/api/claude_cli/bootstrap`
 64  
 65    // withOAuth401Retry handles the refresh-and-retry. API key users fail
 66    // through on 401 (no refresh mechanism — no OAuth token to pass).
 67    try {
 68      return await withOAuth401Retry(async () => {
 69        // Re-read OAuth each call so the retry picks up the refreshed token.
 70        const token = getClaudeAIOAuthTokens()?.accessToken
 71        let authHeaders: Record<string, string>
 72        if (token && hasProfileScope()) {
 73          authHeaders = {
 74            Authorization: `Bearer ${token}`,
 75            'anthropic-beta': OAUTH_BETA_HEADER,
 76          }
 77        } else if (apiKey) {
 78          authHeaders = { 'x-api-key': apiKey }
 79        } else {
 80          logForDebugging('[Bootstrap] No auth available on retry, aborting')
 81          return null
 82        }
 83  
 84        logForDebugging('[Bootstrap] Fetching')
 85        const response = await axios.get<unknown>(endpoint, {
 86          headers: {
 87            'Content-Type': 'application/json',
 88            'User-Agent': getClaudeCodeUserAgent(),
 89            ...authHeaders,
 90          },
 91          timeout: 5000,
 92        })
 93        const parsed = bootstrapResponseSchema().safeParse(response.data)
 94        if (!parsed.success) {
 95          logForDebugging(
 96            `[Bootstrap] Response failed validation: ${parsed.error.message}`,
 97          )
 98          return null
 99        }
100        logForDebugging('[Bootstrap] Fetch ok')
101        return parsed.data
102      })
103    } catch (error) {
104      logForDebugging(
105        `[Bootstrap] Fetch failed: ${axios.isAxiosError(error) ? (error.response?.status ?? error.code) : 'unknown'}`,
106      )
107      throw error
108    }
109  }
110  
111  /**
112   * Fetch bootstrap data from the API and persist to disk cache.
113   */
114  export async function fetchBootstrapData(): Promise<void> {
115    try {
116      const response = await fetchBootstrapAPI()
117      if (!response) return
118  
119      const clientData = response.client_data ?? null
120      const additionalModelOptions = response.additional_model_options ?? []
121  
122      // Only persist if data actually changed — avoids a config write on every startup.
123      const config = getGlobalConfig()
124      if (
125        isEqual(config.clientDataCache, clientData) &&
126        isEqual(config.additionalModelOptionsCache, additionalModelOptions)
127      ) {
128        logForDebugging('[Bootstrap] Cache unchanged, skipping write')
129        return
130      }
131  
132      logForDebugging('[Bootstrap] Cache updated, persisting to disk')
133      saveGlobalConfig(current => ({
134        ...current,
135        clientDataCache: clientData,
136        additionalModelOptionsCache: additionalModelOptions,
137      }))
138    } catch (error) {
139      logError(error)
140    }
141  }