Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 57 additions & 8 deletions packages/core/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export type OAuthQuotaSnapshot = Partial<
export type RoutingMode = 'main-first' | 'fallback-first'

export type KillswitchThresholds = Partial<
Record<QuotaWindowName | '5h' | '1w', number>
Record<QuotaWindowName | '5h' | '1w' | 'scoped', number>
>

export type KillswitchConfig = {
Expand Down Expand Up @@ -1656,16 +1656,21 @@ export function quotaSnapshotPassesPolicy(
// thresholds, even if the API would still accept them.
// ---------------------------------------------------------------------------

export const DEFAULT_KILLSWITCH_THRESHOLDS: Record<QuotaWindowName, number> = {
export const DEFAULT_KILLSWITCH_THRESHOLDS: Record<
QuotaWindowName | 'scoped',
number
> = {
five_hour: 5,
seven_day: 10,
scoped: 0,
}

function normalizeKillswitchThresholds(
export function normalizeKillswitchThresholds(
thresholds: KillswitchThresholds | undefined,
): Record<QuotaWindowName, number> {
): Record<QuotaWindowName | 'scoped', number> {
const fiveHour = thresholds?.five_hour ?? thresholds?.['5h']
const sevenDay = thresholds?.seven_day ?? thresholds?.['1w']
const scoped = thresholds?.scoped
return {
five_hour:
typeof fiveHour === 'number' && Number.isFinite(fiveHour)
Expand All @@ -1675,17 +1680,21 @@ function normalizeKillswitchThresholds(
typeof sevenDay === 'number' && Number.isFinite(sevenDay)
? sevenDay
: DEFAULT_KILLSWITCH_THRESHOLDS.seven_day,
scoped:
typeof scoped === 'number' && Number.isFinite(scoped)
? scoped
: DEFAULT_KILLSWITCH_THRESHOLDS.scoped,
}
}

export function isKillswitchEnabled(storage: AccountStorage | null) {
return storage?.killswitch?.enabled === true
}

function getKillswitchThresholdsForAccount(
export function getKillswitchThresholdsForAccount(
storage: AccountStorage | null,
accountId?: string,
): Record<QuotaWindowName, number> {
): Record<QuotaWindowName | 'scoped', number> {
if (!storage?.killswitch) return DEFAULT_KILLSWITCH_THRESHOLDS
if (accountId && storage.killswitch.accounts?.[accountId]) {
return normalizeKillswitchThresholds(storage.killswitch.accounts[accountId])
Expand All @@ -1696,11 +1705,16 @@ function getKillswitchThresholdsForAccount(
/**
* Returns true if the account's quota is above its killswitch threshold.
* When killswitch is disabled, always returns true.
*
* When `modelId` is provided, the per-account `scoped` threshold is also
* evaluated against the quota window matching that model — additive to the
* 5h/7d check. A model with no matching scoped window is unaffected.
*/
export function killswitchPassesPolicy(
quota: OAuthQuotaSnapshot | undefined,
storage: AccountStorage | null,
accountId?: string,
modelId?: string,
) {
if (!isKillswitchEnabled(storage)) return true
const thresholds = getKillswitchThresholdsForAccount(storage, accountId)
Expand All @@ -1720,29 +1734,64 @@ export function killswitchPassesPolicy(
}
if (window.remainingPercent < thresholds[key]) return false
}
// Scoped check is additive to the 5h/7d evaluation above and is an
// INDEPENDENT block reason — it must run before the unknown-window
// fail-closed decision, so an exhausted scoped window blocks even when
// 5h/7d is missing/non-finite (the latter only changes the fall-through
// for accounts that did not already block on scoped). A missing scoped
// window (no carve-out for this model) is not "unknown quota" — only a
// PRESENT window at/below threshold blocks. The comparison is inclusive
// (`<=`) so the default 0 fires at exhaustion.
if (modelId) {
const scopedWindow = getScopedQuotaWindowForModel(quota, modelId)
if (
scopedWindow &&
Number.isFinite(scopedWindow.remainingPercent) &&
scopedWindow.remainingPercent <= thresholds.scoped
) {
return false
}
}
if (sawUnknownWindow) return !failClosedOnUnknownQuota(storage)
return true
}

/**
* Find the earliest reset time across all accounts' quota windows.
* Returns seconds from `now` until that reset, or 300 as a fallback.
*
* When `scopedModelId` is provided, ONLY the matched scoped window's
* `resetsAt` is considered — the 5h/7d resets are intentionally ignored
* so the retry hint reflects the weekly reset, not the sooner 5h reset
* (which would cause a retry-storm against a block that won't clear for
* days). With `scopedModelId` undefined, the 5h/7d behavior is unchanged.
*/
export function killswitchRetryAfterSeconds(
mainQuota: OAuthQuotaSnapshot | undefined,
fallbackAccounts: Array<{ quota?: OAuthQuotaSnapshot }>,
now: number,
scopedModelId?: string,
): number {
const resetTimes: number[] = []
const allQuotas = [mainQuota, ...fallbackAccounts.map((a) => a.quota)]
for (const quota of allQuotas) {
for (const key of ['five_hour', 'seven_day'] as const) {
const resetStr = quota?.[key]?.resetsAt
if (scopedModelId) {
Comment thread
iceteaSA marked this conversation as resolved.
const scopedWindow = getScopedQuotaWindowForModel(quota, scopedModelId)
const resetStr = scopedWindow?.resetsAt
if (!resetStr) continue
const resetTime = Date.parse(resetStr)
if (Number.isFinite(resetTime) && resetTime > now) {
resetTimes.push(resetTime)
}
} else {
for (const key of ['five_hour', 'seven_day'] as const) {
const resetStr = quota?.[key]?.resetsAt
if (!resetStr) continue
const resetTime = Date.parse(resetStr)
if (Number.isFinite(resetTime) && resetTime > now) {
resetTimes.push(resetTime)
}
}
}
}
Comment thread
iceteaSA marked this conversation as resolved.
if (!resetTimes.length) return 300
Expand Down
67 changes: 49 additions & 18 deletions packages/core/src/killswitch.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import type { KillswitchConfig, KillswitchThresholds } from './accounts.ts'
import { DEFAULT_KILLSWITCH_THRESHOLDS as DEFAULT_THRESHOLDS } from './accounts.ts'
import {
DEFAULT_KILLSWITCH_THRESHOLDS as DEFAULT_THRESHOLDS,
normalizeKillswitchThresholds,
} from './accounts.ts'

export const KILLSWITCH_COMMAND_NAME = 'claude-killswitch'

export type KillswitchCommandAction =
| { type: 'status' }
| { type: 'on' }
| { type: 'off' }
| { type: 'set'; entries: Array<{ account: string; fh: number; sd: number }> }
| {
type: 'set'
entries: Array<{
account: string
fh: number
sd: number
scoped?: number
}>
}
| { type: 'usage' }

export function parseKillswitchCommandAction(
Expand All @@ -19,17 +30,33 @@ export function parseKillswitchCommandAction(
if (parts.length === 1 && parts[0] === 'off') return { type: 'off' }

if (parts[0] === 'set') {
const entries: Array<{ account: string; fh: number; sd: number }> = []
const entries: Array<{
account: string
fh: number
sd: number
scoped?: number
}> = []
for (let i = 1; i < parts.length; i++) {
const match = parts[i]?.match(/^([^:]+):(\d+),(\d+)$/)
const match = parts[i]?.match(/^([^:]+):(\d+),(\d+)(?:,(\d+))?$/)
if (!match) return { type: 'usage' }
const [, account, fhStr, sdStr] = match as RegExpMatchArray &
[string, string, string, string]
entries.push({
const [, account, fhStr, sdStr, scopedStr] = match as RegExpMatchArray &
[string, string, string, string, string | undefined]
const scoped =
typeof scopedStr === 'string' && scopedStr.length > 0
? Number.parseInt(scopedStr, 10)
: undefined
const entry: {
account: string
fh: number
sd: number
scoped?: number
} = {
account,
fh: Number.parseInt(fhStr, 10),
sd: Number.parseInt(sdStr, 10),
})
}
if (scoped !== undefined) entry.scoped = scoped
entries.push(entry)
}
if (entries.length === 0) return { type: 'usage' }
return { type: 'set', entries }
Expand All @@ -51,19 +78,21 @@ function buildStatusTable(

if (enabled) {
lines.push('')
lines.push('| Account | 5h threshold | 1w threshold |')
lines.push('| ------- | ------------ | ------------ |')
lines.push('| Account | 5h threshold | 1w threshold | Scoped |')
lines.push('| ------- | ------------ | ------------ | ------ |')

const mainT = config.main ?? {}
const fh = mainT.five_hour ?? mainT['5h'] ?? DEFAULT_THRESHOLDS.five_hour
const sd = mainT.seven_day ?? mainT['1w'] ?? DEFAULT_THRESHOLDS.seven_day
lines.push(`| main | \u2265 ${fh}% | \u2265 ${sd}% |`)
const mainT = normalizeKillswitchThresholds(config.main)
lines.push(
`| main | \u2265 ${mainT.five_hour}% | \u2265 ${mainT.seven_day}% | \u2264 ${mainT.scoped}% |`,
)

for (const id of accountIds) {
const t = config.accounts?.[id] ?? config.main ?? {}
const afh = t.five_hour ?? t['5h'] ?? DEFAULT_THRESHOLDS.five_hour
const asd = t.seven_day ?? t['1w'] ?? DEFAULT_THRESHOLDS.seven_day
lines.push(`| ${id} | \u2265 ${afh}% | \u2265 ${asd}% |`)
const t = normalizeKillswitchThresholds(
config.accounts?.[id] ?? config.main,
)
lines.push(
`| ${id} | \u2265 ${t.five_hour}% | \u2265 ${t.seven_day}% | \u2264 ${t.scoped}% |`,
)
}
}

Expand All @@ -78,6 +107,7 @@ const USAGE_TEXT = [
`/${KILLSWITCH_COMMAND_NAME} on — enable with current or default thresholds`,
`/${KILLSWITCH_COMMAND_NAME} off — disable`,
`/${KILLSWITCH_COMMAND_NAME} set all:5,10 — set all accounts to 5h≥5%, 1w≥10%`,
`/${KILLSWITCH_COMMAND_NAME} set main:3,8,0 — set 5h≥3%, 1w≥8%, scoped≤0%`,
`/${KILLSWITCH_COMMAND_NAME} set main:3,8 work-alt:5,10 — per-account`,
'```',
].join('\n')
Expand Down Expand Up @@ -130,6 +160,7 @@ export function executeKillswitchCommand(input: {
five_hour: entry.fh,
seven_day: entry.sd,
}
if (entry.scoped !== undefined) thresholds.scoped = entry.scoped
if (entry.account === 'main') {
updated.main = thresholds
} else if (entry.account === 'all') {
Expand Down
Loading