realtime-voice-agents 2.0.1 → 2.2.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 (38) hide show
  1. package/README.md +58 -2
  2. package/dist/{BaseRealtimeProvider-BehPNT1r.d.cts → BaseRealtimeProvider-BL75_HHh.d.cts} +53 -3
  3. package/dist/{BaseRealtimeProvider-DI4pKtOb.cjs → BaseRealtimeProvider-C2mRn1V_.cjs} +10 -0
  4. package/dist/{BaseRealtimeProvider-BQigr5mB.mjs → BaseRealtimeProvider-C9C3s8jx.mjs} +10 -0
  5. package/dist/{BaseRealtimeProvider-ClP8Wx1X.d.mts → BaseRealtimeProvider-CWJ81HIt.d.mts} +53 -3
  6. package/dist/{GeminiLiveProvider-DvkTgzjG.d.cts → GeminiLiveProvider-Cc1dkxW6.d.cts} +1 -1
  7. package/dist/{GeminiLiveProvider-x2nyx5aO.d.mts → GeminiLiveProvider-TvY_cQQZ.d.mts} +1 -1
  8. package/dist/{OpenAICompatibleProvider-Bdtl-UXH.mjs → OpenAICompatibleProvider-D-2OOVBU.mjs} +98 -11
  9. package/dist/{OpenAICompatibleProvider-NS4cKVQj.cjs → OpenAICompatibleProvider-Mp0Mefbh.cjs} +98 -11
  10. package/dist/audio.cjs +9 -6
  11. package/dist/audio.d.cts +51 -1
  12. package/dist/audio.d.mts +51 -1
  13. package/dist/audio.mjs +2 -2
  14. package/dist/gemini.cjs +16 -6
  15. package/dist/gemini.d.cts +8 -3
  16. package/dist/gemini.d.mts +8 -3
  17. package/dist/gemini.mjs +16 -6
  18. package/dist/index.cjs +356 -16
  19. package/dist/index.d.cts +211 -2
  20. package/dist/index.d.mts +211 -2
  21. package/dist/index.mjs +355 -16
  22. package/dist/{BackgroundAudioPlayer-iMcivjis.mjs → noise-CJ789zzj.mjs} +94 -1
  23. package/dist/{BackgroundAudioPlayer-jfRULWKC.cjs → noise-D9jKottW.cjs} +111 -0
  24. package/dist/openai.cjs +22 -8
  25. package/dist/openai.d.cts +31 -3
  26. package/dist/openai.d.mts +31 -3
  27. package/dist/openai.mjs +22 -8
  28. package/dist/{session-config-BVLl7-ha.mjs → session-config-CbifLlkV.mjs} +1 -1
  29. package/dist/{session-config-c8sOw1XL.cjs → session-config-CqJm2Kxz.cjs} +1 -1
  30. package/dist/testing.cjs +29 -2
  31. package/dist/testing.d.cts +31 -1
  32. package/dist/testing.d.mts +31 -1
  33. package/dist/testing.mjs +29 -2
  34. package/dist/xai.cjs +23 -9
  35. package/dist/xai.d.cts +7 -2
  36. package/dist/xai.d.mts +7 -2
  37. package/dist/xai.mjs +23 -9
  38. package/package.json +1 -1
package/README.md CHANGED
@@ -104,6 +104,26 @@ geminiLive({ model: 'gemini-2.5-flash-native-audio-preview-12-2025', voice: 'Aoe
104
104
 
105
105
  One `SessionOptions` surface configures all three; where a provider can't honor a knob, the fallback is documented and pinned by the parity test suite.
106
106
 
107
+ ## Provider fallbacks
108
+
109
+ One bad API key, an exhausted quota, or a provider outage should not send your calls to dead air. Give the bridge backup providers and it tries them in order while the call is being established:
110
+
111
+ ```ts
112
+ const bridge = new TwilioRealtimeBridge({
113
+ agent,
114
+ provider: openaiRealtime(), // primary
115
+ fallbacks: [xaiRealtime(), geminiLive()], // tried in order if it fails to come up
116
+ });
117
+ ```
118
+
119
+ - **Covers the real failure modes.** A missing API key (the factories defer their credential check to call time precisely so the chain can absorb it), an expired/revoked key (HTTP 401), exhausted credits/quota (403/429), a provider internal error (5xx or a dropped socket), and a hung endpoint (connect timeout) all walk the chain — anything that keeps a provider from coming up.
120
+ - **Connect-time only.** A dead provider is dropped and the next one is tried immediately — no backoff between attempts. Once a provider answers, the call stays with it: mid-call reconnects reuse the same provider (per the `session.reconnect` policy), and a mid-call death past that budget fails the call rather than switching voices mid-conversation.
121
+ - **Observable.** Each advance emits `provider.fallback` (`{ from, to, error }`) on the session — count these to alarm on a degraded primary.
122
+ - **Voices don't cross vendors.** Configure the voice per factory (`openaiRealtime({ voice: 'marin' })`, `xaiRealtime({ voice: 'eve' })`) rather than on the `Agent` — an OpenAI voice name would fail the xAI/Gemini connect and the chain would skip past a healthy provider.
123
+ - **Latency.** Each dead provider costs up to its `connectTimeoutMs` (default 10s) before the next is tried — set a tighter one on the primary if its endpoint tends to hang rather than refuse. A [pre-synthesized greeting](#pre-synthesized-greeting-15s-to-first-word) bursts onto the line before any handshake, so the caller hears a voice while the chain walks.
124
+
125
+ Testing it: `FakeOpenAIServer.start({ refuseConnections: true })` gives you a provider that is "down", and `{ rejectUpgrade: { status: 401, body: 'invalid_api_key' } }` one that rejects like a real auth/quota failure (both flippable at runtime to script recoveries) — see `src/bridge/fallback.test.ts` for ready-made scenarios.
126
+
107
127
  ## Tools: Zod schemas + execution strategies
108
128
 
109
129
  ```ts
@@ -193,6 +213,41 @@ session: {
193
213
 
194
214
  Blocked attempts emit `interruption.blocked` with a cause (`guard` | `rate_limit` | `tool_running` | …). Honored ones flush Twilio, truncate the model's context to the heard milliseconds, and emit `playback.interrupted` with exactly how much the caller heard.
195
215
 
216
+ ## Noise-adaptive VAD (opt-in)
217
+
218
+ Server VAD tuned for quiet rooms misfires on noisy lines — street, car, speakerphone — as phantom barge-ins and chopped replies. `noiseAdaptiveVad` measures the line itself: a per-frame μ-law meter estimates the caller's noise floor (a low percentile over a sliding window, so speech doesn't read as noise), and when it stays high, the bridge escalates turn detection mid-call.
219
+
220
+ ```ts
221
+ session: {
222
+ vad: { type: 'server', threshold: 0.5 },
223
+ noiseAdaptiveVad: {}, // {} = defaults below
224
+ // mode: 'auto', // 'suggest' = events only, you apply
225
+ // noiseFloorDb: -45, // trigger floor, dBFS
226
+ // windowMs: 5000, sustainMs: 3000, // how much/how long analyzed audio
227
+ // cooldownMs: 15_000, maxSteps: 1, // escalation pacing (per call)
228
+ // thresholdStep: 0.1, maxThreshold: 0.9,
229
+ }
230
+ ```
231
+
232
+ How a step lands, per provider:
233
+
234
+ | Provider | Escalation |
235
+ | --- | --- |
236
+ | OpenAI | `threshold` +0.1/step, auto-applied via ack-gated `session.update`; baseline = your `vad.threshold`, else OpenAI's documented 0.5 |
237
+ | xAI | same, from xAI's documented **0.85** default, clamped to its 0.1–0.9 range |
238
+ | Gemini | `vad.suggestion` event only (`startSensitivity: 'low'` analog) — the Live API has no mid-session config updates |
239
+ | semantic VAD | `vad.suggestion` event only: `eagerness: 'low'` trades **end-of-turn latency** (waits up to ~8s) for stability, so that call is yours |
240
+
241
+ The baseline comes from the provider's ACKnowledged effective config plus its declared `capabilities.vadTuning` profile — never guessed (assuming 0.5 on xAI would *lower* its 0.85 default). Escalation is one-way per call: steps up, never back down; `maxSteps` and `cooldownMs` bound the blast radius.
242
+
243
+ Every decision emits `vad.suggestion` (recommended config + `noiseFloorDb`/`analyzedMs`/`elapsedMs` metrics); an applied-and-acknowledged step also emits `vad.adjusted`. In `mode: 'suggest'` nothing is applied automatically — accept with `session.updateVad(info.suggested)`, which persists across reconnects and rebases future escalation on top of it. `updateVad(null)` disables turn detection AND suspends adaptation (it never re-enables VAD by itself).
244
+
245
+ Fine print:
246
+
247
+ - Caller speech, agent playback (speakerphone bleed), and the pre-synthesized greeting are excluded from the floor estimate — a long monologue in a quiet room never escalates. `windowMs` counts **analyzed** idle-line audio, so warmup can span 30–60s of real conversation; the metrics exist to tune this from field data.
248
+ - Enabling the feature also serializes ALL mid-call session updates for that session: one in flight, acknowledged before the next; an ack timeout reconnects into known-good state. Disabled = the legacy fire-and-forget behavior, untouched.
249
+ - Complementary knobs: OpenAI's native `audio.input.noise_reduction` (reachable via the provider's `sessionOptions`) runs before VAD and may fix much of the problem upstream; `interruptions.rateLimit` reacts to barge-in churn after the fact, while this reacts to the audio itself. All three coexist.
250
+
196
251
  ## Pre-synthesized greeting (~1.5s to first word)
197
252
 
198
253
  The slowest part of answering is the provider handshake. Pre-record the greeting once, and the bridge burst-writes it onto the call **while the session is still connecting** — then keeps the model from greeting twice (instruction reinforcement + assistant-turn seeding + suppressed auto-greet) and gates caller audio until Twilio's mark confirms playout.
@@ -217,7 +272,7 @@ Bundled presets (all synthesized, license-free, seamless loops): `elevator-jazz`
217
272
 
218
273
  ## Events (session)
219
274
 
220
- `call.started/ended/failed` · `provider.connected/reconnecting/reconnected/closed` · `agent.speech.started/ended` (generation) · **`playback.started/finished/interrupted`** (what the caller heard, mark-confirmed) · `user.speech.started/ended` · `transcript.user/agent` · `tool.started/completed/failed` · `tool.approval.required` · `agent.handoff` · `interruption` / `interruption.blocked` · `background_audio.started/stopped` · `dtmf` · `usage.updated` · `error`.
275
+ `call.started/ended/failed` · `provider.connected/fallback/reconnecting/reconnected/closed` · `agent.speech.started/ended` (generation) · **`playback.started/finished/interrupted`** (what the caller heard, mark-confirmed) · `user.speech.started/ended` · `transcript.user/agent` · `tool.started/completed/failed` · `tool.approval.required` · `agent.handoff` · `interruption` / `interruption.blocked` · `vad.suggestion` / `vad.adjusted` (noise-adaptive VAD) · `background_audio.started/stopped` · `dtmf` · `usage.updated` · `error`.
221
276
 
222
277
  ```ts
223
278
  bridge.on('session.started', (session) => {
@@ -244,6 +299,7 @@ session: {
244
299
  reconnect: { maxAttempts: 5, initialDelayMs: 250, maxDelayMs: 8000, jitter: true },
245
300
  hangup: { markTimeoutMs: 7000 }, // goodbye watchdog
246
301
  vad: undefined, // normalized VAD, mapped per provider
302
+ noiseAdaptiveVad: undefined, // opt-in noise → VAD escalation ({} enables; see its section)
247
303
  toolResultDelivery: 'afterPlayback', // or 'immediate'
248
304
  toolBackgroundAudio: undefined, // default hold audio for tools
249
305
  handoffVoicePolicy: 'keep', // or 'reconnect' to switch voices
@@ -260,7 +316,7 @@ Outbound calls: the greeting waits for a human — feed your status callback int
260
316
  `realtime-voice-agents/testing` ships the harness this package is tested with:
261
317
 
262
318
  - **`FakeTwilioMediaStream`** — a scripted caller with an exact playout simulation: marks echo only after the media before them "plays"; `clear` discards buffered audio and echoes pending marks, like real Twilio.
263
- - **`FakeOpenAIServer`** — a real-WebSocket GA-protocol server you script (`sendAudioResponse`, `sendToolCall`, `sendSpeechStarted`, drops).
319
+ - **`FakeOpenAIServer`** — a real-WebSocket GA-protocol server you script (`sendAudioResponse`, `sendToolCall`, `sendSpeechStarted`, drops, `refuseConnections` for down-provider/fallback scenarios).
264
320
  - **`FakeGeminiLive`** — a scripted `@google/genai` seam for the Gemini provider.
265
321
 
266
322
  ```ts
@@ -17,6 +17,21 @@ declare const noopLogger: Logger;
17
17
  declare function consoleLogger(context?: Record<string, unknown>): Logger;
18
18
  //#endregion
19
19
  //#region src/providers/base/capabilities.d.ts
20
+ /**
21
+ * The provider's server-VAD tuning envelope. Declared by provider factories
22
+ * (never by the generic OpenAI-compatible base — an arbitrary compatible
23
+ * service must not silently inherit OpenAI's defaults). Noise-adaptive VAD
24
+ * refuses to invent a threshold baseline: absent both this profile and an
25
+ * explicit configured threshold, the numeric ladder stays unavailable.
26
+ */
27
+ interface VadTuningProfile {
28
+ /** The provider's effective server-VAD threshold when none is configured. */
29
+ defaultServerThreshold?: number;
30
+ /** Lowest threshold the provider accepts (clamp target). */
31
+ minServerThreshold?: number;
32
+ /** Highest threshold the provider accepts (clamp target). */
33
+ maxServerThreshold?: number;
34
+ }
20
35
  /**
21
36
  * Capability flags per provider. The engine branches on these — never on a
22
37
  * provider's name — so new providers slot in without touching session logic.
@@ -43,6 +58,11 @@ interface ProviderCapabilities {
43
58
  * Optional so existing third-party providers keep compiling (absent = false).
44
59
  */
45
60
  vadInterruptControl?: boolean;
61
+ /**
62
+ * Server-VAD threshold envelope for noise-adaptive tuning. Optional; absent
63
+ * means the provider declared none (see VadTuningProfile).
64
+ */
65
+ vadTuning?: VadTuningProfile;
46
66
  }
47
67
  //#endregion
48
68
  //#region src/providers/base/events.d.ts
@@ -182,6 +202,13 @@ interface ProviderSessionInit {
182
202
  * (documented fallback: guard protects buffered audio only).
183
203
  */
184
204
  bridgeOwnsInterruptions?: boolean;
205
+ /**
206
+ * Serialize mid-session updates: one session.update in flight at a time,
207
+ * each acknowledged (or timed out) before the next is sent. Set by the
208
+ * bridge when noise-adaptive VAD is configured; absent/false keeps the
209
+ * legacy fire-and-forget update behavior exactly as before.
210
+ */
211
+ serializedSessionUpdates?: boolean;
185
212
  /** Provider-native session options, deep-merged last (escape hatch). */
186
213
  providerOptions?: Record<string, unknown>;
187
214
  }
@@ -194,6 +221,15 @@ interface SendTextOptions {
194
221
  interface SendToolResultOptions {
195
222
  triggerResponse?: boolean;
196
223
  }
224
+ interface SessionUpdateOptions {
225
+ /**
226
+ * Resolve with the provider's acknowledgement of THIS update (`true`) rather
227
+ * than at send time. Meaningful only with serialized session updates (see
228
+ * `ProviderSessionInit.serializedSessionUpdates`); providers without ack
229
+ * support resolve `false`/`void`, which callers treat as not-acknowledged.
230
+ */
231
+ awaitAck?: boolean;
232
+ }
197
233
  /**
198
234
  * The provider contract: μ-law in, μ-law out, normalized events.
199
235
  * Transcoding (when the provider speaks PCM) lives inside the provider, so
@@ -217,8 +253,22 @@ declare abstract class BaseRealtimeProvider extends TypedEmitter<ProviderEvents>
217
253
  abstract createResponse(options?: {
218
254
  instructions?: string;
219
255
  }): void;
220
- /** Mid-session config change (instructions/tools/voice) where supported. */
221
- abstract updateSession(patch: Partial<ProviderSessionInit>): Promise<void>;
256
+ /**
257
+ * Mid-session config change (instructions/tools/voice/vad) where supported.
258
+ * Return `true` to signal the provider ACKNOWLEDGED the update — the
259
+ * noise-adaptive VAD auto-commit requires it. The `boolean | void` return
260
+ * keeps existing third-party `Promise<void>` implementations compiling;
261
+ * anything other than `true` is treated as not-acknowledged.
262
+ */
263
+ abstract updateSession(patch: Partial<ProviderSessionInit>, options?: SessionUpdateOptions): Promise<boolean | void>;
264
+ /**
265
+ * The last ACKNOWLEDGED turn-detection config on the live session — what was
266
+ * actually sent and confirmed, never desired/pending state. Undefined until
267
+ * the provider reports one (third-party providers may never set it).
268
+ */
269
+ getEffectiveVad(): VadConfig | null | undefined;
270
+ /** Set by subclasses when a config carrying `vad` is acknowledged. */
271
+ protected effectiveVadValue: VadConfig | null | undefined;
222
272
  /** Interrupt in-flight generation (where the wire protocol supports it). */
223
273
  cancelResponse(): void;
224
274
  /** Trim the last assistant item to what the caller actually heard. */
@@ -236,4 +286,4 @@ interface ProviderFactoryContext {
236
286
  /** A fresh provider per call; connection config is captured in the factory closure. */
237
287
  type ProviderFactory = (context: ProviderFactoryContext) => BaseRealtimeProvider;
238
288
  //#endregion
239
- export { noopLogger as _, ProviderToolSchema as a, VadConfig as c, ProviderEvents as d, ProviderToolCall as f, consoleLogger as g, Logger as h, ProviderSessionInit as i, ProviderAudioDelta as l, ProviderCapabilities as m, ProviderFactory as n, SendTextOptions as o, ProviderUsage as p, ProviderFactoryContext as r, SendToolResultOptions as s, BaseRealtimeProvider as t, ProviderCloseInfo as u };
289
+ export { Logger as _, ProviderToolSchema as a, SessionUpdateOptions as c, ProviderCloseInfo as d, ProviderEvents as f, VadTuningProfile as g, ProviderCapabilities as h, ProviderSessionInit as i, VadConfig as l, ProviderUsage as m, ProviderFactory as n, SendTextOptions as o, ProviderToolCall as p, ProviderFactoryContext as r, SendToolResultOptions as s, BaseRealtimeProvider as t, ProviderAudioDelta as u, consoleLogger as v, noopLogger as y };
@@ -46,6 +46,16 @@ function deepMerge(base, patch) {
46
46
  * instance — the session layer owns reconnect policy and context re-injection.
47
47
  */
48
48
  var BaseRealtimeProvider = class extends require_events.TypedEmitter {
49
+ /**
50
+ * The last ACKNOWLEDGED turn-detection config on the live session — what was
51
+ * actually sent and confirmed, never desired/pending state. Undefined until
52
+ * the provider reports one (third-party providers may never set it).
53
+ */
54
+ getEffectiveVad() {
55
+ return this.effectiveVadValue;
56
+ }
57
+ /** Set by subclasses when a config carrying `vad` is acknowledged. */
58
+ effectiveVadValue = void 0;
49
59
  /** Interrupt in-flight generation (where the wire protocol supports it). */
50
60
  cancelResponse() {}
51
61
  /** Trim the last assistant item to what the caller actually heard. */
@@ -46,6 +46,16 @@ function deepMerge(base, patch) {
46
46
  * instance — the session layer owns reconnect policy and context re-injection.
47
47
  */
48
48
  var BaseRealtimeProvider = class extends TypedEmitter {
49
+ /**
50
+ * The last ACKNOWLEDGED turn-detection config on the live session — what was
51
+ * actually sent and confirmed, never desired/pending state. Undefined until
52
+ * the provider reports one (third-party providers may never set it).
53
+ */
54
+ getEffectiveVad() {
55
+ return this.effectiveVadValue;
56
+ }
57
+ /** Set by subclasses when a config carrying `vad` is acknowledged. */
58
+ effectiveVadValue = void 0;
49
59
  /** Interrupt in-flight generation (where the wire protocol supports it). */
50
60
  cancelResponse() {}
51
61
  /** Trim the last assistant item to what the caller actually heard. */
@@ -17,6 +17,21 @@ declare const noopLogger: Logger;
17
17
  declare function consoleLogger(context?: Record<string, unknown>): Logger;
18
18
  //#endregion
19
19
  //#region src/providers/base/capabilities.d.ts
20
+ /**
21
+ * The provider's server-VAD tuning envelope. Declared by provider factories
22
+ * (never by the generic OpenAI-compatible base — an arbitrary compatible
23
+ * service must not silently inherit OpenAI's defaults). Noise-adaptive VAD
24
+ * refuses to invent a threshold baseline: absent both this profile and an
25
+ * explicit configured threshold, the numeric ladder stays unavailable.
26
+ */
27
+ interface VadTuningProfile {
28
+ /** The provider's effective server-VAD threshold when none is configured. */
29
+ defaultServerThreshold?: number;
30
+ /** Lowest threshold the provider accepts (clamp target). */
31
+ minServerThreshold?: number;
32
+ /** Highest threshold the provider accepts (clamp target). */
33
+ maxServerThreshold?: number;
34
+ }
20
35
  /**
21
36
  * Capability flags per provider. The engine branches on these — never on a
22
37
  * provider's name — so new providers slot in without touching session logic.
@@ -43,6 +58,11 @@ interface ProviderCapabilities {
43
58
  * Optional so existing third-party providers keep compiling (absent = false).
44
59
  */
45
60
  vadInterruptControl?: boolean;
61
+ /**
62
+ * Server-VAD threshold envelope for noise-adaptive tuning. Optional; absent
63
+ * means the provider declared none (see VadTuningProfile).
64
+ */
65
+ vadTuning?: VadTuningProfile;
46
66
  }
47
67
  //#endregion
48
68
  //#region src/providers/base/events.d.ts
@@ -182,6 +202,13 @@ interface ProviderSessionInit {
182
202
  * (documented fallback: guard protects buffered audio only).
183
203
  */
184
204
  bridgeOwnsInterruptions?: boolean;
205
+ /**
206
+ * Serialize mid-session updates: one session.update in flight at a time,
207
+ * each acknowledged (or timed out) before the next is sent. Set by the
208
+ * bridge when noise-adaptive VAD is configured; absent/false keeps the
209
+ * legacy fire-and-forget update behavior exactly as before.
210
+ */
211
+ serializedSessionUpdates?: boolean;
185
212
  /** Provider-native session options, deep-merged last (escape hatch). */
186
213
  providerOptions?: Record<string, unknown>;
187
214
  }
@@ -194,6 +221,15 @@ interface SendTextOptions {
194
221
  interface SendToolResultOptions {
195
222
  triggerResponse?: boolean;
196
223
  }
224
+ interface SessionUpdateOptions {
225
+ /**
226
+ * Resolve with the provider's acknowledgement of THIS update (`true`) rather
227
+ * than at send time. Meaningful only with serialized session updates (see
228
+ * `ProviderSessionInit.serializedSessionUpdates`); providers without ack
229
+ * support resolve `false`/`void`, which callers treat as not-acknowledged.
230
+ */
231
+ awaitAck?: boolean;
232
+ }
197
233
  /**
198
234
  * The provider contract: μ-law in, μ-law out, normalized events.
199
235
  * Transcoding (when the provider speaks PCM) lives inside the provider, so
@@ -217,8 +253,22 @@ declare abstract class BaseRealtimeProvider extends TypedEmitter<ProviderEvents>
217
253
  abstract createResponse(options?: {
218
254
  instructions?: string;
219
255
  }): void;
220
- /** Mid-session config change (instructions/tools/voice) where supported. */
221
- abstract updateSession(patch: Partial<ProviderSessionInit>): Promise<void>;
256
+ /**
257
+ * Mid-session config change (instructions/tools/voice/vad) where supported.
258
+ * Return `true` to signal the provider ACKNOWLEDGED the update — the
259
+ * noise-adaptive VAD auto-commit requires it. The `boolean | void` return
260
+ * keeps existing third-party `Promise<void>` implementations compiling;
261
+ * anything other than `true` is treated as not-acknowledged.
262
+ */
263
+ abstract updateSession(patch: Partial<ProviderSessionInit>, options?: SessionUpdateOptions): Promise<boolean | void>;
264
+ /**
265
+ * The last ACKNOWLEDGED turn-detection config on the live session — what was
266
+ * actually sent and confirmed, never desired/pending state. Undefined until
267
+ * the provider reports one (third-party providers may never set it).
268
+ */
269
+ getEffectiveVad(): VadConfig | null | undefined;
270
+ /** Set by subclasses when a config carrying `vad` is acknowledged. */
271
+ protected effectiveVadValue: VadConfig | null | undefined;
222
272
  /** Interrupt in-flight generation (where the wire protocol supports it). */
223
273
  cancelResponse(): void;
224
274
  /** Trim the last assistant item to what the caller actually heard. */
@@ -236,4 +286,4 @@ interface ProviderFactoryContext {
236
286
  /** A fresh provider per call; connection config is captured in the factory closure. */
237
287
  type ProviderFactory = (context: ProviderFactoryContext) => BaseRealtimeProvider;
238
288
  //#endregion
239
- export { noopLogger as _, ProviderToolSchema as a, VadConfig as c, ProviderEvents as d, ProviderToolCall as f, consoleLogger as g, Logger as h, ProviderSessionInit as i, ProviderAudioDelta as l, ProviderCapabilities as m, ProviderFactory as n, SendTextOptions as o, ProviderUsage as p, ProviderFactoryContext as r, SendToolResultOptions as s, BaseRealtimeProvider as t, ProviderCloseInfo as u };
289
+ export { Logger as _, ProviderToolSchema as a, SessionUpdateOptions as c, ProviderCloseInfo as d, ProviderEvents as f, VadTuningProfile as g, ProviderCapabilities as h, ProviderSessionInit as i, VadConfig as l, ProviderUsage as m, ProviderFactory as n, SendTextOptions as o, ProviderToolCall as p, ProviderFactoryContext as r, SendToolResultOptions as s, BaseRealtimeProvider as t, ProviderAudioDelta as u, consoleLogger as v, noopLogger as y };
@@ -1,4 +1,4 @@
1
- import { c as VadConfig, h as Logger, i as ProviderSessionInit, m as ProviderCapabilities, o as SendTextOptions, s as SendToolResultOptions, t as BaseRealtimeProvider } from "./BaseRealtimeProvider-BehPNT1r.cjs";
1
+ import { _ as Logger, h as ProviderCapabilities, i as ProviderSessionInit, l as VadConfig, o as SendTextOptions, s as SendToolResultOptions, t as BaseRealtimeProvider } from "./BaseRealtimeProvider-BL75_HHh.cjs";
2
2
  //#region src/providers/gemini/GeminiLiveProvider.d.ts
3
3
  /** Structural slice of @google/genai's live session (also what fakes implement). */
4
4
  interface GeminiLiveSessionLike {
@@ -1,4 +1,4 @@
1
- import { c as VadConfig, h as Logger, i as ProviderSessionInit, m as ProviderCapabilities, o as SendTextOptions, s as SendToolResultOptions, t as BaseRealtimeProvider } from "./BaseRealtimeProvider-ClP8Wx1X.mjs";
1
+ import { _ as Logger, h as ProviderCapabilities, i as ProviderSessionInit, l as VadConfig, o as SendTextOptions, s as SendToolResultOptions, t as BaseRealtimeProvider } from "./BaseRealtimeProvider-CWJ81HIt.mjs";
2
2
  //#region src/providers/gemini/GeminiLiveProvider.d.ts
3
3
  /** Structural slice of @google/genai's live session (also what fakes implement). */
4
4
  interface GeminiLiveSessionLike {
@@ -1,5 +1,5 @@
1
- import { a as noopLogger, t as BaseRealtimeProvider } from "./BaseRealtimeProvider-BQigr5mB.mjs";
2
- import { t as buildSessionUpdate } from "./session-config-BVLl7-ha.mjs";
1
+ import { a as noopLogger, t as BaseRealtimeProvider } from "./BaseRealtimeProvider-C9C3s8jx.mjs";
2
+ import { t as buildSessionUpdate } from "./session-config-CbifLlkV.mjs";
3
3
  import WebSocket$1 from "ws";
4
4
  //#region src/providers/openai-compatible/OpenAICompatibleProvider.ts
5
5
  /**
@@ -41,6 +41,10 @@ var OpenAICompatibleProvider = class extends BaseRealtimeProvider {
41
41
  responseActive = false;
42
42
  pendingCreate = null;
43
43
  lastCreateSent = null;
44
+ /** Serialized-update pipeline (enabled via init.serializedSessionUpdates). */
45
+ serializedUpdates = false;
46
+ updateQueue = [];
47
+ updateInFlight = null;
44
48
  constructor(config, logger = noopLogger) {
45
49
  super();
46
50
  this.config = config;
@@ -62,14 +66,11 @@ var OpenAICompatibleProvider = class extends BaseRealtimeProvider {
62
66
  }
63
67
  async connect(init) {
64
68
  if (this.ws) await this.close();
65
- let vad = init.vad !== void 0 ? init.vad : this.config.defaultVad;
66
- if (init.bridgeOwnsInterruptions && this.capabilities.vadInterruptControl && vad !== null) vad = {
67
- interruptResponse: false,
68
- ...vad ?? { type: "server" }
69
- };
69
+ this.failPendingUpdates();
70
+ this.serializedUpdates = init.serializedSessionUpdates === true;
70
71
  this.sessionInit = {
71
72
  ...init,
72
- vad,
73
+ vad: this.resolveVad(init.vad, init.bridgeOwnsInterruptions),
73
74
  transcription: init.transcription !== void 0 ? init.transcription : this.config.defaultTranscription
74
75
  };
75
76
  this.intentionalClose = false;
@@ -148,6 +149,7 @@ var OpenAICompatibleProvider = class extends BaseRealtimeProvider {
148
149
  ws.on("close", (code, reasonBuf) => {
149
150
  const reason = reasonBuf?.toString();
150
151
  this.ready = false;
152
+ this.failPendingUpdates();
151
153
  if (!settled) {
152
154
  fail(/* @__PURE__ */ new Error(`${this.name} socket closed during setup (${code} ${reason ?? ""})`));
153
155
  return;
@@ -159,6 +161,7 @@ var OpenAICompatibleProvider = class extends BaseRealtimeProvider {
159
161
  });
160
162
  });
161
163
  });
164
+ this.effectiveVadValue = this.sessionInit.vad;
162
165
  }
163
166
  async close() {
164
167
  this.intentionalClose = true;
@@ -239,13 +242,87 @@ var OpenAICompatibleProvider = class extends BaseRealtimeProvider {
239
242
  ...create.instructions ? { response: { instructions: create.instructions } } : {}
240
243
  });
241
244
  }
242
- async updateSession(patch) {
245
+ async updateSession(patch, options = {}) {
243
246
  if (!this.sessionInit) throw new Error("updateSession before connect");
247
+ let resolved = patch;
248
+ if ("vad" in patch) resolved = {
249
+ ...patch,
250
+ vad: this.resolveVad(patch.vad, this.sessionInit.bridgeOwnsInterruptions)
251
+ };
244
252
  this.sessionInit = {
245
253
  ...this.sessionInit,
246
- ...patch
254
+ ...resolved
255
+ };
256
+ if (!this.serializedUpdates) {
257
+ const open = this.ws?.readyState === WebSocket$1.OPEN;
258
+ this.send(this.buildSessionPayload());
259
+ return open;
260
+ }
261
+ let resolveSent;
262
+ let resolveAck;
263
+ const sent = new Promise((resolve) => resolveSent = resolve);
264
+ const acked = new Promise((resolve) => resolveAck = resolve);
265
+ this.updateQueue.push({
266
+ payload: this.buildSessionPayload(),
267
+ touchesVad: "vad" in resolved,
268
+ resolvedVad: resolved.vad,
269
+ resolveSent,
270
+ resolveAck,
271
+ ackTimer: null
272
+ });
273
+ this.pumpUpdateQueue();
274
+ return options.awaitAck ? acked : sent;
275
+ }
276
+ /**
277
+ * Bridge-owned barge-in: the server must NOT auto-cancel the active
278
+ * response on speech onset, or a guard-blocked interruption still kills
279
+ * the sentence mid-air. An explicit vad.interruptResponse wins (spread
280
+ * order). Applied on connect AND on every vad patch.
281
+ */
282
+ resolveVad(vad, bridgeOwnsInterruptions) {
283
+ let resolved = vad !== void 0 ? vad : this.config.defaultVad;
284
+ if (bridgeOwnsInterruptions && this.capabilities.vadInterruptControl && resolved !== null) resolved = {
285
+ interruptResponse: false,
286
+ ...resolved ?? { type: "server" }
247
287
  };
248
- this.send(this.buildSessionPayload());
288
+ return resolved;
289
+ }
290
+ /** Send the next queued update once nothing is awaiting its ack. */
291
+ pumpUpdateQueue() {
292
+ if (this.updateInFlight || this.updateQueue.length === 0) return;
293
+ if (this.ws?.readyState !== WebSocket$1.OPEN) {
294
+ this.failPendingUpdates();
295
+ return;
296
+ }
297
+ const entry = this.updateQueue.shift();
298
+ this.updateInFlight = entry;
299
+ this.send(entry.payload);
300
+ entry.resolveSent(true);
301
+ const timeoutMs = this.config.sessionUpdateAckTimeoutMs ?? 3e3;
302
+ entry.ackTimer = setTimeout(() => {
303
+ entry.ackTimer = null;
304
+ if (this.updateInFlight !== entry) return;
305
+ this.updateInFlight = null;
306
+ entry.resolveAck(false);
307
+ this.logger.warn("session.update not acknowledged in time — desyncing the connection", { timeoutMs });
308
+ this.ws?.terminate();
309
+ }, timeoutMs);
310
+ entry.ackTimer.unref?.();
311
+ }
312
+ /** Settle every queued/in-flight update as not-acked (socket gone/replaced). */
313
+ failPendingUpdates() {
314
+ const inFlight = this.updateInFlight;
315
+ this.updateInFlight = null;
316
+ if (inFlight) {
317
+ if (inFlight.ackTimer) clearTimeout(inFlight.ackTimer);
318
+ inFlight.resolveAck(false);
319
+ }
320
+ const queued = this.updateQueue;
321
+ this.updateQueue = [];
322
+ for (const entry of queued) {
323
+ entry.resolveSent(false);
324
+ entry.resolveAck(false);
325
+ }
249
326
  }
250
327
  buildSessionPayload() {
251
328
  return (this.config.buildSession ?? buildSessionUpdate)(this.sessionInit, {
@@ -267,6 +344,16 @@ var OpenAICompatibleProvider = class extends BaseRealtimeProvider {
267
344
  }
268
345
  handleEvent(event) {
269
346
  switch (event.type) {
347
+ case "session.updated": {
348
+ const entry = this.updateInFlight;
349
+ if (!entry) break;
350
+ this.updateInFlight = null;
351
+ if (entry.ackTimer) clearTimeout(entry.ackTimer);
352
+ if (entry.touchesVad) this.effectiveVadValue = entry.resolvedVad;
353
+ entry.resolveAck(true);
354
+ this.pumpUpdateQueue();
355
+ break;
356
+ }
270
357
  case "response.created": {
271
358
  const responseId = event.response?.id ?? `resp_${Date.now()}`;
272
359
  this.currentResponseId = responseId;
@@ -1,6 +1,6 @@
1
1
  const require_rolldown_runtime = require("./rolldown-runtime-VH7oDXx4.cjs");
2
- const require_BaseRealtimeProvider = require("./BaseRealtimeProvider-DI4pKtOb.cjs");
3
- const require_session_config = require("./session-config-c8sOw1XL.cjs");
2
+ const require_BaseRealtimeProvider = require("./BaseRealtimeProvider-C2mRn1V_.cjs");
3
+ const require_session_config = require("./session-config-CqJm2Kxz.cjs");
4
4
  let ws = require("ws");
5
5
  ws = require_rolldown_runtime.__toESM(ws, 1);
6
6
  //#region src/providers/openai-compatible/OpenAICompatibleProvider.ts
@@ -43,6 +43,10 @@ var OpenAICompatibleProvider = class extends require_BaseRealtimeProvider.BaseRe
43
43
  responseActive = false;
44
44
  pendingCreate = null;
45
45
  lastCreateSent = null;
46
+ /** Serialized-update pipeline (enabled via init.serializedSessionUpdates). */
47
+ serializedUpdates = false;
48
+ updateQueue = [];
49
+ updateInFlight = null;
46
50
  constructor(config, logger = require_BaseRealtimeProvider.noopLogger) {
47
51
  super();
48
52
  this.config = config;
@@ -64,14 +68,11 @@ var OpenAICompatibleProvider = class extends require_BaseRealtimeProvider.BaseRe
64
68
  }
65
69
  async connect(init) {
66
70
  if (this.ws) await this.close();
67
- let vad = init.vad !== void 0 ? init.vad : this.config.defaultVad;
68
- if (init.bridgeOwnsInterruptions && this.capabilities.vadInterruptControl && vad !== null) vad = {
69
- interruptResponse: false,
70
- ...vad ?? { type: "server" }
71
- };
71
+ this.failPendingUpdates();
72
+ this.serializedUpdates = init.serializedSessionUpdates === true;
72
73
  this.sessionInit = {
73
74
  ...init,
74
- vad,
75
+ vad: this.resolveVad(init.vad, init.bridgeOwnsInterruptions),
75
76
  transcription: init.transcription !== void 0 ? init.transcription : this.config.defaultTranscription
76
77
  };
77
78
  this.intentionalClose = false;
@@ -150,6 +151,7 @@ var OpenAICompatibleProvider = class extends require_BaseRealtimeProvider.BaseRe
150
151
  ws$1.on("close", (code, reasonBuf) => {
151
152
  const reason = reasonBuf?.toString();
152
153
  this.ready = false;
154
+ this.failPendingUpdates();
153
155
  if (!settled) {
154
156
  fail(/* @__PURE__ */ new Error(`${this.name} socket closed during setup (${code} ${reason ?? ""})`));
155
157
  return;
@@ -161,6 +163,7 @@ var OpenAICompatibleProvider = class extends require_BaseRealtimeProvider.BaseRe
161
163
  });
162
164
  });
163
165
  });
166
+ this.effectiveVadValue = this.sessionInit.vad;
164
167
  }
165
168
  async close() {
166
169
  this.intentionalClose = true;
@@ -241,13 +244,87 @@ var OpenAICompatibleProvider = class extends require_BaseRealtimeProvider.BaseRe
241
244
  ...create.instructions ? { response: { instructions: create.instructions } } : {}
242
245
  });
243
246
  }
244
- async updateSession(patch) {
247
+ async updateSession(patch, options = {}) {
245
248
  if (!this.sessionInit) throw new Error("updateSession before connect");
249
+ let resolved = patch;
250
+ if ("vad" in patch) resolved = {
251
+ ...patch,
252
+ vad: this.resolveVad(patch.vad, this.sessionInit.bridgeOwnsInterruptions)
253
+ };
246
254
  this.sessionInit = {
247
255
  ...this.sessionInit,
248
- ...patch
256
+ ...resolved
257
+ };
258
+ if (!this.serializedUpdates) {
259
+ const open = this.ws?.readyState === ws.default.OPEN;
260
+ this.send(this.buildSessionPayload());
261
+ return open;
262
+ }
263
+ let resolveSent;
264
+ let resolveAck;
265
+ const sent = new Promise((resolve) => resolveSent = resolve);
266
+ const acked = new Promise((resolve) => resolveAck = resolve);
267
+ this.updateQueue.push({
268
+ payload: this.buildSessionPayload(),
269
+ touchesVad: "vad" in resolved,
270
+ resolvedVad: resolved.vad,
271
+ resolveSent,
272
+ resolveAck,
273
+ ackTimer: null
274
+ });
275
+ this.pumpUpdateQueue();
276
+ return options.awaitAck ? acked : sent;
277
+ }
278
+ /**
279
+ * Bridge-owned barge-in: the server must NOT auto-cancel the active
280
+ * response on speech onset, or a guard-blocked interruption still kills
281
+ * the sentence mid-air. An explicit vad.interruptResponse wins (spread
282
+ * order). Applied on connect AND on every vad patch.
283
+ */
284
+ resolveVad(vad, bridgeOwnsInterruptions) {
285
+ let resolved = vad !== void 0 ? vad : this.config.defaultVad;
286
+ if (bridgeOwnsInterruptions && this.capabilities.vadInterruptControl && resolved !== null) resolved = {
287
+ interruptResponse: false,
288
+ ...resolved ?? { type: "server" }
249
289
  };
250
- this.send(this.buildSessionPayload());
290
+ return resolved;
291
+ }
292
+ /** Send the next queued update once nothing is awaiting its ack. */
293
+ pumpUpdateQueue() {
294
+ if (this.updateInFlight || this.updateQueue.length === 0) return;
295
+ if (this.ws?.readyState !== ws.default.OPEN) {
296
+ this.failPendingUpdates();
297
+ return;
298
+ }
299
+ const entry = this.updateQueue.shift();
300
+ this.updateInFlight = entry;
301
+ this.send(entry.payload);
302
+ entry.resolveSent(true);
303
+ const timeoutMs = this.config.sessionUpdateAckTimeoutMs ?? 3e3;
304
+ entry.ackTimer = setTimeout(() => {
305
+ entry.ackTimer = null;
306
+ if (this.updateInFlight !== entry) return;
307
+ this.updateInFlight = null;
308
+ entry.resolveAck(false);
309
+ this.logger.warn("session.update not acknowledged in time — desyncing the connection", { timeoutMs });
310
+ this.ws?.terminate();
311
+ }, timeoutMs);
312
+ entry.ackTimer.unref?.();
313
+ }
314
+ /** Settle every queued/in-flight update as not-acked (socket gone/replaced). */
315
+ failPendingUpdates() {
316
+ const inFlight = this.updateInFlight;
317
+ this.updateInFlight = null;
318
+ if (inFlight) {
319
+ if (inFlight.ackTimer) clearTimeout(inFlight.ackTimer);
320
+ inFlight.resolveAck(false);
321
+ }
322
+ const queued = this.updateQueue;
323
+ this.updateQueue = [];
324
+ for (const entry of queued) {
325
+ entry.resolveSent(false);
326
+ entry.resolveAck(false);
327
+ }
251
328
  }
252
329
  buildSessionPayload() {
253
330
  return (this.config.buildSession ?? require_session_config.buildSessionUpdate)(this.sessionInit, {
@@ -269,6 +346,16 @@ var OpenAICompatibleProvider = class extends require_BaseRealtimeProvider.BaseRe
269
346
  }
270
347
  handleEvent(event) {
271
348
  switch (event.type) {
349
+ case "session.updated": {
350
+ const entry = this.updateInFlight;
351
+ if (!entry) break;
352
+ this.updateInFlight = null;
353
+ if (entry.ackTimer) clearTimeout(entry.ackTimer);
354
+ if (entry.touchesVad) this.effectiveVadValue = entry.resolvedVad;
355
+ entry.resolveAck(true);
356
+ this.pumpUpdateQueue();
357
+ break;
358
+ }
272
359
  case "response.created": {
273
360
  const responseId = event.response?.id ?? `resp_${Date.now()}`;
274
361
  this.currentResponseId = responseId;