@absolutejs/voice 0.0.22-beta.8 → 0.0.22-beta.80

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 (111) hide show
  1. package/README.md +205 -0
  2. package/dist/agent.d.ts +2 -0
  3. package/dist/angular/index.d.ts +6 -0
  4. package/dist/angular/index.js +833 -43
  5. package/dist/angular/voice-app-kit-status.service.d.ts +12 -0
  6. package/dist/angular/voice-ops-status.component.d.ts +15 -0
  7. package/dist/angular/voice-provider-capabilities.service.d.ts +12 -0
  8. package/dist/angular/voice-provider-status.service.d.ts +12 -0
  9. package/dist/angular/voice-routing-status.service.d.ts +11 -0
  10. package/dist/angular/voice-stream.service.d.ts +2 -0
  11. package/dist/angular/voice-turn-quality.service.d.ts +12 -0
  12. package/dist/angular/voice-workflow-status.service.d.ts +12 -0
  13. package/dist/appKit.d.ts +94 -0
  14. package/dist/assistantHealth.d.ts +81 -0
  15. package/dist/client/actions.d.ts +22 -0
  16. package/dist/client/appKitStatus.d.ts +19 -0
  17. package/dist/client/connection.d.ts +3 -0
  18. package/dist/client/htmxBootstrap.js +44 -2
  19. package/dist/client/index.d.ts +26 -0
  20. package/dist/client/index.js +1290 -2
  21. package/dist/client/opsStatusWidget.d.ts +40 -0
  22. package/dist/client/providerCapabilities.d.ts +19 -0
  23. package/dist/client/providerCapabilitiesWidget.d.ts +32 -0
  24. package/dist/client/providerSimulationControls.d.ts +33 -0
  25. package/dist/client/providerSimulationControlsWidget.d.ts +20 -0
  26. package/dist/client/providerStatus.d.ts +19 -0
  27. package/dist/client/providerStatusWidget.d.ts +32 -0
  28. package/dist/client/routingStatus.d.ts +19 -0
  29. package/dist/client/routingStatusWidget.d.ts +28 -0
  30. package/dist/client/turnQuality.d.ts +19 -0
  31. package/dist/client/turnQualityWidget.d.ts +32 -0
  32. package/dist/client/workflowStatus.d.ts +19 -0
  33. package/dist/diagnosticsRoutes.d.ts +44 -0
  34. package/dist/evalRoutes.d.ts +213 -0
  35. package/dist/handoff.d.ts +54 -0
  36. package/dist/handoffHealth.d.ts +94 -0
  37. package/dist/index.d.ts +56 -7
  38. package/dist/index.js +6919 -128
  39. package/dist/modelAdapters.d.ts +75 -0
  40. package/dist/opsConsoleRoutes.d.ts +77 -0
  41. package/dist/opsWebhook.d.ts +126 -0
  42. package/dist/outcomeContract.d.ts +112 -0
  43. package/dist/postgresStore.d.ts +2 -0
  44. package/dist/providerAdapters.d.ts +48 -0
  45. package/dist/providerCapabilities.d.ts +92 -0
  46. package/dist/providerHealth.d.ts +79 -0
  47. package/dist/qualityRoutes.d.ts +76 -0
  48. package/dist/queue.d.ts +61 -0
  49. package/dist/react/VoiceOpsStatus.d.ts +6 -0
  50. package/dist/react/VoiceProviderCapabilities.d.ts +6 -0
  51. package/dist/react/VoiceProviderSimulationControls.d.ts +5 -0
  52. package/dist/react/VoiceProviderStatus.d.ts +6 -0
  53. package/dist/react/VoiceRoutingStatus.d.ts +6 -0
  54. package/dist/react/VoiceTurnQuality.d.ts +6 -0
  55. package/dist/react/index.d.ts +13 -0
  56. package/dist/react/index.js +1884 -11
  57. package/dist/react/useVoiceAppKitStatus.d.ts +8 -0
  58. package/dist/react/useVoiceController.d.ts +2 -0
  59. package/dist/react/useVoiceProviderCapabilities.d.ts +8 -0
  60. package/dist/react/useVoiceProviderSimulationControls.d.ts +10 -0
  61. package/dist/react/useVoiceProviderStatus.d.ts +8 -0
  62. package/dist/react/useVoiceRoutingStatus.d.ts +8 -0
  63. package/dist/react/useVoiceStream.d.ts +2 -0
  64. package/dist/react/useVoiceTurnQuality.d.ts +8 -0
  65. package/dist/react/useVoiceWorkflowStatus.d.ts +8 -0
  66. package/dist/resilienceRoutes.d.ts +117 -0
  67. package/dist/sessionReplay.d.ts +175 -0
  68. package/dist/sqliteStore.d.ts +2 -0
  69. package/dist/svelte/createVoiceAppKitStatus.d.ts +8 -0
  70. package/dist/svelte/createVoiceOpsStatus.d.ts +9 -0
  71. package/dist/svelte/createVoiceProviderCapabilities.d.ts +10 -0
  72. package/dist/svelte/createVoiceProviderSimulationControls.d.ts +11 -0
  73. package/dist/svelte/createVoiceProviderStatus.d.ts +10 -0
  74. package/dist/svelte/createVoiceRoutingStatus.d.ts +10 -0
  75. package/dist/svelte/createVoiceTurnQuality.d.ts +10 -0
  76. package/dist/svelte/createVoiceWorkflowStatus.d.ts +8 -0
  77. package/dist/svelte/index.d.ts +8 -0
  78. package/dist/svelte/index.js +1330 -3
  79. package/dist/telephony/contract.d.ts +61 -0
  80. package/dist/telephony/matrix.d.ts +97 -0
  81. package/dist/telephony/plivo.d.ts +154 -0
  82. package/dist/telephony/telnyx.d.ts +139 -0
  83. package/dist/telephony/twilio.d.ts +132 -0
  84. package/dist/telephonyOutcome.d.ts +201 -0
  85. package/dist/testing/index.d.ts +2 -0
  86. package/dist/testing/index.js +2541 -14
  87. package/dist/testing/ioProviderSimulator.d.ts +41 -0
  88. package/dist/testing/providerSimulator.d.ts +44 -0
  89. package/dist/toolContract.d.ts +130 -0
  90. package/dist/toolRuntime.d.ts +50 -0
  91. package/dist/trace.d.ts +1 -1
  92. package/dist/turnQuality.d.ts +94 -0
  93. package/dist/types.d.ts +84 -2
  94. package/dist/vue/VoiceOpsStatus.d.ts +30 -0
  95. package/dist/vue/VoiceProviderCapabilities.d.ts +51 -0
  96. package/dist/vue/VoiceProviderSimulationControls.d.ts +88 -0
  97. package/dist/vue/VoiceProviderStatus.d.ts +51 -0
  98. package/dist/vue/VoiceRoutingStatus.d.ts +51 -0
  99. package/dist/vue/VoiceTurnQuality.d.ts +51 -0
  100. package/dist/vue/index.d.ts +13 -0
  101. package/dist/vue/index.js +1934 -25
  102. package/dist/vue/useVoiceAppKitStatus.d.ts +9 -0
  103. package/dist/vue/useVoiceProviderCapabilities.d.ts +9 -0
  104. package/dist/vue/useVoiceProviderSimulationControls.d.ts +24 -0
  105. package/dist/vue/useVoiceProviderStatus.d.ts +9 -0
  106. package/dist/vue/useVoiceRoutingStatus.d.ts +8 -0
  107. package/dist/vue/useVoiceStream.d.ts +2 -0
  108. package/dist/vue/useVoiceTurnQuality.d.ts +9 -0
  109. package/dist/vue/useVoiceWorkflowStatus.d.ts +9 -0
  110. package/dist/workflowContract.d.ts +91 -0
  111. package/package.json +1 -1
@@ -2105,6 +2105,12 @@ var serverMessageToAction = (message) => {
2105
2105
  sessionId: message.sessionId,
2106
2106
  type: "complete"
2107
2107
  };
2108
+ case "call_lifecycle":
2109
+ return {
2110
+ event: message.event,
2111
+ sessionId: message.sessionId,
2112
+ type: "call_lifecycle"
2113
+ };
2108
2114
  case "error":
2109
2115
  return {
2110
2116
  message: normalizeErrorMessage(message.message),
@@ -2148,7 +2154,7 @@ var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
2148
2154
  var noop = () => {};
2149
2155
  var noopUnsubscribe = () => noop;
2150
2156
  var NOOP_CONNECTION = {
2151
- start: () => {},
2157
+ callControl: noop,
2152
2158
  close: noop,
2153
2159
  endTurn: noop,
2154
2160
  getReadyState: () => WS_CLOSED,
@@ -2156,6 +2162,7 @@ var NOOP_CONNECTION = {
2156
2162
  getSessionId: () => "",
2157
2163
  send: noop,
2158
2164
  sendAudio: noop,
2165
+ start: () => {},
2159
2166
  subscribe: noopUnsubscribe
2160
2167
  };
2161
2168
  var createSessionId = () => crypto.randomUUID();
@@ -2177,6 +2184,7 @@ var isVoiceServerMessage = (value) => {
2177
2184
  switch (value.type) {
2178
2185
  case "audio":
2179
2186
  case "assistant":
2187
+ case "call_lifecycle":
2180
2188
  case "complete":
2181
2189
  case "error":
2182
2190
  case "final":
@@ -2317,6 +2325,12 @@ var createVoiceConnection = (path, options = {}) => {
2317
2325
  const endTurn = () => {
2318
2326
  send({ type: "end_turn" });
2319
2327
  };
2328
+ const callControl = (message) => {
2329
+ send({
2330
+ ...message,
2331
+ type: "call_control"
2332
+ });
2333
+ };
2320
2334
  const close = () => {
2321
2335
  clearTimers();
2322
2336
  if (state.ws) {
@@ -2334,7 +2348,7 @@ var createVoiceConnection = (path, options = {}) => {
2334
2348
  };
2335
2349
  connect();
2336
2350
  return {
2337
- start,
2351
+ callControl,
2338
2352
  close,
2339
2353
  endTurn,
2340
2354
  getReadyState: () => state.ws?.readyState ?? WS_CLOSED,
@@ -2342,6 +2356,7 @@ var createVoiceConnection = (path, options = {}) => {
2342
2356
  getSessionId: () => state.sessionId,
2343
2357
  send,
2344
2358
  sendAudio,
2359
+ start,
2345
2360
  subscribe
2346
2361
  };
2347
2362
  };
@@ -2350,6 +2365,7 @@ var createVoiceConnection = (path, options = {}) => {
2350
2365
  var createInitialState2 = () => ({
2351
2366
  assistantAudio: [],
2352
2367
  assistantTexts: [],
2368
+ call: null,
2353
2369
  error: null,
2354
2370
  isConnected: false,
2355
2371
  scenarioId: null,
@@ -2393,6 +2409,20 @@ var createVoiceStreamStore = () => {
2393
2409
  status: "completed"
2394
2410
  };
2395
2411
  break;
2412
+ case "call_lifecycle":
2413
+ state = {
2414
+ ...state,
2415
+ call: {
2416
+ ...state.call,
2417
+ disposition: action.event.type === "end" ? action.event.disposition : state.call?.disposition,
2418
+ endedAt: action.event.type === "end" ? action.event.at : state.call?.endedAt,
2419
+ events: [...state.call?.events ?? [], action.event],
2420
+ lastEventAt: action.event.at,
2421
+ startedAt: state.call?.startedAt ?? action.event.at
2422
+ },
2423
+ sessionId: action.sessionId
2424
+ };
2425
+ break;
2396
2426
  case "connected":
2397
2427
  state = {
2398
2428
  ...state,
@@ -2479,6 +2509,9 @@ var createVoiceStream = (path, options = {}) => {
2479
2509
  }
2480
2510
  });
2481
2511
  return {
2512
+ callControl(message) {
2513
+ connection.callControl(message);
2514
+ },
2482
2515
  close() {
2483
2516
  unsubscribeConnection();
2484
2517
  connection.close();
@@ -2522,6 +2555,9 @@ var createVoiceStream = (path, options = {}) => {
2522
2555
  get assistantAudio() {
2523
2556
  return store.getSnapshot().assistantAudio;
2524
2557
  },
2558
+ get call() {
2559
+ return store.getSnapshot().call;
2560
+ },
2525
2561
  sendAudio(audio) {
2526
2562
  connection.sendAudio(audio);
2527
2563
  },
@@ -2854,6 +2890,7 @@ var resolveVoiceRuntimePreset = (name = "default") => {
2854
2890
  var createInitialState3 = (stream) => ({
2855
2891
  assistantAudio: [...stream.assistantAudio],
2856
2892
  assistantTexts: [...stream.assistantTexts],
2893
+ call: stream.call,
2857
2894
  error: stream.error,
2858
2895
  isConnected: stream.isConnected,
2859
2896
  isRecording: false,
@@ -2883,6 +2920,7 @@ var createVoiceController = (path, options = {}) => {
2883
2920
  ...state,
2884
2921
  assistantAudio: [...stream.assistantAudio],
2885
2922
  assistantTexts: [...stream.assistantTexts],
2923
+ call: stream.call,
2886
2924
  error: stream.error,
2887
2925
  isConnected: stream.isConnected,
2888
2926
  partial: stream.partial,
@@ -2960,6 +2998,7 @@ var createVoiceController = (path, options = {}) => {
2960
2998
  bindHTMX(bindingOptions) {
2961
2999
  return bindVoiceHTMX(stream, bindingOptions);
2962
3000
  },
3001
+ callControl: (message) => stream.callControl(message),
2963
3002
  close,
2964
3003
  endTurn: () => stream.endTurn(),
2965
3004
  get error() {
@@ -3012,6 +3051,9 @@ var createVoiceController = (path, options = {}) => {
3012
3051
  },
3013
3052
  get assistantAudio() {
3014
3053
  return state.assistantAudio;
3054
+ },
3055
+ get call() {
3056
+ return state.call;
3015
3057
  }
3016
3058
  };
3017
3059
  };
@@ -3468,6 +3510,1004 @@ var loadVoiceTestFixtures = async (fixtureDirectory) => {
3468
3510
  }
3469
3511
  return fixtures;
3470
3512
  };
3513
+ // src/testing/ioProviderSimulator.ts
3514
+ var defaultFailureMessage = (input) => `Simulated ${input.provider} ${input.kind.toUpperCase()} ${input.operation} failure.`;
3515
+ var resolveRecoveryElapsedMs = (value, provider) => {
3516
+ if (typeof value === "number") {
3517
+ return value;
3518
+ }
3519
+ return value?.[provider] ?? 25;
3520
+ };
3521
+ var createHealth = (input) => ({
3522
+ consecutiveFailures: input.status === "healthy" ? 0 : 1,
3523
+ lastFailureAt: input.status === "healthy" ? undefined : input.now,
3524
+ provider: input.provider,
3525
+ status: input.status,
3526
+ suppressedUntil: input.suppressedUntil
3527
+ });
3528
+ var resolveFallback = async (options, provider) => {
3529
+ const configured = typeof options.fallback === "function" ? await options.fallback(provider) : options.fallback;
3530
+ return (configured ?? options.providers).find((candidate) => candidate !== provider);
3531
+ };
3532
+ var createVoiceIOProviderFailureSimulator = (options) => {
3533
+ if (options.providers.length === 0) {
3534
+ throw new Error("At least one provider is required.");
3535
+ }
3536
+ const now = options.now ?? Date.now;
3537
+ const operation = options.operation ?? "open";
3538
+ const cooldownMs = Math.max(0, options.cooldownMs ?? 30000);
3539
+ const emit = async (event, input) => {
3540
+ await options.onProviderEvent?.(event, input);
3541
+ };
3542
+ const run = async (provider, mode) => {
3543
+ if (!options.providers.includes(provider)) {
3544
+ throw new Error(`${provider} is not configured for simulation.`);
3545
+ }
3546
+ const startedAt = now();
3547
+ const sessionId = options.sessionId?.({ mode, now: startedAt, provider }) ?? `${options.kind}-provider-sim-${startedAt}`;
3548
+ if (mode === "recovery") {
3549
+ await emit({
3550
+ at: startedAt,
3551
+ attempt: 0,
3552
+ elapsedMs: resolveRecoveryElapsedMs(options.recoveryElapsedMs, provider),
3553
+ kind: options.kind,
3554
+ latencyBudgetMs: options.latencyBudgets?.[provider],
3555
+ operation,
3556
+ provider,
3557
+ providerHealth: createHealth({
3558
+ now: startedAt,
3559
+ provider,
3560
+ status: "healthy"
3561
+ }),
3562
+ selectedProvider: provider,
3563
+ status: "success"
3564
+ }, { mode, provider, sessionId });
3565
+ return {
3566
+ mode,
3567
+ provider,
3568
+ sessionId,
3569
+ status: "simulated"
3570
+ };
3571
+ }
3572
+ const fallbackProvider = await resolveFallback(options, provider);
3573
+ const suppressedUntil = startedAt + cooldownMs;
3574
+ await emit({
3575
+ at: startedAt,
3576
+ attempt: 0,
3577
+ elapsedMs: options.failureElapsedMs ?? 10,
3578
+ error: (options.failureMessage ?? defaultFailureMessage)({
3579
+ kind: options.kind,
3580
+ operation,
3581
+ provider
3582
+ }),
3583
+ fallbackProvider,
3584
+ kind: options.kind,
3585
+ latencyBudgetMs: options.latencyBudgets?.[provider],
3586
+ operation,
3587
+ provider,
3588
+ providerHealth: createHealth({
3589
+ now: startedAt,
3590
+ provider,
3591
+ status: "suppressed",
3592
+ suppressedUntil
3593
+ }),
3594
+ selectedProvider: provider,
3595
+ status: "error",
3596
+ suppressedUntil
3597
+ }, { mode, provider, sessionId });
3598
+ if (fallbackProvider) {
3599
+ await emit({
3600
+ at: startedAt + 1,
3601
+ attempt: 1,
3602
+ elapsedMs: resolveRecoveryElapsedMs(options.recoveryElapsedMs, fallbackProvider),
3603
+ fallbackProvider,
3604
+ kind: options.kind,
3605
+ latencyBudgetMs: options.latencyBudgets?.[fallbackProvider],
3606
+ operation,
3607
+ provider: fallbackProvider,
3608
+ providerHealth: createHealth({
3609
+ now: startedAt + 1,
3610
+ provider: fallbackProvider,
3611
+ status: "healthy"
3612
+ }),
3613
+ selectedProvider: provider,
3614
+ status: "fallback"
3615
+ }, { mode, provider, sessionId });
3616
+ }
3617
+ return {
3618
+ fallbackProvider,
3619
+ mode,
3620
+ provider,
3621
+ sessionId,
3622
+ status: "simulated",
3623
+ suppressedUntil
3624
+ };
3625
+ };
3626
+ return {
3627
+ run
3628
+ };
3629
+ };
3630
+ // src/modelAdapters.ts
3631
+ var resolveVoiceProviderRoutingPolicyPreset = (preset, options = {}) => {
3632
+ switch (preset) {
3633
+ case "balanced":
3634
+ return {
3635
+ fallbackMode: "provider-error",
3636
+ strategy: "balanced",
3637
+ weights: {
3638
+ cost: 1,
3639
+ latencyMs: 0.005,
3640
+ priority: 1,
3641
+ quality: 10,
3642
+ ...options.weights
3643
+ },
3644
+ ...options
3645
+ };
3646
+ case "cost-cap":
3647
+ return {
3648
+ fallbackMode: "provider-error",
3649
+ strategy: "prefer-cheapest",
3650
+ ...options
3651
+ };
3652
+ case "cost-first":
3653
+ return {
3654
+ fallbackMode: "provider-error",
3655
+ strategy: "prefer-cheapest",
3656
+ ...options
3657
+ };
3658
+ case "latency-first":
3659
+ return {
3660
+ fallbackMode: "provider-error",
3661
+ strategy: "prefer-fastest",
3662
+ ...options
3663
+ };
3664
+ case "quality-first":
3665
+ return {
3666
+ fallbackMode: "provider-error",
3667
+ strategy: "quality-first",
3668
+ ...options
3669
+ };
3670
+ }
3671
+ };
3672
+ var OUTPUT_SCHEMA = {
3673
+ additionalProperties: false,
3674
+ properties: {
3675
+ assistantText: {
3676
+ type: "string"
3677
+ },
3678
+ complete: {
3679
+ type: "boolean"
3680
+ },
3681
+ escalate: {
3682
+ additionalProperties: false,
3683
+ properties: {
3684
+ metadata: {
3685
+ additionalProperties: true,
3686
+ type: "object"
3687
+ },
3688
+ reason: {
3689
+ type: "string"
3690
+ }
3691
+ },
3692
+ required: ["reason"],
3693
+ type: "object"
3694
+ },
3695
+ noAnswer: {
3696
+ additionalProperties: false,
3697
+ properties: {
3698
+ metadata: {
3699
+ additionalProperties: true,
3700
+ type: "object"
3701
+ }
3702
+ },
3703
+ type: "object"
3704
+ },
3705
+ result: {
3706
+ additionalProperties: true,
3707
+ type: "object"
3708
+ },
3709
+ transfer: {
3710
+ additionalProperties: false,
3711
+ properties: {
3712
+ metadata: {
3713
+ additionalProperties: true,
3714
+ type: "object"
3715
+ },
3716
+ reason: {
3717
+ type: "string"
3718
+ },
3719
+ target: {
3720
+ type: "string"
3721
+ }
3722
+ },
3723
+ required: ["target"],
3724
+ type: "object"
3725
+ },
3726
+ voicemail: {
3727
+ additionalProperties: false,
3728
+ properties: {
3729
+ metadata: {
3730
+ additionalProperties: true,
3731
+ type: "object"
3732
+ }
3733
+ },
3734
+ type: "object"
3735
+ }
3736
+ },
3737
+ type: "object"
3738
+ };
3739
+ var ROUTE_RESULT_INSTRUCTION = "Return only a JSON object with assistantText, complete, transfer, escalate, voicemail, noAnswer, and result when you are not calling tools. Only set transfer, escalate, voicemail, or noAnswer when the user explicitly asks for that lifecycle outcome or a tool result says that exact outcome. Do not infer voicemail from generic words like voice, voice app, or voice integration.";
3740
+ var stripJSONCodeFence = (value) => {
3741
+ const trimmed = value.trim();
3742
+ const match = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
3743
+ return match?.[1]?.trim() ?? value;
3744
+ };
3745
+ var parseJSON = (value) => {
3746
+ try {
3747
+ const parsed = JSON.parse(stripJSONCodeFence(value));
3748
+ return parsed && typeof parsed === "object" ? parsed : {};
3749
+ } catch {
3750
+ return {
3751
+ assistantText: value
3752
+ };
3753
+ }
3754
+ };
3755
+ var parseJSONValue = (value) => {
3756
+ try {
3757
+ return JSON.parse(value);
3758
+ } catch {
3759
+ return value;
3760
+ }
3761
+ };
3762
+
3763
+ class VoiceProviderTimeoutError extends Error {
3764
+ provider;
3765
+ timeoutMs;
3766
+ constructor(provider, timeoutMs) {
3767
+ super(`Voice provider ${provider} exceeded ${timeoutMs}ms latency budget.`);
3768
+ this.name = "VoiceProviderTimeoutError";
3769
+ this.provider = provider;
3770
+ this.timeoutMs = timeoutMs;
3771
+ }
3772
+ }
3773
+ var getMessageToolCalls = (message) => {
3774
+ const toolCalls = message.metadata?.toolCalls;
3775
+ return Array.isArray(toolCalls) ? toolCalls.filter((toolCall) => toolCall && typeof toolCall === "object" && typeof toolCall.name === "string") : [];
3776
+ };
3777
+ var createHTTPError = (provider, response) => new Error(`${provider} voice assistant model failed: HTTP ${response.status}`);
3778
+ var sleep = (ms) => new Promise((resolve2) => {
3779
+ setTimeout(resolve2, ms);
3780
+ });
3781
+ var errorMessage = (error) => error instanceof Error ? error.message : String(error);
3782
+ var defaultIsRateLimitError = (error) => /(\b429\b|rate limit|quota|too many requests)/i.test(errorMessage(error));
3783
+ var normalizeRouteOutput = (output) => {
3784
+ const result = {};
3785
+ if (typeof output.assistantText === "string") {
3786
+ result.assistantText = output.assistantText;
3787
+ }
3788
+ if (typeof output.complete === "boolean") {
3789
+ result.complete = output.complete;
3790
+ }
3791
+ if (output.result !== undefined) {
3792
+ result.result = output.result;
3793
+ }
3794
+ if (output.transfer && typeof output.transfer === "object") {
3795
+ const transfer = output.transfer;
3796
+ if (typeof transfer.target === "string") {
3797
+ result.transfer = {
3798
+ metadata: transfer.metadata && typeof transfer.metadata === "object" ? transfer.metadata : undefined,
3799
+ reason: typeof transfer.reason === "string" ? transfer.reason : undefined,
3800
+ target: transfer.target
3801
+ };
3802
+ }
3803
+ }
3804
+ if (output.escalate && typeof output.escalate === "object") {
3805
+ const escalate = output.escalate;
3806
+ if (typeof escalate.reason === "string") {
3807
+ result.escalate = {
3808
+ metadata: escalate.metadata && typeof escalate.metadata === "object" ? escalate.metadata : undefined,
3809
+ reason: escalate.reason
3810
+ };
3811
+ }
3812
+ }
3813
+ if (output.voicemail && typeof output.voicemail === "object") {
3814
+ const voicemail = output.voicemail;
3815
+ result.voicemail = {
3816
+ metadata: voicemail.metadata && typeof voicemail.metadata === "object" ? voicemail.metadata : undefined
3817
+ };
3818
+ }
3819
+ if (output.noAnswer && typeof output.noAnswer === "object") {
3820
+ const noAnswer = output.noAnswer;
3821
+ result.noAnswer = {
3822
+ metadata: noAnswer.metadata && typeof noAnswer.metadata === "object" ? noAnswer.metadata : undefined
3823
+ };
3824
+ }
3825
+ return result;
3826
+ };
3827
+ var createJSONVoiceAssistantModel = (options) => ({
3828
+ generate: async (input) => {
3829
+ const output = await options.generate(input);
3830
+ if ("assistantText" in output || "toolCalls" in output || "complete" in output || "transfer" in output || "escalate" in output) {
3831
+ return output;
3832
+ }
3833
+ return options.mapOutput?.(output) ?? normalizeRouteOutput(output);
3834
+ }
3835
+ });
3836
+ var createVoiceProviderRouter = (options) => {
3837
+ const providerIds = Object.keys(options.providers);
3838
+ const firstProvider = providerIds[0];
3839
+ const policy = typeof options.policy === "string" ? options.policy === "balanced" || options.policy === "cost-cap" || options.policy === "cost-first" || options.policy === "latency-first" || options.policy === "quality-first" ? resolveVoiceProviderRoutingPolicyPreset(options.policy) : {
3840
+ strategy: options.policy
3841
+ } : options.policy;
3842
+ const strategy = policy?.strategy ?? "prefer-selected";
3843
+ const fallbackMode = policy?.fallbackMode ?? options.fallbackMode ?? "provider-error";
3844
+ const healthOptions = typeof options.providerHealth === "object" ? options.providerHealth : options.providerHealth ? {} : undefined;
3845
+ const healthState = new Map;
3846
+ const now = () => healthOptions?.now?.() ?? Date.now();
3847
+ const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
3848
+ const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
3849
+ const rateLimitCooldownMs = Math.max(0, healthOptions?.rateLimitCooldownMs ?? 60000);
3850
+ const getProviderTimeoutMs = (provider) => {
3851
+ const timeoutMs = options.providerProfiles?.[provider]?.timeoutMs ?? options.timeoutMs;
3852
+ return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
3853
+ };
3854
+ const getHealth = (provider) => {
3855
+ const existing = healthState.get(provider);
3856
+ if (existing) {
3857
+ return existing;
3858
+ }
3859
+ const next = {
3860
+ consecutiveFailures: 0,
3861
+ provider,
3862
+ status: "healthy"
3863
+ };
3864
+ healthState.set(provider, next);
3865
+ return next;
3866
+ };
3867
+ const cloneHealth = (provider) => {
3868
+ if (!healthOptions) {
3869
+ return;
3870
+ }
3871
+ return {
3872
+ ...getHealth(provider)
3873
+ };
3874
+ };
3875
+ const getSuppressionRemainingMs = (provider) => {
3876
+ if (!healthOptions) {
3877
+ return;
3878
+ }
3879
+ const suppressedUntil = getHealth(provider).suppressedUntil;
3880
+ return typeof suppressedUntil === "number" ? Math.max(0, suppressedUntil - now()) : undefined;
3881
+ };
3882
+ const isSuppressed = (provider) => {
3883
+ if (!healthOptions) {
3884
+ return false;
3885
+ }
3886
+ const health = getHealth(provider);
3887
+ return typeof health.suppressedUntil === "number" && health.suppressedUntil > now();
3888
+ };
3889
+ const recordProviderSuccess = (provider) => {
3890
+ if (!healthOptions) {
3891
+ return;
3892
+ }
3893
+ const health = getHealth(provider);
3894
+ health.consecutiveFailures = 0;
3895
+ health.status = "healthy";
3896
+ health.suppressedUntil = undefined;
3897
+ return cloneHealth(provider);
3898
+ };
3899
+ const recordProviderError = (provider, isProviderError, rateLimited) => {
3900
+ if (!healthOptions || !isProviderError) {
3901
+ return cloneHealth(provider);
3902
+ }
3903
+ const currentTime = now();
3904
+ const health = getHealth(provider);
3905
+ health.consecutiveFailures += 1;
3906
+ health.lastFailureAt = currentTime;
3907
+ if (rateLimited) {
3908
+ health.lastRateLimitedAt = currentTime;
3909
+ }
3910
+ if (rateLimited || health.consecutiveFailures >= failureThreshold) {
3911
+ health.status = "suppressed";
3912
+ health.suppressedUntil = currentTime + (rateLimited ? rateLimitCooldownMs : cooldownMs);
3913
+ }
3914
+ return cloneHealth(provider);
3915
+ };
3916
+ const resolveAllowedProviders = async (input) => {
3917
+ const allowProviders = policy?.allowProviders ?? options.allowProviders;
3918
+ const allowed = typeof allowProviders === "function" ? await allowProviders(input) : allowProviders;
3919
+ return new Set(allowed ?? providerIds);
3920
+ };
3921
+ const passesBudgetFilters = (provider) => {
3922
+ const profile = options.providerProfiles?.[provider];
3923
+ if (typeof policy?.maxCost === "number" && typeof profile?.cost === "number" && profile.cost > policy.maxCost) {
3924
+ return false;
3925
+ }
3926
+ if (typeof policy?.maxLatencyMs === "number" && typeof profile?.latencyMs === "number" && profile.latencyMs > policy.maxLatencyMs) {
3927
+ return false;
3928
+ }
3929
+ if (typeof policy?.minQuality === "number" && typeof profile?.quality === "number" && profile.quality < policy.minQuality) {
3930
+ return false;
3931
+ }
3932
+ return true;
3933
+ };
3934
+ const getBalancedScore = (provider) => {
3935
+ const profile = options.providerProfiles?.[provider];
3936
+ if (policy?.scoreProvider) {
3937
+ return policy.scoreProvider(provider, profile);
3938
+ }
3939
+ const weights = policy?.weights ?? {};
3940
+ return (profile?.cost ?? Number.MAX_SAFE_INTEGER) * (weights.cost ?? 1) + (profile?.latencyMs ?? Number.MAX_SAFE_INTEGER) * (weights.latencyMs ?? 0.005) + (profile?.priority ?? 0) * (weights.priority ?? 1) - (profile?.quality ?? 0) * (weights.quality ?? 10);
3941
+ };
3942
+ const sortProviders = (providers) => {
3943
+ if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest" && strategy !== "quality-first" && strategy !== "balanced") {
3944
+ return providers;
3945
+ }
3946
+ return [...providers].sort((left, right) => {
3947
+ const leftProfile = options.providerProfiles?.[left];
3948
+ const rightProfile = options.providerProfiles?.[right];
3949
+ if (strategy === "quality-first") {
3950
+ return (rightProfile?.quality ?? Number.MIN_SAFE_INTEGER) - (leftProfile?.quality ?? Number.MIN_SAFE_INTEGER) || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.cost ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.cost ?? Number.MAX_SAFE_INTEGER);
3951
+ }
3952
+ if (strategy === "balanced") {
3953
+ return getBalancedScore(left) - getBalancedScore(right);
3954
+ }
3955
+ const leftValue = strategy === "prefer-cheapest" ? leftProfile?.cost ?? Number.MAX_SAFE_INTEGER : leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
3956
+ const rightValue = strategy === "prefer-cheapest" ? rightProfile?.cost ?? Number.MAX_SAFE_INTEGER : rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
3957
+ return leftValue - rightValue || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER);
3958
+ });
3959
+ };
3960
+ const resolveOrder = async (input) => {
3961
+ const selectedProvider = await options.selectProvider?.(input);
3962
+ const allowedProviders = await resolveAllowedProviders(input);
3963
+ const fallbackOrder = typeof options.fallback === "function" ? await options.fallback(input) : options.fallback;
3964
+ const allowedRankedProviders = sortProviders([
3965
+ ...fallbackOrder ?? providerIds
3966
+ ]).filter((provider) => allowedProviders.has(provider));
3967
+ const rankedProviders = allowedRankedProviders.filter(passesBudgetFilters);
3968
+ const healthyRankedProviders = healthOptions ? rankedProviders.filter((provider) => !isSuppressed(provider)) : rankedProviders;
3969
+ const candidateRankedProviders = healthyRankedProviders.length ? healthyRankedProviders : rankedProviders;
3970
+ const preferred = selectedProvider && allowedProviders.has(selectedProvider) && passesBudgetFilters(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
3971
+ const seen = new Set;
3972
+ const order = [];
3973
+ const candidates = strategy === "ordered" ? candidateRankedProviders : [
3974
+ preferred,
3975
+ ...candidateRankedProviders,
3976
+ ...providerIds.filter((provider) => !healthOptions || !isSuppressed(provider))
3977
+ ];
3978
+ for (const provider of candidates) {
3979
+ if (!provider || seen.has(provider) || !allowedProviders.has(provider) || !options.providers[provider]) {
3980
+ continue;
3981
+ }
3982
+ seen.add(provider);
3983
+ order.push(provider);
3984
+ }
3985
+ return {
3986
+ order,
3987
+ selectedProvider: preferred
3988
+ };
3989
+ };
3990
+ const emit = async (event, input) => {
3991
+ await options.onProviderEvent?.(event, input);
3992
+ };
3993
+ const runProvider = async (provider, model, input) => {
3994
+ const timeoutMs = getProviderTimeoutMs(provider);
3995
+ if (!timeoutMs) {
3996
+ return model.generate(input);
3997
+ }
3998
+ let timeout;
3999
+ try {
4000
+ return await Promise.race([
4001
+ model.generate(input),
4002
+ new Promise((_, reject) => {
4003
+ timeout = setTimeout(() => reject(new VoiceProviderTimeoutError(provider, timeoutMs)), timeoutMs);
4004
+ })
4005
+ ]);
4006
+ } finally {
4007
+ if (timeout) {
4008
+ clearTimeout(timeout);
4009
+ }
4010
+ }
4011
+ };
4012
+ return {
4013
+ generate: async (input) => {
4014
+ const { order, selectedProvider } = await resolveOrder(input);
4015
+ if (!selectedProvider || order.length === 0) {
4016
+ throw new Error("Voice provider router has no available providers.");
4017
+ }
4018
+ let lastError;
4019
+ for (const [index, provider] of order.entries()) {
4020
+ const model = options.providers[provider];
4021
+ if (!model) {
4022
+ continue;
4023
+ }
4024
+ const startedAt = Date.now();
4025
+ try {
4026
+ const output = await runProvider(provider, model, input);
4027
+ const providerHealth = recordProviderSuccess(provider);
4028
+ await emit({
4029
+ at: Date.now(),
4030
+ attempt: index + 1,
4031
+ elapsedMs: Date.now() - startedAt,
4032
+ fallbackProvider: provider === selectedProvider ? undefined : provider,
4033
+ latencyBudgetMs: getProviderTimeoutMs(provider),
4034
+ provider,
4035
+ providerHealth,
4036
+ recovered: provider !== selectedProvider,
4037
+ selectedProvider,
4038
+ status: provider === selectedProvider ? "success" : "fallback"
4039
+ }, input);
4040
+ return output;
4041
+ } catch (error) {
4042
+ lastError = error;
4043
+ const hasNextProvider = index < order.length - 1;
4044
+ const isProviderError = options.isProviderError?.(error, provider) ?? true;
4045
+ const timedOut = options.isTimeoutError?.(error, provider) ?? error instanceof VoiceProviderTimeoutError;
4046
+ const rateLimited = options.isRateLimitError?.(error, provider) ?? defaultIsRateLimitError(error);
4047
+ const shouldFallback = fallbackMode === "provider-error" ? isProviderError : fallbackMode === "rate-limit" ? isProviderError && rateLimited : false;
4048
+ const providerHealth = recordProviderError(provider, isProviderError, rateLimited);
4049
+ const nextProvider = hasNextProvider ? order[index + 1] : undefined;
4050
+ await emit({
4051
+ at: Date.now(),
4052
+ attempt: index + 1,
4053
+ elapsedMs: Date.now() - startedAt,
4054
+ error: errorMessage(error),
4055
+ fallbackProvider: shouldFallback ? nextProvider : undefined,
4056
+ latencyBudgetMs: getProviderTimeoutMs(provider),
4057
+ provider,
4058
+ providerHealth,
4059
+ rateLimited,
4060
+ selectedProvider,
4061
+ suppressionRemainingMs: getSuppressionRemainingMs(provider),
4062
+ suppressedUntil: providerHealth?.suppressedUntil,
4063
+ status: "error",
4064
+ timedOut
4065
+ }, input);
4066
+ if (!hasNextProvider || !shouldFallback) {
4067
+ throw error;
4068
+ }
4069
+ }
4070
+ }
4071
+ throw lastError ?? new Error("Voice provider router did not run a provider.");
4072
+ }
4073
+ };
4074
+ };
4075
+ var messageToOpenAIInput = (message) => {
4076
+ if (message.role === "tool") {
4077
+ return [
4078
+ {
4079
+ call_id: message.toolCallId ?? message.name ?? crypto.randomUUID(),
4080
+ output: message.content,
4081
+ type: "function_call_output"
4082
+ }
4083
+ ];
4084
+ }
4085
+ const toolCalls = getMessageToolCalls(message);
4086
+ if (message.role === "assistant" && toolCalls.length) {
4087
+ return toolCalls.map((toolCall) => ({
4088
+ arguments: JSON.stringify(toolCall.args),
4089
+ call_id: toolCall.id ?? crypto.randomUUID(),
4090
+ name: toolCall.name,
4091
+ type: "function_call"
4092
+ }));
4093
+ }
4094
+ return [
4095
+ {
4096
+ content: message.content,
4097
+ role: message.role === "system" ? "developer" : message.role
4098
+ }
4099
+ ];
4100
+ };
4101
+ var messagesToOpenAIInput = (messages) => messages.flatMap(messageToOpenAIInput);
4102
+ var messageToAnthropicMessage = (message) => {
4103
+ if (message.role === "system") {
4104
+ return;
4105
+ }
4106
+ if (message.role === "tool") {
4107
+ if (!message.toolCallId) {
4108
+ return {
4109
+ content: `Tool result from ${message.name ?? "tool"}: ${message.content}`,
4110
+ role: "user"
4111
+ };
4112
+ }
4113
+ return {
4114
+ content: [
4115
+ {
4116
+ content: message.content,
4117
+ tool_use_id: message.toolCallId,
4118
+ type: "tool_result"
4119
+ }
4120
+ ],
4121
+ role: "user"
4122
+ };
4123
+ }
4124
+ const toolCalls = getMessageToolCalls(message);
4125
+ if (message.role === "assistant" && toolCalls.length) {
4126
+ return {
4127
+ content: [
4128
+ ...message.content ? [
4129
+ {
4130
+ text: message.content,
4131
+ type: "text"
4132
+ }
4133
+ ] : [],
4134
+ ...toolCalls.map((toolCall) => ({
4135
+ id: toolCall.id ?? crypto.randomUUID(),
4136
+ input: toolCall.args,
4137
+ name: toolCall.name,
4138
+ type: "tool_use"
4139
+ }))
4140
+ ],
4141
+ role: "assistant"
4142
+ };
4143
+ }
4144
+ return {
4145
+ content: message.content,
4146
+ role: message.role
4147
+ };
4148
+ };
4149
+ var toGeminiSchema = (schema) => {
4150
+ const next = {};
4151
+ for (const [key, value] of Object.entries(schema)) {
4152
+ if (key === "additionalProperties") {
4153
+ continue;
4154
+ }
4155
+ if (key === "type" && typeof value === "string") {
4156
+ next[key] = value.toUpperCase();
4157
+ continue;
4158
+ }
4159
+ if (Array.isArray(value)) {
4160
+ next[key] = value.map((item) => item && typeof item === "object" ? toGeminiSchema(item) : item);
4161
+ continue;
4162
+ }
4163
+ if (value && typeof value === "object") {
4164
+ next[key] = toGeminiSchema(value);
4165
+ continue;
4166
+ }
4167
+ next[key] = value;
4168
+ }
4169
+ return next;
4170
+ };
4171
+ var messageToGeminiContent = (message) => {
4172
+ if (message.role === "system") {
4173
+ return;
4174
+ }
4175
+ if (message.role === "tool") {
4176
+ return {
4177
+ parts: [
4178
+ {
4179
+ functionResponse: {
4180
+ id: message.toolCallId,
4181
+ name: message.name ?? "tool",
4182
+ response: {
4183
+ result: parseJSONValue(message.content)
4184
+ }
4185
+ }
4186
+ }
4187
+ ],
4188
+ role: "user"
4189
+ };
4190
+ }
4191
+ const toolCalls = getMessageToolCalls(message);
4192
+ if (message.role === "assistant" && toolCalls.length) {
4193
+ return {
4194
+ parts: [
4195
+ ...message.content ? [
4196
+ {
4197
+ text: message.content
4198
+ }
4199
+ ] : [],
4200
+ ...toolCalls.map((toolCall) => ({
4201
+ functionCall: {
4202
+ args: toolCall.args,
4203
+ id: toolCall.id,
4204
+ name: toolCall.name
4205
+ }
4206
+ }))
4207
+ ],
4208
+ role: "model"
4209
+ };
4210
+ }
4211
+ return {
4212
+ parts: [
4213
+ {
4214
+ text: message.content
4215
+ }
4216
+ ],
4217
+ role: message.role === "assistant" ? "model" : "user"
4218
+ };
4219
+ };
4220
+ var extractText = (response) => {
4221
+ if (typeof response.output_text === "string") {
4222
+ return response.output_text;
4223
+ }
4224
+ const output = Array.isArray(response.output) ? response.output : [];
4225
+ for (const item of output) {
4226
+ if (!item || typeof item !== "object") {
4227
+ continue;
4228
+ }
4229
+ const record = item;
4230
+ const content = Array.isArray(record.content) ? record.content : [];
4231
+ for (const contentItem of content) {
4232
+ if (!contentItem || typeof contentItem !== "object") {
4233
+ continue;
4234
+ }
4235
+ const contentRecord = contentItem;
4236
+ if (typeof contentRecord.text === "string") {
4237
+ return contentRecord.text;
4238
+ }
4239
+ }
4240
+ }
4241
+ return "";
4242
+ };
4243
+ var extractToolCalls = (response) => {
4244
+ const output = Array.isArray(response.output) ? response.output : [];
4245
+ const toolCalls = [];
4246
+ for (const item of output) {
4247
+ if (!item || typeof item !== "object") {
4248
+ continue;
4249
+ }
4250
+ const record = item;
4251
+ if (record.type !== "function_call" || typeof record.name !== "string") {
4252
+ continue;
4253
+ }
4254
+ const args = typeof record.arguments === "string" ? parseJSON(record.arguments) : {};
4255
+ toolCalls.push({
4256
+ args,
4257
+ id: typeof record.call_id === "string" ? record.call_id : typeof record.id === "string" ? record.id : undefined,
4258
+ name: record.name
4259
+ });
4260
+ }
4261
+ return toolCalls;
4262
+ };
4263
+ var createOpenAIVoiceAssistantModel = (options) => {
4264
+ const fetchImpl = options.fetch ?? globalThis.fetch;
4265
+ const baseUrl = options.baseUrl ?? "https://api.openai.com/v1";
4266
+ const model = options.model ?? "gpt-4.1-mini";
4267
+ return {
4268
+ generate: async (input) => {
4269
+ const response = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/responses`, {
4270
+ body: JSON.stringify({
4271
+ input: messagesToOpenAIInput(input.messages),
4272
+ instructions: [
4273
+ input.system,
4274
+ "Return a JSON object with assistantText, complete, transfer, escalate, voicemail, noAnswer, and result when you are not calling tools."
4275
+ ].filter(Boolean).join(`
4276
+
4277
+ `),
4278
+ max_output_tokens: options.maxOutputTokens,
4279
+ model,
4280
+ temperature: options.temperature,
4281
+ text: {
4282
+ format: {
4283
+ name: "voice_route_result",
4284
+ schema: OUTPUT_SCHEMA,
4285
+ strict: false,
4286
+ type: "json_schema"
4287
+ }
4288
+ },
4289
+ tool_choice: input.tools.length ? "auto" : "none",
4290
+ tools: input.tools.map((tool) => ({
4291
+ description: tool.description,
4292
+ name: tool.name,
4293
+ parameters: tool.parameters ?? {
4294
+ additionalProperties: true,
4295
+ type: "object"
4296
+ },
4297
+ strict: false,
4298
+ type: "function"
4299
+ }))
4300
+ }),
4301
+ headers: {
4302
+ authorization: `Bearer ${options.apiKey}`,
4303
+ "content-type": "application/json"
4304
+ },
4305
+ method: "POST"
4306
+ });
4307
+ if (!response.ok) {
4308
+ throw createHTTPError("OpenAI", response);
4309
+ }
4310
+ const body = await response.json();
4311
+ if (body.usage && typeof body.usage === "object") {
4312
+ await options.onUsage?.(body.usage);
4313
+ }
4314
+ const toolCalls = extractToolCalls(body);
4315
+ if (toolCalls.length) {
4316
+ return {
4317
+ toolCalls
4318
+ };
4319
+ }
4320
+ return normalizeRouteOutput(parseJSON(extractText(body)));
4321
+ }
4322
+ };
4323
+ };
4324
+ var extractAnthropicText = (response) => {
4325
+ const content = Array.isArray(response.content) ? response.content : [];
4326
+ return content.map((item) => item && typeof item === "object" && item.type === "text" && typeof item.text === "string" ? item.text : "").filter(Boolean).join(`
4327
+ `);
4328
+ };
4329
+ var extractAnthropicToolCalls = (response) => {
4330
+ const content = Array.isArray(response.content) ? response.content : [];
4331
+ const toolCalls = [];
4332
+ for (const item of content) {
4333
+ if (!item || typeof item !== "object") {
4334
+ continue;
4335
+ }
4336
+ const record = item;
4337
+ if (record.type !== "tool_use" || typeof record.name !== "string") {
4338
+ continue;
4339
+ }
4340
+ toolCalls.push({
4341
+ args: record.input && typeof record.input === "object" ? record.input : {},
4342
+ id: typeof record.id === "string" ? record.id : undefined,
4343
+ name: record.name
4344
+ });
4345
+ }
4346
+ return toolCalls;
4347
+ };
4348
+ var createAnthropicVoiceAssistantModel = (options) => {
4349
+ const fetchImpl = options.fetch ?? globalThis.fetch;
4350
+ const baseUrl = options.baseUrl ?? "https://api.anthropic.com/v1";
4351
+ const model = options.model ?? "claude-sonnet-4-5";
4352
+ return {
4353
+ generate: async (input) => {
4354
+ const response = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/messages`, {
4355
+ body: JSON.stringify({
4356
+ max_tokens: options.maxOutputTokens ?? 1024,
4357
+ messages: input.messages.map(messageToAnthropicMessage).filter(Boolean),
4358
+ model,
4359
+ system: [input.system, ROUTE_RESULT_INSTRUCTION].filter(Boolean).join(`
4360
+
4361
+ `),
4362
+ temperature: options.temperature,
4363
+ tool_choice: input.tools.length ? { type: "auto" } : { type: "none" },
4364
+ tools: input.tools.map((tool) => ({
4365
+ description: tool.description,
4366
+ input_schema: tool.parameters ?? {
4367
+ additionalProperties: true,
4368
+ type: "object"
4369
+ },
4370
+ name: tool.name
4371
+ }))
4372
+ }),
4373
+ headers: {
4374
+ "anthropic-version": options.version ?? "2023-06-01",
4375
+ "content-type": "application/json",
4376
+ "x-api-key": options.apiKey
4377
+ },
4378
+ method: "POST"
4379
+ });
4380
+ if (!response.ok) {
4381
+ throw createHTTPError("Anthropic", response);
4382
+ }
4383
+ const body = await response.json();
4384
+ if (body.usage && typeof body.usage === "object") {
4385
+ await options.onUsage?.(body.usage);
4386
+ }
4387
+ const toolCalls = extractAnthropicToolCalls(body);
4388
+ if (toolCalls.length) {
4389
+ return {
4390
+ assistantText: extractAnthropicText(body) || undefined,
4391
+ toolCalls
4392
+ };
4393
+ }
4394
+ return normalizeRouteOutput(parseJSON(extractAnthropicText(body)));
4395
+ }
4396
+ };
4397
+ };
4398
+ var extractGeminiCandidateParts = (response) => {
4399
+ const candidates = Array.isArray(response.candidates) ? response.candidates : [];
4400
+ const first = candidates[0];
4401
+ if (!first || typeof first !== "object") {
4402
+ return [];
4403
+ }
4404
+ const content = first.content;
4405
+ if (!content || typeof content !== "object") {
4406
+ return [];
4407
+ }
4408
+ const parts = content.parts;
4409
+ return Array.isArray(parts) ? parts : [];
4410
+ };
4411
+ var extractGeminiText = (response) => extractGeminiCandidateParts(response).map((part) => part && typeof part === "object" && typeof part.text === "string" ? part.text : "").filter(Boolean).join(`
4412
+ `);
4413
+ var extractGeminiToolCalls = (response) => {
4414
+ const toolCalls = [];
4415
+ for (const part of extractGeminiCandidateParts(response)) {
4416
+ if (!part || typeof part !== "object") {
4417
+ continue;
4418
+ }
4419
+ const functionCall = part.functionCall;
4420
+ if (!functionCall || typeof functionCall !== "object") {
4421
+ continue;
4422
+ }
4423
+ const record = functionCall;
4424
+ if (typeof record.name !== "string") {
4425
+ continue;
4426
+ }
4427
+ toolCalls.push({
4428
+ args: record.args && typeof record.args === "object" ? record.args : {},
4429
+ id: typeof record.id === "string" ? record.id : undefined,
4430
+ name: record.name
4431
+ });
4432
+ }
4433
+ return toolCalls;
4434
+ };
4435
+ var createGeminiVoiceAssistantModel = (options) => {
4436
+ const fetchImpl = options.fetch ?? globalThis.fetch;
4437
+ const baseUrl = options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta";
4438
+ const model = options.model ?? "gemini-2.5-flash";
4439
+ const maxRetries = Math.max(0, options.maxRetries ?? 2);
4440
+ return {
4441
+ generate: async (input) => {
4442
+ const endpoint = `${baseUrl.replace(/\/$/, "")}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(options.apiKey)}`;
4443
+ let response;
4444
+ for (let attempt = 0;attempt <= maxRetries; attempt += 1) {
4445
+ response = await fetchImpl(endpoint, {
4446
+ body: JSON.stringify({
4447
+ contents: input.messages.map(messageToGeminiContent).filter(Boolean),
4448
+ generationConfig: {
4449
+ maxOutputTokens: options.maxOutputTokens,
4450
+ ...input.tools.length ? {} : {
4451
+ responseMimeType: "application/json",
4452
+ responseSchema: toGeminiSchema(OUTPUT_SCHEMA)
4453
+ },
4454
+ temperature: options.temperature
4455
+ },
4456
+ systemInstruction: {
4457
+ parts: [
4458
+ {
4459
+ text: [input.system, ROUTE_RESULT_INSTRUCTION].filter(Boolean).join(`
4460
+
4461
+ `)
4462
+ }
4463
+ ]
4464
+ },
4465
+ tools: input.tools.length ? [
4466
+ {
4467
+ functionDeclarations: input.tools.map((tool) => ({
4468
+ description: tool.description,
4469
+ name: tool.name,
4470
+ parameters: toGeminiSchema(tool.parameters ?? {
4471
+ additionalProperties: true,
4472
+ type: "object"
4473
+ })
4474
+ }))
4475
+ }
4476
+ ] : undefined
4477
+ }),
4478
+ headers: {
4479
+ "content-type": "application/json"
4480
+ },
4481
+ method: "POST"
4482
+ });
4483
+ if (response.ok || response.status !== 429 && response.status < 500 || attempt === maxRetries) {
4484
+ break;
4485
+ }
4486
+ const retryAfter = Number(response.headers.get("retry-after"));
4487
+ await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt);
4488
+ }
4489
+ if (!response) {
4490
+ throw new Error("Gemini voice assistant model failed: no response");
4491
+ }
4492
+ if (!response.ok) {
4493
+ throw createHTTPError("Gemini", response);
4494
+ }
4495
+ const body = await response.json();
4496
+ if (body.usageMetadata && typeof body.usageMetadata === "object") {
4497
+ await options.onUsage?.(body.usageMetadata);
4498
+ }
4499
+ const toolCalls = extractGeminiToolCalls(body);
4500
+ if (toolCalls.length) {
4501
+ return {
4502
+ assistantText: extractGeminiText(body) || undefined,
4503
+ toolCalls
4504
+ };
4505
+ }
4506
+ return normalizeRouteOutput(parseJSON(extractGeminiText(body)));
4507
+ }
4508
+ };
4509
+ };
4510
+
3471
4511
  // src/store.ts
3472
4512
  var createId = () => crypto.randomUUID();
3473
4513
  var createVoiceSessionRecord = (id, scenarioId) => ({
@@ -3508,6 +4548,118 @@ var toVoiceSessionSummary = (session) => ({
3508
4548
  turnCount: session.turns.length
3509
4549
  });
3510
4550
 
4551
+ // src/testing/providerSimulator.ts
4552
+ var getContextQuery = (context) => context.query;
4553
+ var titleCaseProvider = (provider) => provider.split(/[-_\s]+/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" ");
4554
+ var resolveRequestedProvider = (context, providers) => {
4555
+ const provider = getContextQuery(context).provider;
4556
+ return providers.includes(provider) ? provider : providers[0];
4557
+ };
4558
+ var createVoiceProviderFailureSimulator = (options) => {
4559
+ if (options.providers.length === 0) {
4560
+ throw new Error("At least one provider is required.");
4561
+ }
4562
+ const providerModels = Object.fromEntries(options.providers.map((provider) => [
4563
+ provider,
4564
+ {
4565
+ generate: async (input) => {
4566
+ const query = getContextQuery(input.context);
4567
+ if (provider === query.simulateFailureProvider) {
4568
+ const label = options.providerLabel?.(provider) ?? titleCaseProvider(provider);
4569
+ throw new Error(`${label} voice assistant model failed: HTTP 429`);
4570
+ }
4571
+ if (options.response) {
4572
+ return options.response({
4573
+ ...input,
4574
+ mode: query.recoverProvider === provider ? "recovery" : "failure",
4575
+ provider
4576
+ });
4577
+ }
4578
+ return {
4579
+ assistantText: `Simulated ${provider} provider recovered.`
4580
+ };
4581
+ }
4582
+ }
4583
+ ]));
4584
+ const router = createVoiceProviderRouter({
4585
+ allowProviders: async (input) => {
4586
+ const recoverProvider = getContextQuery(input.context).recoverProvider;
4587
+ if (recoverProvider) {
4588
+ return [recoverProvider];
4589
+ }
4590
+ if (typeof options.allowProviders === "function") {
4591
+ return options.allowProviders(input);
4592
+ }
4593
+ return options.allowProviders ?? options.providers;
4594
+ },
4595
+ fallback: async (input) => {
4596
+ const selectedProvider = resolveRequestedProvider(input.context, options.providers);
4597
+ if (typeof options.fallback === "function") {
4598
+ return options.fallback(selectedProvider, input);
4599
+ }
4600
+ return options.fallback ?? options.providers.filter((provider) => provider !== selectedProvider);
4601
+ },
4602
+ fallbackMode: "provider-error",
4603
+ isProviderError: options.isProviderError,
4604
+ isRateLimitError: options.isRateLimitError,
4605
+ onProviderEvent: options.onProviderEvent,
4606
+ policy: "prefer-selected",
4607
+ providerHealth: options.providerHealth ?? {
4608
+ cooldownMs: 30000,
4609
+ failureThreshold: 1,
4610
+ rateLimitCooldownMs: 120000
4611
+ },
4612
+ providers: providerModels,
4613
+ selectProvider: ({ context }) => resolveRequestedProvider(context, options.providers)
4614
+ });
4615
+ const run = async (provider, mode) => {
4616
+ const now = Date.now();
4617
+ const session = createVoiceSessionRecord(`provider-sim-${now}`, "provider-simulation");
4618
+ const turn = {
4619
+ committedAt: now,
4620
+ id: `provider-sim-turn-${now}`,
4621
+ text: mode === "failure" ? `Simulate ${provider} provider failure.` : `Simulate ${provider} provider recovery.`,
4622
+ transcripts: []
4623
+ };
4624
+ const context = {
4625
+ query: {
4626
+ provider,
4627
+ ...mode === "recovery" ? { recoverProvider: provider } : {},
4628
+ ...mode === "failure" ? { simulateFailureProvider: provider } : {}
4629
+ }
4630
+ };
4631
+ const result = await router.generate({
4632
+ agentId: "provider-simulator",
4633
+ context,
4634
+ messages: [
4635
+ {
4636
+ content: turn.text,
4637
+ role: "user"
4638
+ }
4639
+ ],
4640
+ session,
4641
+ system: "Simulate provider routing without calling external APIs.",
4642
+ tools: [],
4643
+ turn
4644
+ });
4645
+ return {
4646
+ mode,
4647
+ provider,
4648
+ replayHref: options.replayHref === false ? undefined : typeof options.replayHref === "function" ? options.replayHref({
4649
+ provider,
4650
+ sessionId: session.id,
4651
+ turnId: turn.id
4652
+ }) : `${options.replayHref ?? "/api/voice-sessions"}/${encodeURIComponent(session.id)}/replay/htmx`,
4653
+ result,
4654
+ sessionId: session.id,
4655
+ status: "simulated",
4656
+ turnId: turn.id
4657
+ };
4658
+ };
4659
+ return {
4660
+ run
4661
+ };
4662
+ };
3511
4663
  // src/memoryStore.ts
3512
4664
  var createVoiceMemoryStore = () => {
3513
4665
  const sessions = new Map;
@@ -3531,7 +4683,290 @@ var createVoiceMemoryStore = () => {
3531
4683
  };
3532
4684
 
3533
4685
  // src/session.ts
3534
- import { Buffer } from "buffer";
4686
+ import { Buffer as Buffer2 } from "buffer";
4687
+
4688
+ // src/handoff.ts
4689
+ var toHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
4690
+ var signHandoffBody = async (input) => {
4691
+ const encoder = new TextEncoder;
4692
+ const key = await crypto.subtle.importKey("raw", encoder.encode(input.secret), {
4693
+ hash: "SHA-256",
4694
+ name: "HMAC"
4695
+ }, false, ["sign"]);
4696
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(`${input.timestamp}.${input.body}`));
4697
+ return `sha256=${toHex(new Uint8Array(signature))}`;
4698
+ };
4699
+ var toErrorMessage = (error) => error instanceof Error ? error.message : String(error);
4700
+ var createSkippedDelivery = (adapter) => ({
4701
+ adapterId: adapter.id,
4702
+ adapterKind: adapter.kind,
4703
+ status: "skipped"
4704
+ });
4705
+ var aggregateHandoffStatus = (deliveries) => {
4706
+ const statuses = Object.values(deliveries).map((delivery) => delivery.status);
4707
+ if (statuses.some((status) => status === "failed")) {
4708
+ return "failed";
4709
+ }
4710
+ if (statuses.some((status) => status === "delivered")) {
4711
+ return "delivered";
4712
+ }
4713
+ return "skipped";
4714
+ };
4715
+ var createHandoffDeliveryId = (input) => [
4716
+ "voice-handoff",
4717
+ input.sessionId,
4718
+ input.action,
4719
+ Date.now(),
4720
+ crypto.randomUUID()
4721
+ ].join(":");
4722
+ var resolveHandoffDeliveryError = (deliveries) => Object.values(deliveries).map((delivery) => delivery.error).find(Boolean);
4723
+ var defaultWebhookBody = (input) => ({
4724
+ action: input.action,
4725
+ metadata: input.metadata,
4726
+ reason: input.reason,
4727
+ result: input.result,
4728
+ session: {
4729
+ id: input.session.id,
4730
+ scenarioId: input.session.scenarioId,
4731
+ status: input.session.status
4732
+ },
4733
+ source: "absolutejs-voice",
4734
+ target: input.target
4735
+ });
4736
+ var deliverVoiceHandoff = async (input) => {
4737
+ if (!input.config || input.config.adapters.length === 0) {
4738
+ return;
4739
+ }
4740
+ const deliveries = {};
4741
+ for (const adapter of input.config.adapters) {
4742
+ if (adapter.actions && !adapter.actions.includes(input.handoff.action)) {
4743
+ deliveries[adapter.id] = createSkippedDelivery(adapter);
4744
+ continue;
4745
+ }
4746
+ try {
4747
+ const result = await adapter.handoff(input.handoff);
4748
+ deliveries[adapter.id] = {
4749
+ ...result,
4750
+ adapterId: adapter.id,
4751
+ adapterKind: adapter.kind
4752
+ };
4753
+ } catch (error) {
4754
+ deliveries[adapter.id] = {
4755
+ adapterId: adapter.id,
4756
+ adapterKind: adapter.kind,
4757
+ error: toErrorMessage(error),
4758
+ status: "failed"
4759
+ };
4760
+ if (input.config.failMode === "throw") {
4761
+ throw error;
4762
+ }
4763
+ }
4764
+ }
4765
+ return {
4766
+ action: input.handoff.action,
4767
+ deliveries,
4768
+ status: aggregateHandoffStatus(deliveries)
4769
+ };
4770
+ };
4771
+ var createVoiceHandoffDeliveryRecord = (input) => {
4772
+ const now = Date.now();
4773
+ return {
4774
+ action: input.action,
4775
+ context: input.context,
4776
+ createdAt: now,
4777
+ deliveryAttempts: 0,
4778
+ deliveryStatus: "pending",
4779
+ id: input.id ?? createHandoffDeliveryId({
4780
+ action: input.action,
4781
+ sessionId: input.session.id
4782
+ }),
4783
+ metadata: input.metadata,
4784
+ reason: input.reason,
4785
+ result: input.result,
4786
+ session: input.session,
4787
+ sessionId: input.session.id,
4788
+ target: input.target,
4789
+ updatedAt: now
4790
+ };
4791
+ };
4792
+ var applyVoiceHandoffDeliveryResult = (delivery, result) => ({
4793
+ ...delivery,
4794
+ deliveredAt: result.status === "delivered" || result.status === "skipped" ? Date.now() : delivery.deliveredAt,
4795
+ deliveries: result.deliveries,
4796
+ deliveryAttempts: (delivery.deliveryAttempts ?? 0) + 1,
4797
+ deliveryError: result.status === "failed" ? resolveHandoffDeliveryError(result.deliveries) : undefined,
4798
+ deliveryStatus: result.status,
4799
+ updatedAt: Date.now()
4800
+ });
4801
+ var deliverVoiceHandoffDelivery = async (options) => {
4802
+ const result = await deliverVoiceHandoff({
4803
+ config: {
4804
+ adapters: options.adapters,
4805
+ failMode: options.failMode
4806
+ },
4807
+ handoff: {
4808
+ action: options.delivery.action,
4809
+ api: options.api,
4810
+ context: options.delivery.context,
4811
+ metadata: options.delivery.metadata,
4812
+ reason: options.delivery.reason,
4813
+ result: options.delivery.result,
4814
+ session: options.delivery.session,
4815
+ target: options.delivery.target
4816
+ }
4817
+ });
4818
+ return result ? applyVoiceHandoffDeliveryResult(options.delivery, result) : {
4819
+ ...options.delivery,
4820
+ deliveryAttempts: (options.delivery.deliveryAttempts ?? 0) + 1,
4821
+ deliveryStatus: "skipped",
4822
+ updatedAt: Date.now()
4823
+ };
4824
+ };
4825
+ var createVoiceMemoryHandoffDeliveryStore = () => {
4826
+ const deliveries = new Map;
4827
+ return {
4828
+ get: async (id) => deliveries.get(id),
4829
+ list: async () => [...deliveries.values()].sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)),
4830
+ remove: async (id) => {
4831
+ deliveries.delete(id);
4832
+ },
4833
+ set: async (id, delivery) => {
4834
+ deliveries.set(id, delivery);
4835
+ }
4836
+ };
4837
+ };
4838
+ var createVoiceWebhookHandoffAdapter = (options) => ({
4839
+ actions: options.actions,
4840
+ handoff: async (input) => {
4841
+ const fetchImpl = options.fetch ?? globalThis.fetch;
4842
+ if (typeof fetchImpl !== "function") {
4843
+ return {
4844
+ deliveredTo: options.url,
4845
+ error: "Handoff delivery failed: fetch is not available in this runtime.",
4846
+ status: "failed"
4847
+ };
4848
+ }
4849
+ const body = JSON.stringify(await options.body?.(input) ?? defaultWebhookBody(input));
4850
+ const headers = {
4851
+ "content-type": "application/json",
4852
+ ...options.headers
4853
+ };
4854
+ if (options.signingSecret) {
4855
+ const timestamp = String(Date.now());
4856
+ headers["x-absolutejs-timestamp"] = timestamp;
4857
+ headers["x-absolutejs-signature"] = await signHandoffBody({
4858
+ body,
4859
+ secret: options.signingSecret,
4860
+ timestamp
4861
+ });
4862
+ }
4863
+ const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
4864
+ const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
4865
+ try {
4866
+ const response = await fetchImpl(options.url, {
4867
+ body,
4868
+ headers,
4869
+ method: options.method ?? "POST",
4870
+ signal: controller?.signal
4871
+ });
4872
+ if (!response.ok) {
4873
+ return {
4874
+ deliveredTo: options.url,
4875
+ error: `Handoff delivery failed with response ${response.status}.`,
4876
+ status: "failed"
4877
+ };
4878
+ }
4879
+ return {
4880
+ deliveredAt: Date.now(),
4881
+ deliveredTo: options.url,
4882
+ status: "delivered"
4883
+ };
4884
+ } finally {
4885
+ if (timeout) {
4886
+ clearTimeout(timeout);
4887
+ }
4888
+ }
4889
+ },
4890
+ id: options.id,
4891
+ kind: options.kind ?? "webhook"
4892
+ });
4893
+ var escapeXml = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
4894
+ var defaultTwilioTransferTwiML = (input) => {
4895
+ if (!input.target) {
4896
+ return "<Response><Hangup /></Response>";
4897
+ }
4898
+ return `<Response><Dial>${escapeXml(input.target)}</Dial></Response>`;
4899
+ };
4900
+ var resolveTwilioCallSid = async (resolver, input) => {
4901
+ if (typeof resolver === "function") {
4902
+ return resolver(input);
4903
+ }
4904
+ if (typeof resolver === "string" && resolver.length > 0) {
4905
+ return resolver;
4906
+ }
4907
+ const metadataSid = typeof input.metadata?.callSid === "string" ? input.metadata.callSid : undefined;
4908
+ const sessionMetadata = input.session.metadata && typeof input.session.metadata === "object" ? input.session.metadata : undefined;
4909
+ const sessionSid = typeof sessionMetadata?.callSid === "string" ? sessionMetadata.callSid : undefined;
4910
+ return metadataSid ?? sessionSid;
4911
+ };
4912
+ var createVoiceTwilioRedirectHandoffAdapter = (options) => ({
4913
+ actions: options.actions ?? ["transfer"],
4914
+ handoff: async (input) => {
4915
+ const fetchImpl = options.fetch ?? globalThis.fetch;
4916
+ const callSid = await resolveTwilioCallSid(options.callSid, input);
4917
+ if (!callSid) {
4918
+ return {
4919
+ error: "Twilio handoff requires a callSid.",
4920
+ status: "failed"
4921
+ };
4922
+ }
4923
+ if (typeof fetchImpl !== "function") {
4924
+ return {
4925
+ error: "Twilio handoff failed: fetch is not available in this runtime.",
4926
+ status: "failed"
4927
+ };
4928
+ }
4929
+ const url = `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(options.accountSid)}/Calls/${encodeURIComponent(callSid)}.json`;
4930
+ const body = new URLSearchParams({
4931
+ Twiml: await (options.buildTwiML?.(input) ?? defaultTwilioTransferTwiML(input))
4932
+ });
4933
+ const auth = btoa(`${options.accountSid}:${options.authToken}`);
4934
+ const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
4935
+ const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
4936
+ try {
4937
+ const response = await fetchImpl(url, {
4938
+ body,
4939
+ headers: {
4940
+ authorization: `Basic ${auth}`,
4941
+ "content-type": "application/x-www-form-urlencoded"
4942
+ },
4943
+ method: "POST",
4944
+ signal: controller?.signal
4945
+ });
4946
+ if (!response.ok) {
4947
+ return {
4948
+ deliveredTo: url,
4949
+ error: `Twilio handoff failed with response ${response.status}.`,
4950
+ status: "failed"
4951
+ };
4952
+ }
4953
+ return {
4954
+ deliveredAt: Date.now(),
4955
+ deliveredTo: url,
4956
+ metadata: {
4957
+ callSid
4958
+ },
4959
+ status: "delivered"
4960
+ };
4961
+ } finally {
4962
+ if (timeout) {
4963
+ clearTimeout(timeout);
4964
+ }
4965
+ }
4966
+ },
4967
+ id: options.id ?? "twilio-redirect",
4968
+ kind: "twilio-redirect"
4969
+ });
3535
4970
 
3536
4971
  // src/logger.ts
3537
4972
  var noop2 = () => {};
@@ -3579,7 +5014,7 @@ var createEmptyCurrentTurn = () => ({
3579
5014
  transcripts: []
3580
5015
  });
3581
5016
  var cloneTranscript = (transcript) => ({ ...transcript });
3582
- var encodeBase64 = (chunk) => Buffer.from(chunk).toString("base64");
5017
+ var encodeBase64 = (chunk) => Buffer2.from(chunk).toString("base64");
3583
5018
  var countWords2 = (text) => text.trim().split(/\s+/).filter(Boolean).length;
3584
5019
  var normalizeText2 = (text) => text.trim().replace(/\s+/g, " ");
3585
5020
  var getAudioChunkDurationMs = (chunk) => chunk.byteLength / (DEFAULT_FORMAT.sampleRateHz * DEFAULT_FORMAT.channels * 2) * 1000;
@@ -3725,6 +5160,7 @@ var pushCallLifecycleEvent = (session, input) => {
3725
5160
  }
3726
5161
  return lifecycle;
3727
5162
  };
5163
+ var getLatestCallLifecycleEvent = (session) => session.call?.events.at(-1);
3728
5164
  var createVoiceSession = (options) => {
3729
5165
  const logger = resolveLogger(options.logger);
3730
5166
  const reconnect = {
@@ -3825,6 +5261,64 @@ var createVoiceSession = (options) => {
3825
5261
  });
3826
5262
  }
3827
5263
  };
5264
+ const sendCallLifecycle = async (session) => {
5265
+ const event = getLatestCallLifecycleEvent(session);
5266
+ if (!event) {
5267
+ return;
5268
+ }
5269
+ await send({
5270
+ event,
5271
+ sessionId: options.id,
5272
+ type: "call_lifecycle"
5273
+ });
5274
+ };
5275
+ const runHandoff = async (input) => {
5276
+ const queuedDelivery = options.handoff?.deliveryQueue ? createVoiceHandoffDeliveryRecord({
5277
+ action: input.action,
5278
+ context: options.context,
5279
+ metadata: input.metadata,
5280
+ reason: input.reason,
5281
+ result: input.result,
5282
+ session: input.session,
5283
+ target: input.target
5284
+ }) : undefined;
5285
+ if (queuedDelivery) {
5286
+ await options.handoff?.deliveryQueue?.set(queuedDelivery.id, queuedDelivery);
5287
+ }
5288
+ if (options.handoff?.enqueueOnly) {
5289
+ return;
5290
+ }
5291
+ const result = await deliverVoiceHandoff({
5292
+ config: options.handoff,
5293
+ handoff: {
5294
+ action: input.action,
5295
+ api,
5296
+ context: options.context,
5297
+ metadata: input.metadata,
5298
+ reason: input.reason,
5299
+ result: input.result,
5300
+ session: input.session,
5301
+ target: input.target
5302
+ }
5303
+ });
5304
+ if (!result) {
5305
+ return;
5306
+ }
5307
+ if (queuedDelivery) {
5308
+ const updatedDelivery = applyVoiceHandoffDeliveryResult(queuedDelivery, result);
5309
+ await options.handoff?.deliveryQueue?.set(updatedDelivery.id, updatedDelivery);
5310
+ }
5311
+ await appendTrace({
5312
+ metadata: input.metadata,
5313
+ payload: {
5314
+ ...result,
5315
+ reason: input.reason,
5316
+ target: input.target
5317
+ },
5318
+ session: input.session,
5319
+ type: "call.handoff"
5320
+ });
5321
+ };
3828
5322
  const readSession = async () => options.store.getOrCreate(options.id);
3829
5323
  const writeSession = async (mutate) => {
3830
5324
  const session = await options.store.getOrCreate(options.id);
@@ -4015,6 +5509,7 @@ var createVoiceSession = (options) => {
4015
5509
  await appendTrace({
4016
5510
  payload: {
4017
5511
  disposition,
5512
+ metadata: input.metadata,
4018
5513
  reason: input.reason,
4019
5514
  target: input.target,
4020
5515
  type: "end"
@@ -4022,6 +5517,7 @@ var createVoiceSession = (options) => {
4022
5517
  session,
4023
5518
  type: "call.lifecycle"
4024
5519
  });
5520
+ await sendCallLifecycle(session);
4025
5521
  await send({
4026
5522
  sessionId: options.id,
4027
5523
  type: "complete"
@@ -4101,6 +5597,15 @@ var createVoiceSession = (options) => {
4101
5597
  session,
4102
5598
  type: "call.lifecycle"
4103
5599
  });
5600
+ await sendCallLifecycle(session);
5601
+ await runHandoff({
5602
+ action: "transfer",
5603
+ metadata: input.metadata,
5604
+ reason: input.reason,
5605
+ result: input.result,
5606
+ session,
5607
+ target: input.target
5608
+ });
4104
5609
  await completeInternal(input.result, {
4105
5610
  disposition: "transferred",
4106
5611
  invokeOnComplete: false,
@@ -4126,6 +5631,14 @@ var createVoiceSession = (options) => {
4126
5631
  session,
4127
5632
  type: "call.lifecycle"
4128
5633
  });
5634
+ await sendCallLifecycle(session);
5635
+ await runHandoff({
5636
+ action: "escalate",
5637
+ metadata: input.metadata,
5638
+ reason: input.reason,
5639
+ result: input.result,
5640
+ session
5641
+ });
4129
5642
  await completeInternal(input.result, {
4130
5643
  disposition: "escalated",
4131
5644
  invokeOnComplete: false,
@@ -4148,6 +5661,13 @@ var createVoiceSession = (options) => {
4148
5661
  session,
4149
5662
  type: "call.lifecycle"
4150
5663
  });
5664
+ await sendCallLifecycle(session);
5665
+ await runHandoff({
5666
+ action: "no-answer",
5667
+ metadata: input?.metadata,
5668
+ result: input?.result,
5669
+ session
5670
+ });
4151
5671
  await completeInternal(input?.result, {
4152
5672
  disposition: "no-answer",
4153
5673
  invokeOnComplete: false,
@@ -4169,6 +5689,13 @@ var createVoiceSession = (options) => {
4169
5689
  session,
4170
5690
  type: "call.lifecycle"
4171
5691
  });
5692
+ await sendCallLifecycle(session);
5693
+ await runHandoff({
5694
+ action: "voicemail",
5695
+ metadata: input?.metadata,
5696
+ result: input?.result,
5697
+ session
5698
+ });
4172
5699
  await completeInternal(input?.result, {
4173
5700
  disposition: "voicemail",
4174
5701
  invokeOnComplete: false,
@@ -4955,6 +6482,7 @@ var createVoiceSession = (options) => {
4955
6482
  session,
4956
6483
  type: "call.lifecycle"
4957
6484
  });
6485
+ await sendCallLifecycle(session);
4958
6486
  }
4959
6487
  await send({
4960
6488
  sessionId: options.id,
@@ -5545,7 +7073,7 @@ var createVoiceCallReviewFromLiveTelephonyReport = (report, options = {}) => {
5545
7073
  }
5546
7074
  };
5547
7075
  };
5548
- var toErrorMessage = (error) => {
7076
+ var toErrorMessage2 = (error) => {
5549
7077
  if (typeof error === "string" && error.trim().length > 0) {
5550
7078
  return error;
5551
7079
  }
@@ -5632,7 +7160,7 @@ var createVoiceCallReviewRecorder = (options = {}) => {
5632
7160
  };
5633
7161
  },
5634
7162
  recordError: (error) => {
5635
- const message = toErrorMessage(error);
7163
+ const message = toErrorMessage2(error);
5636
7164
  errors.push(message);
5637
7165
  push("turn", "error", {
5638
7166
  reason: message
@@ -6338,10 +7866,865 @@ var runVoiceSessionBenchmarkSeries = async (input) => {
6338
7866
  });
6339
7867
  };
6340
7868
  // src/telephony/twilio.ts
6341
- import { Buffer as Buffer2 } from "buffer";
7869
+ import { Buffer as Buffer3 } from "buffer";
7870
+ import { Elysia as Elysia2 } from "elysia";
7871
+
7872
+ // src/telephonyOutcome.ts
7873
+ import { Elysia } from "elysia";
7874
+ var DEFAULT_COMPLETED_STATUSES = [
7875
+ "answered",
7876
+ "completed",
7877
+ "complete",
7878
+ "connected",
7879
+ "in-progress",
7880
+ "live"
7881
+ ];
7882
+ var DEFAULT_NO_ANSWER_STATUSES = [
7883
+ "busy",
7884
+ "canceled",
7885
+ "cancelled",
7886
+ "failed",
7887
+ "no-answer",
7888
+ "no_answer",
7889
+ "not-answered",
7890
+ "ring-no-answer",
7891
+ "timeout",
7892
+ "unanswered"
7893
+ ];
7894
+ var DEFAULT_VOICEMAIL_STATUSES = [
7895
+ "answering-machine",
7896
+ "machine",
7897
+ "voicemail",
7898
+ "voice-mail"
7899
+ ];
7900
+ var DEFAULT_TRANSFER_STATUSES = ["bridged", "forwarded", "transferred"];
7901
+ var DEFAULT_ESCALATION_STATUSES = ["escalated", "human-required", "operator"];
7902
+ var DEFAULT_FAILED_STATUSES = ["busy", "failed", "no-answer"];
7903
+ var DEFAULT_MACHINE_VOICEMAIL_VALUES = [
7904
+ "answering-machine",
7905
+ "fax",
7906
+ "machine",
7907
+ "machine-end-beep",
7908
+ "machine-end-other",
7909
+ "machine-start",
7910
+ "voicemail"
7911
+ ];
7912
+ var DEFAULT_NO_ANSWER_SIP_CODES = [408, 480, 486, 487, 603];
7913
+ var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
7914
+
7915
+ class VoiceTelephonyWebhookVerificationError extends Error {
7916
+ result;
7917
+ constructor(result) {
7918
+ super(result.ok ? "telephony webhook verified" : result.reason);
7919
+ this.name = "VoiceTelephonyWebhookVerificationError";
7920
+ this.result = result;
7921
+ }
7922
+ }
7923
+ var createMemoryVoiceTelephonyWebhookIdempotencyStore = () => {
7924
+ const decisions = new Map;
7925
+ return {
7926
+ get: (key) => decisions.get(key),
7927
+ set: (key, decision) => {
7928
+ decisions.set(key, decision);
7929
+ }
7930
+ };
7931
+ };
7932
+ var normalizeToken = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/\s+/g, "-").replace(/_+/g, "-") : undefined;
7933
+ var firstString = (source, keys) => {
7934
+ for (const key of keys) {
7935
+ const value = source[key];
7936
+ if (typeof value === "string" && value.trim()) {
7937
+ return value.trim();
7938
+ }
7939
+ if (typeof value === "number" && Number.isFinite(value)) {
7940
+ return String(value);
7941
+ }
7942
+ }
7943
+ };
7944
+ var firstNumber = (source, keys) => {
7945
+ for (const key of keys) {
7946
+ const value = source[key];
7947
+ if (typeof value === "number" && Number.isFinite(value)) {
7948
+ return value;
7949
+ }
7950
+ if (typeof value === "string" && value.trim()) {
7951
+ const parsed = Number(value);
7952
+ if (Number.isFinite(parsed)) {
7953
+ return parsed;
7954
+ }
7955
+ }
7956
+ }
7957
+ };
7958
+ var parseMaybeJSON = (value) => {
7959
+ try {
7960
+ return JSON.parse(value);
7961
+ } catch {
7962
+ return;
7963
+ }
7964
+ };
7965
+ var flattenPayload = (value) => {
7966
+ if (!isRecord(value)) {
7967
+ return {};
7968
+ }
7969
+ const data = isRecord(value.data) ? value.data : undefined;
7970
+ const payload = isRecord(value.payload) ? value.payload : undefined;
7971
+ const event = isRecord(value.event) ? value.event : undefined;
7972
+ return {
7973
+ ...value,
7974
+ ...payload,
7975
+ ...event,
7976
+ ...data,
7977
+ ...isRecord(data?.payload) ? data.payload : undefined
7978
+ };
7979
+ };
7980
+ var toBase64 = (bytes) => Buffer.from(new Uint8Array(bytes)).toString("base64");
7981
+ var timingSafeEqual = (left, right) => {
7982
+ const encoder = new TextEncoder;
7983
+ const leftBytes = encoder.encode(left);
7984
+ const rightBytes = encoder.encode(right);
7985
+ if (leftBytes.length !== rightBytes.length) {
7986
+ return false;
7987
+ }
7988
+ let diff = 0;
7989
+ for (let index = 0;index < leftBytes.length; index += 1) {
7990
+ diff |= leftBytes[index] ^ rightBytes[index];
7991
+ }
7992
+ return diff === 0;
7993
+ };
7994
+ var signHmacSHA1Base64 = async (secret, payload) => {
7995
+ const encoder = new TextEncoder;
7996
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
7997
+ hash: "SHA-1",
7998
+ name: "HMAC"
7999
+ }, false, ["sign"]);
8000
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
8001
+ return toBase64(signature);
8002
+ };
8003
+ var sortedParamsForSignature = (body) => Object.entries(flattenPayload(body)).filter(([, value]) => value !== undefined && value !== null).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => `${key}${String(value)}`).join("");
8004
+ var normalizeList = (values, fallback) => new Set((values ?? fallback).map(normalizeToken).filter(Boolean));
8005
+ var metadataValue = (metadata, keys) => {
8006
+ for (const key of keys) {
8007
+ const value = metadata?.[key];
8008
+ if (typeof value === "string" && value.trim()) {
8009
+ return value.trim();
8010
+ }
8011
+ }
8012
+ };
8013
+ var resolveTransferTarget = (event, policy) => {
8014
+ if (typeof event.target === "string" && event.target.trim()) {
8015
+ return event.target.trim();
8016
+ }
8017
+ const metadataTarget = metadataValue(event.metadata, [
8018
+ "transferTarget",
8019
+ "target",
8020
+ "queue",
8021
+ "department"
8022
+ ]);
8023
+ if (metadataTarget) {
8024
+ return metadataTarget;
8025
+ }
8026
+ if (typeof policy.transferTarget === "function") {
8027
+ const target = policy.transferTarget(event);
8028
+ return typeof target === "string" && target.trim() ? target.trim() : undefined;
8029
+ }
8030
+ return typeof policy.transferTarget === "string" && policy.transferTarget.trim() ? policy.transferTarget.trim() : undefined;
8031
+ };
8032
+ var mergeMetadata = (event, policy) => ({
8033
+ ...policy.includeProviderPayload ? {
8034
+ answeredBy: event.answeredBy,
8035
+ durationMs: event.durationMs,
8036
+ provider: event.provider,
8037
+ reason: event.reason,
8038
+ sipCode: event.sipCode,
8039
+ status: event.status
8040
+ } : undefined,
8041
+ ...policy.metadata,
8042
+ ...event.metadata
8043
+ });
8044
+ var withDecisionDefaults = (decision, input) => {
8045
+ if (typeof decision === "string") {
8046
+ return buildDecision(decision, input);
8047
+ }
8048
+ return {
8049
+ ...buildDecision(decision.action, input),
8050
+ ...decision,
8051
+ confidence: decision.confidence ?? "high",
8052
+ metadata: {
8053
+ ...mergeMetadata(input.event, input.policy),
8054
+ ...decision.metadata
8055
+ },
8056
+ source: decision.source ?? input.source,
8057
+ target: decision.target ?? (decision.action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined)
8058
+ };
8059
+ };
8060
+ var dispositionForAction = (action) => {
8061
+ switch (action) {
8062
+ case "complete":
8063
+ return "completed";
8064
+ case "escalate":
8065
+ return "escalated";
8066
+ case "no-answer":
8067
+ return "no-answer";
8068
+ case "transfer":
8069
+ return "transferred";
8070
+ case "voicemail":
8071
+ return "voicemail";
8072
+ default:
8073
+ return;
8074
+ }
8075
+ };
8076
+ var buildDecision = (action, input) => ({
8077
+ action,
8078
+ confidence: action === "ignore" ? "low" : "high",
8079
+ disposition: dispositionForAction(action),
8080
+ metadata: mergeMetadata(input.event, input.policy),
8081
+ reason: input.event.reason,
8082
+ source: input.source,
8083
+ target: action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined
8084
+ });
8085
+ var createVoiceTelephonyOutcomePolicy = (policy = {}) => ({
8086
+ completedStatuses: policy.completedStatuses ?? DEFAULT_COMPLETED_STATUSES,
8087
+ escalationStatuses: policy.escalationStatuses ?? DEFAULT_ESCALATION_STATUSES,
8088
+ failedAsNoAnswer: policy.failedAsNoAnswer ?? true,
8089
+ failedStatuses: policy.failedStatuses ?? DEFAULT_FAILED_STATUSES,
8090
+ includeProviderPayload: policy.includeProviderPayload ?? true,
8091
+ machineDetectionVoicemailValues: policy.machineDetectionVoicemailValues ?? DEFAULT_MACHINE_VOICEMAIL_VALUES,
8092
+ metadata: policy.metadata,
8093
+ minAnsweredDurationMs: policy.minAnsweredDurationMs,
8094
+ noAnswerOnZeroDuration: policy.noAnswerOnZeroDuration ?? true,
8095
+ noAnswerSipCodes: policy.noAnswerSipCodes ?? DEFAULT_NO_ANSWER_SIP_CODES,
8096
+ noAnswerStatuses: policy.noAnswerStatuses ?? DEFAULT_NO_ANSWER_STATUSES,
8097
+ statusMap: policy.statusMap,
8098
+ transferStatuses: policy.transferStatuses ?? DEFAULT_TRANSFER_STATUSES,
8099
+ transferTarget: policy.transferTarget,
8100
+ voicemailStatuses: policy.voicemailStatuses ?? DEFAULT_VOICEMAIL_STATUSES
8101
+ });
8102
+ var resolveVoiceTelephonyOutcome = (event, policyInput = {}) => {
8103
+ const policy = createVoiceTelephonyOutcomePolicy(policyInput);
8104
+ const status = normalizeToken(event.status);
8105
+ const provider = normalizeToken(event.provider);
8106
+ const answeredBy = normalizeToken(event.answeredBy);
8107
+ const target = resolveTransferTarget(event, policy);
8108
+ if (status) {
8109
+ const mapped = policy.statusMap?.[status] ?? (provider ? policy.statusMap?.[`${provider}:${status}`] : undefined);
8110
+ if (mapped) {
8111
+ return withDecisionDefaults(mapped, {
8112
+ event,
8113
+ policy,
8114
+ source: "policy"
8115
+ });
8116
+ }
8117
+ }
8118
+ if (answeredBy && normalizeList(policy.machineDetectionVoicemailValues, []).has(answeredBy)) {
8119
+ return buildDecision("voicemail", { event, policy, source: "answered-by" });
8120
+ }
8121
+ if (typeof event.sipCode === "number" && policy.noAnswerSipCodes.includes(event.sipCode)) {
8122
+ return buildDecision("no-answer", { event, policy, source: "sip" });
8123
+ }
8124
+ if (target && status && normalizeList(policy.transferStatuses, []).has(status)) {
8125
+ return buildDecision("transfer", { event, policy, source: "status" });
8126
+ }
8127
+ if (status && normalizeList(policy.voicemailStatuses, []).has(status)) {
8128
+ return buildDecision("voicemail", { event, policy, source: "status" });
8129
+ }
8130
+ if (status && normalizeList(policy.escalationStatuses, []).has(status)) {
8131
+ return buildDecision("escalate", { event, policy, source: "status" });
8132
+ }
8133
+ if (status && (policy.failedAsNoAnswer ? normalizeList(policy.noAnswerStatuses, []).has(status) || normalizeList(policy.failedStatuses, []).has(status) : normalizeList(policy.noAnswerStatuses, []).has(status))) {
8134
+ return buildDecision("no-answer", { event, policy, source: "status" });
8135
+ }
8136
+ if (policy.noAnswerOnZeroDuration && typeof event.durationMs === "number" && event.durationMs <= 0) {
8137
+ return buildDecision("no-answer", { event, policy, source: "duration" });
8138
+ }
8139
+ if (typeof policy.minAnsweredDurationMs === "number" && typeof event.durationMs === "number" && event.durationMs < policy.minAnsweredDurationMs) {
8140
+ return {
8141
+ ...buildDecision("no-answer", { event, policy, source: "duration" }),
8142
+ confidence: "medium"
8143
+ };
8144
+ }
8145
+ if (status && normalizeList(policy.completedStatuses, []).has(status)) {
8146
+ return buildDecision("complete", { event, policy, source: "status" });
8147
+ }
8148
+ if (target) {
8149
+ return {
8150
+ ...buildDecision("transfer", { event, policy, source: "explicit-target" }),
8151
+ confidence: "medium"
8152
+ };
8153
+ }
8154
+ return buildDecision("ignore", { event, policy, source: "status" });
8155
+ };
8156
+ var voiceTelephonyOutcomeToRouteResult = (decision, result) => {
8157
+ switch (decision.action) {
8158
+ case "complete":
8159
+ return { complete: true, result };
8160
+ case "escalate":
8161
+ return {
8162
+ escalate: {
8163
+ metadata: decision.metadata,
8164
+ reason: decision.reason ?? "telephony-escalation"
8165
+ },
8166
+ result
8167
+ };
8168
+ case "no-answer":
8169
+ return {
8170
+ noAnswer: {
8171
+ metadata: decision.metadata
8172
+ },
8173
+ result
8174
+ };
8175
+ case "transfer":
8176
+ if (!decision.target) {
8177
+ return { result };
8178
+ }
8179
+ return {
8180
+ result,
8181
+ transfer: {
8182
+ metadata: decision.metadata,
8183
+ reason: decision.reason,
8184
+ target: decision.target
8185
+ }
8186
+ };
8187
+ case "voicemail":
8188
+ return {
8189
+ result,
8190
+ voicemail: {
8191
+ metadata: decision.metadata
8192
+ }
8193
+ };
8194
+ default:
8195
+ return { result };
8196
+ }
8197
+ };
8198
+ var applyVoiceTelephonyOutcome = async (api, decision, result) => {
8199
+ switch (decision.action) {
8200
+ case "complete":
8201
+ await api.complete(result);
8202
+ break;
8203
+ case "escalate":
8204
+ await api.escalate({
8205
+ metadata: decision.metadata,
8206
+ reason: decision.reason ?? "telephony-escalation",
8207
+ result
8208
+ });
8209
+ break;
8210
+ case "no-answer":
8211
+ await api.markNoAnswer({
8212
+ metadata: decision.metadata,
8213
+ result
8214
+ });
8215
+ break;
8216
+ case "transfer":
8217
+ if (!decision.target) {
8218
+ return;
8219
+ }
8220
+ await api.transfer({
8221
+ metadata: decision.metadata,
8222
+ reason: decision.reason,
8223
+ result,
8224
+ target: decision.target
8225
+ });
8226
+ break;
8227
+ case "voicemail":
8228
+ await api.markVoicemail({
8229
+ metadata: decision.metadata,
8230
+ result
8231
+ });
8232
+ break;
8233
+ default:
8234
+ break;
8235
+ }
8236
+ };
8237
+ var parseRequestBodyText = (input) => {
8238
+ const { contentType, text } = input;
8239
+ if (!text) {
8240
+ return {};
8241
+ }
8242
+ if (contentType.includes("application/json")) {
8243
+ return parseMaybeJSON(text) ?? {};
8244
+ }
8245
+ if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
8246
+ return Object.fromEntries(new URLSearchParams(text));
8247
+ }
8248
+ return parseMaybeJSON(text) ?? Object.fromEntries(new URLSearchParams(text));
8249
+ };
8250
+ var readRequestBody = async (request) => {
8251
+ const contentType = request.headers.get("content-type") ?? "";
8252
+ const text = await request.text();
8253
+ return {
8254
+ body: parseRequestBodyText({ contentType, text }),
8255
+ rawBody: text
8256
+ };
8257
+ };
8258
+ var signVoiceTwilioWebhook = async (input) => signHmacSHA1Base64(input.authToken, `${input.url}${sortedParamsForSignature(input.body ?? {})}`);
8259
+ var verifyVoiceTwilioWebhookSignature = async (input) => {
8260
+ if (!input.authToken) {
8261
+ return { ok: false, reason: "missing-secret" };
8262
+ }
8263
+ const signature = input.headers.get("x-twilio-signature");
8264
+ if (!signature) {
8265
+ return { ok: false, reason: "missing-signature" };
8266
+ }
8267
+ const expected = await signVoiceTwilioWebhook({
8268
+ authToken: input.authToken,
8269
+ body: input.body,
8270
+ url: input.url
8271
+ });
8272
+ return timingSafeEqual(signature, expected) ? { ok: true } : { ok: false, reason: "invalid-signature" };
8273
+ };
8274
+ var resolveVerificationUrl = (option, input) => typeof option === "function" ? option(input) : option ?? input.request.url;
8275
+ var verifyVoiceTelephonyWebhook = async (input) => {
8276
+ if (input.options.verify) {
8277
+ return input.options.verify({
8278
+ body: input.body,
8279
+ headers: input.request.headers,
8280
+ provider: input.provider,
8281
+ query: input.query,
8282
+ rawBody: input.rawBody,
8283
+ request: input.request
8284
+ });
8285
+ }
8286
+ if (!input.options.signingSecret) {
8287
+ return input.options.requireVerification ? { ok: false, reason: "missing-secret" } : { ok: true };
8288
+ }
8289
+ if (input.provider !== "twilio") {
8290
+ return { ok: false, reason: "unsupported-provider" };
8291
+ }
8292
+ return verifyVoiceTwilioWebhookSignature({
8293
+ authToken: input.options.signingSecret,
8294
+ body: input.body,
8295
+ headers: input.request.headers,
8296
+ url: resolveVerificationUrl(input.options.verificationUrl, {
8297
+ query: input.query,
8298
+ request: input.request
8299
+ })
8300
+ });
8301
+ };
8302
+ var durationMsFromSeconds = (value) => typeof value === "number" ? value * 1000 : undefined;
8303
+ var parseVoiceTelephonyWebhookEvent = (input) => {
8304
+ const payload = flattenPayload(input.body);
8305
+ const provider = firstString(payload, ["provider", "Provider"]) ?? input.provider;
8306
+ const status = firstString(payload, [
8307
+ "CallStatus",
8308
+ "call_status",
8309
+ "callStatus",
8310
+ "DialCallStatus",
8311
+ "dial_call_status",
8312
+ "status",
8313
+ "event_type",
8314
+ "type"
8315
+ ]);
8316
+ const durationMs = firstNumber(payload, ["durationMs", "duration_ms"]) ?? durationMsFromSeconds(firstNumber(payload, [
8317
+ "CallDuration",
8318
+ "call_duration",
8319
+ "callDuration",
8320
+ "DialCallDuration",
8321
+ "dial_call_duration",
8322
+ "duration"
8323
+ ]));
8324
+ const sipCode = firstNumber(payload, [
8325
+ "SipResponseCode",
8326
+ "sip_response_code",
8327
+ "sipCode",
8328
+ "sip_code",
8329
+ "hangupCauseCode"
8330
+ ]);
8331
+ const from = firstString(payload, ["From", "from", "caller_id", "callerId"]);
8332
+ const to = firstString(payload, ["To", "to", "called_number", "calledNumber"]);
8333
+ const target = firstString(payload, [
8334
+ "transferTarget",
8335
+ "TransferTarget",
8336
+ "target",
8337
+ "queue",
8338
+ "department"
8339
+ ]);
8340
+ return {
8341
+ answeredBy: firstString(payload, [
8342
+ "AnsweredBy",
8343
+ "answered_by",
8344
+ "answeredBy",
8345
+ "machineDetection",
8346
+ "machine_detection"
8347
+ ]),
8348
+ durationMs,
8349
+ from,
8350
+ metadata: payload,
8351
+ provider,
8352
+ reason: firstString(payload, [
8353
+ "Reason",
8354
+ "reason",
8355
+ "HangupCause",
8356
+ "hangup_cause",
8357
+ "hangupCause"
8358
+ ]),
8359
+ sipCode,
8360
+ status,
8361
+ target,
8362
+ to
8363
+ };
8364
+ };
8365
+ var defaultSessionId = (input) => {
8366
+ const payload = flattenPayload(input.body);
8367
+ const metadataSessionId = input.event.metadata?.sessionId;
8368
+ return firstString(input.query, ["sessionId", "session_id"]) ?? firstString(payload, [
8369
+ "sessionId",
8370
+ "session_id",
8371
+ "SessionId",
8372
+ "CallSid",
8373
+ "call_sid",
8374
+ "callSid",
8375
+ "CallUUID",
8376
+ "call_uuid",
8377
+ "callControlId",
8378
+ "call_control_id"
8379
+ ]) ?? (typeof metadataSessionId === "string" ? metadataSessionId : undefined);
8380
+ };
8381
+ var defaultIdempotencyKey = (input) => {
8382
+ const payload = flattenPayload(input.body);
8383
+ const eventId = firstString(payload, [
8384
+ "id",
8385
+ "event_id",
8386
+ "eventId",
8387
+ "EventSid",
8388
+ "event_sid",
8389
+ "MessageSid",
8390
+ "message_sid",
8391
+ "CallSid",
8392
+ "call_sid",
8393
+ "CallUUID",
8394
+ "call_uuid",
8395
+ "callControlId",
8396
+ "call_control_id"
8397
+ ]);
8398
+ const status = normalizeToken(input.event.status) ?? "unknown";
8399
+ if (eventId) {
8400
+ return `${input.provider}:${eventId}:${status}`;
8401
+ }
8402
+ if (input.sessionId) {
8403
+ return `${input.provider}:${input.sessionId}:${status}`;
8404
+ }
8405
+ };
8406
+ var createVoiceTelephonyWebhookHandler = (options = {}) => async (input) => {
8407
+ const provider = options.provider ?? "generic";
8408
+ const query = input.query ?? {};
8409
+ const { body, rawBody } = await readRequestBody(input.request);
8410
+ const verification = await verifyVoiceTelephonyWebhook({
8411
+ body,
8412
+ options,
8413
+ provider,
8414
+ query,
8415
+ rawBody,
8416
+ request: input.request
8417
+ });
8418
+ if (!verification.ok) {
8419
+ throw new VoiceTelephonyWebhookVerificationError(verification);
8420
+ }
8421
+ const event = options.parse ? await options.parse({
8422
+ body,
8423
+ headers: input.request.headers,
8424
+ provider,
8425
+ query,
8426
+ request: input.request
8427
+ }) : parseVoiceTelephonyWebhookEvent({
8428
+ body,
8429
+ headers: input.request.headers,
8430
+ provider,
8431
+ query,
8432
+ request: input.request
8433
+ });
8434
+ const sessionId = await (options.resolveSessionId?.({
8435
+ body,
8436
+ event,
8437
+ query,
8438
+ request: input.request
8439
+ }) ?? defaultSessionId({ body, event, query }));
8440
+ const idempotencyEnabled = options.idempotency?.enabled !== false;
8441
+ const idempotencyKey = idempotencyEnabled ? await (options.idempotency?.key?.({
8442
+ body,
8443
+ event,
8444
+ provider,
8445
+ query,
8446
+ request: input.request,
8447
+ sessionId
8448
+ }) ?? defaultIdempotencyKey({ body, event, provider, sessionId })) : undefined;
8449
+ const idempotencyStore = options.idempotency?.store;
8450
+ if (idempotencyKey && idempotencyStore) {
8451
+ const existing = await idempotencyStore.get(idempotencyKey);
8452
+ if (existing) {
8453
+ const duplicateDecision = {
8454
+ ...existing,
8455
+ duplicate: true
8456
+ };
8457
+ await options.onDecision?.({
8458
+ ...duplicateDecision,
8459
+ context: options.context,
8460
+ request: input.request
8461
+ });
8462
+ return duplicateDecision;
8463
+ }
8464
+ }
8465
+ const decision = resolveVoiceTelephonyOutcome(event, options.policy);
8466
+ const resultResolver = options.result;
8467
+ const result = typeof resultResolver === "function" ? await resultResolver({
8468
+ decision,
8469
+ event,
8470
+ sessionId
8471
+ }) : resultResolver;
8472
+ const routeResult = voiceTelephonyOutcomeToRouteResult(decision, result);
8473
+ const shouldApply = typeof options.apply === "function" ? options.apply({
8474
+ applied: false,
8475
+ decision,
8476
+ event,
8477
+ routeResult,
8478
+ sessionId
8479
+ }) : options.apply === true;
8480
+ let applied = false;
8481
+ if (shouldApply && decision.action !== "ignore" && options.getSessionHandle) {
8482
+ const api = await options.getSessionHandle({
8483
+ context: options.context,
8484
+ decision,
8485
+ event,
8486
+ request: input.request,
8487
+ sessionId
8488
+ });
8489
+ if (api) {
8490
+ await applyVoiceTelephonyOutcome(api, decision, result);
8491
+ applied = true;
8492
+ }
8493
+ }
8494
+ const webhookDecision = {
8495
+ applied,
8496
+ decision,
8497
+ event,
8498
+ idempotencyKey,
8499
+ routeResult,
8500
+ sessionId
8501
+ };
8502
+ if (idempotencyKey && idempotencyStore) {
8503
+ const now = Date.now();
8504
+ await idempotencyStore.set(idempotencyKey, {
8505
+ ...webhookDecision,
8506
+ createdAt: now,
8507
+ updatedAt: now
8508
+ });
8509
+ }
8510
+ await options.onDecision?.({
8511
+ ...webhookDecision,
8512
+ context: options.context,
8513
+ request: input.request
8514
+ });
8515
+ return webhookDecision;
8516
+ };
8517
+ var createVoiceTelephonyWebhookRoutes = (options = {}) => {
8518
+ const path = options.path ?? "/api/voice/telephony/webhook";
8519
+ const handler = createVoiceTelephonyWebhookHandler(options);
8520
+ return new Elysia({
8521
+ name: options.name ?? "absolutejs-voice-telephony-webhooks"
8522
+ }).post(path, async ({ query, request }) => {
8523
+ try {
8524
+ return await handler({ query, request });
8525
+ } catch (error) {
8526
+ if (error instanceof VoiceTelephonyWebhookVerificationError) {
8527
+ return new Response(JSON.stringify({ verification: error.result }), {
8528
+ headers: {
8529
+ "content-type": "application/json"
8530
+ },
8531
+ status: 401
8532
+ });
8533
+ }
8534
+ throw error;
8535
+ }
8536
+ });
8537
+ };
8538
+
8539
+ // src/telephony/twilio.ts
6342
8540
  var TWILIO_MULAW_SAMPLE_RATE = 8000;
6343
8541
  var VOICE_PCM_SAMPLE_RATE = 16000;
6344
- var escapeXml = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
8542
+ var escapeXml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
8543
+ var resolveRequestOrigin = (request) => {
8544
+ const url = new URL(request.url);
8545
+ const forwardedHost = request.headers.get("x-forwarded-host");
8546
+ const forwardedProto = request.headers.get("x-forwarded-proto");
8547
+ const host = forwardedHost ?? request.headers.get("host") ?? url.host;
8548
+ const protocol = forwardedProto ?? url.protocol.replace(":", "");
8549
+ return `${protocol}://${host}`;
8550
+ };
8551
+ var resolveTwilioStreamUrl = async (options, input) => {
8552
+ if (typeof options.twiml?.streamUrl === "function") {
8553
+ return options.twiml.streamUrl(input);
8554
+ }
8555
+ if (typeof options.twiml?.streamUrl === "string") {
8556
+ return options.twiml.streamUrl;
8557
+ }
8558
+ const origin = resolveRequestOrigin(input.request);
8559
+ const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
8560
+ return `${wsOrigin}${input.streamPath}`;
8561
+ };
8562
+ var resolveTwilioStreamParameters = async (parameters, input) => {
8563
+ if (typeof parameters === "function") {
8564
+ return parameters(input);
8565
+ }
8566
+ return parameters;
8567
+ };
8568
+ var joinUrlPath = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
8569
+ var escapeHtml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&#39;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
8570
+ var getWebhookVerificationUrl = (webhook, input) => {
8571
+ if (!webhook?.verificationUrl) {
8572
+ return;
8573
+ }
8574
+ if (typeof webhook.verificationUrl === "function") {
8575
+ return webhook.verificationUrl(input);
8576
+ }
8577
+ return webhook.verificationUrl;
8578
+ };
8579
+ var buildTwilioVoiceSetupStatus = async (options, input) => {
8580
+ const origin = resolveRequestOrigin(input.request);
8581
+ const stream = await resolveTwilioStreamUrl(options, input);
8582
+ const twiml = joinUrlPath(origin, input.twimlPath);
8583
+ const webhook = joinUrlPath(origin, input.webhookPath);
8584
+ const verificationUrl = getWebhookVerificationUrl(options.webhook, input);
8585
+ const missing = Object.entries(options.setup?.requiredEnv ?? {}).filter((entry) => !entry[1]).map(([name]) => name);
8586
+ const signingConfigured = Boolean(options.webhook?.signingSecret || options.webhook?.verify);
8587
+ const warnings = [
8588
+ ...stream.startsWith("wss://") ? [] : ["Twilio media streams should use wss:// in production."],
8589
+ ...signingConfigured ? [] : ["Webhook signature verification is not configured."],
8590
+ ...verificationUrl || !signingConfigured ? [] : ["Webhook signing is configured without an explicit verification URL."]
8591
+ ];
8592
+ return {
8593
+ generatedAt: Date.now(),
8594
+ missing,
8595
+ provider: "twilio",
8596
+ ready: missing.length === 0 && signingConfigured && warnings.length === 0,
8597
+ signing: {
8598
+ configured: signingConfigured,
8599
+ mode: options.webhook?.verify ? "custom" : options.webhook?.signingSecret ? "twilio-signature" : "none",
8600
+ verificationUrl
8601
+ },
8602
+ urls: {
8603
+ stream,
8604
+ twiml,
8605
+ webhook
8606
+ },
8607
+ warnings
8608
+ };
8609
+ };
8610
+ var renderTwilioVoiceSetupHTML = (status, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
8611
+ <p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio setup</p>
8612
+ <h1>${escapeHtml2(title)}</h1>
8613
+ <p><strong>Status:</strong> ${status.ready ? "Ready" : "Needs attention"}</p>
8614
+ <section>
8615
+ <h2>URLs</h2>
8616
+ <ul>
8617
+ <li><strong>TwiML:</strong> <code>${escapeHtml2(status.urls.twiml)}</code></li>
8618
+ <li><strong>Media stream:</strong> <code>${escapeHtml2(status.urls.stream)}</code></li>
8619
+ <li><strong>Status webhook:</strong> <code>${escapeHtml2(status.urls.webhook)}</code></li>
8620
+ </ul>
8621
+ </section>
8622
+ <section>
8623
+ <h2>Signing</h2>
8624
+ <p>Mode: <code>${status.signing.mode}</code></p>
8625
+ ${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml2(status.signing.verificationUrl)}</code></p>` : ""}
8626
+ </section>
8627
+ ${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml2(name)}</code></li>`).join("")}</ul></section>` : ""}
8628
+ ${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml2(warning)}</li>`).join("")}</ul></section>` : ""}
8629
+ </main>`;
8630
+ var extractTwilioStreamUrl = (twiml) => twiml.match(/<Stream\b[^>]*\surl="([^"]+)"/i)?.[1]?.replaceAll("&amp;", "&");
8631
+ var createSmokeCheck = (name, status, message, details) => ({
8632
+ details,
8633
+ message,
8634
+ name,
8635
+ status
8636
+ });
8637
+ var renderTwilioVoiceSmokeHTML = (report, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
8638
+ <p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio smoke test</p>
8639
+ <h1>${escapeHtml2(title)}</h1>
8640
+ <p><strong>Status:</strong> ${report.pass ? "Pass" : "Fail"}</p>
8641
+ <section>
8642
+ <h2>Checks</h2>
8643
+ <ul>
8644
+ ${report.checks.map((check) => `<li><strong>${escapeHtml2(check.name)}</strong>: ${escapeHtml2(check.status)}${check.message ? ` - ${escapeHtml2(check.message)}` : ""}</li>`).join("")}
8645
+ </ul>
8646
+ </section>
8647
+ <section>
8648
+ <h2>Observed URLs</h2>
8649
+ <ul>
8650
+ <li><strong>TwiML:</strong> <code>${escapeHtml2(report.setup.urls.twiml)}</code></li>
8651
+ <li><strong>Stream:</strong> <code>${escapeHtml2(report.twiml?.streamUrl ?? report.setup.urls.stream)}</code></li>
8652
+ <li><strong>Webhook:</strong> <code>${escapeHtml2(report.setup.urls.webhook)}</code></li>
8653
+ </ul>
8654
+ </section>
8655
+ </main>`;
8656
+ var runTwilioVoiceSmokeTest = async (input) => {
8657
+ const setup = await buildTwilioVoiceSetupStatus(input.options, input);
8658
+ const checks = [];
8659
+ const twimlUrl = new URL(setup.urls.twiml);
8660
+ twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
8661
+ twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
8662
+ const twimlResponse = await input.app.handle(new Request(twimlUrl, {
8663
+ headers: input.request.headers
8664
+ }));
8665
+ const twiml = await twimlResponse.text();
8666
+ const streamUrl = extractTwilioStreamUrl(twiml);
8667
+ checks.push(createSmokeCheck("twiml", twimlResponse.ok && Boolean(streamUrl) ? "pass" : "fail", streamUrl ? "TwiML includes a media stream URL." : 'TwiML is missing <Stream url="...">.', {
8668
+ status: twimlResponse.status,
8669
+ streamUrl
8670
+ }));
8671
+ checks.push(createSmokeCheck("stream-url", streamUrl?.startsWith("wss://") ? "pass" : "fail", streamUrl?.startsWith("wss://") ? "Media stream URL uses wss://." : "Media stream URL should use wss:// for Twilio.", {
8672
+ streamUrl
8673
+ }));
8674
+ const webhookBody = {
8675
+ CallSid: input.options.smoke?.callSid ?? "CA_SMOKE_TEST",
8676
+ CallStatus: input.options.smoke?.status ?? "busy",
8677
+ SipResponseCode: String(input.options.smoke?.sipCode ?? 486)
8678
+ };
8679
+ const webhookHeaders = new Headers({
8680
+ "content-type": "application/x-www-form-urlencoded"
8681
+ });
8682
+ const verificationUrl = setup.signing.verificationUrl ?? setup.urls.webhook;
8683
+ if (input.options.webhook?.signingSecret) {
8684
+ webhookHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
8685
+ authToken: input.options.webhook.signingSecret,
8686
+ body: webhookBody,
8687
+ url: verificationUrl
8688
+ }));
8689
+ }
8690
+ const webhookResponse = await input.app.handle(new Request(setup.urls.webhook, {
8691
+ body: new URLSearchParams(webhookBody),
8692
+ headers: webhookHeaders,
8693
+ method: "POST"
8694
+ }));
8695
+ const webhookText = await webhookResponse.text();
8696
+ const webhookPayload = (() => {
8697
+ try {
8698
+ return JSON.parse(webhookText);
8699
+ } catch {
8700
+ return webhookText;
8701
+ }
8702
+ })();
8703
+ checks.push(createSmokeCheck("webhook", webhookResponse.ok ? "pass" : "fail", webhookResponse.ok ? "Synthetic Twilio status callback was accepted." : "Synthetic Twilio status callback failed.", {
8704
+ status: webhookResponse.status
8705
+ }));
8706
+ for (const warning of setup.warnings) {
8707
+ checks.push(createSmokeCheck("setup-warning", "warn", warning));
8708
+ }
8709
+ for (const name of setup.missing) {
8710
+ checks.push(createSmokeCheck("missing-env", "fail", `${name} is missing.`));
8711
+ }
8712
+ return {
8713
+ checks,
8714
+ generatedAt: Date.now(),
8715
+ pass: checks.every((check) => check.status !== "fail"),
8716
+ provider: "twilio",
8717
+ setup,
8718
+ twiml: {
8719
+ status: twimlResponse.status,
8720
+ streamUrl
8721
+ },
8722
+ webhook: {
8723
+ body: webhookPayload,
8724
+ status: webhookResponse.status
8725
+ }
8726
+ };
8727
+ };
6345
8728
  var normalizeOnTurn = (handler) => {
6346
8729
  if (handler.length > 1) {
6347
8730
  const directHandler = handler;
@@ -6443,7 +8826,7 @@ var bytesToInt16Array = (bytes) => {
6443
8826
  return output;
6444
8827
  };
6445
8828
  var decodeTwilioMulawBase64 = (payload) => {
6446
- const bytes = Uint8Array.from(Buffer2.from(payload, "base64"));
8829
+ const bytes = Uint8Array.from(Buffer3.from(payload, "base64"));
6447
8830
  const samples = new Int16Array(bytes.length);
6448
8831
  for (let index = 0;index < bytes.length; index += 1) {
6449
8832
  samples[index] = decodeMulawSample(bytes[index] ?? 0);
@@ -6455,7 +8838,7 @@ var encodeTwilioMulawBase64 = (samples) => {
6455
8838
  for (let index = 0;index < samples.length; index += 1) {
6456
8839
  bytes[index] = encodeMulawSample(samples[index] ?? 0);
6457
8840
  }
6458
- return Buffer2.from(bytes).toString("base64");
8841
+ return Buffer3.from(bytes).toString("base64");
6459
8842
  };
6460
8843
  var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
6461
8844
  const narrowband = decodeTwilioMulawBase64(payload);
@@ -6464,7 +8847,7 @@ var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
6464
8847
  };
6465
8848
  var transcodePCMToTwilioOutboundPayload = (chunk, format) => {
6466
8849
  if (format.container === "raw" && format.encoding === "mulaw" && format.channels === 1 && format.sampleRateHz === TWILIO_MULAW_SAMPLE_RATE) {
6467
- return Buffer2.from(chunk).toString("base64");
8850
+ return Buffer3.from(chunk).toString("base64");
6468
8851
  }
6469
8852
  if (format.encoding !== "pcm_s16le") {
6470
8853
  throw new Error(`Unsupported outbound telephony audio format: ${format.container}/${format.encoding}`);
@@ -6505,7 +8888,7 @@ var createTwilioSocketAdapter = (socket, getState) => ({
6505
8888
  return;
6506
8889
  }
6507
8890
  if (message.type === "audio") {
6508
- const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer2.from(message.chunkBase64, "base64")), message.format);
8891
+ const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer3.from(message.chunkBase64, "base64")), message.format);
6509
8892
  state.hasOutboundAudioSinceLastInbound = true;
6510
8893
  state.reviewRecorder?.recordTwilioOutbound({
6511
8894
  bytes: payload.length,
@@ -6537,8 +8920,8 @@ var createTwilioSocketAdapter = (socket, getState) => ({
6537
8920
  }
6538
8921
  });
6539
8922
  var createTwilioVoiceResponse = (options) => {
6540
- const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${escapeXml(name)}" value="${escapeXml(String(value))}" />`).join("");
6541
- return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${escapeXml(options.streamUrl)}"${options.track ? ` track="${escapeXml(options.track)}"` : ""}${options.streamName ? ` name="${escapeXml(options.streamName)}"` : ""}>${parameters}</Stream></Connect></Response>`;
8923
+ const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${escapeXml2(name)}" value="${escapeXml2(String(value))}" />`).join("");
8924
+ return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${escapeXml2(options.streamUrl)}"${options.track ? ` track="${escapeXml2(options.track)}"` : ""}${options.streamName ? ` name="${escapeXml2(options.streamName)}"` : ""}>${parameters}</Stream></Connect></Response>`;
6542
8925
  };
6543
8926
  var createTwilioMediaStreamBridge = (socket, options) => {
6544
8927
  const runtimePreset = resolveVoiceRuntimePreset(options.preset);
@@ -6718,6 +9101,148 @@ var createTwilioMediaStreamBridge = (socket, options) => {
6718
9101
  }
6719
9102
  };
6720
9103
  };
9104
+ var createTwilioVoiceRoutes = (options) => {
9105
+ const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
9106
+ const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
9107
+ const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
9108
+ const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
9109
+ const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
9110
+ const bridges = new WeakMap;
9111
+ const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
9112
+ const app = new Elysia2({
9113
+ name: options.name ?? "absolutejs-voice-twilio"
9114
+ }).get(twimlPath, async ({ query, request }) => {
9115
+ const streamUrl = await resolveTwilioStreamUrl(options, {
9116
+ query,
9117
+ request,
9118
+ streamPath
9119
+ });
9120
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
9121
+ query,
9122
+ request
9123
+ });
9124
+ return new Response(createTwilioVoiceResponse({
9125
+ parameters,
9126
+ streamName: options.twiml?.streamName,
9127
+ streamUrl,
9128
+ track: options.twiml?.track
9129
+ }), {
9130
+ headers: {
9131
+ "content-type": "text/xml; charset=utf-8"
9132
+ }
9133
+ });
9134
+ }).post(twimlPath, async ({ query, request }) => {
9135
+ const streamUrl = await resolveTwilioStreamUrl(options, {
9136
+ query,
9137
+ request,
9138
+ streamPath
9139
+ });
9140
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
9141
+ query,
9142
+ request
9143
+ });
9144
+ return new Response(createTwilioVoiceResponse({
9145
+ parameters,
9146
+ streamName: options.twiml?.streamName,
9147
+ streamUrl,
9148
+ track: options.twiml?.track
9149
+ }), {
9150
+ headers: {
9151
+ "content-type": "text/xml; charset=utf-8"
9152
+ }
9153
+ });
9154
+ }).ws(streamPath, {
9155
+ close: async (ws, _code, reason) => {
9156
+ const bridge = bridges.get(ws);
9157
+ bridges.delete(ws);
9158
+ await bridge?.close(reason);
9159
+ },
9160
+ message: async (ws, raw) => {
9161
+ let bridge = bridges.get(ws);
9162
+ if (!bridge) {
9163
+ bridge = createTwilioMediaStreamBridge({
9164
+ close: (code, reason) => {
9165
+ ws.close(code, reason);
9166
+ },
9167
+ send: (data) => {
9168
+ ws.send(data);
9169
+ }
9170
+ }, options);
9171
+ bridges.set(ws, bridge);
9172
+ }
9173
+ await bridge.handleMessage(raw);
9174
+ }
9175
+ }).use(createVoiceTelephonyWebhookRoutes({
9176
+ ...options.webhook ?? {},
9177
+ context: options.context,
9178
+ path: webhookPath,
9179
+ policy: webhookPolicy,
9180
+ provider: "twilio"
9181
+ }));
9182
+ if (!setupPath) {
9183
+ if (!smokePath) {
9184
+ return app;
9185
+ }
9186
+ return app.get(smokePath, async ({ query, request }) => {
9187
+ const report = await runTwilioVoiceSmokeTest({
9188
+ app,
9189
+ options,
9190
+ query,
9191
+ request,
9192
+ streamPath,
9193
+ twimlPath,
9194
+ webhookPath
9195
+ });
9196
+ if (query.format === "html") {
9197
+ return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
9198
+ headers: {
9199
+ "content-type": "text/html; charset=utf-8"
9200
+ }
9201
+ });
9202
+ }
9203
+ return report;
9204
+ });
9205
+ }
9206
+ const withSetup = app.get(setupPath, async ({ query, request }) => {
9207
+ const status = await buildTwilioVoiceSetupStatus(options, {
9208
+ query,
9209
+ request,
9210
+ streamPath,
9211
+ twimlPath,
9212
+ webhookPath
9213
+ });
9214
+ if (query.format === "html") {
9215
+ return new Response(renderTwilioVoiceSetupHTML(status, options.setup?.title ?? "AbsoluteJS Twilio Voice Setup"), {
9216
+ headers: {
9217
+ "content-type": "text/html; charset=utf-8"
9218
+ }
9219
+ });
9220
+ }
9221
+ return status;
9222
+ });
9223
+ if (!smokePath) {
9224
+ return withSetup;
9225
+ }
9226
+ return withSetup.get(smokePath, async ({ query, request }) => {
9227
+ const report = await runTwilioVoiceSmokeTest({
9228
+ app,
9229
+ options,
9230
+ query,
9231
+ request,
9232
+ streamPath,
9233
+ twimlPath,
9234
+ webhookPath
9235
+ });
9236
+ if (query.format === "html") {
9237
+ return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
9238
+ headers: {
9239
+ "content-type": "text/html; charset=utf-8"
9240
+ }
9241
+ });
9242
+ }
9243
+ return report;
9244
+ });
9245
+ };
6721
9246
 
6722
9247
  // src/testing/telephony.ts
6723
9248
  var DEFAULT_PCM16_FORMAT = {
@@ -7208,6 +9733,8 @@ export {
7208
9733
  getDefaultVoiceDuplexBenchmarkScenarios,
7209
9734
  getDefaultTTSBenchmarkFixtures,
7210
9735
  evaluateSTTBenchmarkAcceptance,
9736
+ createVoiceProviderFailureSimulator,
9737
+ createVoiceIOProviderFailureSimulator,
7211
9738
  createVoiceCallReviewRecorder,
7212
9739
  createVoiceCallReviewFromLiveTelephonyReport,
7213
9740
  createTelephonyVoiceTestFixtures,