@sentropic/llm-mesh 0.29.0 → 0.31.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.
Files changed (86) hide show
  1. package/README.md +21 -0
  2. package/dist/decision/adapter.d.ts.map +1 -1
  3. package/dist/decision/adapter.js +2 -1
  4. package/dist/decision/adapter.js.map +1 -1
  5. package/dist/decision/admission.d.ts +2 -1
  6. package/dist/decision/admission.d.ts.map +1 -1
  7. package/dist/decision/admission.js +5 -1
  8. package/dist/decision/admission.js.map +1 -1
  9. package/dist/decision/assessment-controls.d.ts +6 -0
  10. package/dist/decision/assessment-controls.d.ts.map +1 -0
  11. package/dist/decision/assessment-controls.js +45 -0
  12. package/dist/decision/assessment-controls.js.map +1 -0
  13. package/dist/decision/assessment-recording.d.ts +23 -0
  14. package/dist/decision/assessment-recording.d.ts.map +1 -0
  15. package/dist/decision/assessment-recording.js +73 -0
  16. package/dist/decision/assessment-recording.js.map +1 -0
  17. package/dist/decision/assessment-statistics.d.ts +14 -0
  18. package/dist/decision/assessment-statistics.d.ts.map +1 -0
  19. package/dist/decision/assessment-statistics.js +88 -0
  20. package/dist/decision/assessment-statistics.js.map +1 -0
  21. package/dist/decision/assessment-types.d.ts +123 -0
  22. package/dist/decision/assessment-types.d.ts.map +1 -0
  23. package/dist/decision/assessment-types.js +2 -0
  24. package/dist/decision/assessment-types.js.map +1 -0
  25. package/dist/decision/assessment-validation.d.ts +7 -0
  26. package/dist/decision/assessment-validation.d.ts.map +1 -0
  27. package/dist/decision/assessment-validation.js +124 -0
  28. package/dist/decision/assessment-validation.js.map +1 -0
  29. package/dist/decision/assessment.d.ts +6 -0
  30. package/dist/decision/assessment.d.ts.map +1 -0
  31. package/dist/decision/assessment.js +157 -0
  32. package/dist/decision/assessment.js.map +1 -0
  33. package/dist/decision/descriptor-validation.d.ts.map +1 -1
  34. package/dist/decision/descriptor-validation.js +4 -2
  35. package/dist/decision/descriptor-validation.js.map +1 -1
  36. package/dist/decision/descriptor.d.ts +2 -0
  37. package/dist/decision/descriptor.d.ts.map +1 -1
  38. package/dist/decision/descriptor.js.map +1 -1
  39. package/dist/decision/dispatch.d.ts.map +1 -1
  40. package/dist/decision/dispatch.js +2 -1
  41. package/dist/decision/dispatch.js.map +1 -1
  42. package/dist/decision/host-options.d.ts +5 -0
  43. package/dist/decision/host-options.d.ts.map +1 -1
  44. package/dist/decision/host.d.ts +5 -0
  45. package/dist/decision/host.d.ts.map +1 -1
  46. package/dist/decision/host.js +3 -0
  47. package/dist/decision/host.js.map +1 -1
  48. package/dist/decision/index.d.ts +2 -0
  49. package/dist/decision/index.d.ts.map +1 -1
  50. package/dist/decision/jev-response.js +1 -1
  51. package/dist/decision/jev-response.js.map +1 -1
  52. package/dist/decision/openai-adapter.d.ts.map +1 -1
  53. package/dist/decision/openai-adapter.js +2 -1
  54. package/dist/decision/openai-adapter.js.map +1 -1
  55. package/dist/decision/openai-codec.js +1 -1
  56. package/dist/decision/openai-codec.js.map +1 -1
  57. package/dist/decision/policy.d.ts.map +1 -1
  58. package/dist/decision/policy.js +2 -1
  59. package/dist/decision/policy.js.map +1 -1
  60. package/dist/decision/pricing.d.ts +1 -0
  61. package/dist/decision/pricing.d.ts.map +1 -1
  62. package/dist/decision/pricing.js +25 -10
  63. package/dist/decision/pricing.js.map +1 -1
  64. package/dist/decision/qualification-scope.d.ts +22 -0
  65. package/dist/decision/qualification-scope.d.ts.map +1 -0
  66. package/dist/decision/qualification-scope.js +36 -0
  67. package/dist/decision/qualification-scope.js.map +1 -0
  68. package/dist/decision/qualification.d.ts +34 -0
  69. package/dist/decision/qualification.d.ts.map +1 -0
  70. package/dist/decision/qualification.js +74 -0
  71. package/dist/decision/qualification.js.map +1 -0
  72. package/dist/decision/receipt.d.ts +9 -3
  73. package/dist/decision/receipt.d.ts.map +1 -1
  74. package/dist/decision/receipt.js.map +1 -1
  75. package/dist/decision/service.d.ts.map +1 -1
  76. package/dist/decision/service.js +19 -3
  77. package/dist/decision/service.js.map +1 -1
  78. package/dist/speech/index.d.ts +1 -0
  79. package/dist/speech/index.d.ts.map +1 -1
  80. package/dist/speech/index.js +1 -0
  81. package/dist/speech/index.js.map +1 -1
  82. package/dist/speech/openai-transcription.d.ts +173 -0
  83. package/dist/speech/openai-transcription.d.ts.map +1 -0
  84. package/dist/speech/openai-transcription.js +659 -0
  85. package/dist/speech/openai-transcription.js.map +1 -0
  86. package/package.json +1 -1
@@ -0,0 +1,659 @@
1
+ import { isValidSpeechAudioFrame } from './contracts.js';
2
+ export const OPENAI_TRANSCRIPTION_PROVIDER_ID = 'openai';
3
+ export const OPENAI_TRANSCRIPTION_ADAPTER_ID = 'openai-transcription';
4
+ export const OPENAI_TRANSCRIPTION_ADAPTER_REVISION = '1';
5
+ export const OPENAI_TRANSCRIPTION_PLACEMENT = 'cloud-relay';
6
+ export const OPENAI_TRANSCRIPTION_WS_URL = 'wss://api.openai.com/v1/realtime?intent=transcription';
7
+ export const openAiTranscriptionModels = ['gpt-live-transcribe', 'gpt-realtime-whisper'];
8
+ export const openAiTranscriptionDelays = ['minimal', 'low', 'medium', 'high', 'xhigh'];
9
+ const TRANSCRIPTION_FORMAT = { codec: 'pcm16-le', sampleRateHz: 24000, channels: 1 };
10
+ export const openAiTranscriptionIdentity = (config) => ({
11
+ providerId: OPENAI_TRANSCRIPTION_PROVIDER_ID,
12
+ modelId: config.modelId,
13
+ modelRevision: config.modelRevision,
14
+ adapterId: OPENAI_TRANSCRIPTION_ADAPTER_ID,
15
+ adapterRevision: OPENAI_TRANSCRIPTION_ADAPTER_REVISION,
16
+ placement: OPENAI_TRANSCRIPTION_PLACEMENT,
17
+ });
18
+ const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0;
19
+ /** Per-profile capabilities. TTS is unsupported: synthesis is selected separately (local or L13). */
20
+ export const openAiTranscriptionCapabilities = (config) => {
21
+ if (!openAiTranscriptionModels.includes(config.modelId)) {
22
+ throw new OpenAiTranscriptionError('unknown-model', `unknown OpenAI transcription model: ${String(config.modelId)}`);
23
+ }
24
+ if (!isNonEmptyString(config.modelRevision)) {
25
+ throw new OpenAiTranscriptionError('invalid-config', 'modelRevision must be a pinned non-empty revision');
26
+ }
27
+ if (!openAiTranscriptionDelays.includes(config.delay)) {
28
+ throw new OpenAiTranscriptionError('invalid-config', `delay must be one of ${openAiTranscriptionDelays.join(', ')}`);
29
+ }
30
+ if (config.turnDetection !== undefined && config.turnDetection !== 'client') {
31
+ throw new OpenAiTranscriptionError('invalid-config', 'only client turn detection is supported for this profile');
32
+ }
33
+ return {
34
+ binding: openAiTranscriptionIdentity(config),
35
+ ...(config.language ? { languages: [config.language] } : {}),
36
+ ...(config.region ? { regions: [config.region] } : {}),
37
+ features: {
38
+ streamingStt: 'supported',
39
+ partialTranscript: 'supported',
40
+ nativeDuplex: 'unsupported',
41
+ tts: 'unsupported',
42
+ serverVad: 'unsupported',
43
+ clientVad: 'supported',
44
+ manualCommit: 'supported',
45
+ playoutTruncate: 'unsupported',
46
+ toolCancellation: 'unsupported',
47
+ resume: 'unknown',
48
+ },
49
+ inputFormats: [TRANSCRIPTION_FORMAT],
50
+ outputFormats: [],
51
+ usageUnits: ['audio-seconds', 'session-seconds'],
52
+ };
53
+ };
54
+ /** Exact `session.update` body. `turn_detection` is always null (server VAD unsupported). */
55
+ export const buildTranscriptionSessionUpdate = (config) => {
56
+ // Reuse the capability gate so the wire can never carry an unvalidated profile.
57
+ openAiTranscriptionCapabilities(config);
58
+ return {
59
+ type: 'session.update',
60
+ session: {
61
+ type: 'transcription',
62
+ audio: {
63
+ input: {
64
+ format: { type: 'audio/pcm', rate: TRANSCRIPTION_FORMAT.sampleRateHz },
65
+ transcription: {
66
+ model: config.modelId,
67
+ delay: config.delay,
68
+ ...(config.language ? { language: config.language } : {}),
69
+ ...(config.prompt ? { prompt: config.prompt } : {}),
70
+ },
71
+ turn_detection: null,
72
+ },
73
+ },
74
+ },
75
+ };
76
+ };
77
+ export class OpenAiTranscriptionError extends Error {
78
+ code;
79
+ constructor(code, message) {
80
+ super(message);
81
+ this.name = 'OpenAiTranscriptionError';
82
+ this.code = code;
83
+ }
84
+ }
85
+ /** Portable base64 for PCM payloads (no node:buffer; mesh stays runtime-agnostic). */
86
+ const base64Of = (payload) => {
87
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
88
+ let out = '';
89
+ for (let i = 0; i < payload.length; i += 3) {
90
+ const a = payload[i] ?? 0;
91
+ const b = payload[i + 1] ?? 0;
92
+ const c = payload[i + 2] ?? 0;
93
+ const triple = (a << 16) | (b << 8) | c;
94
+ out += alphabet[(triple >> 18) & 63] + alphabet[(triple >> 12) & 63]
95
+ + (i + 1 < payload.length ? alphabet[(triple >> 6) & 63] : '=')
96
+ + (i + 2 < payload.length ? alphabet[triple & 63] : '=');
97
+ }
98
+ return out;
99
+ };
100
+ const assertAdmission = (admission) => {
101
+ if (!admission || !isNonEmptyString(admission.operationId) || !isNonEmptyString(admission.holdRef)
102
+ || !isNonEmptyString(admission.pricingRevision)
103
+ || !(admission.ceiling?.connectedSeconds > 0) || !(admission.ceiling?.admittedAudioSeconds > 0)
104
+ || !(admission.maxDurationMs > 0)) {
105
+ throw new OpenAiTranscriptionError('admission', 'a valid admission (operation, hold, ceilings) is required');
106
+ }
107
+ };
108
+ const monotonicNow = (nowMs) => {
109
+ const now = (nowMs ?? Date.now)();
110
+ if (!Number.isFinite(now) || now < 0)
111
+ throw new OpenAiTranscriptionError('invalid-config', 'clock must be finite');
112
+ return now;
113
+ };
114
+ const errorEvent = (base, eventId, sequence, reason, retryable) => ({
115
+ schemaVersion: 1,
116
+ speechSessionId: base.speechSessionId,
117
+ connectionEpoch: base.connectionEpoch,
118
+ eventId,
119
+ sequence,
120
+ clock: base.clock,
121
+ type: 'error',
122
+ reason,
123
+ retryable,
124
+ });
125
+ export class TranscriptionSession {
126
+ receipt;
127
+ events;
128
+ socket;
129
+ binding;
130
+ admission;
131
+ usage;
132
+ nowMs;
133
+ openedAtMs;
134
+ items = new Map();
135
+ queue = [];
136
+ waiters = [];
137
+ sequence = 0;
138
+ eventId = 0;
139
+ audioSeconds = 0;
140
+ settled = false;
141
+ outcome = 'completed';
142
+ pump;
143
+ signal;
144
+ effectiveMaxMs;
145
+ abortHandler;
146
+ expiryCancel;
147
+ inPump = false;
148
+ turnAudioStartMs;
149
+ /** End of the last frame written in the open turn (capture + frame duration). */
150
+ turnAudioEndMs;
151
+ openTurnUtteranceId;
152
+ turns = [];
153
+ bindings = new Map();
154
+ /** Finalized items: late deltas/commits drop silently, a second final errors. */
155
+ finalizedIds = new Set();
156
+ /** Cleared items: every late provider frame drops silently. */
157
+ clearedIds = new Set();
158
+ committedAudioSeconds = 0;
159
+ usageRevision = 0;
160
+ constructor(receipt, socket, binding, admission, usage, nowMs, signal, schedule, maxDurationMs) {
161
+ this.receipt = receipt;
162
+ this.socket = socket;
163
+ this.binding = binding;
164
+ this.admission = admission;
165
+ this.usage = usage;
166
+ this.nowMs = nowMs;
167
+ this.signal = signal;
168
+ this.openedAtMs = monotonicNow(nowMs);
169
+ this.effectiveMaxMs = Math.min(admission.maxDurationMs, maxDurationMs);
170
+ this.events = this.stream();
171
+ this.pump = this.run(signal);
172
+ const arm = schedule ?? ((delayMs, fn) => {
173
+ const timer = setTimeout(fn, delayMs);
174
+ return () => clearTimeout(timer);
175
+ });
176
+ this.expiryCancel = arm(this.effectiveMaxMs, () => {
177
+ this.pushError('ceiling', false);
178
+ this.outcome = 'cancelled';
179
+ void this.close();
180
+ });
181
+ if (signal) {
182
+ if (signal.aborted) {
183
+ this.outcome = 'cancelled';
184
+ void this.close();
185
+ }
186
+ else {
187
+ this.abortHandler = () => {
188
+ this.outcome = 'cancelled';
189
+ void this.close();
190
+ };
191
+ signal.addEventListener('abort', this.abortHandler, { once: true });
192
+ }
193
+ }
194
+ }
195
+ nextId() {
196
+ this.eventId += 1;
197
+ return `evt-${this.eventId}`;
198
+ }
199
+ emit(event) {
200
+ this.queue.push(event);
201
+ const waiter = this.waiters.shift();
202
+ if (waiter)
203
+ waiter();
204
+ }
205
+ clock() {
206
+ return { id: 'openai-transcription', monotonicMs: monotonicNow(this.nowMs) };
207
+ }
208
+ async *stream() {
209
+ let index = 0;
210
+ for (;;) {
211
+ while (index >= this.queue.length) {
212
+ if (this.settled)
213
+ return;
214
+ await new Promise((resolve) => this.waiters.push(resolve));
215
+ }
216
+ const event = this.queue[index++];
217
+ // Bound the queue for long sessions: consumed events are dropped.
218
+ if (index > 1024) {
219
+ this.queue.splice(0, index);
220
+ index = 0;
221
+ }
222
+ yield event;
223
+ }
224
+ }
225
+ async write(frame) {
226
+ if (this.settled)
227
+ throw new OpenAiTranscriptionError('closed', 'session is closed');
228
+ if (!isValidSpeechAudioFrame(frame) || frame.type !== 'audio.input') {
229
+ throw new OpenAiTranscriptionError('invalid-format', 'audio.input frame failed validation');
230
+ }
231
+ if (frame.format.codec !== TRANSCRIPTION_FORMAT.codec
232
+ || frame.format.sampleRateHz !== TRANSCRIPTION_FORMAT.sampleRateHz
233
+ || frame.format.channels !== TRANSCRIPTION_FORMAT.channels) {
234
+ throw new OpenAiTranscriptionError('invalid-format', 'only pcm16-le 24kHz mono is admitted');
235
+ }
236
+ if (monotonicNow(this.nowMs) - this.openedAtMs >= this.effectiveMaxMs) {
237
+ throw new OpenAiTranscriptionError('ceiling', 'session duration bound reached');
238
+ }
239
+ const seconds = frame.sampleCount / frame.format.sampleRateHz;
240
+ if (!(seconds > 0) || this.audioSeconds + seconds > this.admission.ceiling.admittedAudioSeconds) {
241
+ throw new OpenAiTranscriptionError('ceiling', 'admitted audio ceiling reached');
242
+ }
243
+ this.audioSeconds += seconds;
244
+ this.openTurnUtteranceId = frame.utteranceId;
245
+ // Capture-domain turn window for the final's audioInterval: the window
246
+ // belongs to the open turn only, never to a session-wide maximum (a later
247
+ // turn must not stretch an earlier turn's interval). Dispatch ordering
248
+ // stays with L2.
249
+ if (Number.isFinite(frame.capturedAtMs) && frame.capturedAtMs >= 0) {
250
+ this.turnAudioStartMs ??= frame.capturedAtMs;
251
+ this.turnAudioEndMs = frame.capturedAtMs + (frame.sampleCount / frame.format.sampleRateHz) * 1000;
252
+ }
253
+ // The payload is encoded and released here; the adapter retains only counters.
254
+ const audio = base64Of(frame.payload);
255
+ this.socket.send(JSON.stringify({ type: 'input_audio_buffer.append', audio }));
256
+ }
257
+ async command(command) {
258
+ if (this.settled && command.type !== 'session.close') {
259
+ throw new OpenAiTranscriptionError('closed', 'session is closed');
260
+ }
261
+ switch (command.type) {
262
+ case 'input.commit': {
263
+ if (monotonicNow(this.nowMs) - this.openedAtMs >= this.effectiveMaxMs) {
264
+ throw new OpenAiTranscriptionError('ceiling', 'session duration bound reached');
265
+ }
266
+ this.socket.send(JSON.stringify({ type: 'input_audio_buffer.commit' }));
267
+ this.turns.push({
268
+ utteranceId: command.utteranceId,
269
+ startMs: this.turnAudioStartMs ?? 0,
270
+ endMs: this.turnAudioEndMs ?? this.turnAudioStartMs ?? 0,
271
+ });
272
+ this.turnAudioStartMs = undefined;
273
+ this.turnAudioEndMs = undefined;
274
+ this.openTurnUtteranceId = undefined;
275
+ // Cumulative operational totals (L8 sums incremental events per eventId,
276
+ // so a per-turn total would double-count); the composition settles from these.
277
+ this.committedAudioSeconds = this.audioSeconds;
278
+ await this.usage.record({
279
+ eventId: `${this.admission.operationId}:audio`,
280
+ revision: this.usageRevision++,
281
+ mode: 'cumulative',
282
+ usage: {
283
+ admittedAudioSeconds: this.committedAudioSeconds,
284
+ connectedSeconds: Math.max(0, (monotonicNow(this.nowMs) - this.openedAtMs) / 1000),
285
+ },
286
+ });
287
+ return;
288
+ }
289
+ case 'input.clear':
290
+ // Clear the uncommitted provider buffer, then drop every local trace
291
+ // of the client utterance so late provider frames cannot resurrect it.
292
+ this.socket.send(JSON.stringify({ type: 'input_audio_buffer.clear' }));
293
+ for (let index = this.turns.length - 1; index >= 0; index--) {
294
+ const turn = this.turns[index];
295
+ if (turn && turn.utteranceId === command.utteranceId && !turn.cleared) {
296
+ this.turns[index] = { ...turn, cleared: true };
297
+ }
298
+ }
299
+ for (const [itemId, turn] of this.bindings) {
300
+ if (turn.utteranceId === command.utteranceId) {
301
+ this.clearedIds.add(itemId);
302
+ this.bindings.delete(itemId);
303
+ }
304
+ }
305
+ for (const [itemId, item] of this.items) {
306
+ if (item.utteranceId === command.utteranceId) {
307
+ this.clearedIds.add(itemId);
308
+ this.items.delete(itemId);
309
+ }
310
+ }
311
+ if (this.openTurnUtteranceId === command.utteranceId)
312
+ this.openTurnUtteranceId = undefined;
313
+ return;
314
+ case 'session.mute':
315
+ // Mute is enforced by capture (no audio written); the cloud adapter holds no gate.
316
+ return;
317
+ case 'session.close':
318
+ await this.close();
319
+ return;
320
+ default:
321
+ throw new OpenAiTranscriptionError('unsupported-command', `not applicable to STT-only transcription: ${command.type}`);
322
+ }
323
+ }
324
+ async close() {
325
+ if (this.settled)
326
+ return;
327
+ this.settled = true;
328
+ this.expiryCancel?.();
329
+ this.expiryCancel = undefined;
330
+ if (this.signal && this.abortHandler)
331
+ this.signal.removeEventListener('abort', this.abortHandler);
332
+ try {
333
+ await this.socket.close();
334
+ }
335
+ finally {
336
+ // No usage attached: locally measured seconds are operational estimates,
337
+ // not provider-attested finals. The composition settles from the recorded
338
+ // cumulative totals (or provider-reported usage with a revision), so an
339
+ // L8 `settleSpeech` missing-dimension estimate stays valid.
340
+ await this.usage.settle({
341
+ operationId: this.admission.operationId,
342
+ outcome: this.outcome,
343
+ });
344
+ while (this.waiters.length > 0)
345
+ this.waiters.shift()?.();
346
+ if (!this.inPump)
347
+ await this.pump.catch(() => undefined);
348
+ }
349
+ }
350
+ pushError(reason, retryable) {
351
+ this.sequence += 1;
352
+ this.emit(errorEvent({ speechSessionId: this.receipt.speechSessionId, connectionEpoch: this.receipt.connectionEpoch, clock: this.clock() }, this.nextId(), this.sequence, reason, retryable));
353
+ }
354
+ /**
355
+ * Resolve the client turn for a provider item. Committed binds are
356
+ * authoritative; before the `committed` frame arrives the oldest
357
+ * unconfirmed turn is used (peeked, never consumed), falling back to the
358
+ * turn currently being recorded. Sequential client turns make all three agree.
359
+ */
360
+ attribute(itemId) {
361
+ const bound = this.bindings.get(itemId);
362
+ if (bound)
363
+ return bound;
364
+ if (this.turns.length > 0)
365
+ return this.turns[0];
366
+ if (this.openTurnUtteranceId !== undefined) {
367
+ return {
368
+ utteranceId: this.openTurnUtteranceId,
369
+ startMs: this.turnAudioStartMs ?? 0,
370
+ endMs: this.turnAudioEndMs ?? this.turnAudioStartMs ?? 0,
371
+ };
372
+ }
373
+ return undefined;
374
+ }
375
+ newItem(itemId, turn) {
376
+ const item = {
377
+ text: '',
378
+ revision: 0,
379
+ utteranceId: turn.utteranceId,
380
+ audioStartMs: turn.startMs,
381
+ audioEndMs: turn.endMs,
382
+ finalized: false,
383
+ speechSessionId: this.receipt.speechSessionId,
384
+ connectionEpoch: this.receipt.connectionEpoch,
385
+ binding: this.binding,
386
+ };
387
+ this.items.set(itemId, item);
388
+ return item;
389
+ }
390
+ onCommitted(itemId) {
391
+ if (this.finalizedIds.has(itemId) || this.clearedIds.has(itemId))
392
+ return;
393
+ const turn = this.turns.shift();
394
+ if (!turn) {
395
+ this.pushError('invalid-format', true);
396
+ return;
397
+ }
398
+ // A clear landed after the commit but before this bind: consume the
399
+ // tombstone and drop the late item instead of binding the next turn.
400
+ if (turn.cleared) {
401
+ this.clearedIds.add(itemId);
402
+ return;
403
+ }
404
+ this.bindings.set(itemId, turn);
405
+ // The bind is authoritative: align a live hypothesis created by early deltas.
406
+ const item = this.items.get(itemId);
407
+ if (item && !item.finalized) {
408
+ item.utteranceId = turn.utteranceId;
409
+ item.audioStartMs = turn.startMs;
410
+ item.audioEndMs = turn.endMs;
411
+ }
412
+ }
413
+ onDelta(itemId, contentIndex, delta) {
414
+ if (contentIndex !== 0) {
415
+ this.pushError('invalid-format', true);
416
+ return;
417
+ }
418
+ if (this.finalizedIds.has(itemId) || this.clearedIds.has(itemId))
419
+ return;
420
+ let item = this.items.get(itemId);
421
+ if (!item) {
422
+ const turn = this.attribute(itemId);
423
+ if (!turn) {
424
+ this.pushError('invalid-format', true);
425
+ return;
426
+ }
427
+ item = this.newItem(itemId, turn);
428
+ }
429
+ if (item.finalized)
430
+ return;
431
+ const start = item.text.length;
432
+ item.text += delta;
433
+ item.revision += 1;
434
+ this.sequence += 1;
435
+ const partial = {
436
+ schemaVersion: 1,
437
+ speechSessionId: item.speechSessionId,
438
+ connectionEpoch: item.connectionEpoch,
439
+ eventId: this.nextId(),
440
+ sequence: this.sequence,
441
+ clock: this.clock(),
442
+ type: 'transcript.partial',
443
+ binding: this.binding,
444
+ utteranceId: item.utteranceId,
445
+ revision: item.revision,
446
+ replaces: { start, end: start },
447
+ text: delta,
448
+ };
449
+ this.emit(partial);
450
+ }
451
+ onCompleted(itemId, transcript, languages) {
452
+ if (this.clearedIds.has(itemId))
453
+ return;
454
+ if (this.finalizedIds.has(itemId)) {
455
+ this.pushError('invalid-format', true);
456
+ return;
457
+ }
458
+ let item = this.items.get(itemId);
459
+ if (!item) {
460
+ // A completion without prior deltas (empty turn, unsent deltas for the
461
+ // model/delay) still finalizes the bound turn; otherwise it is orphaned.
462
+ const bound = this.bindings.get(itemId);
463
+ const turn = bound ?? this.attribute(itemId);
464
+ if (!turn) {
465
+ this.pushError('invalid-format', true);
466
+ return;
467
+ }
468
+ item = this.newItem(itemId, turn);
469
+ }
470
+ if (item.finalized) {
471
+ this.pushError('invalid-format', true);
472
+ return;
473
+ }
474
+ item.finalized = true;
475
+ this.finalizedIds.add(itemId);
476
+ item.revision += 1;
477
+ // The window stays the bound turn's: every path into onCompleted (live
478
+ // hypothesis, bind alignment, completed-only creation) already carries it.
479
+ this.sequence += 1;
480
+ let language = this.binding.language;
481
+ if (!language && Array.isArray(languages)) {
482
+ const first = languages[0];
483
+ if (first && typeof first.code === 'string' && first.code)
484
+ language = first.code;
485
+ }
486
+ const final = {
487
+ schemaVersion: 1,
488
+ speechSessionId: item.speechSessionId,
489
+ connectionEpoch: item.connectionEpoch,
490
+ eventId: this.nextId(),
491
+ sequence: this.sequence,
492
+ clock: this.clock(),
493
+ type: 'transcript.final',
494
+ binding: this.binding,
495
+ utteranceId: item.utteranceId,
496
+ revision: item.revision,
497
+ text: transcript,
498
+ language: language ?? 'und',
499
+ audioInterval: { startMs: item.audioStartMs, endMs: item.audioEndMs },
500
+ };
501
+ // Partials are ephemeral: the hypothesis buffer is released with the final.
502
+ this.items.delete(itemId);
503
+ this.emit(final);
504
+ }
505
+ onFrame(raw) {
506
+ if (this.settled)
507
+ return;
508
+ let frame;
509
+ try {
510
+ frame = JSON.parse(raw);
511
+ }
512
+ catch {
513
+ this.pushError('invalid-format', true);
514
+ return;
515
+ }
516
+ if (!frame || typeof frame !== 'object') {
517
+ this.pushError('invalid-format', true);
518
+ return;
519
+ }
520
+ const type = frame.type;
521
+ if (type === 'conversation.item.input_audio_transcription.delta') {
522
+ const record = frame;
523
+ if (!isNonEmptyString(record.item_id) || typeof record.delta !== 'string'
524
+ || typeof record.content_index !== 'number') {
525
+ this.pushError('invalid-format', true);
526
+ return;
527
+ }
528
+ this.onDelta(record.item_id, record.content_index, record.delta);
529
+ return;
530
+ }
531
+ if (type === 'conversation.item.input_audio_transcription.completed') {
532
+ const record = frame;
533
+ if (!isNonEmptyString(record.item_id) || typeof record.transcript !== 'string') {
534
+ this.pushError('invalid-format', true);
535
+ return;
536
+ }
537
+ this.onCompleted(record.item_id, record.transcript, record.languages);
538
+ return;
539
+ }
540
+ if (type === 'input_audio_buffer.committed') {
541
+ const record = frame;
542
+ if (!isNonEmptyString(record.item_id)) {
543
+ this.pushError('invalid-format', true);
544
+ return;
545
+ }
546
+ this.onCommitted(record.item_id);
547
+ return;
548
+ }
549
+ if (type === 'error') {
550
+ this.pushError('provider-error', false);
551
+ return;
552
+ }
553
+ // Unknown provider event types are tolerated (forward compatibility); the stream continues.
554
+ }
555
+ async run(signal) {
556
+ this.inPump = true;
557
+ try {
558
+ for await (const raw of this.socket.messages()) {
559
+ if (this.settled)
560
+ break;
561
+ if (signal?.aborted) {
562
+ this.outcome = 'cancelled';
563
+ break;
564
+ }
565
+ this.onFrame(raw);
566
+ }
567
+ }
568
+ catch {
569
+ if (!this.settled) {
570
+ this.outcome = 'failed';
571
+ this.pushError('transport', false);
572
+ }
573
+ }
574
+ finally {
575
+ this.inPump = false;
576
+ if (!this.settled)
577
+ await this.close();
578
+ }
579
+ }
580
+ }
581
+ /**
582
+ * Redact the bearer material from any message before it reaches an error.
583
+ * A provider that echoes the Authorization header must never leak it through
584
+ * session events or thrown errors.
585
+ */
586
+ const redactBearer = (message, token) => {
587
+ if (!token)
588
+ return message;
589
+ return message.split(`Bearer ${token}`).join('Bearer [REDACTED]').split(token).join('[REDACTED]');
590
+ };
591
+ /**
592
+ * Open an admitted transcription session. The bearer token travels only into
593
+ * the socket handshake; failures never echo it. The `session.update` is sent
594
+ * before any audio; an aborted signal refuses without connecting.
595
+ */
596
+ export const openTranscriptionSession = async (config, options) => {
597
+ const capabilities = openAiTranscriptionCapabilities(config);
598
+ const binding = {
599
+ ...openAiTranscriptionIdentity(config),
600
+ ...(config.language ? { language: config.language } : {}),
601
+ ...(config.accountRef ? { accountRef: config.accountRef } : {}),
602
+ ...(config.region ? { region: config.region } : {}),
603
+ };
604
+ assertAdmission(options.admission);
605
+ if (!isNonEmptyString(options.bearerToken)) {
606
+ throw new OpenAiTranscriptionError('invalid-config', 'bearer token is required');
607
+ }
608
+ const { request } = options;
609
+ // TTS bindings are refused here (fail-closed): a later duplex composition
610
+ // may split them onto a synthesis adapter, but this module never synthesizes.
611
+ if (request.pipeline !== 'chained' || request.bindings.stt === undefined
612
+ || request.bindings.stt.providerId !== binding.providerId
613
+ || request.bindings.stt.modelId !== binding.modelId
614
+ || request.bindings.tts !== undefined) {
615
+ throw new OpenAiTranscriptionError('invalid-config', 'STT-only chained binding for this profile is required');
616
+ }
617
+ for (const format of [...(request.inputFormats ?? []), ...(request.outputFormats ?? [])]) {
618
+ if (format.codec !== TRANSCRIPTION_FORMAT.codec || format.sampleRateHz !== TRANSCRIPTION_FORMAT.sampleRateHz
619
+ || format.channels !== TRANSCRIPTION_FORMAT.channels) {
620
+ throw new OpenAiTranscriptionError('invalid-config', 'only pcm16-le 24kHz mono is supported');
621
+ }
622
+ }
623
+ if (options.signal?.aborted) {
624
+ throw new OpenAiTranscriptionError('aborted', 'open aborted before connect');
625
+ }
626
+ const receipt = {
627
+ protocolVersion: 1,
628
+ speechSessionId: `speech-${options.admission.operationId}`,
629
+ connectionEpoch: request.target.controllerEpoch,
630
+ configuration: request.configuration,
631
+ target: request.target,
632
+ pipeline: 'chained',
633
+ micMode: request.micMode,
634
+ stages: { stt: capabilities },
635
+ inputFormat: TRANSCRIPTION_FORMAT,
636
+ };
637
+ let socket;
638
+ try {
639
+ socket = await options.connect(OPENAI_TRANSCRIPTION_WS_URL, {
640
+ authorization: `Bearer ${options.bearerToken}`,
641
+ originator: options.originator ?? 'sentropic-llm-mesh',
642
+ });
643
+ }
644
+ catch (error) {
645
+ const detail = error instanceof Error ? error.message : 'unknown';
646
+ throw new OpenAiTranscriptionError('transport', `provider connect failed: ${redactBearer(detail, options.bearerToken)}`);
647
+ }
648
+ const session = new TranscriptionSession(receipt, socket, binding, options.admission, options.usage, options.nowMs, options.signal, options.schedule, request.maxDurationMs);
649
+ try {
650
+ socket.send(JSON.stringify(buildTranscriptionSessionUpdate(config)));
651
+ }
652
+ catch (error) {
653
+ await session.close();
654
+ const detail = error instanceof Error ? error.message : 'unknown';
655
+ throw new OpenAiTranscriptionError('transport', `session update failed: ${redactBearer(detail, options.bearerToken)}`);
656
+ }
657
+ return session;
658
+ };
659
+ //# sourceMappingURL=openai-transcription.js.map