/** * Centralized rate limit message generation * Single source of truth for all rate limit-related messages */ import { getOauthAccountInfo, getSubscriptionType, isOverageProvisioningAllowed, } from '../utils/auth.js' import { hasClaudeAiBillingAccess } from '../utils/billing.js' import { formatResetTime } from '../utils/format.js' import type { ClaudeAILimits } from './claudeAiLimits.js' const FEEDBACK_CHANNEL_ANT = '#briarpatch-cc' /** * All possible rate limit error message prefixes * Export this to avoid fragile string matching in UI components */ export const RATE_LIMIT_ERROR_PREFIXES = [ "You've hit your", "You've used", "You're now using extra usage", "You're close to", "You're out of extra usage", ] as const /** * Check if a message is a rate limit error */ export function isRateLimitErrorMessage(text: string): boolean { return RATE_LIMIT_ERROR_PREFIXES.some(prefix => text.startsWith(prefix)) } export type RateLimitMessage = { message: string severity: 'error' | 'warning' } /** * Get the appropriate rate limit message based on limit state * Returns null if no message should be shown */ export function getRateLimitMessage( limits: ClaudeAILimits, model: string, ): RateLimitMessage | null { // Check overage scenarios first (when subscription is rejected but overage is available) // getUsingOverageText is rendered separately from warning. if (limits.isUsingOverage) { // Show warning if approaching overage spending limit if (limits.overageStatus === 'allowed_warning') { return { message: "You're close to your extra usage spending limit", severity: 'warning', } } return null } // ERROR STATES - when limits are rejected if (limits.status === 'rejected') { return { message: getLimitReachedText(limits, model), severity: 'error' } } // WARNING STATES - when approaching limits with early warning if (limits.status === 'allowed_warning') { // Only show warnings when utilization is above threshold (70%) // This prevents false warnings after week reset when API may send // allowed_warning with stale data at low usage levels const WARNING_THRESHOLD = 0.7 if ( limits.utilization !== undefined && limits.utilization < WARNING_THRESHOLD ) { return null } // Don't warn non-billing Team/Enterprise users about approaching plan limits // if overages are enabled - they'll seamlessly roll into overage const subscriptionType = getSubscriptionType() const isTeamOrEnterprise = subscriptionType === 'team' || subscriptionType === 'enterprise' const hasExtraUsageEnabled = getOauthAccountInfo()?.hasExtraUsageEnabled === true if ( isTeamOrEnterprise && hasExtraUsageEnabled && !hasClaudeAiBillingAccess() ) { return null } const text = getEarlyWarningText(limits) if (text) { return { message: text, severity: 'warning' } } } // No message needed return null } /** * Get error message for API errors (used in errors.ts) * Returns the message string or null if no error message should be shown */ export function getRateLimitErrorMessage( limits: ClaudeAILimits, model: string, ): string | null { const message = getRateLimitMessage(limits, model) // Only return error messages, not warnings if (message && message.severity === 'error') { return message.message } return null } /** * Get warning message for UI footer * Returns the warning message string or null if no warning should be shown */ export function getRateLimitWarning( limits: ClaudeAILimits, model: string, ): string | null { const message = getRateLimitMessage(limits, model) // Only return warnings for the footer - errors are shown in AssistantTextMessages if (message && message.severity === 'warning') { return message.message } // Don't show errors in the footer return null } function getLimitReachedText(limits: ClaudeAILimits, model: string): string { const resetsAt = limits.resetsAt const resetTime = resetsAt ? formatResetTime(resetsAt, true) : undefined const overageResetTime = limits.overageResetsAt ? formatResetTime(limits.overageResetsAt, true) : undefined const resetMessage = resetTime ? ` · resets ${resetTime}` : '' // if BOTH subscription (checked before this method) and overage are exhausted if (limits.overageStatus === 'rejected') { // Show the earliest reset time to indicate when user can resume let overageResetMessage = '' if (resetsAt && limits.overageResetsAt) { // Both timestamps present - use the earlier one if (resetsAt < limits.overageResetsAt) { overageResetMessage = ` · resets ${resetTime}` } else { overageResetMessage = ` · resets ${overageResetTime}` } } else if (resetTime) { overageResetMessage = ` · resets ${resetTime}` } else if (overageResetTime) { overageResetMessage = ` · resets ${overageResetTime}` } if (limits.overageDisabledReason === 'out_of_credits') { return `You're out of extra usage${overageResetMessage}` } return formatLimitReachedText('limit', overageResetMessage, model) } if (limits.rateLimitType === 'seven_day_sonnet') { const subscriptionType = getSubscriptionType() const isProOrEnterprise = subscriptionType === 'pro' || subscriptionType === 'enterprise' // For pro and enterprise, Sonnet limit is the same as weekly const limit = isProOrEnterprise ? 'weekly limit' : 'Sonnet limit' return formatLimitReachedText(limit, resetMessage, model) } if (limits.rateLimitType === 'seven_day_opus') { return formatLimitReachedText('Opus limit', resetMessage, model) } if (limits.rateLimitType === 'seven_day') { return formatLimitReachedText('weekly limit', resetMessage, model) } if (limits.rateLimitType === 'five_hour') { return formatLimitReachedText('session limit', resetMessage, model) } return formatLimitReachedText('usage limit', resetMessage, model) } function getEarlyWarningText(limits: ClaudeAILimits): string | null { let limitName: string | null = null switch (limits.rateLimitType) { case 'seven_day': limitName = 'weekly limit' break case 'five_hour': limitName = 'session limit' break case 'seven_day_opus': limitName = 'Opus limit' break case 'seven_day_sonnet': limitName = 'Sonnet limit' break case 'overage': limitName = 'extra usage' break case undefined: return null } // utilization and resetsAt should be defined since early warning is calculated with them const used = limits.utilization ? Math.floor(limits.utilization * 100) : undefined const resetTime = limits.resetsAt ? formatResetTime(limits.resetsAt, true) : undefined // Get upsell command based on subscription type and limit type const upsell = getWarningUpsellText(limits.rateLimitType) if (used && resetTime) { const base = `You've used ${used}% of your ${limitName} · resets ${resetTime}` return upsell ? `${base} · ${upsell}` : base } if (used) { const base = `You've used ${used}% of your ${limitName}` return upsell ? `${base} · ${upsell}` : base } if (limits.rateLimitType === 'overage') { // For the "Approaching " verbiage, "extra usage limit" makes more sense than "extra usage" limitName += ' limit' } if (resetTime) { const base = `Approaching ${limitName} · resets ${resetTime}` return upsell ? `${base} · ${upsell}` : base } const base = `Approaching ${limitName}` return upsell ? `${base} · ${upsell}` : base } /** * Get the upsell command text for warning messages based on subscription and limit type. * Returns null if no upsell should be shown. * Only used for warnings because actual rate limit hits will see an interactive menu of options. */ function getWarningUpsellText( rateLimitType: ClaudeAILimits['rateLimitType'], ): string | null { const subscriptionType = getSubscriptionType() const hasExtraUsageEnabled = getOauthAccountInfo()?.hasExtraUsageEnabled === true // 5-hour session limit warning if (rateLimitType === 'five_hour') { // Teams/Enterprise with overages disabled: prompt to request extra usage // Only show if overage provisioning is allowed for this org type (e.g., not AWS marketplace) if (subscriptionType === 'team' || subscriptionType === 'enterprise') { if (!hasExtraUsageEnabled && isOverageProvisioningAllowed()) { return '/extra-usage to request more' } // Teams/Enterprise with overages enabled or unsupported billing type don't need upsell return null } // Pro/Max users: prompt to upgrade if (subscriptionType === 'pro' || subscriptionType === 'max') { return '/upgrade to keep using Claude Code' } } // Overage warning (approaching spending limit) if (rateLimitType === 'overage') { if (subscriptionType === 'team' || subscriptionType === 'enterprise') { if (!hasExtraUsageEnabled && isOverageProvisioningAllowed()) { return '/extra-usage to request more' } } } // Weekly limit warnings don't show upsell per spec return null } /** * Get notification text for overage mode transitions * Used for transient notifications when entering overage mode */ export function getUsingOverageText(limits: ClaudeAILimits): string { const resetTime = limits.resetsAt ? formatResetTime(limits.resetsAt, true) : '' let limitName = '' if (limits.rateLimitType === 'five_hour') { limitName = 'session limit' } else if (limits.rateLimitType === 'seven_day') { limitName = 'weekly limit' } else if (limits.rateLimitType === 'seven_day_opus') { limitName = 'Opus limit' } else if (limits.rateLimitType === 'seven_day_sonnet') { const subscriptionType = getSubscriptionType() const isProOrEnterprise = subscriptionType === 'pro' || subscriptionType === 'enterprise' // For pro and enterprise, Sonnet limit is the same as weekly limitName = isProOrEnterprise ? 'weekly limit' : 'Sonnet limit' } if (!limitName) { return 'Now using extra usage' } const resetMessage = resetTime ? ` · Your ${limitName} resets ${resetTime}` : '' return `You're now using extra usage${resetMessage}` } function formatLimitReachedText( limit: string, resetMessage: string, _model: string, ): string { // Enhanced messaging for Ant users if (process.env.USER_TYPE === 'ant') { return `You've hit your ${limit}${resetMessage}. If you have feedback about this limit, post in ${FEEDBACK_CHANNEL_ANT}. You can reset your limits with /reset-limits` } return `You've hit your ${limit}${resetMessage}` }