@gotcos/glasses-server 6.15.3 → 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 +39 -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/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
|
@@ -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
|
})
|