@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.
@@ -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, execFileSync } from 'node:child_process'
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 listProcesses(): ProcessEntry[] {
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
- const output = execFileSync('ps', ['-axww', '-o', 'pid=,ppid=,command='], { encoding: 'utf8' })
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 = execFileSync(
184
- 'lsof',
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?.message ?? 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 executable = /(?:^|\s)(?:\S*\/)?whisper-server(?:\s|$)/.test(command)
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<{ text: string; words?: WhisperWord[] }> {
701
+ ): Promise<HighQualityTranscriptionResult> {
585
702
  if (!cliAvailable) {
586
703
  // Fall back to server (no beam search available via HTTP API)
587
- return transcribeLocal(audioBuffer, context)
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', '5', // Beam search width 5 (default disabled)
614
- '-bo', '5', // Best-of-5 candidates (default 2)
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
- return words ? { text: corrected, words } : { text: corrected }
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
  }
@@ -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 { isWhisperLocalAvailable, getWhisperHealth } from '../lib/whisper-local.js'
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
- const finalChunk = {
364
- id: completionId,
365
- object: 'chat.completion.chunk',
366
- created: timestamp,
367
- model: responseModel,
368
- choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
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
- const errChunk = {
385
- id: completionId,
386
- object: 'chat.completion.chunk',
387
- created: timestamp,
388
- model: responseModel,
389
- choices: [{ index: 0, delta: { content: `Error: ${error}` }, finish_reason: 'stop' }],
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, { lightweight: true })
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
- res.write(`data: ${JSON.stringify({ error: { message: err.message } })}\n\n`)
416
- res.write('data: [DONE]\n\n')
417
- res.end()
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, { lightweight: true })
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
  })