@telnyx/voice-agent-tester 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,315 @@
1
+ /**
2
+ * External Provider to Telnyx Assistant Import Module
3
+ *
4
+ * Imports assistants from external providers (vapi, elevenlabs, retell)
5
+ * into Telnyx using the Telnyx AI Assistants Import API.
6
+ *
7
+ * Features:
8
+ * - Auto-creates integration secrets from provider API keys
9
+ * - Enables unauthenticated web calls for imported assistants
10
+ */
11
+
12
+ const TELNYX_BASE_URL = 'https://api.telnyx.com/v2';
13
+ const TELNYX_SECRETS_ENDPOINT = `${TELNYX_BASE_URL}/integration_secrets`;
14
+ const TELNYX_IMPORT_ENDPOINT = `${TELNYX_BASE_URL}/ai/assistants/import`;
15
+ const TELNYX_ASSISTANTS_ENDPOINT = `${TELNYX_BASE_URL}/ai/assistants`;
16
+
17
+ // Supported providers
18
+ const SUPPORTED_PROVIDERS = ['vapi', 'elevenlabs', 'retell'];
19
+
20
+ // Default widget settings for benchmarking
21
+ const DEFAULT_WIDGET_SETTINGS = {
22
+ theme: 'dark',
23
+ audio_visualizer_config: {
24
+ color: 'verdant',
25
+ preset: 'roundBars'
26
+ },
27
+ start_call_text: '',
28
+ default_state: 'expanded',
29
+ position: 'fixed',
30
+ view_history_url: null,
31
+ report_issue_url: null,
32
+ give_feedback_url: null,
33
+ agent_thinking_text: '',
34
+ speak_to_interrupt_text: '',
35
+ logo_icon_url: null
36
+ };
37
+
38
+ /**
39
+ * Create an integration secret in Telnyx from a provider's API key.
40
+ *
41
+ * @param {Object} options
42
+ * @param {string} options.identifier - Unique identifier for the secret
43
+ * @param {string} options.token - The API key/token to store
44
+ * @param {string} options.telnyxApiKey - Telnyx API key for authentication
45
+ * @returns {Promise<{id: string, identifier: string}>}
46
+ */
47
+ async function createIntegrationSecret({ identifier, token, telnyxApiKey }) {
48
+ console.log(`🔐 Creating integration secret: ${identifier}`);
49
+
50
+ const response = await fetch(TELNYX_SECRETS_ENDPOINT, {
51
+ method: 'POST',
52
+ headers: {
53
+ 'Authorization': `Bearer ${telnyxApiKey}`,
54
+ 'Content-Type': 'application/json'
55
+ },
56
+ body: JSON.stringify({
57
+ identifier: identifier,
58
+ type: 'bearer',
59
+ token: token
60
+ })
61
+ });
62
+
63
+ if (!response.ok) {
64
+ const errorText = await response.text();
65
+ throw new Error(`Failed to create integration secret: ${response.status} - ${errorText}`);
66
+ }
67
+
68
+ const data = await response.json();
69
+ console.log(`✅ Integration secret created: ${data.data.identifier}`);
70
+
71
+ return {
72
+ id: data.data.id,
73
+ identifier: data.data.identifier
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Get assistant details from Telnyx API.
79
+ *
80
+ * @param {Object} options
81
+ * @param {string} options.assistantId - The Telnyx assistant ID
82
+ * @param {string} options.telnyxApiKey - Telnyx API key
83
+ * @returns {Promise<Object>} - Assistant details
84
+ */
85
+ export async function getAssistant({ assistantId, telnyxApiKey }) {
86
+ const response = await fetch(`${TELNYX_ASSISTANTS_ENDPOINT}/${assistantId}`, {
87
+ method: 'GET',
88
+ headers: {
89
+ 'Authorization': `Bearer ${telnyxApiKey}`,
90
+ 'Content-Type': 'application/json'
91
+ }
92
+ });
93
+
94
+ if (!response.ok) {
95
+ const errorText = await response.text();
96
+ throw new Error(`Failed to get assistant: ${response.status} ${errorText}`);
97
+ }
98
+
99
+ const data = await response.json();
100
+
101
+ // API returns data at root level for single assistant GET
102
+ if (!data.id) {
103
+ throw new Error(`Assistant not found: ${assistantId}`);
104
+ }
105
+
106
+ return data;
107
+ }
108
+
109
+ /**
110
+ * Enable unauthenticated web calls for a Telnyx assistant.
111
+ *
112
+ * @param {Object} options
113
+ * @param {string} options.assistantId - The Telnyx assistant ID
114
+ * @param {string} options.telnyxApiKey - Telnyx API key
115
+ * @param {Object} options.assistant - Optional existing assistant data to preserve settings
116
+ * @returns {Promise<boolean>} - true if successful
117
+ */
118
+ export async function enableWebCalls({ assistantId, telnyxApiKey, assistant }) {
119
+ console.log(`🔧 Enabling unauthenticated web calls for assistant ${assistantId}...`);
120
+
121
+ // Preserve existing telephony_settings and just enable web calls
122
+ const telephonySettings = {
123
+ ...(assistant?.telephony_settings || {}),
124
+ supports_unauthenticated_web_calls: true
125
+ };
126
+
127
+ // Build request body preserving widget_settings if they exist
128
+ const requestBody = {
129
+ telephony_settings: telephonySettings
130
+ };
131
+
132
+ // Use existing widget_settings or set defaults
133
+ if (assistant?.widget_settings && Object.keys(assistant.widget_settings).length > 0) {
134
+ requestBody.widget_settings = assistant.widget_settings;
135
+ } else {
136
+ requestBody.widget_settings = DEFAULT_WIDGET_SETTINGS;
137
+ }
138
+
139
+ const response = await fetch(`${TELNYX_ASSISTANTS_ENDPOINT}/${assistantId}`, {
140
+ method: 'POST',
141
+ headers: {
142
+ 'Authorization': `Bearer ${telnyxApiKey}`,
143
+ 'Content-Type': 'application/json'
144
+ },
145
+ body: JSON.stringify(requestBody)
146
+ });
147
+
148
+ if (!response.ok) {
149
+ const errorText = await response.text();
150
+ throw new Error(`Failed to enable web calls: ${response.status} ${errorText}`);
151
+ }
152
+
153
+ console.log(`✅ Unauthenticated web calls enabled`);
154
+ return true;
155
+ }
156
+
157
+ /**
158
+ * Configure an imported assistant with web calls enabled, widget settings, and timestamped name.
159
+ * Returns true if successful, false if failed (with warning).
160
+ *
161
+ * @param {Object} options
162
+ * @param {string} options.assistantId - The assistant ID
163
+ * @param {string} options.assistantName - The original assistant name
164
+ * @param {string} options.telnyxApiKey - Telnyx API key for authentication
165
+ * @returns {Promise<boolean>}
166
+ */
167
+ async function configureImportedAssistant({ assistantId, assistantName, telnyxApiKey, provider }) {
168
+ // Generate UTC timestamp suffix
169
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
170
+ const providerLabel = provider ? `_${provider}` : '';
171
+ const newName = `${assistantName || 'Imported'}${providerLabel}_${timestamp}`;
172
+
173
+ console.log(`🔧 Configuring assistant: ${assistantId}`);
174
+ console.log(` 📝 Renaming to: ${newName}`);
175
+
176
+ try {
177
+ const response = await fetch(`${TELNYX_ASSISTANTS_ENDPOINT}/${assistantId}`, {
178
+ method: 'POST',
179
+ headers: {
180
+ 'Authorization': `Bearer ${telnyxApiKey}`,
181
+ 'Content-Type': 'application/json'
182
+ },
183
+ body: JSON.stringify({
184
+ name: newName,
185
+ model: 'Qwen/Qwen3-235B-A22',
186
+ telephony_settings: {
187
+ supports_unauthenticated_web_calls: true
188
+ },
189
+ widget_settings: DEFAULT_WIDGET_SETTINGS
190
+ })
191
+ });
192
+
193
+ if (!response.ok) {
194
+ const errorText = await response.text();
195
+ console.warn(`⚠️ Could not configure assistant ${assistantId}: ${response.status}`);
196
+ console.warn(` This may require manual configuration in the Telnyx portal.`);
197
+ return false;
198
+ }
199
+
200
+ console.log(`✅ Assistant configured: ${newName}`);
201
+ return true;
202
+ } catch (error) {
203
+ console.warn(`⚠️ Error configuring assistant ${assistantId}: ${error.message}`);
204
+ return false;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Import assistants from an external provider into Telnyx.
210
+ *
211
+ * This function:
212
+ * 1. Creates an integration secret from the provider's private API key
213
+ * 2. Imports assistants from the provider (optionally filtered by ID)
214
+ * 3. Enables unauthenticated web calls for each imported assistant
215
+ *
216
+ * @param {Object} options - Import options
217
+ * @param {string} options.provider - External provider name (vapi, elevenlabs, retell)
218
+ * @param {string} options.providerApiKey - The provider's private API key
219
+ * @param {string} options.telnyxApiKey - Telnyx API key for authentication
220
+ * @param {string} [options.assistantId] - Optional: specific assistant ID to import
221
+ * @returns {Promise<{assistants: Array<{id: string, name: string}>, assistantId: string}>}
222
+ */
223
+ export async function importAssistantsFromProvider({ provider, providerApiKey, telnyxApiKey, assistantId }) {
224
+ // Validate provider
225
+ if (!SUPPORTED_PROVIDERS.includes(provider)) {
226
+ throw new Error(`Unsupported provider: ${provider}. Supported providers: ${SUPPORTED_PROVIDERS.join(', ')}`);
227
+ }
228
+
229
+ console.log(`\n🔄 Importing assistants from ${provider} into Telnyx...`);
230
+
231
+ try {
232
+ // Step 1: Create integration secret from provider API key
233
+ const secretIdentifier = `${provider}_import_${Date.now()}`;
234
+ const secret = await createIntegrationSecret({
235
+ identifier: secretIdentifier,
236
+ token: providerApiKey,
237
+ telnyxApiKey
238
+ });
239
+
240
+ // Step 2: Import assistant using the secret reference
241
+ console.log(`📥 Importing assistant ${assistantId} using secret: ${secret.identifier}`);
242
+
243
+ // Build import request body with specific assistant ID
244
+ const importBody = {
245
+ provider: provider,
246
+ api_key_ref: secret.identifier,
247
+ import_ids: [assistantId]
248
+ };
249
+
250
+ const importResponse = await fetch(TELNYX_IMPORT_ENDPOINT, {
251
+ method: 'POST',
252
+ headers: {
253
+ 'Authorization': `Bearer ${telnyxApiKey}`,
254
+ 'Content-Type': 'application/json'
255
+ },
256
+ body: JSON.stringify(importBody)
257
+ });
258
+
259
+ if (!importResponse.ok) {
260
+ const errorText = await importResponse.text();
261
+ throw new Error(`Telnyx import API failed with status ${importResponse.status}: ${errorText}`);
262
+ }
263
+
264
+ const importData = await importResponse.json();
265
+ const assistants = importData.data || [];
266
+
267
+ if (assistants.length === 0) {
268
+ throw new Error(`No assistant was imported for ID "${assistantId}"`);
269
+ }
270
+
271
+ // Validate that we got the correct assistant
272
+ const importedAssistant = assistants[0];
273
+ const importId = importedAssistant.import_metadata?.import_id;
274
+
275
+ if (importId !== assistantId) {
276
+ throw new Error(`Import mismatch: requested "${assistantId}" but got "${importId}"`);
277
+ }
278
+
279
+ // Check if this is a previously imported assistant (re-using existing Telnyx assistant)
280
+ const importedAt = importedAssistant.import_metadata?.imported_at;
281
+ const isReused = importedAt && (Date.now() - new Date(importedAt).getTime() > 60000); // More than 1 minute ago
282
+
283
+ if (isReused) {
284
+ console.log(`♻️ Re-using previously imported assistant: ${importedAssistant.name} (${importedAssistant.id})`);
285
+ console.log(` Originally imported at: ${importedAt}`);
286
+ } else {
287
+ console.log(`✅ Successfully imported: ${importedAssistant.name} (${importedAssistant.id})`);
288
+
289
+ // Only configure newly imported assistants (rename with timestamp, enable web calls, set widget settings)
290
+ console.log(`\n🔧 Configuring imported assistant...`);
291
+
292
+ await configureImportedAssistant({
293
+ assistantId: importedAssistant.id,
294
+ assistantName: importedAssistant.name,
295
+ telnyxApiKey,
296
+ provider
297
+ });
298
+ }
299
+
300
+ return {
301
+ assistants: [{
302
+ id: importedAssistant.id,
303
+ name: importedAssistant.name,
304
+ import_id: importId
305
+ }],
306
+ assistantId: importedAssistant.id
307
+ };
308
+ } catch (error) {
309
+ console.error(`❌ Failed to import assistants from ${provider}:`, error.message);
310
+ throw error;
311
+ }
312
+ }
313
+
314
+ // Export supported providers for CLI validation
315
+ export { SUPPORTED_PROVIDERS };
package/src/report.js ADDED
@@ -0,0 +1,228 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ export class ReportGenerator {
5
+ constructor(filePath) {
6
+ this.filePath = path.resolve(filePath);
7
+ this.allRunsData = [];
8
+ // Map: "app|scenario|repetition" -> { metadata, stepMetrics, startTime }
9
+ this.runs = new Map();
10
+ this.stepColumns = new Map(); // Map of stepIndex -> Map of metricName -> column name
11
+ }
12
+
13
+ // Create a unique key for each run
14
+ _getRunKey(appName, scenarioName, repetition) {
15
+ return `${appName}|${scenarioName}|${repetition}`;
16
+ }
17
+
18
+ beginRun(appName, scenarioName, repetition) {
19
+ const key = this._getRunKey(appName, scenarioName, repetition);
20
+ this.runs.set(key, {
21
+ metadata: {
22
+ app: appName,
23
+ scenario: scenarioName,
24
+ repetition: repetition,
25
+ startTime: Date.now()
26
+ },
27
+ stepMetrics: new Map() // Map of stepIndex -> Map of metricName -> value
28
+ });
29
+ }
30
+
31
+ recordStepMetric(appName, scenarioName, repetition, stepIndex, action, name, value) {
32
+ const key = this._getRunKey(appName, scenarioName, repetition);
33
+ const run = this.runs.get(key);
34
+
35
+ if (!run) {
36
+ console.warn(`Warning: Attempting to record metric for non-existent run: ${key}`);
37
+ return;
38
+ }
39
+
40
+ // Initialize step metrics map if it doesn't exist
41
+ if (!run.stepMetrics.has(stepIndex)) {
42
+ run.stepMetrics.set(stepIndex, new Map());
43
+ }
44
+
45
+ // Record the metric value
46
+ run.stepMetrics.get(stepIndex).set(name, value);
47
+
48
+ // Track column names based on step index, action, and metric name
49
+ if (!this.stepColumns.has(stepIndex)) {
50
+ this.stepColumns.set(stepIndex, new Map());
51
+ }
52
+ if (!this.stepColumns.get(stepIndex).has(name)) {
53
+ this.stepColumns.get(stepIndex).set(name, `step_${stepIndex + 1}_${action}_${name}`);
54
+ }
55
+ }
56
+
57
+ endRun(appName, scenarioName, repetition, success = true) {
58
+ const key = this._getRunKey(appName, scenarioName, repetition);
59
+ const run = this.runs.get(key);
60
+
61
+ if (!run) {
62
+ console.warn(`Warning: Attempting to end non-existent run: ${key}`);
63
+ return;
64
+ }
65
+
66
+ // Calculate duration
67
+ const duration = Date.now() - run.metadata.startTime;
68
+
69
+ // Deep copy the nested Map structure
70
+ const runCopy = new Map();
71
+ run.stepMetrics.forEach((metricsMap, stepIndex) => {
72
+ runCopy.set(stepIndex, new Map(metricsMap));
73
+ });
74
+
75
+ this.allRunsData.push({
76
+ metadata: {
77
+ app: run.metadata.app,
78
+ scenario: run.metadata.scenario,
79
+ repetition: run.metadata.repetition,
80
+ success: success ? 1 : 0,
81
+ duration: duration
82
+ },
83
+ stepMetrics: runCopy
84
+ });
85
+
86
+ // Remove the run from active runs map
87
+ this.runs.delete(key);
88
+ }
89
+
90
+ generateCSV() {
91
+ if (this.allRunsData.length === 0) {
92
+ console.warn('No step times recorded for report generation');
93
+ return;
94
+ }
95
+
96
+ // Collect all step indices and their metrics
97
+ const allStepMetrics = new Map(); // Map of stepIndex -> Set of metricNames
98
+ this.allRunsData.forEach(run => {
99
+ run.stepMetrics.forEach((metrics, stepIndex) => {
100
+ if (!allStepMetrics.has(stepIndex)) {
101
+ allStepMetrics.set(stepIndex, new Set());
102
+ }
103
+ metrics.forEach((_, metricName) => {
104
+ allStepMetrics.get(stepIndex).add(metricName);
105
+ });
106
+ });
107
+ });
108
+
109
+ // Sort step indices
110
+ const sortedStepIndices = Array.from(allStepMetrics.keys()).sort((a, b) => a - b);
111
+
112
+ // Build column headers - start with app, scenario, repetition, success, and duration
113
+ const headers = ['app', 'scenario', 'repetition', 'success', 'duration'];
114
+ sortedStepIndices.forEach(stepIndex => {
115
+ const metricNames = Array.from(allStepMetrics.get(stepIndex)).sort();
116
+ metricNames.forEach(metricName => {
117
+ const columnName = this.stepColumns.get(stepIndex)?.get(metricName) ||
118
+ `step_${stepIndex + 1}_${metricName}`;
119
+ headers.push(columnName);
120
+ });
121
+ });
122
+
123
+ // Create CSV data rows
124
+ const dataRows = this.allRunsData.map(run => {
125
+ // Start with metadata columns
126
+ const row = [
127
+ run.metadata.app,
128
+ run.metadata.scenario,
129
+ run.metadata.repetition,
130
+ run.metadata.success,
131
+ run.metadata.duration
132
+ ];
133
+
134
+ // Add step metrics
135
+ sortedStepIndices.forEach(stepIndex => {
136
+ const stepMetrics = run.stepMetrics.get(stepIndex) || new Map();
137
+ const metricNames = Array.from(allStepMetrics.get(stepIndex)).sort();
138
+ metricNames.forEach(metricName => {
139
+ const value = stepMetrics.get(metricName);
140
+ row.push(value !== undefined ? value : '');
141
+ });
142
+ });
143
+ return row.join(', ');
144
+ });
145
+
146
+ const csvContent = `${headers.join(', ')}\n${dataRows.join('\n')}\n`;
147
+
148
+ // Write to file
149
+ fs.writeFileSync(this.filePath, csvContent, 'utf8');
150
+ console.log(`Report generated: ${this.filePath}`);
151
+ }
152
+
153
+ generateMetricsSummary() {
154
+ if (this.allRunsData.length === 0) {
155
+ return;
156
+ }
157
+
158
+ console.log('\n=== METRICS SUMMARY ===');
159
+
160
+ // Collect all step indices and their metrics
161
+ const allStepMetrics = new Map(); // Map of stepIndex -> Set of metricNames
162
+ this.allRunsData.forEach(run => {
163
+ run.stepMetrics.forEach((metrics, stepIndex) => {
164
+ if (!allStepMetrics.has(stepIndex)) {
165
+ allStepMetrics.set(stepIndex, new Set());
166
+ }
167
+ metrics.forEach((_, metricName) => {
168
+ allStepMetrics.get(stepIndex).add(metricName);
169
+ });
170
+ });
171
+ });
172
+
173
+ const sortedStepIndices = Array.from(allStepMetrics.keys()).sort((a, b) => a - b);
174
+
175
+ if (sortedStepIndices.length === 0) {
176
+ console.log('No metrics collected during test runs.');
177
+ return;
178
+ }
179
+
180
+ sortedStepIndices.forEach(stepIndex => {
181
+ const metricNames = Array.from(allStepMetrics.get(stepIndex)).sort();
182
+
183
+ metricNames.forEach(metricName => {
184
+ const columnName = this.stepColumns.get(stepIndex)?.get(metricName) ||
185
+ `step_${stepIndex + 1}_${metricName}`;
186
+ const values = [];
187
+
188
+ this.allRunsData.forEach(run => {
189
+ const stepMetrics = run.stepMetrics.get(stepIndex);
190
+ if (stepMetrics && stepMetrics.has(metricName)) {
191
+ values.push(stepMetrics.get(metricName));
192
+ }
193
+ });
194
+
195
+ if (values.length > 0) {
196
+ const sum = values.reduce((a, b) => a + b, 0);
197
+ const average = sum / values.length;
198
+ const min = Math.min(...values);
199
+ const max = Math.max(...values);
200
+
201
+ // Calculate p50 (median)
202
+ const sortedValues = [...values].sort((a, b) => a - b);
203
+ let p50;
204
+ if (sortedValues.length % 2 === 0) {
205
+ // Even number of samples: average of two middle values
206
+ const mid1 = sortedValues[sortedValues.length / 2 - 1];
207
+ const mid2 = sortedValues[sortedValues.length / 2];
208
+ p50 = (mid1 + mid2) / 2;
209
+ } else {
210
+ // Odd number of samples: middle value
211
+ p50 = sortedValues[Math.floor(sortedValues.length / 2)];
212
+ }
213
+
214
+ // Only add "ms" unit for elapsed_time metrics
215
+ const unit = metricName === 'elapsed_time' ? 'ms' : '';
216
+ const formatValue = (val) => unit ? `${Math.round(val)}${unit}` : val.toFixed(2);
217
+
218
+ console.log(`${columnName}:`);
219
+ console.log(` Average: ${formatValue(average)}`);
220
+ console.log(` Min: ${formatValue(min)}`);
221
+ console.log(` Max: ${formatValue(max)}`);
222
+ console.log(` p50: ${formatValue(p50)}`);
223
+ console.log('');
224
+ }
225
+ });
226
+ });
227
+ }
228
+ }
package/src/server.js ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+
3
+ import express from 'express';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ const app = express();
11
+ const PORT = process.env.HTTP_PORT || process.env.PORT || 3333;
12
+
13
+ // Serve static assets from the assets folder
14
+ app.use('/assets', express.static(path.join(__dirname, '..', 'assets')));
15
+
16
+ // Serve JavaScript files
17
+ app.use('/javascript', express.static(path.join(__dirname, '..', 'javascript')));
18
+
19
+ // Basic health check endpoint
20
+ app.get('/health', (req, res) => {
21
+ res.json({ status: 'OK', timestamp: new Date().toISOString() });
22
+ });
23
+
24
+ export function createServer() {
25
+ const server = app.listen(PORT, () => {
26
+ console.log(`Server running on http://localhost:${PORT}`);
27
+ });
28
+ return server;
29
+ }
30
+
31
+ export default app;