@gotcos/glasses-server 6.11.0 → 6.12.1
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/.env.example +4 -2
- package/CHANGELOG.md +55 -0
- package/README.md +14 -4
- package/bin/cli.cjs +11 -5
- package/package.json +1 -1
- package/server/index.ts +2 -0
- package/server/lib/claude-bridge.ts +37 -10
- package/server/lib/cli-debug-view.ts +213 -0
- package/server/lib/codex-bridge.ts +41 -6
- package/server/lib/provider-terminal-error.ts +55 -0
- package/server/lib/transcribe-audio.ts +35 -10
- package/server/lib/transcription-policy.ts +29 -0
- package/server/lib/whisper-local.ts +7 -5
- package/server/routes/cli-debug.ts +66 -0
- package/server/routes/health.ts +30 -4
- package/server/routes/transcribe-stream.ts +41 -13
- package/server/routes/transcribe.ts +9 -0
package/.env.example
CHANGED
|
@@ -58,8 +58,10 @@ BIND_HOST=0.0.0.0
|
|
|
58
58
|
|
|
59
59
|
# ── VOICE (optional) ────────────────────────────────────────────────────
|
|
60
60
|
# Local transcription is FREE via whisper.cpp (brew install whisper-cpp; the
|
|
61
|
-
# model auto-downloads on first run).
|
|
62
|
-
#
|
|
61
|
+
# model auto-downloads on first run). Voice is local-only by default. Merely
|
|
62
|
+
# configuring a key never uploads audio. To allow OpenAI Whisper only after a
|
|
63
|
+
# local failure, set BOTH the exact opt-in and a key:
|
|
64
|
+
# COS_OPENAI_WHISPER_FALLBACK=1
|
|
63
65
|
# OPENAI_API_KEY=sk-...
|
|
64
66
|
|
|
65
67
|
# ── FULL COS PIPELINE (optional — leave unset for standalone) ────────────
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 6.12.1
|
|
4
|
+
|
|
5
|
+
Public-safe CLI diagnostics for the COS Glasses Recovery Center.
|
|
6
|
+
|
|
7
|
+
- **Both local agents are visible.** Authenticated clients can inspect a
|
|
8
|
+
versioned Claude Code and Codex status summary at `/api/cli/debug`, including
|
|
9
|
+
provider support, persistence readiness, workspace configuration, and the
|
|
10
|
+
latest run's safe status metadata.
|
|
11
|
+
- **False success is fenced.** Claude Code or Codex output that reports a
|
|
12
|
+
machine-shaped authentication failure while the CLI exits `0` is finalized
|
|
13
|
+
as a typed `auth_error`, never projected as a completed assistant reply.
|
|
14
|
+
- **Build 210 stays compatible.** Sanitized `/api/cli/runs` and
|
|
15
|
+
`/api/codex/runs` projections preserve the fields older Recovery Centers can
|
|
16
|
+
render while newer clients adopt the combined contract.
|
|
17
|
+
- **Diagnostics do not become an exfiltration path.** Responses use explicit
|
|
18
|
+
allowlists and omit commands, filesystem paths, trust modes, prompts,
|
|
19
|
+
answers, tool payloads, content previews, raw run/session/thread IDs,
|
|
20
|
+
resumable handles, environment values, and tokens. Legacy display IDs are
|
|
21
|
+
omitted and workspace state is a fixed label only.
|
|
22
|
+
- **Authentication is mandatory.** All three diagnostic routes remain behind
|
|
23
|
+
the existing `/api` token boundary. Preview-enabled ledger fixtures are
|
|
24
|
+
covered by recursive forbidden-field and private-value tests.
|
|
25
|
+
- **Unauthenticated health is capability-only.** `/api/health` and
|
|
26
|
+
`/api/models` advertise `capabilities.cliDebug`; health exposes only a CLI
|
|
27
|
+
session-availability boolean rather than the resumable session ID.
|
|
28
|
+
- **Backward compatible.** Query, prompt, meeting, media, display, model, and
|
|
29
|
+
recovery behavior is unchanged. Older clients and servers continue to use
|
|
30
|
+
their existing paths; a missing CLI Debug capability remains an unsupported
|
|
31
|
+
feature rather than a connection failure.
|
|
32
|
+
|
|
33
|
+
## 6.12.0
|
|
34
|
+
|
|
35
|
+
Local-first transcription policy and capability-safe recovery diagnostics for
|
|
36
|
+
COS Glasses build 210+.
|
|
37
|
+
|
|
38
|
+
- **Local means local.** Prompt, one-shot, and meeting transcription now remain
|
|
39
|
+
on local Whisper by default. Finding an OpenAI key is not permission to upload
|
|
40
|
+
audio. Cloud Whisper is reachable only when the exact
|
|
41
|
+
`COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a resolved key are both present.
|
|
42
|
+
- **Every cloud chokepoint is fenced.** Both one-shot/prompt finalization and
|
|
43
|
+
continuous meeting transcription recheck the policy immediately before any
|
|
44
|
+
OpenAI request, preventing a future call-site regression from bypassing the
|
|
45
|
+
top-level selection logic.
|
|
46
|
+
- **Failure stays recoverable.** A local ASR outage returns a typed retryable
|
|
47
|
+
`503` instead of silently switching providers. Durable prompt chunks and raw
|
|
48
|
+
meeting audio remain available for retry; meeting receipt and batch-audio
|
|
49
|
+
retention behavior is unchanged.
|
|
50
|
+
- **Clients can tell policy from health.** `/api/health` and `/api/models`
|
|
51
|
+
publish additive `capabilities.transcription` fields. Public installs also
|
|
52
|
+
advertise every privileged recovery control as unsupported, allowing newer
|
|
53
|
+
phone Recovery Centers to hide controls instead of reporting false outages.
|
|
54
|
+
- **Backward compatible.** Existing routes and response fields remain in place.
|
|
55
|
+
Older apps keep their current query, prompt, meeting, image, and display
|
|
56
|
+
paths; cloud fallback remains available to operators who explicitly enable it.
|
|
57
|
+
|
|
3
58
|
## 6.11.0
|
|
4
59
|
|
|
5
60
|
Local-first meeting recovery for COS Glasses build 209+.
|
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ without silently losing completed replies.
|
|
|
27
27
|
- **Claude Code CLI** (Opus/Fable/Sonnet) — https://claude.ai/download, then `claude login`
|
|
28
28
|
_or_ **Codex CLI** (GPT Frontier/Balanced) — https://developers.openai.com/codex/, then `codex login`
|
|
29
29
|
- **Even G2 glasses** + the **COS Glasses** app from the Even Hub
|
|
30
|
-
-
|
|
30
|
+
- `brew install whisper-cpp` for free local voice (the launcher can download the model)
|
|
31
31
|
- _Optional:_ `brew install ffmpeg` for phone/output image attachments (text chat remains available without it)
|
|
32
32
|
- _Optional:_ **Tailscale** so your phone reaches your Mac from anywhere
|
|
33
33
|
|
|
@@ -76,7 +76,10 @@ The built-in IP allowlist blocks public-internet traffic regardless.
|
|
|
76
76
|
locally through a network interruption. Reconnecting reconciles the exact
|
|
77
77
|
chunks already stored by the Mac, uploads only missing audio, and finalizes
|
|
78
78
|
through an idempotent save receipt without duplicating the meeting.
|
|
79
|
-
- Local whisper.cpp transcription (free
|
|
79
|
+
- Local whisper.cpp transcription (free and local-only by default). OpenAI
|
|
80
|
+
Whisper fallback is optional and requires both the exact
|
|
81
|
+
`COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a configured key; a key alone never
|
|
82
|
+
uploads audio.
|
|
80
83
|
- Tasks / calendar / people context **if** you run the
|
|
81
84
|
[COS Starter Kit](https://www.gotcos.com) (`COS_SCRIPTS_DIR`); otherwise it is
|
|
82
85
|
glasses + AI only
|
|
@@ -85,7 +88,8 @@ The built-in IP allowlist blocks public-internet traffic regardless.
|
|
|
85
88
|
|
|
86
89
|
Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
|
|
87
90
|
optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
|
|
88
|
-
`COS_API_TOKEN` (auto if unset), `
|
|
91
|
+
`COS_API_TOKEN` (auto if unset), `COS_OPENAI_WHISPER_FALLBACK=1` plus
|
|
92
|
+
`OPENAI_API_KEY` (explicit cloud voice fallback),
|
|
89
93
|
`COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=1` (build 204+
|
|
90
94
|
server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
|
|
91
95
|
location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
|
|
@@ -105,7 +109,13 @@ BIND_HOST=0.0.0.0 npm run start:server
|
|
|
105
109
|
- *Phone can't connect* — check `BIND_HOST=0.0.0.0`, the same Tailscale account on both devices, and the correct `100.x` IP + token.
|
|
106
110
|
- *Safari connects but the app does not* — confirm `npx @gotcos/glasses-server@latest` is 6.6.0+, then use the app's server reconnect/edit control to verify the current URL and token. Do not run a second source or `npx` server alongside it.
|
|
107
111
|
- *AI queries fail* — run `claude --version` / `codex --version`, then `claude login` / `codex login`.
|
|
108
|
-
- *Voice getting billed?* —
|
|
112
|
+
- *Voice getting billed?* — voice is local-only by default in 6.12.0+. Confirm
|
|
113
|
+
`/api/health` reports `capabilities.transcription.mode: "local-only"`. Remove
|
|
114
|
+
`COS_OPENAI_WHISPER_FALLBACK` (or set it to `0`) to disable an earlier opt-in.
|
|
115
|
+
- *Local voice unavailable?* — install `whisper-cpp`, restart the server, and
|
|
116
|
+
confirm `/api/health` reports `features.whisper: true`. A typed retryable 503
|
|
117
|
+
keeps compatible prompt/meeting audio available for retry instead of silently
|
|
118
|
+
sending it to OpenAI.
|
|
109
119
|
- *Photos unavailable?* — install `ffmpeg`, restart the server, and confirm `/api/health` reports `features.mediaProcessingReady: true`.
|
|
110
120
|
- *Prompt recovery unavailable?* — update with `npx @gotcos/glasses-server@latest`, then confirm `/api/health` reports `features.promptRecovery: true`.
|
|
111
121
|
- *Durable query recovery unavailable?* — build 204+ requires server 6.10.0+ and
|
package/bin/cli.cjs
CHANGED
|
@@ -133,7 +133,8 @@ if (!existsSync(PROFILE_FILE) && existsSync(PROFILE_EXAMPLE)) {
|
|
|
133
133
|
}
|
|
134
134
|
if (!process.env.COS_PROFILE_PATH) process.env.COS_PROFILE_PATH = PROFILE_FILE
|
|
135
135
|
|
|
136
|
-
// Step 5: local Whisper detection + model download
|
|
136
|
+
// Step 5: local Whisper detection + model download. Voice stays local-only by
|
|
137
|
+
// default; cloud fallback requires an explicit flag plus a configured key.
|
|
137
138
|
const WHISPER_KNOWN_PATHS = ['/opt/homebrew/bin/whisper-cli', '/usr/local/bin/whisper-cli']
|
|
138
139
|
const WHISPER_MODEL_DIR = join(homedir(), '.local/share/whisper-models')
|
|
139
140
|
const WHISPER_MODEL_PATH = join(WHISPER_MODEL_DIR, 'ggml-large-v3-turbo.bin')
|
|
@@ -161,10 +162,10 @@ if (whisperCliPath && hasValidModel) {
|
|
|
161
162
|
if (existsSync(WHISPER_MODEL_PATH)) { try { unlinkSync(WHISPER_MODEL_PATH) } catch {} }
|
|
162
163
|
if (existsSync(WHISPER_MODEL_PARTIAL)) { try { unlinkSync(WHISPER_MODEL_PARTIAL) } catch {} }
|
|
163
164
|
console.log(yellow(' ⚠') + ' whisper.cpp installed but model missing')
|
|
164
|
-
console.log(' ' + dim('Downloading ggml-large-v3-turbo (~1.5 GB). Ctrl-C to skip (
|
|
165
|
+
console.log(' ' + dim('Downloading ggml-large-v3-turbo (~1.5 GB). Ctrl-C to skip (voice remains unavailable by default).'))
|
|
165
166
|
console.log(' ' + dim('Skip permanently: SKIP_WHISPER_DOWNLOAD=1 npx @gotcos/glasses-server'))
|
|
166
167
|
if (process.env.SKIP_WHISPER_DOWNLOAD === '1') {
|
|
167
|
-
console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — voice
|
|
168
|
+
console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — local voice unavailable')
|
|
168
169
|
} else {
|
|
169
170
|
try {
|
|
170
171
|
mkdirSync(WHISPER_MODEL_DIR, { recursive: true })
|
|
@@ -175,14 +176,19 @@ if (whisperCliPath && hasValidModel) {
|
|
|
175
176
|
console.log(green(' ✓') + ' Model downloaded ' + dim('— voice = local (FREE)'))
|
|
176
177
|
} catch (err) {
|
|
177
178
|
try { unlinkSync(WHISPER_MODEL_PARTIAL) } catch {}
|
|
178
|
-
console.log(red(' ✗') + ' Model download failed ' + dim('— voice
|
|
179
|
+
console.log(red(' ✗') + ' Model download failed ' + dim('— local voice unavailable'))
|
|
179
180
|
console.log(' ' + dim('Error: ' + (err.message || err).toString().slice(0, 120)))
|
|
180
181
|
}
|
|
181
182
|
}
|
|
182
183
|
} else {
|
|
183
|
-
console.log(yellow(' ⚠') + ' whisper.cpp not installed ' + dim('— voice
|
|
184
|
+
console.log(yellow(' ⚠') + ' whisper.cpp not installed ' + dim('— local voice unavailable'))
|
|
184
185
|
console.log(' Free local voice: ' + bold('brew install whisper-cpp') + dim(' (no Homebrew? https://brew.sh)'))
|
|
185
186
|
}
|
|
187
|
+
if (process.env.COS_OPENAI_WHISPER_FALLBACK === '1') {
|
|
188
|
+
console.log(yellow(' ⚠') + ' Explicit OpenAI Whisper fallback requested ' + dim('— activates only if a key resolves; see /api/health'))
|
|
189
|
+
} else {
|
|
190
|
+
console.log(green(' ✓') + ' Transcription policy: local-only ' + dim('— a key alone never uploads audio'))
|
|
191
|
+
}
|
|
186
192
|
|
|
187
193
|
// Step 6: image capability — ffmpeg validates, strips metadata, normalizes,
|
|
188
194
|
// and builds the exact 288x144 G2 variant. It is optional so text/voice remain
|
package/package.json
CHANGED
package/server/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { archiveRouter } from './routes/archive.js'
|
|
|
27
27
|
import { sessionsRouter } from './routes/sessions.js'
|
|
28
28
|
import { mediaRouter, mediaBodyParser } from './routes/media.js'
|
|
29
29
|
import { promptDraftsRouter } from './routes/prompt-drafts.js'
|
|
30
|
+
import { cliDebugRouter } from './routes/cli-debug.js'
|
|
30
31
|
import { prewarmContext } from './lib/context-builder.js'
|
|
31
32
|
import { preWarmCLI } from './lib/claude-bridge.js'
|
|
32
33
|
import { getCodexRunConfig } from './lib/codex-run-ledger.js'
|
|
@@ -163,6 +164,7 @@ app.use('/api', archiveRouter)
|
|
|
163
164
|
app.use('/api', sessionsRouter)
|
|
164
165
|
app.use('/api', mediaRouter)
|
|
165
166
|
app.use('/api', promptDraftsRouter)
|
|
167
|
+
app.use('/api', cliDebugRouter)
|
|
166
168
|
|
|
167
169
|
// OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
|
|
168
170
|
// Mounted at root — routes are /v1/chat/completions and /v1/models
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
MAX_ATTACHMENTS_PER_PROMPT,
|
|
48
48
|
type MediaAttachmentRef,
|
|
49
49
|
} from '../../shared/media-attachment.js'
|
|
50
|
+
import { terminalProviderAuthFailure } from './provider-terminal-error.js'
|
|
50
51
|
|
|
51
52
|
// Inactivity = no stdout data for this long → kill (catches stalls)
|
|
52
53
|
const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
|
|
@@ -325,12 +326,15 @@ export interface StreamCallbacks {
|
|
|
325
326
|
/** Claude CLI can emit `subtype: success` with `is_error: true`; the boolean
|
|
326
327
|
* is authoritative and must win before session ids or result text are saved. */
|
|
327
328
|
export function claudeResultErrorMessage(event: any): string | null {
|
|
328
|
-
if (event?.type !== 'result'
|
|
329
|
+
if (event?.type !== 'result') return null
|
|
329
330
|
const raw = typeof event?.result === 'string' ? event.result
|
|
330
331
|
: typeof event?.error === 'string' ? event.error
|
|
331
332
|
: typeof event?.error?.message === 'string' ? event.error.message
|
|
332
333
|
: typeof event?.message === 'string' ? event.message
|
|
333
334
|
: ''
|
|
335
|
+
const authFailure = terminalProviderAuthFailure('claude', raw, event?.error)
|
|
336
|
+
if (authFailure) return authFailure
|
|
337
|
+
if (event?.is_error !== true && event?.subtype !== 'error') return null
|
|
334
338
|
const detail = raw.replace(/\s+/g, ' ').trim().slice(0, 240)
|
|
335
339
|
return detail ? `claude-bridge: ${detail}` : 'claude-bridge: Claude CLI returned an error result.'
|
|
336
340
|
}
|
|
@@ -545,6 +549,7 @@ export async function callClaudeStreaming(
|
|
|
545
549
|
let stderr = ''
|
|
546
550
|
let buffer = ''
|
|
547
551
|
let finalized = false // Guard against double onDone/onError
|
|
552
|
+
let terminalTextError: string | null = null
|
|
548
553
|
let lastActivity = Date.now() // Tracks last stdout data for inactivity timeout
|
|
549
554
|
let receivedStreamEvents = false // Track if CLI emits stream_event (vs older assistant-only format)
|
|
550
555
|
const toolInputs = new Map<number, { name: string; json: string }>()
|
|
@@ -570,6 +575,13 @@ export async function callClaudeStreaming(
|
|
|
570
575
|
|
|
571
576
|
async function finalize(text: string) {
|
|
572
577
|
if (finalized) return
|
|
578
|
+
const responseAuthFailure = terminalProviderAuthFailure('claude', text)
|
|
579
|
+
const authFailure = responseAuthFailure
|
|
580
|
+
?? (!text.trim() ? terminalTextError ?? terminalProviderAuthFailure('claude', stderr) : null)
|
|
581
|
+
if (authFailure) {
|
|
582
|
+
await finalizeError(authFailure, 0)
|
|
583
|
+
return
|
|
584
|
+
}
|
|
573
585
|
finalized = true
|
|
574
586
|
cleanup()
|
|
575
587
|
cleanupImages()
|
|
@@ -831,9 +843,16 @@ export async function callClaudeStreaming(
|
|
|
831
843
|
text = event.content
|
|
832
844
|
}
|
|
833
845
|
if (text) {
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
846
|
+
// Withhold only strongly machine-shaped provider auth output so
|
|
847
|
+
// credentials cannot flash through onChunk. Human sign-in
|
|
848
|
+
// instructions do not match the terminal classifier.
|
|
849
|
+
const authFailure = terminalProviderAuthFailure('claude', text)
|
|
850
|
+
if (authFailure) terminalTextError = authFailure
|
|
851
|
+
else {
|
|
852
|
+
phase = 'generating'
|
|
853
|
+
fullText += text
|
|
854
|
+
callbacks.onChunk(text)
|
|
855
|
+
}
|
|
837
856
|
}
|
|
838
857
|
}
|
|
839
858
|
} else if (event.type === 'user') {
|
|
@@ -855,7 +874,9 @@ export async function callClaudeStreaming(
|
|
|
855
874
|
}
|
|
856
875
|
// tool_use/tool_result/other events still reset inactivity (we got stdout data)
|
|
857
876
|
} catch {
|
|
858
|
-
//
|
|
877
|
+
// Older CLI builds can emit a terminal auth error as plain text while
|
|
878
|
+
// still exiting 0. Remember only the canonical classification.
|
|
879
|
+
terminalTextError ??= terminalProviderAuthFailure('claude', trimmed)
|
|
859
880
|
}
|
|
860
881
|
}
|
|
861
882
|
})
|
|
@@ -883,13 +904,19 @@ export async function callClaudeStreaming(
|
|
|
883
904
|
} catch { /* ignore */ }
|
|
884
905
|
}
|
|
885
906
|
|
|
886
|
-
if (
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
// If we got text but no explicit result event, still finalize
|
|
907
|
+
if (fullText) {
|
|
908
|
+
// If we got text but no explicit result event, still finalize. A sole,
|
|
909
|
+
// machine-shaped auth response is classified inside finalize.
|
|
890
910
|
void finalize(fullText)
|
|
891
911
|
} else {
|
|
892
|
-
|
|
912
|
+
const authFailure = terminalTextError ?? terminalProviderAuthFailure('claude', stderr, buffer)
|
|
913
|
+
if (authFailure) {
|
|
914
|
+
finalizeError(authFailure, code)
|
|
915
|
+
} else if (code !== 0) {
|
|
916
|
+
finalizeError(`claude-bridge: exit ${code} — ${stderr.trim().slice(0, 200)}`, code)
|
|
917
|
+
} else {
|
|
918
|
+
finalizeError('claude-bridge: Claude completed without a response.', code)
|
|
919
|
+
}
|
|
893
920
|
}
|
|
894
921
|
})
|
|
895
922
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { ClaudeRunConfig, ClaudeRunRecord } from './claude-run-ledger.js'
|
|
2
|
+
import type { CodexRunConfig, CodexRunRecord } from './codex-run-ledger.js'
|
|
3
|
+
|
|
4
|
+
export const CLI_DEBUG_CAPABILITY = Object.freeze({
|
|
5
|
+
schemaVersion: 1,
|
|
6
|
+
providers: Object.freeze({ claude: true, codex: true }),
|
|
7
|
+
metadataOnly: true,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
export type SafeCliRunStatus =
|
|
11
|
+
| 'running'
|
|
12
|
+
| 'completed'
|
|
13
|
+
| 'failed'
|
|
14
|
+
| 'cancelled'
|
|
15
|
+
| 'client_disconnected'
|
|
16
|
+
|
|
17
|
+
export interface SafeCliDebugLatestRun {
|
|
18
|
+
status: SafeCliRunStatus
|
|
19
|
+
model: string
|
|
20
|
+
concreteModel?: string
|
|
21
|
+
effort?: string
|
|
22
|
+
resumed: boolean
|
|
23
|
+
durationMs?: number
|
|
24
|
+
updatedAt: string
|
|
25
|
+
errorCode?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SafeCliDebugProvider {
|
|
29
|
+
supported: true
|
|
30
|
+
persistenceEnabled: boolean
|
|
31
|
+
workspaceConfigured: boolean
|
|
32
|
+
latestRun: SafeCliDebugLatestRun | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SafeCliDebugResponse {
|
|
36
|
+
schemaVersion: 1
|
|
37
|
+
providers: {
|
|
38
|
+
claude: SafeCliDebugProvider
|
|
39
|
+
codex: SafeCliDebugProvider
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function optionalString(value: unknown): string | undefined {
|
|
44
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function optionalFiniteNumber(value: unknown): number | undefined {
|
|
48
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function safeStatus(value: unknown): SafeCliRunStatus {
|
|
52
|
+
return value === 'running' || value === 'completed' || value === 'failed'
|
|
53
|
+
|| value === 'cancelled' || value === 'client_disconnected'
|
|
54
|
+
? value
|
|
55
|
+
: 'failed'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function safeModelLabel(value: unknown, provider: 'claude' | 'codex'): string | undefined {
|
|
59
|
+
const model = optionalString(value)
|
|
60
|
+
if (!model || model.length > 96) return undefined
|
|
61
|
+
const allowed = provider === 'claude'
|
|
62
|
+
? /^(?:claude-|opus(?:\[1m\])?$|sonnet(?:\[1m\])?$|fable(?:\[1m\])?$|haiku(?:\[1m\])?$)[a-z0-9._\[\]-]*$/i
|
|
63
|
+
: /^(?:gpt-|codex-)[a-z0-9._\[\]-]*$/i
|
|
64
|
+
return allowed.test(model) ? model : undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function safeEffort(value: unknown): string | undefined {
|
|
68
|
+
const effort = optionalString(value)?.toLowerCase()
|
|
69
|
+
return effort && ['low', 'medium', 'high', 'xhigh', 'max', 'ultra', 'ultracode'].includes(effort)
|
|
70
|
+
? effort
|
|
71
|
+
: undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function safeTimestamp(value: unknown): string {
|
|
75
|
+
const timestamp = optionalString(value)
|
|
76
|
+
const parsed = timestamp ? Date.parse(timestamp) : Number.NaN
|
|
77
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date(0).toISOString()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function safeErrorCode(provider: 'claude' | 'codex', value: unknown): string | undefined {
|
|
81
|
+
const candidate = optionalString(value)
|
|
82
|
+
if (!candidate) return undefined
|
|
83
|
+
const allowed = new Set([
|
|
84
|
+
`${provider}.cli_unavailable`,
|
|
85
|
+
`${provider}.permission_denied`,
|
|
86
|
+
`${provider}.auth_error`,
|
|
87
|
+
`${provider}.timeout`,
|
|
88
|
+
`${provider}.nonzero_exit`,
|
|
89
|
+
`${provider}.error`,
|
|
90
|
+
`${provider}.interrupted`,
|
|
91
|
+
])
|
|
92
|
+
return allowed.has(candidate) ? candidate : `${provider}.error`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function safeClaudeLatestRun(run?: ClaudeRunRecord): SafeCliDebugLatestRun | null {
|
|
96
|
+
if (!run) return null
|
|
97
|
+
const concreteModel = safeModelLabel(run.resolvedModelId ?? run.cliModelId, 'claude')
|
|
98
|
+
const effort = safeEffort(run.effortLevel)
|
|
99
|
+
const errorCode = safeErrorCode('claude', run.errorCode)
|
|
100
|
+
return {
|
|
101
|
+
status: safeStatus(run.status),
|
|
102
|
+
model: safeModelLabel(run.model, 'claude') ?? 'unknown',
|
|
103
|
+
...(concreteModel ? { concreteModel } : {}),
|
|
104
|
+
...(effort ? { effort } : {}),
|
|
105
|
+
resumed: run.resumed === true,
|
|
106
|
+
...(optionalFiniteNumber(run.durationMs) !== undefined ? { durationMs: run.durationMs } : {}),
|
|
107
|
+
updatedAt: safeTimestamp(run.updatedAt),
|
|
108
|
+
...(errorCode ? { errorCode } : {}),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function safeCodexLatestRun(run?: CodexRunRecord): SafeCliDebugLatestRun | null {
|
|
113
|
+
if (!run) return null
|
|
114
|
+
const concreteModel = safeModelLabel(run.cliModel, 'codex')
|
|
115
|
+
const effort = safeEffort(run.reasoningEffort)
|
|
116
|
+
const errorCode = safeErrorCode('codex', run.errorCode)
|
|
117
|
+
return {
|
|
118
|
+
status: safeStatus(run.status),
|
|
119
|
+
model: safeModelLabel(run.model, 'codex') ?? 'unknown',
|
|
120
|
+
...(concreteModel ? { concreteModel } : {}),
|
|
121
|
+
...(effort ? { effort } : {}),
|
|
122
|
+
resumed: run.resumed === true,
|
|
123
|
+
...(optionalFiniteNumber(run.durationMs) !== undefined ? { durationMs: run.durationMs } : {}),
|
|
124
|
+
updatedAt: safeTimestamp(run.updatedAt),
|
|
125
|
+
...(errorCode ? { errorCode } : {}),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function safeCliDebugResponse(
|
|
130
|
+
claudeConfig: ClaudeRunConfig,
|
|
131
|
+
claudeRun: ClaudeRunRecord | undefined,
|
|
132
|
+
codexConfig: CodexRunConfig,
|
|
133
|
+
codexRun: CodexRunRecord | undefined,
|
|
134
|
+
): SafeCliDebugResponse {
|
|
135
|
+
return {
|
|
136
|
+
schemaVersion: 1,
|
|
137
|
+
providers: {
|
|
138
|
+
claude: {
|
|
139
|
+
supported: true,
|
|
140
|
+
persistenceEnabled: claudeConfig.persistenceEnabled === true,
|
|
141
|
+
workspaceConfigured: Boolean(claudeConfig.cwd),
|
|
142
|
+
latestRun: safeClaudeLatestRun(claudeRun),
|
|
143
|
+
},
|
|
144
|
+
codex: {
|
|
145
|
+
supported: true,
|
|
146
|
+
persistenceEnabled: codexConfig.persistenceEnabled === true,
|
|
147
|
+
workspaceConfigured: Boolean(codexConfig.cwd),
|
|
148
|
+
latestRun: safeCodexLatestRun(codexRun),
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Compatibility projection for build 210 and earlier Settings panels.
|
|
155
|
+
* Every returned key is an explicit public-safe allowlist entry. */
|
|
156
|
+
export function safeLegacyClaudeResponse(config: ClaudeRunConfig, runs: ClaudeRunRecord[]) {
|
|
157
|
+
return {
|
|
158
|
+
schemaVersion: 1,
|
|
159
|
+
config: {
|
|
160
|
+
persistenceEnabled: config.persistenceEnabled === true,
|
|
161
|
+
workspaceConfigured: Boolean(config.cwd),
|
|
162
|
+
cwd: config.cwd ? 'Configured workspace' : 'Not configured',
|
|
163
|
+
defaultEffortLevel: safeEffort(config.defaultEffortLevel) ?? 'high',
|
|
164
|
+
contentPreviewsEnabled: false,
|
|
165
|
+
},
|
|
166
|
+
runs: runs.map(run => {
|
|
167
|
+
const latest = safeClaudeLatestRun(run)!
|
|
168
|
+
return {
|
|
169
|
+
status: latest.status,
|
|
170
|
+
model: latest.model,
|
|
171
|
+
...(latest.concreteModel ? {
|
|
172
|
+
cliModelId: latest.concreteModel,
|
|
173
|
+
resolvedModelId: latest.concreteModel,
|
|
174
|
+
} : {}),
|
|
175
|
+
...(latest.effort ? { effortLevel: latest.effort } : {}),
|
|
176
|
+
...(typeof latest.resumed === 'boolean' ? { resumed: latest.resumed } : {}),
|
|
177
|
+
...(latest.durationMs !== undefined ? { durationMs: latest.durationMs } : {}),
|
|
178
|
+
...(latest.updatedAt ? { updatedAt: latest.updatedAt } : {}),
|
|
179
|
+
...(latest.errorCode ? { errorCode: latest.errorCode } : {}),
|
|
180
|
+
}
|
|
181
|
+
}),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Compatibility projection for build 210 and earlier Settings panels.
|
|
186
|
+
* Engine sessions are deliberately absent: their thread ids are resumable
|
|
187
|
+
* runtime handles, not display metadata. */
|
|
188
|
+
export function safeLegacyCodexResponse(config: CodexRunConfig, runs: CodexRunRecord[]) {
|
|
189
|
+
return {
|
|
190
|
+
schemaVersion: 1,
|
|
191
|
+
config: {
|
|
192
|
+
persistenceEnabled: config.persistenceEnabled === true,
|
|
193
|
+
workspaceConfigured: Boolean(config.cwd),
|
|
194
|
+
cwd: config.cwd ? 'Configured workspace' : 'Not configured',
|
|
195
|
+
cliModel: safeModelLabel(config.cliModel, 'codex') ?? 'unknown',
|
|
196
|
+
reasoningEffort: safeEffort(config.reasoningEffort) ?? 'high',
|
|
197
|
+
contentPreviewsEnabled: false,
|
|
198
|
+
},
|
|
199
|
+
runs: runs.map(run => {
|
|
200
|
+
const latest = safeCodexLatestRun(run)!
|
|
201
|
+
return {
|
|
202
|
+
status: latest.status,
|
|
203
|
+
model: latest.model,
|
|
204
|
+
...(latest.concreteModel ? { cliModel: latest.concreteModel } : {}),
|
|
205
|
+
...(latest.effort ? { reasoningEffort: latest.effort } : {}),
|
|
206
|
+
...(typeof latest.resumed === 'boolean' ? { resumed: latest.resumed } : {}),
|
|
207
|
+
...(latest.durationMs !== undefined ? { durationMs: latest.durationMs } : {}),
|
|
208
|
+
...(latest.updatedAt ? { updatedAt: latest.updatedAt } : {}),
|
|
209
|
+
...(latest.errorCode ? { errorCode: latest.errorCode } : {}),
|
|
210
|
+
}
|
|
211
|
+
}),
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
MAX_ATTACHMENTS_PER_PROMPT,
|
|
61
61
|
type MediaAttachmentRef,
|
|
62
62
|
} from '../../shared/media-attachment.js'
|
|
63
|
+
import { terminalProviderAuthFailure } from './provider-terminal-error.js'
|
|
63
64
|
|
|
64
65
|
const INACTIVITY_MS = 180_000
|
|
65
66
|
const WALL_MAX_MS = 900_000
|
|
@@ -366,6 +367,7 @@ export async function callCodexStreaming(
|
|
|
366
367
|
let stderr = ''
|
|
367
368
|
let buffer = ''
|
|
368
369
|
let finalized = false
|
|
370
|
+
let terminalTextError: string | null = null
|
|
369
371
|
let lastActivity = Date.now()
|
|
370
372
|
const emittedBlocks = new Set<string>()
|
|
371
373
|
|
|
@@ -425,6 +427,13 @@ export async function callCodexStreaming(
|
|
|
425
427
|
|
|
426
428
|
async function finalize(text: string) {
|
|
427
429
|
if (finalized) return
|
|
430
|
+
const responseAuthFailure = terminalProviderAuthFailure('codex', text)
|
|
431
|
+
const authFailure = responseAuthFailure
|
|
432
|
+
?? (!text.trim() ? terminalTextError ?? terminalProviderAuthFailure('codex', stderr) : null)
|
|
433
|
+
if (authFailure) {
|
|
434
|
+
await finalizeError(authFailure, 0)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
428
437
|
finalized = true
|
|
429
438
|
cleanup()
|
|
430
439
|
cleanupImages()
|
|
@@ -614,13 +623,32 @@ export async function callCodexStreaming(
|
|
|
614
623
|
}
|
|
615
624
|
|
|
616
625
|
const text = extractCodexResponseText(event)
|
|
617
|
-
if (text)
|
|
626
|
+
if (text) {
|
|
627
|
+
// Withhold only strongly machine-shaped provider auth output so
|
|
628
|
+
// credentials cannot flash through onChunk. Human sign-in instructions
|
|
629
|
+
// do not match the terminal classifier.
|
|
630
|
+
const authFailure = terminalProviderAuthFailure('codex', text)
|
|
631
|
+
if (authFailure) terminalTextError = authFailure
|
|
632
|
+
else emitText(text)
|
|
633
|
+
}
|
|
618
634
|
|
|
619
635
|
const type = String(event?.type ?? '')
|
|
620
636
|
if (type === 'turn.completed') {
|
|
621
637
|
void finalize(fullText)
|
|
622
638
|
} else if (type === 'turn.failed' || type === 'error') {
|
|
623
|
-
|
|
639
|
+
const rawProviderError = typeof event?.error === 'string' ? event.error
|
|
640
|
+
: typeof event?.error?.message === 'string' ? event.error.message
|
|
641
|
+
: typeof event?.message === 'string' ? event.message
|
|
642
|
+
: 'unknown error'
|
|
643
|
+
const structuredProviderError = event?.error && typeof event.error === 'object'
|
|
644
|
+
? JSON.stringify(event.error)
|
|
645
|
+
: ''
|
|
646
|
+
const authenticationError = terminalProviderAuthFailure(
|
|
647
|
+
'codex',
|
|
648
|
+
rawProviderError,
|
|
649
|
+
structuredProviderError,
|
|
650
|
+
)
|
|
651
|
+
finalizeError(authenticationError ?? `codex-bridge: ${rawProviderError}`)
|
|
624
652
|
}
|
|
625
653
|
}
|
|
626
654
|
|
|
@@ -636,7 +664,9 @@ export async function callCodexStreaming(
|
|
|
636
664
|
try {
|
|
637
665
|
handleEvent(JSON.parse(trimmed))
|
|
638
666
|
} catch {
|
|
639
|
-
//
|
|
667
|
+
// Older CLI builds can emit a terminal auth error as plain text while
|
|
668
|
+
// still exiting 0. Remember only the canonical classification.
|
|
669
|
+
terminalTextError ??= terminalProviderAuthFailure('codex', trimmed)
|
|
640
670
|
}
|
|
641
671
|
}
|
|
642
672
|
})
|
|
@@ -651,10 +681,15 @@ export async function callCodexStreaming(
|
|
|
651
681
|
try { handleEvent(JSON.parse(buffer.trim())) } catch { /* ignore */ }
|
|
652
682
|
}
|
|
653
683
|
if (finalized) return
|
|
654
|
-
if (
|
|
655
|
-
finalizeError(`codex-bridge: exit ${code} — ${stderr.trim().slice(0, 240)}`, code)
|
|
656
|
-
} else if (fullText) {
|
|
684
|
+
if (fullText) {
|
|
657
685
|
void finalize(fullText)
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
const authFailure = terminalTextError ?? terminalProviderAuthFailure('codex', stderr, buffer)
|
|
689
|
+
if (authFailure) {
|
|
690
|
+
finalizeError(authFailure, code)
|
|
691
|
+
} else if (code !== 0) {
|
|
692
|
+
finalizeError(`codex-bridge: exit ${code} — ${stderr.trim().slice(0, 240)}`, code)
|
|
658
693
|
} else {
|
|
659
694
|
finalizeError('codex-bridge: Codex completed without a response.')
|
|
660
695
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type CliProvider = 'claude' | 'codex'
|
|
2
|
+
|
|
3
|
+
const AUTH_CODE = /^(?:401|403|unauthori[sz]ed|forbidden|authentication_error|authorization_error|invalid_api_key|not_authenticated)$/i
|
|
4
|
+
// Match whole, terminal-looking provider failures only. Natural assistant
|
|
5
|
+
// answers such as "Please sign in to the customer portal, then..." must not
|
|
6
|
+
// be reinterpreted as failures merely because their first words mention auth.
|
|
7
|
+
const AUTH_FAILURE_PREFIX = /^(?:\s*(?:api\s+|http\s+|request\s+)?error(?:\[[^\]]+\])?\s*:\s*.{0,180}\b(?:401|403|unauthori[sz]ed|forbidden|authentication\s+(?:failed|required)|login\s+required|not\s+(?:logged|signed)\s+in)\b.*|\s*(?:http\s+)?(?:401|403)(?:\s+(?:unauthori[sz]ed|forbidden))?(?:\s*[:.\-]\s*.*)?|\s*(?:unauthori[sz]ed|forbidden)(?:\s*[:.\-]\s*.*)?|\s*(?:authentication|authorization)\s+(?:failed|required|error|missing|denied)(?:\s*[:.\-]\s*.*)?|\s*(?:login|sign[ -]?in)\s+(?:required|failed)(?:\s*[:.\-]\s*.*)?|\s*(?:you(?:'re| are)\s+)?not\s+(?:logged|signed)\s+in(?:\s*[:.\-]\s*.*)?|\s*please\s+run\s+(?:[`'"]?(?:claude|codex)[`'"]?\s+)?(?:[`'"]?\/?login[`'"]?|[`'"]?auth(?:enticate)?[`'"]?)\b.*)\s*$/i
|
|
8
|
+
|
|
9
|
+
function stripAnsi(value: string): string {
|
|
10
|
+
// eslint-disable-next-line no-control-regex
|
|
11
|
+
return value.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function structuredAuthFailure(value: unknown): boolean {
|
|
15
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
16
|
+
const record = value as Record<string, unknown>
|
|
17
|
+
if (record.status === 401 || record.status === 403 || record.statusCode === 401 || record.statusCode === 403) return true
|
|
18
|
+
for (const key of ['code', 'type']) {
|
|
19
|
+
if (typeof record[key] === 'string' && AUTH_CODE.test(record[key])) return true
|
|
20
|
+
}
|
|
21
|
+
if (typeof record.error === 'string') {
|
|
22
|
+
return AUTH_CODE.test(record.error.trim()) || AUTH_FAILURE_PREFIX.test(record.error)
|
|
23
|
+
}
|
|
24
|
+
return structuredAuthFailure(record.error)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function looksLikeTerminalAuthFailure(value: unknown): boolean {
|
|
28
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
29
|
+
return structuredAuthFailure(value)
|
|
30
|
+
}
|
|
31
|
+
if (typeof value !== 'string') return false
|
|
32
|
+
const text = stripAnsi(value).trim()
|
|
33
|
+
if (!text || text.length > 2_000) return false
|
|
34
|
+
if (AUTH_FAILURE_PREFIX.test(text.replace(/\s+/g, ' '))) return true
|
|
35
|
+
if (!(text.startsWith('{') && text.endsWith('}'))) return false
|
|
36
|
+
try {
|
|
37
|
+
return structuredAuthFailure(JSON.parse(text))
|
|
38
|
+
} catch {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Some CLI versions report authentication failures as successful process
|
|
45
|
+
* output and exit 0. Detect only terminal, machine-shaped auth messages and
|
|
46
|
+
* return a canonical error that cannot echo credentials or provider output.
|
|
47
|
+
*/
|
|
48
|
+
export function terminalProviderAuthFailure(
|
|
49
|
+
provider: CliProvider,
|
|
50
|
+
...terminalValues: unknown[]
|
|
51
|
+
): string | null {
|
|
52
|
+
return terminalValues.some(looksLikeTerminalAuthFailure)
|
|
53
|
+
? `${provider}-bridge: authentication required.`
|
|
54
|
+
: null
|
|
55
|
+
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
countVocabTerms,
|
|
22
22
|
} from './hallucination-filter.js'
|
|
23
23
|
import { getOpenAIKey, tryGetOpenAIKey } from './openai-key.js'
|
|
24
|
+
import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from './transcription-policy.js'
|
|
24
25
|
|
|
25
26
|
export { OpenAIWhisperBudgetExhaustedError, estimateAudioSeconds }
|
|
26
27
|
|
|
@@ -61,17 +62,30 @@ export class NoSpeechDetectedError extends Error {
|
|
|
61
62
|
// ceiling (anything longer is a dictation, not a query — use meetings instead).
|
|
62
63
|
const HQ_MAX_SECONDS = 60
|
|
63
64
|
|
|
65
|
+
function unavailableAfterLocalFailure(): TranscriptionUnavailableError | null {
|
|
66
|
+
const fallback = getTranscriptionPolicySnapshot()
|
|
67
|
+
if (fallback.openaiFallbackReady) return null
|
|
68
|
+
return fallback.openaiFallbackConfigured
|
|
69
|
+
? new TranscriptionUnavailableError('openai_key_missing', 'Local transcription is unavailable and the explicitly configured OpenAI fallback has no key')
|
|
70
|
+
: new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
|
|
71
|
+
}
|
|
72
|
+
|
|
64
73
|
/** Transcribe via OpenAI Whisper API (cloud fallback).
|
|
65
74
|
* Budget-gated: throws OpenAIWhisperBudgetExhaustedError if today's $5 cap is spent.
|
|
66
75
|
* Ledger only ticks on SUCCESSFUL responses so retries that never reach the API
|
|
67
76
|
* aren't double-counted. */
|
|
68
77
|
async function transcribeCloud(audioBuffer: Buffer): Promise<string> {
|
|
69
|
-
|
|
78
|
+
// Defense in depth: every cloud chokepoint rechecks the explicit two-factor
|
|
79
|
+
// opt-in. A key alone is never authority to upload user audio.
|
|
80
|
+
if (!isOpenAIWhisperFallbackReady()) {
|
|
81
|
+
throw unavailableAfterLocalFailure()!
|
|
82
|
+
}
|
|
70
83
|
|
|
71
84
|
if (!tryGetOpenAIKey()) {
|
|
72
|
-
throw new TranscriptionUnavailableError('openai_key_missing', 'OpenAI key missing;
|
|
85
|
+
throw new TranscriptionUnavailableError('openai_key_missing', 'OpenAI key missing; retry after local Whisper recovers')
|
|
73
86
|
}
|
|
74
87
|
|
|
88
|
+
assertOpenAIWhisperBudget()
|
|
75
89
|
const key = getOpenAIKey()
|
|
76
90
|
const audioSeconds = estimateAudioSeconds(audioBuffer)
|
|
77
91
|
|
|
@@ -157,10 +171,14 @@ export async function transcribeAudioBuffer(
|
|
|
157
171
|
backend = `fast-local-${result.backend}`
|
|
158
172
|
actualQuality = 'fast'
|
|
159
173
|
} catch (localErr: any) {
|
|
160
|
-
|
|
161
|
-
|
|
174
|
+
const unavailable = policy === 'local-only'
|
|
175
|
+
? new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
|
|
176
|
+
: unavailableAfterLocalFailure()
|
|
177
|
+
if (unavailable) {
|
|
178
|
+
console.warn(`[transcribe] Fast local unavailable; preserving audio for retry: ${localErr.message}`)
|
|
179
|
+
throw unavailable
|
|
162
180
|
}
|
|
163
|
-
console.warn(`[transcribe] Fast local also failed
|
|
181
|
+
console.warn(`[transcribe] Fast local also failed; using explicitly enabled OpenAI fallback: ${localErr.message}`)
|
|
164
182
|
text = await transcribeCloud(audioBuffer)
|
|
165
183
|
backend = 'cloud'
|
|
166
184
|
actualQuality = 'cloud'
|
|
@@ -173,17 +191,24 @@ export async function transcribeAudioBuffer(
|
|
|
173
191
|
backend = `fast-local-${result.backend}`
|
|
174
192
|
actualQuality = 'fast'
|
|
175
193
|
} catch (localErr: any) {
|
|
176
|
-
|
|
177
|
-
|
|
194
|
+
const unavailable = policy === 'local-only'
|
|
195
|
+
? new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
|
|
196
|
+
: unavailableAfterLocalFailure()
|
|
197
|
+
if (unavailable) {
|
|
198
|
+
console.warn(`[transcribe] Local unavailable; preserving audio for retry: ${localErr.message}`)
|
|
199
|
+
throw unavailable
|
|
178
200
|
}
|
|
179
|
-
console.warn(`[transcribe] Local whisper failed (${getWhisperBackend()})
|
|
201
|
+
console.warn(`[transcribe] Local whisper failed (${getWhisperBackend()}); using explicitly enabled OpenAI fallback: ${localErr.message}`)
|
|
180
202
|
text = await transcribeCloud(audioBuffer)
|
|
181
203
|
backend = 'cloud'
|
|
182
204
|
actualQuality = 'cloud'
|
|
183
205
|
}
|
|
184
206
|
} else {
|
|
185
|
-
|
|
186
|
-
|
|
207
|
+
const unavailable = policy === 'local-only'
|
|
208
|
+
? new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
|
|
209
|
+
: unavailableAfterLocalFailure()
|
|
210
|
+
if (unavailable) {
|
|
211
|
+
throw unavailable
|
|
187
212
|
}
|
|
188
213
|
text = await transcribeCloud(audioBuffer)
|
|
189
214
|
backend = 'cloud'
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getKeyStatus } from './openai-key.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cloud transcription is a two-factor opt-in. Merely having an OpenAI key on
|
|
5
|
+
* the machine must never route voice away from local Whisper.
|
|
6
|
+
*/
|
|
7
|
+
export const OPENAI_WHISPER_FALLBACK_ENV = 'COS_OPENAI_WHISPER_FALLBACK'
|
|
8
|
+
|
|
9
|
+
export interface TranscriptionPolicySnapshot {
|
|
10
|
+
mode: 'local-only' | 'local-then-openai'
|
|
11
|
+
localRequired: true
|
|
12
|
+
openaiFallbackConfigured: boolean
|
|
13
|
+
openaiFallbackReady: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getTranscriptionPolicySnapshot(): TranscriptionPolicySnapshot {
|
|
17
|
+
const openaiFallbackConfigured = process.env[OPENAI_WHISPER_FALLBACK_ENV] === '1'
|
|
18
|
+
const openaiFallbackReady = openaiFallbackConfigured && getKeyStatus().hasKey
|
|
19
|
+
return {
|
|
20
|
+
mode: openaiFallbackReady ? 'local-then-openai' : 'local-only',
|
|
21
|
+
localRequired: true,
|
|
22
|
+
openaiFallbackConfigured,
|
|
23
|
+
openaiFallbackReady,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isOpenAIWhisperFallbackReady(): boolean {
|
|
28
|
+
return getTranscriptionPolicySnapshot().openaiFallbackReady
|
|
29
|
+
}
|
|
@@ -636,7 +636,7 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
|
|
|
636
636
|
}
|
|
637
637
|
|
|
638
638
|
if (!serverAvailable && (serverStarting || serverRestarting)) {
|
|
639
|
-
throw new Error('whisper-server starting —
|
|
639
|
+
throw new Error('whisper-server starting — preserve audio for retry')
|
|
640
640
|
}
|
|
641
641
|
|
|
642
642
|
// Try whisper-server first (fastest: ~50-100ms, includes DTW word timestamps)
|
|
@@ -690,13 +690,15 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
|
|
|
690
690
|
restartWhisperServer()
|
|
691
691
|
}
|
|
692
692
|
|
|
693
|
-
// Throw so caller
|
|
694
|
-
|
|
693
|
+
// Throw so the caller applies the configured recovery policy. CLI is
|
|
694
|
+
// intentionally skipped for real-time transcription.
|
|
695
|
+
throw new Error('whisper-server unavailable — apply configured recovery policy')
|
|
695
696
|
}
|
|
696
697
|
|
|
697
698
|
/**
|
|
698
699
|
* Auto-restart whisper-server after circuit breaker triggers.
|
|
699
|
-
* Non-blocking — runs in background while callers
|
|
700
|
+
* Non-blocking — runs in background while callers preserve audio or apply the
|
|
701
|
+
* explicitly configured fallback policy.
|
|
700
702
|
*/
|
|
701
703
|
async function restartWhisperServer(): Promise<void> {
|
|
702
704
|
if (serverRestarting) return
|
|
@@ -734,7 +736,7 @@ async function restartWhisperServer(): Promise<void> {
|
|
|
734
736
|
// Without this, the counter stays >= threshold but serverRestarting is false,
|
|
735
737
|
// so every subsequent call would re-trigger restart in a tight loop
|
|
736
738
|
serverConsecutiveFailures = 0
|
|
737
|
-
console.error('[whisper-local] Server restart failed — reset counter, will retry after next 3 failures.
|
|
739
|
+
console.error('[whisper-local] Server restart failed — reset counter, will retry after next 3 failures. Caller recovery policy remains active.')
|
|
738
740
|
}
|
|
739
741
|
} catch (err: any) {
|
|
740
742
|
serverConsecutiveFailures = 0 // Same reset — allow future retry cycle
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
import { normalizeModelPreference, isClaudeModel } from '../../shared/model-preference.js'
|
|
3
|
+
import {
|
|
4
|
+
getClaudeRunConfig,
|
|
5
|
+
listClaudeRuns,
|
|
6
|
+
} from '../lib/claude-run-ledger.js'
|
|
7
|
+
import {
|
|
8
|
+
getCodexRunConfig,
|
|
9
|
+
listCodexRuns,
|
|
10
|
+
} from '../lib/codex-run-ledger.js'
|
|
11
|
+
import {
|
|
12
|
+
safeCliDebugResponse,
|
|
13
|
+
safeLegacyClaudeResponse,
|
|
14
|
+
safeLegacyCodexResponse,
|
|
15
|
+
} from '../lib/cli-debug-view.js'
|
|
16
|
+
|
|
17
|
+
export const cliDebugRouter = Router()
|
|
18
|
+
|
|
19
|
+
function boundedLimit(value: unknown): number {
|
|
20
|
+
const raw = Number(value ?? 20)
|
|
21
|
+
return Number.isFinite(raw) && raw > 0 ? Math.min(Math.floor(raw), 50) : 20
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function optionalSessionId(value: unknown): string | undefined {
|
|
25
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function optionalClaudeModel(value: unknown) {
|
|
29
|
+
const raw = typeof value === 'string' ? normalizeModelPreference(value) : undefined
|
|
30
|
+
return raw && isClaudeModel(raw) ? raw : undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Versioned public-safe view consumed by Recovery Center. Global /api auth
|
|
34
|
+
// protects this route; it must never be added to the unauthenticated allowlist.
|
|
35
|
+
cliDebugRouter.get('/cli/debug', (req, res) => {
|
|
36
|
+
const limit = boundedLimit(req.query.limit)
|
|
37
|
+
const sessionId = optionalSessionId(req.query.sessionId)
|
|
38
|
+
const model = optionalClaudeModel(req.query.model)
|
|
39
|
+
const claudeConfig = getClaudeRunConfig()
|
|
40
|
+
const codexConfig = getCodexRunConfig()
|
|
41
|
+
const claudeRuns = listClaudeRuns(limit, sessionId, model)
|
|
42
|
+
const codexRuns = listCodexRuns(limit, sessionId)
|
|
43
|
+
res.json(safeCliDebugResponse(
|
|
44
|
+
claudeConfig,
|
|
45
|
+
claudeRuns[0],
|
|
46
|
+
codexConfig,
|
|
47
|
+
codexRuns[0],
|
|
48
|
+
))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Build-210 compatibility. These legacy shapes retain only the fields the old
|
|
52
|
+
// panel can render safely; they do not expose raw ledger records.
|
|
53
|
+
cliDebugRouter.get('/cli/runs', (req, res) => {
|
|
54
|
+
const limit = boundedLimit(req.query.limit)
|
|
55
|
+
const sessionId = optionalSessionId(req.query.sessionId)
|
|
56
|
+
const model = optionalClaudeModel(req.query.model)
|
|
57
|
+
const config = getClaudeRunConfig()
|
|
58
|
+
res.json(safeLegacyClaudeResponse(config, listClaudeRuns(limit, sessionId, model)))
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
cliDebugRouter.get('/codex/runs', (req, res) => {
|
|
62
|
+
const limit = boundedLimit(req.query.limit)
|
|
63
|
+
const sessionId = optionalSessionId(req.query.sessionId)
|
|
64
|
+
const config = getCodexRunConfig()
|
|
65
|
+
res.json(safeLegacyCodexResponse(config, listCodexRuns(limit, sessionId)))
|
|
66
|
+
})
|
package/server/routes/health.ts
CHANGED
|
@@ -19,6 +19,8 @@ import { isMediaProcessingReady } from '../lib/image-safety.js'
|
|
|
19
19
|
import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
|
|
20
20
|
import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
|
|
21
21
|
import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
|
|
22
|
+
import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
|
|
23
|
+
import { CLI_DEBUG_CAPABILITY } from '../lib/cli-debug-view.js'
|
|
22
24
|
|
|
23
25
|
export const healthRouter = Router()
|
|
24
26
|
|
|
@@ -52,7 +54,7 @@ function durableQueryJobStatus() {
|
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
healthRouter.get('/health', async (_req, res) => {
|
|
55
|
-
const checks: Record<string, string | number> = {
|
|
57
|
+
const checks: Record<string, string | number | boolean> = {
|
|
56
58
|
status: 'ok',
|
|
57
59
|
mode: COS_MODE ? 'cos' : 'standalone',
|
|
58
60
|
server: 'ok',
|
|
@@ -127,9 +129,11 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
127
129
|
|
|
128
130
|
checks.silero_vad = isSileroAvailable() ? 'active' : 'disabled'
|
|
129
131
|
|
|
130
|
-
//
|
|
132
|
+
// Health is unauthenticated. Publish only availability; the actual CLI
|
|
133
|
+
// session id is a resumable runtime handle and belongs on authenticated
|
|
134
|
+
// query/debug surfaces.
|
|
131
135
|
const cliSid = getAvailableCliSessionId()
|
|
132
|
-
|
|
136
|
+
checks.cli_session_available = Boolean(cliSid)
|
|
133
137
|
|
|
134
138
|
// Feature summary for client capability detection.
|
|
135
139
|
// v5.9.5 — voice.hasKey reflects the centralized resolver (env > saved file >
|
|
@@ -140,6 +144,13 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
140
144
|
const keyStatus = getKeyStatus()
|
|
141
145
|
const durableJobs = durableQueryJobStatus()
|
|
142
146
|
const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
|
|
147
|
+
const transcription = getTranscriptionPolicySnapshot()
|
|
148
|
+
const recovery = {
|
|
149
|
+
status: false,
|
|
150
|
+
restartWhisper: false,
|
|
151
|
+
restartServer: false,
|
|
152
|
+
managed: false,
|
|
153
|
+
}
|
|
143
154
|
const features = {
|
|
144
155
|
claude: claudeAvailable,
|
|
145
156
|
codex: codexAvailable,
|
|
@@ -154,6 +165,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
154
165
|
durableQueryJobs: durableJobs.enabled,
|
|
155
166
|
durableQueryJobsProtocol: durableJobs.protocolVersion,
|
|
156
167
|
localFirstMeetings: localFirstMeetings !== null,
|
|
168
|
+
transcriptionPolicy: transcription.mode,
|
|
157
169
|
}
|
|
158
170
|
const voice = {
|
|
159
171
|
hasKey: keyStatus.hasKey,
|
|
@@ -173,7 +185,12 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
173
185
|
whisper_health,
|
|
174
186
|
openai_whisper_budget,
|
|
175
187
|
codex_models,
|
|
176
|
-
capabilities:
|
|
188
|
+
capabilities: {
|
|
189
|
+
transcription,
|
|
190
|
+
recovery,
|
|
191
|
+
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
192
|
+
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
193
|
+
},
|
|
177
194
|
// /api/health is intentionally unauthenticated for setup diagnostics.
|
|
178
195
|
// Publish capability only; job counts, retention identities, subscriber
|
|
179
196
|
// counts, and the storage fingerprint remain internal.
|
|
@@ -192,6 +209,7 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
192
209
|
const catalog = await getCodexModelCatalog(req.query.refresh === '1')
|
|
193
210
|
const durableJobs = durableQueryJobStatus()
|
|
194
211
|
const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
|
|
212
|
+
const transcription = getTranscriptionPolicySnapshot()
|
|
195
213
|
res.json({
|
|
196
214
|
...catalog,
|
|
197
215
|
serverInstanceId: getServerInstanceId(),
|
|
@@ -200,6 +218,14 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
200
218
|
enabled: durableJobs.enabled,
|
|
201
219
|
protocolVersion: durableJobs.protocolVersion,
|
|
202
220
|
},
|
|
221
|
+
transcription,
|
|
222
|
+
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
223
|
+
recovery: {
|
|
224
|
+
status: false,
|
|
225
|
+
restartWhisper: false,
|
|
226
|
+
restartServer: false,
|
|
227
|
+
managed: false,
|
|
228
|
+
},
|
|
203
229
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
204
230
|
},
|
|
205
231
|
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// POST /api/transcribe-stream — Streaming transcription for continuous meeting capture
|
|
2
|
-
// Uses local Whisper
|
|
2
|
+
// Uses local Whisper. OpenAI API fallback is disabled by default and requires
|
|
3
|
+
// both COS_OPENAI_WHISPER_FALLBACK=1 and a configured key.
|
|
3
4
|
// Streams speaker-labeled transcript chunks for live meeting capture.
|
|
4
5
|
|
|
5
6
|
import { Router } from 'express'
|
|
@@ -10,11 +11,13 @@ import { resolve } from 'node:path'
|
|
|
10
11
|
import { fileURLToPath } from 'node:url'
|
|
11
12
|
import { getVocabulary, getOwnerName } from '../lib/profile.js'
|
|
12
13
|
import { getOpenAIKey } from '../lib/openai-key.js'
|
|
14
|
+
import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from '../lib/transcription-policy.js'
|
|
15
|
+
import { TranscriptionUnavailableError } from '../lib/transcribe-audio.js'
|
|
13
16
|
|
|
14
17
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
15
18
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
16
19
|
import { errMsg } from '../lib/utils.js'
|
|
17
|
-
import { transcribeLocal,
|
|
20
|
+
import { transcribeLocal, applyCorrections, type WhisperWord } from '../lib/whisper-local.js'
|
|
18
21
|
import { enhanceAudio } from '../lib/audio-enhance.js'
|
|
19
22
|
import { trimSilence, isSileroAvailable } from '../lib/vad-silero.js'
|
|
20
23
|
import { identifySpeaker, isEmbeddingAvailable, autoEnroll, getEmbeddingCount } from '../lib/speaker-embeddings.js'
|
|
@@ -1044,18 +1047,26 @@ function canonicalChunkResponse(
|
|
|
1044
1047
|
}
|
|
1045
1048
|
|
|
1046
1049
|
async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Buffer, whisperContext: string, isQuiet: boolean): Promise<{ text: string; words?: WhisperWord[]; backend: string }> {
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1050
|
+
// The worker owns reconciliation of stale health. Always attempt local ASR
|
|
1051
|
+
// once; an availability snapshot must not divert meeting audio to cloud.
|
|
1052
|
+
try {
|
|
1053
|
+
const result = await transcribeLocal(whisperAudio, whisperContext || undefined, isQuiet)
|
|
1054
|
+
return { text: result.text, words: result.words, backend: `local-${result.backend}` }
|
|
1055
|
+
} catch (err: unknown) {
|
|
1056
|
+
if (!isOpenAIWhisperFallbackReady()) {
|
|
1057
|
+
const fallback = getTranscriptionPolicySnapshot()
|
|
1058
|
+
console.warn(`[transcribe-stream] Local Whisper unavailable; preserving chunk for retry: ${errMsg(err)}`)
|
|
1059
|
+
throw new TranscriptionUnavailableError(
|
|
1060
|
+
fallback.openaiFallbackConfigured ? 'openai_key_missing' : 'local_asr_unavailable',
|
|
1061
|
+
fallback.openaiFallbackConfigured
|
|
1062
|
+
? 'Local transcription is unavailable; audio is preserved for retry and the configured OpenAI fallback has no key'
|
|
1063
|
+
: 'Local transcription is unavailable; audio is preserved for retry',
|
|
1064
|
+
)
|
|
1055
1065
|
}
|
|
1066
|
+
console.warn(`[transcribe-stream] Local Whisper failed; using explicitly enabled OpenAI fallback: ${errMsg(err)}`)
|
|
1067
|
+
const text = await transcribeViaCloud(whisperAudio)
|
|
1068
|
+
return { text, words: undefined, backend: 'cloud' }
|
|
1056
1069
|
}
|
|
1057
|
-
const text = await transcribeViaCloud(whisperAudio)
|
|
1058
|
-
return { text, words: undefined, backend: 'cloud' }
|
|
1059
1070
|
}
|
|
1060
1071
|
|
|
1061
1072
|
function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex: number, clientSpeaker: string): { speaker: string; similarity: number } {
|
|
@@ -1315,6 +1326,14 @@ async function processStreamChunk(opts: {
|
|
|
1315
1326
|
}
|
|
1316
1327
|
|
|
1317
1328
|
function sendStreamError(res: { status: (code: number) => { json: (body: unknown) => unknown } }, err: unknown): unknown {
|
|
1329
|
+
if (err instanceof TranscriptionUnavailableError) {
|
|
1330
|
+
console.warn(`[transcribe-stream] ${err.message}`)
|
|
1331
|
+
return res.status(err.status).json({
|
|
1332
|
+
error: err.message,
|
|
1333
|
+
reason: err.reason,
|
|
1334
|
+
retryable: true,
|
|
1335
|
+
})
|
|
1336
|
+
}
|
|
1318
1337
|
if (err instanceof OpenAIWhisperBudgetExhaustedError) {
|
|
1319
1338
|
console.error(`[transcribe-stream] ${err.message}`)
|
|
1320
1339
|
return res.status(503).json({
|
|
@@ -1487,9 +1506,18 @@ transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) =>
|
|
|
1487
1506
|
* A hung whisper-server + long meeting is the exact scenario this guards against —
|
|
1488
1507
|
* chunks stay empty on budget-exceeded instead of silently billing per chunk. */
|
|
1489
1508
|
async function transcribeViaCloud(audioBuffer: Buffer): Promise<string> {
|
|
1490
|
-
|
|
1509
|
+
if (!isOpenAIWhisperFallbackReady()) {
|
|
1510
|
+
const fallback = getTranscriptionPolicySnapshot()
|
|
1511
|
+
throw new TranscriptionUnavailableError(
|
|
1512
|
+
fallback.openaiFallbackConfigured ? 'openai_key_missing' : 'local_asr_unavailable',
|
|
1513
|
+
fallback.openaiFallbackConfigured
|
|
1514
|
+
? 'Local transcription is unavailable; audio is preserved for retry and the configured OpenAI fallback has no key'
|
|
1515
|
+
: 'Local transcription is unavailable; audio is preserved for retry',
|
|
1516
|
+
)
|
|
1517
|
+
}
|
|
1491
1518
|
|
|
1492
1519
|
const key = getOpenAIKey()
|
|
1520
|
+
assertOpenAIWhisperBudget()
|
|
1493
1521
|
const audioSeconds = estimateAudioSeconds(audioBuffer)
|
|
1494
1522
|
|
|
1495
1523
|
const isWav = audioBuffer.length >= 4 && audioBuffer.toString('ascii', 0, 4) === 'RIFF'
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
resolveTranscribeMode,
|
|
10
10
|
NoSpeechDetectedError,
|
|
11
11
|
OpenAIWhisperBudgetExhaustedError,
|
|
12
|
+
TranscriptionUnavailableError,
|
|
12
13
|
} from '../lib/transcribe-audio.js'
|
|
13
14
|
|
|
14
15
|
export const transcribeRouter = Router()
|
|
@@ -50,6 +51,14 @@ transcribeRouter.post('/transcribe', async (req, res) => {
|
|
|
50
51
|
cap_usd: err.capUsd,
|
|
51
52
|
})
|
|
52
53
|
}
|
|
54
|
+
if (err instanceof TranscriptionUnavailableError) {
|
|
55
|
+
console.warn(`[transcribe] ${err.message}`)
|
|
56
|
+
return res.status(err.status).json({
|
|
57
|
+
error: err.message,
|
|
58
|
+
reason: err.reason,
|
|
59
|
+
retryable: true,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
53
62
|
res.status(500).json({ error: err.message })
|
|
54
63
|
}
|
|
55
64
|
})
|