883 lines
29 KiB
TypeScript
883 lines
29 KiB
TypeScript
// Mock rate limits for testing [ANT-ONLY]
|
|
// This allows testing various rate limit scenarios without hitting actual limits
|
|
//
|
|
// ⚠️ WARNING: This is for internal testing/demo purposes only!
|
|
// The mock headers may not exactly match the API specification or real-world behavior.
|
|
// Always validate against actual API responses before relying on this for production features.
|
|
|
|
import type { SubscriptionType } from '../services/oauth/types.js'
|
|
import { setMockBillingAccessOverride } from '../utils/billing.js'
|
|
import type { OverageDisabledReason } from './claudeAiLimits.js'
|
|
|
|
type MockHeaders = {
|
|
'anthropic-ratelimit-unified-status'?:
|
|
| 'allowed'
|
|
| 'allowed_warning'
|
|
| 'rejected'
|
|
'anthropic-ratelimit-unified-reset'?: string
|
|
'anthropic-ratelimit-unified-representative-claim'?:
|
|
| 'five_hour'
|
|
| 'seven_day'
|
|
| 'seven_day_opus'
|
|
| 'seven_day_sonnet'
|
|
'anthropic-ratelimit-unified-overage-status'?:
|
|
| 'allowed'
|
|
| 'allowed_warning'
|
|
| 'rejected'
|
|
'anthropic-ratelimit-unified-overage-reset'?: string
|
|
'anthropic-ratelimit-unified-overage-disabled-reason'?: OverageDisabledReason
|
|
'anthropic-ratelimit-unified-fallback'?: 'available'
|
|
'anthropic-ratelimit-unified-fallback-percentage'?: string
|
|
'retry-after'?: string
|
|
// Early warning utilization headers
|
|
'anthropic-ratelimit-unified-5h-utilization'?: string
|
|
'anthropic-ratelimit-unified-5h-reset'?: string
|
|
'anthropic-ratelimit-unified-5h-surpassed-threshold'?: string
|
|
'anthropic-ratelimit-unified-7d-utilization'?: string
|
|
'anthropic-ratelimit-unified-7d-reset'?: string
|
|
'anthropic-ratelimit-unified-7d-surpassed-threshold'?: string
|
|
'anthropic-ratelimit-unified-overage-utilization'?: string
|
|
'anthropic-ratelimit-unified-overage-surpassed-threshold'?: string
|
|
}
|
|
|
|
export type MockHeaderKey =
|
|
| 'status'
|
|
| 'reset'
|
|
| 'claim'
|
|
| 'overage-status'
|
|
| 'overage-reset'
|
|
| 'overage-disabled-reason'
|
|
| 'fallback'
|
|
| 'fallback-percentage'
|
|
| 'retry-after'
|
|
| '5h-utilization'
|
|
| '5h-reset'
|
|
| '5h-surpassed-threshold'
|
|
| '7d-utilization'
|
|
| '7d-reset'
|
|
| '7d-surpassed-threshold'
|
|
|
|
export type MockScenario =
|
|
| 'normal'
|
|
| 'session-limit-reached'
|
|
| 'approaching-weekly-limit'
|
|
| 'weekly-limit-reached'
|
|
| 'overage-active'
|
|
| 'overage-warning'
|
|
| 'overage-exhausted'
|
|
| 'out-of-credits'
|
|
| 'org-zero-credit-limit'
|
|
| 'org-spend-cap-hit'
|
|
| 'member-zero-credit-limit'
|
|
| 'seat-tier-zero-credit-limit'
|
|
| 'opus-limit'
|
|
| 'opus-warning'
|
|
| 'sonnet-limit'
|
|
| 'sonnet-warning'
|
|
| 'fast-mode-limit'
|
|
| 'fast-mode-short-limit'
|
|
| 'extra-usage-required'
|
|
| 'clear'
|
|
|
|
let mockHeaders: MockHeaders = {}
|
|
let mockEnabled = false
|
|
let mockHeaderless429Message: string | null = null
|
|
let mockSubscriptionType: SubscriptionType | null = null
|
|
let mockFastModeRateLimitDurationMs: number | null = null
|
|
let mockFastModeRateLimitExpiresAt: number | null = null
|
|
// Default subscription type for mock testing
|
|
const DEFAULT_MOCK_SUBSCRIPTION: SubscriptionType = 'max'
|
|
|
|
// Track individual exceeded limits with their reset times
|
|
type ExceededLimit = {
|
|
type: 'five_hour' | 'seven_day' | 'seven_day_opus' | 'seven_day_sonnet'
|
|
resetsAt: number // Unix timestamp
|
|
}
|
|
|
|
let exceededLimits: ExceededLimit[] = []
|
|
|
|
// New approach: Toggle individual headers
|
|
export function setMockHeader(
|
|
key: MockHeaderKey,
|
|
value: string | undefined,
|
|
): void {
|
|
if (process.env.USER_TYPE !== 'ant') {
|
|
return
|
|
}
|
|
|
|
mockEnabled = true
|
|
|
|
// Special case for retry-after which doesn't have the prefix
|
|
const fullKey = (
|
|
key === 'retry-after' ? 'retry-after' : `anthropic-ratelimit-unified-${key}`
|
|
) as keyof MockHeaders
|
|
|
|
if (value === undefined || value === 'clear') {
|
|
delete mockHeaders[fullKey]
|
|
if (key === 'claim') {
|
|
exceededLimits = []
|
|
}
|
|
// Update retry-after if status changed
|
|
if (key === 'status' || key === 'overage-status') {
|
|
updateRetryAfter()
|
|
}
|
|
return
|
|
} else {
|
|
// Handle special cases for reset times
|
|
if (key === 'reset' || key === 'overage-reset') {
|
|
// If user provides a number, treat it as hours from now
|
|
const hours = Number(value)
|
|
if (!isNaN(hours)) {
|
|
value = String(Math.floor(Date.now() / 1000) + hours * 3600)
|
|
}
|
|
}
|
|
|
|
// Handle claims - add to exceeded limits
|
|
if (key === 'claim') {
|
|
const validClaims = [
|
|
'five_hour',
|
|
'seven_day',
|
|
'seven_day_opus',
|
|
'seven_day_sonnet',
|
|
]
|
|
if (validClaims.includes(value)) {
|
|
// Determine reset time based on claim type
|
|
let resetsAt: number
|
|
if (value === 'five_hour') {
|
|
resetsAt = Math.floor(Date.now() / 1000) + 5 * 3600
|
|
} else if (
|
|
value === 'seven_day' ||
|
|
value === 'seven_day_opus' ||
|
|
value === 'seven_day_sonnet'
|
|
) {
|
|
resetsAt = Math.floor(Date.now() / 1000) + 7 * 24 * 3600
|
|
} else {
|
|
resetsAt = Math.floor(Date.now() / 1000) + 3600
|
|
}
|
|
|
|
// Add to exceeded limits (remove if already exists)
|
|
exceededLimits = exceededLimits.filter(l => l.type !== value)
|
|
exceededLimits.push({ type: value as ExceededLimit['type'], resetsAt })
|
|
|
|
// Set the representative claim (furthest reset time)
|
|
updateRepresentativeClaim()
|
|
return
|
|
}
|
|
}
|
|
// Widen to a string-valued record so dynamic key assignment is allowed.
|
|
// MockHeaders values are string-literal unions; assigning a raw user-input
|
|
// string requires widening, but this is mock/test code so it's acceptable.
|
|
const headers: Partial<Record<keyof MockHeaders, string>> = mockHeaders
|
|
headers[fullKey] = value
|
|
|
|
// Update retry-after if status changed
|
|
if (key === 'status' || key === 'overage-status') {
|
|
updateRetryAfter()
|
|
}
|
|
}
|
|
|
|
// If all headers are cleared, disable mocking
|
|
if (Object.keys(mockHeaders).length === 0) {
|
|
mockEnabled = false
|
|
}
|
|
}
|
|
|
|
// Helper to update retry-after based on current state
|
|
function updateRetryAfter(): void {
|
|
const status = mockHeaders['anthropic-ratelimit-unified-status']
|
|
const overageStatus =
|
|
mockHeaders['anthropic-ratelimit-unified-overage-status']
|
|
const reset = mockHeaders['anthropic-ratelimit-unified-reset']
|
|
|
|
if (
|
|
status === 'rejected' &&
|
|
(!overageStatus || overageStatus === 'rejected') &&
|
|
reset
|
|
) {
|
|
// Calculate seconds until reset
|
|
const resetTimestamp = Number(reset)
|
|
const secondsUntilReset = Math.max(
|
|
0,
|
|
resetTimestamp - Math.floor(Date.now() / 1000),
|
|
)
|
|
mockHeaders['retry-after'] = String(secondsUntilReset)
|
|
} else {
|
|
delete mockHeaders['retry-after']
|
|
}
|
|
}
|
|
|
|
// Update the representative claim based on exceeded limits
|
|
function updateRepresentativeClaim(): void {
|
|
if (exceededLimits.length === 0) {
|
|
delete mockHeaders['anthropic-ratelimit-unified-representative-claim']
|
|
delete mockHeaders['anthropic-ratelimit-unified-reset']
|
|
delete mockHeaders['retry-after']
|
|
return
|
|
}
|
|
|
|
// Find the limit with the furthest reset time
|
|
const furthest = exceededLimits.reduce((prev, curr) =>
|
|
curr.resetsAt > prev.resetsAt ? curr : prev,
|
|
)
|
|
|
|
// Set the representative claim (appears for both warning and rejected)
|
|
mockHeaders['anthropic-ratelimit-unified-representative-claim'] =
|
|
furthest.type
|
|
mockHeaders['anthropic-ratelimit-unified-reset'] = String(furthest.resetsAt)
|
|
|
|
// Add retry-after if rejected and no overage available
|
|
if (mockHeaders['anthropic-ratelimit-unified-status'] === 'rejected') {
|
|
const overageStatus =
|
|
mockHeaders['anthropic-ratelimit-unified-overage-status']
|
|
if (!overageStatus || overageStatus === 'rejected') {
|
|
// Calculate seconds until reset
|
|
const secondsUntilReset = Math.max(
|
|
0,
|
|
furthest.resetsAt - Math.floor(Date.now() / 1000),
|
|
)
|
|
mockHeaders['retry-after'] = String(secondsUntilReset)
|
|
} else {
|
|
// Overage is available, no retry-after
|
|
delete mockHeaders['retry-after']
|
|
}
|
|
} else {
|
|
delete mockHeaders['retry-after']
|
|
}
|
|
}
|
|
|
|
// Add function to add exceeded limit with custom reset time
|
|
export function addExceededLimit(
|
|
type: 'five_hour' | 'seven_day' | 'seven_day_opus' | 'seven_day_sonnet',
|
|
hoursFromNow: number,
|
|
): void {
|
|
if (process.env.USER_TYPE !== 'ant') {
|
|
return
|
|
}
|
|
|
|
mockEnabled = true
|
|
const resetsAt = Math.floor(Date.now() / 1000) + hoursFromNow * 3600
|
|
|
|
// Remove existing limit of same type
|
|
exceededLimits = exceededLimits.filter(l => l.type !== type)
|
|
exceededLimits.push({ type, resetsAt })
|
|
|
|
// Update status to rejected if we have exceeded limits
|
|
if (exceededLimits.length > 0) {
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
|
|
}
|
|
|
|
updateRepresentativeClaim()
|
|
}
|
|
|
|
// Set mock early warning utilization for time-relative thresholds
|
|
// claimAbbrev: '5h' or '7d'
|
|
// utilization: 0-1 (e.g., 0.92 for 92% used)
|
|
// hoursFromNow: hours until reset (default: 4 for 5h, 120 for 7d)
|
|
export function setMockEarlyWarning(
|
|
claimAbbrev: '5h' | '7d' | 'overage',
|
|
utilization: number,
|
|
hoursFromNow?: number,
|
|
): void {
|
|
if (process.env.USER_TYPE !== 'ant') {
|
|
return
|
|
}
|
|
|
|
mockEnabled = true
|
|
|
|
// Clear ALL early warning headers first (5h is checked before 7d, so we need
|
|
// to clear 5h headers when testing 7d to avoid 5h taking priority)
|
|
clearMockEarlyWarning()
|
|
|
|
// Default hours based on claim type (early in window to trigger warning)
|
|
const defaultHours = claimAbbrev === '5h' ? 4 : 5 * 24
|
|
const hours = hoursFromNow ?? defaultHours
|
|
const resetsAt = Math.floor(Date.now() / 1000) + hours * 3600
|
|
|
|
mockHeaders[`anthropic-ratelimit-unified-${claimAbbrev}-utilization`] =
|
|
String(utilization)
|
|
mockHeaders[`anthropic-ratelimit-unified-${claimAbbrev}-reset`] =
|
|
String(resetsAt)
|
|
// Set the surpassed-threshold header to trigger early warning
|
|
mockHeaders[
|
|
`anthropic-ratelimit-unified-${claimAbbrev}-surpassed-threshold`
|
|
] = String(utilization)
|
|
|
|
// Set status to allowed so early warning logic can upgrade it
|
|
if (!mockHeaders['anthropic-ratelimit-unified-status']) {
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'allowed'
|
|
}
|
|
}
|
|
|
|
// Clear mock early warning headers
|
|
export function clearMockEarlyWarning(): void {
|
|
delete mockHeaders['anthropic-ratelimit-unified-5h-utilization']
|
|
delete mockHeaders['anthropic-ratelimit-unified-5h-reset']
|
|
delete mockHeaders['anthropic-ratelimit-unified-5h-surpassed-threshold']
|
|
delete mockHeaders['anthropic-ratelimit-unified-7d-utilization']
|
|
delete mockHeaders['anthropic-ratelimit-unified-7d-reset']
|
|
delete mockHeaders['anthropic-ratelimit-unified-7d-surpassed-threshold']
|
|
}
|
|
|
|
export function setMockRateLimitScenario(scenario: MockScenario): void {
|
|
if (process.env.USER_TYPE !== 'ant') {
|
|
return
|
|
}
|
|
|
|
if (scenario === 'clear') {
|
|
mockHeaders = {}
|
|
mockHeaderless429Message = null
|
|
mockEnabled = false
|
|
return
|
|
}
|
|
|
|
mockEnabled = true
|
|
|
|
// Set reset times for demos
|
|
const fiveHoursFromNow = Math.floor(Date.now() / 1000) + 5 * 3600
|
|
const sevenDaysFromNow = Math.floor(Date.now() / 1000) + 7 * 24 * 3600
|
|
|
|
// Clear existing headers
|
|
mockHeaders = {}
|
|
mockHeaderless429Message = null
|
|
|
|
// Only clear exceeded limits for scenarios that explicitly set them
|
|
// Overage scenarios should preserve existing exceeded limits
|
|
const preserveExceededLimits = [
|
|
'overage-active',
|
|
'overage-warning',
|
|
'overage-exhausted',
|
|
].includes(scenario)
|
|
if (!preserveExceededLimits) {
|
|
exceededLimits = []
|
|
}
|
|
|
|
switch (scenario) {
|
|
case 'normal':
|
|
mockHeaders = {
|
|
'anthropic-ratelimit-unified-status': 'allowed',
|
|
'anthropic-ratelimit-unified-reset': String(fiveHoursFromNow),
|
|
}
|
|
break
|
|
|
|
case 'session-limit-reached':
|
|
exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
|
|
updateRepresentativeClaim()
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
|
|
break
|
|
|
|
case 'approaching-weekly-limit':
|
|
mockHeaders = {
|
|
'anthropic-ratelimit-unified-status': 'allowed_warning',
|
|
'anthropic-ratelimit-unified-reset': String(sevenDaysFromNow),
|
|
'anthropic-ratelimit-unified-representative-claim': 'seven_day',
|
|
}
|
|
break
|
|
|
|
case 'weekly-limit-reached':
|
|
exceededLimits = [{ type: 'seven_day', resetsAt: sevenDaysFromNow }]
|
|
updateRepresentativeClaim()
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
|
|
break
|
|
|
|
case 'overage-active': {
|
|
// If no limits have been exceeded yet, default to 5-hour
|
|
if (exceededLimits.length === 0) {
|
|
exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
|
|
}
|
|
updateRepresentativeClaim()
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
|
|
mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'allowed'
|
|
// Set overage reset time (monthly)
|
|
const endOfMonthActive = new Date()
|
|
endOfMonthActive.setMonth(endOfMonthActive.getMonth() + 1, 1)
|
|
endOfMonthActive.setHours(0, 0, 0, 0)
|
|
mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
|
|
Math.floor(endOfMonthActive.getTime() / 1000),
|
|
)
|
|
break
|
|
}
|
|
|
|
case 'overage-warning': {
|
|
// If no limits have been exceeded yet, default to 5-hour
|
|
if (exceededLimits.length === 0) {
|
|
exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
|
|
}
|
|
updateRepresentativeClaim()
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
|
|
mockHeaders['anthropic-ratelimit-unified-overage-status'] =
|
|
'allowed_warning'
|
|
// Overage typically resets monthly, but for demo let's say end of month
|
|
const endOfMonth = new Date()
|
|
endOfMonth.setMonth(endOfMonth.getMonth() + 1, 1)
|
|
endOfMonth.setHours(0, 0, 0, 0)
|
|
mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
|
|
Math.floor(endOfMonth.getTime() / 1000),
|
|
)
|
|
break
|
|
}
|
|
|
|
case 'overage-exhausted': {
|
|
// If no limits have been exceeded yet, default to 5-hour
|
|
if (exceededLimits.length === 0) {
|
|
exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
|
|
}
|
|
updateRepresentativeClaim()
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
|
|
mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected'
|
|
// Both subscription and overage are exhausted
|
|
// Subscription resets based on the exceeded limit, overage resets monthly
|
|
const endOfMonthExhausted = new Date()
|
|
endOfMonthExhausted.setMonth(endOfMonthExhausted.getMonth() + 1, 1)
|
|
endOfMonthExhausted.setHours(0, 0, 0, 0)
|
|
mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
|
|
Math.floor(endOfMonthExhausted.getTime() / 1000),
|
|
)
|
|
break
|
|
}
|
|
|
|
case 'out-of-credits': {
|
|
// Out of credits - subscription limit hit, overage rejected due to insufficient credits
|
|
// (wallet is empty)
|
|
if (exceededLimits.length === 0) {
|
|
exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
|
|
}
|
|
updateRepresentativeClaim()
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
|
|
mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected'
|
|
mockHeaders['anthropic-ratelimit-unified-overage-disabled-reason'] =
|
|
'out_of_credits'
|
|
const endOfMonth = new Date()
|
|
endOfMonth.setMonth(endOfMonth.getMonth() + 1, 1)
|
|
endOfMonth.setHours(0, 0, 0, 0)
|
|
mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
|
|
Math.floor(endOfMonth.getTime() / 1000),
|
|
)
|
|
break
|
|
}
|
|
|
|
case 'org-zero-credit-limit': {
|
|
// Org service has zero credit limit - admin set org-level spend cap to $0
|
|
// Non-admin Team/Enterprise users should not see "Request extra usage" option
|
|
if (exceededLimits.length === 0) {
|
|
exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
|
|
}
|
|
updateRepresentativeClaim()
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
|
|
mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected'
|
|
mockHeaders['anthropic-ratelimit-unified-overage-disabled-reason'] =
|
|
'org_service_zero_credit_limit'
|
|
const endOfMonthZero = new Date()
|
|
endOfMonthZero.setMonth(endOfMonthZero.getMonth() + 1, 1)
|
|
endOfMonthZero.setHours(0, 0, 0, 0)
|
|
mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
|
|
Math.floor(endOfMonthZero.getTime() / 1000),
|
|
)
|
|
break
|
|
}
|
|
|
|
case 'org-spend-cap-hit': {
|
|
// Org spend cap hit for the month - org overages temporarily disabled
|
|
// Non-admin Team/Enterprise users should not see "Request extra usage" option
|
|
if (exceededLimits.length === 0) {
|
|
exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
|
|
}
|
|
updateRepresentativeClaim()
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
|
|
mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected'
|
|
mockHeaders['anthropic-ratelimit-unified-overage-disabled-reason'] =
|
|
'org_level_disabled_until'
|
|
const endOfMonthHit = new Date()
|
|
endOfMonthHit.setMonth(endOfMonthHit.getMonth() + 1, 1)
|
|
endOfMonthHit.setHours(0, 0, 0, 0)
|
|
mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
|
|
Math.floor(endOfMonthHit.getTime() / 1000),
|
|
)
|
|
break
|
|
}
|
|
|
|
case 'member-zero-credit-limit': {
|
|
// Member has zero credit limit - admin set this user's individual limit to $0
|
|
// Non-admin Team/Enterprise users SHOULD see "Request extra usage" (admin can allocate more)
|
|
if (exceededLimits.length === 0) {
|
|
exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
|
|
}
|
|
updateRepresentativeClaim()
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
|
|
mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected'
|
|
mockHeaders['anthropic-ratelimit-unified-overage-disabled-reason'] =
|
|
'member_zero_credit_limit'
|
|
const endOfMonthMember = new Date()
|
|
endOfMonthMember.setMonth(endOfMonthMember.getMonth() + 1, 1)
|
|
endOfMonthMember.setHours(0, 0, 0, 0)
|
|
mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
|
|
Math.floor(endOfMonthMember.getTime() / 1000),
|
|
)
|
|
break
|
|
}
|
|
|
|
case 'seat-tier-zero-credit-limit': {
|
|
// Seat tier has zero credit limit - admin set this seat tier's limit to $0
|
|
// Non-admin Team/Enterprise users SHOULD see "Request extra usage" (admin can allocate more)
|
|
if (exceededLimits.length === 0) {
|
|
exceededLimits = [{ type: 'five_hour', resetsAt: fiveHoursFromNow }]
|
|
}
|
|
updateRepresentativeClaim()
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
|
|
mockHeaders['anthropic-ratelimit-unified-overage-status'] = 'rejected'
|
|
mockHeaders['anthropic-ratelimit-unified-overage-disabled-reason'] =
|
|
'seat_tier_zero_credit_limit'
|
|
const endOfMonthSeatTier = new Date()
|
|
endOfMonthSeatTier.setMonth(endOfMonthSeatTier.getMonth() + 1, 1)
|
|
endOfMonthSeatTier.setHours(0, 0, 0, 0)
|
|
mockHeaders['anthropic-ratelimit-unified-overage-reset'] = String(
|
|
Math.floor(endOfMonthSeatTier.getTime() / 1000),
|
|
)
|
|
break
|
|
}
|
|
|
|
case 'opus-limit': {
|
|
exceededLimits = [{ type: 'seven_day_opus', resetsAt: sevenDaysFromNow }]
|
|
updateRepresentativeClaim()
|
|
// Always send 429 rejected status - the error handler will decide whether
|
|
// to show an error or return NO_RESPONSE_REQUESTED based on fallback eligibility
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
|
|
break
|
|
}
|
|
|
|
case 'opus-warning': {
|
|
mockHeaders = {
|
|
'anthropic-ratelimit-unified-status': 'allowed_warning',
|
|
'anthropic-ratelimit-unified-reset': String(sevenDaysFromNow),
|
|
'anthropic-ratelimit-unified-representative-claim': 'seven_day_opus',
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'sonnet-limit': {
|
|
exceededLimits = [
|
|
{ type: 'seven_day_sonnet', resetsAt: sevenDaysFromNow },
|
|
]
|
|
updateRepresentativeClaim()
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
|
|
break
|
|
}
|
|
|
|
case 'sonnet-warning': {
|
|
mockHeaders = {
|
|
'anthropic-ratelimit-unified-status': 'allowed_warning',
|
|
'anthropic-ratelimit-unified-reset': String(sevenDaysFromNow),
|
|
'anthropic-ratelimit-unified-representative-claim': 'seven_day_sonnet',
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'fast-mode-limit': {
|
|
updateRepresentativeClaim()
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
|
|
// Duration in ms (> 20s threshold to trigger cooldown)
|
|
mockFastModeRateLimitDurationMs = 10 * 60 * 1000
|
|
break
|
|
}
|
|
|
|
case 'fast-mode-short-limit': {
|
|
updateRepresentativeClaim()
|
|
mockHeaders['anthropic-ratelimit-unified-status'] = 'rejected'
|
|
// Duration in ms (< 20s threshold, won't trigger cooldown)
|
|
mockFastModeRateLimitDurationMs = 10 * 1000
|
|
break
|
|
}
|
|
|
|
case 'extra-usage-required': {
|
|
// Headerless 429 — exercises the entitlement-rejection path in errors.ts
|
|
mockHeaderless429Message =
|
|
'Extra usage is required for long context requests.'
|
|
break
|
|
}
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
export function getMockHeaderless429Message(): string | null {
|
|
if (process.env.USER_TYPE !== 'ant') {
|
|
return null
|
|
}
|
|
// Env var path for -p / SDK testing where slash commands aren't available
|
|
if (process.env.CLAUDE_MOCK_HEADERLESS_429) {
|
|
return process.env.CLAUDE_MOCK_HEADERLESS_429
|
|
}
|
|
if (!mockEnabled) {
|
|
return null
|
|
}
|
|
return mockHeaderless429Message
|
|
}
|
|
|
|
export function getMockHeaders(): MockHeaders | null {
|
|
if (
|
|
!mockEnabled ||
|
|
process.env.USER_TYPE !== 'ant' ||
|
|
Object.keys(mockHeaders).length === 0
|
|
) {
|
|
return null
|
|
}
|
|
return mockHeaders
|
|
}
|
|
|
|
export function getMockStatus(): string {
|
|
if (
|
|
!mockEnabled ||
|
|
(Object.keys(mockHeaders).length === 0 && !mockSubscriptionType)
|
|
) {
|
|
return 'No mock headers active (using real limits)'
|
|
}
|
|
|
|
const lines: string[] = []
|
|
lines.push('Active mock headers:')
|
|
|
|
// Show subscription type - either explicitly set or default
|
|
const effectiveSubscription =
|
|
mockSubscriptionType || DEFAULT_MOCK_SUBSCRIPTION
|
|
if (mockSubscriptionType) {
|
|
lines.push(` Subscription Type: ${mockSubscriptionType} (explicitly set)`)
|
|
} else {
|
|
lines.push(` Subscription Type: ${effectiveSubscription} (default)`)
|
|
}
|
|
|
|
Object.entries(mockHeaders).forEach(([key, value]) => {
|
|
if (value !== undefined) {
|
|
// Format the header name nicely
|
|
const formattedKey = key
|
|
.replace('anthropic-ratelimit-unified-', '')
|
|
.replace(/-/g, ' ')
|
|
.replace(/\b\w/g, c => c.toUpperCase())
|
|
|
|
// Format timestamps as human-readable
|
|
if (key.includes('reset') && value) {
|
|
const timestamp = Number(value)
|
|
const date = new Date(timestamp * 1000)
|
|
lines.push(` ${formattedKey}: ${value} (${date.toLocaleString()})`)
|
|
} else {
|
|
lines.push(` ${formattedKey}: ${value}`)
|
|
}
|
|
}
|
|
})
|
|
|
|
// Show exceeded limits if any
|
|
if (exceededLimits.length > 0) {
|
|
lines.push('\nExceeded limits (contributing to representative claim):')
|
|
exceededLimits.forEach(limit => {
|
|
const date = new Date(limit.resetsAt * 1000)
|
|
lines.push(` ${limit.type}: resets at ${date.toLocaleString()}`)
|
|
})
|
|
}
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
export function clearMockHeaders(): void {
|
|
mockHeaders = {}
|
|
exceededLimits = []
|
|
mockSubscriptionType = null
|
|
mockFastModeRateLimitDurationMs = null
|
|
mockFastModeRateLimitExpiresAt = null
|
|
mockHeaderless429Message = null
|
|
setMockBillingAccessOverride(null)
|
|
mockEnabled = false
|
|
}
|
|
|
|
export function applyMockHeaders(
|
|
headers: globalThis.Headers,
|
|
): globalThis.Headers {
|
|
const mock = getMockHeaders()
|
|
if (!mock) {
|
|
return headers
|
|
}
|
|
|
|
// Create a new Headers object with original headers
|
|
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
|
const newHeaders = new globalThis.Headers(headers)
|
|
|
|
// Apply mock headers (overwriting originals)
|
|
Object.entries(mock).forEach(([key, value]) => {
|
|
if (value !== undefined) {
|
|
newHeaders.set(key, value)
|
|
}
|
|
})
|
|
|
|
return newHeaders
|
|
}
|
|
|
|
// Check if we should process rate limits even without subscription
|
|
// This is for Ant employees testing with mocks
|
|
export function shouldProcessMockLimits(): boolean {
|
|
if (process.env.USER_TYPE !== 'ant') {
|
|
return false
|
|
}
|
|
return mockEnabled || Boolean(process.env.CLAUDE_MOCK_HEADERLESS_429)
|
|
}
|
|
|
|
export function getCurrentMockScenario(): MockScenario | null {
|
|
if (!mockEnabled) {
|
|
return null
|
|
}
|
|
|
|
// Reverse lookup the scenario from current headers
|
|
if (!mockHeaders) return null
|
|
|
|
const status = mockHeaders['anthropic-ratelimit-unified-status']
|
|
const overage = mockHeaders['anthropic-ratelimit-unified-overage-status']
|
|
const claim = mockHeaders['anthropic-ratelimit-unified-representative-claim']
|
|
|
|
if (claim === 'seven_day_opus') {
|
|
return status === 'rejected' ? 'opus-limit' : 'opus-warning'
|
|
}
|
|
|
|
if (claim === 'seven_day_sonnet') {
|
|
return status === 'rejected' ? 'sonnet-limit' : 'sonnet-warning'
|
|
}
|
|
|
|
if (overage === 'rejected') return 'overage-exhausted'
|
|
if (overage === 'allowed_warning') return 'overage-warning'
|
|
if (overage === 'allowed') return 'overage-active'
|
|
|
|
if (status === 'rejected') {
|
|
if (claim === 'five_hour') return 'session-limit-reached'
|
|
if (claim === 'seven_day') return 'weekly-limit-reached'
|
|
}
|
|
|
|
if (status === 'allowed_warning') {
|
|
if (claim === 'seven_day') return 'approaching-weekly-limit'
|
|
}
|
|
|
|
if (status === 'allowed') return 'normal'
|
|
|
|
return null
|
|
}
|
|
|
|
export function getScenarioDescription(scenario: MockScenario): string {
|
|
switch (scenario) {
|
|
case 'normal':
|
|
return 'Normal usage, no limits'
|
|
case 'session-limit-reached':
|
|
return 'Session rate limit exceeded'
|
|
case 'approaching-weekly-limit':
|
|
return 'Approaching weekly aggregate limit'
|
|
case 'weekly-limit-reached':
|
|
return 'Weekly aggregate limit exceeded'
|
|
case 'overage-active':
|
|
return 'Using extra usage (overage active)'
|
|
case 'overage-warning':
|
|
return 'Approaching extra usage limit'
|
|
case 'overage-exhausted':
|
|
return 'Both subscription and extra usage limits exhausted'
|
|
case 'out-of-credits':
|
|
return 'Out of extra usage credits (wallet empty)'
|
|
case 'org-zero-credit-limit':
|
|
return 'Org spend cap is zero (no extra usage budget)'
|
|
case 'org-spend-cap-hit':
|
|
return 'Org spend cap hit for the month'
|
|
case 'member-zero-credit-limit':
|
|
return 'Member limit is zero (admin can allocate more)'
|
|
case 'seat-tier-zero-credit-limit':
|
|
return 'Seat tier limit is zero (admin can allocate more)'
|
|
case 'opus-limit':
|
|
return 'Opus limit reached'
|
|
case 'opus-warning':
|
|
return 'Approaching Opus limit'
|
|
case 'sonnet-limit':
|
|
return 'Sonnet limit reached'
|
|
case 'sonnet-warning':
|
|
return 'Approaching Sonnet limit'
|
|
case 'fast-mode-limit':
|
|
return 'Fast mode rate limit'
|
|
case 'fast-mode-short-limit':
|
|
return 'Fast mode rate limit (short)'
|
|
case 'extra-usage-required':
|
|
return 'Headerless 429: Extra usage required for 1M context'
|
|
case 'clear':
|
|
return 'Clear mock headers (use real limits)'
|
|
default:
|
|
return 'Unknown scenario'
|
|
}
|
|
}
|
|
|
|
// Mock subscription type management
|
|
export function setMockSubscriptionType(
|
|
subscriptionType: SubscriptionType | null,
|
|
): void {
|
|
if (process.env.USER_TYPE !== 'ant') {
|
|
return
|
|
}
|
|
mockEnabled = true
|
|
mockSubscriptionType = subscriptionType
|
|
}
|
|
|
|
export function getMockSubscriptionType(): SubscriptionType | null {
|
|
if (!mockEnabled || process.env.USER_TYPE !== 'ant') {
|
|
return null
|
|
}
|
|
// Return the explicitly set subscription type, or default to 'max'
|
|
return mockSubscriptionType || DEFAULT_MOCK_SUBSCRIPTION
|
|
}
|
|
|
|
// Export a function that checks if we should use mock subscription
|
|
export function shouldUseMockSubscription(): boolean {
|
|
return (
|
|
mockEnabled &&
|
|
mockSubscriptionType !== null &&
|
|
process.env.USER_TYPE === 'ant'
|
|
)
|
|
}
|
|
|
|
// Mock billing access (admin vs non-admin)
|
|
export function setMockBillingAccess(hasAccess: boolean | null): void {
|
|
if (process.env.USER_TYPE !== 'ant') {
|
|
return
|
|
}
|
|
mockEnabled = true
|
|
setMockBillingAccessOverride(hasAccess)
|
|
}
|
|
|
|
// Mock fast mode rate limit handling
|
|
export function isMockFastModeRateLimitScenario(): boolean {
|
|
return mockFastModeRateLimitDurationMs !== null
|
|
}
|
|
|
|
export function checkMockFastModeRateLimit(
|
|
isFastModeActive?: boolean,
|
|
): MockHeaders | null {
|
|
if (mockFastModeRateLimitDurationMs === null) {
|
|
return null
|
|
}
|
|
|
|
// Only throw when fast mode is active
|
|
if (!isFastModeActive) {
|
|
return null
|
|
}
|
|
|
|
// Check if the rate limit has expired
|
|
if (
|
|
mockFastModeRateLimitExpiresAt !== null &&
|
|
Date.now() >= mockFastModeRateLimitExpiresAt
|
|
) {
|
|
clearMockHeaders()
|
|
return null
|
|
}
|
|
|
|
// Set expiry on first error (not when scenario is configured)
|
|
if (mockFastModeRateLimitExpiresAt === null) {
|
|
mockFastModeRateLimitExpiresAt =
|
|
Date.now() + mockFastModeRateLimitDurationMs
|
|
}
|
|
|
|
// Compute dynamic retry-after based on remaining time
|
|
const remainingMs = mockFastModeRateLimitExpiresAt - Date.now()
|
|
const headersToSend = { ...mockHeaders }
|
|
headersToSend['retry-after'] = String(
|
|
Math.max(1, Math.ceil(remainingMs / 1000)),
|
|
)
|
|
|
|
return headersToSend
|
|
}
|