@gotcos/glasses-server 6.15.2 → 6.15.5
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/CHANGELOG.md +46 -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 +51 -21
- package/server/lib/claude-tool-access.ts +73 -7
- package/server/lib/codex-bridge.ts +60 -27
- package/server/lib/codex-run-ledger.ts +5 -11
- package/server/lib/launch-dir.ts +28 -2
- package/server/lib/maintenance-lifecycle.ts +40 -0
- package/server/lib/provider-process-lifecycle.ts +139 -0
- package/server/lib/provider-proof.ts +28 -10
- package/server/lib/transcribe-audio.ts +10 -2
- package/server/lib/whisper-local.ts +92 -17
- package/server/routes/health.ts +18 -0
- package/server/routes/openai-compat.ts +54 -27
- package/server/routes/prompt-drafts.ts +79 -7
- package/server/routes/provider-proof.ts +40 -2
|
@@ -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'
|
|
@@ -137,6 +137,10 @@ function getWhisperPrompt(): string {
|
|
|
137
137
|
// whisper-server runs on this port (started at server boot or by LaunchAgent)
|
|
138
138
|
const WHISPER_SERVER_PORT = 8178
|
|
139
139
|
const WHISPER_SERVER_URL = `http://127.0.0.1:${WHISPER_SERVER_PORT}`
|
|
140
|
+
const PROCESS_PROBE_TIMEOUT_MS = 2_000
|
|
141
|
+
const PROCESS_PROBE_MAX_BUFFER = 1024 * 1024
|
|
142
|
+
const PS_BIN = '/bin/ps'
|
|
143
|
+
const LSOF_BIN = existsSync('/usr/sbin/lsof') ? '/usr/sbin/lsof' : 'lsof'
|
|
140
144
|
|
|
141
145
|
// Track which backends are available
|
|
142
146
|
let cliAvailable = false
|
|
@@ -153,6 +157,10 @@ let serverStarting = false // Initial model load is not a circuit fai
|
|
|
153
157
|
let serverStartPromise: Promise<void> | null = null
|
|
154
158
|
let serverRestartPromise: Promise<WhisperRestartResult> | null = null
|
|
155
159
|
let serverHealthProbe: Promise<boolean> | null = null
|
|
160
|
+
type WhisperStartupState = 'not_started' | 'preflight' | 'loading' | 'ready' | 'unavailable' | 'failed' | 'stopped'
|
|
161
|
+
let serverStartupState: WhisperStartupState = 'not_started'
|
|
162
|
+
let serverLastAttemptAt: string | null = null
|
|
163
|
+
let serverLastError: string | null = null
|
|
156
164
|
|
|
157
165
|
interface ProcessEntry {
|
|
158
166
|
pid: number
|
|
@@ -167,10 +175,34 @@ export interface WhisperRestartResult {
|
|
|
167
175
|
|
|
168
176
|
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
|
|
169
177
|
|
|
170
|
-
function
|
|
178
|
+
function boundedError(value: unknown): string {
|
|
179
|
+
const message = value instanceof Error ? value.message : String(value)
|
|
180
|
+
return message.replace(/[\r\n\t]+/g, ' ').slice(0, 240)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function runProcessProbe(file: string, args: string[]): Promise<string> {
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
execFile(file, args, {
|
|
186
|
+
encoding: 'utf8',
|
|
187
|
+
timeout: PROCESS_PROBE_TIMEOUT_MS,
|
|
188
|
+
maxBuffer: PROCESS_PROBE_MAX_BUFFER,
|
|
189
|
+
killSignal: 'SIGKILL',
|
|
190
|
+
}, (error, stdout) => {
|
|
191
|
+
if (error) reject(error)
|
|
192
|
+
else resolve(String(stdout))
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function listProcesses(): Promise<ProcessEntry[]> {
|
|
171
198
|
// `command=` includes arguments, which lets us distinguish this COS-owned
|
|
172
199
|
// port/model signature from unrelated whisper-server instances.
|
|
173
|
-
|
|
200
|
+
let output: string
|
|
201
|
+
try {
|
|
202
|
+
output = await runProcessProbe(PS_BIN, ['-axww', '-o', 'pid=,ppid=,command='])
|
|
203
|
+
} catch (error) {
|
|
204
|
+
throw new Error(`unable to inspect process table: ${boundedError(error)}`)
|
|
205
|
+
}
|
|
174
206
|
return output.split('\n').flatMap(line => {
|
|
175
207
|
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/)
|
|
176
208
|
if (!match) return []
|
|
@@ -178,24 +210,25 @@ function listProcesses(): ProcessEntry[] {
|
|
|
178
210
|
})
|
|
179
211
|
}
|
|
180
212
|
|
|
181
|
-
function listeningPids(): number[] {
|
|
213
|
+
async function listeningPids(): Promise<number[]> {
|
|
182
214
|
try {
|
|
183
|
-
const output =
|
|
184
|
-
|
|
215
|
+
const output = await runProcessProbe(
|
|
216
|
+
LSOF_BIN,
|
|
185
217
|
['-nP', `-iTCP:${WHISPER_SERVER_PORT}`, '-sTCP:LISTEN', '-t'],
|
|
186
|
-
{ encoding: 'utf8' },
|
|
187
218
|
)
|
|
188
219
|
return output.split(/\s+/).map(Number).filter(pid => Number.isInteger(pid) && pid > 0)
|
|
189
220
|
} catch (err: any) {
|
|
190
221
|
// lsof uses exit 1 for "no matches". Anything else means we could not
|
|
191
222
|
// 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
|
|
223
|
+
if (err?.code === 1 || err?.status === 1) return []
|
|
224
|
+
throw new Error(`unable to inspect whisper-server port ${WHISPER_SERVER_PORT}: ${boundedError(err)}`)
|
|
194
225
|
}
|
|
195
226
|
}
|
|
196
227
|
|
|
197
228
|
function isCosWhisperServerCommand(command: string): boolean {
|
|
198
|
-
const
|
|
229
|
+
const firstToken = command.trim().match(/^(?:"([^"]+)"|'([^']+)'|(\S+))/)
|
|
230
|
+
const executablePath = firstToken?.[1] ?? firstToken?.[2] ?? firstToken?.[3] ?? ''
|
|
231
|
+
const executable = basename(executablePath) === 'whisper-server'
|
|
199
232
|
const configuredPort = new RegExp(`(?:^|\\s)--port(?:=|\\s+)${WHISPER_SERVER_PORT}(?:\\s|$)`).test(command)
|
|
200
233
|
return executable && configuredPort && command.includes(MODEL_PATH)
|
|
201
234
|
}
|
|
@@ -249,7 +282,7 @@ async function killAndReapWhisperProcesses(): Promise<void> {
|
|
|
249
282
|
serverAvailable = false
|
|
250
283
|
|
|
251
284
|
for (let round = 0; round < 3; round++) {
|
|
252
|
-
const processes = listProcesses()
|
|
285
|
+
const processes = await listProcesses()
|
|
253
286
|
const ownedPids = [...ownedServerChildren]
|
|
254
287
|
.map(child => child.pid)
|
|
255
288
|
.filter((pid): pid is number => typeof pid === 'number')
|
|
@@ -288,7 +321,7 @@ async function killAndReapWhisperProcesses(): Promise<void> {
|
|
|
288
321
|
await sleep(50)
|
|
289
322
|
}
|
|
290
323
|
|
|
291
|
-
const remaining = listProcesses().filter(entry => isCosWhisperServerCommand(entry.command))
|
|
324
|
+
const remaining = (await listProcesses()).filter(entry => isCosWhisperServerCommand(entry.command))
|
|
292
325
|
if (remaining.length > 0) {
|
|
293
326
|
throw new Error(`stale whisper-server process(es) remain: ${remaining.map(entry => entry.pid).join(', ')}`)
|
|
294
327
|
}
|
|
@@ -298,11 +331,11 @@ async function killAndReapWhisperProcesses(): Promise<void> {
|
|
|
298
331
|
|
|
299
332
|
async function proveWhisperPortClear(): Promise<void> {
|
|
300
333
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
301
|
-
const pids = listeningPids()
|
|
334
|
+
const pids = await listeningPids()
|
|
302
335
|
if (pids.length === 0) return
|
|
303
336
|
if (attempt < 19) await sleep(250)
|
|
304
337
|
}
|
|
305
|
-
const pids = listeningPids()
|
|
338
|
+
const pids = await listeningPids()
|
|
306
339
|
throw new Error(
|
|
307
340
|
`whisper-server port ${WHISPER_SERVER_PORT} remains occupied${pids.length ? ` by PID(s) ${pids.join(', ')}` : ''}`,
|
|
308
341
|
)
|
|
@@ -334,10 +367,17 @@ export async function startWhisperServer(): Promise<void> {
|
|
|
334
367
|
if (serverAvailable && serverProcess) return
|
|
335
368
|
|
|
336
369
|
serverStarting = true
|
|
370
|
+
serverStartupState = 'preflight'
|
|
371
|
+
serverLastAttemptAt = new Date().toISOString()
|
|
372
|
+
serverLastError = null
|
|
337
373
|
const operation = startWhisperServerAttempt()
|
|
338
374
|
serverStartPromise = operation
|
|
339
375
|
try {
|
|
340
376
|
await operation
|
|
377
|
+
} catch (error) {
|
|
378
|
+
serverStartupState = 'failed'
|
|
379
|
+
serverLastError = boundedError(error)
|
|
380
|
+
throw error
|
|
341
381
|
} finally {
|
|
342
382
|
if (serverStartPromise === operation) serverStartPromise = null
|
|
343
383
|
serverStarting = false
|
|
@@ -346,6 +386,8 @@ export async function startWhisperServer(): Promise<void> {
|
|
|
346
386
|
|
|
347
387
|
async function startWhisperServerAttempt(preflightCompleted = false): Promise<void> {
|
|
348
388
|
if (!existsSync(WHISPER_SERVER) || !existsSync(MODEL_PATH)) {
|
|
389
|
+
serverStartupState = 'unavailable'
|
|
390
|
+
serverLastError = 'whisper-server or model not found'
|
|
349
391
|
console.log('[whisper-local] whisper-server or model not found — using CLI fallback')
|
|
350
392
|
return
|
|
351
393
|
}
|
|
@@ -353,9 +395,12 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
|
|
|
353
395
|
// The API process is the sole Whisper owner. Never adopt an untracked daemon:
|
|
354
396
|
// reap stale trees, then prove the fixed local port is free before spawning.
|
|
355
397
|
if (!preflightCompleted) {
|
|
398
|
+
console.log('[whisper-local] preflight: inspecting owned processes and port 8178')
|
|
399
|
+
serverStartupState = 'preflight'
|
|
356
400
|
await killAndReapWhisperProcesses()
|
|
357
401
|
await proveWhisperPortClear()
|
|
358
402
|
}
|
|
403
|
+
serverStartupState = 'loading'
|
|
359
404
|
|
|
360
405
|
// Assemble startup args. VAD only attaches if the ggml model is actually on
|
|
361
406
|
// disk — missing-file is logged, not fatal (server still boots without VAD).
|
|
@@ -402,6 +447,8 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
|
|
|
402
447
|
if (serverProcess !== child) return
|
|
403
448
|
serverAvailable = false
|
|
404
449
|
serverProcess = null
|
|
450
|
+
serverStartupState = 'failed'
|
|
451
|
+
serverLastError = `whisper-server exited${code == null ? '' : ` with code ${code}`}`
|
|
405
452
|
if (code !== null && code !== 0) {
|
|
406
453
|
console.warn(`[whisper-local] whisper-server exited with code ${code}`)
|
|
407
454
|
}
|
|
@@ -422,6 +469,8 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
|
|
|
422
469
|
const res = await fetch(`${WHISPER_SERVER_URL}/health`, { signal: AbortSignal.timeout(1000) })
|
|
423
470
|
if (res.ok) {
|
|
424
471
|
serverAvailable = true
|
|
472
|
+
serverStartupState = 'ready'
|
|
473
|
+
serverLastError = null
|
|
425
474
|
const loadTime = ((Date.now() - startTime) / 1000).toFixed(1)
|
|
426
475
|
console.log(`[whisper-local] whisper-server ready on port ${WHISPER_SERVER_PORT} (loaded in ${loadTime}s)`)
|
|
427
476
|
return
|
|
@@ -444,6 +493,8 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
|
|
|
444
493
|
console.error(`[whisper-local] ${failure} — reaping child and keeping local backend unavailable`)
|
|
445
494
|
await killAndReapWhisperProcesses()
|
|
446
495
|
await proveWhisperPortClear()
|
|
496
|
+
serverStartupState = 'failed'
|
|
497
|
+
serverLastError = failure
|
|
447
498
|
throw new Error(failure)
|
|
448
499
|
}
|
|
449
500
|
|
|
@@ -461,6 +512,7 @@ export function stopWhisperServer(): void {
|
|
|
461
512
|
try { proc.kill('SIGKILL') } catch { /* already exited */ }
|
|
462
513
|
}
|
|
463
514
|
ownedHqChildren.clear()
|
|
515
|
+
serverStartupState = 'stopped'
|
|
464
516
|
}
|
|
465
517
|
|
|
466
518
|
export function isWhisperLocalAvailable(): boolean {
|
|
@@ -480,17 +532,25 @@ export function getWhisperBackend(): 'server' | 'cli' | 'none' {
|
|
|
480
532
|
/** Detailed health status for diagnostics (/api/health) */
|
|
481
533
|
export function getWhisperHealth(): {
|
|
482
534
|
server: boolean
|
|
535
|
+
serverConfigured: boolean
|
|
483
536
|
cli: boolean
|
|
484
537
|
consecutiveFailures: number
|
|
485
538
|
restarting: boolean
|
|
486
539
|
circuitOpen: boolean
|
|
540
|
+
startupState: WhisperStartupState
|
|
541
|
+
lastAttemptAt: string | null
|
|
542
|
+
lastError: string | null
|
|
487
543
|
} {
|
|
488
544
|
return {
|
|
489
545
|
server: serverAvailable,
|
|
546
|
+
serverConfigured: existsSync(WHISPER_SERVER) && existsSync(MODEL_PATH),
|
|
490
547
|
cli: cliAvailable,
|
|
491
548
|
consecutiveFailures: serverConsecutiveFailures,
|
|
492
549
|
restarting: serverRestarting || serverStarting,
|
|
493
550
|
circuitOpen: serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD,
|
|
551
|
+
startupState: serverStartupState,
|
|
552
|
+
lastAttemptAt: serverLastAttemptAt,
|
|
553
|
+
lastError: serverLastError,
|
|
494
554
|
}
|
|
495
555
|
}
|
|
496
556
|
|
|
@@ -604,14 +664,22 @@ export async function transcribeHighQuality(
|
|
|
604
664
|
|
|
605
665
|
const text = await new Promise<string>((resolve, reject) => {
|
|
606
666
|
const isolateBatchFromLiveMetal = opts.priority === 'batch'
|
|
667
|
+
// Interactive HQ: narrower beam (default 2) for latency. Meeting batch keeps 5.
|
|
668
|
+
// Override: COS_HQ_BEAM_INTERACTIVE=N
|
|
669
|
+
const interactiveBeamRaw = Number.parseInt(process.env.COS_HQ_BEAM_INTERACTIVE || '2', 10)
|
|
670
|
+
const interactiveBeam = Number.isFinite(interactiveBeamRaw) && interactiveBeamRaw >= 1
|
|
671
|
+
? Math.min(interactiveBeamRaw, 5)
|
|
672
|
+
: 2
|
|
673
|
+
const beam = isolateBatchFromLiveMetal ? 5 : interactiveBeam
|
|
674
|
+
const bestOf = beam
|
|
607
675
|
const args = [
|
|
608
676
|
'-m', modelPath,
|
|
609
677
|
'-f', tmpWav,
|
|
610
678
|
'-t', isolateBatchFromLiveMetal ? '8' : '16',
|
|
611
679
|
'-l', 'en',
|
|
612
680
|
...(isolateBatchFromLiveMetal ? ['-ng'] : ['-fa']),
|
|
613
|
-
'-bs',
|
|
614
|
-
'-bo',
|
|
681
|
+
'-bs', String(beam),
|
|
682
|
+
'-bo', String(bestOf),
|
|
615
683
|
'--no-timestamps',
|
|
616
684
|
'-np',
|
|
617
685
|
'--prompt', buildPrompt(context),
|
|
@@ -966,6 +1034,9 @@ export async function restartWhisperServer(): Promise<WhisperRestartResult> {
|
|
|
966
1034
|
if (serverRestartPromise) return serverRestartPromise
|
|
967
1035
|
|
|
968
1036
|
serverRestarting = true
|
|
1037
|
+
serverStartupState = 'preflight'
|
|
1038
|
+
serverLastAttemptAt = new Date().toISOString()
|
|
1039
|
+
serverLastError = null
|
|
969
1040
|
const priorStart = serverStartPromise
|
|
970
1041
|
const operation = (async (): Promise<WhisperRestartResult> => {
|
|
971
1042
|
try {
|
|
@@ -984,6 +1055,8 @@ export async function restartWhisperServer(): Promise<WhisperRestartResult> {
|
|
|
984
1055
|
throw new Error('whisper-server did not become healthy')
|
|
985
1056
|
}
|
|
986
1057
|
serverConsecutiveFailures = 0
|
|
1058
|
+
serverStartupState = 'ready'
|
|
1059
|
+
serverLastError = null
|
|
987
1060
|
console.log('[whisper-local] Server restarted successfully — circuit breaker CLOSED')
|
|
988
1061
|
return { status: 'recovered' }
|
|
989
1062
|
} catch (err: any) {
|
|
@@ -992,6 +1065,8 @@ export async function restartWhisperServer(): Promise<WhisperRestartResult> {
|
|
|
992
1065
|
// breaker cycle, and the next three failed calls may request one new cycle.
|
|
993
1066
|
serverConsecutiveFailures = 0
|
|
994
1067
|
const message = err?.message ?? String(err)
|
|
1068
|
+
serverStartupState = 'failed'
|
|
1069
|
+
serverLastError = boundedError(message)
|
|
995
1070
|
console.error(`[whisper-local] Server restart error: ${message} — will retry after next 3 failures`)
|
|
996
1071
|
return { status: 'failed', error: message }
|
|
997
1072
|
}
|
package/server/routes/health.ts
CHANGED
|
@@ -180,6 +180,23 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
180
180
|
// Whisper-server health + cloud budget — exposed so glasses + dashboards can
|
|
181
181
|
// see whether we're at risk of falling to cloud and how much budget remains.
|
|
182
182
|
const whisper_health = getWhisperHealth()
|
|
183
|
+
const whisperReadiness = !whisper_health.serverConfigured
|
|
184
|
+
? 'not_configured'
|
|
185
|
+
: whisper_health.server
|
|
186
|
+
? 'ready'
|
|
187
|
+
: whisper_health.startupState === 'preflight' || whisper_health.startupState === 'loading'
|
|
188
|
+
? 'starting'
|
|
189
|
+
: 'degraded'
|
|
190
|
+
const readiness = {
|
|
191
|
+
// /api/health remains a liveness endpoint and intentionally returns HTTP
|
|
192
|
+
// 200 while the server can answer. This separate field prevents an HTTP-
|
|
193
|
+
// green response from hiding a configured local subsystem failure.
|
|
194
|
+
status: whisperReadiness === 'degraded' ? 'degraded' : 'ready',
|
|
195
|
+
admissions: maintenance.admissionsOpen ? 'open' : 'maintenance',
|
|
196
|
+
whisper: whisperReadiness,
|
|
197
|
+
whisperError: whisper_health.lastError,
|
|
198
|
+
localTts: tts_local.ready ? 'ready' : 'unavailable',
|
|
199
|
+
}
|
|
183
200
|
const openai_whisper_budget = getOpenAIWhisperBudgetState()
|
|
184
201
|
const codex_models = getCodexModelCatalogSnapshot()
|
|
185
202
|
res.json({
|
|
@@ -190,6 +207,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
190
207
|
generation_id: getServerGenerationId(),
|
|
191
208
|
features,
|
|
192
209
|
voice,
|
|
210
|
+
readiness,
|
|
193
211
|
whisper_health,
|
|
194
212
|
openai_whisper_budget,
|
|
195
213
|
tts_local,
|
|
@@ -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
|
})
|
|
@@ -52,9 +52,18 @@ export const promptDraftsRouter = Router()
|
|
|
52
52
|
const MAX_CHUNK_BYTES = 25 * 1024 * 1024
|
|
53
53
|
const MAX_DRAFT_BYTES = 256 * 1024 * 1024
|
|
54
54
|
const MAX_CHUNKS = 600
|
|
55
|
+
/** Purpose-scoped keys (legacy). Prefer modeQualityJobs for HQ warm↔finalize dedupe. */
|
|
55
56
|
const chunkTranscriptJobs = new Map<string, Promise<string>>()
|
|
57
|
+
/** Shared decode per draft/chunk/mode/hash — warm:hq and final:hq await the same promise. */
|
|
58
|
+
const modeQualityJobs = new Map<string, Promise<string>>()
|
|
56
59
|
const finalizeJobs = new Map<string, Promise<any>>()
|
|
57
60
|
let warmTail: Promise<void> = Promise.resolve()
|
|
61
|
+
let hqWarmTail: Promise<void> = Promise.resolve()
|
|
62
|
+
|
|
63
|
+
/** Speculative HQ warm while speaking. Set COS_HQ_SPECULATIVE_WARM=0 to restore Fast-only warm. */
|
|
64
|
+
function speculativeHqWarmEnabled(): boolean {
|
|
65
|
+
return !['0', 'false', 'off'].includes((process.env.COS_HQ_SPECULATIVE_WARM ?? '1').toLowerCase())
|
|
66
|
+
}
|
|
58
67
|
|
|
59
68
|
const autoCleanBreaker = createBreaker({ label: 'dictation-autoclean' })
|
|
60
69
|
const autoCleanCountFile = () => process.env.COS_DICTATION_AUTOCLEAN_COUNT_FILE || dataPath('.dictation_autoclean_count.json')
|
|
@@ -164,14 +173,49 @@ async function sendDraftError(res: Response, draftId: string, err: any): Promise
|
|
|
164
173
|
res.status(err.status ?? 500).json({ error: err.message })
|
|
165
174
|
}
|
|
166
175
|
|
|
176
|
+
function modeQualityKey(draftId: string, chunkIndex: number, mode: 'hq' | 'fast', hash: string): string {
|
|
177
|
+
return `${draftId}:${chunkIndex}:${mode}:${hash}`
|
|
178
|
+
}
|
|
179
|
+
|
|
167
180
|
async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffer, mode: 'hq' | 'fast', purpose: 'warm' | 'final'): Promise<string> {
|
|
168
181
|
const hash = audioHash(audio)
|
|
169
|
-
const
|
|
170
|
-
const
|
|
182
|
+
const purposeKey = `${draftId}:${chunkIndex}:${purpose}:${mode}:${hash}`
|
|
183
|
+
const sharedKey = modeQualityKey(draftId, chunkIndex, mode, hash)
|
|
184
|
+
|
|
185
|
+
// HQ warm and HQ finalize must share one decode (plan step 8a).
|
|
186
|
+
const existingShared = modeQualityJobs.get(sharedKey)
|
|
187
|
+
if (existingShared) {
|
|
188
|
+
try {
|
|
189
|
+
const text = await existingShared
|
|
190
|
+
if (purpose === 'final' && text) {
|
|
191
|
+
const current = loadPromptDraftMeta(draftId)
|
|
192
|
+
const warm = current?.warmTranscripts?.[String(chunkIndex)]
|
|
193
|
+
const cachedFinal = current?.finalTranscripts?.[String(chunkIndex)]
|
|
194
|
+
if (!cachedFinal || cachedFinal.hash !== hash) {
|
|
195
|
+
await markPromptDraftChunkTranscript(draftId, chunkIndex, {
|
|
196
|
+
text,
|
|
197
|
+
hash,
|
|
198
|
+
requestedMode: warm?.requestedMode ?? mode,
|
|
199
|
+
actualQuality: warm?.actualQuality ?? (mode === 'hq' ? 'hq' : 'fast'),
|
|
200
|
+
backend: warm?.backend ?? 'shared-inflight',
|
|
201
|
+
degraded: warm?.degraded ?? false,
|
|
202
|
+
}, 'final')
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return text
|
|
206
|
+
} catch {
|
|
207
|
+
// Shared warm failed under local-only; fall through so finalize can retry with automatic.
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const existing = chunkTranscriptJobs.get(purposeKey)
|
|
171
212
|
if (existing) return existing
|
|
213
|
+
|
|
172
214
|
const job = (async () => {
|
|
173
215
|
try {
|
|
174
|
-
|
|
216
|
+
// Speculative warm is always local-only. Finalize may use automatic cloud fallback.
|
|
217
|
+
const policy = purpose === 'warm' ? 'local-only' as const : 'automatic' as const
|
|
218
|
+
const result = await transcribeAudioBuffer(audio, { mode, policy })
|
|
175
219
|
if (!isCurrentChunk(draftId, chunkIndex, audio)) return ''
|
|
176
220
|
const text = sanitizeTranscript(draftId, result.text)
|
|
177
221
|
const record: PromptDraftTranscriptRecord = {
|
|
@@ -179,7 +223,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
179
223
|
backend: result.backend, degraded: result.degraded,
|
|
180
224
|
}
|
|
181
225
|
await markPromptDraftChunkTranscript(draftId, chunkIndex, record, purpose)
|
|
182
|
-
console.log(`[prompt-draft] chunk ${draftId}/${chunkIndex}: ${result.elapsedMs.toFixed(1)}ms | ${result.backend} | ${text.length} chars`)
|
|
226
|
+
console.log(`[prompt-draft] chunk ${draftId}/${chunkIndex}: ${result.elapsedMs.toFixed(1)}ms | ${result.backend} | ${purpose}/${mode} | ${text.length} chars`)
|
|
183
227
|
return text
|
|
184
228
|
} catch (err) {
|
|
185
229
|
if (err instanceof NoSpeechDetectedError) {
|
|
@@ -190,10 +234,12 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
190
234
|
}
|
|
191
235
|
throw err
|
|
192
236
|
} finally {
|
|
193
|
-
chunkTranscriptJobs.delete(
|
|
237
|
+
chunkTranscriptJobs.delete(purposeKey)
|
|
238
|
+
modeQualityJobs.delete(sharedKey)
|
|
194
239
|
}
|
|
195
240
|
})()
|
|
196
|
-
chunkTranscriptJobs.set(
|
|
241
|
+
chunkTranscriptJobs.set(purposeKey, job)
|
|
242
|
+
modeQualityJobs.set(sharedKey, job)
|
|
197
243
|
return job
|
|
198
244
|
}
|
|
199
245
|
|
|
@@ -203,9 +249,17 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
203
249
|
const texts: string[] = []
|
|
204
250
|
for (const chunk of readPromptDraftChunks(draftId)) {
|
|
205
251
|
try {
|
|
252
|
+
const hash = audioHash(chunk.audioBuffer)
|
|
253
|
+
// Await in-flight HQ warm before deciding cache miss (plan step 8b belt).
|
|
254
|
+
if (mode === 'hq') {
|
|
255
|
+
const inflight = modeQualityJobs.get(modeQualityKey(draftId, chunk.chunkIndex, 'hq', hash))
|
|
256
|
+
if (inflight) {
|
|
257
|
+
try { await inflight } catch { /* finalize may retry with automatic below */ }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
206
260
|
const current = loadPromptDraftMeta(draftId)
|
|
207
261
|
const cached = current?.finalTranscripts?.[String(chunk.chunkIndex)] ?? current?.warmTranscripts?.[String(chunk.chunkIndex)]
|
|
208
|
-
const reusable = Boolean(cached && cached.hash ===
|
|
262
|
+
const reusable = Boolean(cached && cached.hash === hash && (mode === 'fast' ? cached.actualQuality === 'fast' : cached.actualQuality === 'hq'))
|
|
209
263
|
const raw = reusable ? cached!.text : await transcribeChunk(draftId, chunk.chunkIndex, chunk.audioBuffer, mode, 'final')
|
|
210
264
|
const text = sanitizeTranscript(draftId, raw, !reusable)
|
|
211
265
|
if (text.trim()) texts.push(text.trim())
|
|
@@ -265,10 +319,13 @@ promptDraftsRouter.post('/prompt-drafts/:draftId/chunks', async (req, res) => {
|
|
|
265
319
|
const nextTotal = Object.values(before.chunkBytes).reduce((sum, bytes) => sum + bytes, 0) - existingBytes + audio.length
|
|
266
320
|
if (nextTotal > MAX_DRAFT_BYTES) return res.status(413).json({ error: 'prompt draft too large' })
|
|
267
321
|
const meta = await savePromptDraftChunk(req.params.draftId, chunkIndex, audio)
|
|
322
|
+
const requestedMode = routeMode(req)
|
|
268
323
|
const warmLease = acquireMaintenanceWork('prompt_draft_warm', {
|
|
269
324
|
allowDuringDrain: true,
|
|
270
325
|
phase: 'queued',
|
|
271
326
|
})
|
|
327
|
+
// Fast warm feeds the live HUD. Speculative HQ (when Settings HQ / default)
|
|
328
|
+
// overwrites warmTranscripts with actualQuality=hq for near-instant finalize.
|
|
272
329
|
warmTail = warmTail.then(async () => {
|
|
273
330
|
warmLease.setPhase('active')
|
|
274
331
|
const text = await transcribeChunk(req.params.draftId, chunkIndex, audio, 'fast', 'warm')
|
|
@@ -283,6 +340,21 @@ promptDraftsRouter.post('/prompt-drafts/:draftId/chunks', async (req, res) => {
|
|
|
283
340
|
}).catch(err => {
|
|
284
341
|
console.warn(`[prompt-draft] warm transcription failed ${req.params.draftId}/${chunkIndex}: ${err.message}`)
|
|
285
342
|
}).finally(() => warmLease.release())
|
|
343
|
+
|
|
344
|
+
if (requestedMode === 'hq' && speculativeHqWarmEnabled()) {
|
|
345
|
+
const hqLease = acquireMaintenanceWork('prompt_draft_warm', {
|
|
346
|
+
allowDuringDrain: true,
|
|
347
|
+
phase: 'queued',
|
|
348
|
+
})
|
|
349
|
+
hqWarmTail = hqWarmTail.then(async () => {
|
|
350
|
+
hqLease.setPhase('active')
|
|
351
|
+
// Cache only — never emitDisplay HQ (avoids HUD flicker). local-only via purpose=warm.
|
|
352
|
+
await transcribeChunk(req.params.draftId, chunkIndex, audio, 'hq', 'warm')
|
|
353
|
+
}).catch(err => {
|
|
354
|
+
console.warn(`[prompt-draft] speculative HQ warm failed ${req.params.draftId}/${chunkIndex}: ${err.message}`)
|
|
355
|
+
}).finally(() => hqLease.release())
|
|
356
|
+
}
|
|
357
|
+
|
|
286
358
|
res.json({ draftId: meta.draftId, chunkIndex, acked: true, receivedChunkIndexes: meta.receivedChunkIndexes, chunkBytes: meta.chunkBytes[String(chunkIndex)] ?? audio.length, transcriptPending: true, expiresAt: meta.expiresAt })
|
|
287
359
|
} catch (err: any) {
|
|
288
360
|
if (err instanceof MaintenanceLifecycleError) {
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { Router } from 'express'
|
|
2
2
|
import { runProviderProof, type ProofProvider } from '../lib/provider-proof.js'
|
|
3
|
+
import {
|
|
4
|
+
acquireMaintenanceWork,
|
|
5
|
+
maintenanceErrorPayload,
|
|
6
|
+
maintenanceOperationCredentialsValid,
|
|
7
|
+
MaintenanceLifecycleError,
|
|
8
|
+
} from '../lib/maintenance-lifecycle.js'
|
|
3
9
|
|
|
4
10
|
export const providerProofRouter = Router()
|
|
5
11
|
|
|
@@ -14,6 +20,38 @@ providerProofRouter.post('/diagnostics/provider-proof', async (req, res) => {
|
|
|
14
20
|
if (provider !== 'claude' && provider !== 'codex') {
|
|
15
21
|
return res.status(400).json({ error: 'provider must be claude or codex' })
|
|
16
22
|
}
|
|
17
|
-
const
|
|
18
|
-
|
|
23
|
+
const controllerProof = maintenanceOperationCredentialsValid({
|
|
24
|
+
leaseId: typeof req.headers['x-cos-maintenance-lease'] === 'string'
|
|
25
|
+
? req.headers['x-cos-maintenance-lease'] : undefined,
|
|
26
|
+
operationId: typeof req.headers['x-cos-maintenance-operation'] === 'string'
|
|
27
|
+
? req.headers['x-cos-maintenance-operation'] : undefined,
|
|
28
|
+
nonce: typeof req.headers['x-cos-maintenance-nonce'] === 'string'
|
|
29
|
+
? req.headers['x-cos-maintenance-nonce'] : undefined,
|
|
30
|
+
})
|
|
31
|
+
let lease
|
|
32
|
+
try {
|
|
33
|
+
lease = acquireMaintenanceWork('api_mutation', { allowDuringDrain: controllerProof })
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error instanceof MaintenanceLifecycleError) {
|
|
36
|
+
if (error.retryAfterSeconds != null) res.setHeader('Retry-After', String(error.retryAfterSeconds))
|
|
37
|
+
return res.status(error.status).json(maintenanceErrorPayload(error))
|
|
38
|
+
}
|
|
39
|
+
return res.status(500).json({ error: 'maintenance_internal_error', retryable: false })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const abort = new AbortController()
|
|
43
|
+
let responseFinished = false
|
|
44
|
+
const cancel = () => { if (!responseFinished) abort.abort(new Error('Control proof client disconnected')) }
|
|
45
|
+
req.once('aborted', cancel)
|
|
46
|
+
res.once('finish', () => { responseFinished = true })
|
|
47
|
+
res.once('close', cancel)
|
|
48
|
+
try {
|
|
49
|
+
const result = await runProviderProof(provider as ProofProvider, abort.signal)
|
|
50
|
+
if (abort.signal.aborted) return
|
|
51
|
+
return res.status(result.ok ? 200 : 503).json(result)
|
|
52
|
+
} finally {
|
|
53
|
+
req.removeListener('aborted', cancel)
|
|
54
|
+
res.removeListener('close', cancel)
|
|
55
|
+
lease.release()
|
|
56
|
+
}
|
|
19
57
|
})
|