/ utils / fastMode.ts
fastMode.ts
  1  import axios from 'axios'
  2  import { getOauthConfig, OAUTH_BETA_HEADER } from 'src/constants/oauth.js'
  3  import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
  4  import {
  5    getIsNonInteractiveSession,
  6    getKairosActive,
  7    preferThirdPartyAuthentication,
  8  } from '../bootstrap/state.js'
  9  import {
 10    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 11    logEvent,
 12  } from '../services/analytics/index.js'
 13  import {
 14    getAnthropicApiKey,
 15    getClaudeAIOAuthTokens,
 16    handleOAuth401Error,
 17    hasProfileScope,
 18  } from './auth.js'
 19  import { isInBundledMode } from './bundledMode.js'
 20  import { getGlobalConfig, saveGlobalConfig } from './config.js'
 21  import { logForDebugging } from './debug.js'
 22  import { isEnvTruthy } from './envUtils.js'
 23  import {
 24    getDefaultMainLoopModelSetting,
 25    isOpus1mMergeEnabled,
 26    type ModelSetting,
 27    parseUserSpecifiedModel,
 28  } from './model/model.js'
 29  import { getAPIProvider } from './model/providers.js'
 30  import { isEssentialTrafficOnly } from './privacyLevel.js'
 31  import {
 32    getInitialSettings,
 33    getSettingsForSource,
 34    updateSettingsForSource,
 35  } from './settings/settings.js'
 36  import { createSignal } from './signal.js'
 37  
 38  export function isFastModeEnabled(): boolean {
 39    return !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FAST_MODE)
 40  }
 41  
 42  export function isFastModeAvailable(): boolean {
 43    if (!isFastModeEnabled()) {
 44      return false
 45    }
 46    return getFastModeUnavailableReason() === null
 47  }
 48  
 49  type AuthType = 'oauth' | 'api-key'
 50  
 51  function getDisabledReasonMessage(
 52    disabledReason: FastModeDisabledReason,
 53    authType: AuthType,
 54  ): string {
 55    switch (disabledReason) {
 56      case 'free':
 57        return authType === 'oauth'
 58          ? 'Fast mode requires a paid subscription'
 59          : 'Fast mode unavailable during evaluation. Please purchase credits.'
 60      case 'preference':
 61        return 'Fast mode has been disabled by your organization'
 62      case 'extra_usage_disabled':
 63        // Only OAuth users can have extra_usage_disabled; console users don't have this concept
 64        return 'Fast mode requires extra usage billing · /extra-usage to enable'
 65      case 'network_error':
 66        return 'Fast mode unavailable due to network connectivity issues'
 67      case 'unknown':
 68        return 'Fast mode is currently unavailable'
 69    }
 70  }
 71  
 72  export function getFastModeUnavailableReason(): string | null {
 73    if (!isFastModeEnabled()) {
 74      return 'Fast mode is not available'
 75    }
 76  
 77    const statigReason = getFeatureValue_CACHED_MAY_BE_STALE(
 78      'tengu_penguins_off',
 79      null,
 80    )
 81    // Statsig reason has priority over other reasons.
 82    if (statigReason !== null) {
 83      logForDebugging(`Fast mode unavailable: ${statigReason}`)
 84      return statigReason
 85    }
 86  
 87    // Previously, fast mode required the native binary (bun build). This is no
 88    // longer necessary, but we keep this option behind a flag just in case.
 89    if (
 90      !isInBundledMode() &&
 91      getFeatureValue_CACHED_MAY_BE_STALE('tengu_marble_sandcastle', false)
 92    ) {
 93      return 'Fast mode requires the native binary · Install from: https://claude.com/product/claude-code'
 94    }
 95  
 96    // Not available in the SDK unless explicitly opted in via --settings.
 97    // Assistant daemon mode is exempt — it's first-party orchestration, and
 98    // kairosActive is set before this check runs (main.tsx:~1626 vs ~3249).
 99    if (
100      getIsNonInteractiveSession() &&
101      preferThirdPartyAuthentication() &&
102      !getKairosActive()
103    ) {
104      const flagFastMode = getSettingsForSource('flagSettings')?.fastMode
105      if (!flagFastMode) {
106        const reason = 'Fast mode is not available in the Agent SDK'
107        logForDebugging(`Fast mode unavailable: ${reason}`)
108        return reason
109      }
110    }
111  
112    // Only available for 1P (not Bedrock/Vertex/Foundry)
113    if (getAPIProvider() !== 'firstParty') {
114      const reason = 'Fast mode is not available on Bedrock, Vertex, or Foundry'
115      logForDebugging(`Fast mode unavailable: ${reason}`)
116      return reason
117    }
118  
119    if (orgStatus.status === 'disabled') {
120      if (
121        orgStatus.reason === 'network_error' ||
122        orgStatus.reason === 'unknown'
123      ) {
124        // The org check can fail behind corporate proxies that block the
125        // endpoint. We add CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS=1 to
126        // bypass this check in the CC binary. This is OK since we have
127        // another check in the API to error out when disabled by org.
128        if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS)) {
129          return null
130        }
131      }
132      const authType: AuthType =
133        getClaudeAIOAuthTokens() !== null ? 'oauth' : 'api-key'
134      const reason = getDisabledReasonMessage(orgStatus.reason, authType)
135      logForDebugging(`Fast mode unavailable: ${reason}`)
136      return reason
137    }
138  
139    return null
140  }
141  
142  // @[MODEL LAUNCH]: Update supported Fast Mode models.
143  export const FAST_MODE_MODEL_DISPLAY = 'Opus 4.6'
144  
145  export function getFastModeModel(): string {
146    return 'opus' + (isOpus1mMergeEnabled() ? '[1m]' : '')
147  }
148  
149  export function getInitialFastModeSetting(model: ModelSetting): boolean {
150    if (!isFastModeEnabled()) {
151      return false
152    }
153    if (!isFastModeAvailable()) {
154      return false
155    }
156    if (!isFastModeSupportedByModel(model)) {
157      return false
158    }
159    const settings = getInitialSettings()
160    // If per-session opt-in is required, fast mode starts off each session
161    if (settings.fastModePerSessionOptIn) {
162      return false
163    }
164    return settings.fastMode === true
165  }
166  
167  export function isFastModeSupportedByModel(
168    modelSetting: ModelSetting,
169  ): boolean {
170    if (!isFastModeEnabled()) {
171      return false
172    }
173    const model = modelSetting ?? getDefaultMainLoopModelSetting()
174    const parsedModel = parseUserSpecifiedModel(model)
175    return parsedModel.toLowerCase().includes('opus-4-6')
176  }
177  
178  // --- Fast mode runtime state ---
179  // Separate from user preference (settings.fastMode). This tracks the actual
180  // operational state: whether we're actively sending fast speed or in cooldown
181  // after a rate limit.
182  
183  export type FastModeRuntimeState =
184    | { status: 'active' }
185    | { status: 'cooldown'; resetAt: number; reason: CooldownReason }
186  
187  let runtimeState: FastModeRuntimeState = { status: 'active' }
188  let hasLoggedCooldownExpiry = false
189  
190  // --- Cooldown event listeners ---
191  export type CooldownReason = 'rate_limit' | 'overloaded'
192  
193  const cooldownTriggered =
194    createSignal<[resetAt: number, reason: CooldownReason]>()
195  const cooldownExpired = createSignal()
196  export const onCooldownTriggered = cooldownTriggered.subscribe
197  export const onCooldownExpired = cooldownExpired.subscribe
198  
199  export function getFastModeRuntimeState(): FastModeRuntimeState {
200    if (
201      runtimeState.status === 'cooldown' &&
202      Date.now() >= runtimeState.resetAt
203    ) {
204      if (isFastModeEnabled() && !hasLoggedCooldownExpiry) {
205        logForDebugging('Fast mode cooldown expired, re-enabling fast mode')
206        hasLoggedCooldownExpiry = true
207        cooldownExpired.emit()
208      }
209      runtimeState = { status: 'active' }
210    }
211    return runtimeState
212  }
213  
214  export function triggerFastModeCooldown(
215    resetTimestamp: number,
216    reason: CooldownReason,
217  ): void {
218    if (!isFastModeEnabled()) {
219      return
220    }
221    runtimeState = { status: 'cooldown', resetAt: resetTimestamp, reason }
222    hasLoggedCooldownExpiry = false
223    const cooldownDurationMs = resetTimestamp - Date.now()
224    logForDebugging(
225      `Fast mode cooldown triggered (${reason}), duration ${Math.round(cooldownDurationMs / 1000)}s`,
226    )
227    logEvent('tengu_fast_mode_fallback_triggered', {
228      cooldown_duration_ms: cooldownDurationMs,
229      cooldown_reason:
230        reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
231    })
232    cooldownTriggered.emit(resetTimestamp, reason)
233  }
234  
235  export function clearFastModeCooldown(): void {
236    runtimeState = { status: 'active' }
237  }
238  
239  /**
240   * Called when the API rejects a fast mode request (e.g., 400 "Fast mode is
241   * not enabled for your organization"). Permanently disables fast mode using
242   * the same flow as when the prefetch discovers the org has it disabled.
243   */
244  export function handleFastModeRejectedByAPI(): void {
245    if (orgStatus.status === 'disabled') {
246      return
247    }
248    orgStatus = { status: 'disabled', reason: 'preference' }
249    updateSettingsForSource('userSettings', { fastMode: undefined })
250    saveGlobalConfig(current => ({
251      ...current,
252      penguinModeOrgEnabled: false,
253    }))
254    orgFastModeChange.emit(false)
255  }
256  
257  // --- Overage rejection listeners ---
258  // Fired when a 429 indicates fast mode was rejected because extra usage
259  // (overage billing) is not available. Distinct from org-level disabling.
260  const overageRejection = createSignal<[message: string]>()
261  export const onFastModeOverageRejection = overageRejection.subscribe
262  
263  function getOverageDisabledMessage(reason: string | null): string {
264    switch (reason) {
265      case 'out_of_credits':
266        return 'Fast mode disabled · extra usage credits exhausted'
267      case 'org_level_disabled':
268      case 'org_service_level_disabled':
269        return 'Fast mode disabled · extra usage disabled by your organization'
270      case 'org_level_disabled_until':
271        return 'Fast mode disabled · extra usage spending cap reached'
272      case 'member_level_disabled':
273        return 'Fast mode disabled · extra usage disabled for your account'
274      case 'seat_tier_level_disabled':
275      case 'seat_tier_zero_credit_limit':
276      case 'member_zero_credit_limit':
277        return 'Fast mode disabled · extra usage not available for your plan'
278      case 'overage_not_provisioned':
279      case 'no_limits_configured':
280        return 'Fast mode requires extra usage billing · /extra-usage to enable'
281      default:
282        return 'Fast mode disabled · extra usage not available'
283    }
284  }
285  
286  function isOutOfCreditsReason(reason: string | null): boolean {
287    return reason === 'org_level_disabled_until' || reason === 'out_of_credits'
288  }
289  
290  /**
291   * Called when a 429 indicates fast mode was rejected because extra usage
292   * is not available. Permanently disables fast mode (unless the user has
293   * ran out of credits) and notifies with a reason-specific message.
294   */
295  export function handleFastModeOverageRejection(reason: string | null): void {
296    const message = getOverageDisabledMessage(reason)
297    logForDebugging(
298      `Fast mode overage rejection: ${reason ?? 'unknown'} — ${message}`,
299    )
300    logEvent('tengu_fast_mode_overage_rejected', {
301      overage_disabled_reason: (reason ??
302        'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
303    })
304    // Disable fast mode permanently unless the user has ran out of credits
305    if (!isOutOfCreditsReason(reason)) {
306      updateSettingsForSource('userSettings', { fastMode: undefined })
307      saveGlobalConfig(current => ({
308        ...current,
309        penguinModeOrgEnabled: false,
310      }))
311    }
312    overageRejection.emit(message)
313  }
314  
315  export function isFastModeCooldown(): boolean {
316    return getFastModeRuntimeState().status === 'cooldown'
317  }
318  
319  export function getFastModeState(
320    model: ModelSetting,
321    fastModeUserEnabled: boolean | undefined,
322  ): 'off' | 'cooldown' | 'on' {
323    const enabled =
324      isFastModeEnabled() &&
325      isFastModeAvailable() &&
326      !!fastModeUserEnabled &&
327      isFastModeSupportedByModel(model)
328    if (enabled && isFastModeCooldown()) {
329      return 'cooldown'
330    }
331    if (enabled) {
332      return 'on'
333    }
334    return 'off'
335  }
336  
337  // Disabled reason returned by the API. The API is the canonical source for why
338  // fast mode is disabled (free account, admin preference, extra usage not enabled).
339  export type FastModeDisabledReason =
340    | 'free'
341    | 'preference'
342    | 'extra_usage_disabled'
343    | 'network_error'
344    | 'unknown'
345  
346  // In-memory cache of the fast mode status from the API.
347  // Distinct from the user's fastMode app state — this represents
348  // whether the org *allows* fast mode and why it may be disabled.
349  // Modeled as a discriminated union so the invalid state
350  // (disabled without a reason) is unrepresentable.
351  type FastModeOrgStatus =
352    | { status: 'pending' }
353    | { status: 'enabled' }
354    | { status: 'disabled'; reason: FastModeDisabledReason }
355  
356  let orgStatus: FastModeOrgStatus = { status: 'pending' }
357  
358  // Listeners notified when org-level fast mode status changes
359  const orgFastModeChange = createSignal<[orgEnabled: boolean]>()
360  export const onOrgFastModeChanged = orgFastModeChange.subscribe
361  
362  type FastModeResponse = {
363    enabled: boolean
364    disabled_reason: FastModeDisabledReason | null
365  }
366  
367  async function fetchFastModeStatus(
368    auth: { accessToken: string } | { apiKey: string },
369  ): Promise<FastModeResponse> {
370    const endpoint = `${getOauthConfig().BASE_API_URL}/api/claude_code_penguin_mode`
371    const headers: Record<string, string> =
372      'accessToken' in auth
373        ? {
374            Authorization: `Bearer ${auth.accessToken}`,
375            'anthropic-beta': OAUTH_BETA_HEADER,
376          }
377        : { 'x-api-key': auth.apiKey }
378  
379    const response = await axios.get<FastModeResponse>(endpoint, { headers })
380    return response.data
381  }
382  
383  const PREFETCH_MIN_INTERVAL_MS = 30_000
384  let lastPrefetchAt = 0
385  let inflightPrefetch: Promise<void> | null = null
386  
387  /**
388   * Resolve orgStatus from the persisted cache without making any API calls.
389   * Used when startup prefetches are throttled to avoid hitting the network
390   * while still making fast mode availability checks work.
391   */
392  export function resolveFastModeStatusFromCache(): void {
393    if (!isFastModeEnabled()) {
394      return
395    }
396    if (orgStatus.status !== 'pending') {
397      return
398    }
399    const isAnt = process.env.USER_TYPE === 'ant'
400    const cachedEnabled = getGlobalConfig().penguinModeOrgEnabled === true
401    orgStatus =
402      isAnt || cachedEnabled
403        ? { status: 'enabled' }
404        : { status: 'disabled', reason: 'unknown' }
405  }
406  
407  export async function prefetchFastModeStatus(): Promise<void> {
408    // Skip network requests if nonessential traffic is disabled
409    if (isEssentialTrafficOnly()) {
410      return
411    }
412  
413    if (!isFastModeEnabled()) {
414      return
415    }
416  
417    if (inflightPrefetch) {
418      logForDebugging(
419        'Fast mode prefetch in progress, returning in-flight promise',
420      )
421      return inflightPrefetch
422    }
423  
424    // Service key OAuth sessions lack user:profile scope → endpoint 403s.
425    // Resolve orgStatus from cache and bail before burning the throttle window.
426    // API key auth is unaffected.
427    const apiKey = getAnthropicApiKey()
428    const hasUsableOAuth =
429      getClaudeAIOAuthTokens()?.accessToken && hasProfileScope()
430    if (!hasUsableOAuth && !apiKey) {
431      const isAnt = process.env.USER_TYPE === 'ant'
432      const cachedEnabled = getGlobalConfig().penguinModeOrgEnabled === true
433      orgStatus =
434        isAnt || cachedEnabled
435          ? { status: 'enabled' }
436          : { status: 'disabled', reason: 'preference' }
437      return
438    }
439  
440    const now = Date.now()
441    if (now - lastPrefetchAt < PREFETCH_MIN_INTERVAL_MS) {
442      logForDebugging('Skipping fast mode prefetch, fetched recently')
443      return
444    }
445    lastPrefetchAt = now
446  
447    const fetchWithCurrentAuth = async (): Promise<FastModeResponse> => {
448      const currentTokens = getClaudeAIOAuthTokens()
449      const auth =
450        currentTokens?.accessToken && hasProfileScope()
451          ? { accessToken: currentTokens.accessToken }
452          : apiKey
453            ? { apiKey }
454            : null
455      if (!auth) {
456        throw new Error('No auth available')
457      }
458      return fetchFastModeStatus(auth)
459    }
460  
461    async function doFetch(): Promise<void> {
462      try {
463        let status: FastModeResponse
464        try {
465          status = await fetchWithCurrentAuth()
466        } catch (err) {
467          const isAuthError =
468            axios.isAxiosError(err) &&
469            (err.response?.status === 401 ||
470              (err.response?.status === 403 &&
471                typeof err.response?.data === 'string' &&
472                err.response.data.includes('OAuth token has been revoked')))
473          if (isAuthError) {
474            const failedAccessToken = getClaudeAIOAuthTokens()?.accessToken
475            if (failedAccessToken) {
476              await handleOAuth401Error(failedAccessToken)
477              status = await fetchWithCurrentAuth()
478            } else {
479              throw err
480            }
481          } else {
482            throw err
483          }
484        }
485  
486        const previousEnabled =
487          orgStatus.status !== 'pending'
488            ? orgStatus.status === 'enabled'
489            : getGlobalConfig().penguinModeOrgEnabled
490        orgStatus = status.enabled
491          ? { status: 'enabled' }
492          : {
493              status: 'disabled',
494              reason: status.disabled_reason ?? 'preference',
495            }
496        if (previousEnabled !== status.enabled) {
497          // When org disables fast mode, permanently turn off the user's fast mode setting
498          if (!status.enabled) {
499            updateSettingsForSource('userSettings', { fastMode: undefined })
500          }
501          saveGlobalConfig(current => ({
502            ...current,
503            penguinModeOrgEnabled: status.enabled,
504          }))
505          orgFastModeChange.emit(status.enabled)
506        }
507        logForDebugging(
508          `Org fast mode: ${status.enabled ? 'enabled' : `disabled (${status.disabled_reason ?? 'preference'})`}`,
509        )
510      } catch (err) {
511        // On failure: ants default to enabled (don't block internal users).
512        // External users: fall back to the cached penguinModeOrgEnabled value;
513        // if no positive cache, disable with network_error reason.
514        const isAnt = process.env.USER_TYPE === 'ant'
515        const cachedEnabled = getGlobalConfig().penguinModeOrgEnabled === true
516        orgStatus =
517          isAnt || cachedEnabled
518            ? { status: 'enabled' }
519            : { status: 'disabled', reason: 'network_error' }
520        logForDebugging(
521          `Failed to fetch org fast mode status, defaulting to ${orgStatus.status === 'enabled' ? 'enabled (cached)' : 'disabled (network_error)'}: ${err}`,
522          { level: 'error' },
523        )
524        logEvent('tengu_org_penguin_mode_fetch_failed', {})
525      } finally {
526        inflightPrefetch = null
527      }
528    }
529  
530    inflightPrefetch = doFetch()
531    return inflightPrefetch
532  }