realtime-voice-agents 2.1.0 → 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.
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
@@ -252,7 +272,7 @@ Bundled presets (all synthesized, license-free, seamless loops): `elevator-jazz`
252
272
 
253
273
  ## Events (session)
254
274
 
255
- `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` · `vad.suggestion` / `vad.adjusted` (noise-adaptive VAD) · `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`.
256
276
 
257
277
  ```ts
258
278
  bridge.on('session.started', (session) => {
@@ -296,7 +316,7 @@ Outbound calls: the greeting waits for a human — feed your status callback int
296
316
  `realtime-voice-agents/testing` ships the harness this package is tested with:
297
317
 
298
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.
299
- - **`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).
300
320
  - **`FakeGeminiLive`** — a scripted `@google/genai` seam for the Gemini provider.
301
321
 
302
322
  ```ts
package/dist/gemini.cjs CHANGED
@@ -373,12 +373,14 @@ const GEMINI_VOICES = [
373
373
  "Kore",
374
374
  "Puck"
375
375
  ];
376
- /** Create a Gemini Live provider factory for the bridge. */
376
+ /**
377
+ * Create a Gemini Live provider factory for the bridge. Credentials are
378
+ * resolved per call (see `openaiRealtime` — missing credentials fail that
379
+ * call's connect so a fallback chain can absorb it, instead of crashing
380
+ * config construction).
381
+ */
377
382
  function geminiLive(options = {}) {
378
- const apiKey = require_env.resolveApiKey(options.apiKey, GEMINI_KEY_ENV_VARS);
379
- if (!apiKey && !options.vertex && !options.connector) throw new Error(`geminiLive: credentials missing (pass apiKey, set one of ${GEMINI_KEY_ENV_VARS.join("/")}, or configure vertex)`);
380
383
  const config = {
381
- apiKey,
382
384
  vertex: options.vertex,
383
385
  model: options.model ?? "gemini-2.5-flash-native-audio-preview-12-2025",
384
386
  voice: options.voice ?? "Aoede",
@@ -391,7 +393,14 @@ function geminiLive(options = {}) {
391
393
  connectTimeoutMs: options.connectTimeoutMs,
392
394
  connector: options.connector
393
395
  };
394
- return ({ logger }) => new GeminiLiveProvider(config, logger);
396
+ return ({ logger }) => {
397
+ const apiKey = require_env.resolveApiKey(options.apiKey, GEMINI_KEY_ENV_VARS);
398
+ if (!apiKey && !options.vertex && !options.connector) throw new Error(`geminiLive: credentials missing (pass apiKey, set one of ${GEMINI_KEY_ENV_VARS.join("/")}, or configure vertex)`);
399
+ return new GeminiLiveProvider({
400
+ ...config,
401
+ apiKey
402
+ }, logger);
403
+ };
395
404
  }
396
405
  //#endregion
397
406
  exports.GEMINI_DEFAULT_MODEL = GEMINI_DEFAULT_MODEL;
package/dist/gemini.d.cts CHANGED
@@ -35,7 +35,12 @@ interface GeminiLiveOptions {
35
35
  /** Test seam: replaces the @google/genai live connection. */
36
36
  connector?: GeminiLiveConnector;
37
37
  }
38
- /** Create a Gemini Live provider factory for the bridge. */
38
+ /**
39
+ * Create a Gemini Live provider factory for the bridge. Credentials are
40
+ * resolved per call (see `openaiRealtime` — missing credentials fail that
41
+ * call's connect so a fallback chain can absorb it, instead of crashing
42
+ * config construction).
43
+ */
39
44
  declare function geminiLive(options?: GeminiLiveOptions): ProviderFactory;
40
45
  //#endregion
41
46
  export { GEMINI_DEFAULT_MODEL, GEMINI_DEFAULT_VOICE, GEMINI_KEY_ENV_VARS, GEMINI_VOICES, type GeminiConnectParams, type GeminiLiveConnector, GeminiLiveOptions, GeminiLiveProvider, type GeminiLiveProviderConfig, type GeminiLiveSessionLike, geminiLive };
package/dist/gemini.d.mts CHANGED
@@ -35,7 +35,12 @@ interface GeminiLiveOptions {
35
35
  /** Test seam: replaces the @google/genai live connection. */
36
36
  connector?: GeminiLiveConnector;
37
37
  }
38
- /** Create a Gemini Live provider factory for the bridge. */
38
+ /**
39
+ * Create a Gemini Live provider factory for the bridge. Credentials are
40
+ * resolved per call (see `openaiRealtime` — missing credentials fail that
41
+ * call's connect so a fallback chain can absorb it, instead of crashing
42
+ * config construction).
43
+ */
39
44
  declare function geminiLive(options?: GeminiLiveOptions): ProviderFactory;
40
45
  //#endregion
41
46
  export { GEMINI_DEFAULT_MODEL, GEMINI_DEFAULT_VOICE, GEMINI_KEY_ENV_VARS, GEMINI_VOICES, type GeminiConnectParams, type GeminiLiveConnector, GeminiLiveOptions, GeminiLiveProvider, type GeminiLiveProviderConfig, type GeminiLiveSessionLike, geminiLive };
package/dist/gemini.mjs CHANGED
@@ -372,12 +372,14 @@ const GEMINI_VOICES = [
372
372
  "Kore",
373
373
  "Puck"
374
374
  ];
375
- /** Create a Gemini Live provider factory for the bridge. */
375
+ /**
376
+ * Create a Gemini Live provider factory for the bridge. Credentials are
377
+ * resolved per call (see `openaiRealtime` — missing credentials fail that
378
+ * call's connect so a fallback chain can absorb it, instead of crashing
379
+ * config construction).
380
+ */
376
381
  function geminiLive(options = {}) {
377
- const apiKey = resolveApiKey(options.apiKey, GEMINI_KEY_ENV_VARS);
378
- if (!apiKey && !options.vertex && !options.connector) throw new Error(`geminiLive: credentials missing (pass apiKey, set one of ${GEMINI_KEY_ENV_VARS.join("/")}, or configure vertex)`);
379
382
  const config = {
380
- apiKey,
381
383
  vertex: options.vertex,
382
384
  model: options.model ?? "gemini-2.5-flash-native-audio-preview-12-2025",
383
385
  voice: options.voice ?? "Aoede",
@@ -390,7 +392,14 @@ function geminiLive(options = {}) {
390
392
  connectTimeoutMs: options.connectTimeoutMs,
391
393
  connector: options.connector
392
394
  };
393
- return ({ logger }) => new GeminiLiveProvider(config, logger);
395
+ return ({ logger }) => {
396
+ const apiKey = resolveApiKey(options.apiKey, GEMINI_KEY_ENV_VARS);
397
+ if (!apiKey && !options.vertex && !options.connector) throw new Error(`geminiLive: credentials missing (pass apiKey, set one of ${GEMINI_KEY_ENV_VARS.join("/")}, or configure vertex)`);
398
+ return new GeminiLiveProvider({
399
+ ...config,
400
+ apiKey
401
+ }, logger);
402
+ };
394
403
  }
395
404
  //#endregion
396
405
  export { GEMINI_DEFAULT_MODEL, GEMINI_DEFAULT_VOICE, GEMINI_KEY_ENV_VARS, GEMINI_VOICES, GeminiLiveProvider, geminiLive };
package/dist/index.cjs CHANGED
@@ -907,6 +907,8 @@ var CallSession = class extends require_events.TypedEmitter {
907
907
  runningTools = /* @__PURE__ */ new Map();
908
908
  timers = /* @__PURE__ */ new Set();
909
909
  provider = null;
910
+ /** Primary + fallbacks, in try-order. Only walked while connecting. */
911
+ providerChain;
910
912
  activeAgentValue;
911
913
  generating = false;
912
914
  currentResponseId = null;
@@ -965,6 +967,7 @@ var CallSession = class extends require_events.TypedEmitter {
965
967
  this.callSid = deps.start.start.callSid;
966
968
  this.streamSid = deps.start.start.streamSid ?? deps.start.streamSid;
967
969
  this.log = require_BaseRealtimeProvider.childLogger(deps.logger, { callSid: this.callSid });
970
+ this.providerChain = [deps.providerFactory, ...deps.fallbacks ?? []];
968
971
  this.activeAgentValue = deps.agent;
969
972
  this.context = new SessionContext(deps.options.context);
970
973
  this.interruptions = new InterruptionController(deps.options.interruptions);
@@ -1003,17 +1006,7 @@ var CallSession = class extends require_events.TypedEmitter {
1003
1006
  /** Connect the provider and activate the call. Called by the bridge. */
1004
1007
  async begin() {
1005
1008
  this.playPreGreeting();
1006
- try {
1007
- this.provider = this.deps.providerFactory({
1008
- logger: this.log,
1009
- callSid: this.callSid
1010
- });
1011
- this.wireProvider(this.provider);
1012
- await this.provider.connect(this.buildProviderInit());
1013
- } catch (error) {
1014
- this.fail(error instanceof Error ? error : new Error(String(error)), "provider-failed");
1015
- return;
1016
- }
1009
+ if (!await this.connectInitialProvider()) return;
1017
1010
  if (this.stateValue !== "connecting") return;
1018
1011
  this.stateValue = "active";
1019
1012
  this.emit("provider.connected");
@@ -1039,6 +1032,55 @@ var CallSession = class extends require_events.TypedEmitter {
1039
1032
  this.saveSnapshot();
1040
1033
  this.maybeGreet();
1041
1034
  }
1035
+ /**
1036
+ * Walk primary + fallbacks until one connects. A provider that fails to
1037
+ * come up is detached BEFORE the chain advances, so a half-open socket's
1038
+ * late events can never reach the session once the next provider owns the
1039
+ * call. Connect-time only by design: once a provider has answered, the
1040
+ * call stays with it. Returns false after failing the call (chain
1041
+ * exhausted) or when the call was torn down while connecting.
1042
+ */
1043
+ async connectInitialProvider() {
1044
+ let lastFrom = "provider";
1045
+ let lastError = null;
1046
+ for (const factory of this.providerChain) {
1047
+ let provider;
1048
+ try {
1049
+ provider = factory({
1050
+ logger: this.log,
1051
+ callSid: this.callSid
1052
+ });
1053
+ } catch (error) {
1054
+ lastError = toError(error);
1055
+ this.log.warn("provider factory threw — trying the next fallback", { error: String(lastError) });
1056
+ continue;
1057
+ }
1058
+ if (lastError) this.emit("provider.fallback", {
1059
+ from: lastFrom,
1060
+ to: provider.name,
1061
+ error: lastError
1062
+ });
1063
+ this.provider = provider;
1064
+ this.wireProvider(provider);
1065
+ try {
1066
+ await provider.connect(this.buildProviderInit());
1067
+ return true;
1068
+ } catch (error) {
1069
+ provider.removeAllListeners();
1070
+ provider.close().catch(() => {});
1071
+ this.provider = null;
1072
+ if (this.stateValue !== "connecting") return false;
1073
+ lastFrom = provider.name;
1074
+ lastError = toError(error);
1075
+ this.log.warn("provider failed to connect", {
1076
+ provider: provider.name,
1077
+ error: String(lastError)
1078
+ });
1079
+ }
1080
+ }
1081
+ this.fail(lastError ?? /* @__PURE__ */ new Error("provider connect failed"), "provider-failed");
1082
+ return false;
1083
+ }
1042
1084
  /** The outbound leg was answered (host's Twilio status callback). */
1043
1085
  notifyAnswered() {
1044
1086
  if (this.answered) return;
@@ -2159,6 +2201,9 @@ var CallSession = class extends require_events.TypedEmitter {
2159
2201
  const PREGREETING_MARK = "pre:greeting";
2160
2202
  /** 400ms per frame — matches production burst-write implementations. */
2161
2203
  const PREGREETING_CHUNK_BYTES = 3200;
2204
+ function toError(value) {
2205
+ return value instanceof Error ? value : new Error(String(value));
2206
+ }
2162
2207
  function safeJsonStringify(value) {
2163
2208
  try {
2164
2209
  return JSON.stringify(value) ?? "null";
@@ -2293,6 +2338,7 @@ var TwilioRealtimeBridge = class extends require_events.TypedEmitter {
2293
2338
  transport,
2294
2339
  start,
2295
2340
  providerFactory: this.config.provider,
2341
+ fallbacks: this.config.fallbacks,
2296
2342
  agent,
2297
2343
  options,
2298
2344
  store: this.store,
package/dist/index.d.cts CHANGED
@@ -535,6 +535,19 @@ interface BridgeConfig {
535
535
  /** The (root) agent, or a resolver for multi-tenant routing per call. */
536
536
  agent: Agent | ((start: TwilioStartEvent) => Agent | Promise<Agent>);
537
537
  provider: ProviderFactory;
538
+ /**
539
+ * Backup providers tried in order when `provider` fails to come up at call
540
+ * start (bad key, exhausted quota, outage, connect timeout). Each attempt
541
+ * emits `provider.fallback`; the call fails only when every fallback is
542
+ * exhausted. Connect-time only: once a provider answers, the call stays
543
+ * with it (mid-call reconnects keep using the same provider).
544
+ *
545
+ * ```ts
546
+ * provider: openaiRealtime(),
547
+ * fallbacks: [xaiRealtime(), geminiLive()],
548
+ * ```
549
+ */
550
+ fallbacks?: ProviderFactory[];
538
551
  session?: Partial<SessionOptions> | ((start: TwilioStartEvent) => Partial<SessionOptions> | Promise<Partial<SessionOptions>>);
539
552
  /** Twilio REST credentials — enables clean hangup, transfer, SMS/WhatsApp. */
540
553
  twilio?: TwilioRestOptions;
@@ -593,6 +606,19 @@ interface ApprovalRequestInfo {
593
606
  type VadSuggestionInfo = VadAdjustment & {
594
607
  willAutoApply: boolean;
595
608
  };
609
+ /**
610
+ * While ESTABLISHING the call, a provider failed to come up and the session
611
+ * is trying the next factory in the fallback chain. Fires only before the
612
+ * call is active — once a provider has connected, the call stays with it.
613
+ */
614
+ interface ProviderFallbackInfo {
615
+ /** Name of the provider whose connect failed. */
616
+ from: string;
617
+ /** Name of the provider being tried instead. */
618
+ to: string;
619
+ /** The connect error that triggered the fallback. */
620
+ error: Error;
621
+ }
596
622
  interface SessionEventMap {
597
623
  'call.started': (info: CallStartedInfo) => void;
598
624
  'call.ended': (info: {
@@ -611,6 +637,8 @@ interface SessionEventMap {
611
637
  code?: number;
612
638
  reason?: string;
613
639
  }) => void;
640
+ /** Connect-time fallback: trying the next provider in the configured chain. */
641
+ 'provider.fallback': (info: ProviderFallbackInfo) => void;
614
642
  /** Generation-side: the model started/finished producing a response. */
615
643
  'agent.speech.started': (info: {
616
644
  responseId: string;
@@ -683,6 +711,8 @@ interface CallSessionDeps {
683
711
  transport: TwilioMediaTransport;
684
712
  start: TwilioStartEvent;
685
713
  providerFactory: ProviderFactory;
714
+ /** Backup factories tried in order when `providerFactory` fails to connect. */
715
+ fallbacks?: readonly ProviderFactory[];
686
716
  agent: Agent;
687
717
  options: SessionOptions;
688
718
  store: SessionStore;
@@ -713,6 +743,8 @@ declare class CallSession extends TypedEmitter<SessionEventMap> {
713
743
  private readonly runningTools;
714
744
  private readonly timers;
715
745
  private provider;
746
+ /** Primary + fallbacks, in try-order. Only walked while connecting. */
747
+ private readonly providerChain;
716
748
  private activeAgentValue;
717
749
  private generating;
718
750
  private currentResponseId;
@@ -772,6 +804,15 @@ declare class CallSession extends TypedEmitter<SessionEventMap> {
772
804
  get transcript(): readonly TranscriptEntry[];
773
805
  /** Connect the provider and activate the call. Called by the bridge. */
774
806
  begin(): Promise<void>;
807
+ /**
808
+ * Walk primary + fallbacks until one connects. A provider that fails to
809
+ * come up is detached BEFORE the chain advances, so a half-open socket's
810
+ * late events can never reach the session once the next provider owns the
811
+ * call. Connect-time only by design: once a provider has answered, the
812
+ * call stays with it. Returns false after failing the call (chain
813
+ * exhausted) or when the call was torn down while connecting.
814
+ */
815
+ private connectInitialProvider;
775
816
  /** The outbound leg was answered (host's Twilio status callback). */
776
817
  notifyAnswered(): void;
777
818
  sendText(text: string, options?: {
@@ -1085,4 +1126,4 @@ declare class PlaybackTracker {
1085
1126
  private maybeForget;
1086
1127
  }
1087
1128
  //#endregion
1088
- export { Agent, type AgentDefinition, type ApprovalRequestInfo, type BackgroundAudioOptions, type BackgroundAudioPreset, type BackgroundAudioSpec, BaseRealtimeProvider, type BridgeConfig, type BridgeEventMap, type BuiltinToolsConfig, type CallEndReason, CallSession, type CallSessionFacade, type CallSnapshot, type CallStartedInfo, type CallState, type CaptureGreetingOptions, type CapturedGreeting, DEFAULT_RECONNECT_POLICY, DEFAULT_SESSION_OPTIONS, type DeafnessOptions, type FinishCallToolOptions, type GreetingOptions, type HandoffDirective, type HangupOptions, type IdleOptions, InMemorySessionStore, type InferSchema, type InterruptionBlockCause, InterruptionController, type InterruptionDecision, type InterruptionRateLimit, type InterruptionSettings, type Logger, type MarkEchoResult, NoiseAdaptiveVadController, type NoiseAdaptiveVadOptions, PlaybackTracker, type ProviderAudioDelta, type ProviderCapabilities, type ProviderCloseInfo, type ProviderEvents, type ProviderFactory, type ProviderFactoryContext, type ProviderSessionInit, type ProviderToolCall, type ProviderToolSchema, type ProviderUsage, type ReconnectPolicy, type SendTextOptions, type SendToolResultOptions, SessionContext, type SessionEventMap, type SessionOptions, type SessionStore, type SessionUpdateOptions, type Tool, type ToolCallInfo, type ToolContext, type ToolDecoration, type ToolDefinition, type ToolMiddleware, type ToolRunInfo, type ToolStrategy, type TranscriptEntry, type TransferCallToolOptions, TwilioRealtimeBridge, type TwilioRestOptions, type TwilioStartEvent, type UsageInfo, type VadAdjustment, type VadConfig, type VadNoiseMetrics, type VadSuggestionInfo, type VadTuningProfile, type WebSocketLike, type ZodSchemaLike, captureGreetingAudio, collectAgentGraph, composeExecution, connectStreamTwiml, consoleLogger, createFinishCallTool, createHandoffTool, createTransferCallTool, decorateTool, emptyUsage, handoffToolName, isHandoffDirective, noopLogger, resolveSessionOptions, tool, zodToJsonSchema };
1129
+ export { Agent, type AgentDefinition, type ApprovalRequestInfo, type BackgroundAudioOptions, type BackgroundAudioPreset, type BackgroundAudioSpec, BaseRealtimeProvider, type BridgeConfig, type BridgeEventMap, type BuiltinToolsConfig, type CallEndReason, CallSession, type CallSessionFacade, type CallSnapshot, type CallStartedInfo, type CallState, type CaptureGreetingOptions, type CapturedGreeting, DEFAULT_RECONNECT_POLICY, DEFAULT_SESSION_OPTIONS, type DeafnessOptions, type FinishCallToolOptions, type GreetingOptions, type HandoffDirective, type HangupOptions, type IdleOptions, InMemorySessionStore, type InferSchema, type InterruptionBlockCause, InterruptionController, type InterruptionDecision, type InterruptionRateLimit, type InterruptionSettings, type Logger, type MarkEchoResult, NoiseAdaptiveVadController, type NoiseAdaptiveVadOptions, PlaybackTracker, type ProviderAudioDelta, type ProviderCapabilities, type ProviderCloseInfo, type ProviderEvents, type ProviderFactory, type ProviderFactoryContext, type ProviderFallbackInfo, type ProviderSessionInit, type ProviderToolCall, type ProviderToolSchema, type ProviderUsage, type ReconnectPolicy, type SendTextOptions, type SendToolResultOptions, SessionContext, type SessionEventMap, type SessionOptions, type SessionStore, type SessionUpdateOptions, type Tool, type ToolCallInfo, type ToolContext, type ToolDecoration, type ToolDefinition, type ToolMiddleware, type ToolRunInfo, type ToolStrategy, type TranscriptEntry, type TransferCallToolOptions, TwilioRealtimeBridge, type TwilioRestOptions, type TwilioStartEvent, type UsageInfo, type VadAdjustment, type VadConfig, type VadNoiseMetrics, type VadSuggestionInfo, type VadTuningProfile, type WebSocketLike, type ZodSchemaLike, captureGreetingAudio, collectAgentGraph, composeExecution, connectStreamTwiml, consoleLogger, createFinishCallTool, createHandoffTool, createTransferCallTool, decorateTool, emptyUsage, handoffToolName, isHandoffDirective, noopLogger, resolveSessionOptions, tool, zodToJsonSchema };
package/dist/index.d.mts CHANGED
@@ -535,6 +535,19 @@ interface BridgeConfig {
535
535
  /** The (root) agent, or a resolver for multi-tenant routing per call. */
536
536
  agent: Agent | ((start: TwilioStartEvent) => Agent | Promise<Agent>);
537
537
  provider: ProviderFactory;
538
+ /**
539
+ * Backup providers tried in order when `provider` fails to come up at call
540
+ * start (bad key, exhausted quota, outage, connect timeout). Each attempt
541
+ * emits `provider.fallback`; the call fails only when every fallback is
542
+ * exhausted. Connect-time only: once a provider answers, the call stays
543
+ * with it (mid-call reconnects keep using the same provider).
544
+ *
545
+ * ```ts
546
+ * provider: openaiRealtime(),
547
+ * fallbacks: [xaiRealtime(), geminiLive()],
548
+ * ```
549
+ */
550
+ fallbacks?: ProviderFactory[];
538
551
  session?: Partial<SessionOptions> | ((start: TwilioStartEvent) => Partial<SessionOptions> | Promise<Partial<SessionOptions>>);
539
552
  /** Twilio REST credentials — enables clean hangup, transfer, SMS/WhatsApp. */
540
553
  twilio?: TwilioRestOptions;
@@ -593,6 +606,19 @@ interface ApprovalRequestInfo {
593
606
  type VadSuggestionInfo = VadAdjustment & {
594
607
  willAutoApply: boolean;
595
608
  };
609
+ /**
610
+ * While ESTABLISHING the call, a provider failed to come up and the session
611
+ * is trying the next factory in the fallback chain. Fires only before the
612
+ * call is active — once a provider has connected, the call stays with it.
613
+ */
614
+ interface ProviderFallbackInfo {
615
+ /** Name of the provider whose connect failed. */
616
+ from: string;
617
+ /** Name of the provider being tried instead. */
618
+ to: string;
619
+ /** The connect error that triggered the fallback. */
620
+ error: Error;
621
+ }
596
622
  interface SessionEventMap {
597
623
  'call.started': (info: CallStartedInfo) => void;
598
624
  'call.ended': (info: {
@@ -611,6 +637,8 @@ interface SessionEventMap {
611
637
  code?: number;
612
638
  reason?: string;
613
639
  }) => void;
640
+ /** Connect-time fallback: trying the next provider in the configured chain. */
641
+ 'provider.fallback': (info: ProviderFallbackInfo) => void;
614
642
  /** Generation-side: the model started/finished producing a response. */
615
643
  'agent.speech.started': (info: {
616
644
  responseId: string;
@@ -683,6 +711,8 @@ interface CallSessionDeps {
683
711
  transport: TwilioMediaTransport;
684
712
  start: TwilioStartEvent;
685
713
  providerFactory: ProviderFactory;
714
+ /** Backup factories tried in order when `providerFactory` fails to connect. */
715
+ fallbacks?: readonly ProviderFactory[];
686
716
  agent: Agent;
687
717
  options: SessionOptions;
688
718
  store: SessionStore;
@@ -713,6 +743,8 @@ declare class CallSession extends TypedEmitter<SessionEventMap> {
713
743
  private readonly runningTools;
714
744
  private readonly timers;
715
745
  private provider;
746
+ /** Primary + fallbacks, in try-order. Only walked while connecting. */
747
+ private readonly providerChain;
716
748
  private activeAgentValue;
717
749
  private generating;
718
750
  private currentResponseId;
@@ -772,6 +804,15 @@ declare class CallSession extends TypedEmitter<SessionEventMap> {
772
804
  get transcript(): readonly TranscriptEntry[];
773
805
  /** Connect the provider and activate the call. Called by the bridge. */
774
806
  begin(): Promise<void>;
807
+ /**
808
+ * Walk primary + fallbacks until one connects. A provider that fails to
809
+ * come up is detached BEFORE the chain advances, so a half-open socket's
810
+ * late events can never reach the session once the next provider owns the
811
+ * call. Connect-time only by design: once a provider has answered, the
812
+ * call stays with it. Returns false after failing the call (chain
813
+ * exhausted) or when the call was torn down while connecting.
814
+ */
815
+ private connectInitialProvider;
775
816
  /** The outbound leg was answered (host's Twilio status callback). */
776
817
  notifyAnswered(): void;
777
818
  sendText(text: string, options?: {
@@ -1085,4 +1126,4 @@ declare class PlaybackTracker {
1085
1126
  private maybeForget;
1086
1127
  }
1087
1128
  //#endregion
1088
- export { Agent, type AgentDefinition, type ApprovalRequestInfo, type BackgroundAudioOptions, type BackgroundAudioPreset, type BackgroundAudioSpec, BaseRealtimeProvider, type BridgeConfig, type BridgeEventMap, type BuiltinToolsConfig, type CallEndReason, CallSession, type CallSessionFacade, type CallSnapshot, type CallStartedInfo, type CallState, type CaptureGreetingOptions, type CapturedGreeting, DEFAULT_RECONNECT_POLICY, DEFAULT_SESSION_OPTIONS, type DeafnessOptions, type FinishCallToolOptions, type GreetingOptions, type HandoffDirective, type HangupOptions, type IdleOptions, InMemorySessionStore, type InferSchema, type InterruptionBlockCause, InterruptionController, type InterruptionDecision, type InterruptionRateLimit, type InterruptionSettings, type Logger, type MarkEchoResult, NoiseAdaptiveVadController, type NoiseAdaptiveVadOptions, PlaybackTracker, type ProviderAudioDelta, type ProviderCapabilities, type ProviderCloseInfo, type ProviderEvents, type ProviderFactory, type ProviderFactoryContext, type ProviderSessionInit, type ProviderToolCall, type ProviderToolSchema, type ProviderUsage, type ReconnectPolicy, type SendTextOptions, type SendToolResultOptions, SessionContext, type SessionEventMap, type SessionOptions, type SessionStore, type SessionUpdateOptions, type Tool, type ToolCallInfo, type ToolContext, type ToolDecoration, type ToolDefinition, type ToolMiddleware, type ToolRunInfo, type ToolStrategy, type TranscriptEntry, type TransferCallToolOptions, TwilioRealtimeBridge, type TwilioRestOptions, type TwilioStartEvent, type UsageInfo, type VadAdjustment, type VadConfig, type VadNoiseMetrics, type VadSuggestionInfo, type VadTuningProfile, type WebSocketLike, type ZodSchemaLike, captureGreetingAudio, collectAgentGraph, composeExecution, connectStreamTwiml, consoleLogger, createFinishCallTool, createHandoffTool, createTransferCallTool, decorateTool, emptyUsage, handoffToolName, isHandoffDirective, noopLogger, resolveSessionOptions, tool, zodToJsonSchema };
1129
+ export { Agent, type AgentDefinition, type ApprovalRequestInfo, type BackgroundAudioOptions, type BackgroundAudioPreset, type BackgroundAudioSpec, BaseRealtimeProvider, type BridgeConfig, type BridgeEventMap, type BuiltinToolsConfig, type CallEndReason, CallSession, type CallSessionFacade, type CallSnapshot, type CallStartedInfo, type CallState, type CaptureGreetingOptions, type CapturedGreeting, DEFAULT_RECONNECT_POLICY, DEFAULT_SESSION_OPTIONS, type DeafnessOptions, type FinishCallToolOptions, type GreetingOptions, type HandoffDirective, type HangupOptions, type IdleOptions, InMemorySessionStore, type InferSchema, type InterruptionBlockCause, InterruptionController, type InterruptionDecision, type InterruptionRateLimit, type InterruptionSettings, type Logger, type MarkEchoResult, NoiseAdaptiveVadController, type NoiseAdaptiveVadOptions, PlaybackTracker, type ProviderAudioDelta, type ProviderCapabilities, type ProviderCloseInfo, type ProviderEvents, type ProviderFactory, type ProviderFactoryContext, type ProviderFallbackInfo, type ProviderSessionInit, type ProviderToolCall, type ProviderToolSchema, type ProviderUsage, type ReconnectPolicy, type SendTextOptions, type SendToolResultOptions, SessionContext, type SessionEventMap, type SessionOptions, type SessionStore, type SessionUpdateOptions, type Tool, type ToolCallInfo, type ToolContext, type ToolDecoration, type ToolDefinition, type ToolMiddleware, type ToolRunInfo, type ToolStrategy, type TranscriptEntry, type TransferCallToolOptions, TwilioRealtimeBridge, type TwilioRestOptions, type TwilioStartEvent, type UsageInfo, type VadAdjustment, type VadConfig, type VadNoiseMetrics, type VadSuggestionInfo, type VadTuningProfile, type WebSocketLike, type ZodSchemaLike, captureGreetingAudio, collectAgentGraph, composeExecution, connectStreamTwiml, consoleLogger, createFinishCallTool, createHandoffTool, createTransferCallTool, decorateTool, emptyUsage, handoffToolName, isHandoffDirective, noopLogger, resolveSessionOptions, tool, zodToJsonSchema };
package/dist/index.mjs CHANGED
@@ -903,6 +903,8 @@ var CallSession = class extends TypedEmitter {
903
903
  runningTools = /* @__PURE__ */ new Map();
904
904
  timers = /* @__PURE__ */ new Set();
905
905
  provider = null;
906
+ /** Primary + fallbacks, in try-order. Only walked while connecting. */
907
+ providerChain;
906
908
  activeAgentValue;
907
909
  generating = false;
908
910
  currentResponseId = null;
@@ -961,6 +963,7 @@ var CallSession = class extends TypedEmitter {
961
963
  this.callSid = deps.start.start.callSid;
962
964
  this.streamSid = deps.start.start.streamSid ?? deps.start.streamSid;
963
965
  this.log = childLogger(deps.logger, { callSid: this.callSid });
966
+ this.providerChain = [deps.providerFactory, ...deps.fallbacks ?? []];
964
967
  this.activeAgentValue = deps.agent;
965
968
  this.context = new SessionContext(deps.options.context);
966
969
  this.interruptions = new InterruptionController(deps.options.interruptions);
@@ -999,17 +1002,7 @@ var CallSession = class extends TypedEmitter {
999
1002
  /** Connect the provider and activate the call. Called by the bridge. */
1000
1003
  async begin() {
1001
1004
  this.playPreGreeting();
1002
- try {
1003
- this.provider = this.deps.providerFactory({
1004
- logger: this.log,
1005
- callSid: this.callSid
1006
- });
1007
- this.wireProvider(this.provider);
1008
- await this.provider.connect(this.buildProviderInit());
1009
- } catch (error) {
1010
- this.fail(error instanceof Error ? error : new Error(String(error)), "provider-failed");
1011
- return;
1012
- }
1005
+ if (!await this.connectInitialProvider()) return;
1013
1006
  if (this.stateValue !== "connecting") return;
1014
1007
  this.stateValue = "active";
1015
1008
  this.emit("provider.connected");
@@ -1035,6 +1028,55 @@ var CallSession = class extends TypedEmitter {
1035
1028
  this.saveSnapshot();
1036
1029
  this.maybeGreet();
1037
1030
  }
1031
+ /**
1032
+ * Walk primary + fallbacks until one connects. A provider that fails to
1033
+ * come up is detached BEFORE the chain advances, so a half-open socket's
1034
+ * late events can never reach the session once the next provider owns the
1035
+ * call. Connect-time only by design: once a provider has answered, the
1036
+ * call stays with it. Returns false after failing the call (chain
1037
+ * exhausted) or when the call was torn down while connecting.
1038
+ */
1039
+ async connectInitialProvider() {
1040
+ let lastFrom = "provider";
1041
+ let lastError = null;
1042
+ for (const factory of this.providerChain) {
1043
+ let provider;
1044
+ try {
1045
+ provider = factory({
1046
+ logger: this.log,
1047
+ callSid: this.callSid
1048
+ });
1049
+ } catch (error) {
1050
+ lastError = toError(error);
1051
+ this.log.warn("provider factory threw — trying the next fallback", { error: String(lastError) });
1052
+ continue;
1053
+ }
1054
+ if (lastError) this.emit("provider.fallback", {
1055
+ from: lastFrom,
1056
+ to: provider.name,
1057
+ error: lastError
1058
+ });
1059
+ this.provider = provider;
1060
+ this.wireProvider(provider);
1061
+ try {
1062
+ await provider.connect(this.buildProviderInit());
1063
+ return true;
1064
+ } catch (error) {
1065
+ provider.removeAllListeners();
1066
+ provider.close().catch(() => {});
1067
+ this.provider = null;
1068
+ if (this.stateValue !== "connecting") return false;
1069
+ lastFrom = provider.name;
1070
+ lastError = toError(error);
1071
+ this.log.warn("provider failed to connect", {
1072
+ provider: provider.name,
1073
+ error: String(lastError)
1074
+ });
1075
+ }
1076
+ }
1077
+ this.fail(lastError ?? /* @__PURE__ */ new Error("provider connect failed"), "provider-failed");
1078
+ return false;
1079
+ }
1038
1080
  /** The outbound leg was answered (host's Twilio status callback). */
1039
1081
  notifyAnswered() {
1040
1082
  if (this.answered) return;
@@ -2155,6 +2197,9 @@ var CallSession = class extends TypedEmitter {
2155
2197
  const PREGREETING_MARK = "pre:greeting";
2156
2198
  /** 400ms per frame — matches production burst-write implementations. */
2157
2199
  const PREGREETING_CHUNK_BYTES = 3200;
2200
+ function toError(value) {
2201
+ return value instanceof Error ? value : new Error(String(value));
2202
+ }
2158
2203
  function safeJsonStringify(value) {
2159
2204
  try {
2160
2205
  return JSON.stringify(value) ?? "null";
@@ -2289,6 +2334,7 @@ var TwilioRealtimeBridge = class extends TypedEmitter {
2289
2334
  transport,
2290
2335
  start,
2291
2336
  providerFactory: this.config.provider,
2337
+ fallbacks: this.config.fallbacks,
2292
2338
  agent,
2293
2339
  options,
2294
2340
  store: this.store,
package/dist/openai.cjs CHANGED
@@ -30,12 +30,16 @@ const OPENAI_REALTIME_VOICES = [
30
30
  "shimmer",
31
31
  "verse"
32
32
  ];
33
- /** Create a provider factory for the bridge (`provider: openaiRealtime({...})`). */
33
+ /**
34
+ * Create a provider factory for the bridge (`provider: openaiRealtime({...})`).
35
+ *
36
+ * Credentials are resolved per call, when the factory runs — not while the
37
+ * config is being built. A missing key therefore fails THAT provider's
38
+ * connect (with a clear error) instead of crashing config construction, so a
39
+ * fallback chain (`BridgeConfig.fallbacks`) can absorb it.
40
+ */
34
41
  function openaiRealtime(options = {}) {
35
- const apiKey = require_env.resolveApiKey(options.apiKey, OPENAI_KEY_ENV_VARS);
36
- if (!apiKey) throw new Error(`openaiRealtime: apiKey missing (pass apiKey or set one of ${OPENAI_KEY_ENV_VARS.join("/")})`);
37
42
  const config = {
38
- apiKey,
39
43
  model: options.model ?? "gpt-realtime",
40
44
  voice: options.voice ?? "marin",
41
45
  baseUrl: options.baseUrl,
@@ -52,7 +56,12 @@ function openaiRealtime(options = {}) {
52
56
  } }
53
57
  };
54
58
  return ({ logger }) => {
55
- return new require_OpenAICompatibleProvider.OpenAICompatibleProvider(config, logger);
59
+ const apiKey = require_env.resolveApiKey(options.apiKey, OPENAI_KEY_ENV_VARS);
60
+ if (!apiKey) throw new Error(`openaiRealtime: apiKey missing (pass apiKey or set one of ${OPENAI_KEY_ENV_VARS.join("/")})`);
61
+ return new require_OpenAICompatibleProvider.OpenAICompatibleProvider({
62
+ ...config,
63
+ apiKey
64
+ }, logger);
56
65
  };
57
66
  }
58
67
  //#endregion
package/dist/openai.d.cts CHANGED
@@ -136,7 +136,14 @@ interface OpenAIRealtimeOptions {
136
136
  sessionOptions?: Record<string, unknown>;
137
137
  connectTimeoutMs?: number;
138
138
  }
139
- /** Create a provider factory for the bridge (`provider: openaiRealtime({...})`). */
139
+ /**
140
+ * Create a provider factory for the bridge (`provider: openaiRealtime({...})`).
141
+ *
142
+ * Credentials are resolved per call, when the factory runs — not while the
143
+ * config is being built. A missing key therefore fails THAT provider's
144
+ * connect (with a clear error) instead of crashing config construction, so a
145
+ * fallback chain (`BridgeConfig.fallbacks`) can absorb it.
146
+ */
140
147
  declare function openaiRealtime(options?: OpenAIRealtimeOptions): ProviderFactory;
141
148
  //#endregion
142
149
  export { OPENAI_DEFAULT_MODEL, OPENAI_DEFAULT_VOICE, OPENAI_KEY_ENV_VARS, OPENAI_REALTIME_VOICES, OpenAICompatibleProvider, type OpenAICompatibleProviderConfig, OpenAIRealtimeOptions, OpenAIRealtimeVoice, buildSessionUpdate, buildTurnDetection, openaiRealtime };
package/dist/openai.d.mts CHANGED
@@ -136,7 +136,14 @@ interface OpenAIRealtimeOptions {
136
136
  sessionOptions?: Record<string, unknown>;
137
137
  connectTimeoutMs?: number;
138
138
  }
139
- /** Create a provider factory for the bridge (`provider: openaiRealtime({...})`). */
139
+ /**
140
+ * Create a provider factory for the bridge (`provider: openaiRealtime({...})`).
141
+ *
142
+ * Credentials are resolved per call, when the factory runs — not while the
143
+ * config is being built. A missing key therefore fails THAT provider's
144
+ * connect (with a clear error) instead of crashing config construction, so a
145
+ * fallback chain (`BridgeConfig.fallbacks`) can absorb it.
146
+ */
140
147
  declare function openaiRealtime(options?: OpenAIRealtimeOptions): ProviderFactory;
141
148
  //#endregion
142
149
  export { OPENAI_DEFAULT_MODEL, OPENAI_DEFAULT_VOICE, OPENAI_KEY_ENV_VARS, OPENAI_REALTIME_VOICES, OpenAICompatibleProvider, type OpenAICompatibleProviderConfig, OpenAIRealtimeOptions, OpenAIRealtimeVoice, buildSessionUpdate, buildTurnDetection, openaiRealtime };
package/dist/openai.mjs CHANGED
@@ -29,12 +29,16 @@ const OPENAI_REALTIME_VOICES = [
29
29
  "shimmer",
30
30
  "verse"
31
31
  ];
32
- /** Create a provider factory for the bridge (`provider: openaiRealtime({...})`). */
32
+ /**
33
+ * Create a provider factory for the bridge (`provider: openaiRealtime({...})`).
34
+ *
35
+ * Credentials are resolved per call, when the factory runs — not while the
36
+ * config is being built. A missing key therefore fails THAT provider's
37
+ * connect (with a clear error) instead of crashing config construction, so a
38
+ * fallback chain (`BridgeConfig.fallbacks`) can absorb it.
39
+ */
33
40
  function openaiRealtime(options = {}) {
34
- const apiKey = resolveApiKey(options.apiKey, OPENAI_KEY_ENV_VARS);
35
- if (!apiKey) throw new Error(`openaiRealtime: apiKey missing (pass apiKey or set one of ${OPENAI_KEY_ENV_VARS.join("/")})`);
36
41
  const config = {
37
- apiKey,
38
42
  model: options.model ?? "gpt-realtime",
39
43
  voice: options.voice ?? "marin",
40
44
  baseUrl: options.baseUrl,
@@ -51,7 +55,12 @@ function openaiRealtime(options = {}) {
51
55
  } }
52
56
  };
53
57
  return ({ logger }) => {
54
- return new OpenAICompatibleProvider(config, logger);
58
+ const apiKey = resolveApiKey(options.apiKey, OPENAI_KEY_ENV_VARS);
59
+ if (!apiKey) throw new Error(`openaiRealtime: apiKey missing (pass apiKey or set one of ${OPENAI_KEY_ENV_VARS.join("/")})`);
60
+ return new OpenAICompatibleProvider({
61
+ ...config,
62
+ apiKey
63
+ }, logger);
55
64
  };
56
65
  }
57
66
  //#endregion
package/dist/testing.cjs CHANGED
@@ -358,6 +358,14 @@ var FakeOpenAIConnection = class {
358
358
  };
359
359
  var FakeOpenAIServer = class FakeOpenAIServer {
360
360
  connections = [];
361
+ /** Flip at runtime to start/stop refusing sessions (see the option's docs). */
362
+ refuseConnections;
363
+ /** Sockets closed by `refuseConnections` before a session was created. */
364
+ refusedConnections = 0;
365
+ /** Set at runtime to start rejecting HTTP upgrades; null re-accepts. */
366
+ rejectUpgrade;
367
+ /** Upgrades rejected by `rejectUpgrade`. */
368
+ rejectedUpgrades = 0;
361
369
  wss;
362
370
  options;
363
371
  url;
@@ -365,7 +373,14 @@ var FakeOpenAIServer = class FakeOpenAIServer {
365
373
  this.wss = wss;
366
374
  this.url = url;
367
375
  this.options = options;
376
+ this.refuseConnections = options.refuseConnections ?? false;
377
+ this.rejectUpgrade = options.rejectUpgrade ?? null;
368
378
  wss.on("connection", (socket, request) => {
379
+ if (this.refuseConnections) {
380
+ this.refusedConnections++;
381
+ socket.close(1011, "fake server refusing sessions");
382
+ return;
383
+ }
369
384
  const connection = new FakeOpenAIConnection(this, socket, request.headers);
370
385
  this.connections.push(connection);
371
386
  socket.on("message", (raw) => {
@@ -388,9 +403,19 @@ var FakeOpenAIServer = class FakeOpenAIServer {
388
403
  });
389
404
  }
390
405
  static async start(options = {}) {
406
+ const holder = {};
391
407
  const wss = new ws.WebSocketServer({
392
408
  port: 0,
393
- host: "127.0.0.1"
409
+ host: "127.0.0.1",
410
+ verifyClient: (_info, done) => {
411
+ const reject = holder.server?.rejectUpgrade;
412
+ if (reject) {
413
+ holder.server.rejectedUpgrades++;
414
+ done(false, reject.status, reject.body ?? "rejected by fake server");
415
+ return;
416
+ }
417
+ done(true);
418
+ }
394
419
  });
395
420
  await new Promise((resolve, reject) => {
396
421
  wss.once("listening", resolve);
@@ -398,7 +423,9 @@ var FakeOpenAIServer = class FakeOpenAIServer {
398
423
  });
399
424
  const address = wss.address();
400
425
  if (typeof address === "string" || address === null) throw new Error("no server address");
401
- return new FakeOpenAIServer(wss, `ws://127.0.0.1:${address.port}`, options);
426
+ const server = new FakeOpenAIServer(wss, `ws://127.0.0.1:${address.port}`, options);
427
+ holder.server = server;
428
+ return server;
402
429
  }
403
430
  get latest() {
404
431
  const connection = this.connections[this.connections.length - 1];
@@ -76,6 +76,25 @@ declare class FakeTwilioMediaStream implements WebSocketLike {
76
76
  interface FakeOpenAIServerOptions {
77
77
  /** Auto-ack session.update with session.updated. Default true. */
78
78
  autoAckSessionUpdate?: boolean;
79
+ /**
80
+ * Refuse sessions: close each new socket immediately, before
81
+ * session.created — a provider that is down or rejecting (exercises
82
+ * connect failures and provider fallback chains). Mutable at runtime via
83
+ * the server's `refuseConnections` field; refused sockets are counted in
84
+ * `refusedConnections` and never appear in `connections`.
85
+ */
86
+ refuseConnections?: boolean;
87
+ /**
88
+ * Reject the HTTP upgrade itself with this status + body — how real
89
+ * providers surface an invalid/expired key (401), exhausted credits
90
+ * (403/429), or an internal error (5xx). Mutable at runtime via the
91
+ * server's `rejectUpgrade` field (null re-accepts); rejections are counted
92
+ * in `rejectedUpgrades` and never appear in `connections`.
93
+ */
94
+ rejectUpgrade?: {
95
+ status: number;
96
+ body?: string;
97
+ };
79
98
  }
80
99
  interface FakeAudioResponseOptions {
81
100
  responseId?: string;
@@ -119,6 +138,17 @@ declare class FakeOpenAIConnection {
119
138
  }
120
139
  declare class FakeOpenAIServer {
121
140
  readonly connections: FakeOpenAIConnection[];
141
+ /** Flip at runtime to start/stop refusing sessions (see the option's docs). */
142
+ refuseConnections: boolean;
143
+ /** Sockets closed by `refuseConnections` before a session was created. */
144
+ refusedConnections: number;
145
+ /** Set at runtime to start rejecting HTTP upgrades; null re-accepts. */
146
+ rejectUpgrade: {
147
+ status: number;
148
+ body?: string;
149
+ } | null;
150
+ /** Upgrades rejected by `rejectUpgrade`. */
151
+ rejectedUpgrades: number;
122
152
  private readonly wss;
123
153
  private readonly options;
124
154
  readonly url: string;
@@ -76,6 +76,25 @@ declare class FakeTwilioMediaStream implements WebSocketLike {
76
76
  interface FakeOpenAIServerOptions {
77
77
  /** Auto-ack session.update with session.updated. Default true. */
78
78
  autoAckSessionUpdate?: boolean;
79
+ /**
80
+ * Refuse sessions: close each new socket immediately, before
81
+ * session.created — a provider that is down or rejecting (exercises
82
+ * connect failures and provider fallback chains). Mutable at runtime via
83
+ * the server's `refuseConnections` field; refused sockets are counted in
84
+ * `refusedConnections` and never appear in `connections`.
85
+ */
86
+ refuseConnections?: boolean;
87
+ /**
88
+ * Reject the HTTP upgrade itself with this status + body — how real
89
+ * providers surface an invalid/expired key (401), exhausted credits
90
+ * (403/429), or an internal error (5xx). Mutable at runtime via the
91
+ * server's `rejectUpgrade` field (null re-accepts); rejections are counted
92
+ * in `rejectedUpgrades` and never appear in `connections`.
93
+ */
94
+ rejectUpgrade?: {
95
+ status: number;
96
+ body?: string;
97
+ };
79
98
  }
80
99
  interface FakeAudioResponseOptions {
81
100
  responseId?: string;
@@ -119,6 +138,17 @@ declare class FakeOpenAIConnection {
119
138
  }
120
139
  declare class FakeOpenAIServer {
121
140
  readonly connections: FakeOpenAIConnection[];
141
+ /** Flip at runtime to start/stop refusing sessions (see the option's docs). */
142
+ refuseConnections: boolean;
143
+ /** Sockets closed by `refuseConnections` before a session was created. */
144
+ refusedConnections: number;
145
+ /** Set at runtime to start rejecting HTTP upgrades; null re-accepts. */
146
+ rejectUpgrade: {
147
+ status: number;
148
+ body?: string;
149
+ } | null;
150
+ /** Upgrades rejected by `rejectUpgrade`. */
151
+ rejectedUpgrades: number;
122
152
  private readonly wss;
123
153
  private readonly options;
124
154
  readonly url: string;
package/dist/testing.mjs CHANGED
@@ -357,6 +357,14 @@ var FakeOpenAIConnection = class {
357
357
  };
358
358
  var FakeOpenAIServer = class FakeOpenAIServer {
359
359
  connections = [];
360
+ /** Flip at runtime to start/stop refusing sessions (see the option's docs). */
361
+ refuseConnections;
362
+ /** Sockets closed by `refuseConnections` before a session was created. */
363
+ refusedConnections = 0;
364
+ /** Set at runtime to start rejecting HTTP upgrades; null re-accepts. */
365
+ rejectUpgrade;
366
+ /** Upgrades rejected by `rejectUpgrade`. */
367
+ rejectedUpgrades = 0;
360
368
  wss;
361
369
  options;
362
370
  url;
@@ -364,7 +372,14 @@ var FakeOpenAIServer = class FakeOpenAIServer {
364
372
  this.wss = wss;
365
373
  this.url = url;
366
374
  this.options = options;
375
+ this.refuseConnections = options.refuseConnections ?? false;
376
+ this.rejectUpgrade = options.rejectUpgrade ?? null;
367
377
  wss.on("connection", (socket, request) => {
378
+ if (this.refuseConnections) {
379
+ this.refusedConnections++;
380
+ socket.close(1011, "fake server refusing sessions");
381
+ return;
382
+ }
368
383
  const connection = new FakeOpenAIConnection(this, socket, request.headers);
369
384
  this.connections.push(connection);
370
385
  socket.on("message", (raw) => {
@@ -387,9 +402,19 @@ var FakeOpenAIServer = class FakeOpenAIServer {
387
402
  });
388
403
  }
389
404
  static async start(options = {}) {
405
+ const holder = {};
390
406
  const wss = new WebSocketServer({
391
407
  port: 0,
392
- host: "127.0.0.1"
408
+ host: "127.0.0.1",
409
+ verifyClient: (_info, done) => {
410
+ const reject = holder.server?.rejectUpgrade;
411
+ if (reject) {
412
+ holder.server.rejectedUpgrades++;
413
+ done(false, reject.status, reject.body ?? "rejected by fake server");
414
+ return;
415
+ }
416
+ done(true);
417
+ }
393
418
  });
394
419
  await new Promise((resolve, reject) => {
395
420
  wss.once("listening", resolve);
@@ -397,7 +422,9 @@ var FakeOpenAIServer = class FakeOpenAIServer {
397
422
  });
398
423
  const address = wss.address();
399
424
  if (typeof address === "string" || address === null) throw new Error("no server address");
400
- return new FakeOpenAIServer(wss, `ws://127.0.0.1:${address.port}`, options);
425
+ const server = new FakeOpenAIServer(wss, `ws://127.0.0.1:${address.port}`, options);
426
+ holder.server = server;
427
+ return server;
401
428
  }
402
429
  get latest() {
403
430
  const connection = this.connections[this.connections.length - 1];
package/dist/xai.cjs CHANGED
@@ -59,13 +59,15 @@ function buildXaiSessionUpdate(init, config = {}) {
59
59
  session
60
60
  };
61
61
  }
62
- /** Create an xAI Grok Voice provider factory for the bridge. */
62
+ /**
63
+ * Create an xAI Grok Voice provider factory for the bridge. Credentials are
64
+ * resolved per call (see `openaiRealtime` — a missing key fails that call's
65
+ * connect so a fallback chain can absorb it, instead of crashing config
66
+ * construction).
67
+ */
63
68
  function xaiRealtime(options = {}) {
64
- const apiKey = require_env.resolveApiKey(options.apiKey, XAI_KEY_ENV_VARS);
65
- if (!apiKey) throw new Error(`xaiRealtime: apiKey missing (pass apiKey or set one of ${XAI_KEY_ENV_VARS.join("/")})`);
66
69
  const transcription = options.transcription === false ? false : options.transcription ? { language: options.transcription.languageHint } : void 0;
67
70
  const config = {
68
- apiKey,
69
71
  model: options.model ?? "grok-voice-latest",
70
72
  voice: options.voice ?? "eve",
71
73
  baseUrl: options.baseUrl ?? "wss://api.x.ai/v1/realtime",
@@ -86,7 +88,14 @@ function xaiRealtime(options = {}) {
86
88
  }
87
89
  }
88
90
  };
89
- return ({ logger }) => new require_OpenAICompatibleProvider.OpenAICompatibleProvider(config, logger);
91
+ return ({ logger }) => {
92
+ const apiKey = require_env.resolveApiKey(options.apiKey, XAI_KEY_ENV_VARS);
93
+ if (!apiKey) throw new Error(`xaiRealtime: apiKey missing (pass apiKey or set one of ${XAI_KEY_ENV_VARS.join("/")})`);
94
+ return new require_OpenAICompatibleProvider.OpenAICompatibleProvider({
95
+ ...config,
96
+ apiKey
97
+ }, logger);
98
+ };
90
99
  }
91
100
  //#endregion
92
101
  exports.XAI_BASE_URL = XAI_BASE_URL;
package/dist/xai.d.cts CHANGED
@@ -29,7 +29,12 @@ declare function buildXaiSessionUpdate(init: ProviderSessionInit, config?: {
29
29
  defaultVoice?: string;
30
30
  extraSessionOptions?: Record<string, unknown>;
31
31
  }): Record<string, unknown>;
32
- /** Create an xAI Grok Voice provider factory for the bridge. */
32
+ /**
33
+ * Create an xAI Grok Voice provider factory for the bridge. Credentials are
34
+ * resolved per call (see `openaiRealtime` — a missing key fails that call's
35
+ * connect so a fallback chain can absorb it, instead of crashing config
36
+ * construction).
37
+ */
33
38
  declare function xaiRealtime(options?: XaiRealtimeOptions): ProviderFactory;
34
39
  //#endregion
35
40
  export { XAI_BASE_URL, XAI_DEFAULT_MODEL, XAI_DEFAULT_VOICE, XAI_KEY_ENV_VARS, XaiRealtimeOptions, buildXaiSessionUpdate, xaiRealtime };
package/dist/xai.d.mts CHANGED
@@ -29,7 +29,12 @@ declare function buildXaiSessionUpdate(init: ProviderSessionInit, config?: {
29
29
  defaultVoice?: string;
30
30
  extraSessionOptions?: Record<string, unknown>;
31
31
  }): Record<string, unknown>;
32
- /** Create an xAI Grok Voice provider factory for the bridge. */
32
+ /**
33
+ * Create an xAI Grok Voice provider factory for the bridge. Credentials are
34
+ * resolved per call (see `openaiRealtime` — a missing key fails that call's
35
+ * connect so a fallback chain can absorb it, instead of crashing config
36
+ * construction).
37
+ */
33
38
  declare function xaiRealtime(options?: XaiRealtimeOptions): ProviderFactory;
34
39
  //#endregion
35
40
  export { XAI_BASE_URL, XAI_DEFAULT_MODEL, XAI_DEFAULT_VOICE, XAI_KEY_ENV_VARS, XaiRealtimeOptions, buildXaiSessionUpdate, xaiRealtime };
package/dist/xai.mjs CHANGED
@@ -58,13 +58,15 @@ function buildXaiSessionUpdate(init, config = {}) {
58
58
  session
59
59
  };
60
60
  }
61
- /** Create an xAI Grok Voice provider factory for the bridge. */
61
+ /**
62
+ * Create an xAI Grok Voice provider factory for the bridge. Credentials are
63
+ * resolved per call (see `openaiRealtime` — a missing key fails that call's
64
+ * connect so a fallback chain can absorb it, instead of crashing config
65
+ * construction).
66
+ */
62
67
  function xaiRealtime(options = {}) {
63
- const apiKey = resolveApiKey(options.apiKey, XAI_KEY_ENV_VARS);
64
- if (!apiKey) throw new Error(`xaiRealtime: apiKey missing (pass apiKey or set one of ${XAI_KEY_ENV_VARS.join("/")})`);
65
68
  const transcription = options.transcription === false ? false : options.transcription ? { language: options.transcription.languageHint } : void 0;
66
69
  const config = {
67
- apiKey,
68
70
  model: options.model ?? "grok-voice-latest",
69
71
  voice: options.voice ?? "eve",
70
72
  baseUrl: options.baseUrl ?? "wss://api.x.ai/v1/realtime",
@@ -85,7 +87,14 @@ function xaiRealtime(options = {}) {
85
87
  }
86
88
  }
87
89
  };
88
- return ({ logger }) => new OpenAICompatibleProvider(config, logger);
90
+ return ({ logger }) => {
91
+ const apiKey = resolveApiKey(options.apiKey, XAI_KEY_ENV_VARS);
92
+ if (!apiKey) throw new Error(`xaiRealtime: apiKey missing (pass apiKey or set one of ${XAI_KEY_ENV_VARS.join("/")})`);
93
+ return new OpenAICompatibleProvider({
94
+ ...config,
95
+ apiKey
96
+ }, logger);
97
+ };
89
98
  }
90
99
  //#endregion
91
100
  export { XAI_BASE_URL, XAI_DEFAULT_MODEL, XAI_DEFAULT_VOICE, XAI_KEY_ENV_VARS, buildXaiSessionUpdate, xaiRealtime };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "realtime-voice-agents",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Provider-agnostic bridge between Twilio Media Streams and realtime speech-to-speech AI APIs (OpenAI Realtime, xAI Grok Voice, Gemini Live). Multi-agent handoffs, Zod tools with execution strategies, mark-based playback tracking, interruption guards, and hold audio — for Node.js voice agents over the phone.",
5
5
  "keywords": [
6
6
  "twilio",