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 }