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.
- package/dist/agent-scheduler/index.js +49 -9
- package/dist/auth-broker/index.js +152 -46
- package/dist/cli/autoaccept-poll.js +23 -0
- package/dist/cli/drive-write-pretool.mjs +24 -1
- package/dist/cli/foreground-hog-pretool.mjs +264 -0
- package/dist/cli/notion-write-pretool.mjs +0 -1
- package/dist/cli/switchroom.js +1185 -1072
- package/dist/host-control/main.js +53 -52
- package/dist/vault/approvals/kernel-server.js +16 -13
- package/dist/vault/broker/server.js +672 -669
- package/package.json +1 -1
- package/profiles/coding/CLAUDE.md.hbs +2 -0
- package/profiles/default/CLAUDE.md.hbs +2 -0
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/telegram-plugin/auth-snapshot-format.ts +37 -5
- package/telegram-plugin/auto-fallback-fleet.ts +29 -1
- package/telegram-plugin/bridge/bridge.ts +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +23 -0
- package/telegram-plugin/dist/gateway/gateway.js +765 -67
- package/telegram-plugin/dist/server.js +24 -1
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +14 -0
- package/telegram-plugin/gateway/forward-origin.ts +235 -0
- package/telegram-plugin/gateway/gateway.ts +270 -10
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
- package/telegram-plugin/history.ts +55 -6
- package/telegram-plugin/model-unavailable.ts +234 -2
- package/telegram-plugin/render/rich-render.ts +40 -32
- package/telegram-plugin/runtime-metrics.ts +31 -0
- package/telegram-plugin/session-tail.ts +14 -2
- package/telegram-plugin/stream-controller.ts +3 -2
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
- package/telegram-plugin/tests/forward-origin.test.ts +309 -0
- package/telegram-plugin/tests/history.test.ts +157 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
- package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
- package/telegram-plugin/tests/runtime-metrics.test.ts +24 -0
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
- package/telegram-plugin/tests/status-accent.test.ts +5 -3
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
- package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
- package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +454 -0
- package/telegram-plugin/throttle-tier.ts +323 -0
- 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
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
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 =
|
|
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
|
}
|