@gotcos/glasses-server 6.15.3 → 6.16.0
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 +5 -1
- package/CHANGELOG.md +57 -0
- package/README.md +30 -0
- package/package.json +1 -1
- package/server/index.ts +48 -23
- package/server/lib/api-auth.ts +5 -1
- package/server/lib/audio-enhance.ts +15 -8
- package/server/lib/claude-bridge.ts +48 -18
- package/server/lib/claude-tool-access.ts +73 -7
- package/server/lib/codex-bridge.ts +60 -27
- package/server/lib/maintenance-lifecycle.ts +40 -0
- package/server/lib/prompt-draft-store.ts +1 -0
- package/server/lib/provider-process-lifecycle.ts +139 -0
- package/server/lib/provider-proof.ts +28 -10
- package/server/lib/transcribe-audio.ts +20 -3
- package/server/lib/whisper-local.ts +170 -20
- package/server/routes/health.ts +27 -3
- package/server/routes/openai-compat.ts +54 -27
- package/server/routes/prompt-drafts.ts +122 -9
- package/server/routes/provider-proof.ts +40 -2
- package/server/routes/transcribe.ts +9 -1
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// 2. whisper-cli (spawned per request, model loaded from disk) → ~500-700ms
|
|
7
7
|
// 3. OpenAI API (cloud, handled by transcribe.ts) → ~1000-3000ms
|
|
8
8
|
|
|
9
|
-
import { spawn,
|
|
9
|
+
import { spawn, execFile } from 'node:child_process'
|
|
10
10
|
import type { ChildProcess } from 'node:child_process'
|
|
11
11
|
import { writeFileSync, unlinkSync, existsSync, readFileSync } from 'node:fs'
|
|
12
|
-
import { join } from 'node:path'
|
|
12
|
+
import { basename, join } from 'node:path'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
14
|
import crypto from 'node:crypto'
|
|
15
15
|
import { getVocabulary, getOwnerName } from './profile.js'
|
|
@@ -42,6 +42,49 @@ export interface WhisperSegment {
|
|
|
42
42
|
words?: WhisperWord[]
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export type HighQualityUnavailableReason =
|
|
46
|
+
| 'hq_disabled'
|
|
47
|
+
| 'whisper_cli_missing'
|
|
48
|
+
| 'turbo_model_missing'
|
|
49
|
+
| 'large_v3_model_missing'
|
|
50
|
+
|
|
51
|
+
export interface HighQualityTranscriptionCapability {
|
|
52
|
+
hqAvailable: boolean
|
|
53
|
+
model: 'large-v3' | null
|
|
54
|
+
backend: 'whisper-cli' | null
|
|
55
|
+
reason: HighQualityUnavailableReason | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface HighQualityTranscriptionResult {
|
|
59
|
+
text: string
|
|
60
|
+
words?: WhisperWord[]
|
|
61
|
+
model: 'large-v3' | 'turbo'
|
|
62
|
+
backend: 'whisper-cli' | 'whisper-server'
|
|
63
|
+
actualQuality: 'hq' | 'fast'
|
|
64
|
+
degradationReason?: HighQualityUnavailableReason
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function classifyHighQualityTranscriptionCapability(input: {
|
|
68
|
+
enabled: boolean
|
|
69
|
+
cliPresent: boolean
|
|
70
|
+
turboReady: boolean
|
|
71
|
+
largeV3Present: boolean
|
|
72
|
+
}): HighQualityTranscriptionCapability {
|
|
73
|
+
if (!input.enabled) {
|
|
74
|
+
return { hqAvailable: false, model: null, backend: null, reason: 'hq_disabled' }
|
|
75
|
+
}
|
|
76
|
+
if (!input.cliPresent) {
|
|
77
|
+
return { hqAvailable: false, model: null, backend: null, reason: 'whisper_cli_missing' }
|
|
78
|
+
}
|
|
79
|
+
if (!input.turboReady) {
|
|
80
|
+
return { hqAvailable: false, model: null, backend: null, reason: 'turbo_model_missing' }
|
|
81
|
+
}
|
|
82
|
+
if (!input.largeV3Present) {
|
|
83
|
+
return { hqAvailable: false, model: null, backend: null, reason: 'large_v3_model_missing' }
|
|
84
|
+
}
|
|
85
|
+
return { hqAvailable: true, model: 'large-v3', backend: 'whisper-cli', reason: null }
|
|
86
|
+
}
|
|
87
|
+
|
|
45
88
|
interface WhisperJsonResponse {
|
|
46
89
|
text?: unknown
|
|
47
90
|
}
|
|
@@ -137,6 +180,10 @@ function getWhisperPrompt(): string {
|
|
|
137
180
|
// whisper-server runs on this port (started at server boot or by LaunchAgent)
|
|
138
181
|
const WHISPER_SERVER_PORT = 8178
|
|
139
182
|
const WHISPER_SERVER_URL = `http://127.0.0.1:${WHISPER_SERVER_PORT}`
|
|
183
|
+
const PROCESS_PROBE_TIMEOUT_MS = 2_000
|
|
184
|
+
const PROCESS_PROBE_MAX_BUFFER = 1024 * 1024
|
|
185
|
+
const PS_BIN = '/bin/ps'
|
|
186
|
+
const LSOF_BIN = existsSync('/usr/sbin/lsof') ? '/usr/sbin/lsof' : 'lsof'
|
|
140
187
|
|
|
141
188
|
// Track which backends are available
|
|
142
189
|
let cliAvailable = false
|
|
@@ -153,6 +200,10 @@ let serverStarting = false // Initial model load is not a circuit fai
|
|
|
153
200
|
let serverStartPromise: Promise<void> | null = null
|
|
154
201
|
let serverRestartPromise: Promise<WhisperRestartResult> | null = null
|
|
155
202
|
let serverHealthProbe: Promise<boolean> | null = null
|
|
203
|
+
type WhisperStartupState = 'not_started' | 'preflight' | 'loading' | 'ready' | 'unavailable' | 'failed' | 'stopped'
|
|
204
|
+
let serverStartupState: WhisperStartupState = 'not_started'
|
|
205
|
+
let serverLastAttemptAt: string | null = null
|
|
206
|
+
let serverLastError: string | null = null
|
|
156
207
|
|
|
157
208
|
interface ProcessEntry {
|
|
158
209
|
pid: number
|
|
@@ -167,10 +218,34 @@ export interface WhisperRestartResult {
|
|
|
167
218
|
|
|
168
219
|
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
|
|
169
220
|
|
|
170
|
-
function
|
|
221
|
+
function boundedError(value: unknown): string {
|
|
222
|
+
const message = value instanceof Error ? value.message : String(value)
|
|
223
|
+
return message.replace(/[\r\n\t]+/g, ' ').slice(0, 240)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function runProcessProbe(file: string, args: string[]): Promise<string> {
|
|
227
|
+
return new Promise((resolve, reject) => {
|
|
228
|
+
execFile(file, args, {
|
|
229
|
+
encoding: 'utf8',
|
|
230
|
+
timeout: PROCESS_PROBE_TIMEOUT_MS,
|
|
231
|
+
maxBuffer: PROCESS_PROBE_MAX_BUFFER,
|
|
232
|
+
killSignal: 'SIGKILL',
|
|
233
|
+
}, (error, stdout) => {
|
|
234
|
+
if (error) reject(error)
|
|
235
|
+
else resolve(String(stdout))
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function listProcesses(): Promise<ProcessEntry[]> {
|
|
171
241
|
// `command=` includes arguments, which lets us distinguish this COS-owned
|
|
172
242
|
// port/model signature from unrelated whisper-server instances.
|
|
173
|
-
|
|
243
|
+
let output: string
|
|
244
|
+
try {
|
|
245
|
+
output = await runProcessProbe(PS_BIN, ['-axww', '-o', 'pid=,ppid=,command='])
|
|
246
|
+
} catch (error) {
|
|
247
|
+
throw new Error(`unable to inspect process table: ${boundedError(error)}`)
|
|
248
|
+
}
|
|
174
249
|
return output.split('\n').flatMap(line => {
|
|
175
250
|
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/)
|
|
176
251
|
if (!match) return []
|
|
@@ -178,24 +253,25 @@ function listProcesses(): ProcessEntry[] {
|
|
|
178
253
|
})
|
|
179
254
|
}
|
|
180
255
|
|
|
181
|
-
function listeningPids(): number[] {
|
|
256
|
+
async function listeningPids(): Promise<number[]> {
|
|
182
257
|
try {
|
|
183
|
-
const output =
|
|
184
|
-
|
|
258
|
+
const output = await runProcessProbe(
|
|
259
|
+
LSOF_BIN,
|
|
185
260
|
['-nP', `-iTCP:${WHISPER_SERVER_PORT}`, '-sTCP:LISTEN', '-t'],
|
|
186
|
-
{ encoding: 'utf8' },
|
|
187
261
|
)
|
|
188
262
|
return output.split(/\s+/).map(Number).filter(pid => Number.isInteger(pid) && pid > 0)
|
|
189
263
|
} catch (err: any) {
|
|
190
264
|
// lsof uses exit 1 for "no matches". Anything else means we could not
|
|
191
265
|
// prove the port state, so startup must fail closed.
|
|
192
|
-
if (err?.status === 1) return []
|
|
193
|
-
throw new Error(`unable to inspect whisper-server port ${WHISPER_SERVER_PORT}: ${err
|
|
266
|
+
if (err?.code === 1 || err?.status === 1) return []
|
|
267
|
+
throw new Error(`unable to inspect whisper-server port ${WHISPER_SERVER_PORT}: ${boundedError(err)}`)
|
|
194
268
|
}
|
|
195
269
|
}
|
|
196
270
|
|
|
197
271
|
function isCosWhisperServerCommand(command: string): boolean {
|
|
198
|
-
const
|
|
272
|
+
const firstToken = command.trim().match(/^(?:"([^"]+)"|'([^']+)'|(\S+))/)
|
|
273
|
+
const executablePath = firstToken?.[1] ?? firstToken?.[2] ?? firstToken?.[3] ?? ''
|
|
274
|
+
const executable = basename(executablePath) === 'whisper-server'
|
|
199
275
|
const configuredPort = new RegExp(`(?:^|\\s)--port(?:=|\\s+)${WHISPER_SERVER_PORT}(?:\\s|$)`).test(command)
|
|
200
276
|
return executable && configuredPort && command.includes(MODEL_PATH)
|
|
201
277
|
}
|
|
@@ -249,7 +325,7 @@ async function killAndReapWhisperProcesses(): Promise<void> {
|
|
|
249
325
|
serverAvailable = false
|
|
250
326
|
|
|
251
327
|
for (let round = 0; round < 3; round++) {
|
|
252
|
-
const processes = listProcesses()
|
|
328
|
+
const processes = await listProcesses()
|
|
253
329
|
const ownedPids = [...ownedServerChildren]
|
|
254
330
|
.map(child => child.pid)
|
|
255
331
|
.filter((pid): pid is number => typeof pid === 'number')
|
|
@@ -288,7 +364,7 @@ async function killAndReapWhisperProcesses(): Promise<void> {
|
|
|
288
364
|
await sleep(50)
|
|
289
365
|
}
|
|
290
366
|
|
|
291
|
-
const remaining = listProcesses().filter(entry => isCosWhisperServerCommand(entry.command))
|
|
367
|
+
const remaining = (await listProcesses()).filter(entry => isCosWhisperServerCommand(entry.command))
|
|
292
368
|
if (remaining.length > 0) {
|
|
293
369
|
throw new Error(`stale whisper-server process(es) remain: ${remaining.map(entry => entry.pid).join(', ')}`)
|
|
294
370
|
}
|
|
@@ -298,11 +374,11 @@ async function killAndReapWhisperProcesses(): Promise<void> {
|
|
|
298
374
|
|
|
299
375
|
async function proveWhisperPortClear(): Promise<void> {
|
|
300
376
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
301
|
-
const pids = listeningPids()
|
|
377
|
+
const pids = await listeningPids()
|
|
302
378
|
if (pids.length === 0) return
|
|
303
379
|
if (attempt < 19) await sleep(250)
|
|
304
380
|
}
|
|
305
|
-
const pids = listeningPids()
|
|
381
|
+
const pids = await listeningPids()
|
|
306
382
|
throw new Error(
|
|
307
383
|
`whisper-server port ${WHISPER_SERVER_PORT} remains occupied${pids.length ? ` by PID(s) ${pids.join(', ')}` : ''}`,
|
|
308
384
|
)
|
|
@@ -334,10 +410,17 @@ export async function startWhisperServer(): Promise<void> {
|
|
|
334
410
|
if (serverAvailable && serverProcess) return
|
|
335
411
|
|
|
336
412
|
serverStarting = true
|
|
413
|
+
serverStartupState = 'preflight'
|
|
414
|
+
serverLastAttemptAt = new Date().toISOString()
|
|
415
|
+
serverLastError = null
|
|
337
416
|
const operation = startWhisperServerAttempt()
|
|
338
417
|
serverStartPromise = operation
|
|
339
418
|
try {
|
|
340
419
|
await operation
|
|
420
|
+
} catch (error) {
|
|
421
|
+
serverStartupState = 'failed'
|
|
422
|
+
serverLastError = boundedError(error)
|
|
423
|
+
throw error
|
|
341
424
|
} finally {
|
|
342
425
|
if (serverStartPromise === operation) serverStartPromise = null
|
|
343
426
|
serverStarting = false
|
|
@@ -346,6 +429,8 @@ export async function startWhisperServer(): Promise<void> {
|
|
|
346
429
|
|
|
347
430
|
async function startWhisperServerAttempt(preflightCompleted = false): Promise<void> {
|
|
348
431
|
if (!existsSync(WHISPER_SERVER) || !existsSync(MODEL_PATH)) {
|
|
432
|
+
serverStartupState = 'unavailable'
|
|
433
|
+
serverLastError = 'whisper-server or model not found'
|
|
349
434
|
console.log('[whisper-local] whisper-server or model not found — using CLI fallback')
|
|
350
435
|
return
|
|
351
436
|
}
|
|
@@ -353,9 +438,12 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
|
|
|
353
438
|
// The API process is the sole Whisper owner. Never adopt an untracked daemon:
|
|
354
439
|
// reap stale trees, then prove the fixed local port is free before spawning.
|
|
355
440
|
if (!preflightCompleted) {
|
|
441
|
+
console.log('[whisper-local] preflight: inspecting owned processes and port 8178')
|
|
442
|
+
serverStartupState = 'preflight'
|
|
356
443
|
await killAndReapWhisperProcesses()
|
|
357
444
|
await proveWhisperPortClear()
|
|
358
445
|
}
|
|
446
|
+
serverStartupState = 'loading'
|
|
359
447
|
|
|
360
448
|
// Assemble startup args. VAD only attaches if the ggml model is actually on
|
|
361
449
|
// disk — missing-file is logged, not fatal (server still boots without VAD).
|
|
@@ -402,6 +490,8 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
|
|
|
402
490
|
if (serverProcess !== child) return
|
|
403
491
|
serverAvailable = false
|
|
404
492
|
serverProcess = null
|
|
493
|
+
serverStartupState = 'failed'
|
|
494
|
+
serverLastError = `whisper-server exited${code == null ? '' : ` with code ${code}`}`
|
|
405
495
|
if (code !== null && code !== 0) {
|
|
406
496
|
console.warn(`[whisper-local] whisper-server exited with code ${code}`)
|
|
407
497
|
}
|
|
@@ -422,6 +512,8 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
|
|
|
422
512
|
const res = await fetch(`${WHISPER_SERVER_URL}/health`, { signal: AbortSignal.timeout(1000) })
|
|
423
513
|
if (res.ok) {
|
|
424
514
|
serverAvailable = true
|
|
515
|
+
serverStartupState = 'ready'
|
|
516
|
+
serverLastError = null
|
|
425
517
|
const loadTime = ((Date.now() - startTime) / 1000).toFixed(1)
|
|
426
518
|
console.log(`[whisper-local] whisper-server ready on port ${WHISPER_SERVER_PORT} (loaded in ${loadTime}s)`)
|
|
427
519
|
return
|
|
@@ -444,6 +536,8 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
|
|
|
444
536
|
console.error(`[whisper-local] ${failure} — reaping child and keeping local backend unavailable`)
|
|
445
537
|
await killAndReapWhisperProcesses()
|
|
446
538
|
await proveWhisperPortClear()
|
|
539
|
+
serverStartupState = 'failed'
|
|
540
|
+
serverLastError = failure
|
|
447
541
|
throw new Error(failure)
|
|
448
542
|
}
|
|
449
543
|
|
|
@@ -461,6 +555,7 @@ export function stopWhisperServer(): void {
|
|
|
461
555
|
try { proc.kill('SIGKILL') } catch { /* already exited */ }
|
|
462
556
|
}
|
|
463
557
|
ownedHqChildren.clear()
|
|
558
|
+
serverStartupState = 'stopped'
|
|
464
559
|
}
|
|
465
560
|
|
|
466
561
|
export function isWhisperLocalAvailable(): boolean {
|
|
@@ -480,20 +575,42 @@ export function getWhisperBackend(): 'server' | 'cli' | 'none' {
|
|
|
480
575
|
/** Detailed health status for diagnostics (/api/health) */
|
|
481
576
|
export function getWhisperHealth(): {
|
|
482
577
|
server: boolean
|
|
578
|
+
serverConfigured: boolean
|
|
483
579
|
cli: boolean
|
|
484
580
|
consecutiveFailures: number
|
|
485
581
|
restarting: boolean
|
|
486
582
|
circuitOpen: boolean
|
|
583
|
+
startupState: WhisperStartupState
|
|
584
|
+
lastAttemptAt: string | null
|
|
585
|
+
lastError: string | null
|
|
487
586
|
} {
|
|
488
587
|
return {
|
|
489
588
|
server: serverAvailable,
|
|
589
|
+
serverConfigured: existsSync(WHISPER_SERVER) && existsSync(MODEL_PATH),
|
|
490
590
|
cli: cliAvailable,
|
|
491
591
|
consecutiveFailures: serverConsecutiveFailures,
|
|
492
592
|
restarting: serverRestarting || serverStarting,
|
|
493
593
|
circuitOpen: serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD,
|
|
594
|
+
startupState: serverStartupState,
|
|
595
|
+
lastAttemptAt: serverLastAttemptAt,
|
|
596
|
+
lastError: serverLastError,
|
|
494
597
|
}
|
|
495
598
|
}
|
|
496
599
|
|
|
600
|
+
/** Public, path-free truth about whether an HQ request can actually run the
|
|
601
|
+
* full large-v3 decoder. Keep this separate from generic Whisper liveness: the
|
|
602
|
+
* persistent turbo server may be healthy while HQ weights or the CLI are not. */
|
|
603
|
+
export function getHighQualityTranscriptionCapability(): HighQualityTranscriptionCapability {
|
|
604
|
+
// cliAvailable also proves the turbo model exists. That fallback is part of
|
|
605
|
+
// the current decoder contract and is initialized once at process startup.
|
|
606
|
+
return classifyHighQualityTranscriptionCapability({
|
|
607
|
+
enabled: BATCH_LARGE_V3_ENABLED,
|
|
608
|
+
cliPresent: existsSync(WHISPER_CLI),
|
|
609
|
+
turboReady: cliAvailable,
|
|
610
|
+
largeV3Present: existsSync(BATCH_MODEL_LARGE_V3),
|
|
611
|
+
})
|
|
612
|
+
}
|
|
613
|
+
|
|
497
614
|
/**
|
|
498
615
|
* Reconcile a cached unavailable flag with the daemon's live health endpoint.
|
|
499
616
|
* Only successful inference resets the failure count: /health can be responsive
|
|
@@ -581,10 +698,18 @@ export async function transcribeHighQuality(
|
|
|
581
698
|
audioBuffer: Buffer,
|
|
582
699
|
context?: string,
|
|
583
700
|
opts: { priority?: 'interactive' | 'batch' } = {},
|
|
584
|
-
): Promise<
|
|
701
|
+
): Promise<HighQualityTranscriptionResult> {
|
|
585
702
|
if (!cliAvailable) {
|
|
586
703
|
// Fall back to server (no beam search available via HTTP API)
|
|
587
|
-
|
|
704
|
+
const fallback = await transcribeLocal(audioBuffer, context)
|
|
705
|
+
return {
|
|
706
|
+
text: fallback.text,
|
|
707
|
+
words: fallback.words,
|
|
708
|
+
model: 'turbo',
|
|
709
|
+
backend: fallback.backend === 'server' ? 'whisper-server' : 'whisper-cli',
|
|
710
|
+
actualQuality: 'fast',
|
|
711
|
+
degradationReason: existsSync(WHISPER_CLI) ? 'turbo_model_missing' : 'whisper_cli_missing',
|
|
712
|
+
}
|
|
588
713
|
}
|
|
589
714
|
|
|
590
715
|
const start = Date.now()
|
|
@@ -604,14 +729,22 @@ export async function transcribeHighQuality(
|
|
|
604
729
|
|
|
605
730
|
const text = await new Promise<string>((resolve, reject) => {
|
|
606
731
|
const isolateBatchFromLiveMetal = opts.priority === 'batch'
|
|
732
|
+
// Interactive HQ: narrower beam (default 2) for latency. Meeting batch keeps 5.
|
|
733
|
+
// Override: COS_HQ_BEAM_INTERACTIVE=N
|
|
734
|
+
const interactiveBeamRaw = Number.parseInt(process.env.COS_HQ_BEAM_INTERACTIVE || '2', 10)
|
|
735
|
+
const interactiveBeam = Number.isFinite(interactiveBeamRaw) && interactiveBeamRaw >= 1
|
|
736
|
+
? Math.min(interactiveBeamRaw, 5)
|
|
737
|
+
: 2
|
|
738
|
+
const beam = isolateBatchFromLiveMetal ? 5 : interactiveBeam
|
|
739
|
+
const bestOf = beam
|
|
607
740
|
const args = [
|
|
608
741
|
'-m', modelPath,
|
|
609
742
|
'-f', tmpWav,
|
|
610
743
|
'-t', isolateBatchFromLiveMetal ? '8' : '16',
|
|
611
744
|
'-l', 'en',
|
|
612
745
|
...(isolateBatchFromLiveMetal ? ['-ng'] : ['-fa']),
|
|
613
|
-
'-bs',
|
|
614
|
-
'-bo',
|
|
746
|
+
'-bs', String(beam),
|
|
747
|
+
'-bo', String(bestOf),
|
|
615
748
|
'--no-timestamps',
|
|
616
749
|
'-np',
|
|
617
750
|
'--prompt', buildPrompt(context),
|
|
@@ -701,7 +834,17 @@ export async function transcribeHighQuality(
|
|
|
701
834
|
`${words ? `, ${words.length} words` : ''}): ` +
|
|
702
835
|
`"${corrected.slice(0, 80)}${corrected.length > 80 ? '...' : ''}"`,
|
|
703
836
|
)
|
|
704
|
-
|
|
837
|
+
const metadata = useLargeV3
|
|
838
|
+
? { model: 'large-v3' as const, backend: 'whisper-cli' as const, actualQuality: 'hq' as const }
|
|
839
|
+
: {
|
|
840
|
+
model: 'turbo' as const,
|
|
841
|
+
backend: 'whisper-cli' as const,
|
|
842
|
+
actualQuality: 'fast' as const,
|
|
843
|
+
degradationReason: BATCH_LARGE_V3_ENABLED
|
|
844
|
+
? 'large_v3_model_missing' as const
|
|
845
|
+
: 'hq_disabled' as const,
|
|
846
|
+
}
|
|
847
|
+
return words ? { text: corrected, words, ...metadata } : { text: corrected, ...metadata }
|
|
705
848
|
} finally {
|
|
706
849
|
try { unlinkSync(tmpWav) } catch { /* cleanup */ }
|
|
707
850
|
if (captureBatchWords) {
|
|
@@ -966,6 +1109,9 @@ export async function restartWhisperServer(): Promise<WhisperRestartResult> {
|
|
|
966
1109
|
if (serverRestartPromise) return serverRestartPromise
|
|
967
1110
|
|
|
968
1111
|
serverRestarting = true
|
|
1112
|
+
serverStartupState = 'preflight'
|
|
1113
|
+
serverLastAttemptAt = new Date().toISOString()
|
|
1114
|
+
serverLastError = null
|
|
969
1115
|
const priorStart = serverStartPromise
|
|
970
1116
|
const operation = (async (): Promise<WhisperRestartResult> => {
|
|
971
1117
|
try {
|
|
@@ -984,6 +1130,8 @@ export async function restartWhisperServer(): Promise<WhisperRestartResult> {
|
|
|
984
1130
|
throw new Error('whisper-server did not become healthy')
|
|
985
1131
|
}
|
|
986
1132
|
serverConsecutiveFailures = 0
|
|
1133
|
+
serverStartupState = 'ready'
|
|
1134
|
+
serverLastError = null
|
|
987
1135
|
console.log('[whisper-local] Server restarted successfully — circuit breaker CLOSED')
|
|
988
1136
|
return { status: 'recovered' }
|
|
989
1137
|
} catch (err: any) {
|
|
@@ -992,6 +1140,8 @@ export async function restartWhisperServer(): Promise<WhisperRestartResult> {
|
|
|
992
1140
|
// breaker cycle, and the next three failed calls may request one new cycle.
|
|
993
1141
|
serverConsecutiveFailures = 0
|
|
994
1142
|
const message = err?.message ?? String(err)
|
|
1143
|
+
serverStartupState = 'failed'
|
|
1144
|
+
serverLastError = boundedError(message)
|
|
995
1145
|
console.error(`[whisper-local] Server restart error: ${message} — will retry after next 3 failures`)
|
|
996
1146
|
return { status: 'failed', error: message }
|
|
997
1147
|
}
|
package/server/routes/health.ts
CHANGED
|
@@ -8,7 +8,11 @@ import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
|
8
8
|
import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
|
|
9
9
|
import { isSileroAvailable } from '../lib/vad-silero.js'
|
|
10
10
|
import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
isWhisperLocalAvailable,
|
|
13
|
+
getWhisperHealth,
|
|
14
|
+
getHighQualityTranscriptionCapability,
|
|
15
|
+
} from '../lib/whisper-local.js'
|
|
12
16
|
import { getOpenAIWhisperBudgetState } from '../lib/openai-whisper-budget.js'
|
|
13
17
|
import { getKeyStatus } from '../lib/openai-key.js'
|
|
14
18
|
import {
|
|
@@ -150,6 +154,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
150
154
|
const durableJobs = durableQueryJobStatus()
|
|
151
155
|
const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
|
|
152
156
|
const transcription = getTranscriptionPolicySnapshot()
|
|
157
|
+
const transcriptionHq = getHighQualityTranscriptionCapability()
|
|
153
158
|
const recovery = managedRuntimeCapability()
|
|
154
159
|
const maintenance = maintenanceLifecycle.snapshot()
|
|
155
160
|
const tts_local = getLocalTtsHealth()
|
|
@@ -180,6 +185,23 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
180
185
|
// Whisper-server health + cloud budget — exposed so glasses + dashboards can
|
|
181
186
|
// see whether we're at risk of falling to cloud and how much budget remains.
|
|
182
187
|
const whisper_health = getWhisperHealth()
|
|
188
|
+
const whisperReadiness = !whisper_health.serverConfigured
|
|
189
|
+
? 'not_configured'
|
|
190
|
+
: whisper_health.server
|
|
191
|
+
? 'ready'
|
|
192
|
+
: whisper_health.startupState === 'preflight' || whisper_health.startupState === 'loading'
|
|
193
|
+
? 'starting'
|
|
194
|
+
: 'degraded'
|
|
195
|
+
const readiness = {
|
|
196
|
+
// /api/health remains a liveness endpoint and intentionally returns HTTP
|
|
197
|
+
// 200 while the server can answer. This separate field prevents an HTTP-
|
|
198
|
+
// green response from hiding a configured local subsystem failure.
|
|
199
|
+
status: whisperReadiness === 'degraded' ? 'degraded' : 'ready',
|
|
200
|
+
admissions: maintenance.admissionsOpen ? 'open' : 'maintenance',
|
|
201
|
+
whisper: whisperReadiness,
|
|
202
|
+
whisperError: whisper_health.lastError,
|
|
203
|
+
localTts: tts_local.ready ? 'ready' : 'unavailable',
|
|
204
|
+
}
|
|
183
205
|
const openai_whisper_budget = getOpenAIWhisperBudgetState()
|
|
184
206
|
const codex_models = getCodexModelCatalogSnapshot()
|
|
185
207
|
res.json({
|
|
@@ -190,12 +212,13 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
190
212
|
generation_id: getServerGenerationId(),
|
|
191
213
|
features,
|
|
192
214
|
voice,
|
|
215
|
+
readiness,
|
|
193
216
|
whisper_health,
|
|
194
217
|
openai_whisper_budget,
|
|
195
218
|
tts_local,
|
|
196
219
|
codex_models,
|
|
197
220
|
capabilities: {
|
|
198
|
-
transcription,
|
|
221
|
+
transcription: { ...transcription, hq: transcriptionHq },
|
|
199
222
|
recovery,
|
|
200
223
|
maintenance: {
|
|
201
224
|
state: maintenance.state,
|
|
@@ -224,6 +247,7 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
224
247
|
const durableJobs = durableQueryJobStatus()
|
|
225
248
|
const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
|
|
226
249
|
const transcription = getTranscriptionPolicySnapshot()
|
|
250
|
+
const transcriptionHq = getHighQualityTranscriptionCapability()
|
|
227
251
|
res.json({
|
|
228
252
|
...catalog,
|
|
229
253
|
serverInstanceId: getServerInstanceId(),
|
|
@@ -232,7 +256,7 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
232
256
|
enabled: durableJobs.enabled,
|
|
233
257
|
protocolVersion: durableJobs.protocolVersion,
|
|
234
258
|
},
|
|
235
|
-
transcription,
|
|
259
|
+
transcription: { ...transcription, hq: transcriptionHq },
|
|
236
260
|
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
237
261
|
recovery: managedRuntimeCapability(),
|
|
238
262
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
@@ -295,6 +295,21 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
295
295
|
void inflightPromise.catch(() => { /* duplicate waiters observe the original rejection */ })
|
|
296
296
|
inflightQueries.set(dedupKey, { promise: inflightPromise, timestamp: Date.now() })
|
|
297
297
|
|
|
298
|
+
// Register disconnect cancellation before the provider starts. G2 can abort
|
|
299
|
+
// its first fetch while Claude/Codex continues running; without this signal
|
|
300
|
+
// the provider and its maintenance lease can strand a Control restart for
|
|
301
|
+
// the full model timeout. The lease is released only after the bridge reaches
|
|
302
|
+
// its terminal callback/catch, never merely because the socket disappeared.
|
|
303
|
+
const providerAbort = new AbortController()
|
|
304
|
+
let responseFinished = false
|
|
305
|
+
let clientDisconnected = false
|
|
306
|
+
res.once('finish', () => { responseFinished = true })
|
|
307
|
+
res.once('close', () => {
|
|
308
|
+
if (responseFinished) return
|
|
309
|
+
clientDisconnected = true
|
|
310
|
+
providerAbort.abort(new Error('G2 client disconnected'))
|
|
311
|
+
})
|
|
312
|
+
|
|
298
313
|
// ── Streaming response (SSE) ──
|
|
299
314
|
if (stream) {
|
|
300
315
|
res.writeHead(200, {
|
|
@@ -326,7 +341,7 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
326
341
|
try {
|
|
327
342
|
const returnedSid = await callModelStreaming(query, currentSessionId, {
|
|
328
343
|
onChunk: (text) => {
|
|
329
|
-
if (!done) {
|
|
344
|
+
if (!done && !clientDisconnected) {
|
|
330
345
|
if (!firstChunkLogged) {
|
|
331
346
|
firstChunkLogged = true
|
|
332
347
|
actualTtfbMs = Date.now() - requestReceivedAt
|
|
@@ -360,16 +375,18 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
360
375
|
stream_requested: true,
|
|
361
376
|
})
|
|
362
377
|
// Final chunk with finish_reason
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
378
|
+
if (!clientDisconnected) {
|
|
379
|
+
const finalChunk = {
|
|
380
|
+
id: completionId,
|
|
381
|
+
object: 'chat.completion.chunk',
|
|
382
|
+
created: timestamp,
|
|
383
|
+
model: responseModel,
|
|
384
|
+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
|
385
|
+
}
|
|
386
|
+
res.write(`data: ${JSON.stringify(finalChunk)}\n\n`)
|
|
387
|
+
res.write('data: [DONE]\n\n')
|
|
388
|
+
res.end()
|
|
369
389
|
}
|
|
370
|
-
res.write(`data: ${JSON.stringify(finalChunk)}\n\n`)
|
|
371
|
-
res.write('data: [DONE]\n\n')
|
|
372
|
-
res.end()
|
|
373
390
|
}
|
|
374
391
|
} finally {
|
|
375
392
|
maintenanceLease.release()
|
|
@@ -381,29 +398,34 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
381
398
|
done = true
|
|
382
399
|
rejectInflight!(new Error(error))
|
|
383
400
|
inflightQueries.delete(dedupKey)
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
401
|
+
if (!clientDisconnected) {
|
|
402
|
+
const errChunk = {
|
|
403
|
+
id: completionId,
|
|
404
|
+
object: 'chat.completion.chunk',
|
|
405
|
+
created: timestamp,
|
|
406
|
+
model: responseModel,
|
|
407
|
+
choices: [{ index: 0, delta: { content: `Error: ${error}` }, finish_reason: 'stop' }],
|
|
408
|
+
}
|
|
409
|
+
res.write(`data: ${JSON.stringify(errChunk)}\n\n`)
|
|
410
|
+
res.write('data: [DONE]\n\n')
|
|
411
|
+
res.end()
|
|
390
412
|
}
|
|
391
|
-
res.write(`data: ${JSON.stringify(errChunk)}\n\n`)
|
|
392
|
-
res.write('data: [DONE]\n\n')
|
|
393
|
-
res.end()
|
|
394
413
|
}
|
|
395
414
|
} finally {
|
|
396
415
|
maintenanceLease.release()
|
|
397
416
|
}
|
|
398
417
|
},
|
|
399
418
|
onToolStatus: (status) => {
|
|
400
|
-
if (!done) {
|
|
419
|
+
if (!done && !clientDisconnected) {
|
|
401
420
|
// SSE comment — invisible to JSON parsers but keeps connection alive
|
|
402
421
|
res.write(`: ${status}\n\n`)
|
|
403
422
|
}
|
|
404
423
|
},
|
|
405
424
|
onStart: () => {},
|
|
406
|
-
}, resolvedModel, undefined, undefined, undefined, {
|
|
425
|
+
}, resolvedModel, undefined, undefined, undefined, {
|
|
426
|
+
lightweight: true,
|
|
427
|
+
abortSignal: providerAbort.signal,
|
|
428
|
+
})
|
|
407
429
|
// Persist session ID for multi-turn context on subsequent G2 queries
|
|
408
430
|
g2SessionId = returnedSid
|
|
409
431
|
} catch (err: any) {
|
|
@@ -412,13 +434,13 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
412
434
|
done = true
|
|
413
435
|
rejectInflight!(err)
|
|
414
436
|
inflightQueries.delete(dedupKey)
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
437
|
+
if (!clientDisconnected) {
|
|
438
|
+
res.write(`data: ${JSON.stringify({ error: { message: err.message } })}\n\n`)
|
|
439
|
+
res.write('data: [DONE]\n\n')
|
|
440
|
+
res.end()
|
|
441
|
+
}
|
|
418
442
|
}
|
|
419
443
|
}
|
|
420
|
-
|
|
421
|
-
req.on('close', () => { done = true })
|
|
422
444
|
return
|
|
423
445
|
}
|
|
424
446
|
|
|
@@ -479,11 +501,15 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
479
501
|
},
|
|
480
502
|
onToolStatus: () => {},
|
|
481
503
|
onStart: () => {},
|
|
482
|
-
}, resolvedModel, undefined, undefined, undefined, {
|
|
504
|
+
}, resolvedModel, undefined, undefined, undefined, {
|
|
505
|
+
lightweight: true,
|
|
506
|
+
abortSignal: providerAbort.signal,
|
|
507
|
+
})
|
|
483
508
|
.then(sid => { g2SessionId = sid })
|
|
484
509
|
.catch(fail)
|
|
485
510
|
})
|
|
486
511
|
|
|
512
|
+
if (clientDisconnected) return
|
|
487
513
|
res.json({
|
|
488
514
|
id: completionId,
|
|
489
515
|
object: 'chat.completion',
|
|
@@ -497,6 +523,7 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
497
523
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
498
524
|
})
|
|
499
525
|
} catch (err: any) {
|
|
526
|
+
if (clientDisconnected) return
|
|
500
527
|
res.status(500).json({
|
|
501
528
|
error: { message: err.message, type: 'server_error' },
|
|
502
529
|
})
|