switchroom 0.18.13 → 0.18.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/agent-scheduler/index.js +49 -9
  2. package/dist/auth-broker/index.js +152 -46
  3. package/dist/cli/autoaccept-poll.js +23 -0
  4. package/dist/cli/drive-write-pretool.mjs +24 -1
  5. package/dist/cli/foreground-hog-pretool.mjs +264 -0
  6. package/dist/cli/notion-write-pretool.mjs +0 -1
  7. package/dist/cli/switchroom.js +1185 -1072
  8. package/dist/host-control/main.js +53 -52
  9. package/dist/vault/approvals/kernel-server.js +16 -13
  10. package/dist/vault/broker/server.js +672 -669
  11. package/package.json +1 -1
  12. package/profiles/coding/CLAUDE.md.hbs +2 -0
  13. package/profiles/default/CLAUDE.md.hbs +2 -0
  14. package/skills/switchroom-architecture/telegram.md +0 -1
  15. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  16. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  17. package/telegram-plugin/bridge/bridge.ts +2 -0
  18. package/telegram-plugin/dist/bridge/bridge.js +23 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +765 -67
  20. package/telegram-plugin/dist/server.js +24 -1
  21. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  22. package/telegram-plugin/gateway/auth-command.ts +14 -0
  23. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  24. package/telegram-plugin/gateway/gateway.ts +270 -10
  25. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  26. package/telegram-plugin/history.ts +55 -6
  27. package/telegram-plugin/model-unavailable.ts +234 -2
  28. package/telegram-plugin/render/rich-render.ts +40 -32
  29. package/telegram-plugin/runtime-metrics.ts +31 -0
  30. package/telegram-plugin/session-tail.ts +14 -2
  31. package/telegram-plugin/stream-controller.ts +3 -2
  32. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  34. package/telegram-plugin/tests/history.test.ts +157 -0
  35. package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
  36. package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
  37. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  38. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  39. package/telegram-plugin/tests/runtime-metrics.test.ts +24 -0
  40. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  41. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  42. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  43. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  44. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  45. package/telegram-plugin/tests/throttle-tier.test.ts +454 -0
  46. package/telegram-plugin/throttle-tier.ts +323 -0
  47. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
@@ -0,0 +1,268 @@
1
+ /**
2
+ * throttle-tier-wiring.ts — side-effect runner for the 429 throttle tier.
3
+ *
4
+ * The DECISION and notice text live in ../throttle-tier.ts (pure). This
5
+ * module owns the sequenced side effects, with every dependency injected so
6
+ * the wiring is unit-testable without importing gateway.ts:
7
+ *
8
+ * 1. Broker `mark-throttled` — records `throttled_until` in the quota
9
+ * ledger (no roll, no eligibility change) and runs the broker-side
10
+ * escalation guard (3 hits / 10 min → live probe → mark-exhausted when
11
+ * corroborated). EVERY fire reaches the broker — the notice cooldown
12
+ * below never suppresses the ledger write, because the escalation
13
+ * counter is what corroborates a wall hiding behind transient wording.
14
+ * 2. ONE lightweight operator notice — deduped per account BOTH locally
15
+ * (cooldown window) and FLEET-WIDE via the broker's claim-notification
16
+ * verb (N agents sharing a throttled account produce one copy per chat,
17
+ * not N). Claim failures FAIL OPEN (send anyway) per the claim
18
+ * contract.
19
+ * 3. A delayed retry nudge — after `throttled_until` (+slack +jitter so N
20
+ * agents don't restart-and-replay simultaneously into the just-cleared
21
+ * account), replay the turn the 429 killed via the existing resume
22
+ * lever (triggerSelfRestart → boot-resume). Guards, in order:
23
+ * - a LIVE turn that started AFTER the throttle was armed supersedes
24
+ * the dead turn — skip entirely (restarting would kill live work
25
+ * and boot-resume would replay the WRONG turn);
26
+ * - a live turn that started BEFORE the arm is the dead turn itself
27
+ * still holding the in-flight gate — defer the restart to the
28
+ * turn-complete drain (`pendingRestarts`) instead of SIGTERM-now;
29
+ * - otherwise consult the SHARED fleet-fallback resume gate
30
+ * (single-flight across throttle AND fallback resumes + staleness)
31
+ * and restart on a 'resume' verdict.
32
+ *
33
+ * The escalated outcome (broker corroborated a wall and already rolled)
34
+ * posts its own announcement — gateway-side, per the reactive-path doctrine
35
+ * (`LastFleetRoll` docstring in src/auth/broker/server.ts), which also
36
+ * covers PINNED (non-fleet-active) account rolls — then nudges the resume
37
+ * immediately through the same turn-safety guards.
38
+ */
39
+
40
+ import {
41
+ evaluateThrottleNotice,
42
+ renderThrottleEscalationNotice,
43
+ renderThrottleNotice,
44
+ THROTTLE_NOTICE_COOLDOWN_MS,
45
+ type ThrottleNoticeState,
46
+ } from '../throttle-tier.js'
47
+
48
+ /** Slack past throttled_until before the retry nudge fires. */
49
+ export const THROTTLE_RETRY_NUDGE_SLACK_MS = 5_000
50
+
51
+ /** Max random jitter added to the nudge so agents sharing the throttled
52
+ * account stagger their restart-and-replay instead of stampeding the
53
+ * just-cleared account. */
54
+ export const THROTTLE_RETRY_NUDGE_JITTER_MAX_MS = 30_000
55
+
56
+ /** The narrow broker surface the runner needs (structurally satisfied by
57
+ * the gateway's AuthBrokerClient). */
58
+ export interface ThrottleBrokerClient {
59
+ markThrottled(until: number): Promise<{
60
+ account: string
61
+ throttled_until: number
62
+ escalated: boolean
63
+ rolledTo?: string | null
64
+ }>
65
+ claimNotification(key: string, windowMs: number): Promise<{ granted: boolean }>
66
+ }
67
+
68
+ export interface ThrottleTierRunnerDeps {
69
+ /** This gateway's own agent (SWITCHROOM_AGENT_NAME). */
70
+ agentName: string
71
+ getBrokerClient(): Promise<ThrottleBrokerClient | null>
72
+ /** Chats the notice broadcasts to (access.allowFrom, resolved per call). */
73
+ listNoticeChats(): Array<string | number>
74
+ /** Fire-and-forget rich send (gateway wraps swallowingApiCall). */
75
+ sendNotice(chatId: string | number, markdown: string): void
76
+ /** THE shared fleet-fallback resume gate (single-flight + staleness). */
77
+ resumeDecide(failedTurnStartedAtMs: number | null): 'resume' | 'skip-inflight' | 'skip-stale'
78
+ newestActiveTurnStartedAtMs(): number | null
79
+ /** True while a turn is in flight (gateway turnInFlightForGate()). */
80
+ turnInFlight(): boolean
81
+ /** Defer the restart to the turn-complete drain (pendingRestarts). */
82
+ deferRestartToTurnComplete(agentName: string, reason: string): void
83
+ /** Restart now (gateway triggerSelfRestart). */
84
+ restartNow(agentName: string, reason: string): void
85
+ log(msg: string): void
86
+ now?: () => number
87
+ /** Timer seam (tests drive synchronously). Default setTimeout+unref. */
88
+ schedule?: (fn: () => void, ms: number) => { cancel(): void }
89
+ /** Jitter source (tests pin it). Default uniform 0..JITTER_MAX. */
90
+ jitterMs?: () => number
91
+ }
92
+
93
+ export interface ThrottleTierRunner {
94
+ /**
95
+ * Run the throttle path for one terminal transient 429. Fire-and-forget
96
+ * from the caller's perspective — never throws.
97
+ */
98
+ fire(triggerAgent: string, throttledUntilMs: number, resetParsed: boolean): Promise<void>
99
+ /** Test/debug view of internal state. */
100
+ inspect(): { noticeState: ThrottleNoticeState; nudgePending: boolean }
101
+ }
102
+
103
+ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): ThrottleTierRunner {
104
+ const now = deps.now ?? (() => Date.now())
105
+ const schedule =
106
+ deps.schedule ??
107
+ ((fn: () => void, ms: number) => {
108
+ const t = setTimeout(fn, ms)
109
+ if (typeof t.unref === 'function') t.unref()
110
+ return { cancel: () => clearTimeout(t) }
111
+ })
112
+ const jitterMs =
113
+ deps.jitterMs ?? (() => Math.floor(Math.random() * THROTTLE_RETRY_NUDGE_JITTER_MAX_MS))
114
+
115
+ let noticeState: ThrottleNoticeState = { lastSentAtMsByAccount: {} }
116
+ /** The LATEST throttle owns the nudge — a newer hit replaces an armed
117
+ * timer instead of stacking restarts. */
118
+ let pendingNudge: { cancel(): void } | null = null
119
+
120
+ /**
121
+ * Post `markdown` to every authorized chat, fleet-deduped per chat via the
122
+ * broker claim verb when a client + account are available. Fail-open: a
123
+ * claim error or missing broker never drops the notice.
124
+ */
125
+ async function broadcastDeduped(
126
+ client: ThrottleBrokerClient | null,
127
+ keyPrefix: string,
128
+ account: string | null,
129
+ markdown: string,
130
+ ): Promise<void> {
131
+ for (const chatId of deps.listNoticeChats()) {
132
+ let granted = true
133
+ if (client && account) {
134
+ try {
135
+ granted = (
136
+ await client.claimNotification(
137
+ `${keyPrefix}:${account}:${chatId}`,
138
+ THROTTLE_NOTICE_COOLDOWN_MS,
139
+ )
140
+ ).granted
141
+ } catch {
142
+ granted = true // fail open — a duplicated notice beats a dropped one
143
+ }
144
+ }
145
+ if (granted) {
146
+ deps.sendNotice(chatId, markdown)
147
+ } else {
148
+ deps.log(`[throttle-tier] notice suppressed (fleet claim) chat=${chatId}`)
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Replay the dead turn via restart, with the turn-safety guards (see
155
+ * module docstring). `armedAtMs` anchors the "newer turn supersedes"
156
+ * check: a turn that started after the throttle was armed is live user
157
+ * work, never to be killed for a replay.
158
+ */
159
+ function nudgeResume(reason: string, armedAtMs: number): void {
160
+ const newest = deps.newestActiveTurnStartedAtMs()
161
+ if (deps.turnInFlight()) {
162
+ if (newest != null && newest > armedAtMs) {
163
+ deps.log(
164
+ `[throttle-tier] resume skipped (superseded by a live newer turn) reason=${reason}`,
165
+ )
166
+ return
167
+ }
168
+ // The in-flight gate is held by the dead throttled turn itself —
169
+ // defer to the turn-complete drain instead of SIGTERM-ing now.
170
+ deps.log(`[throttle-tier] resume deferred to turn-complete reason=${reason}`)
171
+ deps.deferRestartToTurnComplete(deps.agentName, reason)
172
+ return
173
+ }
174
+ const verdict = deps.resumeDecide(newest)
175
+ if (verdict === 'resume') {
176
+ deps.log(`[throttle-tier] resuming dead turn via self-restart reason=${reason}`)
177
+ deps.restartNow(deps.agentName, reason)
178
+ } else {
179
+ deps.log(`[throttle-tier] resume suppressed (${verdict}) reason=${reason}`)
180
+ }
181
+ }
182
+
183
+ async function fire(
184
+ triggerAgent: string,
185
+ throttledUntilMs: number,
186
+ resetParsed: boolean,
187
+ ): Promise<void> {
188
+ const armedAtMs = now()
189
+ let client: ThrottleBrokerClient | null = null
190
+ let account: string | null = null
191
+ let escalated = false
192
+ let rolledTo: string | null = null
193
+ try {
194
+ client = await deps.getBrokerClient()
195
+ if (client) {
196
+ const r = await client.markThrottled(throttledUntilMs)
197
+ account = r.account
198
+ escalated = r.escalated
199
+ rolledTo = r.rolledTo ?? null
200
+ } else {
201
+ deps.log(
202
+ `[throttle-tier] broker unreachable — notice only, no ledger record agent=${triggerAgent}`,
203
+ )
204
+ }
205
+ } catch (err) {
206
+ deps.log(
207
+ `[throttle-tier] markThrottled failed agent=${triggerAgent}: ${(err as Error)?.message ?? err}`,
208
+ )
209
+ }
210
+
211
+ if (escalated) {
212
+ // The broker corroborated a genuine wall via a live probe and already
213
+ // ran mark-exhausted + roll (fleet active AND pinned accounts alike).
214
+ // The RAISING gateway announces — reactive-path doctrine — fleet-
215
+ // deduped so N gateways sharing the account produce one copy per chat.
216
+ deps.log(
217
+ `[throttle-tier] escalated to wall account=${account ?? '?'} ` +
218
+ `rolledTo=${rolledTo ?? 'none (all blocked)'}`,
219
+ )
220
+ await broadcastDeduped(
221
+ client,
222
+ 'throttle-escalation',
223
+ account,
224
+ renderThrottleEscalationNotice({ account, agent: triggerAgent, rolledTo }),
225
+ )
226
+ if (rolledTo) nudgeResume('throttle-escalation-resume', armedAtMs)
227
+ return
228
+ }
229
+
230
+ // ONE lightweight notice, deduped per account: locally by cooldown, then
231
+ // fleet-wide by broker claim. Unknown account (broker down) keys the
232
+ // local cooldown on the agent so the degraded path still can't spam.
233
+ const cooldownKey = account ?? `agent:${triggerAgent}`
234
+ const verdict = evaluateThrottleNotice(noticeState, cooldownKey, now())
235
+ if (verdict.send) {
236
+ noticeState = verdict.next
237
+ await broadcastDeduped(
238
+ client,
239
+ 'throttle-notice',
240
+ account,
241
+ renderThrottleNotice({
242
+ account,
243
+ agent: triggerAgent,
244
+ throttledUntilMs,
245
+ resetParsed,
246
+ now: new Date(now()),
247
+ }),
248
+ )
249
+ } else {
250
+ deps.log(`[throttle-tier] notice suppressed (cooldown) key=${cooldownKey}`)
251
+ }
252
+
253
+ // Arm (or re-arm) the retry nudge: slack past the reset + jitter so
254
+ // agents sharing the account stagger their replays.
255
+ const delayMs =
256
+ Math.max(throttledUntilMs - now(), 0) + THROTTLE_RETRY_NUDGE_SLACK_MS + jitterMs()
257
+ if (pendingNudge) pendingNudge.cancel()
258
+ pendingNudge = schedule(() => {
259
+ pendingNudge = null
260
+ nudgeResume('throttle-retry-resume', armedAtMs)
261
+ }, delayMs)
262
+ }
263
+
264
+ return {
265
+ fire,
266
+ inspect: () => ({ noticeState, nudgePending: pendingNudge != null }),
267
+ }
268
+ }
@@ -108,6 +108,25 @@ export interface RecordedMessage {
108
108
  * Only emoji reactions are tracked — custom emoji are ignored for v1.
109
109
  */
110
110
  user_reaction: string | null
111
+ /**
112
+ * Set when the inbound user message was FORWARDED: the server-stamped
113
+ * `forward_origin` of the original message (Bot API 7.0+), so
114
+ * get_recent_messages can surface who originally sent the content the
115
+ * agent saw at delivery time. `forwarded_from` is the raw (truncated,
116
+ * unescaped) human-readable name/title; `forwarded_from_type` is
117
+ * user|hidden_user|chat|channel (hidden_user = self-reported display
118
+ * name, no verifiable id); `forwarded_from_id` is the numeric id when
119
+ * the origin shape exposes one; `forwarded_date` is the original
120
+ * message's ISO timestamp; `forwarded_message_id` is the message id
121
+ * inside the origin channel (channel origins only). For a multi-origin
122
+ * coalesced burst only the PRIMARY (first) origin is persisted here —
123
+ * origins 2+ exist only in the delivered channel tag's numbered attrs.
124
+ */
125
+ forwarded_from: string | null
126
+ forwarded_from_type: string | null
127
+ forwarded_from_id: string | null
128
+ forwarded_date: string | null
129
+ forwarded_message_id: number | null
111
130
  }
112
131
 
113
132
  export interface QueryOptions {
@@ -166,10 +185,20 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
166
185
  CREATE INDEX IF NOT EXISTS idx_messages_recent
167
186
  ON messages (chat_id, thread_id, ts DESC)
168
187
  `)
169
- // Migration: add reply_to columns to existing DBs that pre-date issue #119.
170
- // SQLite has no IF NOT EXISTS for ALTER TABLE ADD COLUMN, so we tolerate
171
- // "duplicate column name" errors and re-throw anything else.
172
- for (const column of ["reply_to_message_id INTEGER", "reply_to_text TEXT", "user_reaction TEXT"]) {
188
+ // Migration: add reply_to columns to existing DBs that pre-date issue #119,
189
+ // and the forwarded_* origin columns (forward_origin metadata) to DBs that
190
+ // pre-date them. SQLite has no IF NOT EXISTS for ALTER TABLE ADD COLUMN, so
191
+ // we tolerate "duplicate column name" errors and re-throw anything else.
192
+ for (const column of [
193
+ "reply_to_message_id INTEGER",
194
+ "reply_to_text TEXT",
195
+ "user_reaction TEXT",
196
+ "forwarded_from TEXT",
197
+ "forwarded_from_type TEXT",
198
+ "forwarded_from_id TEXT",
199
+ "forwarded_date TEXT",
200
+ "forwarded_message_id INTEGER",
201
+ ]) {
173
202
  try {
174
203
  db.exec(`ALTER TABLE messages ADD COLUMN ${column}`)
175
204
  } catch (err) {
@@ -349,6 +378,19 @@ interface RecordInboundArgs {
349
378
  */
350
379
  reply_to_message_id?: number | null | undefined
351
380
  reply_to_text?: string | null | undefined
381
+ /**
382
+ * If the message was forwarded, the server-stamped origin metadata
383
+ * (Bot API 7.0 `forward_origin`). Populated from
384
+ * `ctx.message.forward_origin` in the gateway handler. `forwarded_from`
385
+ * is the RAW (truncated, unescaped) name — the XML-escaped form goes to
386
+ * the channel meta only. `forwarded_date` is the origin message's ISO
387
+ * timestamp; `forwarded_message_id` is set for channel origins only.
388
+ */
389
+ forwarded_from?: string | null | undefined
390
+ forwarded_from_type?: string | null | undefined
391
+ forwarded_from_id?: string | null | undefined
392
+ forwarded_date?: string | null | undefined
393
+ forwarded_message_id?: number | null | undefined
352
394
  }
353
395
 
354
396
  /**
@@ -364,8 +406,8 @@ export function recordInbound(args: RecordInboundArgs): void {
364
406
  if (args.message_id == null) return
365
407
  const stmt = requireDb().prepare(`
366
408
  INSERT OR REPLACE INTO messages
367
- (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, reply_to_message_id, reply_to_text)
368
- VALUES (?, ?, ?, 'user', ?, ?, ?, ?, ?, NULL, ?, ?)
409
+ (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, reply_to_message_id, reply_to_text, forwarded_from, forwarded_from_type, forwarded_from_id, forwarded_date, forwarded_message_id)
410
+ VALUES (?, ?, ?, 'user', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)
369
411
  `)
370
412
  // Defense-in-depth: never persist a detected secret to the message store.
371
413
  // The inbound gate (server.ts handleInbound) already deletes + vaults a
@@ -382,6 +424,13 @@ export function recordInbound(args: RecordInboundArgs): void {
382
424
  args.attachment_kind ?? null,
383
425
  args.reply_to_message_id ?? null,
384
426
  args.reply_to_text != null ? redact(args.reply_to_text) : (args.reply_to_text ?? null),
427
+ // Origin names/titles are user-controlled display strings; run them
428
+ // through the same secret-redaction backstop as message text.
429
+ args.forwarded_from != null ? redact(args.forwarded_from) : null,
430
+ args.forwarded_from_type ?? null,
431
+ args.forwarded_from_id ?? null,
432
+ args.forwarded_date ?? null,
433
+ args.forwarded_message_id ?? null,
385
434
  )
386
435
  }
387
436
 
@@ -31,7 +31,20 @@ import { escapeMarkdown } from './card-format.js'
31
31
 
32
32
  // ─── Public types ────────────────────────────────────────────────────────────
33
33
 
34
- export type ModelUnavailableKind = 'overload' | 'quota_exhausted' | 'network'
34
+ export type ModelUnavailableKind =
35
+ | 'overload'
36
+ | 'quota_exhausted'
37
+ | 'network'
38
+ /**
39
+ * 429 throttle tier — a TRANSIENT per-account 429 (explicit
40
+ * `transientUpstreamSignals` negation wording) whose parsed reset lies
41
+ * BEYOND the retry-in-place threshold, so the gateway escalates it to the
42
+ * standard mark-exhausted + fleet-failover machinery. Never produced by
43
+ * `detectModelUnavailable` (a transient-negation string classifies as
44
+ * `overload` there); constructed only by the gateway's throttle-tier
45
+ * branch so the card names the true cause instead of "quota exhausted".
46
+ */
47
+ | 'rate_limited'
35
48
 
36
49
  export interface ModelUnavailableDetection {
37
50
  kind: ModelUnavailableKind
@@ -79,6 +92,203 @@ export function isTransientUpstreamSignal(text: string): boolean {
79
92
  return transientUpstreamSignals.some(s => lower.includes(s))
80
93
  }
81
94
 
95
+ // ─── LiteLLM-proxy-LOCAL 429 signals (canonical, single source of truth) ─────
96
+
97
+ /**
98
+ * Explicit markers of a 429 generated by the LiteLLM proxy's OWN rate
99
+ * limiters — a router `tpm_limit`/`rpm_limit` deployment cap, a virtual-key /
100
+ * team / user cap, or an all-deployments-cooling-down router state. The
101
+ * request never reached Anthropic, so the condition is PROXY-LOCAL: it says
102
+ * nothing about the Anthropic account's quota and must never mark the account
103
+ * throttled/exhausted or trigger fleet failover.
104
+ *
105
+ * Each wording key is grounded in LiteLLM's source (verified against
106
+ * BerriAI/litellm `main`, 2026-07), matching how
107
+ * `accountScopedThrottleSignals` (throttle-tier.ts) documents its keys:
108
+ *
109
+ * - "deployment over user-defined ratelimit" —
110
+ * `RouterErrors.user_defined_ratelimit_error` (litellm/types/router.py),
111
+ * the body prefix of every deployment tpm/rpm cap 429 raised by
112
+ * litellm/router_utils/pre_call_checks/model_rate_limit_check.py:
113
+ * "Deployment over user-defined ratelimit. tpm limit={n}. current
114
+ * usage={n}. id={id}, model_group={g}".
115
+ * - "model rate limit exceeded. tpm/rpm limit" — the `message` field of the
116
+ * same enforcement RateLimitError: "Model rate limit exceeded. TPM
117
+ * limit={n}, current usage={n}" (model_rate_limit_check.py).
118
+ * - "deployment over defined rpm limit" — the usage-based-routing-v2
119
+ * strategy wording "Deployment over defined rpm limit={n}. current
120
+ * usage={n}" (litellm/router_strategy/lowest_tpm_rpm_v2.py). There is NO
121
+ * tpm sibling in any known release — v2 TPM exhaustion surfaces as the
122
+ * `no_deployments_available` wording below (the deployment is filtered
123
+ * from the candidate set rather than erroring in the increment path).
124
+ * - "no deployments available for selected model" —
125
+ * `RouterErrors.no_deployments_available`, the RouterRateLimitError
126
+ * message when every deployment for the model group is cooling down:
127
+ * "No deployments available for selected model, Try again in {n}
128
+ * seconds…" (litellm/types/router.py).
129
+ * - "litellm rate limit handler" — the v1 parallel-request limiter's
130
+ * ProxyRateLimitError detail prefix: "LiteLLM Rate Limit Handler for
131
+ * rate limit type = {t}. Crossed TPM / RPM / Max Parallel Request
132
+ * Limit. current rpm: {n}, rpm limit: {n}…"
133
+ * (litellm/proxy/hooks/parallel_request_limiter.py).
134
+ * - "crossed tpm / rpm" — `CommonProxyErrors
135
+ * .max_parallel_request_limit_reached.value` = "Crossed TPM / RPM / Max
136
+ * Parallel Request Limit" (litellm/proxy/_types.py), interpolated into
137
+ * BOTH v1 detail shapes, so it also covers the zero-limit branch's
138
+ * "Max parallel request limit reached {additional_details}" message.
139
+ * - "max parallel request limit reached" — the standalone prefix
140
+ * `raise_rate_limit_error` builds when a key's limit is set to 0
141
+ * (parallel_request_limiter.py `error_message`).
142
+ *
143
+ * The proxy-side per-key/team/user limiter (parallel_request_limiter_v3.py
144
+ * `_handle_rate_limit_error`) is matched by CO-OCCURRENCE instead of a
145
+ * per-descriptor entry — see `isLitellmProxyLocal429`. Its detail shape:
146
+ * "Rate limit exceeded for {descriptor}: {value}. Limit type: {t}. Current
147
+ * limit: {n}, Remaining: {n}. Limit resets at: {ts}". Descriptor keys in
148
+ * source are numerous and growing (api_key / user / team / team_member /
149
+ * organization / end_user / agent / agent_session / model_per_key /
150
+ * model_per_team / model_per_organization / model_per_project / tag_per_key
151
+ * / mcp_per_key / mcp_per_team as of 2026-07), so enumerating them is a
152
+ * treadmill: the pair "rate limit exceeded for " + "limit type:" appears in
153
+ * every v3 body and in no Anthropic error. This is the shape a `tpm_limit`
154
+ * on the per-agent virtual keys (src/litellm/provision.ts) trips.
155
+ *
156
+ * Deliberately EXCLUDED: the bare exception-mapping prefix
157
+ * "litellm.RateLimitError:". LiteLLM wraps FORWARDED upstream 429s with the
158
+ * same prefix on the pass-through, so its presence is NOT evidence the limit
159
+ * was proxy-local — a genuine Anthropic account throttle traversing the
160
+ * proxy must keep its account-scoped classification (see
161
+ * `classify429Detail` in throttle-tier.ts for the tie-break).
162
+ */
163
+ export const litellmProxyLocal429Signals = [
164
+ 'deployment over user-defined ratelimit',
165
+ 'model rate limit exceeded. tpm limit',
166
+ 'model rate limit exceeded. rpm limit',
167
+ 'deployment over defined rpm limit',
168
+ 'no deployments available for selected model',
169
+ 'litellm rate limit handler',
170
+ 'crossed tpm / rpm',
171
+ 'max parallel request limit reached',
172
+ ]
173
+
174
+ /**
175
+ * The v3 proxy-limiter co-occurrence pair (see the provenance comment on
176
+ * `litellmProxyLocal429Signals`): both substrings appear in every
177
+ * parallel_request_limiter_v3 429 body regardless of descriptor key, and
178
+ * never in an Anthropic error. Exported so tests can pin the rule.
179
+ */
180
+ export const litellmV3LimiterSignalPair = ['rate limit exceeded for ', 'limit type:'] as const
181
+
182
+ /**
183
+ * True when `text` carries an EXPLICIT LiteLLM-proxy-local rate-limit marker:
184
+ * one of `litellmProxyLocal429Signals`, OR the v3 limiter co-occurrence pair
185
+ * (`litellmV3LimiterSignalPair` — descriptor-agnostic, so new v3 descriptor
186
+ * keys are covered without a list update). Never throws on weird input. Pure
187
+ * wording detection only — precedence against account-scoped wording is
188
+ * owned by `classify429Detail` (throttle-tier.ts).
189
+ */
190
+ export function isLitellmProxyLocal429(text: string): boolean {
191
+ if (typeof text !== 'string' || text.length === 0) return false
192
+ const sample = text.length > 16_384 ? text.slice(0, 16_384) : text
193
+ const lower = sample.toLowerCase()
194
+ if (litellmProxyLocal429Signals.some(s => lower.includes(s))) return true
195
+ return litellmV3LimiterSignalPair.every(s => lower.includes(s))
196
+ }
197
+
198
+ /**
199
+ * Best-effort extraction of the limit detail LiteLLM embeds in its
200
+ * proxy-local 429 bodies, for instrumentation (the `rate_limit_429_classified`
201
+ * runtime metric). All fields null when nothing parseable is found — never
202
+ * throws. Shapes covered (same provenance as `litellmProxyLocal429Signals`):
203
+ *
204
+ * - "tpm limit=8000. current usage=8241" (model_rate_limit_check body)
205
+ * - "TPM limit=8000, current usage=8241" (model_rate_limit_check message)
206
+ * - "Deployment over defined rpm limit=60. current usage=61" (v2 strategy)
207
+ * - "Limit type: tokens. Current limit: 8000 … Limit resets at:
208
+ * 2026-07-12 08:05:00 UTC" (parallel_request_limiter_v3)
209
+ * - "Try again in 27.5 seconds" (RouterRateLimitError cooldown)
210
+ *
211
+ * CAVEAT — the v3 "Limit resets at" timestamp is NOT reliably UTC despite
212
+ * the literal: litellm formats it with a naive `datetime.fromtimestamp(...)
213
+ * .strftime("%Y-%m-%d %H:%M:%S UTC")` (parallel_request_limiter_v3.py
214
+ * `_handle_rate_limit_error`), i.e. proxy-LOCAL wall-clock time with a
215
+ * hard-coded "UTC" suffix. We parse it as UTC (nothing better is possible
216
+ * from the body alone), so `resetAtMs` — and the metric fields derived from
217
+ * it — can be skewed by the proxy host's UTC offset when the proxy doesn't
218
+ * run on UTC. Treat v3-derived resets as approximate; don't chase phantom
219
+ * clock drift from these metrics. The rate-limit windows are ≤1 minute
220
+ * anyway, so the absolute timestamp is informational.
221
+ */
222
+ export function parseLitellmLimitDetail(
223
+ text: string,
224
+ parseTimeNow: Date = new Date(),
225
+ ): {
226
+ limitType: string | null
227
+ limit: number | null
228
+ currentUsage: number | null
229
+ resetAtMs: number | null
230
+ } {
231
+ const empty = { limitType: null, limit: null, currentUsage: null, resetAtMs: null }
232
+ if (typeof text !== 'string' || text.length === 0) return empty
233
+ const sample = text.length > 16_384 ? text.slice(0, 16_384) : text
234
+ const lower = sample.toLowerCase()
235
+
236
+ // "tpm limit=8000" / "rpm limit=60" (body), "TPM limit=8000" (message),
237
+ // and the v1 limiter's colon form "rpm limit: 60".
238
+ let limitType: string | null = null
239
+ let limit: number | null = null
240
+ const eqLimit = lower.match(/\b([tr]pm)[ _]limit[=:]\s*(\d+)/)
241
+ if (eqLimit) {
242
+ limitType = eqLimit[1]
243
+ limit = Number(eqLimit[2])
244
+ }
245
+ // v3 limiter: "Limit type: tokens. Current limit: 8000"
246
+ if (limitType == null) {
247
+ const v3Type = lower.match(/limit type:\s*(tokens|requests|max_parallel_requests)/)
248
+ if (v3Type) limitType = v3Type[1]
249
+ }
250
+ if (limit == null) {
251
+ const v3Limit = lower.match(/current limit:\s*(\d+)/)
252
+ if (v3Limit) limit = Number(v3Limit[1])
253
+ }
254
+
255
+ // "current usage=8241" (body/message, both `=` and `= ` never occur with
256
+ // separators other than the literal shown in source).
257
+ let currentUsage: number | null = null
258
+ const usage = lower.match(/current usage=(\d+)/)
259
+ if (usage) currentUsage = Number(usage[1])
260
+
261
+ // Reset hints LiteLLM emits that the Anthropic-shaped parseResetTime does
262
+ // not cover: "Limit resets at: 2026-07-12 08:05:00 UTC" (v3 limiter — note
263
+ // the space separator, not ISO 'T') and "Try again in 27.5 seconds"
264
+ // (RouterRateLimitError).
265
+ let resetAtMs: number | null = null
266
+ const resetsAt = sample.match(
267
+ /limit resets at:\s*(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) UTC/i,
268
+ )
269
+ if (resetsAt) {
270
+ const d = new Date(`${resetsAt[1]}T${resetsAt[2]}Z`)
271
+ if (!Number.isNaN(d.getTime())) resetAtMs = d.getTime()
272
+ }
273
+ if (resetAtMs == null) {
274
+ const tryAgain = lower.match(/try again in\s+(\d+(?:\.\d+)?)\s*seconds/)
275
+ if (tryAgain) {
276
+ const secs = Number(tryAgain[1])
277
+ if (Number.isFinite(secs) && secs > 0 && secs < 7 * 24 * 3600) {
278
+ resetAtMs = parseTimeNow.getTime() + Math.round(secs * 1000)
279
+ }
280
+ }
281
+ }
282
+
283
+ return {
284
+ limitType,
285
+ limit: limit != null && Number.isFinite(limit) ? limit : null,
286
+ currentUsage:
287
+ currentUsage != null && Number.isFinite(currentUsage) ? currentUsage : null,
288
+ resetAtMs,
289
+ }
290
+ }
291
+
82
292
  // ─── Detection ───────────────────────────────────────────────────────────────
83
293
 
84
294
  /**
@@ -125,6 +335,23 @@ export function detectModelUnavailable(
125
335
  : { kind: 'overload', raw: stderr }
126
336
  }
127
337
 
338
+ // ── 0.5. LiteLLM-proxy-LOCAL 429 — never a quota signal ─────────────────
339
+ // A 429 raised by the LiteLLM proxy's own limiters (`tpm_limit`/`rpm_limit`
340
+ // caps, router cooldown) never reached Anthropic — nothing about the
341
+ // account is exhausted, so it must classify to the calm retryable kind
342
+ // BEFORE the quota substrings run (some LiteLLM bodies contain the word
343
+ // "limit", which a negation-blind quota match could seize on). Runs after
344
+ // step 0 deliberately: account-affirming wording, when present, can only
345
+ // have originated upstream (LiteLLM never emits it) and wins — see the
346
+ // tie-break note on `classify429Detail` (throttle-tier.ts). Uses the full
347
+ // matcher (list + v3 co-occurrence pair) so every descriptor is covered.
348
+ if (isLitellmProxyLocal429(sample)) {
349
+ const resetAt = parseResetTime(sample)
350
+ return resetAt !== undefined
351
+ ? { kind: 'overload', resetAt, raw: stderr }
352
+ : { kind: 'overload', raw: stderr }
353
+ }
354
+
128
355
  // ── 1. Quota / billing exhaustion ──────────────────────────────────────
129
356
  const quotaSignals = [
130
357
  'out of extra usage',
@@ -212,7 +439,7 @@ export function detectModelUnavailable(
212
439
  * arg lets tests pin the relative-clock anchor; production callers omit
213
440
  * it to use Date.now().
214
441
  */
215
- function parseResetTime(text: string, parseTimeNow: Date = new Date()): Date | undefined {
442
+ export function parseResetTime(text: string, parseTimeNow: Date = new Date()): Date | undefined {
216
443
  const lower = text.toLowerCase()
217
444
 
218
445
  // "retry after 60 seconds" / "retry-after: 60"
@@ -459,6 +686,11 @@ function formatReason(d: ModelUnavailableDetection, now: Date): string {
459
686
  return `quota exhausted${reset}`
460
687
  case 'overload':
461
688
  return `model overloaded${reset}`
689
+ case 'rate_limited':
690
+ // Throttle-tier escalation (429 with transient wording but a reset too
691
+ // far out to wait in place). Honest cause: the account is rate-limited,
692
+ // not quota-exhausted — the reset names when it frees.
693
+ return `account rate-limited${reset}`
462
694
  case 'network':
463
695
  return 'network unreachable'
464
696
  }