switchroom 0.18.29 → 0.18.30
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/bin/handoff-briefing.sh +8 -2
- package/dist/agent-scheduler/index.js +111 -7
- package/dist/auth-broker/index.js +154 -16
- package/dist/cli/autoaccept-poll.js +8 -3
- package/dist/cli/drive-write-pretool.mjs +8 -3
- package/dist/cli/ms-365-write-pretool.mjs +158 -11
- package/dist/cli/notion-write-pretool.mjs +103 -4
- package/dist/cli/switchroom.js +2074 -1585
- package/dist/host-control/main.js +110 -13
- package/dist/vault/approvals/kernel-server.js +116 -13
- package/dist/vault/broker/server.js +314 -145
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +73 -20
- package/telegram-plugin/dist/bridge/bridge.js +71 -47
- package/telegram-plugin/dist/gateway/gateway.js +560 -96
- package/telegram-plugin/dist/server.js +89 -64
- package/telegram-plugin/gateway/gateway.ts +212 -17
- package/telegram-plugin/gateway/model-command.ts +104 -0
- package/telegram-plugin/gateway/session-model-file.ts +40 -0
- package/telegram-plugin/gateway/unhandled-message.ts +177 -0
- package/telegram-plugin/llm-error-present.ts +24 -0
- package/telegram-plugin/model-unavailable.ts +55 -0
- package/telegram-plugin/operator-events.ts +113 -0
- package/telegram-plugin/pending-user-notice.ts +88 -0
- package/telegram-plugin/shared/local-time.ts +43 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +26 -2
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
- package/telegram-plugin/tests/local-time.test.ts +68 -1
- package/telegram-plugin/tests/model-command.test.ts +133 -0
- package/telegram-plugin/tests/session-model-file.test.ts +23 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +53 -1
- package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
- package/vendor/hindsight-memory/tests/test_content.py +35 -0
|
@@ -38,6 +38,15 @@ import { isValidModelArg } from './model-command.js'
|
|
|
38
38
|
|
|
39
39
|
export const SESSION_MODEL_FILE = '.session-model'
|
|
40
40
|
export const CONFIGURED_DEFAULT_MODEL_FILE = '.configured-default-model'
|
|
41
|
+
/**
|
|
42
|
+
* Bounded-retry attempt counter for the session-model carrier (#3284).
|
|
43
|
+
* start.sh increments this on every boot that reads `.session-model` and
|
|
44
|
+
* applies it, and gives up (reverts + alerts) once it exceeds its bound. The
|
|
45
|
+
* gateway clears BOTH this file and the carrier on a healthy boot (see
|
|
46
|
+
* consumeSessionModelCarrierOnHealthyBoot) — the "healthy" signal a wedged
|
|
47
|
+
* boot cannot fake. Kept in sync with start.sh.hbs.
|
|
48
|
+
*/
|
|
49
|
+
export const SESSION_MODEL_BOOT_ATTEMPTS_FILE = '.session-model-boot-attempts'
|
|
41
50
|
|
|
42
51
|
export interface SessionModelRecord {
|
|
43
52
|
model: string
|
|
@@ -125,6 +134,37 @@ export function clearSessionModelFile(agentDir: string): void {
|
|
|
125
134
|
}
|
|
126
135
|
}
|
|
127
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Consume the session-model carrier on a HEALTHY boot (#3284).
|
|
139
|
+
*
|
|
140
|
+
* Called once this gateway has ACQUIRED the boot lock (boot.lock_acquired) —
|
|
141
|
+
* the deterministic signal that THIS boot is the surviving healthy session,
|
|
142
|
+
* which a boot that wedges before lock-acquire cannot fake. Deletes BOTH the
|
|
143
|
+
* consume-once carrier and the bounded-retry attempt counter, so:
|
|
144
|
+
* - the next ordinary restart (deploy, /restart, crash) finds no carrier and
|
|
145
|
+
* reverts to the configured default (session-scoped semantics preserved);
|
|
146
|
+
* - a transient wedge BEFORE this point leaves the carrier in place, so the
|
|
147
|
+
* retry boot re-applies the intended model instead of silently reverting.
|
|
148
|
+
*
|
|
149
|
+
* This is what moved out of start.sh's old delete-before-apply: start.sh no
|
|
150
|
+
* longer consumes the carrier itself, it only APPLIES it and lets this healthy
|
|
151
|
+
* signal do the consume. Best-effort — a failure here just means the carrier is
|
|
152
|
+
* re-read (and re-applied, harmlessly) on the next boot, still bounded by the
|
|
153
|
+
* counter. Idempotent.
|
|
154
|
+
*/
|
|
155
|
+
export function consumeSessionModelCarrierOnHealthyBoot(agentDir: string): void {
|
|
156
|
+
try {
|
|
157
|
+
rmSync(join(agentDir, SESSION_MODEL_FILE), { force: true })
|
|
158
|
+
} catch {
|
|
159
|
+
/* best-effort */
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
rmSync(join(agentDir, SESSION_MODEL_BOOT_ATTEMPTS_FILE), { force: true })
|
|
163
|
+
} catch {
|
|
164
|
+
/* best-effort */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
128
168
|
/** Restore a rollback snapshot taken with readSessionModelFileRaw. */
|
|
129
169
|
export function restoreSessionModelFileRaw(agentDir: string, raw: string | null): void {
|
|
130
170
|
if (raw == null) {
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal catch-all + diagnostic tap for inbound Telegram messages (#3300).
|
|
3
|
+
*
|
|
4
|
+
* THE CLASS OF BUG THIS CLOSES: the gateway registers only content-specific
|
|
5
|
+
* handlers (`bot.on('message:text')`, `:photo`, … `:paid_media`). grammy
|
|
6
|
+
* ^1.44 routes `bot.on` as filtering middleware — internally
|
|
7
|
+
* `on → filter(pred, handler) → branch(pred, handler, pass)` — and a
|
|
8
|
+
* `message` update matching NONE of the registered predicates falls through
|
|
9
|
+
* every branch and is SILENTLY discarded: no log, no ack, no history row.
|
|
10
|
+
*
|
|
11
|
+
* Live signature (2026-07-16, klanker DM): message_id 19090 was allocated
|
|
12
|
+
* between an outbound reply (19089, 21:51:11Z) and the next inbound (19091,
|
|
13
|
+
* 21:59:31Z) with ZERO gateway trace — no early_ack, no gw-trace inbound, no
|
|
14
|
+
* gate-deny, no error — while polling stayed healthy on both sides. Exactly
|
|
15
|
+
* the zero-observability failure this module makes impossible: every update
|
|
16
|
+
* is now logged on receipt (tap) and every message either produces a turn or
|
|
17
|
+
* an explicit log-only line (catch-all).
|
|
18
|
+
*
|
|
19
|
+
* Extracted into its own module (rather than inline in gateway.ts) so tests
|
|
20
|
+
* can drive the REAL registration path on a real grammy Bot — gateway.ts is
|
|
21
|
+
* a side-effecting module that cannot be imported into a unit test.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Bot, Context } from 'grammy'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Top-level `message` envelope fields — identity/routing metadata, excluded
|
|
28
|
+
* when surfacing the CONTENT keys of a message (the fields that determine
|
|
29
|
+
* which `bot.on('message:*')` handler should match).
|
|
30
|
+
*/
|
|
31
|
+
export const MESSAGE_ENVELOPE_KEYS: ReadonlySet<string> = new Set<string>([
|
|
32
|
+
'message_id', 'message_thread_id', 'date', 'chat', 'from', 'sender_chat',
|
|
33
|
+
'forward_origin', 'reply_to_message', 'external_reply', 'quote',
|
|
34
|
+
'reply_to_story', 'edit_date', 'media_group_id', 'author_signature',
|
|
35
|
+
'is_topic_message', 'is_automatic_forward', 'via_bot', 'sender_boost_count',
|
|
36
|
+
'business_connection_id', 'effect_id', 'has_protected_content',
|
|
37
|
+
'is_from_offline', 'link_preview_options', 'show_caption_above_media',
|
|
38
|
+
'entities', 'caption_entities', 'paid_star_count',
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Known-noise SERVICE message content keys: chat/topic lifecycle events that
|
|
43
|
+
* carry no user intent for the agent. These are LOGGED (never silently
|
|
44
|
+
* dropped — the whole point of #3300) but do NOT become agent turns:
|
|
45
|
+
* forum-topic lifecycle events are a recurring spam vector in supergroups,
|
|
46
|
+
* and each spurious turn costs tokens and attention.
|
|
47
|
+
*
|
|
48
|
+
* Anything NOT in this set that reaches the catch-all is delivered as a turn
|
|
49
|
+
* (fail-toward-delivery: an unknown content type plausibly carries user
|
|
50
|
+
* intent; better a placeholder turn than a lost message).
|
|
51
|
+
*/
|
|
52
|
+
export const SERVICE_NOISE_KEYS: ReadonlySet<string> = new Set<string>([
|
|
53
|
+
'new_chat_members', 'left_chat_member', 'new_chat_title', 'new_chat_photo',
|
|
54
|
+
'delete_chat_photo', 'group_chat_created', 'supergroup_chat_created',
|
|
55
|
+
'channel_chat_created', 'message_auto_delete_timer_changed',
|
|
56
|
+
'migrate_to_chat_id', 'migrate_from_chat_id',
|
|
57
|
+
'forum_topic_created', 'forum_topic_edited', 'forum_topic_closed',
|
|
58
|
+
'forum_topic_reopened', 'general_forum_topic_hidden',
|
|
59
|
+
'general_forum_topic_unhidden',
|
|
60
|
+
'video_chat_scheduled', 'video_chat_started', 'video_chat_ended',
|
|
61
|
+
'video_chat_participants_invited',
|
|
62
|
+
'giveaway_created', 'giveaway', 'giveaway_winners', 'giveaway_completed',
|
|
63
|
+
'boost_added', 'chat_background_set', 'write_access_allowed',
|
|
64
|
+
'proximity_alert_triggered', 'chat_set_theme',
|
|
65
|
+
'connected_website', 'direct_message_price_changed',
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
/** The CONTENT keys of a message — top-level keys minus envelope metadata. */
|
|
69
|
+
export function messageContentKeys(msg: Record<string, unknown>): string[] {
|
|
70
|
+
return Object.keys(msg).filter(k => !MESSAGE_ENVELOPE_KEYS.has(k))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type UnhandledMessagePlan =
|
|
74
|
+
| { action: 'turn'; text: string; contentKeys: string[] }
|
|
75
|
+
| { action: 'log-only'; contentKeys: string[] }
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Decide what the catch-all does with a message no specific handler consumed:
|
|
79
|
+
* known-noise service messages → log-only (no turn); everything else → a turn
|
|
80
|
+
* with best-effort text (`text ?? caption ?? placeholder naming the type`).
|
|
81
|
+
*/
|
|
82
|
+
export function planUnhandledMessage(msg: Record<string, unknown>): UnhandledMessagePlan {
|
|
83
|
+
const contentKeys = messageContentKeys(msg)
|
|
84
|
+
if (contentKeys.length > 0 && contentKeys.every(k => SERVICE_NOISE_KEYS.has(k))) {
|
|
85
|
+
return { action: 'log-only', contentKeys }
|
|
86
|
+
}
|
|
87
|
+
const contentType = contentKeys[0] ?? 'unknown'
|
|
88
|
+
const text =
|
|
89
|
+
(typeof msg.text === 'string' ? msg.text : undefined) ??
|
|
90
|
+
(typeof msg.caption === 'string' ? msg.caption : undefined) ??
|
|
91
|
+
`(unhandled message content: ${contentType})`
|
|
92
|
+
return { action: 'turn', text, contentKeys }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One compact diagnostic line per update; cap prevents log flooding. */
|
|
96
|
+
export const TAP_MAX_LINES_PER_MINUTE = 300
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Install the diagnostic update tap: pass-through middleware (`bot.use` →
|
|
100
|
+
* always calls next()) logging every received update's type + content keys
|
|
101
|
+
* (never payload bodies — privacy). Rate-limited to TAP_MAX_LINES_PER_MINUTE
|
|
102
|
+
* per wall-clock minute; on rollover a single summary line reports how many
|
|
103
|
+
* were suppressed, so even a flood is never invisible.
|
|
104
|
+
*
|
|
105
|
+
* MUST be installed before all `bot.on` handlers so it observes every update.
|
|
106
|
+
*/
|
|
107
|
+
export function installUpdateTap(
|
|
108
|
+
bot: Pick<Bot, 'use'>,
|
|
109
|
+
log: (line: string) => void,
|
|
110
|
+
nowMs: () => number = Date.now,
|
|
111
|
+
): void {
|
|
112
|
+
let windowStart = 0
|
|
113
|
+
let windowCount = 0
|
|
114
|
+
let suppressed = 0
|
|
115
|
+
bot.use(async (ctx, next) => {
|
|
116
|
+
try {
|
|
117
|
+
const now = nowMs()
|
|
118
|
+
if (now - windowStart >= 60_000) {
|
|
119
|
+
if (suppressed > 0) {
|
|
120
|
+
log(`telegram gateway: rx tap suppressed ${suppressed} update lines in the last minute (cap ${TAP_MAX_LINES_PER_MINUTE}/min)\n`)
|
|
121
|
+
}
|
|
122
|
+
windowStart = now
|
|
123
|
+
windowCount = 0
|
|
124
|
+
suppressed = 0
|
|
125
|
+
}
|
|
126
|
+
if (windowCount < TAP_MAX_LINES_PER_MINUTE) {
|
|
127
|
+
windowCount++
|
|
128
|
+
const upd = ctx.update as unknown as Record<string, unknown>
|
|
129
|
+
const updateType = Object.keys(upd).find(k => k !== 'update_id') ?? 'unknown'
|
|
130
|
+
const msg = ctx.message as unknown as Record<string, unknown> | undefined
|
|
131
|
+
const detail = msg ? ` content=[${messageContentKeys(msg).join(',')}]` : ''
|
|
132
|
+
log(`telegram gateway: rx update_id=${ctx.update.update_id} type=${updateType}${detail}\n`)
|
|
133
|
+
} else {
|
|
134
|
+
suppressed++
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// The diagnostic tap must never break the inbound pipeline.
|
|
138
|
+
}
|
|
139
|
+
await next()
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Install the terminal catch-all. MUST be registered LAST among the
|
|
145
|
+
* `message`/`message:*` handlers: grammy leaf handlers never call `next()`,
|
|
146
|
+
* so a matched specific handler stops the chain (specific handler always
|
|
147
|
+
* wins) and this only fires for messages no specific handler consumed —
|
|
148
|
+
* registration order IS the no-double-handling guarantee.
|
|
149
|
+
*
|
|
150
|
+
* `onInbound` is the normal inbound pipeline (gateway.ts passes
|
|
151
|
+
* handleInboundCoalesced), which applies the same access gating and
|
|
152
|
+
* forward-origin parsing as every other content handler.
|
|
153
|
+
*/
|
|
154
|
+
export function installUnhandledMessageCatchAll(
|
|
155
|
+
bot: Pick<Bot, 'on'>,
|
|
156
|
+
onInbound: (ctx: Context, text: string) => Promise<void>,
|
|
157
|
+
log: (line: string) => void,
|
|
158
|
+
): void {
|
|
159
|
+
bot.on('message', async ctx => {
|
|
160
|
+
try {
|
|
161
|
+
const msg = ctx.message as unknown as Record<string, unknown>
|
|
162
|
+
const plan = planUnhandledMessage(msg)
|
|
163
|
+
// Log KEYS + ids only — never the payload bodies (privacy).
|
|
164
|
+
log(
|
|
165
|
+
`telegram gateway: catch-all inbound (no specific handler) ` +
|
|
166
|
+
`update_id=${ctx.update.update_id} chat_id=${ctx.chat?.id ?? '?'} ` +
|
|
167
|
+
`message_id=${ctx.message?.message_id ?? '?'} ` +
|
|
168
|
+
`content_keys=[${plan.contentKeys.join(',')}] action=${plan.action}\n`,
|
|
169
|
+
)
|
|
170
|
+
if (plan.action === 'turn') {
|
|
171
|
+
await onInbound(ctx, plan.text)
|
|
172
|
+
}
|
|
173
|
+
} catch (err) {
|
|
174
|
+
log(`telegram gateway: catch-all handler error: ${(err as Error).message}\n`)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
}
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import {
|
|
31
31
|
detectModelUnavailable,
|
|
32
32
|
isLitellmProxyLocal429,
|
|
33
|
+
isLitellmProxyAuthMisconfig,
|
|
33
34
|
parseResetTime,
|
|
34
35
|
} from './model-unavailable.js'
|
|
35
36
|
import { classify429Detail } from './throttle-tier.js'
|
|
@@ -46,6 +47,7 @@ export type LlmErrorKind =
|
|
|
46
47
|
| 'overload_529'
|
|
47
48
|
| 'quota_wall'
|
|
48
49
|
| 'auth'
|
|
50
|
+
| 'infra_misconfig'
|
|
49
51
|
| 'transient'
|
|
50
52
|
| 'unknown'
|
|
51
53
|
|
|
@@ -150,8 +152,22 @@ export function parseLlmError(
|
|
|
150
152
|
function classifyKindAndSource(text: string): { kind: LlmErrorKind; source: LlmErrorSource } {
|
|
151
153
|
const lower = text.toLowerCase()
|
|
152
154
|
|
|
155
|
+
// 0. LiteLLM-proxy AUTH misconfig — checked BEFORE the auth branch. The
|
|
156
|
+
// proxy's internal fallback re-dispatched WITHOUT the OAuth header onto a
|
|
157
|
+
// keyless deployment, so Anthropic 401'd it ("x-api-key header is
|
|
158
|
+
// required"). This is an OPERATOR-only infra fault, NOT an end-user login
|
|
159
|
+
// wall — never the user-facing 'auth' kind (which renders a re-auth card a
|
|
160
|
+
// non-operator user cannot act on). Source is the local proxy, not
|
|
161
|
+
// Anthropic. See isLitellmProxyAuthMisconfig for provenance.
|
|
162
|
+
if (isLitellmProxyAuthMisconfig(text)) {
|
|
163
|
+
return { kind: 'infra_misconfig', source: 'litellm-local' }
|
|
164
|
+
}
|
|
165
|
+
|
|
153
166
|
// 1. Auth — always terminal, always actionable.
|
|
154
167
|
const claudeKind = classifyClaudeError({ message: text, type: text })
|
|
168
|
+
if (claudeKind === 'proxy-misconfig') {
|
|
169
|
+
return { kind: 'infra_misconfig', source: 'litellm-local' }
|
|
170
|
+
}
|
|
155
171
|
if (claudeKind === 'credentials-expired' || claudeKind === 'credentials-invalid') {
|
|
156
172
|
return { kind: 'auth', source: 'anthropic' }
|
|
157
173
|
}
|
|
@@ -212,6 +228,8 @@ function buildCoreText(kind: LlmErrorKind, source: LlmErrorSource): string {
|
|
|
212
228
|
return 'Usage limit reached on this Claude subscription.'
|
|
213
229
|
case 'auth':
|
|
214
230
|
return 'Claude login needs re-authentication.'
|
|
231
|
+
case 'infra_misconfig':
|
|
232
|
+
return 'Local model-gateway auth misconfig (proxy fallback dropped the OAuth header).'
|
|
215
233
|
case 'transient':
|
|
216
234
|
return source === 'network'
|
|
217
235
|
? "Couldn't reach Anthropic (network) — retrying automatically."
|
|
@@ -280,6 +298,10 @@ function buildRecommendation(parsed: ParsedLlmError, tz: string): string | undef
|
|
|
280
298
|
switch (parsed.kind) {
|
|
281
299
|
case 'auth':
|
|
282
300
|
return '→ Re-authenticate this account to continue.'
|
|
301
|
+
case 'infra_misconfig':
|
|
302
|
+
// Operator-facing — NO re-auth wording (the login is fine). Points at the
|
|
303
|
+
// real remedy: the local LiteLLM proxy fallback config.
|
|
304
|
+
return '→ Fix the LiteLLM proxy fallback config (deployment missing OAuth passthrough).'
|
|
283
305
|
case 'quota_wall': {
|
|
284
306
|
const reset = formatResetClock(parsed.resetAt, tz)
|
|
285
307
|
return reset
|
|
@@ -353,6 +375,8 @@ function kindEmoji(kind: LlmErrorKind): string {
|
|
|
353
375
|
return '⚠️'
|
|
354
376
|
case 'auth':
|
|
355
377
|
return '🔑'
|
|
378
|
+
case 'infra_misconfig':
|
|
379
|
+
return '🛠️'
|
|
356
380
|
case 'transient':
|
|
357
381
|
return '🌐'
|
|
358
382
|
case 'unknown':
|
|
@@ -195,6 +195,61 @@ export function isLitellmProxyLocal429(text: string): boolean {
|
|
|
195
195
|
return litellmV3LimiterSignalPair.every(s => lower.includes(s))
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* True when `text` is a LiteLLM-proxy-LOCAL AUTH misconfiguration — the
|
|
200
|
+
* proxy-fallback keyless-401 class, NOT a genuine end-user credential wall.
|
|
201
|
+
*
|
|
202
|
+
* PROVENANCE (incident, 2026-07-16). LiteLLM's internal model-fallback chain
|
|
203
|
+
* (`gpt-oss-20b -> [gpt-oss-20b-openrouter, claude-sonnet-5]`) re-dispatches a
|
|
204
|
+
* failed primary WITHOUT forwarding the client's OAuth `Authorization` header.
|
|
205
|
+
* The `claude-sonnet-5` deployment is deliberately keyless (passthrough auth —
|
|
206
|
+
* it expects the forwarded OAuth), so Anthropic rejects the header-less
|
|
207
|
+
* fallback with a 401 `authentication_error` whose message is
|
|
208
|
+
* "x-api-key header is required". Switchroom agents authenticate the unmodified
|
|
209
|
+
* `claude` CLI with an OAuth Bearer token and NEVER an `x-api-key`, so that
|
|
210
|
+
* demand can only originate from the proxy dropping the header on a keyless
|
|
211
|
+
* fallback deployment — a host-side infra misconfig for the OPERATOR to fix,
|
|
212
|
+
* never a login problem an end user (or even the operator's OAuth) can act on.
|
|
213
|
+
*
|
|
214
|
+
* Misclassifying it (as `classifyClaudeError` did → `credentials-invalid` →
|
|
215
|
+
* a "🔑 re-authenticate" card) is doubly wrong: wrong AUDIENCE (a non-operator
|
|
216
|
+
* user like Lisa cannot re-auth anything) and wrong DIAGNOSIS (the login is
|
|
217
|
+
* fine; the proxy fallback config is not). Detecting it here lets both
|
|
218
|
+
* classifiers route it to the operator-only infra surface instead.
|
|
219
|
+
*
|
|
220
|
+
* Detection (deterministic wording, mirrors `isLitellmProxyLocal429`):
|
|
221
|
+
* - the definitive "x-api-key header is required" marker (impossible for our
|
|
222
|
+
* OAuth flow — only the keyless-proxy path emits it), OR
|
|
223
|
+
* - an `authentication_error` CO-OCCURRING with the proxy-FALLBACK-specific
|
|
224
|
+
* structural pair: "fallback" re-dispatch provenance AND an explicit
|
|
225
|
+
* "x-api-key" mention.
|
|
226
|
+
*
|
|
227
|
+
* PRECISION over recall (#3293 review finding 2): an earlier draft matched any
|
|
228
|
+
* `authentication_error` + (litellm|proxy) + (fallback|x-api-key|api_key) —
|
|
229
|
+
* broad enough that a GENUINE OAuth expiry wrapped in a proxy envelope that
|
|
230
|
+
* merely mentions "api_key" would misdiagnose as proxy-misconfig (still
|
|
231
|
+
* operator-visible, but the recommendation would point at the proxy config
|
|
232
|
+
* instead of a re-auth). Both classes route operator-only now, so an ambiguous
|
|
233
|
+
* envelope deliberately KEEPS the credentials-invalid/-expired diagnosis;
|
|
234
|
+
* only the unambiguous keyless-fallback signature classifies as misconfig.
|
|
235
|
+
* Never throws on weird input.
|
|
236
|
+
*/
|
|
237
|
+
export function isLitellmProxyAuthMisconfig(text: string): boolean {
|
|
238
|
+
if (typeof text !== 'string' || text.length === 0) return false
|
|
239
|
+
const sample = text.length > 16_384 ? text.slice(0, 16_384) : text
|
|
240
|
+
const lower = sample.toLowerCase()
|
|
241
|
+
// Definitive marker — see provenance above.
|
|
242
|
+
if (lower.includes('x-api-key header is required')) return true
|
|
243
|
+
// Structural signature: an authentication_error carrying BOTH the fallback
|
|
244
|
+
// re-dispatch provenance AND an explicit x-api-key mention. A proxy envelope
|
|
245
|
+
// that merely mentions litellm/proxy/api_key stays on the credentials
|
|
246
|
+
// diagnosis (ambiguous → not misconfig).
|
|
247
|
+
const isAuthErr =
|
|
248
|
+
lower.includes('authentication_error') || lower.includes('authenticationerror')
|
|
249
|
+
if (!isAuthErr) return false
|
|
250
|
+
return lower.includes('fallback') && lower.includes('x-api-key')
|
|
251
|
+
}
|
|
252
|
+
|
|
198
253
|
/**
|
|
199
254
|
* Best-effort extraction of the limit detail LiteLLM embeds in its
|
|
200
255
|
* proxy-local 429 bodies, for instrumentation (the `rate_limit_429_classified`
|
|
@@ -14,12 +14,14 @@
|
|
|
14
14
|
|
|
15
15
|
import { escapeMarkdown } from './format.js'
|
|
16
16
|
import { stripRawErrorBytes } from './raw-error-scrub.js'
|
|
17
|
+
import { isLitellmProxyAuthMisconfig } from './model-unavailable.js'
|
|
17
18
|
|
|
18
19
|
// ─── Taxonomy ────────────────────────────────────────────────────────────────
|
|
19
20
|
|
|
20
21
|
export type OperatorEventKind =
|
|
21
22
|
| 'credentials-expired'
|
|
22
23
|
| 'credentials-invalid'
|
|
24
|
+
| 'proxy-misconfig'
|
|
23
25
|
| 'credit-exhausted'
|
|
24
26
|
| 'quota-exhausted'
|
|
25
27
|
| 'rate-limited'
|
|
@@ -92,6 +94,20 @@ function classifyInner(raw: unknown): OperatorEventKind {
|
|
|
92
94
|
// Anthropic SDK: error_code field (newer SDK shape)
|
|
93
95
|
const sdkCode = extractString(obj, 'error_code') ?? ''
|
|
94
96
|
|
|
97
|
+
// LiteLLM-proxy AUTH misconfig — checked BEFORE the generic
|
|
98
|
+
// authentication_error branch. The proxy's internal fallback chain
|
|
99
|
+
// re-dispatches without the client's OAuth Authorization header onto a
|
|
100
|
+
// keyless deployment, so Anthropic returns a 401 authentication_error
|
|
101
|
+
// ("x-api-key header is required"). That is a HOST-side infra misconfig for
|
|
102
|
+
// the operator to fix — NOT an end-user login wall. Mapping it to
|
|
103
|
+
// credentials-invalid mis-fired a "🔑 re-authenticate" card at non-operator
|
|
104
|
+
// users who cannot re-auth anything (incident 2026-07-16). Route it to the
|
|
105
|
+
// operator-only infra kind instead. Scanned across type/code/message so the
|
|
106
|
+
// marker is caught wherever the proxy stamps it.
|
|
107
|
+
if (isLitellmProxyAuthMisconfig(`${errorType}\n${errorCode}\n${sdkCode}\n${message}`)) {
|
|
108
|
+
return 'proxy-misconfig'
|
|
109
|
+
}
|
|
110
|
+
|
|
95
111
|
// Map known Anthropic error types/codes first.
|
|
96
112
|
// Source: https://docs.anthropic.com/en/api/errors
|
|
97
113
|
if (
|
|
@@ -263,6 +279,27 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
263
279
|
},
|
|
264
280
|
}
|
|
265
281
|
|
|
282
|
+
// Operator-only infra fault. Deliberately NO "Reauth" button and NO
|
|
283
|
+
// login language: the OAuth credential is fine — the local LiteLLM proxy
|
|
284
|
+
// re-dispatched an internal fallback without forwarding the OAuth header
|
|
285
|
+
// onto a keyless deployment, so Anthropic 401'd it. The fix is in the
|
|
286
|
+
// proxy fallback config (host-side), not a re-auth. Dismiss-only.
|
|
287
|
+
case 'proxy-misconfig':
|
|
288
|
+
return {
|
|
289
|
+
text: [
|
|
290
|
+
`🛠️ **Model-gateway auth misconfig** for **${agent}**.`,
|
|
291
|
+
detail ? `_${detail}_` : '',
|
|
292
|
+
`The local LiteLLM proxy re-dispatched a fallback without forwarding the OAuth header (keyless deployment → Anthropic 401). Fix the proxy fallback config — this is NOT a login problem.`,
|
|
293
|
+
]
|
|
294
|
+
.filter(Boolean)
|
|
295
|
+
.join('\n'),
|
|
296
|
+
keyboard: {
|
|
297
|
+
inline_keyboard: [
|
|
298
|
+
[{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
|
|
299
|
+
],
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
|
|
266
303
|
case 'credit-exhausted':
|
|
267
304
|
return {
|
|
268
305
|
text: [
|
|
@@ -487,4 +524,80 @@ export function resetAllCooldowns(): void {
|
|
|
487
524
|
cooldownMap.clear()
|
|
488
525
|
}
|
|
489
526
|
|
|
527
|
+
// ─── Audience routing (Ken's deterministic error-surfacing policy) ────────────
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Kinds that are OPERATOR-ACTIONABLE and NOT user-actionable: a credential /
|
|
531
|
+
* infra fault whose remedy (re-auth, switch account slot, fix the proxy
|
|
532
|
+
* fallback config) only the operator can perform. Per Ken's standing policy
|
|
533
|
+
* (2026-07): raw or misleading auth/infra errors must not reach non-operator
|
|
534
|
+
* end users — they cannot act on them and the diagnosis is often wrong for
|
|
535
|
+
* them (e.g. a proxy misconfig rendered as "your login expired"). These route
|
|
536
|
+
* to the operator surface ONLY; a non-operator user gets at most a brief
|
|
537
|
+
* plain-language "couldn't complete, it's on our side" notice.
|
|
538
|
+
*
|
|
539
|
+
* NOTE: `quota-exhausted` / `rate-limited` are deliberately EXCLUDED — they
|
|
540
|
+
* carry their own auto-fallback UX + card-collapse machinery and are handled
|
|
541
|
+
* upstream; this set is scoped to the credential/infra fault classes.
|
|
542
|
+
*/
|
|
543
|
+
export const OPERATOR_ACTIONABLE_KINDS: ReadonlySet<OperatorEventKind> = new Set<OperatorEventKind>([
|
|
544
|
+
'credentials-expired',
|
|
545
|
+
'credentials-invalid',
|
|
546
|
+
'credit-exhausted',
|
|
547
|
+
'proxy-misconfig',
|
|
548
|
+
])
|
|
549
|
+
|
|
550
|
+
export function isOperatorActionableKind(kind: OperatorEventKind): boolean {
|
|
551
|
+
return OPERATOR_ACTIONABLE_KINDS.has(kind)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export interface OperatorEventAudience {
|
|
555
|
+
/** Chats that receive the full operator card (with any action buttons). */
|
|
556
|
+
operatorChats: string[]
|
|
557
|
+
/** Non-operator chats that receive only the plain-language failure notice. */
|
|
558
|
+
userNoticeChats: string[]
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Split an operator-event's allowlist audience per Ken's routing policy.
|
|
563
|
+
*
|
|
564
|
+
* - Non-operator-actionable kinds (transient rate-limit, 5xx, crash, config
|
|
565
|
+
* warning, …) keep their existing broadcast: every allowlist chat is an
|
|
566
|
+
* `operatorChat`. Behavior unchanged.
|
|
567
|
+
* - Operator-actionable kinds (see {@link OPERATOR_ACTIONABLE_KINDS}) go to the
|
|
568
|
+
* OPERATOR chat only. `operatorChatId` is the operator (in switchroom the
|
|
569
|
+
* allowlist HEAD — `allowFrom[0]`); it stays an `operatorChat` even in a DM
|
|
570
|
+
* agent where the operator is their own user (so the operator NEVER has an
|
|
571
|
+
* error hidden from their own DM). Every other allowlist chat becomes a
|
|
572
|
+
* `userNoticeChat`.
|
|
573
|
+
*
|
|
574
|
+
* Pure — no IPC, no bot. `allowFrom` order is preserved.
|
|
575
|
+
*/
|
|
576
|
+
export function decideOperatorEventAudience(
|
|
577
|
+
kind: OperatorEventKind,
|
|
578
|
+
allowFrom: readonly string[],
|
|
579
|
+
operatorChatId: string | undefined,
|
|
580
|
+
): OperatorEventAudience {
|
|
581
|
+
if (!isOperatorActionableKind(kind)) {
|
|
582
|
+
return { operatorChats: [...allowFrom], userNoticeChats: [] }
|
|
583
|
+
}
|
|
584
|
+
const operator =
|
|
585
|
+
operatorChatId != null && allowFrom.includes(operatorChatId)
|
|
586
|
+
? operatorChatId
|
|
587
|
+
: allowFrom[0]
|
|
588
|
+
const operatorChats = operator != null ? [operator] : []
|
|
589
|
+
const userNoticeChats = allowFrom.filter((c) => c !== operator)
|
|
590
|
+
return { operatorChats, userNoticeChats }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The ONE brief, plain-language failure notice a non-operator user gets when a
|
|
595
|
+
* turn genuinely can't be served because of an operator-actionable fault.
|
|
596
|
+
* Deliberately carries NO diagnosis, NO agent internals, NO re-auth / config
|
|
597
|
+
* instructions, and NO raw error text — just an honest "it's on our side".
|
|
598
|
+
*/
|
|
599
|
+
export function renderUserFacingFailureNotice(): string {
|
|
600
|
+
return "⚠️ Sorry — I couldn't complete that just now. It's a problem on our side, not anything you did. Please try again shortly."
|
|
601
|
+
}
|
|
602
|
+
|
|
490
603
|
// ─── Markdown escape (#2669) ──────────────────────────────────────────────────
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pending-user-notice.ts — deterministic turn-outcome gate for the
|
|
3
|
+
* non-operator user failure notice (#3293 review finding 1).
|
|
4
|
+
*
|
|
5
|
+
* THE PROBLEM this closes: `emitGatewayOperatorEvent` fires whenever
|
|
6
|
+
* session-tail sees an api_error line, with no knowledge of whether the turn
|
|
7
|
+
* ultimately RECOVERS (e.g. the LiteLLM fallback 401s but a retry / another
|
|
8
|
+
* deployment serves the turn). Sending the "couldn't complete that — it's on
|
|
9
|
+
* our side" notice at error time would tell users a turn failed that actually
|
|
10
|
+
* completed. The 5-min per-kind operator-event cooldown debounces retry SPAM
|
|
11
|
+
* but cannot know the turn's outcome.
|
|
12
|
+
*
|
|
13
|
+
* THE MECHANISM: the notice is never sent at error time. It is SCHEDULED here,
|
|
14
|
+
* and resolved at the gateway's single turn-end funnel (`endCurrentTurnAtomic`):
|
|
15
|
+
* - turn ended WITH a delivered reply → the turn recovered → DROP the notice
|
|
16
|
+
* - turn ended WITHOUT a delivered reply → the turn genuinely died → SEND it
|
|
17
|
+
* Operator cards are NOT gated — they stay immediate (the operator must see
|
|
18
|
+
* the infra fault even when the turn recovers).
|
|
19
|
+
*
|
|
20
|
+
* CONSERVATIVE TTL: if no turn end resolves a scheduled notice within
|
|
21
|
+
* {@link PENDING_USER_NOTICE_TTL_MS} (error arrived between turns, or the turn
|
|
22
|
+
* record was lost), the notice is silently discarded — a missed notice costs a
|
|
23
|
+
* user some confusion; a FALSE "couldn't complete" on a served turn costs
|
|
24
|
+
* trust. Bias to silence.
|
|
25
|
+
*
|
|
26
|
+
* Pure module: no IPC, no bot, no FS. Injectable `now` throughout.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface PendingUserNotice {
|
|
30
|
+
/** Non-operator allowlist chats that should receive the notice. */
|
|
31
|
+
chatIds: string[]
|
|
32
|
+
/** The plain-language notice text (renderUserFacingFailureNotice()). */
|
|
33
|
+
text: string
|
|
34
|
+
agent: string
|
|
35
|
+
/** The operator-event kind that produced it (log/debug context only). */
|
|
36
|
+
kind: string
|
|
37
|
+
/** When the notice was scheduled (ms epoch). */
|
|
38
|
+
atMs: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** How long a scheduled notice may wait for a resolving turn end. */
|
|
42
|
+
export const PENDING_USER_NOTICE_TTL_MS = 10 * 60_000
|
|
43
|
+
|
|
44
|
+
export class PendingUserNoticeGate {
|
|
45
|
+
private pending: PendingUserNotice[] = []
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Schedule a notice for turn-end resolution. Collapses per agent — a burst
|
|
49
|
+
* of error lines within one turn holds ONE pending notice, not a stack.
|
|
50
|
+
*/
|
|
51
|
+
schedule(notice: PendingUserNotice): void {
|
|
52
|
+
this.prune(notice.atMs)
|
|
53
|
+
this.pending = this.pending.filter((p) => p.agent !== notice.agent)
|
|
54
|
+
this.pending.push(notice)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve at turn end. `turnDeliveredReply` is the turn's outcome signal
|
|
59
|
+
* (the gateway passes `finalAnswerDelivered || replyCalled`):
|
|
60
|
+
* - true → the turn recovered; every pending notice is dropped, [] returned.
|
|
61
|
+
* - false → the turn died without a reply; the un-expired pending notices
|
|
62
|
+
* are returned EXACTLY ONCE for the caller to send.
|
|
63
|
+
* Either way the ledger is cleared (a notice never survives its turn end).
|
|
64
|
+
*/
|
|
65
|
+
resolveTurnEnd(turnDeliveredReply: boolean, now: number = Date.now()): PendingUserNotice[] {
|
|
66
|
+
this.prune(now)
|
|
67
|
+
const out = turnDeliveredReply ? [] : [...this.pending]
|
|
68
|
+
this.pending = []
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when at least one un-expired notice is pending (does not mutate). */
|
|
73
|
+
hasPending(now: number = Date.now()): boolean {
|
|
74
|
+
return this.pending.some((p) => now - p.atMs < PENDING_USER_NOTICE_TTL_MS)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private prune(now: number): void {
|
|
78
|
+
this.pending = this.pending.filter((p) => now - p.atMs < PENDING_USER_NOTICE_TTL_MS)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Test-only: forget everything. */
|
|
82
|
+
reset(): void {
|
|
83
|
+
this.pending = []
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The process-wide gate the gateway consults. */
|
|
88
|
+
export const pendingUserNoticeGate = new PendingUserNoticeGate()
|
|
@@ -123,3 +123,46 @@ export function fmtLocalStamp(ms: number, tz: string): string {
|
|
|
123
123
|
return new Date(ms).toISOString()
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Leading ISO-8601-Z timestamp at the start of a log line, e.g.
|
|
129
|
+
* `2026-07-16T04:09:00.123456789Z` or `2026-07-16T04:09:00Z`. Docker log
|
|
130
|
+
* lines (as surfaced by `switchroom agent logs`) carry one of these per line.
|
|
131
|
+
* Anchored at line start; the trailing capture is the rest of the line.
|
|
132
|
+
*/
|
|
133
|
+
const LEADING_ISO_Z = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)(\s|$)/
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* DISPLAY-ONLY: rewrite a leading UTC ISO-8601-Z timestamp on each line of
|
|
137
|
+
* `text` into the caller's LOCAL am/pm wall clock via {@link fmtLocalStamp},
|
|
138
|
+
* so `/logs` output reads `Thursday 2026-07-16 02:09 PM AEST …` instead of a
|
|
139
|
+
* raw `…T04:09:00Z`. Applied at send time; never mutates stored logs.
|
|
140
|
+
*
|
|
141
|
+
* The leading stamp comes from `docker logs --timestamps` (requested by the
|
|
142
|
+
* /logs path via `switchroom agent logs --timestamps`) — docker's stamp is
|
|
143
|
+
* ALWAYS UTC ISO-Z, which is what makes this conversion deterministic. App-
|
|
144
|
+
* emitted stamps inside the line body (e.g. Python's `%H:%M:%S,mmm`) are
|
|
145
|
+
* deliberately NOT converted: post-#3275 containers run with local TZ baked,
|
|
146
|
+
* so those are already local wall clock — re-shifting them as UTC would be
|
|
147
|
+
* wrong. The `tz` passed by /logs is the GATEWAY/operator zone
|
|
148
|
+
* (`resolveEnvTimezone` of the gateway process), not the target agent's
|
|
149
|
+
* zone — intended, since /logs is an operator-facing surface.
|
|
150
|
+
*
|
|
151
|
+
* Pure / total — matches ONLY a well-formed leading ISO-Z stamp and preserves
|
|
152
|
+
* every other line (and any line whose timestamp doesn't parse) verbatim, so a
|
|
153
|
+
* non-timestamped or partial log line is passed through untouched. `\r`
|
|
154
|
+
* line endings are preserved.
|
|
155
|
+
*/
|
|
156
|
+
export function renderLogTimestampsLocal(text: string, tz: string): string {
|
|
157
|
+
if (!text) return text
|
|
158
|
+
return text
|
|
159
|
+
.split('\n')
|
|
160
|
+
.map((line) => {
|
|
161
|
+
const m = LEADING_ISO_Z.exec(line)
|
|
162
|
+
if (!m) return line
|
|
163
|
+
const ms = Date.parse(m[1])
|
|
164
|
+
if (Number.isNaN(ms)) return line
|
|
165
|
+
return `${fmtLocalStamp(ms, tz)}${m[2] === '' ? '' : ' '}${line.slice(m[0].length)}`
|
|
166
|
+
})
|
|
167
|
+
.join('\n')
|
|
168
|
+
}
|