/ services / rateLimitMessages.ts
rateLimitMessages.ts
  1  /**
  2   * Centralized rate limit message generation
  3   * Single source of truth for all rate limit-related messages
  4   */
  5  
  6  import {
  7    getOauthAccountInfo,
  8    getSubscriptionType,
  9    isOverageProvisioningAllowed,
 10  } from '../utils/auth.js'
 11  import { hasClaudeAiBillingAccess } from '../utils/billing.js'
 12  import { formatResetTime } from '../utils/format.js'
 13  import type { ClaudeAILimits } from './claudeAiLimits.js'
 14  
 15  const FEEDBACK_CHANNEL_ANT = '#briarpatch-cc'
 16  
 17  /**
 18   * All possible rate limit error message prefixes
 19   * Export this to avoid fragile string matching in UI components
 20   */
 21  export const RATE_LIMIT_ERROR_PREFIXES = [
 22    "You've hit your",
 23    "You've used",
 24    "You're now using extra usage",
 25    "You're close to",
 26    "You're out of extra usage",
 27  ] as const
 28  
 29  /**
 30   * Check if a message is a rate limit error
 31   */
 32  export function isRateLimitErrorMessage(text: string): boolean {
 33    return RATE_LIMIT_ERROR_PREFIXES.some(prefix => text.startsWith(prefix))
 34  }
 35  
 36  export type RateLimitMessage = {
 37    message: string
 38    severity: 'error' | 'warning'
 39  }
 40  
 41  /**
 42   * Get the appropriate rate limit message based on limit state
 43   * Returns null if no message should be shown
 44   */
 45  export function getRateLimitMessage(
 46    limits: ClaudeAILimits,
 47    model: string,
 48  ): RateLimitMessage | null {
 49    // Check overage scenarios first (when subscription is rejected but overage is available)
 50    // getUsingOverageText is rendered separately from warning.
 51    if (limits.isUsingOverage) {
 52      // Show warning if approaching overage spending limit
 53      if (limits.overageStatus === 'allowed_warning') {
 54        return {
 55          message: "You're close to your extra usage spending limit",
 56          severity: 'warning',
 57        }
 58      }
 59      return null
 60    }
 61  
 62    // ERROR STATES - when limits are rejected
 63    if (limits.status === 'rejected') {
 64      return { message: getLimitReachedText(limits, model), severity: 'error' }
 65    }
 66  
 67    // WARNING STATES - when approaching limits with early warning
 68    if (limits.status === 'allowed_warning') {
 69      // Only show warnings when utilization is above threshold (70%)
 70      // This prevents false warnings after week reset when API may send
 71      // allowed_warning with stale data at low usage levels
 72      const WARNING_THRESHOLD = 0.7
 73      if (
 74        limits.utilization !== undefined &&
 75        limits.utilization < WARNING_THRESHOLD
 76      ) {
 77        return null
 78      }
 79  
 80      // Don't warn non-billing Team/Enterprise users about approaching plan limits
 81      // if overages are enabled - they'll seamlessly roll into overage
 82      const subscriptionType = getSubscriptionType()
 83      const isTeamOrEnterprise =
 84        subscriptionType === 'team' || subscriptionType === 'enterprise'
 85      const hasExtraUsageEnabled =
 86        getOauthAccountInfo()?.hasExtraUsageEnabled === true
 87  
 88      if (
 89        isTeamOrEnterprise &&
 90        hasExtraUsageEnabled &&
 91        !hasClaudeAiBillingAccess()
 92      ) {
 93        return null
 94      }
 95  
 96      const text = getEarlyWarningText(limits)
 97      if (text) {
 98        return { message: text, severity: 'warning' }
 99      }
100    }
101  
102    // No message needed
103    return null
104  }
105  
106  /**
107   * Get error message for API errors (used in errors.ts)
108   * Returns the message string or null if no error message should be shown
109   */
110  export function getRateLimitErrorMessage(
111    limits: ClaudeAILimits,
112    model: string,
113  ): string | null {
114    const message = getRateLimitMessage(limits, model)
115  
116    // Only return error messages, not warnings
117    if (message && message.severity === 'error') {
118      return message.message
119    }
120  
121    return null
122  }
123  
124  /**
125   * Get warning message for UI footer
126   * Returns the warning message string or null if no warning should be shown
127   */
128  export function getRateLimitWarning(
129    limits: ClaudeAILimits,
130    model: string,
131  ): string | null {
132    const message = getRateLimitMessage(limits, model)
133  
134    // Only return warnings for the footer - errors are shown in AssistantTextMessages
135    if (message && message.severity === 'warning') {
136      return message.message
137    }
138  
139    // Don't show errors in the footer
140    return null
141  }
142  
143  function getLimitReachedText(limits: ClaudeAILimits, model: string): string {
144    const resetsAt = limits.resetsAt
145    const resetTime = resetsAt ? formatResetTime(resetsAt, true) : undefined
146    const overageResetTime = limits.overageResetsAt
147      ? formatResetTime(limits.overageResetsAt, true)
148      : undefined
149    const resetMessage = resetTime ? ` · resets ${resetTime}` : ''
150  
151    // if BOTH subscription (checked before this method) and overage are exhausted
152    if (limits.overageStatus === 'rejected') {
153      // Show the earliest reset time to indicate when user can resume
154      let overageResetMessage = ''
155      if (resetsAt && limits.overageResetsAt) {
156        // Both timestamps present - use the earlier one
157        if (resetsAt < limits.overageResetsAt) {
158          overageResetMessage = ` · resets ${resetTime}`
159        } else {
160          overageResetMessage = ` · resets ${overageResetTime}`
161        }
162      } else if (resetTime) {
163        overageResetMessage = ` · resets ${resetTime}`
164      } else if (overageResetTime) {
165        overageResetMessage = ` · resets ${overageResetTime}`
166      }
167  
168      if (limits.overageDisabledReason === 'out_of_credits') {
169        return `You're out of extra usage${overageResetMessage}`
170      }
171  
172      return formatLimitReachedText('limit', overageResetMessage, model)
173    }
174  
175    if (limits.rateLimitType === 'seven_day_sonnet') {
176      const subscriptionType = getSubscriptionType()
177      const isProOrEnterprise =
178        subscriptionType === 'pro' || subscriptionType === 'enterprise'
179      // For pro and enterprise, Sonnet limit is the same as weekly
180      const limit = isProOrEnterprise ? 'weekly limit' : 'Sonnet limit'
181      return formatLimitReachedText(limit, resetMessage, model)
182    }
183  
184    if (limits.rateLimitType === 'seven_day_opus') {
185      return formatLimitReachedText('Opus limit', resetMessage, model)
186    }
187  
188    if (limits.rateLimitType === 'seven_day') {
189      return formatLimitReachedText('weekly limit', resetMessage, model)
190    }
191  
192    if (limits.rateLimitType === 'five_hour') {
193      return formatLimitReachedText('session limit', resetMessage, model)
194    }
195  
196    return formatLimitReachedText('usage limit', resetMessage, model)
197  }
198  
199  function getEarlyWarningText(limits: ClaudeAILimits): string | null {
200    let limitName: string | null = null
201    switch (limits.rateLimitType) {
202      case 'seven_day':
203        limitName = 'weekly limit'
204        break
205      case 'five_hour':
206        limitName = 'session limit'
207        break
208      case 'seven_day_opus':
209        limitName = 'Opus limit'
210        break
211      case 'seven_day_sonnet':
212        limitName = 'Sonnet limit'
213        break
214      case 'overage':
215        limitName = 'extra usage'
216        break
217      case undefined:
218        return null
219    }
220  
221    // utilization and resetsAt should be defined since early warning is calculated with them
222    const used = limits.utilization
223      ? Math.floor(limits.utilization * 100)
224      : undefined
225    const resetTime = limits.resetsAt
226      ? formatResetTime(limits.resetsAt, true)
227      : undefined
228  
229    // Get upsell command based on subscription type and limit type
230    const upsell = getWarningUpsellText(limits.rateLimitType)
231  
232    if (used && resetTime) {
233      const base = `You've used ${used}% of your ${limitName} · resets ${resetTime}`
234      return upsell ? `${base} · ${upsell}` : base
235    }
236  
237    if (used) {
238      const base = `You've used ${used}% of your ${limitName}`
239      return upsell ? `${base} · ${upsell}` : base
240    }
241  
242    if (limits.rateLimitType === 'overage') {
243      // For the "Approaching <x>" verbiage, "extra usage limit" makes more sense than "extra usage"
244      limitName += ' limit'
245    }
246  
247    if (resetTime) {
248      const base = `Approaching ${limitName} · resets ${resetTime}`
249      return upsell ? `${base} · ${upsell}` : base
250    }
251  
252    const base = `Approaching ${limitName}`
253    return upsell ? `${base} · ${upsell}` : base
254  }
255  
256  /**
257   * Get the upsell command text for warning messages based on subscription and limit type.
258   * Returns null if no upsell should be shown.
259   * Only used for warnings because actual rate limit hits will see an interactive menu of options.
260   */
261  function getWarningUpsellText(
262    rateLimitType: ClaudeAILimits['rateLimitType'],
263  ): string | null {
264    const subscriptionType = getSubscriptionType()
265    const hasExtraUsageEnabled =
266      getOauthAccountInfo()?.hasExtraUsageEnabled === true
267  
268    // 5-hour session limit warning
269    if (rateLimitType === 'five_hour') {
270      // Teams/Enterprise with overages disabled: prompt to request extra usage
271      // Only show if overage provisioning is allowed for this org type (e.g., not AWS marketplace)
272      if (subscriptionType === 'team' || subscriptionType === 'enterprise') {
273        if (!hasExtraUsageEnabled && isOverageProvisioningAllowed()) {
274          return '/extra-usage to request more'
275        }
276        // Teams/Enterprise with overages enabled or unsupported billing type don't need upsell
277        return null
278      }
279  
280      // Pro/Max users: prompt to upgrade
281      if (subscriptionType === 'pro' || subscriptionType === 'max') {
282        return '/upgrade to keep using Claude Code'
283      }
284    }
285  
286    // Overage warning (approaching spending limit)
287    if (rateLimitType === 'overage') {
288      if (subscriptionType === 'team' || subscriptionType === 'enterprise') {
289        if (!hasExtraUsageEnabled && isOverageProvisioningAllowed()) {
290          return '/extra-usage to request more'
291        }
292      }
293    }
294  
295    // Weekly limit warnings don't show upsell per spec
296    return null
297  }
298  
299  /**
300   * Get notification text for overage mode transitions
301   * Used for transient notifications when entering overage mode
302   */
303  export function getUsingOverageText(limits: ClaudeAILimits): string {
304    const resetTime = limits.resetsAt
305      ? formatResetTime(limits.resetsAt, true)
306      : ''
307  
308    let limitName = ''
309    if (limits.rateLimitType === 'five_hour') {
310      limitName = 'session limit'
311    } else if (limits.rateLimitType === 'seven_day') {
312      limitName = 'weekly limit'
313    } else if (limits.rateLimitType === 'seven_day_opus') {
314      limitName = 'Opus limit'
315    } else if (limits.rateLimitType === 'seven_day_sonnet') {
316      const subscriptionType = getSubscriptionType()
317      const isProOrEnterprise =
318        subscriptionType === 'pro' || subscriptionType === 'enterprise'
319      // For pro and enterprise, Sonnet limit is the same as weekly
320      limitName = isProOrEnterprise ? 'weekly limit' : 'Sonnet limit'
321    }
322  
323    if (!limitName) {
324      return 'Now using extra usage'
325    }
326  
327    const resetMessage = resetTime
328      ? ` · Your ${limitName} resets ${resetTime}`
329      : ''
330    return `You're now using extra usage${resetMessage}`
331  }
332  
333  function formatLimitReachedText(
334    limit: string,
335    resetMessage: string,
336    _model: string,
337  ): string {
338    // Enhanced messaging for Ant users
339    if (process.env.USER_TYPE === 'ant') {
340      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`
341    }
342  
343    return `You've hit your ${limit}${resetMessage}`
344  }