533 lines
17 KiB
TypeScript
533 lines
17 KiB
TypeScript
import axios from 'axios'
|
|
import { getOauthConfig, OAUTH_BETA_HEADER } from 'src/constants/oauth.js'
|
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
|
import {
|
|
getIsNonInteractiveSession,
|
|
getKairosActive,
|
|
preferThirdPartyAuthentication,
|
|
} from '../bootstrap/state.js'
|
|
import {
|
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
logEvent,
|
|
} from '../services/analytics/index.js'
|
|
import {
|
|
getAnthropicApiKey,
|
|
getClaudeAIOAuthTokens,
|
|
handleOAuth401Error,
|
|
hasProfileScope,
|
|
} from './auth.js'
|
|
import { isInBundledMode } from './bundledMode.js'
|
|
import { getGlobalConfig, saveGlobalConfig } from './config.js'
|
|
import { logForDebugging } from './debug.js'
|
|
import { isEnvTruthy } from './envUtils.js'
|
|
import {
|
|
getDefaultMainLoopModelSetting,
|
|
isOpus1mMergeEnabled,
|
|
type ModelSetting,
|
|
parseUserSpecifiedModel,
|
|
} from './model/model.js'
|
|
import { getAPIProvider } from './model/providers.js'
|
|
import { isEssentialTrafficOnly } from './privacyLevel.js'
|
|
import {
|
|
getInitialSettings,
|
|
getSettingsForSource,
|
|
updateSettingsForSource,
|
|
} from './settings/settings.js'
|
|
import { createSignal } from './signal.js'
|
|
|
|
export function isFastModeEnabled(): boolean {
|
|
return !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FAST_MODE)
|
|
}
|
|
|
|
export function isFastModeAvailable(): boolean {
|
|
if (!isFastModeEnabled()) {
|
|
return false
|
|
}
|
|
return getFastModeUnavailableReason() === null
|
|
}
|
|
|
|
type AuthType = 'oauth' | 'api-key'
|
|
|
|
function getDisabledReasonMessage(
|
|
disabledReason: FastModeDisabledReason,
|
|
authType: AuthType,
|
|
): string {
|
|
switch (disabledReason) {
|
|
case 'free':
|
|
return authType === 'oauth'
|
|
? 'Fast mode requires a paid subscription'
|
|
: 'Fast mode unavailable during evaluation. Please purchase credits.'
|
|
case 'preference':
|
|
return 'Fast mode has been disabled by your organization'
|
|
case 'extra_usage_disabled':
|
|
// Only OAuth users can have extra_usage_disabled; console users don't have this concept
|
|
return 'Fast mode requires extra usage billing · /extra-usage to enable'
|
|
case 'network_error':
|
|
return 'Fast mode unavailable due to network connectivity issues'
|
|
case 'unknown':
|
|
return 'Fast mode is currently unavailable'
|
|
}
|
|
}
|
|
|
|
export function getFastModeUnavailableReason(): string | null {
|
|
if (!isFastModeEnabled()) {
|
|
return 'Fast mode is not available'
|
|
}
|
|
|
|
const statigReason = getFeatureValue_CACHED_MAY_BE_STALE(
|
|
'tengu_penguins_off',
|
|
null,
|
|
)
|
|
// Statsig reason has priority over other reasons.
|
|
if (statigReason !== null) {
|
|
logForDebugging(`Fast mode unavailable: ${statigReason}`)
|
|
return statigReason
|
|
}
|
|
|
|
// Previously, fast mode required the native binary (bun build). This is no
|
|
// longer necessary, but we keep this option behind a flag just in case.
|
|
if (
|
|
!isInBundledMode() &&
|
|
getFeatureValue_CACHED_MAY_BE_STALE('tengu_marble_sandcastle', false)
|
|
) {
|
|
return 'Fast mode requires the native binary · Install from: https://claude.com/product/claude-code'
|
|
}
|
|
|
|
// Not available in the SDK unless explicitly opted in via --settings.
|
|
// Assistant daemon mode is exempt — it's first-party orchestration, and
|
|
// kairosActive is set before this check runs (main.tsx:~1626 vs ~3249).
|
|
if (
|
|
getIsNonInteractiveSession() &&
|
|
preferThirdPartyAuthentication() &&
|
|
!getKairosActive()
|
|
) {
|
|
const flagFastMode = getSettingsForSource('flagSettings')?.fastMode
|
|
if (!flagFastMode) {
|
|
const reason = 'Fast mode is not available in the Agent SDK'
|
|
logForDebugging(`Fast mode unavailable: ${reason}`)
|
|
return reason
|
|
}
|
|
}
|
|
|
|
// Only available for 1P (not Bedrock/Vertex/Foundry)
|
|
if (getAPIProvider() !== 'firstParty') {
|
|
const reason = 'Fast mode is not available on Bedrock, Vertex, or Foundry'
|
|
logForDebugging(`Fast mode unavailable: ${reason}`)
|
|
return reason
|
|
}
|
|
|
|
if (orgStatus.status === 'disabled') {
|
|
if (
|
|
orgStatus.reason === 'network_error' ||
|
|
orgStatus.reason === 'unknown'
|
|
) {
|
|
// The org check can fail behind corporate proxies that block the
|
|
// endpoint. We add CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS=1 to
|
|
// bypass this check in the CC binary. This is OK since we have
|
|
// another check in the API to error out when disabled by org.
|
|
if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS)) {
|
|
return null
|
|
}
|
|
}
|
|
const authType: AuthType =
|
|
getClaudeAIOAuthTokens() !== null ? 'oauth' : 'api-key'
|
|
const reason = getDisabledReasonMessage(orgStatus.reason, authType)
|
|
logForDebugging(`Fast mode unavailable: ${reason}`)
|
|
return reason
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// @[MODEL LAUNCH]: Update supported Fast Mode models.
|
|
export const FAST_MODE_MODEL_DISPLAY = 'Opus 4.6'
|
|
|
|
export function getFastModeModel(): string {
|
|
return 'opus' + (isOpus1mMergeEnabled() ? '[1m]' : '')
|
|
}
|
|
|
|
export function getInitialFastModeSetting(model: ModelSetting): boolean {
|
|
if (!isFastModeEnabled()) {
|
|
return false
|
|
}
|
|
if (!isFastModeAvailable()) {
|
|
return false
|
|
}
|
|
if (!isFastModeSupportedByModel(model)) {
|
|
return false
|
|
}
|
|
const settings = getInitialSettings()
|
|
// If per-session opt-in is required, fast mode starts off each session
|
|
if (settings.fastModePerSessionOptIn) {
|
|
return false
|
|
}
|
|
return settings.fastMode === true
|
|
}
|
|
|
|
export function isFastModeSupportedByModel(
|
|
modelSetting: ModelSetting,
|
|
): boolean {
|
|
if (!isFastModeEnabled()) {
|
|
return false
|
|
}
|
|
const model = modelSetting ?? getDefaultMainLoopModelSetting()
|
|
const parsedModel = parseUserSpecifiedModel(model)
|
|
return parsedModel.toLowerCase().includes('opus-4-6')
|
|
}
|
|
|
|
// --- Fast mode runtime state ---
|
|
// Separate from user preference (settings.fastMode). This tracks the actual
|
|
// operational state: whether we're actively sending fast speed or in cooldown
|
|
// after a rate limit.
|
|
|
|
export type FastModeRuntimeState =
|
|
| { status: 'active' }
|
|
| { status: 'cooldown'; resetAt: number; reason: CooldownReason }
|
|
|
|
let runtimeState: FastModeRuntimeState = { status: 'active' }
|
|
let hasLoggedCooldownExpiry = false
|
|
|
|
// --- Cooldown event listeners ---
|
|
export type CooldownReason = 'rate_limit' | 'overloaded'
|
|
|
|
const cooldownTriggered =
|
|
createSignal<[resetAt: number, reason: CooldownReason]>()
|
|
const cooldownExpired = createSignal()
|
|
export const onCooldownTriggered = cooldownTriggered.subscribe
|
|
export const onCooldownExpired = cooldownExpired.subscribe
|
|
|
|
export function getFastModeRuntimeState(): FastModeRuntimeState {
|
|
if (
|
|
runtimeState.status === 'cooldown' &&
|
|
Date.now() >= runtimeState.resetAt
|
|
) {
|
|
if (isFastModeEnabled() && !hasLoggedCooldownExpiry) {
|
|
logForDebugging('Fast mode cooldown expired, re-enabling fast mode')
|
|
hasLoggedCooldownExpiry = true
|
|
cooldownExpired.emit()
|
|
}
|
|
runtimeState = { status: 'active' }
|
|
}
|
|
return runtimeState
|
|
}
|
|
|
|
export function triggerFastModeCooldown(
|
|
resetTimestamp: number,
|
|
reason: CooldownReason,
|
|
): void {
|
|
if (!isFastModeEnabled()) {
|
|
return
|
|
}
|
|
runtimeState = { status: 'cooldown', resetAt: resetTimestamp, reason }
|
|
hasLoggedCooldownExpiry = false
|
|
const cooldownDurationMs = resetTimestamp - Date.now()
|
|
logForDebugging(
|
|
`Fast mode cooldown triggered (${reason}), duration ${Math.round(cooldownDurationMs / 1000)}s`,
|
|
)
|
|
logEvent('tengu_fast_mode_fallback_triggered', {
|
|
cooldown_duration_ms: cooldownDurationMs,
|
|
cooldown_reason:
|
|
reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
cooldownTriggered.emit(resetTimestamp, reason)
|
|
}
|
|
|
|
export function clearFastModeCooldown(): void {
|
|
runtimeState = { status: 'active' }
|
|
}
|
|
|
|
/**
|
|
* Called when the API rejects a fast mode request (e.g., 400 "Fast mode is
|
|
* not enabled for your organization"). Permanently disables fast mode using
|
|
* the same flow as when the prefetch discovers the org has it disabled.
|
|
*/
|
|
export function handleFastModeRejectedByAPI(): void {
|
|
if (orgStatus.status === 'disabled') {
|
|
return
|
|
}
|
|
orgStatus = { status: 'disabled', reason: 'preference' }
|
|
updateSettingsForSource('userSettings', { fastMode: undefined })
|
|
saveGlobalConfig(current => ({
|
|
...current,
|
|
penguinModeOrgEnabled: false,
|
|
}))
|
|
orgFastModeChange.emit(false)
|
|
}
|
|
|
|
// --- Overage rejection listeners ---
|
|
// Fired when a 429 indicates fast mode was rejected because extra usage
|
|
// (overage billing) is not available. Distinct from org-level disabling.
|
|
const overageRejection = createSignal<[message: string]>()
|
|
export const onFastModeOverageRejection = overageRejection.subscribe
|
|
|
|
function getOverageDisabledMessage(reason: string | null): string {
|
|
switch (reason) {
|
|
case 'out_of_credits':
|
|
return 'Fast mode disabled · extra usage credits exhausted'
|
|
case 'org_level_disabled':
|
|
case 'org_service_level_disabled':
|
|
return 'Fast mode disabled · extra usage disabled by your organization'
|
|
case 'org_level_disabled_until':
|
|
return 'Fast mode disabled · extra usage spending cap reached'
|
|
case 'member_level_disabled':
|
|
return 'Fast mode disabled · extra usage disabled for your account'
|
|
case 'seat_tier_level_disabled':
|
|
case 'seat_tier_zero_credit_limit':
|
|
case 'member_zero_credit_limit':
|
|
return 'Fast mode disabled · extra usage not available for your plan'
|
|
case 'overage_not_provisioned':
|
|
case 'no_limits_configured':
|
|
return 'Fast mode requires extra usage billing · /extra-usage to enable'
|
|
default:
|
|
return 'Fast mode disabled · extra usage not available'
|
|
}
|
|
}
|
|
|
|
function isOutOfCreditsReason(reason: string | null): boolean {
|
|
return reason === 'org_level_disabled_until' || reason === 'out_of_credits'
|
|
}
|
|
|
|
/**
|
|
* Called when a 429 indicates fast mode was rejected because extra usage
|
|
* is not available. Permanently disables fast mode (unless the user has
|
|
* ran out of credits) and notifies with a reason-specific message.
|
|
*/
|
|
export function handleFastModeOverageRejection(reason: string | null): void {
|
|
const message = getOverageDisabledMessage(reason)
|
|
logForDebugging(
|
|
`Fast mode overage rejection: ${reason ?? 'unknown'} — ${message}`,
|
|
)
|
|
logEvent('tengu_fast_mode_overage_rejected', {
|
|
overage_disabled_reason: (reason ??
|
|
'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
// Disable fast mode permanently unless the user has ran out of credits
|
|
if (!isOutOfCreditsReason(reason)) {
|
|
updateSettingsForSource('userSettings', { fastMode: undefined })
|
|
saveGlobalConfig(current => ({
|
|
...current,
|
|
penguinModeOrgEnabled: false,
|
|
}))
|
|
}
|
|
overageRejection.emit(message)
|
|
}
|
|
|
|
export function isFastModeCooldown(): boolean {
|
|
return getFastModeRuntimeState().status === 'cooldown'
|
|
}
|
|
|
|
export function getFastModeState(
|
|
model: ModelSetting,
|
|
fastModeUserEnabled: boolean | undefined,
|
|
): 'off' | 'cooldown' | 'on' {
|
|
const enabled =
|
|
isFastModeEnabled() &&
|
|
isFastModeAvailable() &&
|
|
!!fastModeUserEnabled &&
|
|
isFastModeSupportedByModel(model)
|
|
if (enabled && isFastModeCooldown()) {
|
|
return 'cooldown'
|
|
}
|
|
if (enabled) {
|
|
return 'on'
|
|
}
|
|
return 'off'
|
|
}
|
|
|
|
// Disabled reason returned by the API. The API is the canonical source for why
|
|
// fast mode is disabled (free account, admin preference, extra usage not enabled).
|
|
export type FastModeDisabledReason =
|
|
| 'free'
|
|
| 'preference'
|
|
| 'extra_usage_disabled'
|
|
| 'network_error'
|
|
| 'unknown'
|
|
|
|
// In-memory cache of the fast mode status from the API.
|
|
// Distinct from the user's fastMode app state — this represents
|
|
// whether the org *allows* fast mode and why it may be disabled.
|
|
// Modeled as a discriminated union so the invalid state
|
|
// (disabled without a reason) is unrepresentable.
|
|
type FastModeOrgStatus =
|
|
| { status: 'pending' }
|
|
| { status: 'enabled' }
|
|
| { status: 'disabled'; reason: FastModeDisabledReason }
|
|
|
|
let orgStatus: FastModeOrgStatus = { status: 'pending' }
|
|
|
|
// Listeners notified when org-level fast mode status changes
|
|
const orgFastModeChange = createSignal<[orgEnabled: boolean]>()
|
|
export const onOrgFastModeChanged = orgFastModeChange.subscribe
|
|
|
|
type FastModeResponse = {
|
|
enabled: boolean
|
|
disabled_reason: FastModeDisabledReason | null
|
|
}
|
|
|
|
async function fetchFastModeStatus(
|
|
auth: { accessToken: string } | { apiKey: string },
|
|
): Promise<FastModeResponse> {
|
|
const endpoint = `${getOauthConfig().BASE_API_URL}/api/claude_code_penguin_mode`
|
|
const headers: Record<string, string> =
|
|
'accessToken' in auth
|
|
? {
|
|
Authorization: `Bearer ${auth.accessToken}`,
|
|
'anthropic-beta': OAUTH_BETA_HEADER,
|
|
}
|
|
: { 'x-api-key': auth.apiKey }
|
|
|
|
const response = await axios.get<FastModeResponse>(endpoint, { headers })
|
|
return response.data
|
|
}
|
|
|
|
const PREFETCH_MIN_INTERVAL_MS = 30_000
|
|
let lastPrefetchAt = 0
|
|
let inflightPrefetch: Promise<void> | null = null
|
|
|
|
/**
|
|
* Resolve orgStatus from the persisted cache without making any API calls.
|
|
* Used when startup prefetches are throttled to avoid hitting the network
|
|
* while still making fast mode availability checks work.
|
|
*/
|
|
export function resolveFastModeStatusFromCache(): void {
|
|
if (!isFastModeEnabled()) {
|
|
return
|
|
}
|
|
if (orgStatus.status !== 'pending') {
|
|
return
|
|
}
|
|
const isAnt = process.env.USER_TYPE === 'ant'
|
|
const cachedEnabled = getGlobalConfig().penguinModeOrgEnabled === true
|
|
orgStatus =
|
|
isAnt || cachedEnabled
|
|
? { status: 'enabled' }
|
|
: { status: 'disabled', reason: 'unknown' }
|
|
}
|
|
|
|
export async function prefetchFastModeStatus(): Promise<void> {
|
|
// Skip network requests if nonessential traffic is disabled
|
|
if (isEssentialTrafficOnly()) {
|
|
return
|
|
}
|
|
|
|
if (!isFastModeEnabled()) {
|
|
return
|
|
}
|
|
|
|
if (inflightPrefetch) {
|
|
logForDebugging(
|
|
'Fast mode prefetch in progress, returning in-flight promise',
|
|
)
|
|
return inflightPrefetch
|
|
}
|
|
|
|
// Service key OAuth sessions lack user:profile scope → endpoint 403s.
|
|
// Resolve orgStatus from cache and bail before burning the throttle window.
|
|
// API key auth is unaffected.
|
|
const apiKey = getAnthropicApiKey()
|
|
const hasUsableOAuth =
|
|
getClaudeAIOAuthTokens()?.accessToken && hasProfileScope()
|
|
if (!hasUsableOAuth && !apiKey) {
|
|
const isAnt = process.env.USER_TYPE === 'ant'
|
|
const cachedEnabled = getGlobalConfig().penguinModeOrgEnabled === true
|
|
orgStatus =
|
|
isAnt || cachedEnabled
|
|
? { status: 'enabled' }
|
|
: { status: 'disabled', reason: 'preference' }
|
|
return
|
|
}
|
|
|
|
const now = Date.now()
|
|
if (now - lastPrefetchAt < PREFETCH_MIN_INTERVAL_MS) {
|
|
logForDebugging('Skipping fast mode prefetch, fetched recently')
|
|
return
|
|
}
|
|
lastPrefetchAt = now
|
|
|
|
const fetchWithCurrentAuth = async (): Promise<FastModeResponse> => {
|
|
const currentTokens = getClaudeAIOAuthTokens()
|
|
const auth =
|
|
currentTokens?.accessToken && hasProfileScope()
|
|
? { accessToken: currentTokens.accessToken }
|
|
: apiKey
|
|
? { apiKey }
|
|
: null
|
|
if (!auth) {
|
|
throw new Error('No auth available')
|
|
}
|
|
return fetchFastModeStatus(auth)
|
|
}
|
|
|
|
async function doFetch(): Promise<void> {
|
|
try {
|
|
let status: FastModeResponse
|
|
try {
|
|
status = await fetchWithCurrentAuth()
|
|
} catch (err) {
|
|
const isAuthError =
|
|
axios.isAxiosError(err) &&
|
|
(err.response?.status === 401 ||
|
|
(err.response?.status === 403 &&
|
|
typeof err.response?.data === 'string' &&
|
|
err.response.data.includes('OAuth token has been revoked')))
|
|
if (isAuthError) {
|
|
const failedAccessToken = getClaudeAIOAuthTokens()?.accessToken
|
|
if (failedAccessToken) {
|
|
await handleOAuth401Error(failedAccessToken)
|
|
status = await fetchWithCurrentAuth()
|
|
} else {
|
|
throw err
|
|
}
|
|
} else {
|
|
throw err
|
|
}
|
|
}
|
|
|
|
const previousEnabled =
|
|
orgStatus.status !== 'pending'
|
|
? orgStatus.status === 'enabled'
|
|
: getGlobalConfig().penguinModeOrgEnabled
|
|
orgStatus = status.enabled
|
|
? { status: 'enabled' }
|
|
: {
|
|
status: 'disabled',
|
|
reason: status.disabled_reason ?? 'preference',
|
|
}
|
|
if (previousEnabled !== status.enabled) {
|
|
// When org disables fast mode, permanently turn off the user's fast mode setting
|
|
if (!status.enabled) {
|
|
updateSettingsForSource('userSettings', { fastMode: undefined })
|
|
}
|
|
saveGlobalConfig(current => ({
|
|
...current,
|
|
penguinModeOrgEnabled: status.enabled,
|
|
}))
|
|
orgFastModeChange.emit(status.enabled)
|
|
}
|
|
logForDebugging(
|
|
`Org fast mode: ${status.enabled ? 'enabled' : `disabled (${status.disabled_reason ?? 'preference'})`}`,
|
|
)
|
|
} catch (err) {
|
|
// On failure: ants default to enabled (don't block internal users).
|
|
// External users: fall back to the cached penguinModeOrgEnabled value;
|
|
// if no positive cache, disable with network_error reason.
|
|
const isAnt = process.env.USER_TYPE === 'ant'
|
|
const cachedEnabled = getGlobalConfig().penguinModeOrgEnabled === true
|
|
orgStatus =
|
|
isAnt || cachedEnabled
|
|
? { status: 'enabled' }
|
|
: { status: 'disabled', reason: 'network_error' }
|
|
logForDebugging(
|
|
`Failed to fetch org fast mode status, defaulting to ${orgStatus.status === 'enabled' ? 'enabled (cached)' : 'disabled (network_error)'}: ${err}`,
|
|
{ level: 'error' },
|
|
)
|
|
logEvent('tengu_org_penguin_mode_fetch_failed', {})
|
|
} finally {
|
|
inflightPrefetch = null
|
|
}
|
|
}
|
|
|
|
inflightPrefetch = doFetch()
|
|
return inflightPrefetch
|
|
}
|