@absolutejs/voice 0.0.22-beta.3 → 0.0.22-beta.300
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3232 -82
- package/dist/agent.d.ts +62 -0
- package/dist/agentSquadContract.d.ts +98 -0
- package/dist/angular/index.d.ts +17 -0
- package/dist/angular/index.js +3598 -1058
- package/dist/angular/voice-agent-squad-status.service.d.ts +12 -0
- package/dist/angular/voice-campaign-dialer-proof.service.d.ts +14 -0
- package/dist/angular/voice-controller.service.d.ts +1 -0
- package/dist/angular/voice-delivery-runtime.component.d.ts +17 -0
- package/dist/angular/voice-delivery-runtime.service.d.ts +16 -0
- package/dist/angular/voice-live-ops.service.d.ts +11 -0
- package/dist/angular/voice-ops-action-center.service.d.ts +13 -0
- package/dist/angular/voice-ops-status.component.d.ts +15 -0
- package/dist/angular/voice-ops-status.service.d.ts +12 -0
- package/dist/angular/voice-platform-coverage.service.d.ts +12 -0
- package/dist/angular/voice-proof-trends.service.d.ts +12 -0
- package/dist/angular/voice-provider-capabilities.service.d.ts +12 -0
- package/dist/angular/voice-provider-contracts.service.d.ts +12 -0
- package/dist/angular/voice-provider-status.service.d.ts +12 -0
- package/dist/angular/voice-readiness-failures.service.d.ts +13 -0
- package/dist/angular/voice-routing-status.service.d.ts +11 -0
- package/dist/angular/voice-stream.service.d.ts +3 -0
- package/dist/angular/voice-trace-timeline.service.d.ts +12 -0
- package/dist/angular/voice-turn-latency.service.d.ts +13 -0
- package/dist/angular/voice-turn-quality.service.d.ts +12 -0
- package/dist/angular/voice-workflow-status.service.d.ts +12 -0
- package/dist/assistant.d.ts +20 -0
- package/dist/assistantHealth.d.ts +81 -0
- package/dist/assistantMemory.d.ts +63 -0
- package/dist/audit.d.ts +128 -0
- package/dist/auditDeliveryRoutes.d.ts +85 -0
- package/dist/auditExport.d.ts +34 -0
- package/dist/auditRoutes.d.ts +66 -0
- package/dist/auditSinks.d.ts +151 -0
- package/dist/bargeInRoutes.d.ts +56 -0
- package/dist/campaign.d.ts +768 -0
- package/dist/campaignDialers.d.ts +111 -0
- package/dist/client/actions.d.ts +105 -0
- package/dist/client/agentSquadStatus.d.ts +37 -0
- package/dist/client/agentSquadStatusWidget.d.ts +24 -0
- package/dist/client/bargeInMonitor.d.ts +7 -0
- package/dist/client/campaignDialerProof.d.ts +23 -0
- package/dist/client/connection.d.ts +3 -0
- package/dist/client/deliveryRuntime.d.ts +34 -0
- package/dist/client/deliveryRuntimeWidget.d.ts +37 -0
- package/dist/client/duplex.d.ts +1 -1
- package/dist/client/htmxBootstrap.js +747 -15
- package/dist/client/index.d.ts +72 -0
- package/dist/client/index.js +5371 -10
- package/dist/client/liveOps.d.ts +22 -0
- package/dist/client/liveOpsWidget.d.ts +23 -0
- package/dist/client/liveTurnLatency.d.ts +41 -0
- package/dist/client/opsActionCenter.d.ts +54 -0
- package/dist/client/opsActionCenterWidget.d.ts +29 -0
- package/dist/client/opsActionHistory.d.ts +19 -0
- package/dist/client/opsActionHistoryWidget.d.ts +11 -0
- package/dist/client/opsStatus.d.ts +19 -0
- package/dist/client/opsStatusWidget.d.ts +40 -0
- package/dist/client/platformCoverage.d.ts +19 -0
- package/dist/client/platformCoverageWidget.d.ts +37 -0
- package/dist/client/proofTrends.d.ts +19 -0
- package/dist/client/proofTrendsWidget.d.ts +37 -0
- package/dist/client/providerCapabilities.d.ts +19 -0
- package/dist/client/providerCapabilitiesWidget.d.ts +32 -0
- package/dist/client/providerContracts.d.ts +19 -0
- package/dist/client/providerContractsWidget.d.ts +37 -0
- package/dist/client/providerSimulationControls.d.ts +33 -0
- package/dist/client/providerSimulationControlsWidget.d.ts +20 -0
- package/dist/client/providerStatus.d.ts +19 -0
- package/dist/client/providerStatusWidget.d.ts +32 -0
- package/dist/client/readinessFailures.d.ts +19 -0
- package/dist/client/readinessFailuresWidget.d.ts +42 -0
- package/dist/client/routingStatus.d.ts +19 -0
- package/dist/client/routingStatusWidget.d.ts +28 -0
- package/dist/client/traceTimeline.d.ts +19 -0
- package/dist/client/traceTimelineWidget.d.ts +36 -0
- package/dist/client/turnLatency.d.ts +22 -0
- package/dist/client/turnLatencyWidget.d.ts +33 -0
- package/dist/client/turnQuality.d.ts +19 -0
- package/dist/client/turnQualityWidget.d.ts +32 -0
- package/dist/client/workflowStatus.d.ts +19 -0
- package/dist/dataControl.d.ts +180 -0
- package/dist/deliveryRuntime.d.ts +158 -0
- package/dist/deliverySinkRoutes.d.ts +117 -0
- package/dist/demoReadyRoutes.d.ts +98 -0
- package/dist/diagnosticsRoutes.d.ts +44 -0
- package/dist/evalRoutes.d.ts +219 -0
- package/dist/fileStore.d.ts +17 -2
- package/dist/guardrails.d.ts +128 -0
- package/dist/handoff.d.ts +54 -0
- package/dist/handoffHealth.d.ts +94 -0
- package/dist/incidentBundle.d.ts +116 -0
- package/dist/index.d.ts +151 -10
- package/dist/index.js +28252 -3426
- package/dist/latencySlo.d.ts +56 -0
- package/dist/liveLatency.d.ts +78 -0
- package/dist/liveOps.d.ts +190 -0
- package/dist/modelAdapters.d.ts +151 -0
- package/dist/observabilityExport.d.ts +481 -0
- package/dist/openaiRealtime.d.ts +27 -0
- package/dist/openaiTTS.d.ts +18 -0
- package/dist/operationsRecord.d.ts +254 -0
- package/dist/opsActionAuditRoutes.d.ts +99 -0
- package/dist/opsConsoleRoutes.d.ts +80 -0
- package/dist/opsRecovery.d.ts +137 -0
- package/dist/opsStatus.d.ts +76 -0
- package/dist/opsStatusRoutes.d.ts +33 -0
- package/dist/opsWebhook.d.ts +126 -0
- package/dist/outcomeContract.d.ts +146 -0
- package/dist/phoneAgent.d.ts +139 -0
- package/dist/phoneAgentProductionSmoke.d.ts +115 -0
- package/dist/platformCoverage.d.ts +91 -0
- package/dist/postCallAnalysis.d.ts +98 -0
- package/dist/postgresStore.d.ts +13 -2
- package/dist/productionReadiness.d.ts +559 -0
- package/dist/proofTrends.d.ts +133 -0
- package/dist/providerAdapters.d.ts +48 -0
- package/dist/providerCapabilities.d.ts +92 -0
- package/dist/providerDecisionTraces.d.ts +130 -0
- package/dist/providerHealth.d.ts +79 -0
- package/dist/providerOrchestration.d.ts +109 -0
- package/dist/providerRoutingContract.d.ts +71 -0
- package/dist/providerSlo.d.ts +142 -0
- package/dist/providerStackRecommendations.d.ts +187 -0
- package/dist/qualityRoutes.d.ts +76 -0
- package/dist/queue.d.ts +61 -0
- package/dist/react/VoiceAgentSquadStatus.d.ts +5 -0
- package/dist/react/VoiceDeliveryRuntime.d.ts +7 -0
- package/dist/react/VoiceOpsActionCenter.d.ts +5 -0
- package/dist/react/VoiceOpsStatus.d.ts +6 -0
- package/dist/react/VoicePlatformCoverage.d.ts +6 -0
- package/dist/react/VoiceProofTrends.d.ts +6 -0
- package/dist/react/VoiceProviderCapabilities.d.ts +6 -0
- package/dist/react/VoiceProviderContracts.d.ts +6 -0
- package/dist/react/VoiceProviderSimulationControls.d.ts +5 -0
- package/dist/react/VoiceProviderStatus.d.ts +6 -0
- package/dist/react/VoiceReadinessFailures.d.ts +6 -0
- package/dist/react/VoiceRoutingStatus.d.ts +6 -0
- package/dist/react/VoiceTraceTimeline.d.ts +6 -0
- package/dist/react/VoiceTurnLatency.d.ts +6 -0
- package/dist/react/VoiceTurnQuality.d.ts +6 -0
- package/dist/react/index.d.ts +33 -0
- package/dist/react/index.js +5188 -14
- package/dist/react/useVoiceAgentSquadStatus.d.ts +8 -0
- package/dist/react/useVoiceCampaignDialerProof.d.ts +10 -0
- package/dist/react/useVoiceController.d.ts +3 -0
- package/dist/react/useVoiceDeliveryRuntime.d.ts +13 -0
- package/dist/react/useVoiceLiveOps.d.ts +9 -0
- package/dist/react/useVoiceOpsActionCenter.d.ts +11 -0
- package/dist/react/useVoiceOpsStatus.d.ts +8 -0
- package/dist/react/useVoicePlatformCoverage.d.ts +8 -0
- package/dist/react/useVoiceProofTrends.d.ts +8 -0
- package/dist/react/useVoiceProviderCapabilities.d.ts +8 -0
- package/dist/react/useVoiceProviderContracts.d.ts +8 -0
- package/dist/react/useVoiceProviderSimulationControls.d.ts +10 -0
- package/dist/react/useVoiceProviderStatus.d.ts +8 -0
- package/dist/react/useVoiceReadinessFailures.d.ts +8 -0
- package/dist/react/useVoiceRoutingStatus.d.ts +8 -0
- package/dist/react/useVoiceStream.d.ts +3 -0
- package/dist/react/useVoiceTraceTimeline.d.ts +8 -0
- package/dist/react/useVoiceTurnLatency.d.ts +9 -0
- package/dist/react/useVoiceTurnQuality.d.ts +8 -0
- package/dist/react/useVoiceWorkflowStatus.d.ts +8 -0
- package/dist/readinessProfiles.d.ts +38 -0
- package/dist/reconnectContract.d.ts +88 -0
- package/dist/resilienceRoutes.d.ts +143 -0
- package/dist/sessionReplay.d.ts +187 -0
- package/dist/simulationSuite.d.ts +143 -0
- package/dist/sloCalibration.d.ts +185 -0
- package/dist/sqliteStore.d.ts +13 -2
- package/dist/svelte/createVoiceAgentSquadStatus.d.ts +9 -0
- package/dist/svelte/createVoiceCampaignDialerProof.d.ts +9 -0
- package/dist/svelte/createVoiceDeliveryRuntime.d.ts +11 -0
- package/dist/svelte/createVoiceLiveOps.d.ts +13 -0
- package/dist/svelte/createVoiceOpsActionCenter.d.ts +10 -0
- package/dist/svelte/createVoiceOpsStatus.d.ts +9 -0
- package/dist/svelte/createVoicePlatformCoverage.d.ts +7 -0
- package/dist/svelte/createVoiceProofTrends.d.ts +7 -0
- package/dist/svelte/createVoiceProviderCapabilities.d.ts +10 -0
- package/dist/svelte/createVoiceProviderContracts.d.ts +10 -0
- package/dist/svelte/createVoiceProviderSimulationControls.d.ts +11 -0
- package/dist/svelte/createVoiceProviderStatus.d.ts +10 -0
- package/dist/svelte/createVoiceReadinessFailures.d.ts +7 -0
- package/dist/svelte/createVoiceRoutingStatus.d.ts +10 -0
- package/dist/svelte/createVoiceTraceTimeline.d.ts +10 -0
- package/dist/svelte/createVoiceTurnLatency.d.ts +11 -0
- package/dist/svelte/createVoiceTurnQuality.d.ts +10 -0
- package/dist/svelte/createVoiceWorkflowStatus.d.ts +8 -0
- package/dist/svelte/index.d.ts +18 -0
- package/dist/svelte/index.js +5036 -407
- package/dist/telephony/contract.d.ts +61 -0
- package/dist/telephony/matrix.d.ts +97 -0
- package/dist/telephony/plivo.d.ts +303 -0
- package/dist/telephony/security.d.ts +182 -0
- package/dist/telephony/telnyx.d.ts +291 -0
- package/dist/telephony/twilio.d.ts +135 -2
- package/dist/telephonyOutcome.d.ts +273 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +3206 -180
- package/dist/testing/ioProviderSimulator.d.ts +41 -0
- package/dist/testing/providerSimulator.d.ts +44 -0
- package/dist/toolContract.d.ts +161 -0
- package/dist/toolRuntime.d.ts +50 -0
- package/dist/trace.d.ts +19 -1
- package/dist/traceDeliveryRoutes.d.ts +86 -0
- package/dist/traceTimeline.d.ts +97 -0
- package/dist/turnLatency.d.ts +95 -0
- package/dist/turnQuality.d.ts +94 -0
- package/dist/types.d.ts +180 -4
- package/dist/voiceMonitoring.d.ts +444 -0
- package/dist/vue/VoiceDeliveryRuntime.d.ts +30 -0
- package/dist/vue/VoiceOpsActionCenter.d.ts +13 -0
- package/dist/vue/VoiceOpsStatus.d.ts +30 -0
- package/dist/vue/VoicePlatformCoverage.d.ts +23 -0
- package/dist/vue/VoiceProofTrends.d.ts +21 -0
- package/dist/vue/VoiceProviderCapabilities.d.ts +51 -0
- package/dist/vue/VoiceProviderContracts.d.ts +21 -0
- package/dist/vue/VoiceProviderSimulationControls.d.ts +88 -0
- package/dist/vue/VoiceProviderStatus.d.ts +51 -0
- package/dist/vue/VoiceReadinessFailures.d.ts +21 -0
- package/dist/vue/VoiceRoutingStatus.d.ts +51 -0
- package/dist/vue/VoiceTurnLatency.d.ts +69 -0
- package/dist/vue/VoiceTurnQuality.d.ts +51 -0
- package/dist/vue/index.d.ts +31 -0
- package/dist/vue/index.js +4962 -31
- package/dist/vue/useVoiceAgentSquadStatus.d.ts +9 -0
- package/dist/vue/useVoiceCampaignDialerProof.d.ts +11 -0
- package/dist/vue/useVoiceController.d.ts +2 -1
- package/dist/vue/useVoiceDeliveryRuntime.d.ts +13 -0
- package/dist/vue/useVoiceLiveOps.d.ts +9 -0
- package/dist/vue/useVoiceOpsActionCenter.d.ts +11 -0
- package/dist/vue/useVoiceOpsStatus.d.ts +9 -0
- package/dist/vue/useVoicePlatformCoverage.d.ts +9 -0
- package/dist/vue/useVoiceProofTrends.d.ts +9 -0
- package/dist/vue/useVoiceProviderCapabilities.d.ts +9 -0
- package/dist/vue/useVoiceProviderContracts.d.ts +9 -0
- package/dist/vue/useVoiceProviderSimulationControls.d.ts +24 -0
- package/dist/vue/useVoiceProviderStatus.d.ts +9 -0
- package/dist/vue/useVoiceReadinessFailures.d.ts +775 -0
- package/dist/vue/useVoiceRoutingStatus.d.ts +8 -0
- package/dist/vue/useVoiceStream.d.ts +4 -1
- package/dist/vue/useVoiceTraceTimeline.d.ts +9 -0
- package/dist/vue/useVoiceTurnLatency.d.ts +10 -0
- package/dist/vue/useVoiceTurnQuality.d.ts +9 -0
- package/dist/vue/useVoiceWorkflowStatus.d.ts +9 -0
- package/dist/workflowContract.d.ts +91 -0
- package/package.json +1 -1
package/dist/testing/index.js
CHANGED
|
@@ -2105,6 +2105,17 @@ var serverMessageToAction = (message) => {
|
|
|
2105
2105
|
sessionId: message.sessionId,
|
|
2106
2106
|
type: "complete"
|
|
2107
2107
|
};
|
|
2108
|
+
case "connection":
|
|
2109
|
+
return {
|
|
2110
|
+
reconnect: message.reconnect,
|
|
2111
|
+
type: "connection"
|
|
2112
|
+
};
|
|
2113
|
+
case "call_lifecycle":
|
|
2114
|
+
return {
|
|
2115
|
+
event: message.event,
|
|
2116
|
+
sessionId: message.sessionId,
|
|
2117
|
+
type: "call_lifecycle"
|
|
2118
|
+
};
|
|
2108
2119
|
case "error":
|
|
2109
2120
|
return {
|
|
2110
2121
|
message: normalizeErrorMessage(message.message),
|
|
@@ -2120,6 +2131,17 @@ var serverMessageToAction = (message) => {
|
|
|
2120
2131
|
transcript: message.transcript,
|
|
2121
2132
|
type: "partial"
|
|
2122
2133
|
};
|
|
2134
|
+
case "replay":
|
|
2135
|
+
return {
|
|
2136
|
+
assistantTexts: message.assistantTexts,
|
|
2137
|
+
call: message.call,
|
|
2138
|
+
partial: message.partial,
|
|
2139
|
+
scenarioId: message.scenarioId,
|
|
2140
|
+
sessionId: message.sessionId,
|
|
2141
|
+
status: message.status,
|
|
2142
|
+
turns: message.turns,
|
|
2143
|
+
type: "replay"
|
|
2144
|
+
};
|
|
2123
2145
|
case "session":
|
|
2124
2146
|
return {
|
|
2125
2147
|
sessionId: message.sessionId,
|
|
@@ -2148,7 +2170,7 @@ var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
|
|
|
2148
2170
|
var noop = () => {};
|
|
2149
2171
|
var noopUnsubscribe = () => noop;
|
|
2150
2172
|
var NOOP_CONNECTION = {
|
|
2151
|
-
|
|
2173
|
+
callControl: noop,
|
|
2152
2174
|
close: noop,
|
|
2153
2175
|
endTurn: noop,
|
|
2154
2176
|
getReadyState: () => WS_CLOSED,
|
|
@@ -2156,6 +2178,7 @@ var NOOP_CONNECTION = {
|
|
|
2156
2178
|
getSessionId: () => "",
|
|
2157
2179
|
send: noop,
|
|
2158
2180
|
sendAudio: noop,
|
|
2181
|
+
start: () => {},
|
|
2159
2182
|
subscribe: noopUnsubscribe
|
|
2160
2183
|
};
|
|
2161
2184
|
var createSessionId = () => crypto.randomUUID();
|
|
@@ -2177,11 +2200,14 @@ var isVoiceServerMessage = (value) => {
|
|
|
2177
2200
|
switch (value.type) {
|
|
2178
2201
|
case "audio":
|
|
2179
2202
|
case "assistant":
|
|
2203
|
+
case "call_lifecycle":
|
|
2180
2204
|
case "complete":
|
|
2205
|
+
case "connection":
|
|
2181
2206
|
case "error":
|
|
2182
2207
|
case "final":
|
|
2183
2208
|
case "partial":
|
|
2184
2209
|
case "pong":
|
|
2210
|
+
case "replay":
|
|
2185
2211
|
case "session":
|
|
2186
2212
|
case "turn":
|
|
2187
2213
|
return true;
|
|
@@ -2218,6 +2244,9 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2218
2244
|
sessionId: options.sessionId ?? createSessionId(),
|
|
2219
2245
|
ws: null
|
|
2220
2246
|
};
|
|
2247
|
+
const emitConnection = (reconnect) => {
|
|
2248
|
+
listeners.forEach((listener) => listener(reconnect));
|
|
2249
|
+
};
|
|
2221
2250
|
const clearTimers = () => {
|
|
2222
2251
|
if (state.pingInterval) {
|
|
2223
2252
|
clearInterval(state.pingInterval);
|
|
@@ -2240,9 +2269,28 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2240
2269
|
}
|
|
2241
2270
|
};
|
|
2242
2271
|
const scheduleReconnect = () => {
|
|
2272
|
+
const nextAttemptAt = Date.now() + RECONNECT_DELAY_MS;
|
|
2243
2273
|
state.reconnectAttempts += 1;
|
|
2274
|
+
emitConnection({
|
|
2275
|
+
reconnect: {
|
|
2276
|
+
attempts: state.reconnectAttempts,
|
|
2277
|
+
lastDisconnectAt: Date.now(),
|
|
2278
|
+
maxAttempts: maxReconnectAttempts,
|
|
2279
|
+
nextAttemptAt,
|
|
2280
|
+
status: "reconnecting"
|
|
2281
|
+
},
|
|
2282
|
+
type: "connection"
|
|
2283
|
+
});
|
|
2244
2284
|
state.reconnectTimeout = setTimeout(() => {
|
|
2245
2285
|
if (state.reconnectAttempts > maxReconnectAttempts) {
|
|
2286
|
+
emitConnection({
|
|
2287
|
+
reconnect: {
|
|
2288
|
+
attempts: state.reconnectAttempts,
|
|
2289
|
+
maxAttempts: maxReconnectAttempts,
|
|
2290
|
+
status: "exhausted"
|
|
2291
|
+
},
|
|
2292
|
+
type: "connection"
|
|
2293
|
+
});
|
|
2246
2294
|
return;
|
|
2247
2295
|
}
|
|
2248
2296
|
connect();
|
|
@@ -2252,9 +2300,21 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2252
2300
|
const ws = new WebSocket(buildWsUrl(path, state.sessionId, state.scenarioId));
|
|
2253
2301
|
ws.binaryType = "arraybuffer";
|
|
2254
2302
|
ws.onopen = () => {
|
|
2303
|
+
const wasReconnecting = state.reconnectAttempts > 0;
|
|
2255
2304
|
state.isConnected = true;
|
|
2256
|
-
state.reconnectAttempts = 0;
|
|
2257
2305
|
flushPendingMessages();
|
|
2306
|
+
if (wasReconnecting) {
|
|
2307
|
+
emitConnection({
|
|
2308
|
+
reconnect: {
|
|
2309
|
+
attempts: state.reconnectAttempts,
|
|
2310
|
+
lastResumedAt: Date.now(),
|
|
2311
|
+
maxAttempts: maxReconnectAttempts,
|
|
2312
|
+
status: "resumed"
|
|
2313
|
+
},
|
|
2314
|
+
type: "connection"
|
|
2315
|
+
});
|
|
2316
|
+
state.reconnectAttempts = 0;
|
|
2317
|
+
}
|
|
2258
2318
|
listeners.forEach((listener) => listener({
|
|
2259
2319
|
scenarioId: state.scenarioId ?? undefined,
|
|
2260
2320
|
sessionId: state.sessionId,
|
|
@@ -2284,6 +2344,16 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2284
2344
|
const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && state.reconnectAttempts < maxReconnectAttempts;
|
|
2285
2345
|
if (reconnectable) {
|
|
2286
2346
|
scheduleReconnect();
|
|
2347
|
+
} else if (shouldReconnect && event.code !== WS_NORMAL_CLOSURE) {
|
|
2348
|
+
emitConnection({
|
|
2349
|
+
reconnect: {
|
|
2350
|
+
attempts: state.reconnectAttempts,
|
|
2351
|
+
lastDisconnectAt: Date.now(),
|
|
2352
|
+
maxAttempts: maxReconnectAttempts,
|
|
2353
|
+
status: "exhausted"
|
|
2354
|
+
},
|
|
2355
|
+
type: "connection"
|
|
2356
|
+
});
|
|
2287
2357
|
}
|
|
2288
2358
|
};
|
|
2289
2359
|
state.ws = ws;
|
|
@@ -2317,6 +2387,12 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2317
2387
|
const endTurn = () => {
|
|
2318
2388
|
send({ type: "end_turn" });
|
|
2319
2389
|
};
|
|
2390
|
+
const callControl = (message) => {
|
|
2391
|
+
send({
|
|
2392
|
+
...message,
|
|
2393
|
+
type: "call_control"
|
|
2394
|
+
});
|
|
2395
|
+
};
|
|
2320
2396
|
const close = () => {
|
|
2321
2397
|
clearTimers();
|
|
2322
2398
|
if (state.ws) {
|
|
@@ -2334,7 +2410,7 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2334
2410
|
};
|
|
2335
2411
|
connect();
|
|
2336
2412
|
return {
|
|
2337
|
-
|
|
2413
|
+
callControl,
|
|
2338
2414
|
close,
|
|
2339
2415
|
endTurn,
|
|
2340
2416
|
getReadyState: () => state.ws?.readyState ?? WS_CLOSED,
|
|
@@ -2342,18 +2418,26 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2342
2418
|
getSessionId: () => state.sessionId,
|
|
2343
2419
|
send,
|
|
2344
2420
|
sendAudio,
|
|
2421
|
+
start,
|
|
2345
2422
|
subscribe
|
|
2346
2423
|
};
|
|
2347
2424
|
};
|
|
2348
2425
|
|
|
2349
2426
|
// src/client/store.ts
|
|
2427
|
+
var createInitialReconnectState = () => ({
|
|
2428
|
+
attempts: 0,
|
|
2429
|
+
maxAttempts: 0,
|
|
2430
|
+
status: "idle"
|
|
2431
|
+
});
|
|
2350
2432
|
var createInitialState2 = () => ({
|
|
2351
2433
|
assistantAudio: [],
|
|
2352
2434
|
assistantTexts: [],
|
|
2435
|
+
call: null,
|
|
2353
2436
|
error: null,
|
|
2354
2437
|
isConnected: false,
|
|
2355
2438
|
scenarioId: null,
|
|
2356
2439
|
partial: "",
|
|
2440
|
+
reconnect: createInitialReconnectState(),
|
|
2357
2441
|
sessionId: null,
|
|
2358
2442
|
status: "idle",
|
|
2359
2443
|
turns: []
|
|
@@ -2393,10 +2477,36 @@ var createVoiceStreamStore = () => {
|
|
|
2393
2477
|
status: "completed"
|
|
2394
2478
|
};
|
|
2395
2479
|
break;
|
|
2480
|
+
case "call_lifecycle":
|
|
2481
|
+
state = {
|
|
2482
|
+
...state,
|
|
2483
|
+
call: {
|
|
2484
|
+
...state.call,
|
|
2485
|
+
disposition: action.event.type === "end" ? action.event.disposition : state.call?.disposition,
|
|
2486
|
+
endedAt: action.event.type === "end" ? action.event.at : state.call?.endedAt,
|
|
2487
|
+
events: [...state.call?.events ?? [], action.event],
|
|
2488
|
+
lastEventAt: action.event.at,
|
|
2489
|
+
startedAt: state.call?.startedAt ?? action.event.at
|
|
2490
|
+
},
|
|
2491
|
+
sessionId: action.sessionId
|
|
2492
|
+
};
|
|
2493
|
+
break;
|
|
2396
2494
|
case "connected":
|
|
2397
2495
|
state = {
|
|
2398
2496
|
...state,
|
|
2399
|
-
isConnected: true
|
|
2497
|
+
isConnected: true,
|
|
2498
|
+
reconnect: state.reconnect.status === "reconnecting" ? {
|
|
2499
|
+
...state.reconnect,
|
|
2500
|
+
lastResumedAt: Date.now(),
|
|
2501
|
+
nextAttemptAt: undefined,
|
|
2502
|
+
status: "resumed"
|
|
2503
|
+
} : state.reconnect
|
|
2504
|
+
};
|
|
2505
|
+
break;
|
|
2506
|
+
case "connection":
|
|
2507
|
+
state = {
|
|
2508
|
+
...state,
|
|
2509
|
+
reconnect: action.reconnect
|
|
2400
2510
|
};
|
|
2401
2511
|
break;
|
|
2402
2512
|
case "disconnected":
|
|
@@ -2424,6 +2534,26 @@ var createVoiceStreamStore = () => {
|
|
|
2424
2534
|
partial: action.transcript.text
|
|
2425
2535
|
};
|
|
2426
2536
|
break;
|
|
2537
|
+
case "replay":
|
|
2538
|
+
state = {
|
|
2539
|
+
...state,
|
|
2540
|
+
assistantTexts: [...action.assistantTexts],
|
|
2541
|
+
call: action.call ?? null,
|
|
2542
|
+
error: null,
|
|
2543
|
+
isConnected: action.status === "active",
|
|
2544
|
+
partial: action.partial,
|
|
2545
|
+
reconnect: state.reconnect.status === "reconnecting" ? {
|
|
2546
|
+
...state.reconnect,
|
|
2547
|
+
lastResumedAt: Date.now(),
|
|
2548
|
+
nextAttemptAt: undefined,
|
|
2549
|
+
status: "resumed"
|
|
2550
|
+
} : state.reconnect,
|
|
2551
|
+
scenarioId: action.scenarioId ?? state.scenarioId,
|
|
2552
|
+
sessionId: action.sessionId,
|
|
2553
|
+
status: action.status,
|
|
2554
|
+
turns: [...action.turns]
|
|
2555
|
+
};
|
|
2556
|
+
break;
|
|
2427
2557
|
case "session":
|
|
2428
2558
|
state = {
|
|
2429
2559
|
...state,
|
|
@@ -2471,14 +2601,41 @@ var createVoiceStream = (path, options = {}) => {
|
|
|
2471
2601
|
const notify = () => {
|
|
2472
2602
|
subscribers.forEach((subscriber) => subscriber());
|
|
2473
2603
|
};
|
|
2604
|
+
const reportReconnect = () => {
|
|
2605
|
+
if (!options.reconnectReportPath || typeof fetch === "undefined") {
|
|
2606
|
+
return;
|
|
2607
|
+
}
|
|
2608
|
+
const snapshot = store.getSnapshot();
|
|
2609
|
+
const body = JSON.stringify({
|
|
2610
|
+
at: Date.now(),
|
|
2611
|
+
reconnect: snapshot.reconnect,
|
|
2612
|
+
scenarioId: snapshot.scenarioId,
|
|
2613
|
+
sessionId: connection.getSessionId(),
|
|
2614
|
+
turnIds: snapshot.turns.map((turn) => turn.id)
|
|
2615
|
+
});
|
|
2616
|
+
fetch(options.reconnectReportPath, {
|
|
2617
|
+
body,
|
|
2618
|
+
headers: {
|
|
2619
|
+
"Content-Type": "application/json"
|
|
2620
|
+
},
|
|
2621
|
+
keepalive: true,
|
|
2622
|
+
method: "POST"
|
|
2623
|
+
}).catch(() => {});
|
|
2624
|
+
};
|
|
2474
2625
|
const unsubscribeConnection = connection.subscribe((message) => {
|
|
2475
2626
|
const action = serverMessageToAction(message);
|
|
2476
2627
|
if (action) {
|
|
2477
2628
|
store.dispatch(action);
|
|
2629
|
+
if (message.type === "connection") {
|
|
2630
|
+
reportReconnect();
|
|
2631
|
+
}
|
|
2478
2632
|
notify();
|
|
2479
2633
|
}
|
|
2480
2634
|
});
|
|
2481
2635
|
return {
|
|
2636
|
+
callControl(message) {
|
|
2637
|
+
connection.callControl(message);
|
|
2638
|
+
},
|
|
2482
2639
|
close() {
|
|
2483
2640
|
unsubscribeConnection();
|
|
2484
2641
|
connection.close();
|
|
@@ -2507,6 +2664,9 @@ var createVoiceStream = (path, options = {}) => {
|
|
|
2507
2664
|
get partial() {
|
|
2508
2665
|
return store.getSnapshot().partial;
|
|
2509
2666
|
},
|
|
2667
|
+
get reconnect() {
|
|
2668
|
+
return store.getSnapshot().reconnect;
|
|
2669
|
+
},
|
|
2510
2670
|
get sessionId() {
|
|
2511
2671
|
return connection.getSessionId();
|
|
2512
2672
|
},
|
|
@@ -2522,6 +2682,9 @@ var createVoiceStream = (path, options = {}) => {
|
|
|
2522
2682
|
get assistantAudio() {
|
|
2523
2683
|
return store.getSnapshot().assistantAudio;
|
|
2524
2684
|
},
|
|
2685
|
+
get call() {
|
|
2686
|
+
return store.getSnapshot().call;
|
|
2687
|
+
},
|
|
2525
2688
|
sendAudio(audio) {
|
|
2526
2689
|
connection.sendAudio(audio);
|
|
2527
2690
|
},
|
|
@@ -2854,10 +3017,12 @@ var resolveVoiceRuntimePreset = (name = "default") => {
|
|
|
2854
3017
|
var createInitialState3 = (stream) => ({
|
|
2855
3018
|
assistantAudio: [...stream.assistantAudio],
|
|
2856
3019
|
assistantTexts: [...stream.assistantTexts],
|
|
3020
|
+
call: stream.call,
|
|
2857
3021
|
error: stream.error,
|
|
2858
3022
|
isConnected: stream.isConnected,
|
|
2859
3023
|
isRecording: false,
|
|
2860
3024
|
partial: stream.partial,
|
|
3025
|
+
reconnect: stream.reconnect,
|
|
2861
3026
|
recordingError: null,
|
|
2862
3027
|
sessionId: stream.sessionId,
|
|
2863
3028
|
scenarioId: stream.scenarioId,
|
|
@@ -2883,9 +3048,11 @@ var createVoiceController = (path, options = {}) => {
|
|
|
2883
3048
|
...state,
|
|
2884
3049
|
assistantAudio: [...stream.assistantAudio],
|
|
2885
3050
|
assistantTexts: [...stream.assistantTexts],
|
|
3051
|
+
call: stream.call,
|
|
2886
3052
|
error: stream.error,
|
|
2887
3053
|
isConnected: stream.isConnected,
|
|
2888
3054
|
partial: stream.partial,
|
|
3055
|
+
reconnect: stream.reconnect,
|
|
2889
3056
|
sessionId: stream.sessionId,
|
|
2890
3057
|
scenarioId: stream.scenarioId,
|
|
2891
3058
|
status: stream.status,
|
|
@@ -2910,7 +3077,13 @@ var createVoiceController = (path, options = {}) => {
|
|
|
2910
3077
|
capture = createMicrophoneCapture({
|
|
2911
3078
|
channelCount: options.capture?.channelCount ?? preset.capture.channelCount,
|
|
2912
3079
|
onLevel: options.capture?.onLevel,
|
|
2913
|
-
onAudio: (audio) =>
|
|
3080
|
+
onAudio: (audio) => {
|
|
3081
|
+
if (options.capture?.onAudio) {
|
|
3082
|
+
options.capture.onAudio(audio, stream.sendAudio);
|
|
3083
|
+
return;
|
|
3084
|
+
}
|
|
3085
|
+
stream.sendAudio(audio);
|
|
3086
|
+
},
|
|
2914
3087
|
sampleRateHz: options.capture?.sampleRateHz ?? preset.capture.sampleRateHz
|
|
2915
3088
|
});
|
|
2916
3089
|
return capture;
|
|
@@ -2960,6 +3133,7 @@ var createVoiceController = (path, options = {}) => {
|
|
|
2960
3133
|
bindHTMX(bindingOptions) {
|
|
2961
3134
|
return bindVoiceHTMX(stream, bindingOptions);
|
|
2962
3135
|
},
|
|
3136
|
+
callControl: (message) => stream.callControl(message),
|
|
2963
3137
|
close,
|
|
2964
3138
|
endTurn: () => stream.endTurn(),
|
|
2965
3139
|
get error() {
|
|
@@ -2979,6 +3153,9 @@ var createVoiceController = (path, options = {}) => {
|
|
|
2979
3153
|
get recordingError() {
|
|
2980
3154
|
return state.recordingError;
|
|
2981
3155
|
},
|
|
3156
|
+
get reconnect() {
|
|
3157
|
+
return state.reconnect;
|
|
3158
|
+
},
|
|
2982
3159
|
sendAudio: (audio) => stream.sendAudio(audio),
|
|
2983
3160
|
get sessionId() {
|
|
2984
3161
|
return state.sessionId;
|
|
@@ -3012,6 +3189,9 @@ var createVoiceController = (path, options = {}) => {
|
|
|
3012
3189
|
},
|
|
3013
3190
|
get assistantAudio() {
|
|
3014
3191
|
return state.assistantAudio;
|
|
3192
|
+
},
|
|
3193
|
+
get call() {
|
|
3194
|
+
return state.call;
|
|
3015
3195
|
}
|
|
3016
3196
|
};
|
|
3017
3197
|
};
|
|
@@ -3021,11 +3201,26 @@ var DEFAULT_INTERRUPT_THRESHOLD = 0.08;
|
|
|
3021
3201
|
var shouldInterruptForLevel = (level, options = {}) => (options.enabled ?? true) && level >= (options.interruptThreshold ?? DEFAULT_INTERRUPT_THRESHOLD);
|
|
3022
3202
|
var bindVoiceBargeIn = (controller, player, options = {}) => {
|
|
3023
3203
|
let lastPartial = controller.partial;
|
|
3024
|
-
const interruptIfPlaying = () => {
|
|
3204
|
+
const interruptIfPlaying = (reason) => {
|
|
3025
3205
|
if (!player.isPlaying || options.enabled === false) {
|
|
3206
|
+
options.monitor?.recordSkipped({
|
|
3207
|
+
reason,
|
|
3208
|
+
sessionId: controller.sessionId
|
|
3209
|
+
});
|
|
3026
3210
|
return;
|
|
3027
3211
|
}
|
|
3028
|
-
|
|
3212
|
+
options.monitor?.recordRequested({
|
|
3213
|
+
reason,
|
|
3214
|
+
sessionId: controller.sessionId
|
|
3215
|
+
});
|
|
3216
|
+
player.interrupt().then(() => {
|
|
3217
|
+
options.monitor?.recordStopped({
|
|
3218
|
+
latencyMs: player.lastInterruptLatencyMs,
|
|
3219
|
+
playbackStopLatencyMs: player.lastPlaybackStopLatencyMs,
|
|
3220
|
+
reason,
|
|
3221
|
+
sessionId: controller.sessionId
|
|
3222
|
+
});
|
|
3223
|
+
});
|
|
3029
3224
|
};
|
|
3030
3225
|
const unsubscribe = controller.subscribe(() => {
|
|
3031
3226
|
if (options.interruptOnPartial === false) {
|
|
@@ -3033,7 +3228,7 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
|
|
|
3033
3228
|
return;
|
|
3034
3229
|
}
|
|
3035
3230
|
if (!lastPartial && controller.partial) {
|
|
3036
|
-
interruptIfPlaying();
|
|
3231
|
+
interruptIfPlaying("partial-transcript");
|
|
3037
3232
|
}
|
|
3038
3233
|
lastPartial = controller.partial;
|
|
3039
3234
|
});
|
|
@@ -3043,11 +3238,11 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
|
|
|
3043
3238
|
},
|
|
3044
3239
|
handleLevel: (level) => {
|
|
3045
3240
|
if (shouldInterruptForLevel(level, options)) {
|
|
3046
|
-
interruptIfPlaying();
|
|
3241
|
+
interruptIfPlaying("input-level");
|
|
3047
3242
|
}
|
|
3048
3243
|
},
|
|
3049
3244
|
sendAudio: (audio) => {
|
|
3050
|
-
interruptIfPlaying();
|
|
3245
|
+
interruptIfPlaying("manual-audio");
|
|
3051
3246
|
controller.sendAudio(audio);
|
|
3052
3247
|
}
|
|
3053
3248
|
};
|
|
@@ -3077,7 +3272,17 @@ var createVoiceDuplexController = (path, options = {}) => {
|
|
|
3077
3272
|
audioPlayer,
|
|
3078
3273
|
close,
|
|
3079
3274
|
interruptAssistant: async () => {
|
|
3275
|
+
options.bargeIn?.monitor?.recordRequested({
|
|
3276
|
+
reason: "manual-interrupt",
|
|
3277
|
+
sessionId: controller.sessionId
|
|
3278
|
+
});
|
|
3080
3279
|
await audioPlayer.interrupt();
|
|
3280
|
+
options.bargeIn?.monitor?.recordStopped({
|
|
3281
|
+
latencyMs: audioPlayer.lastInterruptLatencyMs,
|
|
3282
|
+
playbackStopLatencyMs: audioPlayer.lastPlaybackStopLatencyMs,
|
|
3283
|
+
reason: "manual-interrupt",
|
|
3284
|
+
sessionId: controller.sessionId
|
|
3285
|
+
});
|
|
3081
3286
|
},
|
|
3082
3287
|
sendAudio: (audio) => {
|
|
3083
3288
|
bargeInBinding?.sendAudio(audio);
|
|
@@ -3468,166 +3673,1640 @@ var loadVoiceTestFixtures = async (fixtureDirectory) => {
|
|
|
3468
3673
|
}
|
|
3469
3674
|
return fixtures;
|
|
3470
3675
|
};
|
|
3471
|
-
// src/
|
|
3472
|
-
var
|
|
3473
|
-
var
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
currentTurn: {
|
|
3477
|
-
finalText: "",
|
|
3478
|
-
lastSpeechAt: undefined,
|
|
3479
|
-
lastTranscriptAt: undefined,
|
|
3480
|
-
partialEndedAt: undefined,
|
|
3481
|
-
partialStartedAt: undefined,
|
|
3482
|
-
partialText: "",
|
|
3483
|
-
silenceStartedAt: undefined,
|
|
3484
|
-
transcripts: []
|
|
3485
|
-
},
|
|
3486
|
-
id,
|
|
3487
|
-
scenarioId,
|
|
3488
|
-
reconnect: { attempts: 0 },
|
|
3489
|
-
status: "active",
|
|
3490
|
-
transcripts: [],
|
|
3491
|
-
turns: [],
|
|
3492
|
-
lastCommittedTurn: {
|
|
3493
|
-
committedAt: 0,
|
|
3494
|
-
signature: "",
|
|
3495
|
-
text: "",
|
|
3496
|
-
transcriptIds: []
|
|
3676
|
+
// src/testing/ioProviderSimulator.ts
|
|
3677
|
+
var defaultFailureMessage = (input) => `Simulated ${input.provider} ${input.kind.toUpperCase()} ${input.operation} failure.`;
|
|
3678
|
+
var resolveRecoveryElapsedMs = (value, provider) => {
|
|
3679
|
+
if (typeof value === "number") {
|
|
3680
|
+
return value;
|
|
3497
3681
|
}
|
|
3682
|
+
return value?.[provider] ?? 25;
|
|
3683
|
+
};
|
|
3684
|
+
var createHealth = (input) => ({
|
|
3685
|
+
consecutiveFailures: input.status === "healthy" ? 0 : 1,
|
|
3686
|
+
lastFailureAt: input.status === "healthy" ? undefined : input.now,
|
|
3687
|
+
provider: input.provider,
|
|
3688
|
+
status: input.status,
|
|
3689
|
+
suppressedUntil: input.suppressedUntil
|
|
3498
3690
|
});
|
|
3499
|
-
var
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
}
|
|
3503
|
-
var
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
const
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3691
|
+
var resolveFallback = async (options, provider) => {
|
|
3692
|
+
const configured = typeof options.fallback === "function" ? await options.fallback(provider) : options.fallback;
|
|
3693
|
+
return (configured ?? options.providers).find((candidate) => candidate !== provider);
|
|
3694
|
+
};
|
|
3695
|
+
var createVoiceIOProviderFailureSimulator = (options) => {
|
|
3696
|
+
if (options.providers.length === 0) {
|
|
3697
|
+
throw new Error("At least one provider is required.");
|
|
3698
|
+
}
|
|
3699
|
+
const now = options.now ?? Date.now;
|
|
3700
|
+
const operation = options.operation ?? "open";
|
|
3701
|
+
const cooldownMs = Math.max(0, options.cooldownMs ?? 30000);
|
|
3702
|
+
const emit = async (event, input) => {
|
|
3703
|
+
await options.onProviderEvent?.(event, input);
|
|
3704
|
+
};
|
|
3705
|
+
const run = async (provider, mode) => {
|
|
3706
|
+
if (!options.providers.includes(provider)) {
|
|
3707
|
+
throw new Error(`${provider} is not configured for simulation.`);
|
|
3708
|
+
}
|
|
3709
|
+
const startedAt = now();
|
|
3710
|
+
const sessionId = options.sessionId?.({ mode, now: startedAt, provider }) ?? `${options.kind}-provider-sim-${startedAt}`;
|
|
3711
|
+
if (mode === "recovery") {
|
|
3712
|
+
await emit({
|
|
3713
|
+
at: startedAt,
|
|
3714
|
+
attempt: 0,
|
|
3715
|
+
elapsedMs: resolveRecoveryElapsedMs(options.recoveryElapsedMs, provider),
|
|
3716
|
+
kind: options.kind,
|
|
3717
|
+
latencyBudgetMs: options.latencyBudgets?.[provider],
|
|
3718
|
+
operation,
|
|
3719
|
+
provider,
|
|
3720
|
+
providerHealth: createHealth({
|
|
3721
|
+
now: startedAt,
|
|
3722
|
+
provider,
|
|
3723
|
+
status: "healthy"
|
|
3724
|
+
}),
|
|
3725
|
+
selectedProvider: provider,
|
|
3726
|
+
status: "success"
|
|
3727
|
+
}, { mode, provider, sessionId });
|
|
3728
|
+
return {
|
|
3729
|
+
mode,
|
|
3730
|
+
provider,
|
|
3731
|
+
sessionId,
|
|
3732
|
+
status: "simulated"
|
|
3733
|
+
};
|
|
3520
3734
|
}
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3735
|
+
const fallbackProvider = await resolveFallback(options, provider);
|
|
3736
|
+
const suppressedUntil = startedAt + cooldownMs;
|
|
3737
|
+
await emit({
|
|
3738
|
+
at: startedAt,
|
|
3739
|
+
attempt: 0,
|
|
3740
|
+
elapsedMs: options.failureElapsedMs ?? 10,
|
|
3741
|
+
error: (options.failureMessage ?? defaultFailureMessage)({
|
|
3742
|
+
kind: options.kind,
|
|
3743
|
+
operation,
|
|
3744
|
+
provider
|
|
3745
|
+
}),
|
|
3746
|
+
fallbackProvider,
|
|
3747
|
+
kind: options.kind,
|
|
3748
|
+
latencyBudgetMs: options.latencyBudgets?.[provider],
|
|
3749
|
+
operation,
|
|
3750
|
+
provider,
|
|
3751
|
+
providerHealth: createHealth({
|
|
3752
|
+
now: startedAt,
|
|
3753
|
+
provider,
|
|
3754
|
+
status: "suppressed",
|
|
3755
|
+
suppressedUntil
|
|
3756
|
+
}),
|
|
3757
|
+
selectedProvider: provider,
|
|
3758
|
+
status: "error",
|
|
3759
|
+
suppressedUntil
|
|
3760
|
+
}, { mode, provider, sessionId });
|
|
3761
|
+
if (fallbackProvider) {
|
|
3762
|
+
await emit({
|
|
3763
|
+
at: startedAt + 1,
|
|
3764
|
+
attempt: 1,
|
|
3765
|
+
elapsedMs: resolveRecoveryElapsedMs(options.recoveryElapsedMs, fallbackProvider),
|
|
3766
|
+
fallbackProvider,
|
|
3767
|
+
kind: options.kind,
|
|
3768
|
+
latencyBudgetMs: options.latencyBudgets?.[fallbackProvider],
|
|
3769
|
+
operation,
|
|
3770
|
+
provider: fallbackProvider,
|
|
3771
|
+
providerHealth: createHealth({
|
|
3772
|
+
now: startedAt + 1,
|
|
3773
|
+
provider: fallbackProvider,
|
|
3774
|
+
status: "healthy"
|
|
3775
|
+
}),
|
|
3776
|
+
selectedProvider: provider,
|
|
3777
|
+
status: "fallback"
|
|
3778
|
+
}, { mode, provider, sessionId });
|
|
3779
|
+
}
|
|
3780
|
+
return {
|
|
3781
|
+
fallbackProvider,
|
|
3782
|
+
mode,
|
|
3783
|
+
provider,
|
|
3784
|
+
sessionId,
|
|
3785
|
+
status: "simulated",
|
|
3786
|
+
suppressedUntil
|
|
3787
|
+
};
|
|
3525
3788
|
};
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
sessions.delete(id);
|
|
3789
|
+
return {
|
|
3790
|
+
run
|
|
3529
3791
|
};
|
|
3530
|
-
return { get, getOrCreate, list, remove, set };
|
|
3531
3792
|
};
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3793
|
+
// src/modelAdapters.ts
|
|
3794
|
+
var isVoiceProviderRoutingPolicyPreset = (value) => value === "balanced" || value === "cost-cap" || value === "cost-first" || value === "latency-first" || value === "quality-first";
|
|
3795
|
+
var resolveVoiceProviderRoutingPolicyPreset = (preset, options = {}) => {
|
|
3796
|
+
switch (preset) {
|
|
3797
|
+
case "balanced":
|
|
3798
|
+
return {
|
|
3799
|
+
fallbackMode: "provider-error",
|
|
3800
|
+
strategy: "balanced",
|
|
3801
|
+
weights: {
|
|
3802
|
+
cost: 1,
|
|
3803
|
+
latencyMs: 0.005,
|
|
3804
|
+
priority: 1,
|
|
3805
|
+
quality: 10,
|
|
3806
|
+
...options.weights
|
|
3807
|
+
},
|
|
3808
|
+
...options
|
|
3809
|
+
};
|
|
3810
|
+
case "cost-cap":
|
|
3811
|
+
return {
|
|
3812
|
+
fallbackMode: "provider-error",
|
|
3813
|
+
strategy: "prefer-cheapest",
|
|
3814
|
+
...options
|
|
3815
|
+
};
|
|
3816
|
+
case "cost-first":
|
|
3817
|
+
return {
|
|
3818
|
+
fallbackMode: "provider-error",
|
|
3819
|
+
strategy: "prefer-cheapest",
|
|
3820
|
+
...options
|
|
3821
|
+
};
|
|
3822
|
+
case "latency-first":
|
|
3823
|
+
return {
|
|
3824
|
+
fallbackMode: "provider-error",
|
|
3825
|
+
strategy: "prefer-fastest",
|
|
3826
|
+
...options
|
|
3827
|
+
};
|
|
3828
|
+
case "quality-first":
|
|
3829
|
+
return {
|
|
3830
|
+
fallbackMode: "provider-error",
|
|
3831
|
+
strategy: "quality-first",
|
|
3832
|
+
...options
|
|
3833
|
+
};
|
|
3834
|
+
}
|
|
3569
3835
|
};
|
|
3570
|
-
var
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
lastSpeechAt: undefined,
|
|
3574
|
-
lastTranscriptAt: undefined,
|
|
3575
|
-
partialEndedAt: undefined,
|
|
3576
|
-
partialStartedAt: undefined,
|
|
3577
|
-
partialText: "",
|
|
3578
|
-
silenceStartedAt: undefined,
|
|
3579
|
-
transcripts: []
|
|
3580
|
-
});
|
|
3581
|
-
var cloneTranscript = (transcript) => ({ ...transcript });
|
|
3582
|
-
var encodeBase64 = (chunk) => Buffer.from(chunk).toString("base64");
|
|
3583
|
-
var countWords2 = (text) => text.trim().split(/\s+/).filter(Boolean).length;
|
|
3584
|
-
var normalizeText2 = (text) => text.trim().replace(/\s+/g, " ");
|
|
3585
|
-
var getAudioChunkDurationMs = (chunk) => chunk.byteLength / (DEFAULT_FORMAT.sampleRateHz * DEFAULT_FORMAT.channels * 2) * 1000;
|
|
3586
|
-
var getBufferedAudioDurationMs = (chunks) => chunks.reduce((total, chunk) => total + getAudioChunkDurationMs(chunk), 0);
|
|
3587
|
-
var calculateMeanConfidence = (transcripts) => {
|
|
3588
|
-
let sum = 0;
|
|
3589
|
-
let total = 0;
|
|
3590
|
-
for (const transcript of transcripts) {
|
|
3591
|
-
if (typeof transcript.confidence === "number") {
|
|
3592
|
-
sum += transcript.confidence;
|
|
3593
|
-
total += 1;
|
|
3594
|
-
}
|
|
3836
|
+
var resolveVoiceProviderRoutingPolicy = (policy) => {
|
|
3837
|
+
if (!policy) {
|
|
3838
|
+
return;
|
|
3595
3839
|
}
|
|
3596
|
-
if (
|
|
3597
|
-
return
|
|
3840
|
+
if (typeof policy === "string") {
|
|
3841
|
+
return isVoiceProviderRoutingPolicyPreset(policy) ? resolveVoiceProviderRoutingPolicyPreset(policy) : {
|
|
3842
|
+
strategy: policy
|
|
3843
|
+
};
|
|
3598
3844
|
}
|
|
3599
|
-
return
|
|
3845
|
+
return policy;
|
|
3600
3846
|
};
|
|
3601
|
-
var
|
|
3602
|
-
const
|
|
3603
|
-
|
|
3604
|
-
return {
|
|
3605
|
-
averageConfidence: confidenceSampleCount > 0 ? sampledTranscripts.reduce((sum, transcript) => sum + transcript.confidence, 0) / confidenceSampleCount : undefined,
|
|
3606
|
-
confidenceSampleCount,
|
|
3607
|
-
correction: correctionDiagnostics,
|
|
3608
|
-
cost: costEstimate,
|
|
3609
|
-
fallback: fallbackDiagnostics,
|
|
3610
|
-
fallbackUsed,
|
|
3611
|
-
finalTranscriptCount: transcripts.filter((transcript) => transcript.isFinal).length,
|
|
3612
|
-
partialTranscriptCount: transcripts.filter((transcript) => !transcript.isFinal).length,
|
|
3613
|
-
selectedTranscriptCount: transcripts.length,
|
|
3614
|
-
source
|
|
3847
|
+
var mergeDefinedProviderPolicyFields = (base, surface) => {
|
|
3848
|
+
const next = {
|
|
3849
|
+
...base ?? {}
|
|
3615
3850
|
};
|
|
3851
|
+
if (surface.allowProviders !== undefined) {
|
|
3852
|
+
next.allowProviders = surface.allowProviders;
|
|
3853
|
+
}
|
|
3854
|
+
if (surface.fallbackMode !== undefined) {
|
|
3855
|
+
next.fallbackMode = surface.fallbackMode;
|
|
3856
|
+
}
|
|
3857
|
+
if (surface.maxCost !== undefined) {
|
|
3858
|
+
next.maxCost = surface.maxCost;
|
|
3859
|
+
}
|
|
3860
|
+
if (surface.maxLatencyMs !== undefined) {
|
|
3861
|
+
next.maxLatencyMs = surface.maxLatencyMs;
|
|
3862
|
+
}
|
|
3863
|
+
if (surface.minQuality !== undefined) {
|
|
3864
|
+
next.minQuality = surface.minQuality;
|
|
3865
|
+
}
|
|
3866
|
+
if (surface.strategy !== undefined) {
|
|
3867
|
+
next.strategy = surface.strategy;
|
|
3868
|
+
}
|
|
3869
|
+
if (surface.weights !== undefined) {
|
|
3870
|
+
next.weights = {
|
|
3871
|
+
...base?.weights ?? {},
|
|
3872
|
+
...surface.weights
|
|
3873
|
+
};
|
|
3874
|
+
}
|
|
3875
|
+
return next;
|
|
3616
3876
|
};
|
|
3617
|
-
var
|
|
3618
|
-
const
|
|
3619
|
-
const
|
|
3620
|
-
|
|
3621
|
-
|
|
3877
|
+
var createVoiceProviderOrchestrationProfile = (options) => {
|
|
3878
|
+
const surfaceNames = Object.keys(options.surfaces);
|
|
3879
|
+
const defaultSurface = options.defaultSurface ?? surfaceNames[0];
|
|
3880
|
+
if (!defaultSurface || !options.surfaces[defaultSurface]) {
|
|
3881
|
+
throw new Error("Voice provider orchestration profile has no surfaces.");
|
|
3882
|
+
}
|
|
3622
3883
|
return {
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3884
|
+
defaultSurface,
|
|
3885
|
+
id: options.id,
|
|
3886
|
+
resolve: (surface = defaultSurface) => {
|
|
3887
|
+
const config = options.surfaces[surface];
|
|
3888
|
+
if (!config) {
|
|
3889
|
+
throw new Error(`Voice provider orchestration profile ${options.id} has no surface "${surface}".`);
|
|
3890
|
+
}
|
|
3891
|
+
const policy = mergeDefinedProviderPolicyFields(resolveVoiceProviderRoutingPolicy(config.policy), config);
|
|
3892
|
+
return {
|
|
3893
|
+
allowProviders: config.allowProviders,
|
|
3894
|
+
fallback: config.fallback,
|
|
3895
|
+
fallbackMode: config.fallbackMode,
|
|
3896
|
+
policy,
|
|
3897
|
+
providerHealth: config.providerHealth,
|
|
3898
|
+
providerProfiles: config.providerProfiles,
|
|
3899
|
+
timeoutMs: config.timeoutMs
|
|
3900
|
+
};
|
|
3901
|
+
},
|
|
3902
|
+
surfaces: options.surfaces
|
|
3628
3903
|
};
|
|
3629
3904
|
};
|
|
3630
|
-
var
|
|
3905
|
+
var OUTPUT_SCHEMA = {
|
|
3906
|
+
additionalProperties: false,
|
|
3907
|
+
properties: {
|
|
3908
|
+
assistantText: {
|
|
3909
|
+
type: "string"
|
|
3910
|
+
},
|
|
3911
|
+
complete: {
|
|
3912
|
+
type: "boolean"
|
|
3913
|
+
},
|
|
3914
|
+
escalate: {
|
|
3915
|
+
additionalProperties: false,
|
|
3916
|
+
properties: {
|
|
3917
|
+
metadata: {
|
|
3918
|
+
additionalProperties: true,
|
|
3919
|
+
type: "object"
|
|
3920
|
+
},
|
|
3921
|
+
reason: {
|
|
3922
|
+
type: "string"
|
|
3923
|
+
}
|
|
3924
|
+
},
|
|
3925
|
+
required: ["reason"],
|
|
3926
|
+
type: "object"
|
|
3927
|
+
},
|
|
3928
|
+
noAnswer: {
|
|
3929
|
+
additionalProperties: false,
|
|
3930
|
+
properties: {
|
|
3931
|
+
metadata: {
|
|
3932
|
+
additionalProperties: true,
|
|
3933
|
+
type: "object"
|
|
3934
|
+
}
|
|
3935
|
+
},
|
|
3936
|
+
type: "object"
|
|
3937
|
+
},
|
|
3938
|
+
result: {
|
|
3939
|
+
additionalProperties: true,
|
|
3940
|
+
type: "object"
|
|
3941
|
+
},
|
|
3942
|
+
transfer: {
|
|
3943
|
+
additionalProperties: false,
|
|
3944
|
+
properties: {
|
|
3945
|
+
metadata: {
|
|
3946
|
+
additionalProperties: true,
|
|
3947
|
+
type: "object"
|
|
3948
|
+
},
|
|
3949
|
+
reason: {
|
|
3950
|
+
type: "string"
|
|
3951
|
+
},
|
|
3952
|
+
target: {
|
|
3953
|
+
type: "string"
|
|
3954
|
+
}
|
|
3955
|
+
},
|
|
3956
|
+
required: ["target"],
|
|
3957
|
+
type: "object"
|
|
3958
|
+
},
|
|
3959
|
+
voicemail: {
|
|
3960
|
+
additionalProperties: false,
|
|
3961
|
+
properties: {
|
|
3962
|
+
metadata: {
|
|
3963
|
+
additionalProperties: true,
|
|
3964
|
+
type: "object"
|
|
3965
|
+
}
|
|
3966
|
+
},
|
|
3967
|
+
type: "object"
|
|
3968
|
+
}
|
|
3969
|
+
},
|
|
3970
|
+
type: "object"
|
|
3971
|
+
};
|
|
3972
|
+
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.";
|
|
3973
|
+
var stripJSONCodeFence = (value) => {
|
|
3974
|
+
const trimmed = value.trim();
|
|
3975
|
+
const match = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
3976
|
+
return match?.[1]?.trim() ?? value;
|
|
3977
|
+
};
|
|
3978
|
+
var parseJSON = (value) => {
|
|
3979
|
+
try {
|
|
3980
|
+
const parsed = JSON.parse(stripJSONCodeFence(value));
|
|
3981
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
3982
|
+
} catch {
|
|
3983
|
+
return {
|
|
3984
|
+
assistantText: value
|
|
3985
|
+
};
|
|
3986
|
+
}
|
|
3987
|
+
};
|
|
3988
|
+
var parseJSONValue = (value) => {
|
|
3989
|
+
try {
|
|
3990
|
+
return JSON.parse(value);
|
|
3991
|
+
} catch {
|
|
3992
|
+
return value;
|
|
3993
|
+
}
|
|
3994
|
+
};
|
|
3995
|
+
|
|
3996
|
+
class VoiceProviderTimeoutError extends Error {
|
|
3997
|
+
provider;
|
|
3998
|
+
timeoutMs;
|
|
3999
|
+
constructor(provider, timeoutMs) {
|
|
4000
|
+
super(`Voice provider ${provider} exceeded ${timeoutMs}ms latency budget.`);
|
|
4001
|
+
this.name = "VoiceProviderTimeoutError";
|
|
4002
|
+
this.provider = provider;
|
|
4003
|
+
this.timeoutMs = timeoutMs;
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
var getMessageToolCalls = (message) => {
|
|
4007
|
+
const toolCalls = message.metadata?.toolCalls;
|
|
4008
|
+
return Array.isArray(toolCalls) ? toolCalls.filter((toolCall) => toolCall && typeof toolCall === "object" && typeof toolCall.name === "string") : [];
|
|
4009
|
+
};
|
|
4010
|
+
var createHTTPError = (provider, response) => new Error(`${provider} voice assistant model failed: HTTP ${response.status}`);
|
|
4011
|
+
var sleep = (ms) => new Promise((resolve2) => {
|
|
4012
|
+
setTimeout(resolve2, ms);
|
|
4013
|
+
});
|
|
4014
|
+
var errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
4015
|
+
var defaultIsRateLimitError = (error) => /(\b429\b|rate limit|quota|too many requests)/i.test(errorMessage(error));
|
|
4016
|
+
var normalizeRouteOutput = (output) => {
|
|
4017
|
+
const result = {};
|
|
4018
|
+
if (typeof output.assistantText === "string") {
|
|
4019
|
+
result.assistantText = output.assistantText;
|
|
4020
|
+
}
|
|
4021
|
+
if (typeof output.complete === "boolean") {
|
|
4022
|
+
result.complete = output.complete;
|
|
4023
|
+
}
|
|
4024
|
+
if (output.result !== undefined) {
|
|
4025
|
+
result.result = output.result;
|
|
4026
|
+
}
|
|
4027
|
+
if (output.transfer && typeof output.transfer === "object") {
|
|
4028
|
+
const transfer = output.transfer;
|
|
4029
|
+
if (typeof transfer.target === "string") {
|
|
4030
|
+
result.transfer = {
|
|
4031
|
+
metadata: transfer.metadata && typeof transfer.metadata === "object" ? transfer.metadata : undefined,
|
|
4032
|
+
reason: typeof transfer.reason === "string" ? transfer.reason : undefined,
|
|
4033
|
+
target: transfer.target
|
|
4034
|
+
};
|
|
4035
|
+
}
|
|
4036
|
+
}
|
|
4037
|
+
if (output.escalate && typeof output.escalate === "object") {
|
|
4038
|
+
const escalate = output.escalate;
|
|
4039
|
+
if (typeof escalate.reason === "string") {
|
|
4040
|
+
result.escalate = {
|
|
4041
|
+
metadata: escalate.metadata && typeof escalate.metadata === "object" ? escalate.metadata : undefined,
|
|
4042
|
+
reason: escalate.reason
|
|
4043
|
+
};
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
4046
|
+
if (output.voicemail && typeof output.voicemail === "object") {
|
|
4047
|
+
const voicemail = output.voicemail;
|
|
4048
|
+
result.voicemail = {
|
|
4049
|
+
metadata: voicemail.metadata && typeof voicemail.metadata === "object" ? voicemail.metadata : undefined
|
|
4050
|
+
};
|
|
4051
|
+
}
|
|
4052
|
+
if (output.noAnswer && typeof output.noAnswer === "object") {
|
|
4053
|
+
const noAnswer = output.noAnswer;
|
|
4054
|
+
result.noAnswer = {
|
|
4055
|
+
metadata: noAnswer.metadata && typeof noAnswer.metadata === "object" ? noAnswer.metadata : undefined
|
|
4056
|
+
};
|
|
4057
|
+
}
|
|
4058
|
+
return result;
|
|
4059
|
+
};
|
|
4060
|
+
var createJSONVoiceAssistantModel = (options) => ({
|
|
4061
|
+
generate: async (input) => {
|
|
4062
|
+
const output = await options.generate(input);
|
|
4063
|
+
if ("assistantText" in output || "toolCalls" in output || "complete" in output || "transfer" in output || "escalate" in output) {
|
|
4064
|
+
return output;
|
|
4065
|
+
}
|
|
4066
|
+
return options.mapOutput?.(output) ?? normalizeRouteOutput(output);
|
|
4067
|
+
}
|
|
4068
|
+
});
|
|
4069
|
+
var createVoiceProviderRouter = (options) => {
|
|
4070
|
+
const providerIds = Object.keys(options.providers);
|
|
4071
|
+
const firstProvider = providerIds[0];
|
|
4072
|
+
const orchestrationSurface = options.orchestrationProfile?.resolve(options.orchestrationSurface);
|
|
4073
|
+
const policy = resolveVoiceProviderRoutingPolicy(options.policy) ?? resolveVoiceProviderRoutingPolicy(orchestrationSurface?.policy);
|
|
4074
|
+
const strategy = policy?.strategy ?? "prefer-selected";
|
|
4075
|
+
const fallbackMode = policy?.fallbackMode ?? options.fallbackMode ?? orchestrationSurface?.fallbackMode ?? "provider-error";
|
|
4076
|
+
const providerProfiles = {
|
|
4077
|
+
...orchestrationSurface?.providerProfiles ?? {},
|
|
4078
|
+
...options.providerProfiles ?? {}
|
|
4079
|
+
};
|
|
4080
|
+
const providerHealthOption = options.providerHealth ?? orchestrationSurface?.providerHealth;
|
|
4081
|
+
const healthOptions = typeof providerHealthOption === "object" ? providerHealthOption : providerHealthOption ? {} : undefined;
|
|
4082
|
+
const healthState = new Map;
|
|
4083
|
+
const now = () => healthOptions?.now?.() ?? Date.now();
|
|
4084
|
+
const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
|
|
4085
|
+
const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
|
|
4086
|
+
const rateLimitCooldownMs = Math.max(0, healthOptions?.rateLimitCooldownMs ?? 60000);
|
|
4087
|
+
const getProviderTimeoutMs = (provider) => {
|
|
4088
|
+
const timeoutMs = providerProfiles[provider]?.timeoutMs ?? options.timeoutMs ?? orchestrationSurface?.timeoutMs;
|
|
4089
|
+
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
|
|
4090
|
+
};
|
|
4091
|
+
const getHealth = (provider) => {
|
|
4092
|
+
const existing = healthState.get(provider);
|
|
4093
|
+
if (existing) {
|
|
4094
|
+
return existing;
|
|
4095
|
+
}
|
|
4096
|
+
const next = {
|
|
4097
|
+
consecutiveFailures: 0,
|
|
4098
|
+
provider,
|
|
4099
|
+
status: "healthy"
|
|
4100
|
+
};
|
|
4101
|
+
healthState.set(provider, next);
|
|
4102
|
+
return next;
|
|
4103
|
+
};
|
|
4104
|
+
const cloneHealth = (provider) => {
|
|
4105
|
+
if (!healthOptions) {
|
|
4106
|
+
return;
|
|
4107
|
+
}
|
|
4108
|
+
return {
|
|
4109
|
+
...getHealth(provider)
|
|
4110
|
+
};
|
|
4111
|
+
};
|
|
4112
|
+
const getSuppressionRemainingMs = (provider) => {
|
|
4113
|
+
if (!healthOptions) {
|
|
4114
|
+
return;
|
|
4115
|
+
}
|
|
4116
|
+
const suppressedUntil = getHealth(provider).suppressedUntil;
|
|
4117
|
+
return typeof suppressedUntil === "number" ? Math.max(0, suppressedUntil - now()) : undefined;
|
|
4118
|
+
};
|
|
4119
|
+
const isSuppressed = (provider) => {
|
|
4120
|
+
if (!healthOptions) {
|
|
4121
|
+
return false;
|
|
4122
|
+
}
|
|
4123
|
+
const health = getHealth(provider);
|
|
4124
|
+
return typeof health.suppressedUntil === "number" && health.suppressedUntil > now();
|
|
4125
|
+
};
|
|
4126
|
+
const recordProviderSuccess = (provider) => {
|
|
4127
|
+
if (!healthOptions) {
|
|
4128
|
+
return;
|
|
4129
|
+
}
|
|
4130
|
+
const health = getHealth(provider);
|
|
4131
|
+
health.consecutiveFailures = 0;
|
|
4132
|
+
health.status = "healthy";
|
|
4133
|
+
health.suppressedUntil = undefined;
|
|
4134
|
+
return cloneHealth(provider);
|
|
4135
|
+
};
|
|
4136
|
+
const recordProviderError = (provider, isProviderError, rateLimited) => {
|
|
4137
|
+
if (!healthOptions || !isProviderError) {
|
|
4138
|
+
return cloneHealth(provider);
|
|
4139
|
+
}
|
|
4140
|
+
const currentTime = now();
|
|
4141
|
+
const health = getHealth(provider);
|
|
4142
|
+
health.consecutiveFailures += 1;
|
|
4143
|
+
health.lastFailureAt = currentTime;
|
|
4144
|
+
if (rateLimited) {
|
|
4145
|
+
health.lastRateLimitedAt = currentTime;
|
|
4146
|
+
}
|
|
4147
|
+
if (rateLimited || health.consecutiveFailures >= failureThreshold) {
|
|
4148
|
+
health.status = "suppressed";
|
|
4149
|
+
health.suppressedUntil = currentTime + (rateLimited ? rateLimitCooldownMs : cooldownMs);
|
|
4150
|
+
}
|
|
4151
|
+
return cloneHealth(provider);
|
|
4152
|
+
};
|
|
4153
|
+
const resolveAllowedProviders = async (input) => {
|
|
4154
|
+
const allowProviders = policy?.allowProviders ?? options.allowProviders ?? orchestrationSurface?.allowProviders;
|
|
4155
|
+
const allowed = typeof allowProviders === "function" ? await allowProviders(input) : allowProviders;
|
|
4156
|
+
return new Set(allowed ?? providerIds);
|
|
4157
|
+
};
|
|
4158
|
+
const passesBudgetFilters = (provider) => {
|
|
4159
|
+
const profile = providerProfiles[provider];
|
|
4160
|
+
if (typeof policy?.maxCost === "number" && typeof profile?.cost === "number" && profile.cost > policy.maxCost) {
|
|
4161
|
+
return false;
|
|
4162
|
+
}
|
|
4163
|
+
if (typeof policy?.maxLatencyMs === "number" && typeof profile?.latencyMs === "number" && profile.latencyMs > policy.maxLatencyMs) {
|
|
4164
|
+
return false;
|
|
4165
|
+
}
|
|
4166
|
+
if (typeof policy?.minQuality === "number" && typeof profile?.quality === "number" && profile.quality < policy.minQuality) {
|
|
4167
|
+
return false;
|
|
4168
|
+
}
|
|
4169
|
+
return true;
|
|
4170
|
+
};
|
|
4171
|
+
const getBalancedScore = (provider) => {
|
|
4172
|
+
const profile = providerProfiles[provider];
|
|
4173
|
+
if (policy?.scoreProvider) {
|
|
4174
|
+
return policy.scoreProvider(provider, profile);
|
|
4175
|
+
}
|
|
4176
|
+
const weights = policy?.weights ?? {};
|
|
4177
|
+
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);
|
|
4178
|
+
};
|
|
4179
|
+
const sortProviders = (providers) => {
|
|
4180
|
+
if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest" && strategy !== "quality-first" && strategy !== "balanced") {
|
|
4181
|
+
return providers;
|
|
4182
|
+
}
|
|
4183
|
+
return [...providers].sort((left, right) => {
|
|
4184
|
+
const leftProfile = providerProfiles[left];
|
|
4185
|
+
const rightProfile = providerProfiles[right];
|
|
4186
|
+
if (strategy === "quality-first") {
|
|
4187
|
+
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);
|
|
4188
|
+
}
|
|
4189
|
+
if (strategy === "balanced") {
|
|
4190
|
+
return getBalancedScore(left) - getBalancedScore(right);
|
|
4191
|
+
}
|
|
4192
|
+
const leftValue = strategy === "prefer-cheapest" ? leftProfile?.cost ?? Number.MAX_SAFE_INTEGER : leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
4193
|
+
const rightValue = strategy === "prefer-cheapest" ? rightProfile?.cost ?? Number.MAX_SAFE_INTEGER : rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
4194
|
+
return leftValue - rightValue || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER);
|
|
4195
|
+
});
|
|
4196
|
+
};
|
|
4197
|
+
const resolveOrder = async (input) => {
|
|
4198
|
+
const selectedProvider = await options.selectProvider?.(input);
|
|
4199
|
+
const allowedProviders = await resolveAllowedProviders(input);
|
|
4200
|
+
const fallbackSource = options.fallback ?? orchestrationSurface?.fallback;
|
|
4201
|
+
const fallbackOrder = typeof fallbackSource === "function" ? await fallbackSource(input) : fallbackSource;
|
|
4202
|
+
const allowedRankedProviders = sortProviders([
|
|
4203
|
+
...fallbackOrder ?? providerIds
|
|
4204
|
+
]).filter((provider) => allowedProviders.has(provider));
|
|
4205
|
+
const rankedProviders = allowedRankedProviders.filter(passesBudgetFilters);
|
|
4206
|
+
const healthyRankedProviders = healthOptions ? rankedProviders.filter((provider) => !isSuppressed(provider)) : rankedProviders;
|
|
4207
|
+
const candidateRankedProviders = healthyRankedProviders.length ? healthyRankedProviders : rankedProviders;
|
|
4208
|
+
const preferred = selectedProvider && allowedProviders.has(selectedProvider) && passesBudgetFilters(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
|
|
4209
|
+
const seen = new Set;
|
|
4210
|
+
const order = [];
|
|
4211
|
+
const candidates = strategy === "ordered" ? candidateRankedProviders : [
|
|
4212
|
+
preferred,
|
|
4213
|
+
...candidateRankedProviders,
|
|
4214
|
+
...providerIds.filter((provider) => !healthOptions || !isSuppressed(provider))
|
|
4215
|
+
];
|
|
4216
|
+
for (const provider of candidates) {
|
|
4217
|
+
if (!provider || seen.has(provider) || !allowedProviders.has(provider) || !options.providers[provider]) {
|
|
4218
|
+
continue;
|
|
4219
|
+
}
|
|
4220
|
+
seen.add(provider);
|
|
4221
|
+
order.push(provider);
|
|
4222
|
+
}
|
|
4223
|
+
return {
|
|
4224
|
+
order,
|
|
4225
|
+
selectedProvider: preferred
|
|
4226
|
+
};
|
|
4227
|
+
};
|
|
4228
|
+
const emit = async (event, input) => {
|
|
4229
|
+
await options.onProviderEvent?.(event, input);
|
|
4230
|
+
};
|
|
4231
|
+
const runProvider = async (provider, model, input) => {
|
|
4232
|
+
const timeoutMs = getProviderTimeoutMs(provider);
|
|
4233
|
+
if (!timeoutMs) {
|
|
4234
|
+
return model.generate(input);
|
|
4235
|
+
}
|
|
4236
|
+
let timeout;
|
|
4237
|
+
try {
|
|
4238
|
+
return await Promise.race([
|
|
4239
|
+
model.generate(input),
|
|
4240
|
+
new Promise((_, reject) => {
|
|
4241
|
+
timeout = setTimeout(() => reject(new VoiceProviderTimeoutError(provider, timeoutMs)), timeoutMs);
|
|
4242
|
+
})
|
|
4243
|
+
]);
|
|
4244
|
+
} finally {
|
|
4245
|
+
if (timeout) {
|
|
4246
|
+
clearTimeout(timeout);
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
};
|
|
4250
|
+
return {
|
|
4251
|
+
generate: async (input) => {
|
|
4252
|
+
const { order, selectedProvider } = await resolveOrder(input);
|
|
4253
|
+
if (!selectedProvider || order.length === 0) {
|
|
4254
|
+
throw new Error("Voice provider router has no available providers.");
|
|
4255
|
+
}
|
|
4256
|
+
let lastError;
|
|
4257
|
+
for (const [index, provider] of order.entries()) {
|
|
4258
|
+
const model = options.providers[provider];
|
|
4259
|
+
if (!model) {
|
|
4260
|
+
continue;
|
|
4261
|
+
}
|
|
4262
|
+
const startedAt = Date.now();
|
|
4263
|
+
try {
|
|
4264
|
+
const output = await runProvider(provider, model, input);
|
|
4265
|
+
const providerHealth = recordProviderSuccess(provider);
|
|
4266
|
+
await emit({
|
|
4267
|
+
at: Date.now(),
|
|
4268
|
+
attempt: index + 1,
|
|
4269
|
+
elapsedMs: Date.now() - startedAt,
|
|
4270
|
+
fallbackProvider: provider === selectedProvider ? undefined : provider,
|
|
4271
|
+
latencyBudgetMs: getProviderTimeoutMs(provider),
|
|
4272
|
+
provider,
|
|
4273
|
+
providerHealth,
|
|
4274
|
+
recovered: provider !== selectedProvider,
|
|
4275
|
+
selectedProvider,
|
|
4276
|
+
status: provider === selectedProvider ? "success" : "fallback"
|
|
4277
|
+
}, input);
|
|
4278
|
+
return output;
|
|
4279
|
+
} catch (error) {
|
|
4280
|
+
lastError = error;
|
|
4281
|
+
const hasNextProvider = index < order.length - 1;
|
|
4282
|
+
const isProviderError = options.isProviderError?.(error, provider) ?? true;
|
|
4283
|
+
const timedOut = options.isTimeoutError?.(error, provider) ?? error instanceof VoiceProviderTimeoutError;
|
|
4284
|
+
const rateLimited = options.isRateLimitError?.(error, provider) ?? defaultIsRateLimitError(error);
|
|
4285
|
+
const shouldFallback = fallbackMode === "provider-error" ? isProviderError : fallbackMode === "rate-limit" ? isProviderError && rateLimited : false;
|
|
4286
|
+
const providerHealth = recordProviderError(provider, isProviderError, rateLimited);
|
|
4287
|
+
const nextProvider = hasNextProvider ? order[index + 1] : undefined;
|
|
4288
|
+
await emit({
|
|
4289
|
+
at: Date.now(),
|
|
4290
|
+
attempt: index + 1,
|
|
4291
|
+
elapsedMs: Date.now() - startedAt,
|
|
4292
|
+
error: errorMessage(error),
|
|
4293
|
+
fallbackProvider: shouldFallback ? nextProvider : undefined,
|
|
4294
|
+
latencyBudgetMs: getProviderTimeoutMs(provider),
|
|
4295
|
+
provider,
|
|
4296
|
+
providerHealth,
|
|
4297
|
+
rateLimited,
|
|
4298
|
+
selectedProvider,
|
|
4299
|
+
suppressionRemainingMs: getSuppressionRemainingMs(provider),
|
|
4300
|
+
suppressedUntil: providerHealth?.suppressedUntil,
|
|
4301
|
+
status: "error",
|
|
4302
|
+
timedOut
|
|
4303
|
+
}, input);
|
|
4304
|
+
if (!hasNextProvider || !shouldFallback) {
|
|
4305
|
+
throw error;
|
|
4306
|
+
}
|
|
4307
|
+
}
|
|
4308
|
+
}
|
|
4309
|
+
throw lastError ?? new Error("Voice provider router did not run a provider.");
|
|
4310
|
+
}
|
|
4311
|
+
};
|
|
4312
|
+
};
|
|
4313
|
+
var messageToOpenAIInput = (message) => {
|
|
4314
|
+
if (message.role === "tool") {
|
|
4315
|
+
return [
|
|
4316
|
+
{
|
|
4317
|
+
call_id: message.toolCallId ?? message.name ?? crypto.randomUUID(),
|
|
4318
|
+
output: message.content,
|
|
4319
|
+
type: "function_call_output"
|
|
4320
|
+
}
|
|
4321
|
+
];
|
|
4322
|
+
}
|
|
4323
|
+
const toolCalls = getMessageToolCalls(message);
|
|
4324
|
+
if (message.role === "assistant" && toolCalls.length) {
|
|
4325
|
+
return toolCalls.map((toolCall) => ({
|
|
4326
|
+
arguments: JSON.stringify(toolCall.args),
|
|
4327
|
+
call_id: toolCall.id ?? crypto.randomUUID(),
|
|
4328
|
+
name: toolCall.name,
|
|
4329
|
+
type: "function_call"
|
|
4330
|
+
}));
|
|
4331
|
+
}
|
|
4332
|
+
return [
|
|
4333
|
+
{
|
|
4334
|
+
content: message.content,
|
|
4335
|
+
role: message.role === "system" ? "developer" : message.role
|
|
4336
|
+
}
|
|
4337
|
+
];
|
|
4338
|
+
};
|
|
4339
|
+
var messagesToOpenAIInput = (messages) => messages.flatMap(messageToOpenAIInput);
|
|
4340
|
+
var messageToAnthropicMessage = (message) => {
|
|
4341
|
+
if (message.role === "system") {
|
|
4342
|
+
return;
|
|
4343
|
+
}
|
|
4344
|
+
if (message.role === "tool") {
|
|
4345
|
+
if (!message.toolCallId) {
|
|
4346
|
+
return {
|
|
4347
|
+
content: `Tool result from ${message.name ?? "tool"}: ${message.content}`,
|
|
4348
|
+
role: "user"
|
|
4349
|
+
};
|
|
4350
|
+
}
|
|
4351
|
+
return {
|
|
4352
|
+
content: [
|
|
4353
|
+
{
|
|
4354
|
+
content: message.content,
|
|
4355
|
+
tool_use_id: message.toolCallId,
|
|
4356
|
+
type: "tool_result"
|
|
4357
|
+
}
|
|
4358
|
+
],
|
|
4359
|
+
role: "user"
|
|
4360
|
+
};
|
|
4361
|
+
}
|
|
4362
|
+
const toolCalls = getMessageToolCalls(message);
|
|
4363
|
+
if (message.role === "assistant" && toolCalls.length) {
|
|
4364
|
+
return {
|
|
4365
|
+
content: [
|
|
4366
|
+
...message.content ? [
|
|
4367
|
+
{
|
|
4368
|
+
text: message.content,
|
|
4369
|
+
type: "text"
|
|
4370
|
+
}
|
|
4371
|
+
] : [],
|
|
4372
|
+
...toolCalls.map((toolCall) => ({
|
|
4373
|
+
id: toolCall.id ?? crypto.randomUUID(),
|
|
4374
|
+
input: toolCall.args,
|
|
4375
|
+
name: toolCall.name,
|
|
4376
|
+
type: "tool_use"
|
|
4377
|
+
}))
|
|
4378
|
+
],
|
|
4379
|
+
role: "assistant"
|
|
4380
|
+
};
|
|
4381
|
+
}
|
|
4382
|
+
return {
|
|
4383
|
+
content: message.content,
|
|
4384
|
+
role: message.role
|
|
4385
|
+
};
|
|
4386
|
+
};
|
|
4387
|
+
var toGeminiSchema = (schema) => {
|
|
4388
|
+
const next = {};
|
|
4389
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
4390
|
+
if (key === "additionalProperties") {
|
|
4391
|
+
continue;
|
|
4392
|
+
}
|
|
4393
|
+
if (key === "type" && typeof value === "string") {
|
|
4394
|
+
next[key] = value.toUpperCase();
|
|
4395
|
+
continue;
|
|
4396
|
+
}
|
|
4397
|
+
if (Array.isArray(value)) {
|
|
4398
|
+
next[key] = value.map((item) => item && typeof item === "object" ? toGeminiSchema(item) : item);
|
|
4399
|
+
continue;
|
|
4400
|
+
}
|
|
4401
|
+
if (value && typeof value === "object") {
|
|
4402
|
+
next[key] = toGeminiSchema(value);
|
|
4403
|
+
continue;
|
|
4404
|
+
}
|
|
4405
|
+
next[key] = value;
|
|
4406
|
+
}
|
|
4407
|
+
return next;
|
|
4408
|
+
};
|
|
4409
|
+
var messageToGeminiContent = (message) => {
|
|
4410
|
+
if (message.role === "system") {
|
|
4411
|
+
return;
|
|
4412
|
+
}
|
|
4413
|
+
if (message.role === "tool") {
|
|
4414
|
+
return {
|
|
4415
|
+
parts: [
|
|
4416
|
+
{
|
|
4417
|
+
functionResponse: {
|
|
4418
|
+
id: message.toolCallId,
|
|
4419
|
+
name: message.name ?? "tool",
|
|
4420
|
+
response: {
|
|
4421
|
+
result: parseJSONValue(message.content)
|
|
4422
|
+
}
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4425
|
+
],
|
|
4426
|
+
role: "user"
|
|
4427
|
+
};
|
|
4428
|
+
}
|
|
4429
|
+
const toolCalls = getMessageToolCalls(message);
|
|
4430
|
+
if (message.role === "assistant" && toolCalls.length) {
|
|
4431
|
+
return {
|
|
4432
|
+
parts: [
|
|
4433
|
+
...message.content ? [
|
|
4434
|
+
{
|
|
4435
|
+
text: message.content
|
|
4436
|
+
}
|
|
4437
|
+
] : [],
|
|
4438
|
+
...toolCalls.map((toolCall) => ({
|
|
4439
|
+
functionCall: {
|
|
4440
|
+
args: toolCall.args,
|
|
4441
|
+
id: toolCall.id,
|
|
4442
|
+
name: toolCall.name
|
|
4443
|
+
}
|
|
4444
|
+
}))
|
|
4445
|
+
],
|
|
4446
|
+
role: "model"
|
|
4447
|
+
};
|
|
4448
|
+
}
|
|
4449
|
+
return {
|
|
4450
|
+
parts: [
|
|
4451
|
+
{
|
|
4452
|
+
text: message.content
|
|
4453
|
+
}
|
|
4454
|
+
],
|
|
4455
|
+
role: message.role === "assistant" ? "model" : "user"
|
|
4456
|
+
};
|
|
4457
|
+
};
|
|
4458
|
+
var extractText = (response) => {
|
|
4459
|
+
if (typeof response.output_text === "string") {
|
|
4460
|
+
return response.output_text;
|
|
4461
|
+
}
|
|
4462
|
+
const output = Array.isArray(response.output) ? response.output : [];
|
|
4463
|
+
for (const item of output) {
|
|
4464
|
+
if (!item || typeof item !== "object") {
|
|
4465
|
+
continue;
|
|
4466
|
+
}
|
|
4467
|
+
const record = item;
|
|
4468
|
+
const content = Array.isArray(record.content) ? record.content : [];
|
|
4469
|
+
for (const contentItem of content) {
|
|
4470
|
+
if (!contentItem || typeof contentItem !== "object") {
|
|
4471
|
+
continue;
|
|
4472
|
+
}
|
|
4473
|
+
const contentRecord = contentItem;
|
|
4474
|
+
if (typeof contentRecord.text === "string") {
|
|
4475
|
+
return contentRecord.text;
|
|
4476
|
+
}
|
|
4477
|
+
}
|
|
4478
|
+
}
|
|
4479
|
+
return "";
|
|
4480
|
+
};
|
|
4481
|
+
var extractToolCalls = (response) => {
|
|
4482
|
+
const output = Array.isArray(response.output) ? response.output : [];
|
|
4483
|
+
const toolCalls = [];
|
|
4484
|
+
for (const item of output) {
|
|
4485
|
+
if (!item || typeof item !== "object") {
|
|
4486
|
+
continue;
|
|
4487
|
+
}
|
|
4488
|
+
const record = item;
|
|
4489
|
+
if (record.type !== "function_call" || typeof record.name !== "string") {
|
|
4490
|
+
continue;
|
|
4491
|
+
}
|
|
4492
|
+
const args = typeof record.arguments === "string" ? parseJSON(record.arguments) : {};
|
|
4493
|
+
toolCalls.push({
|
|
4494
|
+
args,
|
|
4495
|
+
id: typeof record.call_id === "string" ? record.call_id : typeof record.id === "string" ? record.id : undefined,
|
|
4496
|
+
name: record.name
|
|
4497
|
+
});
|
|
4498
|
+
}
|
|
4499
|
+
return toolCalls;
|
|
4500
|
+
};
|
|
4501
|
+
var createOpenAIVoiceAssistantModel = (options) => {
|
|
4502
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
4503
|
+
const baseUrl = options.baseUrl ?? "https://api.openai.com/v1";
|
|
4504
|
+
const model = options.model ?? "gpt-4.1-mini";
|
|
4505
|
+
return {
|
|
4506
|
+
generate: async (input) => {
|
|
4507
|
+
const response = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/responses`, {
|
|
4508
|
+
body: JSON.stringify({
|
|
4509
|
+
input: messagesToOpenAIInput(input.messages),
|
|
4510
|
+
instructions: [
|
|
4511
|
+
input.system,
|
|
4512
|
+
"Return a JSON object with assistantText, complete, transfer, escalate, voicemail, noAnswer, and result when you are not calling tools."
|
|
4513
|
+
].filter(Boolean).join(`
|
|
4514
|
+
|
|
4515
|
+
`),
|
|
4516
|
+
max_output_tokens: options.maxOutputTokens,
|
|
4517
|
+
model,
|
|
4518
|
+
temperature: options.temperature,
|
|
4519
|
+
text: {
|
|
4520
|
+
format: {
|
|
4521
|
+
name: "voice_route_result",
|
|
4522
|
+
schema: OUTPUT_SCHEMA,
|
|
4523
|
+
strict: false,
|
|
4524
|
+
type: "json_schema"
|
|
4525
|
+
}
|
|
4526
|
+
},
|
|
4527
|
+
tool_choice: input.tools.length ? "auto" : "none",
|
|
4528
|
+
tools: input.tools.map((tool) => ({
|
|
4529
|
+
description: tool.description,
|
|
4530
|
+
name: tool.name,
|
|
4531
|
+
parameters: tool.parameters ?? {
|
|
4532
|
+
additionalProperties: true,
|
|
4533
|
+
type: "object"
|
|
4534
|
+
},
|
|
4535
|
+
strict: false,
|
|
4536
|
+
type: "function"
|
|
4537
|
+
}))
|
|
4538
|
+
}),
|
|
4539
|
+
headers: {
|
|
4540
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
4541
|
+
"content-type": "application/json"
|
|
4542
|
+
},
|
|
4543
|
+
method: "POST"
|
|
4544
|
+
});
|
|
4545
|
+
if (!response.ok) {
|
|
4546
|
+
throw createHTTPError("OpenAI", response);
|
|
4547
|
+
}
|
|
4548
|
+
const body = await response.json();
|
|
4549
|
+
if (body.usage && typeof body.usage === "object") {
|
|
4550
|
+
await options.onUsage?.(body.usage);
|
|
4551
|
+
}
|
|
4552
|
+
const toolCalls = extractToolCalls(body);
|
|
4553
|
+
if (toolCalls.length) {
|
|
4554
|
+
return {
|
|
4555
|
+
toolCalls
|
|
4556
|
+
};
|
|
4557
|
+
}
|
|
4558
|
+
return normalizeRouteOutput(parseJSON(extractText(body)));
|
|
4559
|
+
}
|
|
4560
|
+
};
|
|
4561
|
+
};
|
|
4562
|
+
var extractAnthropicText = (response) => {
|
|
4563
|
+
const content = Array.isArray(response.content) ? response.content : [];
|
|
4564
|
+
return content.map((item) => item && typeof item === "object" && item.type === "text" && typeof item.text === "string" ? item.text : "").filter(Boolean).join(`
|
|
4565
|
+
`);
|
|
4566
|
+
};
|
|
4567
|
+
var extractAnthropicToolCalls = (response) => {
|
|
4568
|
+
const content = Array.isArray(response.content) ? response.content : [];
|
|
4569
|
+
const toolCalls = [];
|
|
4570
|
+
for (const item of content) {
|
|
4571
|
+
if (!item || typeof item !== "object") {
|
|
4572
|
+
continue;
|
|
4573
|
+
}
|
|
4574
|
+
const record = item;
|
|
4575
|
+
if (record.type !== "tool_use" || typeof record.name !== "string") {
|
|
4576
|
+
continue;
|
|
4577
|
+
}
|
|
4578
|
+
toolCalls.push({
|
|
4579
|
+
args: record.input && typeof record.input === "object" ? record.input : {},
|
|
4580
|
+
id: typeof record.id === "string" ? record.id : undefined,
|
|
4581
|
+
name: record.name
|
|
4582
|
+
});
|
|
4583
|
+
}
|
|
4584
|
+
return toolCalls;
|
|
4585
|
+
};
|
|
4586
|
+
var createAnthropicVoiceAssistantModel = (options) => {
|
|
4587
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
4588
|
+
const baseUrl = options.baseUrl ?? "https://api.anthropic.com/v1";
|
|
4589
|
+
const model = options.model ?? "claude-sonnet-4-5";
|
|
4590
|
+
return {
|
|
4591
|
+
generate: async (input) => {
|
|
4592
|
+
const response = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/messages`, {
|
|
4593
|
+
body: JSON.stringify({
|
|
4594
|
+
max_tokens: options.maxOutputTokens ?? 1024,
|
|
4595
|
+
messages: input.messages.map(messageToAnthropicMessage).filter(Boolean),
|
|
4596
|
+
model,
|
|
4597
|
+
system: [input.system, ROUTE_RESULT_INSTRUCTION].filter(Boolean).join(`
|
|
4598
|
+
|
|
4599
|
+
`),
|
|
4600
|
+
temperature: options.temperature,
|
|
4601
|
+
tool_choice: input.tools.length ? { type: "auto" } : { type: "none" },
|
|
4602
|
+
tools: input.tools.map((tool) => ({
|
|
4603
|
+
description: tool.description,
|
|
4604
|
+
input_schema: tool.parameters ?? {
|
|
4605
|
+
additionalProperties: true,
|
|
4606
|
+
type: "object"
|
|
4607
|
+
},
|
|
4608
|
+
name: tool.name
|
|
4609
|
+
}))
|
|
4610
|
+
}),
|
|
4611
|
+
headers: {
|
|
4612
|
+
"anthropic-version": options.version ?? "2023-06-01",
|
|
4613
|
+
"content-type": "application/json",
|
|
4614
|
+
"x-api-key": options.apiKey
|
|
4615
|
+
},
|
|
4616
|
+
method: "POST"
|
|
4617
|
+
});
|
|
4618
|
+
if (!response.ok) {
|
|
4619
|
+
throw createHTTPError("Anthropic", response);
|
|
4620
|
+
}
|
|
4621
|
+
const body = await response.json();
|
|
4622
|
+
if (body.usage && typeof body.usage === "object") {
|
|
4623
|
+
await options.onUsage?.(body.usage);
|
|
4624
|
+
}
|
|
4625
|
+
const toolCalls = extractAnthropicToolCalls(body);
|
|
4626
|
+
if (toolCalls.length) {
|
|
4627
|
+
return {
|
|
4628
|
+
assistantText: extractAnthropicText(body) || undefined,
|
|
4629
|
+
toolCalls
|
|
4630
|
+
};
|
|
4631
|
+
}
|
|
4632
|
+
return normalizeRouteOutput(parseJSON(extractAnthropicText(body)));
|
|
4633
|
+
}
|
|
4634
|
+
};
|
|
4635
|
+
};
|
|
4636
|
+
var extractGeminiCandidateParts = (response) => {
|
|
4637
|
+
const candidates = Array.isArray(response.candidates) ? response.candidates : [];
|
|
4638
|
+
const first = candidates[0];
|
|
4639
|
+
if (!first || typeof first !== "object") {
|
|
4640
|
+
return [];
|
|
4641
|
+
}
|
|
4642
|
+
const content = first.content;
|
|
4643
|
+
if (!content || typeof content !== "object") {
|
|
4644
|
+
return [];
|
|
4645
|
+
}
|
|
4646
|
+
const parts = content.parts;
|
|
4647
|
+
return Array.isArray(parts) ? parts : [];
|
|
4648
|
+
};
|
|
4649
|
+
var extractGeminiText = (response) => extractGeminiCandidateParts(response).map((part) => part && typeof part === "object" && typeof part.text === "string" ? part.text : "").filter(Boolean).join(`
|
|
4650
|
+
`);
|
|
4651
|
+
var extractGeminiToolCalls = (response) => {
|
|
4652
|
+
const toolCalls = [];
|
|
4653
|
+
for (const part of extractGeminiCandidateParts(response)) {
|
|
4654
|
+
if (!part || typeof part !== "object") {
|
|
4655
|
+
continue;
|
|
4656
|
+
}
|
|
4657
|
+
const functionCall = part.functionCall;
|
|
4658
|
+
if (!functionCall || typeof functionCall !== "object") {
|
|
4659
|
+
continue;
|
|
4660
|
+
}
|
|
4661
|
+
const record = functionCall;
|
|
4662
|
+
if (typeof record.name !== "string") {
|
|
4663
|
+
continue;
|
|
4664
|
+
}
|
|
4665
|
+
toolCalls.push({
|
|
4666
|
+
args: record.args && typeof record.args === "object" ? record.args : {},
|
|
4667
|
+
id: typeof record.id === "string" ? record.id : undefined,
|
|
4668
|
+
name: record.name
|
|
4669
|
+
});
|
|
4670
|
+
}
|
|
4671
|
+
return toolCalls;
|
|
4672
|
+
};
|
|
4673
|
+
var createGeminiVoiceAssistantModel = (options) => {
|
|
4674
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
4675
|
+
const baseUrl = options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta";
|
|
4676
|
+
const model = options.model ?? "gemini-2.5-flash";
|
|
4677
|
+
const maxRetries = Math.max(0, options.maxRetries ?? 2);
|
|
4678
|
+
return {
|
|
4679
|
+
generate: async (input) => {
|
|
4680
|
+
const endpoint = `${baseUrl.replace(/\/$/, "")}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(options.apiKey)}`;
|
|
4681
|
+
let response;
|
|
4682
|
+
for (let attempt = 0;attempt <= maxRetries; attempt += 1) {
|
|
4683
|
+
response = await fetchImpl(endpoint, {
|
|
4684
|
+
body: JSON.stringify({
|
|
4685
|
+
contents: input.messages.map(messageToGeminiContent).filter(Boolean),
|
|
4686
|
+
generationConfig: {
|
|
4687
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
4688
|
+
...input.tools.length ? {} : {
|
|
4689
|
+
responseMimeType: "application/json",
|
|
4690
|
+
responseSchema: toGeminiSchema(OUTPUT_SCHEMA)
|
|
4691
|
+
},
|
|
4692
|
+
temperature: options.temperature
|
|
4693
|
+
},
|
|
4694
|
+
systemInstruction: {
|
|
4695
|
+
parts: [
|
|
4696
|
+
{
|
|
4697
|
+
text: [input.system, ROUTE_RESULT_INSTRUCTION].filter(Boolean).join(`
|
|
4698
|
+
|
|
4699
|
+
`)
|
|
4700
|
+
}
|
|
4701
|
+
]
|
|
4702
|
+
},
|
|
4703
|
+
tools: input.tools.length ? [
|
|
4704
|
+
{
|
|
4705
|
+
functionDeclarations: input.tools.map((tool) => ({
|
|
4706
|
+
description: tool.description,
|
|
4707
|
+
name: tool.name,
|
|
4708
|
+
parameters: toGeminiSchema(tool.parameters ?? {
|
|
4709
|
+
additionalProperties: true,
|
|
4710
|
+
type: "object"
|
|
4711
|
+
})
|
|
4712
|
+
}))
|
|
4713
|
+
}
|
|
4714
|
+
] : undefined
|
|
4715
|
+
}),
|
|
4716
|
+
headers: {
|
|
4717
|
+
"content-type": "application/json"
|
|
4718
|
+
},
|
|
4719
|
+
method: "POST"
|
|
4720
|
+
});
|
|
4721
|
+
if (response.ok || response.status !== 429 && response.status < 500 || attempt === maxRetries) {
|
|
4722
|
+
break;
|
|
4723
|
+
}
|
|
4724
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
4725
|
+
await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt);
|
|
4726
|
+
}
|
|
4727
|
+
if (!response) {
|
|
4728
|
+
throw new Error("Gemini voice assistant model failed: no response");
|
|
4729
|
+
}
|
|
4730
|
+
if (!response.ok) {
|
|
4731
|
+
throw createHTTPError("Gemini", response);
|
|
4732
|
+
}
|
|
4733
|
+
const body = await response.json();
|
|
4734
|
+
if (body.usageMetadata && typeof body.usageMetadata === "object") {
|
|
4735
|
+
await options.onUsage?.(body.usageMetadata);
|
|
4736
|
+
}
|
|
4737
|
+
const toolCalls = extractGeminiToolCalls(body);
|
|
4738
|
+
if (toolCalls.length) {
|
|
4739
|
+
return {
|
|
4740
|
+
assistantText: extractGeminiText(body) || undefined,
|
|
4741
|
+
toolCalls
|
|
4742
|
+
};
|
|
4743
|
+
}
|
|
4744
|
+
return normalizeRouteOutput(parseJSON(extractGeminiText(body)));
|
|
4745
|
+
}
|
|
4746
|
+
};
|
|
4747
|
+
};
|
|
4748
|
+
|
|
4749
|
+
// src/store.ts
|
|
4750
|
+
var createId = () => crypto.randomUUID();
|
|
4751
|
+
var createVoiceSessionRecord = (id, scenarioId) => ({
|
|
4752
|
+
committedTurnIds: [],
|
|
4753
|
+
createdAt: Date.now(),
|
|
4754
|
+
currentTurn: {
|
|
4755
|
+
finalText: "",
|
|
4756
|
+
lastSpeechAt: undefined,
|
|
4757
|
+
lastTranscriptAt: undefined,
|
|
4758
|
+
partialEndedAt: undefined,
|
|
4759
|
+
partialStartedAt: undefined,
|
|
4760
|
+
partialText: "",
|
|
4761
|
+
silenceStartedAt: undefined,
|
|
4762
|
+
transcripts: []
|
|
4763
|
+
},
|
|
4764
|
+
id,
|
|
4765
|
+
scenarioId,
|
|
4766
|
+
reconnect: { attempts: 0 },
|
|
4767
|
+
status: "active",
|
|
4768
|
+
transcripts: [],
|
|
4769
|
+
turns: [],
|
|
4770
|
+
lastCommittedTurn: {
|
|
4771
|
+
committedAt: 0,
|
|
4772
|
+
signature: "",
|
|
4773
|
+
text: "",
|
|
4774
|
+
transcriptIds: []
|
|
4775
|
+
}
|
|
4776
|
+
});
|
|
4777
|
+
var resetVoiceSessionRecord = (id, existing, scenarioId) => ({
|
|
4778
|
+
...createVoiceSessionRecord(id, scenarioId),
|
|
4779
|
+
metadata: existing?.metadata
|
|
4780
|
+
});
|
|
4781
|
+
var toVoiceSessionSummary = (session) => ({
|
|
4782
|
+
createdAt: session.createdAt,
|
|
4783
|
+
id: session.id,
|
|
4784
|
+
lastActivityAt: session.lastActivityAt,
|
|
4785
|
+
status: session.status,
|
|
4786
|
+
turnCount: session.turns.length
|
|
4787
|
+
});
|
|
4788
|
+
|
|
4789
|
+
// src/testing/providerSimulator.ts
|
|
4790
|
+
var getContextQuery = (context) => context.query;
|
|
4791
|
+
var titleCaseProvider = (provider) => provider.split(/[-_\s]+/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" ");
|
|
4792
|
+
var resolveRequestedProvider = (context, providers) => {
|
|
4793
|
+
const provider = getContextQuery(context).provider;
|
|
4794
|
+
return providers.includes(provider) ? provider : providers[0];
|
|
4795
|
+
};
|
|
4796
|
+
var createVoiceProviderFailureSimulator = (options) => {
|
|
4797
|
+
if (options.providers.length === 0) {
|
|
4798
|
+
throw new Error("At least one provider is required.");
|
|
4799
|
+
}
|
|
4800
|
+
const providerModels = Object.fromEntries(options.providers.map((provider) => [
|
|
4801
|
+
provider,
|
|
4802
|
+
{
|
|
4803
|
+
generate: async (input) => {
|
|
4804
|
+
const query = getContextQuery(input.context);
|
|
4805
|
+
if (provider === query.simulateFailureProvider) {
|
|
4806
|
+
const label = options.providerLabel?.(provider) ?? titleCaseProvider(provider);
|
|
4807
|
+
throw new Error(`${label} voice assistant model failed: HTTP 429`);
|
|
4808
|
+
}
|
|
4809
|
+
if (options.response) {
|
|
4810
|
+
return options.response({
|
|
4811
|
+
...input,
|
|
4812
|
+
mode: query.recoverProvider === provider ? "recovery" : "failure",
|
|
4813
|
+
provider
|
|
4814
|
+
});
|
|
4815
|
+
}
|
|
4816
|
+
return {
|
|
4817
|
+
assistantText: `Simulated ${provider} provider recovered.`
|
|
4818
|
+
};
|
|
4819
|
+
}
|
|
4820
|
+
}
|
|
4821
|
+
]));
|
|
4822
|
+
const router = createVoiceProviderRouter({
|
|
4823
|
+
allowProviders: async (input) => {
|
|
4824
|
+
const recoverProvider = getContextQuery(input.context).recoverProvider;
|
|
4825
|
+
if (recoverProvider) {
|
|
4826
|
+
return [recoverProvider];
|
|
4827
|
+
}
|
|
4828
|
+
if (typeof options.allowProviders === "function") {
|
|
4829
|
+
return options.allowProviders(input);
|
|
4830
|
+
}
|
|
4831
|
+
return options.allowProviders ?? options.providers;
|
|
4832
|
+
},
|
|
4833
|
+
fallback: async (input) => {
|
|
4834
|
+
const selectedProvider = resolveRequestedProvider(input.context, options.providers);
|
|
4835
|
+
if (typeof options.fallback === "function") {
|
|
4836
|
+
return options.fallback(selectedProvider, input);
|
|
4837
|
+
}
|
|
4838
|
+
return options.fallback ?? options.providers.filter((provider) => provider !== selectedProvider);
|
|
4839
|
+
},
|
|
4840
|
+
fallbackMode: "provider-error",
|
|
4841
|
+
isProviderError: options.isProviderError,
|
|
4842
|
+
isRateLimitError: options.isRateLimitError,
|
|
4843
|
+
onProviderEvent: options.onProviderEvent,
|
|
4844
|
+
policy: "prefer-selected",
|
|
4845
|
+
providerHealth: options.providerHealth ?? {
|
|
4846
|
+
cooldownMs: 30000,
|
|
4847
|
+
failureThreshold: 1,
|
|
4848
|
+
rateLimitCooldownMs: 120000
|
|
4849
|
+
},
|
|
4850
|
+
providers: providerModels,
|
|
4851
|
+
selectProvider: ({ context }) => resolveRequestedProvider(context, options.providers)
|
|
4852
|
+
});
|
|
4853
|
+
const run = async (provider, mode) => {
|
|
4854
|
+
const now = Date.now();
|
|
4855
|
+
const session = createVoiceSessionRecord(`provider-sim-${now}`, "provider-simulation");
|
|
4856
|
+
const turn = {
|
|
4857
|
+
committedAt: now,
|
|
4858
|
+
id: `provider-sim-turn-${now}`,
|
|
4859
|
+
text: mode === "failure" ? `Simulate ${provider} provider failure.` : `Simulate ${provider} provider recovery.`,
|
|
4860
|
+
transcripts: []
|
|
4861
|
+
};
|
|
4862
|
+
const context = {
|
|
4863
|
+
query: {
|
|
4864
|
+
provider,
|
|
4865
|
+
...mode === "recovery" ? { recoverProvider: provider } : {},
|
|
4866
|
+
...mode === "failure" ? { simulateFailureProvider: provider } : {}
|
|
4867
|
+
}
|
|
4868
|
+
};
|
|
4869
|
+
const result = await router.generate({
|
|
4870
|
+
agentId: "provider-simulator",
|
|
4871
|
+
context,
|
|
4872
|
+
messages: [
|
|
4873
|
+
{
|
|
4874
|
+
content: turn.text,
|
|
4875
|
+
role: "user"
|
|
4876
|
+
}
|
|
4877
|
+
],
|
|
4878
|
+
session,
|
|
4879
|
+
system: "Simulate provider routing without calling external APIs.",
|
|
4880
|
+
tools: [],
|
|
4881
|
+
turn
|
|
4882
|
+
});
|
|
4883
|
+
return {
|
|
4884
|
+
mode,
|
|
4885
|
+
provider,
|
|
4886
|
+
replayHref: options.replayHref === false ? undefined : typeof options.replayHref === "function" ? options.replayHref({
|
|
4887
|
+
provider,
|
|
4888
|
+
sessionId: session.id,
|
|
4889
|
+
turnId: turn.id
|
|
4890
|
+
}) : `${options.replayHref ?? "/api/voice-sessions"}/${encodeURIComponent(session.id)}/replay/htmx`,
|
|
4891
|
+
result,
|
|
4892
|
+
sessionId: session.id,
|
|
4893
|
+
status: "simulated",
|
|
4894
|
+
turnId: turn.id
|
|
4895
|
+
};
|
|
4896
|
+
};
|
|
4897
|
+
return {
|
|
4898
|
+
run
|
|
4899
|
+
};
|
|
4900
|
+
};
|
|
4901
|
+
// src/memoryStore.ts
|
|
4902
|
+
var createVoiceMemoryStore = () => {
|
|
4903
|
+
const sessions = new Map;
|
|
4904
|
+
const get = async (id) => sessions.get(id);
|
|
4905
|
+
const getOrCreate = async (id) => {
|
|
4906
|
+
let session = sessions.get(id);
|
|
4907
|
+
if (!session) {
|
|
4908
|
+
session = createVoiceSessionRecord(id);
|
|
4909
|
+
sessions.set(id, session);
|
|
4910
|
+
}
|
|
4911
|
+
return session;
|
|
4912
|
+
};
|
|
4913
|
+
const set = async (id, value) => {
|
|
4914
|
+
sessions.set(id, value);
|
|
4915
|
+
};
|
|
4916
|
+
const list = async () => Array.from(sessions.values()).map((session) => toVoiceSessionSummary(session)).sort((first, second) => (second.lastActivityAt ?? second.createdAt) - (first.lastActivityAt ?? first.createdAt));
|
|
4917
|
+
const remove = async (id) => {
|
|
4918
|
+
sessions.delete(id);
|
|
4919
|
+
};
|
|
4920
|
+
return { get, getOrCreate, list, remove, set };
|
|
4921
|
+
};
|
|
4922
|
+
|
|
4923
|
+
// src/session.ts
|
|
4924
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
4925
|
+
|
|
4926
|
+
// src/handoff.ts
|
|
4927
|
+
var toHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
4928
|
+
var signHandoffBody = async (input) => {
|
|
4929
|
+
const encoder = new TextEncoder;
|
|
4930
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(input.secret), {
|
|
4931
|
+
hash: "SHA-256",
|
|
4932
|
+
name: "HMAC"
|
|
4933
|
+
}, false, ["sign"]);
|
|
4934
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(`${input.timestamp}.${input.body}`));
|
|
4935
|
+
return `sha256=${toHex(new Uint8Array(signature))}`;
|
|
4936
|
+
};
|
|
4937
|
+
var toErrorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
4938
|
+
var createSkippedDelivery = (adapter) => ({
|
|
4939
|
+
adapterId: adapter.id,
|
|
4940
|
+
adapterKind: adapter.kind,
|
|
4941
|
+
status: "skipped"
|
|
4942
|
+
});
|
|
4943
|
+
var aggregateHandoffStatus = (deliveries) => {
|
|
4944
|
+
const statuses = Object.values(deliveries).map((delivery) => delivery.status);
|
|
4945
|
+
if (statuses.some((status) => status === "failed")) {
|
|
4946
|
+
return "failed";
|
|
4947
|
+
}
|
|
4948
|
+
if (statuses.some((status) => status === "delivered")) {
|
|
4949
|
+
return "delivered";
|
|
4950
|
+
}
|
|
4951
|
+
return "skipped";
|
|
4952
|
+
};
|
|
4953
|
+
var createHandoffDeliveryId = (input) => [
|
|
4954
|
+
"voice-handoff",
|
|
4955
|
+
input.sessionId,
|
|
4956
|
+
input.action,
|
|
4957
|
+
Date.now(),
|
|
4958
|
+
crypto.randomUUID()
|
|
4959
|
+
].join(":");
|
|
4960
|
+
var resolveHandoffDeliveryError = (deliveries) => Object.values(deliveries).map((delivery) => delivery.error).find(Boolean);
|
|
4961
|
+
var defaultWebhookBody = (input) => ({
|
|
4962
|
+
action: input.action,
|
|
4963
|
+
metadata: input.metadata,
|
|
4964
|
+
reason: input.reason,
|
|
4965
|
+
result: input.result,
|
|
4966
|
+
session: {
|
|
4967
|
+
id: input.session.id,
|
|
4968
|
+
scenarioId: input.session.scenarioId,
|
|
4969
|
+
status: input.session.status
|
|
4970
|
+
},
|
|
4971
|
+
source: "absolutejs-voice",
|
|
4972
|
+
target: input.target
|
|
4973
|
+
});
|
|
4974
|
+
var deliverVoiceHandoff = async (input) => {
|
|
4975
|
+
if (!input.config || input.config.adapters.length === 0) {
|
|
4976
|
+
return;
|
|
4977
|
+
}
|
|
4978
|
+
const deliveries = {};
|
|
4979
|
+
for (const adapter of input.config.adapters) {
|
|
4980
|
+
if (adapter.actions && !adapter.actions.includes(input.handoff.action)) {
|
|
4981
|
+
deliveries[adapter.id] = createSkippedDelivery(adapter);
|
|
4982
|
+
continue;
|
|
4983
|
+
}
|
|
4984
|
+
try {
|
|
4985
|
+
const result = await adapter.handoff(input.handoff);
|
|
4986
|
+
deliveries[adapter.id] = {
|
|
4987
|
+
...result,
|
|
4988
|
+
adapterId: adapter.id,
|
|
4989
|
+
adapterKind: adapter.kind
|
|
4990
|
+
};
|
|
4991
|
+
} catch (error) {
|
|
4992
|
+
deliveries[adapter.id] = {
|
|
4993
|
+
adapterId: adapter.id,
|
|
4994
|
+
adapterKind: adapter.kind,
|
|
4995
|
+
error: toErrorMessage(error),
|
|
4996
|
+
status: "failed"
|
|
4997
|
+
};
|
|
4998
|
+
if (input.config.failMode === "throw") {
|
|
4999
|
+
throw error;
|
|
5000
|
+
}
|
|
5001
|
+
}
|
|
5002
|
+
}
|
|
5003
|
+
return {
|
|
5004
|
+
action: input.handoff.action,
|
|
5005
|
+
deliveries,
|
|
5006
|
+
status: aggregateHandoffStatus(deliveries)
|
|
5007
|
+
};
|
|
5008
|
+
};
|
|
5009
|
+
var createVoiceHandoffDeliveryRecord = (input) => {
|
|
5010
|
+
const now = Date.now();
|
|
5011
|
+
return {
|
|
5012
|
+
action: input.action,
|
|
5013
|
+
context: input.context,
|
|
5014
|
+
createdAt: now,
|
|
5015
|
+
deliveryAttempts: 0,
|
|
5016
|
+
deliveryStatus: "pending",
|
|
5017
|
+
id: input.id ?? createHandoffDeliveryId({
|
|
5018
|
+
action: input.action,
|
|
5019
|
+
sessionId: input.session.id
|
|
5020
|
+
}),
|
|
5021
|
+
metadata: input.metadata,
|
|
5022
|
+
reason: input.reason,
|
|
5023
|
+
result: input.result,
|
|
5024
|
+
session: input.session,
|
|
5025
|
+
sessionId: input.session.id,
|
|
5026
|
+
target: input.target,
|
|
5027
|
+
updatedAt: now
|
|
5028
|
+
};
|
|
5029
|
+
};
|
|
5030
|
+
var applyVoiceHandoffDeliveryResult = (delivery, result) => ({
|
|
5031
|
+
...delivery,
|
|
5032
|
+
deliveredAt: result.status === "delivered" || result.status === "skipped" ? Date.now() : delivery.deliveredAt,
|
|
5033
|
+
deliveries: result.deliveries,
|
|
5034
|
+
deliveryAttempts: (delivery.deliveryAttempts ?? 0) + 1,
|
|
5035
|
+
deliveryError: result.status === "failed" ? resolveHandoffDeliveryError(result.deliveries) : undefined,
|
|
5036
|
+
deliveryStatus: result.status,
|
|
5037
|
+
updatedAt: Date.now()
|
|
5038
|
+
});
|
|
5039
|
+
var deliverVoiceHandoffDelivery = async (options) => {
|
|
5040
|
+
const result = await deliverVoiceHandoff({
|
|
5041
|
+
config: {
|
|
5042
|
+
adapters: options.adapters,
|
|
5043
|
+
failMode: options.failMode
|
|
5044
|
+
},
|
|
5045
|
+
handoff: {
|
|
5046
|
+
action: options.delivery.action,
|
|
5047
|
+
api: options.api,
|
|
5048
|
+
context: options.delivery.context,
|
|
5049
|
+
metadata: options.delivery.metadata,
|
|
5050
|
+
reason: options.delivery.reason,
|
|
5051
|
+
result: options.delivery.result,
|
|
5052
|
+
session: options.delivery.session,
|
|
5053
|
+
target: options.delivery.target
|
|
5054
|
+
}
|
|
5055
|
+
});
|
|
5056
|
+
return result ? applyVoiceHandoffDeliveryResult(options.delivery, result) : {
|
|
5057
|
+
...options.delivery,
|
|
5058
|
+
deliveryAttempts: (options.delivery.deliveryAttempts ?? 0) + 1,
|
|
5059
|
+
deliveryStatus: "skipped",
|
|
5060
|
+
updatedAt: Date.now()
|
|
5061
|
+
};
|
|
5062
|
+
};
|
|
5063
|
+
var createVoiceMemoryHandoffDeliveryStore = () => {
|
|
5064
|
+
const deliveries = new Map;
|
|
5065
|
+
return {
|
|
5066
|
+
get: async (id) => deliveries.get(id),
|
|
5067
|
+
list: async () => [...deliveries.values()].sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)),
|
|
5068
|
+
remove: async (id) => {
|
|
5069
|
+
deliveries.delete(id);
|
|
5070
|
+
},
|
|
5071
|
+
set: async (id, delivery) => {
|
|
5072
|
+
deliveries.set(id, delivery);
|
|
5073
|
+
}
|
|
5074
|
+
};
|
|
5075
|
+
};
|
|
5076
|
+
var createVoiceWebhookHandoffAdapter = (options) => ({
|
|
5077
|
+
actions: options.actions,
|
|
5078
|
+
handoff: async (input) => {
|
|
5079
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
5080
|
+
if (typeof fetchImpl !== "function") {
|
|
5081
|
+
return {
|
|
5082
|
+
deliveredTo: options.url,
|
|
5083
|
+
error: "Handoff delivery failed: fetch is not available in this runtime.",
|
|
5084
|
+
status: "failed"
|
|
5085
|
+
};
|
|
5086
|
+
}
|
|
5087
|
+
const body = JSON.stringify(await options.body?.(input) ?? defaultWebhookBody(input));
|
|
5088
|
+
const headers = {
|
|
5089
|
+
"content-type": "application/json",
|
|
5090
|
+
...options.headers
|
|
5091
|
+
};
|
|
5092
|
+
if (options.signingSecret) {
|
|
5093
|
+
const timestamp = String(Date.now());
|
|
5094
|
+
headers["x-absolutejs-timestamp"] = timestamp;
|
|
5095
|
+
headers["x-absolutejs-signature"] = await signHandoffBody({
|
|
5096
|
+
body,
|
|
5097
|
+
secret: options.signingSecret,
|
|
5098
|
+
timestamp
|
|
5099
|
+
});
|
|
5100
|
+
}
|
|
5101
|
+
const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
|
|
5102
|
+
const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
|
|
5103
|
+
try {
|
|
5104
|
+
const response = await fetchImpl(options.url, {
|
|
5105
|
+
body,
|
|
5106
|
+
headers,
|
|
5107
|
+
method: options.method ?? "POST",
|
|
5108
|
+
signal: controller?.signal
|
|
5109
|
+
});
|
|
5110
|
+
if (!response.ok) {
|
|
5111
|
+
return {
|
|
5112
|
+
deliveredTo: options.url,
|
|
5113
|
+
error: `Handoff delivery failed with response ${response.status}.`,
|
|
5114
|
+
status: "failed"
|
|
5115
|
+
};
|
|
5116
|
+
}
|
|
5117
|
+
return {
|
|
5118
|
+
deliveredAt: Date.now(),
|
|
5119
|
+
deliveredTo: options.url,
|
|
5120
|
+
status: "delivered"
|
|
5121
|
+
};
|
|
5122
|
+
} finally {
|
|
5123
|
+
if (timeout) {
|
|
5124
|
+
clearTimeout(timeout);
|
|
5125
|
+
}
|
|
5126
|
+
}
|
|
5127
|
+
},
|
|
5128
|
+
id: options.id,
|
|
5129
|
+
kind: options.kind ?? "webhook"
|
|
5130
|
+
});
|
|
5131
|
+
var escapeXml = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
5132
|
+
var defaultTwilioTransferTwiML = (input) => {
|
|
5133
|
+
if (!input.target) {
|
|
5134
|
+
return "<Response><Hangup /></Response>";
|
|
5135
|
+
}
|
|
5136
|
+
return `<Response><Dial>${escapeXml(input.target)}</Dial></Response>`;
|
|
5137
|
+
};
|
|
5138
|
+
var resolveTwilioCallSid = async (resolver, input) => {
|
|
5139
|
+
if (typeof resolver === "function") {
|
|
5140
|
+
return resolver(input);
|
|
5141
|
+
}
|
|
5142
|
+
if (typeof resolver === "string" && resolver.length > 0) {
|
|
5143
|
+
return resolver;
|
|
5144
|
+
}
|
|
5145
|
+
const metadataSid = typeof input.metadata?.callSid === "string" ? input.metadata.callSid : undefined;
|
|
5146
|
+
const sessionMetadata = input.session.metadata && typeof input.session.metadata === "object" ? input.session.metadata : undefined;
|
|
5147
|
+
const sessionSid = typeof sessionMetadata?.callSid === "string" ? sessionMetadata.callSid : undefined;
|
|
5148
|
+
return metadataSid ?? sessionSid;
|
|
5149
|
+
};
|
|
5150
|
+
var createVoiceTwilioRedirectHandoffAdapter = (options) => ({
|
|
5151
|
+
actions: options.actions ?? ["transfer"],
|
|
5152
|
+
handoff: async (input) => {
|
|
5153
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
5154
|
+
const callSid = await resolveTwilioCallSid(options.callSid, input);
|
|
5155
|
+
if (!callSid) {
|
|
5156
|
+
return {
|
|
5157
|
+
error: "Twilio handoff requires a callSid.",
|
|
5158
|
+
status: "failed"
|
|
5159
|
+
};
|
|
5160
|
+
}
|
|
5161
|
+
if (typeof fetchImpl !== "function") {
|
|
5162
|
+
return {
|
|
5163
|
+
error: "Twilio handoff failed: fetch is not available in this runtime.",
|
|
5164
|
+
status: "failed"
|
|
5165
|
+
};
|
|
5166
|
+
}
|
|
5167
|
+
const url = `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(options.accountSid)}/Calls/${encodeURIComponent(callSid)}.json`;
|
|
5168
|
+
const body = new URLSearchParams({
|
|
5169
|
+
Twiml: await (options.buildTwiML?.(input) ?? defaultTwilioTransferTwiML(input))
|
|
5170
|
+
});
|
|
5171
|
+
const auth = btoa(`${options.accountSid}:${options.authToken}`);
|
|
5172
|
+
const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
|
|
5173
|
+
const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
|
|
5174
|
+
try {
|
|
5175
|
+
const response = await fetchImpl(url, {
|
|
5176
|
+
body,
|
|
5177
|
+
headers: {
|
|
5178
|
+
authorization: `Basic ${auth}`,
|
|
5179
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
5180
|
+
},
|
|
5181
|
+
method: "POST",
|
|
5182
|
+
signal: controller?.signal
|
|
5183
|
+
});
|
|
5184
|
+
if (!response.ok) {
|
|
5185
|
+
return {
|
|
5186
|
+
deliveredTo: url,
|
|
5187
|
+
error: `Twilio handoff failed with response ${response.status}.`,
|
|
5188
|
+
status: "failed"
|
|
5189
|
+
};
|
|
5190
|
+
}
|
|
5191
|
+
return {
|
|
5192
|
+
deliveredAt: Date.now(),
|
|
5193
|
+
deliveredTo: url,
|
|
5194
|
+
metadata: {
|
|
5195
|
+
callSid
|
|
5196
|
+
},
|
|
5197
|
+
status: "delivered"
|
|
5198
|
+
};
|
|
5199
|
+
} finally {
|
|
5200
|
+
if (timeout) {
|
|
5201
|
+
clearTimeout(timeout);
|
|
5202
|
+
}
|
|
5203
|
+
}
|
|
5204
|
+
},
|
|
5205
|
+
id: options.id ?? "twilio-redirect",
|
|
5206
|
+
kind: "twilio-redirect"
|
|
5207
|
+
});
|
|
5208
|
+
|
|
5209
|
+
// src/logger.ts
|
|
5210
|
+
var noop2 = () => {};
|
|
5211
|
+
var createNoopLogger = () => ({
|
|
5212
|
+
debug: noop2,
|
|
5213
|
+
error: noop2,
|
|
5214
|
+
info: noop2,
|
|
5215
|
+
warn: noop2
|
|
5216
|
+
});
|
|
5217
|
+
var resolveLogger = (logger) => ({
|
|
5218
|
+
...createNoopLogger(),
|
|
5219
|
+
...logger
|
|
5220
|
+
});
|
|
5221
|
+
|
|
5222
|
+
// src/session.ts
|
|
5223
|
+
var DEFAULT_RECONNECT_TIMEOUT = 30000;
|
|
5224
|
+
var DEFAULT_MAX_RECONNECT_ATTEMPTS2 = 10;
|
|
5225
|
+
var DEFAULT_TRANSCRIPT_STABILITY_MS = 450;
|
|
5226
|
+
var DEFAULT_FALLBACK_REPLAY_MS = 8000;
|
|
5227
|
+
var DEFAULT_FALLBACK_SETTLE_MS = 220;
|
|
5228
|
+
var DEFAULT_FALLBACK_COMPLETION_TIMEOUT_MS = 2500;
|
|
5229
|
+
var DEFAULT_FALLBACK_CONFIDENCE_THRESHOLD = 0.6;
|
|
5230
|
+
var DEFAULT_FALLBACK_MIN_TEXT_LENGTH = 2;
|
|
5231
|
+
var DEFAULT_FALLBACK_MAX_ATTEMPTS_PER_TURN = 1;
|
|
5232
|
+
var DEFAULT_DUPLICATE_TURN_WINDOW_MS = 5000;
|
|
5233
|
+
var FALLBACK_CONFIDENCE_SELECTION_DELTA = 0.05;
|
|
5234
|
+
var FALLBACK_WORD_COUNT_SELECTION_MARGIN_RATIO = 0.12;
|
|
5235
|
+
var EXTENDED_VENDOR_COMMIT_SILENCE_THRESHOLD_MS = 200;
|
|
5236
|
+
var MAX_VENDOR_COMMIT_GRACE_MS = 1200;
|
|
5237
|
+
var DEFAULT_FORMAT = {
|
|
5238
|
+
channels: 1,
|
|
5239
|
+
container: "raw",
|
|
5240
|
+
encoding: "pcm_s16le",
|
|
5241
|
+
sampleRateHz: 16000
|
|
5242
|
+
};
|
|
5243
|
+
var DEFAULT_REALTIME_FORMAT = {
|
|
5244
|
+
channels: 1,
|
|
5245
|
+
container: "raw",
|
|
5246
|
+
encoding: "pcm_s16le",
|
|
5247
|
+
sampleRateHz: 24000
|
|
5248
|
+
};
|
|
5249
|
+
var toError = (value) => value instanceof Error ? value : new Error(String(value));
|
|
5250
|
+
var createEmptyCurrentTurn = () => ({
|
|
5251
|
+
finalText: "",
|
|
5252
|
+
lastSpeechAt: undefined,
|
|
5253
|
+
lastTranscriptAt: undefined,
|
|
5254
|
+
partialEndedAt: undefined,
|
|
5255
|
+
partialStartedAt: undefined,
|
|
5256
|
+
partialText: "",
|
|
5257
|
+
silenceStartedAt: undefined,
|
|
5258
|
+
transcripts: []
|
|
5259
|
+
});
|
|
5260
|
+
var cloneTranscript = (transcript) => ({ ...transcript });
|
|
5261
|
+
var encodeBase64 = (chunk) => Buffer2.from(chunk).toString("base64");
|
|
5262
|
+
var countWords2 = (text) => text.trim().split(/\s+/).filter(Boolean).length;
|
|
5263
|
+
var normalizeText2 = (text) => text.trim().replace(/\s+/g, " ");
|
|
5264
|
+
var getAudioChunkDurationMs = (chunk) => chunk.byteLength / (DEFAULT_FORMAT.sampleRateHz * DEFAULT_FORMAT.channels * 2) * 1000;
|
|
5265
|
+
var getBufferedAudioDurationMs = (chunks) => chunks.reduce((total, chunk) => total + getAudioChunkDurationMs(chunk), 0);
|
|
5266
|
+
var calculateMeanConfidence = (transcripts) => {
|
|
5267
|
+
let sum = 0;
|
|
5268
|
+
let total = 0;
|
|
5269
|
+
for (const transcript of transcripts) {
|
|
5270
|
+
if (typeof transcript.confidence === "number") {
|
|
5271
|
+
sum += transcript.confidence;
|
|
5272
|
+
total += 1;
|
|
5273
|
+
}
|
|
5274
|
+
}
|
|
5275
|
+
if (total === 0) {
|
|
5276
|
+
return 0;
|
|
5277
|
+
}
|
|
5278
|
+
return sum / total;
|
|
5279
|
+
};
|
|
5280
|
+
var createTurnQuality = (transcripts, source, fallbackUsed, fallbackDiagnostics, correctionDiagnostics, costEstimate) => {
|
|
5281
|
+
const sampledTranscripts = transcripts.filter((transcript) => typeof transcript.confidence === "number");
|
|
5282
|
+
const confidenceSampleCount = sampledTranscripts.length;
|
|
5283
|
+
return {
|
|
5284
|
+
averageConfidence: confidenceSampleCount > 0 ? sampledTranscripts.reduce((sum, transcript) => sum + transcript.confidence, 0) / confidenceSampleCount : undefined,
|
|
5285
|
+
confidenceSampleCount,
|
|
5286
|
+
correction: correctionDiagnostics,
|
|
5287
|
+
cost: costEstimate,
|
|
5288
|
+
fallback: fallbackDiagnostics,
|
|
5289
|
+
fallbackUsed,
|
|
5290
|
+
finalTranscriptCount: transcripts.filter((transcript) => transcript.isFinal).length,
|
|
5291
|
+
partialTranscriptCount: transcripts.filter((transcript) => !transcript.isFinal).length,
|
|
5292
|
+
selectedTranscriptCount: transcripts.length,
|
|
5293
|
+
source
|
|
5294
|
+
};
|
|
5295
|
+
};
|
|
5296
|
+
var createTurnCostEstimate = (input) => {
|
|
5297
|
+
const primaryMinutes = Math.max(0, input.primaryAudioMs) / 60000;
|
|
5298
|
+
const fallbackMinutes = Math.max(0, input.fallbackReplayAudioMs) / 60000;
|
|
5299
|
+
const primaryCostUnit = input.primaryPassCostUnit ?? 1;
|
|
5300
|
+
const fallbackCostUnit = input.fallbackPassCostUnit ?? primaryCostUnit;
|
|
5301
|
+
return {
|
|
5302
|
+
estimatedRelativeCostUnits: primaryMinutes * primaryCostUnit + fallbackMinutes * fallbackCostUnit,
|
|
5303
|
+
fallbackAttemptCount: input.fallbackAttemptCount,
|
|
5304
|
+
fallbackReplayAudioMs: Math.max(0, input.fallbackReplayAudioMs),
|
|
5305
|
+
primaryAudioMs: Math.max(0, input.primaryAudioMs),
|
|
5306
|
+
totalBillableAudioMs: Math.max(0, input.primaryAudioMs) + Math.max(0, input.fallbackReplayAudioMs)
|
|
5307
|
+
};
|
|
5308
|
+
};
|
|
5309
|
+
var normalizeCorrectionText = (text) => normalizeText2(text);
|
|
3631
5310
|
var isFallbackNeeded = (candidate, config) => {
|
|
3632
5311
|
const trimmed = normalizeText2(candidate.text);
|
|
3633
5312
|
const wordCount = countWords2(trimmed);
|
|
@@ -3725,6 +5404,7 @@ var pushCallLifecycleEvent = (session, input) => {
|
|
|
3725
5404
|
}
|
|
3726
5405
|
return lifecycle;
|
|
3727
5406
|
};
|
|
5407
|
+
var getLatestCallLifecycleEvent = (session) => session.call?.events.at(-1);
|
|
3728
5408
|
var createVoiceSession = (options) => {
|
|
3729
5409
|
const logger = resolveLogger(options.logger);
|
|
3730
5410
|
const reconnect = {
|
|
@@ -3749,7 +5429,7 @@ var createVoiceSession = (options) => {
|
|
|
3749
5429
|
} : undefined;
|
|
3750
5430
|
const appendTrace = async (input) => {
|
|
3751
5431
|
await options.trace?.append({
|
|
3752
|
-
at: Date.now(),
|
|
5432
|
+
at: input.at ?? Date.now(),
|
|
3753
5433
|
metadata: input.metadata,
|
|
3754
5434
|
payload: input.payload,
|
|
3755
5435
|
scenarioId: input.session?.scenarioId ?? options.scenarioId,
|
|
@@ -3758,6 +5438,13 @@ var createVoiceSession = (options) => {
|
|
|
3758
5438
|
type: input.type
|
|
3759
5439
|
});
|
|
3760
5440
|
};
|
|
5441
|
+
const appendTurnLatencyStage = async (input) => appendTrace({
|
|
5442
|
+
at: input.at,
|
|
5443
|
+
payload: { stage: input.stage },
|
|
5444
|
+
session: input.session,
|
|
5445
|
+
turnId: input.turnId,
|
|
5446
|
+
type: "turn_latency.stage"
|
|
5447
|
+
});
|
|
3761
5448
|
const phraseHints = options.phraseHints ?? [];
|
|
3762
5449
|
const lexicon = options.lexicon ?? [];
|
|
3763
5450
|
let socket = options.socket;
|
|
@@ -3825,6 +5512,76 @@ var createVoiceSession = (options) => {
|
|
|
3825
5512
|
});
|
|
3826
5513
|
}
|
|
3827
5514
|
};
|
|
5515
|
+
const sendCallLifecycle = async (session) => {
|
|
5516
|
+
const event = getLatestCallLifecycleEvent(session);
|
|
5517
|
+
if (!event) {
|
|
5518
|
+
return;
|
|
5519
|
+
}
|
|
5520
|
+
await send({
|
|
5521
|
+
event,
|
|
5522
|
+
sessionId: options.id,
|
|
5523
|
+
type: "call_lifecycle"
|
|
5524
|
+
});
|
|
5525
|
+
};
|
|
5526
|
+
const sendReplay = async (session) => {
|
|
5527
|
+
await send({
|
|
5528
|
+
assistantTexts: session.turns.flatMap((turn) => turn.assistantText ? [turn.assistantText] : []),
|
|
5529
|
+
call: session.call,
|
|
5530
|
+
partial: session.currentTurn.partialText,
|
|
5531
|
+
scenarioId: session.scenarioId,
|
|
5532
|
+
sessionId: options.id,
|
|
5533
|
+
status: session.status,
|
|
5534
|
+
turns: session.turns,
|
|
5535
|
+
type: "replay"
|
|
5536
|
+
});
|
|
5537
|
+
};
|
|
5538
|
+
const runHandoff = async (input) => {
|
|
5539
|
+
const queuedDelivery = options.handoff?.deliveryQueue ? createVoiceHandoffDeliveryRecord({
|
|
5540
|
+
action: input.action,
|
|
5541
|
+
context: options.context,
|
|
5542
|
+
metadata: input.metadata,
|
|
5543
|
+
reason: input.reason,
|
|
5544
|
+
result: input.result,
|
|
5545
|
+
session: input.session,
|
|
5546
|
+
target: input.target
|
|
5547
|
+
}) : undefined;
|
|
5548
|
+
if (queuedDelivery) {
|
|
5549
|
+
await options.handoff?.deliveryQueue?.set(queuedDelivery.id, queuedDelivery);
|
|
5550
|
+
}
|
|
5551
|
+
if (options.handoff?.enqueueOnly) {
|
|
5552
|
+
return;
|
|
5553
|
+
}
|
|
5554
|
+
const result = await deliverVoiceHandoff({
|
|
5555
|
+
config: options.handoff,
|
|
5556
|
+
handoff: {
|
|
5557
|
+
action: input.action,
|
|
5558
|
+
api,
|
|
5559
|
+
context: options.context,
|
|
5560
|
+
metadata: input.metadata,
|
|
5561
|
+
reason: input.reason,
|
|
5562
|
+
result: input.result,
|
|
5563
|
+
session: input.session,
|
|
5564
|
+
target: input.target
|
|
5565
|
+
}
|
|
5566
|
+
});
|
|
5567
|
+
if (!result) {
|
|
5568
|
+
return;
|
|
5569
|
+
}
|
|
5570
|
+
if (queuedDelivery) {
|
|
5571
|
+
const updatedDelivery = applyVoiceHandoffDeliveryResult(queuedDelivery, result);
|
|
5572
|
+
await options.handoff?.deliveryQueue?.set(updatedDelivery.id, updatedDelivery);
|
|
5573
|
+
}
|
|
5574
|
+
await appendTrace({
|
|
5575
|
+
metadata: input.metadata,
|
|
5576
|
+
payload: {
|
|
5577
|
+
...result,
|
|
5578
|
+
reason: input.reason,
|
|
5579
|
+
target: input.target
|
|
5580
|
+
},
|
|
5581
|
+
session: input.session,
|
|
5582
|
+
type: "call.handoff"
|
|
5583
|
+
});
|
|
5584
|
+
};
|
|
3828
5585
|
const readSession = async () => options.store.getOrCreate(options.id);
|
|
3829
5586
|
const writeSession = async (mutate) => {
|
|
3830
5587
|
const session = await options.store.getOrCreate(options.id);
|
|
@@ -3881,6 +5638,23 @@ var createVoiceSession = (options) => {
|
|
|
3881
5638
|
});
|
|
3882
5639
|
}
|
|
3883
5640
|
};
|
|
5641
|
+
const sendAssistantAudio = async (chunk, input) => {
|
|
5642
|
+
const normalizedChunk = chunk instanceof Uint8Array ? new Uint8Array(chunk) : chunk instanceof ArrayBuffer ? new Uint8Array(chunk.slice(0)) : new Uint8Array(chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength));
|
|
5643
|
+
await send({
|
|
5644
|
+
chunkBase64: encodeBase64(normalizedChunk),
|
|
5645
|
+
format: input.format,
|
|
5646
|
+
receivedAt: input.receivedAt,
|
|
5647
|
+
turnId: activeTTSTurnId,
|
|
5648
|
+
type: "audio"
|
|
5649
|
+
});
|
|
5650
|
+
if (activeTTSTurnId) {
|
|
5651
|
+
await appendTurnLatencyStage({
|
|
5652
|
+
at: input.receivedAt,
|
|
5653
|
+
stage: "assistant_audio_received",
|
|
5654
|
+
turnId: activeTTSTurnId
|
|
5655
|
+
});
|
|
5656
|
+
}
|
|
5657
|
+
};
|
|
3884
5658
|
const scheduleTurnCommit = (delayMs, reason, reset = true) => {
|
|
3885
5659
|
if (!reset && silenceTimer) {
|
|
3886
5660
|
return;
|
|
@@ -4015,6 +5789,7 @@ var createVoiceSession = (options) => {
|
|
|
4015
5789
|
await appendTrace({
|
|
4016
5790
|
payload: {
|
|
4017
5791
|
disposition,
|
|
5792
|
+
metadata: input.metadata,
|
|
4018
5793
|
reason: input.reason,
|
|
4019
5794
|
target: input.target,
|
|
4020
5795
|
type: "end"
|
|
@@ -4022,6 +5797,7 @@ var createVoiceSession = (options) => {
|
|
|
4022
5797
|
session,
|
|
4023
5798
|
type: "call.lifecycle"
|
|
4024
5799
|
});
|
|
5800
|
+
await sendCallLifecycle(session);
|
|
4025
5801
|
await send({
|
|
4026
5802
|
sessionId: options.id,
|
|
4027
5803
|
type: "complete"
|
|
@@ -4101,6 +5877,15 @@ var createVoiceSession = (options) => {
|
|
|
4101
5877
|
session,
|
|
4102
5878
|
type: "call.lifecycle"
|
|
4103
5879
|
});
|
|
5880
|
+
await sendCallLifecycle(session);
|
|
5881
|
+
await runHandoff({
|
|
5882
|
+
action: "transfer",
|
|
5883
|
+
metadata: input.metadata,
|
|
5884
|
+
reason: input.reason,
|
|
5885
|
+
result: input.result,
|
|
5886
|
+
session,
|
|
5887
|
+
target: input.target
|
|
5888
|
+
});
|
|
4104
5889
|
await completeInternal(input.result, {
|
|
4105
5890
|
disposition: "transferred",
|
|
4106
5891
|
invokeOnComplete: false,
|
|
@@ -4126,6 +5911,14 @@ var createVoiceSession = (options) => {
|
|
|
4126
5911
|
session,
|
|
4127
5912
|
type: "call.lifecycle"
|
|
4128
5913
|
});
|
|
5914
|
+
await sendCallLifecycle(session);
|
|
5915
|
+
await runHandoff({
|
|
5916
|
+
action: "escalate",
|
|
5917
|
+
metadata: input.metadata,
|
|
5918
|
+
reason: input.reason,
|
|
5919
|
+
result: input.result,
|
|
5920
|
+
session
|
|
5921
|
+
});
|
|
4129
5922
|
await completeInternal(input.result, {
|
|
4130
5923
|
disposition: "escalated",
|
|
4131
5924
|
invokeOnComplete: false,
|
|
@@ -4148,6 +5941,13 @@ var createVoiceSession = (options) => {
|
|
|
4148
5941
|
session,
|
|
4149
5942
|
type: "call.lifecycle"
|
|
4150
5943
|
});
|
|
5944
|
+
await sendCallLifecycle(session);
|
|
5945
|
+
await runHandoff({
|
|
5946
|
+
action: "no-answer",
|
|
5947
|
+
metadata: input?.metadata,
|
|
5948
|
+
result: input?.result,
|
|
5949
|
+
session
|
|
5950
|
+
});
|
|
4151
5951
|
await completeInternal(input?.result, {
|
|
4152
5952
|
disposition: "no-answer",
|
|
4153
5953
|
invokeOnComplete: false,
|
|
@@ -4169,6 +5969,13 @@ var createVoiceSession = (options) => {
|
|
|
4169
5969
|
session,
|
|
4170
5970
|
type: "call.lifecycle"
|
|
4171
5971
|
});
|
|
5972
|
+
await sendCallLifecycle(session);
|
|
5973
|
+
await runHandoff({
|
|
5974
|
+
action: "voicemail",
|
|
5975
|
+
metadata: input?.metadata,
|
|
5976
|
+
result: input?.result,
|
|
5977
|
+
session
|
|
5978
|
+
});
|
|
4172
5979
|
await completeInternal(input?.result, {
|
|
4173
5980
|
disposition: "voicemail",
|
|
4174
5981
|
invokeOnComplete: false,
|
|
@@ -4549,8 +6356,12 @@ var createVoiceSession = (options) => {
|
|
|
4549
6356
|
if (sttSession) {
|
|
4550
6357
|
return sttSession;
|
|
4551
6358
|
}
|
|
4552
|
-
const
|
|
4553
|
-
|
|
6359
|
+
const inputAdapter = options.realtime ?? options.stt;
|
|
6360
|
+
if (!inputAdapter) {
|
|
6361
|
+
throw new Error("Voice session requires either an stt or realtime adapter.");
|
|
6362
|
+
}
|
|
6363
|
+
const openedSession = await inputAdapter.open({
|
|
6364
|
+
format: options.realtime ? options.realtimeInputFormat ?? DEFAULT_REALTIME_FORMAT : DEFAULT_FORMAT,
|
|
4554
6365
|
languageStrategy: options.languageStrategy,
|
|
4555
6366
|
lexicon,
|
|
4556
6367
|
phraseHints,
|
|
@@ -4585,6 +6396,16 @@ var createVoiceSession = (options) => {
|
|
|
4585
6396
|
openedSession.on("close", (event) => {
|
|
4586
6397
|
runAdapterEvent("adapter.close", () => handleClose(event));
|
|
4587
6398
|
});
|
|
6399
|
+
if (options.realtime) {
|
|
6400
|
+
openedSession.on("audio", ({ chunk, format, receivedAt }) => {
|
|
6401
|
+
runAdapterEvent("adapter.audio", async () => {
|
|
6402
|
+
await sendAssistantAudio(chunk, {
|
|
6403
|
+
format,
|
|
6404
|
+
receivedAt
|
|
6405
|
+
});
|
|
6406
|
+
});
|
|
6407
|
+
});
|
|
6408
|
+
}
|
|
4588
6409
|
return openedSession;
|
|
4589
6410
|
};
|
|
4590
6411
|
const ensureTTSSession = async () => {
|
|
@@ -4609,13 +6430,9 @@ var createVoiceSession = (options) => {
|
|
|
4609
6430
|
if (ttsSession !== openedSession) {
|
|
4610
6431
|
return;
|
|
4611
6432
|
}
|
|
4612
|
-
|
|
4613
|
-
await send({
|
|
4614
|
-
chunkBase64: encodeBase64(normalizedChunk),
|
|
6433
|
+
await sendAssistantAudio(chunk, {
|
|
4615
6434
|
format,
|
|
4616
|
-
receivedAt
|
|
4617
|
-
turnId: activeTTSTurnId,
|
|
4618
|
-
type: "audio"
|
|
6435
|
+
receivedAt
|
|
4619
6436
|
});
|
|
4620
6437
|
});
|
|
4621
6438
|
});
|
|
@@ -4659,9 +6476,32 @@ var createVoiceSession = (options) => {
|
|
|
4659
6476
|
});
|
|
4660
6477
|
};
|
|
4661
6478
|
const completeTurn = async (session, turn) => {
|
|
6479
|
+
const liveOpsControl = await options.liveOps?.getControl(options.id);
|
|
6480
|
+
if (liveOpsControl?.assistantPaused || liveOpsControl?.operatorTakeover) {
|
|
6481
|
+
await appendTrace({
|
|
6482
|
+
metadata: {
|
|
6483
|
+
source: "voice-live-ops"
|
|
6484
|
+
},
|
|
6485
|
+
payload: {
|
|
6486
|
+
action: "turn.skipped",
|
|
6487
|
+
control: liveOpsControl,
|
|
6488
|
+
reason: liveOpsControl.operatorTakeover ? "operator-takeover" : "assistant-paused",
|
|
6489
|
+
status: "skipped"
|
|
6490
|
+
},
|
|
6491
|
+
session,
|
|
6492
|
+
turnId: turn.id,
|
|
6493
|
+
type: "operator.action"
|
|
6494
|
+
});
|
|
6495
|
+
return;
|
|
6496
|
+
}
|
|
6497
|
+
const injectedInstruction = liveOpsControl?.injectedInstruction?.trim();
|
|
4662
6498
|
const committedOutput = await options.route.onTurn({
|
|
4663
6499
|
api,
|
|
4664
6500
|
context: options.context,
|
|
6501
|
+
liveOps: liveOpsControl ? {
|
|
6502
|
+
control: liveOpsControl,
|
|
6503
|
+
injectedInstruction
|
|
6504
|
+
} : undefined,
|
|
4665
6505
|
session,
|
|
4666
6506
|
turn
|
|
4667
6507
|
});
|
|
@@ -4675,6 +6515,7 @@ var createVoiceSession = (options) => {
|
|
|
4675
6515
|
voicemail: committedOutput?.voicemail
|
|
4676
6516
|
};
|
|
4677
6517
|
if (output?.assistantText) {
|
|
6518
|
+
const assistantTextStartedAt = Date.now();
|
|
4678
6519
|
await writeSession((currentSession) => {
|
|
4679
6520
|
setTurnResult(currentSession, turn.id, {
|
|
4680
6521
|
assistantText: output.assistantText
|
|
@@ -4685,10 +6526,17 @@ var createVoiceSession = (options) => {
|
|
|
4685
6526
|
turnId: turn.id,
|
|
4686
6527
|
type: "assistant"
|
|
4687
6528
|
});
|
|
6529
|
+
await appendTurnLatencyStage({
|
|
6530
|
+
at: assistantTextStartedAt,
|
|
6531
|
+
session,
|
|
6532
|
+
stage: "assistant_text_started",
|
|
6533
|
+
turnId: turn.id
|
|
6534
|
+
});
|
|
4688
6535
|
await appendTrace({
|
|
4689
6536
|
payload: {
|
|
4690
6537
|
text: output.assistantText,
|
|
4691
|
-
ttsConfigured: Boolean(options.tts)
|
|
6538
|
+
ttsConfigured: Boolean(options.tts),
|
|
6539
|
+
realtimeConfigured: Boolean(options.realtime)
|
|
4692
6540
|
},
|
|
4693
6541
|
session,
|
|
4694
6542
|
turnId: turn.id,
|
|
@@ -4699,7 +6547,18 @@ var createVoiceSession = (options) => {
|
|
|
4699
6547
|
if (activeTTSSession) {
|
|
4700
6548
|
const ttsStartedAt = Date.now();
|
|
4701
6549
|
activeTTSTurnId = turn.id;
|
|
6550
|
+
await appendTurnLatencyStage({
|
|
6551
|
+
at: ttsStartedAt,
|
|
6552
|
+
session,
|
|
6553
|
+
stage: "tts_send_started",
|
|
6554
|
+
turnId: turn.id
|
|
6555
|
+
});
|
|
4702
6556
|
await activeTTSSession.send(output.assistantText);
|
|
6557
|
+
await appendTurnLatencyStage({
|
|
6558
|
+
session,
|
|
6559
|
+
stage: "tts_send_completed",
|
|
6560
|
+
turnId: turn.id
|
|
6561
|
+
});
|
|
4703
6562
|
await appendTrace({
|
|
4704
6563
|
payload: {
|
|
4705
6564
|
elapsedMs: Date.now() - ttsStartedAt,
|
|
@@ -4709,9 +6568,35 @@ var createVoiceSession = (options) => {
|
|
|
4709
6568
|
turnId: turn.id,
|
|
4710
6569
|
type: "turn.assistant"
|
|
4711
6570
|
});
|
|
6571
|
+
} else if (options.realtime) {
|
|
6572
|
+
const activeRealtimeSession = await ensureAdapter();
|
|
6573
|
+
const realtimeStartedAt = Date.now();
|
|
6574
|
+
activeTTSTurnId = turn.id;
|
|
6575
|
+
await appendTurnLatencyStage({
|
|
6576
|
+
at: realtimeStartedAt,
|
|
6577
|
+
session,
|
|
6578
|
+
stage: "tts_send_started",
|
|
6579
|
+
turnId: turn.id
|
|
6580
|
+
});
|
|
6581
|
+
await activeRealtimeSession.send(output.assistantText);
|
|
6582
|
+
await appendTurnLatencyStage({
|
|
6583
|
+
session,
|
|
6584
|
+
stage: "tts_send_completed",
|
|
6585
|
+
turnId: turn.id
|
|
6586
|
+
});
|
|
6587
|
+
await appendTrace({
|
|
6588
|
+
payload: {
|
|
6589
|
+
elapsedMs: Date.now() - realtimeStartedAt,
|
|
6590
|
+
mode: "realtime",
|
|
6591
|
+
status: "sent"
|
|
6592
|
+
},
|
|
6593
|
+
session,
|
|
6594
|
+
turnId: turn.id,
|
|
6595
|
+
type: "turn.assistant"
|
|
6596
|
+
});
|
|
4712
6597
|
}
|
|
4713
6598
|
} catch (error) {
|
|
4714
|
-
logger.warn("voice
|
|
6599
|
+
logger.warn("voice assistant audio send failed", {
|
|
4715
6600
|
error: toError(error).message,
|
|
4716
6601
|
sessionId: options.id,
|
|
4717
6602
|
turnId: turn.id
|
|
@@ -4719,7 +6604,7 @@ var createVoiceSession = (options) => {
|
|
|
4719
6604
|
await appendTrace({
|
|
4720
6605
|
payload: {
|
|
4721
6606
|
error: toError(error).message,
|
|
4722
|
-
status: "tts-send-failed"
|
|
6607
|
+
status: options.realtime ? "realtime-send-failed" : "tts-send-failed"
|
|
4723
6608
|
},
|
|
4724
6609
|
session,
|
|
4725
6610
|
turnId: turn.id,
|
|
@@ -4896,11 +6781,35 @@ var createVoiceSession = (options) => {
|
|
|
4896
6781
|
turnId: turn.id,
|
|
4897
6782
|
type: "turn.cost"
|
|
4898
6783
|
});
|
|
6784
|
+
const firstTranscriptAt = turn.transcripts.map((transcript) => transcript.endedAtMs ?? transcript.startedAtMs).filter((value) => typeof value === "number").sort((left, right) => left - right)[0];
|
|
6785
|
+
const finalTranscriptAt = turn.transcripts.filter((transcript) => transcript.isFinal).map((transcript) => transcript.endedAtMs ?? transcript.startedAtMs).filter((value) => typeof value === "number").sort((left, right) => left - right)[0];
|
|
6786
|
+
if (firstTranscriptAt !== undefined) {
|
|
6787
|
+
await appendTurnLatencyStage({
|
|
6788
|
+
at: firstTranscriptAt,
|
|
6789
|
+
session: updatedSession,
|
|
6790
|
+
stage: "speech_detected",
|
|
6791
|
+
turnId: turn.id
|
|
6792
|
+
});
|
|
6793
|
+
}
|
|
6794
|
+
if (finalTranscriptAt !== undefined) {
|
|
6795
|
+
await appendTurnLatencyStage({
|
|
6796
|
+
at: finalTranscriptAt,
|
|
6797
|
+
session: updatedSession,
|
|
6798
|
+
stage: "final_transcript",
|
|
6799
|
+
turnId: turn.id
|
|
6800
|
+
});
|
|
6801
|
+
}
|
|
6802
|
+
await appendTurnLatencyStage({
|
|
6803
|
+
at: turn.committedAt,
|
|
6804
|
+
session: updatedSession,
|
|
6805
|
+
stage: "turn_committed",
|
|
6806
|
+
turnId: turn.id
|
|
6807
|
+
});
|
|
4899
6808
|
await send({
|
|
4900
6809
|
turn,
|
|
4901
6810
|
type: "turn"
|
|
4902
6811
|
});
|
|
4903
|
-
if (options.sttLifecycle === "turn-scoped") {
|
|
6812
|
+
if (options.stt && options.sttLifecycle === "turn-scoped") {
|
|
4904
6813
|
await closeAdapter("turn-commit");
|
|
4905
6814
|
}
|
|
4906
6815
|
await completeTurn(updatedSession, turn);
|
|
@@ -4955,6 +6864,7 @@ var createVoiceSession = (options) => {
|
|
|
4955
6864
|
session,
|
|
4956
6865
|
type: "call.lifecycle"
|
|
4957
6866
|
});
|
|
6867
|
+
await sendCallLifecycle(session);
|
|
4958
6868
|
}
|
|
4959
6869
|
await send({
|
|
4960
6870
|
sessionId: options.id,
|
|
@@ -4962,6 +6872,7 @@ var createVoiceSession = (options) => {
|
|
|
4962
6872
|
scenarioId: session.scenarioId,
|
|
4963
6873
|
type: "session"
|
|
4964
6874
|
});
|
|
6875
|
+
await sendReplay(session);
|
|
4965
6876
|
if (shouldFireOnSession) {
|
|
4966
6877
|
await options.route.onCallStart?.({
|
|
4967
6878
|
api,
|
|
@@ -5545,7 +7456,7 @@ var createVoiceCallReviewFromLiveTelephonyReport = (report, options = {}) => {
|
|
|
5545
7456
|
}
|
|
5546
7457
|
};
|
|
5547
7458
|
};
|
|
5548
|
-
var
|
|
7459
|
+
var toErrorMessage2 = (error) => {
|
|
5549
7460
|
if (typeof error === "string" && error.trim().length > 0) {
|
|
5550
7461
|
return error;
|
|
5551
7462
|
}
|
|
@@ -5632,7 +7543,7 @@ var createVoiceCallReviewRecorder = (options = {}) => {
|
|
|
5632
7543
|
};
|
|
5633
7544
|
},
|
|
5634
7545
|
recordError: (error) => {
|
|
5635
|
-
const message =
|
|
7546
|
+
const message = toErrorMessage2(error);
|
|
5636
7547
|
errors.push(message);
|
|
5637
7548
|
push("turn", "error", {
|
|
5638
7549
|
reason: message
|
|
@@ -6338,10 +8249,981 @@ var runVoiceSessionBenchmarkSeries = async (input) => {
|
|
|
6338
8249
|
});
|
|
6339
8250
|
};
|
|
6340
8251
|
// src/telephony/twilio.ts
|
|
6341
|
-
import { Buffer as
|
|
8252
|
+
import { Buffer as Buffer3 } from "buffer";
|
|
8253
|
+
import { Elysia as Elysia2 } from "elysia";
|
|
8254
|
+
|
|
8255
|
+
// src/telephonyOutcome.ts
|
|
8256
|
+
import { Elysia } from "elysia";
|
|
8257
|
+
var DEFAULT_COMPLETED_STATUSES = [
|
|
8258
|
+
"answered",
|
|
8259
|
+
"completed",
|
|
8260
|
+
"complete",
|
|
8261
|
+
"connected",
|
|
8262
|
+
"in-progress",
|
|
8263
|
+
"live"
|
|
8264
|
+
];
|
|
8265
|
+
var DEFAULT_NO_ANSWER_STATUSES = [
|
|
8266
|
+
"busy",
|
|
8267
|
+
"canceled",
|
|
8268
|
+
"cancelled",
|
|
8269
|
+
"failed",
|
|
8270
|
+
"no-answer",
|
|
8271
|
+
"no_answer",
|
|
8272
|
+
"not-answered",
|
|
8273
|
+
"ring-no-answer",
|
|
8274
|
+
"timeout",
|
|
8275
|
+
"unanswered"
|
|
8276
|
+
];
|
|
8277
|
+
var DEFAULT_VOICEMAIL_STATUSES = [
|
|
8278
|
+
"answering-machine",
|
|
8279
|
+
"machine",
|
|
8280
|
+
"voicemail",
|
|
8281
|
+
"voice-mail"
|
|
8282
|
+
];
|
|
8283
|
+
var DEFAULT_TRANSFER_STATUSES = ["bridged", "forwarded", "transferred"];
|
|
8284
|
+
var DEFAULT_ESCALATION_STATUSES = ["escalated", "human-required", "operator"];
|
|
8285
|
+
var DEFAULT_FAILED_STATUSES = ["busy", "failed", "no-answer"];
|
|
8286
|
+
var DEFAULT_MACHINE_VOICEMAIL_VALUES = [
|
|
8287
|
+
"answering-machine",
|
|
8288
|
+
"fax",
|
|
8289
|
+
"machine",
|
|
8290
|
+
"machine-end-beep",
|
|
8291
|
+
"machine-end-other",
|
|
8292
|
+
"machine-start",
|
|
8293
|
+
"voicemail"
|
|
8294
|
+
];
|
|
8295
|
+
var DEFAULT_NO_ANSWER_SIP_CODES = [408, 480, 486, 487, 603];
|
|
8296
|
+
var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
8297
|
+
var uniqueSorted = (values) => Array.from(new Set(values)).sort();
|
|
8298
|
+
var findMissing = (values, required) => {
|
|
8299
|
+
if (!required?.length) {
|
|
8300
|
+
return [];
|
|
8301
|
+
}
|
|
8302
|
+
const valueSet = new Set(values);
|
|
8303
|
+
return required.filter((value) => !valueSet.has(value));
|
|
8304
|
+
};
|
|
8305
|
+
|
|
8306
|
+
class VoiceTelephonyWebhookVerificationError extends Error {
|
|
8307
|
+
result;
|
|
8308
|
+
constructor(result) {
|
|
8309
|
+
super(result.ok ? "telephony webhook verified" : result.reason);
|
|
8310
|
+
this.name = "VoiceTelephonyWebhookVerificationError";
|
|
8311
|
+
this.result = result;
|
|
8312
|
+
}
|
|
8313
|
+
}
|
|
8314
|
+
var createMemoryVoiceTelephonyWebhookIdempotencyStore = () => {
|
|
8315
|
+
const decisions = new Map;
|
|
8316
|
+
return {
|
|
8317
|
+
get: (key) => decisions.get(key),
|
|
8318
|
+
set: (key, decision) => {
|
|
8319
|
+
decisions.set(key, decision);
|
|
8320
|
+
}
|
|
8321
|
+
};
|
|
8322
|
+
};
|
|
8323
|
+
var isTelephonyWebhookProvider = (value) => value === "generic" || value === "plivo" || value === "telnyx" || value === "twilio";
|
|
8324
|
+
var isTelephonyOutcomeAction = (value) => value === "complete" || value === "escalate" || value === "ignore" || value === "no-answer" || value === "transfer" || value === "voicemail";
|
|
8325
|
+
var isCallDisposition = (value) => value === "completed" || value === "escalated" || value === "failed" || value === "no-answer" || value === "transferred" || value === "voicemail";
|
|
8326
|
+
var evaluateVoiceTelephonyWebhookNormalizationEvidence = (input = {}) => {
|
|
8327
|
+
const issues = [];
|
|
8328
|
+
const decisions = input.decisions ?? [];
|
|
8329
|
+
const verificationAttempts = input.verificationAttempts ?? [];
|
|
8330
|
+
const actions = uniqueSorted(decisions.map((decision) => decision.decision?.action ?? decision.action).filter(isTelephonyOutcomeAction));
|
|
8331
|
+
const dispositions = uniqueSorted(decisions.map((decision) => decision.decision?.disposition ?? decision.disposition).filter(isCallDisposition));
|
|
8332
|
+
const providers = uniqueSorted(decisions.map((decision) => decision.provider ?? decision.event?.provider).filter(isTelephonyWebhookProvider));
|
|
8333
|
+
const sources = uniqueSorted(decisions.map((decision) => decision.decision?.source ?? decision.source).filter((source) => typeof source === "string"));
|
|
8334
|
+
const applied = decisions.filter((decision) => decision.applied === true).length;
|
|
8335
|
+
const duplicateDecisions = decisions.filter((decision) => decision.duplicate === true);
|
|
8336
|
+
const duplicateProviders = uniqueSorted(duplicateDecisions.map((decision) => decision.provider ?? decision.event?.provider).filter(isTelephonyWebhookProvider));
|
|
8337
|
+
const duplicateIdempotencyKeys = new Set(duplicateDecisions.map((decision) => decision.idempotencyKey).filter((key) => typeof key === "string" && key.length > 0)).size;
|
|
8338
|
+
const duplicateCampaignOutcomesApplied = duplicateDecisions.filter((decision) => isRecord(decision.campaignOutcome) && decision.campaignOutcome.applied === true).length;
|
|
8339
|
+
const duplicateOutcomeReasons = uniqueSorted(duplicateDecisions.map((decision) => isRecord(decision.campaignOutcome) ? decision.campaignOutcome.reason : undefined).filter((reason) => typeof reason === "string"));
|
|
8340
|
+
const routeResults = decisions.filter((decision) => isRecord(decision.routeResult)).length;
|
|
8341
|
+
const missingSessionIds = decisions.filter((decision) => !decision.sessionId).length;
|
|
8342
|
+
const rejectedVerificationAttempts = verificationAttempts.filter((attempt) => attempt.rejected === true || attempt.status === 401 || attempt.verification?.ok === false && attempt.verification.reason === "invalid-signature");
|
|
8343
|
+
const rejectedVerificationProviders = uniqueSorted(rejectedVerificationAttempts.map((attempt) => attempt.provider).filter(isTelephonyWebhookProvider));
|
|
8344
|
+
const replayRejectedVerificationAttempts = rejectedVerificationAttempts.filter((attempt) => attempt.replayRejected === true);
|
|
8345
|
+
const replayRejectedVerificationProviders = uniqueSorted(replayRejectedVerificationAttempts.map((attempt) => attempt.provider).filter(isTelephonyWebhookProvider));
|
|
8346
|
+
const rejectedVerificationSideEffects = rejectedVerificationAttempts.reduce((total, attempt) => total + Math.max(0, attempt.sideEffects ?? 0), 0);
|
|
8347
|
+
if (input.minDecisions !== undefined && decisions.length < input.minDecisions) {
|
|
8348
|
+
issues.push(`Expected at least ${String(input.minDecisions)} telephony webhook decision(s), found ${String(decisions.length)}.`);
|
|
8349
|
+
}
|
|
8350
|
+
if (input.minApplied !== undefined && applied < input.minApplied) {
|
|
8351
|
+
issues.push(`Expected at least ${String(input.minApplied)} applied telephony webhook decision(s), found ${String(applied)}.`);
|
|
8352
|
+
}
|
|
8353
|
+
if (input.minDuplicates !== undefined && duplicateDecisions.length < input.minDuplicates) {
|
|
8354
|
+
issues.push(`Expected at least ${String(input.minDuplicates)} duplicate telephony webhook decision(s), found ${String(duplicateDecisions.length)}.`);
|
|
8355
|
+
}
|
|
8356
|
+
if (input.minDuplicateIdempotencyKeys !== undefined && duplicateIdempotencyKeys < input.minDuplicateIdempotencyKeys) {
|
|
8357
|
+
issues.push(`Expected at least ${String(input.minDuplicateIdempotencyKeys)} duplicate telephony webhook idempotency key(s), found ${String(duplicateIdempotencyKeys)}.`);
|
|
8358
|
+
}
|
|
8359
|
+
if (input.maxDuplicateCampaignOutcomesApplied !== undefined && duplicateCampaignOutcomesApplied > input.maxDuplicateCampaignOutcomesApplied) {
|
|
8360
|
+
issues.push(`Expected at most ${String(input.maxDuplicateCampaignOutcomesApplied)} duplicate telephony webhook campaign outcome application(s), found ${String(duplicateCampaignOutcomesApplied)}.`);
|
|
8361
|
+
}
|
|
8362
|
+
if (input.minRejectedVerificationAttempts !== undefined && rejectedVerificationAttempts.length < input.minRejectedVerificationAttempts) {
|
|
8363
|
+
issues.push(`Expected at least ${String(input.minRejectedVerificationAttempts)} rejected telephony webhook verification attempt(s), found ${String(rejectedVerificationAttempts.length)}.`);
|
|
8364
|
+
}
|
|
8365
|
+
if (input.maxRejectedVerificationSideEffects !== undefined && rejectedVerificationSideEffects > input.maxRejectedVerificationSideEffects) {
|
|
8366
|
+
issues.push(`Expected at most ${String(input.maxRejectedVerificationSideEffects)} rejected telephony webhook side effect(s), found ${String(rejectedVerificationSideEffects)}.`);
|
|
8367
|
+
}
|
|
8368
|
+
if (input.minReplayRejectedVerificationAttempts !== undefined && replayRejectedVerificationAttempts.length < input.minReplayRejectedVerificationAttempts) {
|
|
8369
|
+
issues.push(`Expected at least ${String(input.minReplayRejectedVerificationAttempts)} replay-rejected telephony webhook verification attempt(s), found ${String(replayRejectedVerificationAttempts.length)}.`);
|
|
8370
|
+
}
|
|
8371
|
+
if (input.maxMissingSessionIds !== undefined && missingSessionIds > input.maxMissingSessionIds) {
|
|
8372
|
+
issues.push(`Expected at most ${String(input.maxMissingSessionIds)} telephony webhook decision(s) without sessionId, found ${String(missingSessionIds)}.`);
|
|
8373
|
+
}
|
|
8374
|
+
if (input.requireRouteResults && routeResults < decisions.length) {
|
|
8375
|
+
issues.push(`Expected every telephony webhook decision to include a route result, found ${String(routeResults)} of ${String(decisions.length)}.`);
|
|
8376
|
+
}
|
|
8377
|
+
for (const provider of findMissing(providers, input.requiredProviders)) {
|
|
8378
|
+
issues.push(`Missing telephony webhook provider: ${provider}.`);
|
|
8379
|
+
}
|
|
8380
|
+
for (const provider of findMissing(duplicateProviders, input.requiredDuplicateProviders)) {
|
|
8381
|
+
issues.push(`Missing duplicate telephony webhook provider: ${provider}.`);
|
|
8382
|
+
}
|
|
8383
|
+
for (const provider of findMissing(rejectedVerificationProviders, input.requiredRejectedVerificationProviders)) {
|
|
8384
|
+
issues.push(`Missing rejected telephony webhook verification provider: ${provider}.`);
|
|
8385
|
+
}
|
|
8386
|
+
for (const provider of findMissing(replayRejectedVerificationProviders, input.requiredReplayRejectedVerificationProviders)) {
|
|
8387
|
+
issues.push(`Missing replay-rejected telephony webhook verification provider: ${provider}.`);
|
|
8388
|
+
}
|
|
8389
|
+
for (const action of findMissing(actions, input.requiredActions)) {
|
|
8390
|
+
issues.push(`Missing telephony webhook action: ${action}.`);
|
|
8391
|
+
}
|
|
8392
|
+
for (const disposition of findMissing(dispositions, input.requiredDispositions)) {
|
|
8393
|
+
issues.push(`Missing telephony webhook disposition: ${disposition}.`);
|
|
8394
|
+
}
|
|
8395
|
+
return {
|
|
8396
|
+
actions,
|
|
8397
|
+
applied,
|
|
8398
|
+
decisions: decisions.length,
|
|
8399
|
+
dispositions,
|
|
8400
|
+
duplicateCampaignOutcomesApplied,
|
|
8401
|
+
duplicateIdempotencyKeys,
|
|
8402
|
+
duplicateOutcomeReasons,
|
|
8403
|
+
duplicateProviders,
|
|
8404
|
+
duplicates: duplicateDecisions.length,
|
|
8405
|
+
issues,
|
|
8406
|
+
missingSessionIds,
|
|
8407
|
+
ok: issues.length === 0,
|
|
8408
|
+
providers,
|
|
8409
|
+
rejectedVerificationAttempts: rejectedVerificationAttempts.length,
|
|
8410
|
+
rejectedVerificationProviders,
|
|
8411
|
+
rejectedVerificationSideEffects,
|
|
8412
|
+
replayRejectedVerificationAttempts: replayRejectedVerificationAttempts.length,
|
|
8413
|
+
replayRejectedVerificationProviders,
|
|
8414
|
+
routeResults,
|
|
8415
|
+
sources,
|
|
8416
|
+
verificationAttempts: verificationAttempts.length
|
|
8417
|
+
};
|
|
8418
|
+
};
|
|
8419
|
+
var assertVoiceTelephonyWebhookNormalizationEvidence = (input = {}) => {
|
|
8420
|
+
const assertion = evaluateVoiceTelephonyWebhookNormalizationEvidence(input);
|
|
8421
|
+
if (!assertion.ok) {
|
|
8422
|
+
throw new Error(`Voice telephony webhook normalization evidence assertion failed: ${assertion.issues.join(" ")}`);
|
|
8423
|
+
}
|
|
8424
|
+
return assertion;
|
|
8425
|
+
};
|
|
8426
|
+
var normalizeToken = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/\s+/g, "-").replace(/_+/g, "-") : undefined;
|
|
8427
|
+
var firstString = (source, keys) => {
|
|
8428
|
+
for (const key of keys) {
|
|
8429
|
+
const value = source[key];
|
|
8430
|
+
if (typeof value === "string" && value.trim()) {
|
|
8431
|
+
return value.trim();
|
|
8432
|
+
}
|
|
8433
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
8434
|
+
return String(value);
|
|
8435
|
+
}
|
|
8436
|
+
}
|
|
8437
|
+
};
|
|
8438
|
+
var firstNumber = (source, keys) => {
|
|
8439
|
+
for (const key of keys) {
|
|
8440
|
+
const value = source[key];
|
|
8441
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
8442
|
+
return value;
|
|
8443
|
+
}
|
|
8444
|
+
if (typeof value === "string" && value.trim()) {
|
|
8445
|
+
const parsed = Number(value);
|
|
8446
|
+
if (Number.isFinite(parsed)) {
|
|
8447
|
+
return parsed;
|
|
8448
|
+
}
|
|
8449
|
+
}
|
|
8450
|
+
}
|
|
8451
|
+
};
|
|
8452
|
+
var parseMaybeJSON = (value) => {
|
|
8453
|
+
try {
|
|
8454
|
+
return JSON.parse(value);
|
|
8455
|
+
} catch {
|
|
8456
|
+
return;
|
|
8457
|
+
}
|
|
8458
|
+
};
|
|
8459
|
+
var flattenPayload = (value) => {
|
|
8460
|
+
if (!isRecord(value)) {
|
|
8461
|
+
return {};
|
|
8462
|
+
}
|
|
8463
|
+
const data = isRecord(value.data) ? value.data : undefined;
|
|
8464
|
+
const payload = isRecord(value.payload) ? value.payload : undefined;
|
|
8465
|
+
const event = isRecord(value.event) ? value.event : undefined;
|
|
8466
|
+
return {
|
|
8467
|
+
...value,
|
|
8468
|
+
...payload,
|
|
8469
|
+
...event,
|
|
8470
|
+
...data,
|
|
8471
|
+
...isRecord(data?.payload) ? data.payload : undefined
|
|
8472
|
+
};
|
|
8473
|
+
};
|
|
8474
|
+
var toBase64 = (bytes) => Buffer.from(new Uint8Array(bytes)).toString("base64");
|
|
8475
|
+
var timingSafeEqual = (left, right) => {
|
|
8476
|
+
const encoder = new TextEncoder;
|
|
8477
|
+
const leftBytes = encoder.encode(left);
|
|
8478
|
+
const rightBytes = encoder.encode(right);
|
|
8479
|
+
if (leftBytes.length !== rightBytes.length) {
|
|
8480
|
+
return false;
|
|
8481
|
+
}
|
|
8482
|
+
let diff = 0;
|
|
8483
|
+
for (let index = 0;index < leftBytes.length; index += 1) {
|
|
8484
|
+
diff |= leftBytes[index] ^ rightBytes[index];
|
|
8485
|
+
}
|
|
8486
|
+
return diff === 0;
|
|
8487
|
+
};
|
|
8488
|
+
var signHmacSHA1Base64 = async (secret, payload) => {
|
|
8489
|
+
const encoder = new TextEncoder;
|
|
8490
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
|
|
8491
|
+
hash: "SHA-1",
|
|
8492
|
+
name: "HMAC"
|
|
8493
|
+
}, false, ["sign"]);
|
|
8494
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
|
8495
|
+
return toBase64(signature);
|
|
8496
|
+
};
|
|
8497
|
+
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("");
|
|
8498
|
+
var normalizeList = (values, fallback) => new Set((values ?? fallback).map(normalizeToken).filter(Boolean));
|
|
8499
|
+
var metadataValue = (metadata, keys) => {
|
|
8500
|
+
for (const key of keys) {
|
|
8501
|
+
const value = metadata?.[key];
|
|
8502
|
+
if (typeof value === "string" && value.trim()) {
|
|
8503
|
+
return value.trim();
|
|
8504
|
+
}
|
|
8505
|
+
}
|
|
8506
|
+
};
|
|
8507
|
+
var resolveTransferTarget = (event, policy) => {
|
|
8508
|
+
if (typeof event.target === "string" && event.target.trim()) {
|
|
8509
|
+
return event.target.trim();
|
|
8510
|
+
}
|
|
8511
|
+
const metadataTarget = metadataValue(event.metadata, [
|
|
8512
|
+
"transferTarget",
|
|
8513
|
+
"target",
|
|
8514
|
+
"queue",
|
|
8515
|
+
"department"
|
|
8516
|
+
]);
|
|
8517
|
+
if (metadataTarget) {
|
|
8518
|
+
return metadataTarget;
|
|
8519
|
+
}
|
|
8520
|
+
if (typeof policy.transferTarget === "function") {
|
|
8521
|
+
const target = policy.transferTarget(event);
|
|
8522
|
+
return typeof target === "string" && target.trim() ? target.trim() : undefined;
|
|
8523
|
+
}
|
|
8524
|
+
return typeof policy.transferTarget === "string" && policy.transferTarget.trim() ? policy.transferTarget.trim() : undefined;
|
|
8525
|
+
};
|
|
8526
|
+
var mergeMetadata = (event, policy) => ({
|
|
8527
|
+
...policy.includeProviderPayload ? {
|
|
8528
|
+
answeredBy: event.answeredBy,
|
|
8529
|
+
durationMs: event.durationMs,
|
|
8530
|
+
provider: event.provider,
|
|
8531
|
+
reason: event.reason,
|
|
8532
|
+
sipCode: event.sipCode,
|
|
8533
|
+
status: event.status
|
|
8534
|
+
} : undefined,
|
|
8535
|
+
...policy.metadata,
|
|
8536
|
+
...event.metadata
|
|
8537
|
+
});
|
|
8538
|
+
var withDecisionDefaults = (decision, input) => {
|
|
8539
|
+
if (typeof decision === "string") {
|
|
8540
|
+
return buildDecision(decision, input);
|
|
8541
|
+
}
|
|
8542
|
+
return {
|
|
8543
|
+
...buildDecision(decision.action, input),
|
|
8544
|
+
...decision,
|
|
8545
|
+
confidence: decision.confidence ?? "high",
|
|
8546
|
+
metadata: {
|
|
8547
|
+
...mergeMetadata(input.event, input.policy),
|
|
8548
|
+
...decision.metadata
|
|
8549
|
+
},
|
|
8550
|
+
source: decision.source ?? input.source,
|
|
8551
|
+
target: decision.target ?? (decision.action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined)
|
|
8552
|
+
};
|
|
8553
|
+
};
|
|
8554
|
+
var dispositionForAction = (action) => {
|
|
8555
|
+
switch (action) {
|
|
8556
|
+
case "complete":
|
|
8557
|
+
return "completed";
|
|
8558
|
+
case "escalate":
|
|
8559
|
+
return "escalated";
|
|
8560
|
+
case "no-answer":
|
|
8561
|
+
return "no-answer";
|
|
8562
|
+
case "transfer":
|
|
8563
|
+
return "transferred";
|
|
8564
|
+
case "voicemail":
|
|
8565
|
+
return "voicemail";
|
|
8566
|
+
default:
|
|
8567
|
+
return;
|
|
8568
|
+
}
|
|
8569
|
+
};
|
|
8570
|
+
var buildDecision = (action, input) => ({
|
|
8571
|
+
action,
|
|
8572
|
+
confidence: action === "ignore" ? "low" : "high",
|
|
8573
|
+
disposition: dispositionForAction(action),
|
|
8574
|
+
metadata: mergeMetadata(input.event, input.policy),
|
|
8575
|
+
reason: input.event.reason,
|
|
8576
|
+
source: input.source,
|
|
8577
|
+
target: action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined
|
|
8578
|
+
});
|
|
8579
|
+
var createVoiceTelephonyOutcomePolicy = (policy = {}) => ({
|
|
8580
|
+
completedStatuses: policy.completedStatuses ?? DEFAULT_COMPLETED_STATUSES,
|
|
8581
|
+
escalationStatuses: policy.escalationStatuses ?? DEFAULT_ESCALATION_STATUSES,
|
|
8582
|
+
failedAsNoAnswer: policy.failedAsNoAnswer ?? true,
|
|
8583
|
+
failedStatuses: policy.failedStatuses ?? DEFAULT_FAILED_STATUSES,
|
|
8584
|
+
includeProviderPayload: policy.includeProviderPayload ?? true,
|
|
8585
|
+
machineDetectionVoicemailValues: policy.machineDetectionVoicemailValues ?? DEFAULT_MACHINE_VOICEMAIL_VALUES,
|
|
8586
|
+
metadata: policy.metadata,
|
|
8587
|
+
minAnsweredDurationMs: policy.minAnsweredDurationMs,
|
|
8588
|
+
noAnswerOnZeroDuration: policy.noAnswerOnZeroDuration ?? true,
|
|
8589
|
+
noAnswerSipCodes: policy.noAnswerSipCodes ?? DEFAULT_NO_ANSWER_SIP_CODES,
|
|
8590
|
+
noAnswerStatuses: policy.noAnswerStatuses ?? DEFAULT_NO_ANSWER_STATUSES,
|
|
8591
|
+
statusMap: policy.statusMap,
|
|
8592
|
+
transferStatuses: policy.transferStatuses ?? DEFAULT_TRANSFER_STATUSES,
|
|
8593
|
+
transferTarget: policy.transferTarget,
|
|
8594
|
+
voicemailStatuses: policy.voicemailStatuses ?? DEFAULT_VOICEMAIL_STATUSES
|
|
8595
|
+
});
|
|
8596
|
+
var resolveVoiceTelephonyOutcome = (event, policyInput = {}) => {
|
|
8597
|
+
const policy = createVoiceTelephonyOutcomePolicy(policyInput);
|
|
8598
|
+
const status = normalizeToken(event.status);
|
|
8599
|
+
const provider = normalizeToken(event.provider);
|
|
8600
|
+
const answeredBy = normalizeToken(event.answeredBy);
|
|
8601
|
+
const target = resolveTransferTarget(event, policy);
|
|
8602
|
+
if (status) {
|
|
8603
|
+
const mapped = policy.statusMap?.[status] ?? (provider ? policy.statusMap?.[`${provider}:${status}`] : undefined);
|
|
8604
|
+
if (mapped) {
|
|
8605
|
+
return withDecisionDefaults(mapped, {
|
|
8606
|
+
event,
|
|
8607
|
+
policy,
|
|
8608
|
+
source: "policy"
|
|
8609
|
+
});
|
|
8610
|
+
}
|
|
8611
|
+
}
|
|
8612
|
+
if (answeredBy && normalizeList(policy.machineDetectionVoicemailValues, []).has(answeredBy)) {
|
|
8613
|
+
return buildDecision("voicemail", { event, policy, source: "answered-by" });
|
|
8614
|
+
}
|
|
8615
|
+
if (typeof event.sipCode === "number" && policy.noAnswerSipCodes.includes(event.sipCode)) {
|
|
8616
|
+
return buildDecision("no-answer", { event, policy, source: "sip" });
|
|
8617
|
+
}
|
|
8618
|
+
if (target && status && normalizeList(policy.transferStatuses, []).has(status)) {
|
|
8619
|
+
return buildDecision("transfer", { event, policy, source: "status" });
|
|
8620
|
+
}
|
|
8621
|
+
if (status && normalizeList(policy.voicemailStatuses, []).has(status)) {
|
|
8622
|
+
return buildDecision("voicemail", { event, policy, source: "status" });
|
|
8623
|
+
}
|
|
8624
|
+
if (status && normalizeList(policy.escalationStatuses, []).has(status)) {
|
|
8625
|
+
return buildDecision("escalate", { event, policy, source: "status" });
|
|
8626
|
+
}
|
|
8627
|
+
if (status && (policy.failedAsNoAnswer ? normalizeList(policy.noAnswerStatuses, []).has(status) || normalizeList(policy.failedStatuses, []).has(status) : normalizeList(policy.noAnswerStatuses, []).has(status))) {
|
|
8628
|
+
return buildDecision("no-answer", { event, policy, source: "status" });
|
|
8629
|
+
}
|
|
8630
|
+
if (policy.noAnswerOnZeroDuration && typeof event.durationMs === "number" && event.durationMs <= 0) {
|
|
8631
|
+
return buildDecision("no-answer", { event, policy, source: "duration" });
|
|
8632
|
+
}
|
|
8633
|
+
if (typeof policy.minAnsweredDurationMs === "number" && typeof event.durationMs === "number" && event.durationMs < policy.minAnsweredDurationMs) {
|
|
8634
|
+
return {
|
|
8635
|
+
...buildDecision("no-answer", { event, policy, source: "duration" }),
|
|
8636
|
+
confidence: "medium"
|
|
8637
|
+
};
|
|
8638
|
+
}
|
|
8639
|
+
if (status && normalizeList(policy.completedStatuses, []).has(status)) {
|
|
8640
|
+
return buildDecision("complete", { event, policy, source: "status" });
|
|
8641
|
+
}
|
|
8642
|
+
if (target) {
|
|
8643
|
+
return {
|
|
8644
|
+
...buildDecision("transfer", { event, policy, source: "explicit-target" }),
|
|
8645
|
+
confidence: "medium"
|
|
8646
|
+
};
|
|
8647
|
+
}
|
|
8648
|
+
return buildDecision("ignore", { event, policy, source: "status" });
|
|
8649
|
+
};
|
|
8650
|
+
var voiceTelephonyOutcomeToRouteResult = (decision, result) => {
|
|
8651
|
+
switch (decision.action) {
|
|
8652
|
+
case "complete":
|
|
8653
|
+
return { complete: true, result };
|
|
8654
|
+
case "escalate":
|
|
8655
|
+
return {
|
|
8656
|
+
escalate: {
|
|
8657
|
+
metadata: decision.metadata,
|
|
8658
|
+
reason: decision.reason ?? "telephony-escalation"
|
|
8659
|
+
},
|
|
8660
|
+
result
|
|
8661
|
+
};
|
|
8662
|
+
case "no-answer":
|
|
8663
|
+
return {
|
|
8664
|
+
noAnswer: {
|
|
8665
|
+
metadata: decision.metadata
|
|
8666
|
+
},
|
|
8667
|
+
result
|
|
8668
|
+
};
|
|
8669
|
+
case "transfer":
|
|
8670
|
+
if (!decision.target) {
|
|
8671
|
+
return { result };
|
|
8672
|
+
}
|
|
8673
|
+
return {
|
|
8674
|
+
result,
|
|
8675
|
+
transfer: {
|
|
8676
|
+
metadata: decision.metadata,
|
|
8677
|
+
reason: decision.reason,
|
|
8678
|
+
target: decision.target
|
|
8679
|
+
}
|
|
8680
|
+
};
|
|
8681
|
+
case "voicemail":
|
|
8682
|
+
return {
|
|
8683
|
+
result,
|
|
8684
|
+
voicemail: {
|
|
8685
|
+
metadata: decision.metadata
|
|
8686
|
+
}
|
|
8687
|
+
};
|
|
8688
|
+
default:
|
|
8689
|
+
return { result };
|
|
8690
|
+
}
|
|
8691
|
+
};
|
|
8692
|
+
var applyVoiceTelephonyOutcome = async (api, decision, result) => {
|
|
8693
|
+
switch (decision.action) {
|
|
8694
|
+
case "complete":
|
|
8695
|
+
await api.complete(result);
|
|
8696
|
+
break;
|
|
8697
|
+
case "escalate":
|
|
8698
|
+
await api.escalate({
|
|
8699
|
+
metadata: decision.metadata,
|
|
8700
|
+
reason: decision.reason ?? "telephony-escalation",
|
|
8701
|
+
result
|
|
8702
|
+
});
|
|
8703
|
+
break;
|
|
8704
|
+
case "no-answer":
|
|
8705
|
+
await api.markNoAnswer({
|
|
8706
|
+
metadata: decision.metadata,
|
|
8707
|
+
result
|
|
8708
|
+
});
|
|
8709
|
+
break;
|
|
8710
|
+
case "transfer":
|
|
8711
|
+
if (!decision.target) {
|
|
8712
|
+
return;
|
|
8713
|
+
}
|
|
8714
|
+
await api.transfer({
|
|
8715
|
+
metadata: decision.metadata,
|
|
8716
|
+
reason: decision.reason,
|
|
8717
|
+
result,
|
|
8718
|
+
target: decision.target
|
|
8719
|
+
});
|
|
8720
|
+
break;
|
|
8721
|
+
case "voicemail":
|
|
8722
|
+
await api.markVoicemail({
|
|
8723
|
+
metadata: decision.metadata,
|
|
8724
|
+
result
|
|
8725
|
+
});
|
|
8726
|
+
break;
|
|
8727
|
+
default:
|
|
8728
|
+
break;
|
|
8729
|
+
}
|
|
8730
|
+
};
|
|
8731
|
+
var parseRequestBodyText = (input) => {
|
|
8732
|
+
const { contentType, text } = input;
|
|
8733
|
+
if (!text) {
|
|
8734
|
+
return {};
|
|
8735
|
+
}
|
|
8736
|
+
if (contentType.includes("application/json")) {
|
|
8737
|
+
return parseMaybeJSON(text) ?? {};
|
|
8738
|
+
}
|
|
8739
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
8740
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
8741
|
+
}
|
|
8742
|
+
return parseMaybeJSON(text) ?? Object.fromEntries(new URLSearchParams(text));
|
|
8743
|
+
};
|
|
8744
|
+
var readRequestBody = async (request) => {
|
|
8745
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
8746
|
+
const text = await request.text();
|
|
8747
|
+
return {
|
|
8748
|
+
body: parseRequestBodyText({ contentType, text }),
|
|
8749
|
+
rawBody: text
|
|
8750
|
+
};
|
|
8751
|
+
};
|
|
8752
|
+
var signVoiceTwilioWebhook = async (input) => signHmacSHA1Base64(input.authToken, `${input.url}${sortedParamsForSignature(input.body ?? {})}`);
|
|
8753
|
+
var verifyVoiceTwilioWebhookSignature = async (input) => {
|
|
8754
|
+
if (!input.authToken) {
|
|
8755
|
+
return { ok: false, reason: "missing-secret" };
|
|
8756
|
+
}
|
|
8757
|
+
const signature = input.headers.get("x-twilio-signature");
|
|
8758
|
+
if (!signature) {
|
|
8759
|
+
return { ok: false, reason: "missing-signature" };
|
|
8760
|
+
}
|
|
8761
|
+
const expected = await signVoiceTwilioWebhook({
|
|
8762
|
+
authToken: input.authToken,
|
|
8763
|
+
body: input.body,
|
|
8764
|
+
url: input.url
|
|
8765
|
+
});
|
|
8766
|
+
return timingSafeEqual(signature, expected) ? { ok: true } : { ok: false, reason: "invalid-signature" };
|
|
8767
|
+
};
|
|
8768
|
+
var resolveVerificationUrl = (option, input) => typeof option === "function" ? option(input) : option ?? input.request.url;
|
|
8769
|
+
var verifyVoiceTelephonyWebhook = async (input) => {
|
|
8770
|
+
if (input.options.verify) {
|
|
8771
|
+
return input.options.verify({
|
|
8772
|
+
body: input.body,
|
|
8773
|
+
headers: input.request.headers,
|
|
8774
|
+
provider: input.provider,
|
|
8775
|
+
query: input.query,
|
|
8776
|
+
rawBody: input.rawBody,
|
|
8777
|
+
request: input.request
|
|
8778
|
+
});
|
|
8779
|
+
}
|
|
8780
|
+
if (!input.options.signingSecret) {
|
|
8781
|
+
return input.options.requireVerification ? { ok: false, reason: "missing-secret" } : { ok: true };
|
|
8782
|
+
}
|
|
8783
|
+
if (input.provider !== "twilio") {
|
|
8784
|
+
return { ok: false, reason: "unsupported-provider" };
|
|
8785
|
+
}
|
|
8786
|
+
return verifyVoiceTwilioWebhookSignature({
|
|
8787
|
+
authToken: input.options.signingSecret,
|
|
8788
|
+
body: input.body,
|
|
8789
|
+
headers: input.request.headers,
|
|
8790
|
+
url: resolveVerificationUrl(input.options.verificationUrl, {
|
|
8791
|
+
query: input.query,
|
|
8792
|
+
request: input.request
|
|
8793
|
+
})
|
|
8794
|
+
});
|
|
8795
|
+
};
|
|
8796
|
+
var durationMsFromSeconds = (value) => typeof value === "number" ? value * 1000 : undefined;
|
|
8797
|
+
var parseVoiceTelephonyWebhookEvent = (input) => {
|
|
8798
|
+
const payload = flattenPayload(input.body);
|
|
8799
|
+
const provider = firstString(payload, ["provider", "Provider"]) ?? input.provider;
|
|
8800
|
+
const status = firstString(payload, [
|
|
8801
|
+
"CallStatus",
|
|
8802
|
+
"call_status",
|
|
8803
|
+
"callStatus",
|
|
8804
|
+
"DialCallStatus",
|
|
8805
|
+
"dial_call_status",
|
|
8806
|
+
"status",
|
|
8807
|
+
"event_type",
|
|
8808
|
+
"type"
|
|
8809
|
+
]);
|
|
8810
|
+
const durationMs = firstNumber(payload, ["durationMs", "duration_ms"]) ?? durationMsFromSeconds(firstNumber(payload, [
|
|
8811
|
+
"CallDuration",
|
|
8812
|
+
"call_duration",
|
|
8813
|
+
"callDuration",
|
|
8814
|
+
"DialCallDuration",
|
|
8815
|
+
"dial_call_duration",
|
|
8816
|
+
"duration"
|
|
8817
|
+
]));
|
|
8818
|
+
const sipCode = firstNumber(payload, [
|
|
8819
|
+
"SipResponseCode",
|
|
8820
|
+
"sip_response_code",
|
|
8821
|
+
"sipCode",
|
|
8822
|
+
"sip_code",
|
|
8823
|
+
"hangupCauseCode"
|
|
8824
|
+
]);
|
|
8825
|
+
const from = firstString(payload, ["From", "from", "caller_id", "callerId"]);
|
|
8826
|
+
const to = firstString(payload, ["To", "to", "called_number", "calledNumber"]);
|
|
8827
|
+
const target = firstString(payload, [
|
|
8828
|
+
"transferTarget",
|
|
8829
|
+
"TransferTarget",
|
|
8830
|
+
"target",
|
|
8831
|
+
"queue",
|
|
8832
|
+
"department"
|
|
8833
|
+
]);
|
|
8834
|
+
return {
|
|
8835
|
+
answeredBy: firstString(payload, [
|
|
8836
|
+
"AnsweredBy",
|
|
8837
|
+
"answered_by",
|
|
8838
|
+
"answeredBy",
|
|
8839
|
+
"machineDetection",
|
|
8840
|
+
"machine_detection"
|
|
8841
|
+
]),
|
|
8842
|
+
durationMs,
|
|
8843
|
+
from,
|
|
8844
|
+
metadata: {
|
|
8845
|
+
...input.query,
|
|
8846
|
+
...payload
|
|
8847
|
+
},
|
|
8848
|
+
provider,
|
|
8849
|
+
reason: firstString(payload, [
|
|
8850
|
+
"Reason",
|
|
8851
|
+
"reason",
|
|
8852
|
+
"HangupCause",
|
|
8853
|
+
"hangup_cause",
|
|
8854
|
+
"hangupCause"
|
|
8855
|
+
]),
|
|
8856
|
+
sipCode,
|
|
8857
|
+
status,
|
|
8858
|
+
target,
|
|
8859
|
+
to
|
|
8860
|
+
};
|
|
8861
|
+
};
|
|
8862
|
+
var defaultSessionId = (input) => {
|
|
8863
|
+
const payload = flattenPayload(input.body);
|
|
8864
|
+
const metadataSessionId = input.event.metadata?.sessionId;
|
|
8865
|
+
return firstString(input.query, ["sessionId", "session_id"]) ?? firstString(payload, [
|
|
8866
|
+
"sessionId",
|
|
8867
|
+
"session_id",
|
|
8868
|
+
"SessionId",
|
|
8869
|
+
"CallSid",
|
|
8870
|
+
"call_sid",
|
|
8871
|
+
"callSid",
|
|
8872
|
+
"CallUUID",
|
|
8873
|
+
"call_uuid",
|
|
8874
|
+
"callControlId",
|
|
8875
|
+
"call_control_id"
|
|
8876
|
+
]) ?? (typeof metadataSessionId === "string" ? metadataSessionId : undefined);
|
|
8877
|
+
};
|
|
8878
|
+
var defaultIdempotencyKey = (input) => {
|
|
8879
|
+
const payload = flattenPayload(input.body);
|
|
8880
|
+
const eventId = firstString(payload, [
|
|
8881
|
+
"id",
|
|
8882
|
+
"event_id",
|
|
8883
|
+
"eventId",
|
|
8884
|
+
"EventSid",
|
|
8885
|
+
"event_sid",
|
|
8886
|
+
"MessageSid",
|
|
8887
|
+
"message_sid",
|
|
8888
|
+
"CallSid",
|
|
8889
|
+
"call_sid",
|
|
8890
|
+
"CallUUID",
|
|
8891
|
+
"call_uuid",
|
|
8892
|
+
"callControlId",
|
|
8893
|
+
"call_control_id"
|
|
8894
|
+
]);
|
|
8895
|
+
const status = normalizeToken(input.event.status) ?? "unknown";
|
|
8896
|
+
if (eventId) {
|
|
8897
|
+
return `${input.provider}:${eventId}:${status}`;
|
|
8898
|
+
}
|
|
8899
|
+
if (input.sessionId) {
|
|
8900
|
+
return `${input.provider}:${input.sessionId}:${status}`;
|
|
8901
|
+
}
|
|
8902
|
+
};
|
|
8903
|
+
var createVoiceTelephonyWebhookHandler = (options = {}) => async (input) => {
|
|
8904
|
+
const provider = options.provider ?? "generic";
|
|
8905
|
+
const query = input.query ?? {};
|
|
8906
|
+
const { body, rawBody } = await readRequestBody(input.request);
|
|
8907
|
+
const verification = await verifyVoiceTelephonyWebhook({
|
|
8908
|
+
body,
|
|
8909
|
+
options,
|
|
8910
|
+
provider,
|
|
8911
|
+
query,
|
|
8912
|
+
rawBody,
|
|
8913
|
+
request: input.request
|
|
8914
|
+
});
|
|
8915
|
+
if (!verification.ok) {
|
|
8916
|
+
throw new VoiceTelephonyWebhookVerificationError(verification);
|
|
8917
|
+
}
|
|
8918
|
+
const event = options.parse ? await options.parse({
|
|
8919
|
+
body,
|
|
8920
|
+
headers: input.request.headers,
|
|
8921
|
+
provider,
|
|
8922
|
+
query,
|
|
8923
|
+
request: input.request
|
|
8924
|
+
}) : parseVoiceTelephonyWebhookEvent({
|
|
8925
|
+
body,
|
|
8926
|
+
headers: input.request.headers,
|
|
8927
|
+
provider,
|
|
8928
|
+
query,
|
|
8929
|
+
request: input.request
|
|
8930
|
+
});
|
|
8931
|
+
const sessionId = await (options.resolveSessionId?.({
|
|
8932
|
+
body,
|
|
8933
|
+
event,
|
|
8934
|
+
query,
|
|
8935
|
+
request: input.request
|
|
8936
|
+
}) ?? defaultSessionId({ body, event, query }));
|
|
8937
|
+
const idempotencyEnabled = options.idempotency?.enabled !== false;
|
|
8938
|
+
const idempotencyKey = idempotencyEnabled ? await (options.idempotency?.key?.({
|
|
8939
|
+
body,
|
|
8940
|
+
event,
|
|
8941
|
+
provider,
|
|
8942
|
+
query,
|
|
8943
|
+
request: input.request,
|
|
8944
|
+
sessionId
|
|
8945
|
+
}) ?? defaultIdempotencyKey({ body, event, provider, sessionId })) : undefined;
|
|
8946
|
+
const idempotencyStore = options.idempotency?.store;
|
|
8947
|
+
if (idempotencyKey && idempotencyStore) {
|
|
8948
|
+
const existing = await idempotencyStore.get(idempotencyKey);
|
|
8949
|
+
if (existing) {
|
|
8950
|
+
const duplicateDecision = {
|
|
8951
|
+
...existing,
|
|
8952
|
+
duplicate: true
|
|
8953
|
+
};
|
|
8954
|
+
await options.onDecision?.({
|
|
8955
|
+
...duplicateDecision,
|
|
8956
|
+
context: options.context,
|
|
8957
|
+
request: input.request
|
|
8958
|
+
});
|
|
8959
|
+
return duplicateDecision;
|
|
8960
|
+
}
|
|
8961
|
+
}
|
|
8962
|
+
const decision = resolveVoiceTelephonyOutcome(event, options.policy);
|
|
8963
|
+
const resultResolver = options.result;
|
|
8964
|
+
const result = typeof resultResolver === "function" ? await resultResolver({
|
|
8965
|
+
decision,
|
|
8966
|
+
event,
|
|
8967
|
+
sessionId
|
|
8968
|
+
}) : resultResolver;
|
|
8969
|
+
const routeResult = voiceTelephonyOutcomeToRouteResult(decision, result);
|
|
8970
|
+
const shouldApply = typeof options.apply === "function" ? options.apply({
|
|
8971
|
+
applied: false,
|
|
8972
|
+
decision,
|
|
8973
|
+
event,
|
|
8974
|
+
routeResult,
|
|
8975
|
+
sessionId
|
|
8976
|
+
}) : options.apply === true;
|
|
8977
|
+
let applied = false;
|
|
8978
|
+
if (shouldApply && decision.action !== "ignore" && options.getSessionHandle) {
|
|
8979
|
+
const api = await options.getSessionHandle({
|
|
8980
|
+
context: options.context,
|
|
8981
|
+
decision,
|
|
8982
|
+
event,
|
|
8983
|
+
request: input.request,
|
|
8984
|
+
sessionId
|
|
8985
|
+
});
|
|
8986
|
+
if (api) {
|
|
8987
|
+
await applyVoiceTelephonyOutcome(api, decision, result);
|
|
8988
|
+
applied = true;
|
|
8989
|
+
}
|
|
8990
|
+
}
|
|
8991
|
+
const webhookDecision = {
|
|
8992
|
+
applied,
|
|
8993
|
+
decision,
|
|
8994
|
+
event,
|
|
8995
|
+
idempotencyKey,
|
|
8996
|
+
routeResult,
|
|
8997
|
+
sessionId
|
|
8998
|
+
};
|
|
8999
|
+
if (idempotencyKey && idempotencyStore) {
|
|
9000
|
+
const now = Date.now();
|
|
9001
|
+
await idempotencyStore.set(idempotencyKey, {
|
|
9002
|
+
...webhookDecision,
|
|
9003
|
+
createdAt: now,
|
|
9004
|
+
updatedAt: now
|
|
9005
|
+
});
|
|
9006
|
+
}
|
|
9007
|
+
await options.onDecision?.({
|
|
9008
|
+
...webhookDecision,
|
|
9009
|
+
context: options.context,
|
|
9010
|
+
request: input.request
|
|
9011
|
+
});
|
|
9012
|
+
return webhookDecision;
|
|
9013
|
+
};
|
|
9014
|
+
var createVoiceTelephonyWebhookRoutes = (options = {}) => {
|
|
9015
|
+
const path = options.path ?? "/api/voice/telephony/webhook";
|
|
9016
|
+
const handler = createVoiceTelephonyWebhookHandler(options);
|
|
9017
|
+
return new Elysia({
|
|
9018
|
+
name: options.name ?? "absolutejs-voice-telephony-webhooks"
|
|
9019
|
+
}).post(path, async ({ query, request }) => {
|
|
9020
|
+
try {
|
|
9021
|
+
return await handler({ query, request });
|
|
9022
|
+
} catch (error) {
|
|
9023
|
+
if (error instanceof VoiceTelephonyWebhookVerificationError) {
|
|
9024
|
+
return new Response(JSON.stringify({ verification: error.result }), {
|
|
9025
|
+
headers: {
|
|
9026
|
+
"content-type": "application/json"
|
|
9027
|
+
},
|
|
9028
|
+
status: 401
|
|
9029
|
+
});
|
|
9030
|
+
}
|
|
9031
|
+
throw error;
|
|
9032
|
+
}
|
|
9033
|
+
}, {
|
|
9034
|
+
parse: "none"
|
|
9035
|
+
});
|
|
9036
|
+
};
|
|
9037
|
+
|
|
9038
|
+
// src/telephony/twilio.ts
|
|
6342
9039
|
var TWILIO_MULAW_SAMPLE_RATE = 8000;
|
|
6343
9040
|
var VOICE_PCM_SAMPLE_RATE = 16000;
|
|
6344
|
-
var
|
|
9041
|
+
var escapeXml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
9042
|
+
var resolveRequestOrigin = (request) => {
|
|
9043
|
+
const url = new URL(request.url);
|
|
9044
|
+
const forwardedHost = request.headers.get("x-forwarded-host");
|
|
9045
|
+
const forwardedProto = request.headers.get("x-forwarded-proto");
|
|
9046
|
+
const host = forwardedHost ?? request.headers.get("host") ?? url.host;
|
|
9047
|
+
const protocol = forwardedProto ?? url.protocol.replace(":", "");
|
|
9048
|
+
return `${protocol}://${host}`;
|
|
9049
|
+
};
|
|
9050
|
+
var resolveTwilioStreamUrl = async (options, input) => {
|
|
9051
|
+
if (typeof options.twiml?.streamUrl === "function") {
|
|
9052
|
+
return options.twiml.streamUrl(input);
|
|
9053
|
+
}
|
|
9054
|
+
if (typeof options.twiml?.streamUrl === "string") {
|
|
9055
|
+
return options.twiml.streamUrl;
|
|
9056
|
+
}
|
|
9057
|
+
const origin = resolveRequestOrigin(input.request);
|
|
9058
|
+
const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
9059
|
+
return `${wsOrigin}${input.streamPath}`;
|
|
9060
|
+
};
|
|
9061
|
+
var resolveTwilioStreamParameters = async (parameters, input) => {
|
|
9062
|
+
if (typeof parameters === "function") {
|
|
9063
|
+
return parameters(input);
|
|
9064
|
+
}
|
|
9065
|
+
return parameters;
|
|
9066
|
+
};
|
|
9067
|
+
var joinUrlPath = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
9068
|
+
var escapeHtml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
9069
|
+
var getWebhookVerificationUrl = (webhook, input) => {
|
|
9070
|
+
if (!webhook?.verificationUrl) {
|
|
9071
|
+
return;
|
|
9072
|
+
}
|
|
9073
|
+
if (typeof webhook.verificationUrl === "function") {
|
|
9074
|
+
return webhook.verificationUrl(input);
|
|
9075
|
+
}
|
|
9076
|
+
return webhook.verificationUrl;
|
|
9077
|
+
};
|
|
9078
|
+
var buildTwilioVoiceSetupStatus = async (options, input) => {
|
|
9079
|
+
const origin = resolveRequestOrigin(input.request);
|
|
9080
|
+
const stream = await resolveTwilioStreamUrl(options, input);
|
|
9081
|
+
const twiml = joinUrlPath(origin, input.twimlPath);
|
|
9082
|
+
const webhook = joinUrlPath(origin, input.webhookPath);
|
|
9083
|
+
const verificationUrl = getWebhookVerificationUrl(options.webhook, input);
|
|
9084
|
+
const missing = Object.entries(options.setup?.requiredEnv ?? {}).filter((entry) => !entry[1]).map(([name]) => name);
|
|
9085
|
+
const signingConfigured = Boolean(options.webhook?.signingSecret || options.webhook?.verify);
|
|
9086
|
+
const warnings = [
|
|
9087
|
+
...stream.startsWith("wss://") ? [] : ["Twilio media streams should use wss:// in production."],
|
|
9088
|
+
...signingConfigured ? [] : ["Webhook signature verification is not configured."],
|
|
9089
|
+
...verificationUrl || !signingConfigured ? [] : ["Webhook signing is configured without an explicit verification URL."]
|
|
9090
|
+
];
|
|
9091
|
+
return {
|
|
9092
|
+
generatedAt: Date.now(),
|
|
9093
|
+
missing,
|
|
9094
|
+
provider: "twilio",
|
|
9095
|
+
ready: missing.length === 0 && signingConfigured && warnings.length === 0,
|
|
9096
|
+
signing: {
|
|
9097
|
+
configured: signingConfigured,
|
|
9098
|
+
mode: options.webhook?.verify ? "custom" : options.webhook?.signingSecret ? "twilio-signature" : "none",
|
|
9099
|
+
verificationUrl
|
|
9100
|
+
},
|
|
9101
|
+
urls: {
|
|
9102
|
+
stream,
|
|
9103
|
+
twiml,
|
|
9104
|
+
webhook
|
|
9105
|
+
},
|
|
9106
|
+
warnings
|
|
9107
|
+
};
|
|
9108
|
+
};
|
|
9109
|
+
var renderTwilioVoiceSetupHTML = (status, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
9110
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio setup</p>
|
|
9111
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
9112
|
+
<p><strong>Status:</strong> ${status.ready ? "Ready" : "Needs attention"}</p>
|
|
9113
|
+
<section>
|
|
9114
|
+
<h2>URLs</h2>
|
|
9115
|
+
<ul>
|
|
9116
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml2(status.urls.twiml)}</code></li>
|
|
9117
|
+
<li><strong>Media stream:</strong> <code>${escapeHtml2(status.urls.stream)}</code></li>
|
|
9118
|
+
<li><strong>Status webhook:</strong> <code>${escapeHtml2(status.urls.webhook)}</code></li>
|
|
9119
|
+
</ul>
|
|
9120
|
+
</section>
|
|
9121
|
+
<section>
|
|
9122
|
+
<h2>Signing</h2>
|
|
9123
|
+
<p>Mode: <code>${status.signing.mode}</code></p>
|
|
9124
|
+
${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml2(status.signing.verificationUrl)}</code></p>` : ""}
|
|
9125
|
+
</section>
|
|
9126
|
+
${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml2(name)}</code></li>`).join("")}</ul></section>` : ""}
|
|
9127
|
+
${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml2(warning)}</li>`).join("")}</ul></section>` : ""}
|
|
9128
|
+
</main>`;
|
|
9129
|
+
var extractTwilioStreamUrl = (twiml) => twiml.match(/<Stream\b[^>]*\surl="([^"]+)"/i)?.[1]?.replaceAll("&", "&");
|
|
9130
|
+
var createSmokeCheck = (name, status, message, details) => ({
|
|
9131
|
+
details,
|
|
9132
|
+
message,
|
|
9133
|
+
name,
|
|
9134
|
+
status
|
|
9135
|
+
});
|
|
9136
|
+
var renderTwilioVoiceSmokeHTML = (report, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
9137
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio smoke test</p>
|
|
9138
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
9139
|
+
<p><strong>Status:</strong> ${report.pass ? "Pass" : "Fail"}</p>
|
|
9140
|
+
<section>
|
|
9141
|
+
<h2>Checks</h2>
|
|
9142
|
+
<ul>
|
|
9143
|
+
${report.checks.map((check) => `<li><strong>${escapeHtml2(check.name)}</strong>: ${escapeHtml2(check.status)}${check.message ? ` - ${escapeHtml2(check.message)}` : ""}</li>`).join("")}
|
|
9144
|
+
</ul>
|
|
9145
|
+
</section>
|
|
9146
|
+
<section>
|
|
9147
|
+
<h2>Observed URLs</h2>
|
|
9148
|
+
<ul>
|
|
9149
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml2(report.setup.urls.twiml)}</code></li>
|
|
9150
|
+
<li><strong>Stream:</strong> <code>${escapeHtml2(report.twiml?.streamUrl ?? report.setup.urls.stream)}</code></li>
|
|
9151
|
+
<li><strong>Webhook:</strong> <code>${escapeHtml2(report.setup.urls.webhook)}</code></li>
|
|
9152
|
+
</ul>
|
|
9153
|
+
</section>
|
|
9154
|
+
</main>`;
|
|
9155
|
+
var runTwilioVoiceSmokeTest = async (input) => {
|
|
9156
|
+
const setup = await buildTwilioVoiceSetupStatus(input.options, input);
|
|
9157
|
+
const checks = [];
|
|
9158
|
+
const twimlUrl = new URL(setup.urls.twiml);
|
|
9159
|
+
twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
|
|
9160
|
+
twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
|
|
9161
|
+
const twimlResponse = await input.app.handle(new Request(twimlUrl, {
|
|
9162
|
+
headers: input.request.headers
|
|
9163
|
+
}));
|
|
9164
|
+
const twiml = await twimlResponse.text();
|
|
9165
|
+
const streamUrl = extractTwilioStreamUrl(twiml);
|
|
9166
|
+
checks.push(createSmokeCheck("twiml", twimlResponse.ok && Boolean(streamUrl) ? "pass" : "fail", streamUrl ? "TwiML includes a media stream URL." : 'TwiML is missing <Stream url="...">.', {
|
|
9167
|
+
status: twimlResponse.status,
|
|
9168
|
+
streamUrl
|
|
9169
|
+
}));
|
|
9170
|
+
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.", {
|
|
9171
|
+
streamUrl
|
|
9172
|
+
}));
|
|
9173
|
+
const webhookBody = {
|
|
9174
|
+
CallSid: input.options.smoke?.callSid ?? "CA_SMOKE_TEST",
|
|
9175
|
+
CallStatus: input.options.smoke?.status ?? "busy",
|
|
9176
|
+
SipResponseCode: String(input.options.smoke?.sipCode ?? 486)
|
|
9177
|
+
};
|
|
9178
|
+
const webhookHeaders = new Headers({
|
|
9179
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
9180
|
+
});
|
|
9181
|
+
const verificationUrl = setup.signing.verificationUrl ?? setup.urls.webhook;
|
|
9182
|
+
if (input.options.webhook?.signingSecret) {
|
|
9183
|
+
webhookHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
|
|
9184
|
+
authToken: input.options.webhook.signingSecret,
|
|
9185
|
+
body: webhookBody,
|
|
9186
|
+
url: verificationUrl
|
|
9187
|
+
}));
|
|
9188
|
+
}
|
|
9189
|
+
const webhookResponse = await input.app.handle(new Request(setup.urls.webhook, {
|
|
9190
|
+
body: new URLSearchParams(webhookBody),
|
|
9191
|
+
headers: webhookHeaders,
|
|
9192
|
+
method: "POST"
|
|
9193
|
+
}));
|
|
9194
|
+
const webhookText = await webhookResponse.text();
|
|
9195
|
+
const webhookPayload = (() => {
|
|
9196
|
+
try {
|
|
9197
|
+
return JSON.parse(webhookText);
|
|
9198
|
+
} catch {
|
|
9199
|
+
return webhookText;
|
|
9200
|
+
}
|
|
9201
|
+
})();
|
|
9202
|
+
checks.push(createSmokeCheck("webhook", webhookResponse.ok ? "pass" : "fail", webhookResponse.ok ? "Synthetic Twilio status callback was accepted." : "Synthetic Twilio status callback failed.", {
|
|
9203
|
+
status: webhookResponse.status
|
|
9204
|
+
}));
|
|
9205
|
+
for (const warning of setup.warnings) {
|
|
9206
|
+
checks.push(createSmokeCheck("setup-warning", "warn", warning));
|
|
9207
|
+
}
|
|
9208
|
+
for (const name of setup.missing) {
|
|
9209
|
+
checks.push(createSmokeCheck("missing-env", "fail", `${name} is missing.`));
|
|
9210
|
+
}
|
|
9211
|
+
return {
|
|
9212
|
+
checks,
|
|
9213
|
+
generatedAt: Date.now(),
|
|
9214
|
+
pass: checks.every((check) => check.status !== "fail"),
|
|
9215
|
+
provider: "twilio",
|
|
9216
|
+
setup,
|
|
9217
|
+
twiml: {
|
|
9218
|
+
status: twimlResponse.status,
|
|
9219
|
+
streamUrl
|
|
9220
|
+
},
|
|
9221
|
+
webhook: {
|
|
9222
|
+
body: webhookPayload,
|
|
9223
|
+
status: webhookResponse.status
|
|
9224
|
+
}
|
|
9225
|
+
};
|
|
9226
|
+
};
|
|
6345
9227
|
var normalizeOnTurn = (handler) => {
|
|
6346
9228
|
if (handler.length > 1) {
|
|
6347
9229
|
const directHandler = handler;
|
|
@@ -6443,7 +9325,7 @@ var bytesToInt16Array = (bytes) => {
|
|
|
6443
9325
|
return output;
|
|
6444
9326
|
};
|
|
6445
9327
|
var decodeTwilioMulawBase64 = (payload) => {
|
|
6446
|
-
const bytes = Uint8Array.from(
|
|
9328
|
+
const bytes = Uint8Array.from(Buffer3.from(payload, "base64"));
|
|
6447
9329
|
const samples = new Int16Array(bytes.length);
|
|
6448
9330
|
for (let index = 0;index < bytes.length; index += 1) {
|
|
6449
9331
|
samples[index] = decodeMulawSample(bytes[index] ?? 0);
|
|
@@ -6455,7 +9337,7 @@ var encodeTwilioMulawBase64 = (samples) => {
|
|
|
6455
9337
|
for (let index = 0;index < samples.length; index += 1) {
|
|
6456
9338
|
bytes[index] = encodeMulawSample(samples[index] ?? 0);
|
|
6457
9339
|
}
|
|
6458
|
-
return
|
|
9340
|
+
return Buffer3.from(bytes).toString("base64");
|
|
6459
9341
|
};
|
|
6460
9342
|
var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
|
|
6461
9343
|
const narrowband = decodeTwilioMulawBase64(payload);
|
|
@@ -6464,7 +9346,7 @@ var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
|
|
|
6464
9346
|
};
|
|
6465
9347
|
var transcodePCMToTwilioOutboundPayload = (chunk, format) => {
|
|
6466
9348
|
if (format.container === "raw" && format.encoding === "mulaw" && format.channels === 1 && format.sampleRateHz === TWILIO_MULAW_SAMPLE_RATE) {
|
|
6467
|
-
return
|
|
9349
|
+
return Buffer3.from(chunk).toString("base64");
|
|
6468
9350
|
}
|
|
6469
9351
|
if (format.encoding !== "pcm_s16le") {
|
|
6470
9352
|
throw new Error(`Unsupported outbound telephony audio format: ${format.container}/${format.encoding}`);
|
|
@@ -6505,7 +9387,7 @@ var createTwilioSocketAdapter = (socket, getState) => ({
|
|
|
6505
9387
|
return;
|
|
6506
9388
|
}
|
|
6507
9389
|
if (message.type === "audio") {
|
|
6508
|
-
const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(
|
|
9390
|
+
const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer3.from(message.chunkBase64, "base64")), message.format);
|
|
6509
9391
|
state.hasOutboundAudioSinceLastInbound = true;
|
|
6510
9392
|
state.reviewRecorder?.recordTwilioOutbound({
|
|
6511
9393
|
bytes: payload.length,
|
|
@@ -6537,8 +9419,8 @@ var createTwilioSocketAdapter = (socket, getState) => ({
|
|
|
6537
9419
|
}
|
|
6538
9420
|
});
|
|
6539
9421
|
var createTwilioVoiceResponse = (options) => {
|
|
6540
|
-
const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${
|
|
6541
|
-
return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${
|
|
9422
|
+
const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${escapeXml2(name)}" value="${escapeXml2(String(value))}" />`).join("");
|
|
9423
|
+
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
9424
|
};
|
|
6543
9425
|
var createTwilioMediaStreamBridge = (socket, options) => {
|
|
6544
9426
|
const runtimePreset = resolveVoiceRuntimePreset(options.preset);
|
|
@@ -6718,6 +9600,148 @@ var createTwilioMediaStreamBridge = (socket, options) => {
|
|
|
6718
9600
|
}
|
|
6719
9601
|
};
|
|
6720
9602
|
};
|
|
9603
|
+
var createTwilioVoiceRoutes = (options) => {
|
|
9604
|
+
const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
|
|
9605
|
+
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
9606
|
+
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
9607
|
+
const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
|
|
9608
|
+
const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
|
|
9609
|
+
const bridges = new WeakMap;
|
|
9610
|
+
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
9611
|
+
const app = new Elysia2({
|
|
9612
|
+
name: options.name ?? "absolutejs-voice-twilio"
|
|
9613
|
+
}).get(twimlPath, async ({ query, request }) => {
|
|
9614
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
9615
|
+
query,
|
|
9616
|
+
request,
|
|
9617
|
+
streamPath
|
|
9618
|
+
});
|
|
9619
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
9620
|
+
query,
|
|
9621
|
+
request
|
|
9622
|
+
});
|
|
9623
|
+
return new Response(createTwilioVoiceResponse({
|
|
9624
|
+
parameters,
|
|
9625
|
+
streamName: options.twiml?.streamName,
|
|
9626
|
+
streamUrl,
|
|
9627
|
+
track: options.twiml?.track
|
|
9628
|
+
}), {
|
|
9629
|
+
headers: {
|
|
9630
|
+
"content-type": "text/xml; charset=utf-8"
|
|
9631
|
+
}
|
|
9632
|
+
});
|
|
9633
|
+
}).post(twimlPath, async ({ query, request }) => {
|
|
9634
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
9635
|
+
query,
|
|
9636
|
+
request,
|
|
9637
|
+
streamPath
|
|
9638
|
+
});
|
|
9639
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
9640
|
+
query,
|
|
9641
|
+
request
|
|
9642
|
+
});
|
|
9643
|
+
return new Response(createTwilioVoiceResponse({
|
|
9644
|
+
parameters,
|
|
9645
|
+
streamName: options.twiml?.streamName,
|
|
9646
|
+
streamUrl,
|
|
9647
|
+
track: options.twiml?.track
|
|
9648
|
+
}), {
|
|
9649
|
+
headers: {
|
|
9650
|
+
"content-type": "text/xml; charset=utf-8"
|
|
9651
|
+
}
|
|
9652
|
+
});
|
|
9653
|
+
}).ws(streamPath, {
|
|
9654
|
+
close: async (ws, _code, reason) => {
|
|
9655
|
+
const bridge = bridges.get(ws);
|
|
9656
|
+
bridges.delete(ws);
|
|
9657
|
+
await bridge?.close(reason);
|
|
9658
|
+
},
|
|
9659
|
+
message: async (ws, raw) => {
|
|
9660
|
+
let bridge = bridges.get(ws);
|
|
9661
|
+
if (!bridge) {
|
|
9662
|
+
bridge = createTwilioMediaStreamBridge({
|
|
9663
|
+
close: (code, reason) => {
|
|
9664
|
+
ws.close(code, reason);
|
|
9665
|
+
},
|
|
9666
|
+
send: (data) => {
|
|
9667
|
+
ws.send(data);
|
|
9668
|
+
}
|
|
9669
|
+
}, options);
|
|
9670
|
+
bridges.set(ws, bridge);
|
|
9671
|
+
}
|
|
9672
|
+
await bridge.handleMessage(raw);
|
|
9673
|
+
}
|
|
9674
|
+
}).use(createVoiceTelephonyWebhookRoutes({
|
|
9675
|
+
...options.webhook ?? {},
|
|
9676
|
+
context: options.context,
|
|
9677
|
+
path: webhookPath,
|
|
9678
|
+
policy: webhookPolicy,
|
|
9679
|
+
provider: "twilio"
|
|
9680
|
+
}));
|
|
9681
|
+
if (!setupPath) {
|
|
9682
|
+
if (!smokePath) {
|
|
9683
|
+
return app;
|
|
9684
|
+
}
|
|
9685
|
+
return app.get(smokePath, async ({ query, request }) => {
|
|
9686
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
9687
|
+
app,
|
|
9688
|
+
options,
|
|
9689
|
+
query,
|
|
9690
|
+
request,
|
|
9691
|
+
streamPath,
|
|
9692
|
+
twimlPath,
|
|
9693
|
+
webhookPath
|
|
9694
|
+
});
|
|
9695
|
+
if (query.format === "html") {
|
|
9696
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
9697
|
+
headers: {
|
|
9698
|
+
"content-type": "text/html; charset=utf-8"
|
|
9699
|
+
}
|
|
9700
|
+
});
|
|
9701
|
+
}
|
|
9702
|
+
return report;
|
|
9703
|
+
});
|
|
9704
|
+
}
|
|
9705
|
+
const withSetup = app.get(setupPath, async ({ query, request }) => {
|
|
9706
|
+
const status = await buildTwilioVoiceSetupStatus(options, {
|
|
9707
|
+
query,
|
|
9708
|
+
request,
|
|
9709
|
+
streamPath,
|
|
9710
|
+
twimlPath,
|
|
9711
|
+
webhookPath
|
|
9712
|
+
});
|
|
9713
|
+
if (query.format === "html") {
|
|
9714
|
+
return new Response(renderTwilioVoiceSetupHTML(status, options.setup?.title ?? "AbsoluteJS Twilio Voice Setup"), {
|
|
9715
|
+
headers: {
|
|
9716
|
+
"content-type": "text/html; charset=utf-8"
|
|
9717
|
+
}
|
|
9718
|
+
});
|
|
9719
|
+
}
|
|
9720
|
+
return status;
|
|
9721
|
+
});
|
|
9722
|
+
if (!smokePath) {
|
|
9723
|
+
return withSetup;
|
|
9724
|
+
}
|
|
9725
|
+
return withSetup.get(smokePath, async ({ query, request }) => {
|
|
9726
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
9727
|
+
app,
|
|
9728
|
+
options,
|
|
9729
|
+
query,
|
|
9730
|
+
request,
|
|
9731
|
+
streamPath,
|
|
9732
|
+
twimlPath,
|
|
9733
|
+
webhookPath
|
|
9734
|
+
});
|
|
9735
|
+
if (query.format === "html") {
|
|
9736
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
9737
|
+
headers: {
|
|
9738
|
+
"content-type": "text/html; charset=utf-8"
|
|
9739
|
+
}
|
|
9740
|
+
});
|
|
9741
|
+
}
|
|
9742
|
+
return report;
|
|
9743
|
+
});
|
|
9744
|
+
};
|
|
6721
9745
|
|
|
6722
9746
|
// src/testing/telephony.ts
|
|
6723
9747
|
var DEFAULT_PCM16_FORMAT = {
|
|
@@ -6983,7 +10007,7 @@ var runVoiceTelephonyBenchmark = async (scenarios = getDefaultVoiceTelephonyBenc
|
|
|
6983
10007
|
};
|
|
6984
10008
|
};
|
|
6985
10009
|
// src/testing/tts.ts
|
|
6986
|
-
var
|
|
10010
|
+
var DEFAULT_REALTIME_FORMAT2 = {
|
|
6987
10011
|
channels: 1,
|
|
6988
10012
|
container: "raw",
|
|
6989
10013
|
encoding: "pcm_s16le",
|
|
@@ -7042,7 +10066,7 @@ var runTTSAdapterFixture = async (adapter, fixture, options = {}) => {
|
|
|
7042
10066
|
let audioDurationMs = 0;
|
|
7043
10067
|
let audioChunkCount = 0;
|
|
7044
10068
|
const session = adapter.kind === "realtime" ? await adapter.open({
|
|
7045
|
-
format: options.realtimeFormat ??
|
|
10069
|
+
format: options.realtimeFormat ?? DEFAULT_REALTIME_FORMAT2,
|
|
7046
10070
|
sessionId: `tts-benchmark:${fixture.id}`,
|
|
7047
10071
|
...openOptions ?? {}
|
|
7048
10072
|
}) : await adapter.open({
|
|
@@ -7208,6 +10232,8 @@ export {
|
|
|
7208
10232
|
getDefaultVoiceDuplexBenchmarkScenarios,
|
|
7209
10233
|
getDefaultTTSBenchmarkFixtures,
|
|
7210
10234
|
evaluateSTTBenchmarkAcceptance,
|
|
10235
|
+
createVoiceProviderFailureSimulator,
|
|
10236
|
+
createVoiceIOProviderFailureSimulator,
|
|
7211
10237
|
createVoiceCallReviewRecorder,
|
|
7212
10238
|
createVoiceCallReviewFromLiveTelephonyReport,
|
|
7213
10239
|
createTelephonyVoiceTestFixtures,
|