/ services / mockRateLimits.ts
mockRateLimits.ts
  1  // Mock rate limits for testing [ANT-ONLY]
  2  // This allows testing various rate limit scenarios without hitting actual limits
  3  //
  4  // ⚠️  WARNING: This is for internal testing/demo purposes only!
  5  // The mock headers may not exactly match the API specification or real-world behavior.
  6  // Always validate against actual API responses before relying on this for production features.
  7  
  8  import type { SubscriptionType } from '../services/oauth/types.js'
  9  import { setMockBillingAccessOverride } from '../utils/billing.js'
 10  import type { OverageDisabledReason } from './claudeAiLimits.js'
 11  
 12  type MockHeaders = {
 13    'anthropic-ratelimit-unified-status'?:
 14      | 'allowed'
 15      | 'allowed_warning'
 16      | 'rejected'
 17    'anthropic-ratelimit-unified-reset'?: string
 18    'anthropic-ratelimit-unified-representative-claim'?:
 19      | 'five_hour'
 20      | 'seven_day'
 21      | 'seven_day_opus'
 22      | 'seven_day_sonnet'
 23    'anthropic-ratelimit-unified-overage-status'?:
 24      | 'allowed'
 25      | 'allowed_warning'
 26      | 'rejected'
 27    'anthropic-ratelimit-unified-overage-reset'?: string
 28    'anthropic-ratelimit-unified-overage-disabled-reason'?: OverageDisabledReason
 29    'anthropic-ratelimit-unified-fallback'?: 'available'
 30    'anthropic-ratelimit-unified-fallback-percentage'?: string
 31    'retry-after'?: string
 32    // Early warning utilization headers
 33    'anthropic-ratelimit-unified-5h-utilization'?: string
 34    'anthropic-ratelimit-unified-5h-reset'?: string
 35    'anthropic-ratelimit-unified-5h-surpassed-threshold'?: string
 36    'anthropic-ratelimit-unified-7d-utilization'?: string
 37    'anthropic-ratelimit-unified-7d-reset'?: string
 38    'anthropic-ratelimit-unified-7d-surpassed-threshold'?: string
 39    'anthropic-ratelimit-unified-overage-utilization'?: string
 40    'anthropic-ratelimit-unified-overage-surpassed-threshold'?: string
 41  }
 42  
 43  export type MockHeaderKey =
 44    | 'status'
 45    | 'reset'
 46    | 'claim'
 47    | 'overage-status'
 48    | 'overage-reset'
 49    | 'overage-disabled-reason'
 50    | 'fallback'
 51    | 'fallback-percentage'
 52    | 'retry-after'
 53    | '5h-utilization'
 54    | '5h-reset'
 55    | '5h-surpassed-threshold'
 56    | '7d-utilization'
 57    | '7d-reset'
 58    | '7d-surpassed-threshold'
 59  
 60  export type MockScenario =
 61    | 'normal'
 62    | 'session-limit-reached'
 63    | 'approaching-weekly-limit'
 64    | 'weekly-limit-reached'
 65    | 'overage-active'
 66    | 'overage-warning'
 67    | 'overage-exhausted'
 68    | 'out-of-credits'
 69    | 'org-zero-credit-limit'
 70    | 'org-spend-cap-hit'
 71    | 'member-zero-credit-limit'
 72    | 'seat-tier-zero-credit-limit'
 73    | 'opus-limit'
 74    | 'opus-warning'
 75    | 'sonnet-limit'
 76    | 'sonnet-warning'
 77    | 'fast-mode-limit'
 78    | 'fast-mode-short-limit'
 79    | 'extra-usage-required'
 80    | 'clear'
 81  
 82  let mockHeaders: MockHeaders = {}
 83  let mockEnabled = false
 84  let mockHeaderless429Message: string | null = null
 85  let mockSubscriptionType: SubscriptionType | null = null
 86  let mockFastModeRateLimitDurationMs: number | null = null
 87  let mockFastModeRateLimitExpiresAt: number | null = null
 88  // Default subscription type for mock testing
 89  const DEFAULT_MOCK_SUBSCRIPTION: SubscriptionType = 'max'
 90  
 91  // Track individual exceeded limits with their reset times
 92  type ExceededLimit = {
 93    type: 'five_hour' | 'seven_day' | 'seven_day_opus' | 'seven_day_sonnet'
 94    resetsAt: number // Unix timestamp
 95  }
 96  
 97  let exceededLimits: ExceededLimit[] = []
 98  
 99  // New approach: Toggle individual headers
100  export function setMockHeader(
101    key: MockHeaderKey,
102    value: string | undefined,
103  ): void {
104    if (process.env.USER_TYPE !== 'ant') {
105      return
106    }
107  
108    mockEnabled = true
109  
110    // Special case for retry-after which doesn't have the prefix
111    const fullKey = (
112      key === 'retry-after' ? 'retry-after' : `anthropic-ratelimit-unified-${key}`
113    ) as keyof MockHeaders
114  
115    if (value === undefined || value === 'clear') {
116      delete mockHeaders[fullKey]
117      if (key === 'claim') {
118        exceededLimits = []
119      }
120      // Update retry-after if status changed
121      if (key === 'status' || key === 'overage-status') {
122        updateRetryAfter()
123      }
124      return
125    } else {
126      // Handle special cases for reset times
127      if (key === 'reset' || key === 'overage-reset') {
128        // If user provides a number, treat it as hours from now
129        const hours = Number(value)
130        if (!isNaN(hours)) {
131          value = String(Math.floor(Date.now() / 1000) + hours * 3600)
132        }
133      }
134  
135      // Handle claims - add to exceeded limits
136      if (key === 'claim') {
137        const validClaims = [
138          'five_hour',
139          'seven_day',
140          'seven_day_opus',
141          'seven_day_sonnet',
142        ]
143        if (validClaims.includes(value)) {
144          // Determine reset time based on claim type
145          let resetsAt: number
146          if (value === 'five_hour') {
147            resetsAt = Math.floor(Date.now() / 1000) + 5 * 3600
148          } else if (
149            value === 'seven_day' ||
150            value === 'seven_day_opus' ||
151            value === 'seven_day_sonnet'
152          ) {
153            resetsAt = Math.floor(Date.now() / 1000) + 7 * 24 * 3600
154          } else {
155            resetsAt = Math.floor(Date.now() / 1000) + 3600
156          }
157  
158          // Add to exceeded limits (remove if already exists)
159          exceededLimits = exceededLimits.filter(l => l.type !== value)
160          exceededLimits.push({ type: value as ExceededLimit['type'], resetsAt })
161  
162          // Set the representative claim (furthest reset time)
163          updateRepresentativeClaim()
164          return
165        }
166      }
167      // Widen to a string-valued record so dynamic key assignment is allowed.
168      // MockHeaders values are string-literal unions; assigning a raw user-input
169      // string requires widening, but this is mock/test code so it's acceptable.
170      const headers: Partial<Record<keyof MockHeaders, string>> = mockHeaders
171      headers[fullKey] = value
172  
173      // Update retry-after if status changed
174      if (key === 'status' || key === 'overage-status') {
175        updateRetryAfter()
176      }
177    }
178  
179    // If all headers are cleared, disable mocking
180    if (Object.keys(mockHeaders).length === 0) {
181      mockEnabled = false
182    }
183  }
184  
185  // Helper to update retry-after based on current state
186  function updateRetryAfter(): void {
187    const status = mockHeaders['anthropic-ratelimit-unified-status']
188    const overageStatus =
189      mockHeaders['anthropic-ratelimit-unified-overage-status']
190    const reset = mockHeaders['anthropic-ratelimit-unified-reset']
191  
192    if (
193      status === 'rejected' &&
194      (!overageStatus || overageStatus === 'rejected') &&
195      reset
196    ) {
197      // Calculate seconds until reset
198      const resetTimestamp = Number(reset)
199      const secondsUntilReset = Math.max(
200        0,
201        resetTimestamp - Math.floor(Date.now() / 1000),
202      )
203      mockHeaders['retry-after'] = String(secondsUntilReset)
204    } else {
205      delete mockHeaders['retry-after']
206    }
207  }
208  
209  // Update the representative claim based on exceeded limits
210  function updateRepresentativeClaim(): void {
211    if (exceededLimits.length === 0) {
212      delete mockHeaders['anthropic-ratelimit-unified-representative-claim']
213      delete mockHeaders['anthropic-ratelimit-unified-reset']
214      delete mockHeaders['retry-after']
215      return
216    }
217  
218    // Find the limit with the furthest reset time
219    const furthest = exceededLimits.reduce((prev, curr) =>
220      curr.resetsAt > prev.resetsAt ? curr : prev,
221    )
222  
223    // Set the representative claim (appears for both warning and rejected)
224    mockHeaders['anthropic-ratelimit-unified-representative-claim'] =
225      furthest.type
226    mockHeaders['anthropic-ratelimit-unified-reset'] = String(furthest.resetsAt)
227  
228    // Add retry-after if rejected and no overage available
229    if (mockHeaders['anthropic-ratelimit-unified-status'] === 'rejected') {
230      const overageStatus =
231        mockHeaders['anthropic-ratelimit-unified-overage-status']
232      if (!overageStatus || overageStatus === 'rejected') {
233        // Calculate seconds until reset
234        const secondsUntilReset = Math.max(
235          0,
236          furthest.resetsAt - Math.floor(Date.now() / 1000),
237        )
238        mockHeaders['retry-after'] = String(secondsUntilReset)
239      } else {
240        // Overage is available, no retry-after
241        delete mockHeaders['retry-after']
242      }
243    } else {
244      delete mockHeaders['retry-after']
245    }
246  }
247  
248  // Add function to add exceeded limit with custom reset time
249  export function addExceededLimit(
250    type: 'five_hour' | 'seven_day' | 'seven_day_opus' | 'seven_day_sonnet',
251    hoursFromNow: number,
252  ): void {
253    if (process.env.USER_TYPE !== 'ant') {
254      return
255    }
256  
257    mockEnabled = true
258    const resetsAt = Math.floor(Date.now() / 1000) + hoursFromNow * 3600
259  
260    // Remove existing limit of same type
261    exceededLimits = exceededLimits.filter(l => l.type !== type)
262    exceededLimits.push({ type, resetsAt })
263  
264    // Update status to rejected if we have exceeded limits
265    if (exceededLimits.length > 0) {
266      mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
267    }
268  
269    updateRepresentativeClaim()
270  }
271  
272  // Set mock early warning utilization for time-relative thresholds
273  // claimAbbrev: '5h' or '7d'
274  // utilization: 0-1 (e.g., 0.92 for 92% used)
275  // hoursFromNow: hours until reset (default: 4 for 5h, 120 for 7d)
276  export function setMockEarlyWarning(
277    claimAbbrev: '5h' | '7d' | 'overage',
278    utilization: number,
279    hoursFromNow?: number,
280  ): void {
281    if (process.env.USER_TYPE !== 'ant') {
282      return
283    }
284  
285    mockEnabled = true
286  
287    // Clear ALL early warning headers first (5h is checked before 7d, so we need
288    // to clear 5h headers when testing 7d to avoid 5h taking priority)
289    clearMockEarlyWarning()
290  
291    // Default hours based on claim type (early in window to trigger warning)
292    const defaultHours = claimAbbrev === '5h' ? 4 : 5 * 24
293    const hours = hoursFromNow ?? defaultHours
294    const resetsAt = Math.floor(Date.now() / 1000) + hours * 3600
295  
296    mockHeaders[`anthropic-ratelimit-unified-${claimAbbrev}-utilization`] =
297      String(utilization)
298    mockHeaders[`anthropic-ratelimit-unified-${claimAbbrev}-reset`] =
299      String(resetsAt)
300    // Set the surpassed-threshold header to trigger early warning
301    mockHeaders[
302      `anthropic-ratelimit-unified-${claimAbbrev}-surpassed-threshold`
303    ] = String(utilization)
304  
305    // Set status to allowed so early warning logic can upgrade it
306    if (!mockHeaders['anthropic-ratelimit-unified-status']) {
307      mockHeaders['anthropic-ratelimit-unified-status'] = 'allowed'
308    }
309  }
310  
311  // Clear mock early warning headers
312  export function clearMockEarlyWarning(): void {
313    delete mockHeaders['anthropic-ratelimit-unified-5h-utilization']
314    delete mockHeaders['anthropic-ratelimit-unified-5h-reset']
315    delete mockHeaders['anthropic-ratelimit-unified-5h-surpassed-threshold']
316    delete mockHeaders['anthropic-ratelimit-unified-7d-utilization']
317    delete mockHeaders['anthropic-ratelimit-unified-7d-reset']
318    delete mockHeaders['anthropic-ratelimit-unified-7d-surpassed-threshold']
319  }
320  
321  export function setMockRateLimitScenario(scenario: MockScenario): void {
322    if (process.env.USER_TYPE !== 'ant') {
323      return
324    }
325  
326    if (scenario === 'clear') {
327      mockHeaders = {}
328      mockHeaderless429Message = null
329      mockEnabled = false
330      return
331    }
332  
333    mockEnabled = true
334  
335    // Set reset times for demos
336    const fiveHoursFromNow = Math.floor(Date.now() / 1000) + 5 * 3600
337    const sevenDaysFromNow = Math.floor(Date.now() / 1000) + 7 * 24 * 3600
338  
339    // Clear existing headers
340    mockHeaders = {}
341    mockHeaderless429Message = null
342  
343    // Only clear exceeded limits for scenarios that explicitly set them
344    // Overage scenarios should preserve existing exceeded limits
345    const preserveExceededLimits = [
346      'overage-active',
347      'overage-warning',
348      'overage-exhausted',
349    ].includes(scenario)
350    if (!preserveExceededLimits) {
351      exceededLimits = []
352    }
353  
354    switch (scenario) {
355      case 'normal':
356        mockHeaders = {
357          'anthropic-ratelimit-unified-status': 'allowed',
358          'anthropic-ratelimit-unified-reset': String(fiveHoursFromNow),
359        }
360        break
361  
362      case 'session-limit-reached':
363        exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
364        updateRepresentativeClaim()
365        mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
366        break
367  
368      case 'approaching-weekly-limit':
369        mockHeaders = {
370          'anthropic-ratelimit-unified-status': 'allowed_warning',
371          'anthropic-ratelimit-unified-reset': String(sevenDaysFromNow),
372          'anthropic-ratelimit-unified-representative-claim': 'seven_day',
373        }
374        break
375  
376      case 'weekly-limit-reached':
377        exceededLimits = [{ type: 'seven_day', resetsAt: sevenDaysFromNow }]
378        updateRepresentativeClaim()
379        mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
380        break
381  
382      case 'overage-active': {
383        // If no limits have been exceeded yet, default to 5-hour
384        if (exceededLimits.length === 0) {
385          exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
386        }
387        updateRepresentativeClaim()
388        mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
389        mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'allowed'
390        // Set overage reset time (monthly)
391        const endOfMonthActive = new Date()
392        endOfMonthActive.setMonth(endOfMonthActive.getMonth() + 1, 1)
393        endOfMonthActive.setHours(0, 0, 0, 0)
394        mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
395          Math.floor(endOfMonthActive.getTime() / 1000),
396        )
397        break
398      }
399  
400      case 'overage-warning': {
401        // If no limits have been exceeded yet, default to 5-hour
402        if (exceededLimits.length === 0) {
403          exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
404        }
405        updateRepresentativeClaim()
406        mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
407        mockHeaders['anthropic-ratelimit-unified-overage-status'] =
408          'allowed_warning'
409        // Overage typically resets monthly, but for demo let's say end of month
410        const endOfMonth = new Date()
411        endOfMonth.setMonth(endOfMonth.getMonth() + 1, 1)
412        endOfMonth.setHours(0, 0, 0, 0)
413        mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
414          Math.floor(endOfMonth.getTime() / 1000),
415        )
416        break
417      }
418  
419      case 'overage-exhausted': {
420        // If no limits have been exceeded yet, default to 5-hour
421        if (exceededLimits.length === 0) {
422          exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
423        }
424        updateRepresentativeClaim()
425        mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
426        mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected'
427        // Both subscription and overage are exhausted
428        // Subscription resets based on the exceeded limit, overage resets monthly
429        const endOfMonthExhausted = new Date()
430        endOfMonthExhausted.setMonth(endOfMonthExhausted.getMonth() + 1, 1)
431        endOfMonthExhausted.setHours(0, 0, 0, 0)
432        mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
433          Math.floor(endOfMonthExhausted.getTime() / 1000),
434        )
435        break
436      }
437  
438      case 'out-of-credits': {
439        // Out of credits - subscription limit hit, overage rejected due to insufficient credits
440        // (wallet is empty)
441        if (exceededLimits.length === 0) {
442          exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
443        }
444        updateRepresentativeClaim()
445        mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
446        mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected'
447        mockHeaders['anthropic-ratelimit-unified-overage-disabled-reason'] =
448          'out_of_credits'
449        const endOfMonth = new Date()
450        endOfMonth.setMonth(endOfMonth.getMonth() + 1, 1)
451        endOfMonth.setHours(0, 0, 0, 0)
452        mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
453          Math.floor(endOfMonth.getTime() / 1000),
454        )
455        break
456      }
457  
458      case 'org-zero-credit-limit': {
459        // Org service has zero credit limit - admin set org-level spend cap to $0
460        // Non-admin Team/Enterprise users should not see "Request extra usage" option
461        if (exceededLimits.length === 0) {
462          exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
463        }
464        updateRepresentativeClaim()
465        mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
466        mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected'
467        mockHeaders['anthropic-ratelimit-unified-overage-disabled-reason'] =
468          'org_service_zero_credit_limit'
469        const endOfMonthZero = new Date()
470        endOfMonthZero.setMonth(endOfMonthZero.getMonth() + 1, 1)
471        endOfMonthZero.setHours(0, 0, 0, 0)
472        mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
473          Math.floor(endOfMonthZero.getTime() / 1000),
474        )
475        break
476      }
477  
478      case 'org-spend-cap-hit': {
479        // Org spend cap hit for the month - org overages temporarily disabled
480        // Non-admin Team/Enterprise users should not see "Request extra usage" option
481        if (exceededLimits.length === 0) {
482          exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
483        }
484        updateRepresentativeClaim()
485        mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
486        mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected'
487        mockHeaders['anthropic-ratelimit-unified-overage-disabled-reason'] =
488          'org_level_disabled_until'
489        const endOfMonthHit = new Date()
490        endOfMonthHit.setMonth(endOfMonthHit.getMonth() + 1, 1)
491        endOfMonthHit.setHours(0, 0, 0, 0)
492        mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
493          Math.floor(endOfMonthHit.getTime() / 1000),
494        )
495        break
496      }
497  
498      case 'member-zero-credit-limit': {
499        // Member has zero credit limit - admin set this user's individual limit to $0
500        // Non-admin Team/Enterprise users SHOULD see "Request extra usage" (admin can allocate more)
501        if (exceededLimits.length === 0) {
502          exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
503        }
504        updateRepresentativeClaim()
505        mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
506        mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected'
507        mockHeaders['anthropic-ratelimit-unified-overage-disabled-reason'] =
508          'member_zero_credit_limit'
509        const endOfMonthMember = new Date()
510        endOfMonthMember.setMonth(endOfMonthMember.getMonth() + 1, 1)
511        endOfMonthMember.setHours(0, 0, 0, 0)
512        mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
513          Math.floor(endOfMonthMember.getTime() / 1000),
514        )
515        break
516      }
517  
518      case 'seat-tier-zero-credit-limit': {
519        // Seat tier has zero credit limit - admin set this seat tier's limit to $0
520        // Non-admin Team/Enterprise users SHOULD see "Request extra usage" (admin can allocate more)
521        if (exceededLimits.length === 0) {
522          exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
523        }
524        updateRepresentativeClaim()
525        mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
526        mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected'
527        mockHeaders['anthropic-ratelimit-unified-overage-disabled-reason'] =
528          'seat_tier_zero_credit_limit'
529        const endOfMonthSeatTier = new Date()
530        endOfMonthSeatTier.setMonth(endOfMonthSeatTier.getMonth() + 1, 1)
531        endOfMonthSeatTier.setHours(0, 0, 0, 0)
532        mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
533          Math.floor(endOfMonthSeatTier.getTime() / 1000),
534        )
535        break
536      }
537  
538      case 'opus-limit': {
539        exceededLimits = [{ type: 'seven_day_opus', resetsAt: sevenDaysFromNow }]
540        updateRepresentativeClaim()
541        // Always send 429 rejected status - the error handler will decide whether
542        // to show an error or return NO_RESPONSE_REQUESTED based on fallback eligibility
543        mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
544        break
545      }
546  
547      case 'opus-warning': {
548        mockHeaders = {
549          'anthropic-ratelimit-unified-status': 'allowed_warning',
550          'anthropic-ratelimit-unified-reset': String(sevenDaysFromNow),
551          'anthropic-ratelimit-unified-representative-claim': 'seven_day_opus',
552        }
553        break
554      }
555  
556      case 'sonnet-limit': {
557        exceededLimits = [
558          { type: 'seven_day_sonnet', resetsAt: sevenDaysFromNow },
559        ]
560        updateRepresentativeClaim()
561        mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
562        break
563      }
564  
565      case 'sonnet-warning': {
566        mockHeaders = {
567          'anthropic-ratelimit-unified-status': 'allowed_warning',
568          'anthropic-ratelimit-unified-reset': String(sevenDaysFromNow),
569          'anthropic-ratelimit-unified-representative-claim': 'seven_day_sonnet',
570        }
571        break
572      }
573  
574      case 'fast-mode-limit': {
575        updateRepresentativeClaim()
576        mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
577        // Duration in ms (> 20s threshold to trigger cooldown)
578        mockFastModeRateLimitDurationMs = 10 * 60 * 1000
579        break
580      }
581  
582      case 'fast-mode-short-limit': {
583        updateRepresentativeClaim()
584        mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
585        // Duration in ms (< 20s threshold, won't trigger cooldown)
586        mockFastModeRateLimitDurationMs = 10 * 1000
587        break
588      }
589  
590      case 'extra-usage-required': {
591        // Headerless 429 — exercises the entitlement-rejection path in errors.ts
592        mockHeaderless429Message =
593          'Extra usage is required for long context requests.'
594        break
595      }
596  
597      default:
598        break
599    }
600  }
601  
602  export function getMockHeaderless429Message(): string | null {
603    if (process.env.USER_TYPE !== 'ant') {
604      return null
605    }
606    // Env var path for -p / SDK testing where slash commands aren't available
607    if (process.env.CLAUDE_MOCK_HEADERLESS_429) {
608      return process.env.CLAUDE_MOCK_HEADERLESS_429
609    }
610    if (!mockEnabled) {
611      return null
612    }
613    return mockHeaderless429Message
614  }
615  
616  export function getMockHeaders(): MockHeaders | null {
617    if (
618      !mockEnabled ||
619      process.env.USER_TYPE !== 'ant' ||
620      Object.keys(mockHeaders).length === 0
621    ) {
622      return null
623    }
624    return mockHeaders
625  }
626  
627  export function getMockStatus(): string {
628    if (
629      !mockEnabled ||
630      (Object.keys(mockHeaders).length === 0 && !mockSubscriptionType)
631    ) {
632      return 'No mock headers active (using real limits)'
633    }
634  
635    const lines: string[] = []
636    lines.push('Active mock headers:')
637  
638    // Show subscription type - either explicitly set or default
639    const effectiveSubscription =
640      mockSubscriptionType || DEFAULT_MOCK_SUBSCRIPTION
641    if (mockSubscriptionType) {
642      lines.push(`  Subscription Type: ${mockSubscriptionType} (explicitly set)`)
643    } else {
644      lines.push(`  Subscription Type: ${effectiveSubscription} (default)`)
645    }
646  
647    Object.entries(mockHeaders).forEach(([key, value]) => {
648      if (value !== undefined) {
649        // Format the header name nicely
650        const formattedKey = key
651          .replace('anthropic-ratelimit-unified-', '')
652          .replace(/-/g, ' ')
653          .replace(/\b\w/g, c => c.toUpperCase())
654  
655        // Format timestamps as human-readable
656        if (key.includes('reset') && value) {
657          const timestamp = Number(value)
658          const date = new Date(timestamp * 1000)
659          lines.push(`  ${formattedKey}: ${value} (${date.toLocaleString()})`)
660        } else {
661          lines.push(`  ${formattedKey}: ${value}`)
662        }
663      }
664    })
665  
666    // Show exceeded limits if any
667    if (exceededLimits.length > 0) {
668      lines.push('\nExceeded limits (contributing to representative claim):')
669      exceededLimits.forEach(limit => {
670        const date = new Date(limit.resetsAt * 1000)
671        lines.push(`  ${limit.type}: resets at ${date.toLocaleString()}`)
672      })
673    }
674  
675    return lines.join('\n')
676  }
677  
678  export function clearMockHeaders(): void {
679    mockHeaders = {}
680    exceededLimits = []
681    mockSubscriptionType = null
682    mockFastModeRateLimitDurationMs = null
683    mockFastModeRateLimitExpiresAt = null
684    mockHeaderless429Message = null
685    setMockBillingAccessOverride(null)
686    mockEnabled = false
687  }
688  
689  export function applyMockHeaders(
690    headers: globalThis.Headers,
691  ): globalThis.Headers {
692    const mock = getMockHeaders()
693    if (!mock) {
694      return headers
695    }
696  
697    // Create a new Headers object with original headers
698    // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
699    const newHeaders = new globalThis.Headers(headers)
700  
701    // Apply mock headers (overwriting originals)
702    Object.entries(mock).forEach(([key, value]) => {
703      if (value !== undefined) {
704        newHeaders.set(key, value)
705      }
706    })
707  
708    return newHeaders
709  }
710  
711  // Check if we should process rate limits even without subscription
712  // This is for Ant employees testing with mocks
713  export function shouldProcessMockLimits(): boolean {
714    if (process.env.USER_TYPE !== 'ant') {
715      return false
716    }
717    return mockEnabled || Boolean(process.env.CLAUDE_MOCK_HEADERLESS_429)
718  }
719  
720  export function getCurrentMockScenario(): MockScenario | null {
721    if (!mockEnabled) {
722      return null
723    }
724  
725    // Reverse lookup the scenario from current headers
726    if (!mockHeaders) return null
727  
728    const status = mockHeaders['anthropic-ratelimit-unified-status']
729    const overage = mockHeaders['anthropic-ratelimit-unified-overage-status']
730    const claim = mockHeaders['anthropic-ratelimit-unified-representative-claim']
731  
732    if (claim === 'seven_day_opus') {
733      return status === 'rejected' ? 'opus-limit' : 'opus-warning'
734    }
735  
736    if (claim === 'seven_day_sonnet') {
737      return status === 'rejected' ? 'sonnet-limit' : 'sonnet-warning'
738    }
739  
740    if (overage === 'rejected') return 'overage-exhausted'
741    if (overage === 'allowed_warning') return 'overage-warning'
742    if (overage === 'allowed') return 'overage-active'
743  
744    if (status === 'rejected') {
745      if (claim === 'five_hour') return 'session-limit-reached'
746      if (claim === 'seven_day') return 'weekly-limit-reached'
747    }
748  
749    if (status === 'allowed_warning') {
750      if (claim === 'seven_day') return 'approaching-weekly-limit'
751    }
752  
753    if (status === 'allowed') return 'normal'
754  
755    return null
756  }
757  
758  export function getScenarioDescription(scenario: MockScenario): string {
759    switch (scenario) {
760      case 'normal':
761        return 'Normal usage, no limits'
762      case 'session-limit-reached':
763        return 'Session rate limit exceeded'
764      case 'approaching-weekly-limit':
765        return 'Approaching weekly aggregate limit'
766      case 'weekly-limit-reached':
767        return 'Weekly aggregate limit exceeded'
768      case 'overage-active':
769        return 'Using extra usage (overage active)'
770      case 'overage-warning':
771        return 'Approaching extra usage limit'
772      case 'overage-exhausted':
773        return 'Both subscription and extra usage limits exhausted'
774      case 'out-of-credits':
775        return 'Out of extra usage credits (wallet empty)'
776      case 'org-zero-credit-limit':
777        return 'Org spend cap is zero (no extra usage budget)'
778      case 'org-spend-cap-hit':
779        return 'Org spend cap hit for the month'
780      case 'member-zero-credit-limit':
781        return 'Member limit is zero (admin can allocate more)'
782      case 'seat-tier-zero-credit-limit':
783        return 'Seat tier limit is zero (admin can allocate more)'
784      case 'opus-limit':
785        return 'Opus limit reached'
786      case 'opus-warning':
787        return 'Approaching Opus limit'
788      case 'sonnet-limit':
789        return 'Sonnet limit reached'
790      case 'sonnet-warning':
791        return 'Approaching Sonnet limit'
792      case 'fast-mode-limit':
793        return 'Fast mode rate limit'
794      case 'fast-mode-short-limit':
795        return 'Fast mode rate limit (short)'
796      case 'extra-usage-required':
797        return 'Headerless 429: Extra usage required for 1M context'
798      case 'clear':
799        return 'Clear mock headers (use real limits)'
800      default:
801        return 'Unknown scenario'
802    }
803  }
804  
805  // Mock subscription type management
806  export function setMockSubscriptionType(
807    subscriptionType: SubscriptionType | null,
808  ): void {
809    if (process.env.USER_TYPE !== 'ant') {
810      return
811    }
812    mockEnabled = true
813    mockSubscriptionType = subscriptionType
814  }
815  
816  export function getMockSubscriptionType(): SubscriptionType | null {
817    if (!mockEnabled || process.env.USER_TYPE !== 'ant') {
818      return null
819    }
820    // Return the explicitly set subscription type, or default to 'max'
821    return mockSubscriptionType || DEFAULT_MOCK_SUBSCRIPTION
822  }
823  
824  // Export a function that checks if we should use mock subscription
825  export function shouldUseMockSubscription(): boolean {
826    return (
827      mockEnabled &&
828      mockSubscriptionType !== null &&
829      process.env.USER_TYPE === 'ant'
830    )
831  }
832  
833  // Mock billing access (admin vs non-admin)
834  export function setMockBillingAccess(hasAccess: boolean | null): void {
835    if (process.env.USER_TYPE !== 'ant') {
836      return
837    }
838    mockEnabled = true
839    setMockBillingAccessOverride(hasAccess)
840  }
841  
842  // Mock fast mode rate limit handling
843  export function isMockFastModeRateLimitScenario(): boolean {
844    return mockFastModeRateLimitDurationMs !== null
845  }
846  
847  export function checkMockFastModeRateLimit(
848    isFastModeActive?: boolean,
849  ): MockHeaders | null {
850    if (mockFastModeRateLimitDurationMs === null) {
851      return null
852    }
853  
854    // Only throw when fast mode is active
855    if (!isFastModeActive) {
856      return null
857    }
858  
859    // Check if the rate limit has expired
860    if (
861      mockFastModeRateLimitExpiresAt !== null &&
862      Date.now() >= mockFastModeRateLimitExpiresAt
863    ) {
864      clearMockHeaders()
865      return null
866    }
867  
868    // Set expiry on first error (not when scenario is configured)
869    if (mockFastModeRateLimitExpiresAt === null) {
870      mockFastModeRateLimitExpiresAt =
871        Date.now() + mockFastModeRateLimitDurationMs
872    }
873  
874    // Compute dynamic retry-after based on remaining time
875    const remainingMs = mockFastModeRateLimitExpiresAt - Date.now()
876    const headersToSend = { ...mockHeaders }
877    headersToSend['retry-after'] = String(
878      Math.max(1, Math.ceil(remainingMs / 1000)),
879    )
880  
881    return headersToSend
882  }