@juspay/neurolink 12.47.14 → 12.48.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/dist/auth/middleware/AuthMiddleware.d.ts +1 -1
  3. package/dist/browser/neurolink.min.js +578 -565
  4. package/dist/cli/factories/commandFactory.d.ts +7 -0
  5. package/dist/cli/factories/commandFactory.js +26 -0
  6. package/dist/cli/loop/optionsSchema.js +9 -0
  7. package/dist/context/budgetChecker.js +54 -2
  8. package/dist/context/contextDecision.js +2 -0
  9. package/dist/context/toolPairRepair.d.ts +12 -0
  10. package/dist/context/toolPairRepair.js +29 -3
  11. package/dist/core/baseProvider.d.ts +92 -3
  12. package/dist/core/baseProvider.js +254 -10
  13. package/dist/core/conversationMemoryManager.js +17 -2
  14. package/dist/core/loopEngine.js +1 -1
  15. package/dist/core/modules/MessageBuilder.js +11 -3
  16. package/dist/core/modules/TelemetryHandler.d.ts +2 -2
  17. package/dist/core/modules/ToolsManager.d.ts +35 -0
  18. package/dist/core/modules/ToolsManager.js +159 -55
  19. package/dist/core/nativeGenerateLoop.d.ts +58 -1
  20. package/dist/core/nativeGenerateLoop.js +277 -3
  21. package/dist/core/redisConversationMemoryManager.js +10 -3
  22. package/dist/core/resolveRequestKind.d.ts +24 -4
  23. package/dist/core/resolveRequestKind.js +31 -4
  24. package/dist/core/toolExecutionRecorder.d.ts +6 -0
  25. package/dist/core/toolExecutionRecorder.js +24 -2
  26. package/dist/core/toolRoutingDecision.js +1 -0
  27. package/dist/factories/mediaHandlerCatalog.js +5 -0
  28. package/dist/factories/providerDescriptors.js +15 -0
  29. package/dist/files/fileTools.d.ts +1 -1
  30. package/dist/index.d.ts +5 -2
  31. package/dist/index.js +9 -2
  32. package/dist/memory/condensationPrompt.d.ts +8 -0
  33. package/dist/memory/condensationPrompt.js +29 -0
  34. package/dist/memory/hippocampusInitializer.d.ts +14 -2
  35. package/dist/memory/hippocampusInitializer.js +64 -3
  36. package/dist/neurolink.d.ts +252 -3
  37. package/dist/neurolink.js +898 -43
  38. package/dist/providers/amazonBedrock/client.js +6 -0
  39. package/dist/providers/amazonSagemaker.js +14 -1
  40. package/dist/providers/anthropic/client.js +148 -19
  41. package/dist/providers/anthropic/loopAdapter.js +2 -2
  42. package/dist/providers/googleAiStudio/client.js +2 -1
  43. package/dist/providers/openaiChatCompletionsBase.js +103 -23
  44. package/dist/providers/systemOneDecision.d.ts +6 -0
  45. package/dist/providers/systemOneDecision.js +14 -63
  46. package/dist/providers/typesafe.d.ts +6 -0
  47. package/dist/providers/typesafe.js +6 -0
  48. package/dist/rag/retrieval/searchDecision.js +1 -0
  49. package/dist/routing/classifierRouter.js +6 -5
  50. package/dist/routing/classifierStrategies.js +8 -1
  51. package/dist/types/classifierRouter.d.ts +16 -3
  52. package/dist/types/common.d.ts +2 -0
  53. package/dist/types/config.d.ts +9 -0
  54. package/dist/types/context.d.ts +8 -1
  55. package/dist/types/conversation.d.ts +87 -0
  56. package/dist/types/decision.d.ts +210 -1
  57. package/dist/types/decision.js +9 -1
  58. package/dist/types/dispatch.d.ts +11 -4
  59. package/dist/types/generate.d.ts +118 -54
  60. package/dist/types/loopEngine.d.ts +7 -2
  61. package/dist/types/memory.d.ts +177 -9
  62. package/dist/types/openaiCompatible.d.ts +11 -3
  63. package/dist/types/stream.d.ts +13 -16
  64. package/dist/types/stt.d.ts +58 -1
  65. package/dist/types/tools.d.ts +14 -0
  66. package/dist/types/tools.js +8 -0
  67. package/dist/types/tts.d.ts +108 -0
  68. package/dist/types/voice.d.ts +41 -4
  69. package/dist/utils/decisionAnswers.d.ts +31 -1
  70. package/dist/utils/decisionAnswers.js +114 -0
  71. package/dist/utils/decisionLimits.d.ts +39 -0
  72. package/dist/utils/decisionLimits.js +116 -0
  73. package/dist/utils/logger.d.ts +7 -0
  74. package/dist/utils/logger.js +9 -0
  75. package/dist/utils/messageBuilder.d.ts +45 -1
  76. package/dist/utils/messageBuilder.js +393 -16
  77. package/dist/utils/multimodalOptionsBuilder.d.ts +1 -0
  78. package/dist/utils/multimodalOptionsBuilder.js +1 -0
  79. package/dist/utils/speechText.d.ts +41 -0
  80. package/dist/utils/speechText.js +364 -0
  81. package/dist/utils/toolResultStatus.d.ts +26 -0
  82. package/dist/utils/toolResultStatus.js +73 -0
  83. package/dist/utils/ttsProcessor.d.ts +29 -0
  84. package/dist/utils/ttsProcessor.js +84 -2
  85. package/dist/voice/index.d.ts +1 -0
  86. package/dist/voice/index.js +3 -0
  87. package/dist/voice/providers/ElevenLabsSTT.d.ts +42 -0
  88. package/dist/voice/providers/ElevenLabsSTT.js +351 -0
  89. package/dist/voice/providers/ElevenLabsTTS.d.ts +81 -1
  90. package/dist/voice/providers/ElevenLabsTTS.js +415 -74
  91. package/docs-site/static/search-index.json +48 -33
  92. package/package.json +5 -2
  93. package/dist/utils/toolEndEmitter.d.ts +0 -25
  94. package/dist/utils/toolEndEmitter.js +0 -64
@@ -6,8 +6,76 @@
6
6
  * @module voice/providers/ElevenLabsTTS
7
7
  */
8
8
  import { ErrorCategory, ErrorSeverity } from "../../constants/enums.js";
9
+ import { delay } from "../../utils/async/delay.js";
10
+ import { calculateBackoff } from "../../utils/async/retry.js";
9
11
  import { logger } from "../../utils/logger.js";
10
12
  import { TTS_ERROR_CODES, TTSError } from "../../utils/ttsProcessor.js";
13
+ const DEFAULT_BASE_URL = "https://api.elevenlabs.io/v1";
14
+ const DEFAULT_TIMEOUT_MS = 30_000;
15
+ /** Largest delay `setTimeout` honours; above it the timer fires at once. */
16
+ const MAX_TIMEOUT_MS = 2_147_483_647;
17
+ const DEFAULT_RETRIES = 1;
18
+ const DEFAULT_MODEL = "eleven_multilingual_v2";
19
+ const DEFAULT_VOICE_ID = "21m00Tcm4TlvDq8ikWAM"; // Rachel
20
+ const DEFAULT_OPUS_BITRATE = 64;
21
+ const DEFAULT_MP3_BITRATE = 128;
22
+ /** The kbps values ElevenLabs serves on `opus_48000_*` / `mp3_44100_*`. */
23
+ const OPUS_BITRATES = new Set([32, 64, 96, 128, 192]);
24
+ const MP3_BITRATES = new Set([32, 64, 96, 128, 192]);
25
+ /** ElevenLabs accepts `voice_settings.speed` in this range only. */
26
+ const SPEED_MIN = 0.7;
27
+ const SPEED_MAX = 1.2;
28
+ /** Backoff between retries when the response carries no `Retry-After`. */
29
+ const RETRY_BASE_DELAY_MS = 500;
30
+ const RETRY_MAX_DELAY_MS = 5_000;
31
+ /** Upper bound honoured for a server-supplied `Retry-After`. */
32
+ const RETRY_AFTER_CAP_MS = 10_000;
33
+ /**
34
+ * Models that reject the `language_code` request field. The API reference
35
+ * names only `multilingual_v2`; on every other model an unsupported code is
36
+ * ignored server-side, so the field is sent rather than dropped — the old
37
+ * two-model allow-list dropped a pin that v3 and the v4 family honour.
38
+ */
39
+ const LANGUAGE_CODE_REJECTING_MODELS = new Set([
40
+ "eleven_multilingual_v2",
41
+ ]);
42
+ /**
43
+ * Error codes a failed `fetch()` carries (directly or on `cause`) when the
44
+ * failure was the transport, not the request: worth a resend. Anything else
45
+ * a `fetch()` throws before a request is sent — an invalid header value, a
46
+ * malformed URL — is a permanent configuration error and is not retried.
47
+ */
48
+ const TRANSPORT_ERROR_CODES = new Set([
49
+ "ECONNRESET",
50
+ "ECONNREFUSED",
51
+ "ECONNABORTED",
52
+ "ETIMEDOUT",
53
+ "EPIPE",
54
+ "EAI_AGAIN",
55
+ "ENOTFOUND",
56
+ "EHOSTUNREACH",
57
+ "ENETUNREACH",
58
+ "UND_ERR_SOCKET",
59
+ "UND_ERR_CONNECT_TIMEOUT",
60
+ "UND_ERR_HEADERS_TIMEOUT",
61
+ "UND_ERR_BODY_TIMEOUT",
62
+ ]);
63
+ /** The numeric sample-rate segment of an `output_format` (`opus_48000_64` → 48000). */
64
+ const SAMPLE_RATE_SEGMENT = /_(\d{4,5})(?:_|$)/;
65
+ /**
66
+ * Thrown inside the retry loop for a non-2xx response so the loop can decide
67
+ * on status alone whether another attempt is worth making.
68
+ */
69
+ class ElevenLabsHttpError extends Error {
70
+ status;
71
+ retryAfterMs;
72
+ constructor(message, status, retryAfterMs) {
73
+ super(message);
74
+ this.status = status;
75
+ this.retryAfterMs = retryAfterMs;
76
+ this.name = "ElevenLabsHttpError";
77
+ }
78
+ }
11
79
  /**
12
80
  * ElevenLabs Text-to-Speech Handler
13
81
  *
@@ -17,7 +85,11 @@ import { TTS_ERROR_CODES, TTSError } from "../../utils/ttsProcessor.js";
17
85
  */
18
86
  export class ElevenLabsTTS {
19
87
  apiKey;
20
- baseUrl = "https://api.elevenlabs.io/v1";
88
+ baseUrl;
89
+ /** `ELEVENLABS_VOICE_ID`, else Rachel; a per-request `voice` still wins. */
90
+ defaultVoiceId;
91
+ /** `ELEVENLABS_MODEL`, else `eleven_multilingual_v2`; a per-request `model` still wins. */
92
+ defaultModel;
21
93
  voicesCache = null;
22
94
  static CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
23
95
  /**
@@ -27,6 +99,16 @@ export class ElevenLabsTTS {
27
99
  constructor(apiKey) {
28
100
  const resolvedKey = (apiKey ?? process.env.ELEVENLABS_API_KEY ?? "").trim();
29
101
  this.apiKey = resolvedKey.length > 0 ? resolvedKey : null;
102
+ this.baseUrl = ElevenLabsTTS.normalizeBaseUrl(process.env.ELEVENLABS_BASE_URL ?? DEFAULT_BASE_URL);
103
+ this.defaultVoiceId =
104
+ ElevenLabsTTS.envValue("ELEVENLABS_VOICE_ID") ?? DEFAULT_VOICE_ID;
105
+ this.defaultModel =
106
+ ElevenLabsTTS.envValue("ELEVENLABS_MODEL") ?? DEFAULT_MODEL;
107
+ }
108
+ /** A trimmed, non-empty environment value, else `undefined`. */
109
+ static envValue(name) {
110
+ const value = process.env[name]?.trim();
111
+ return value ? value : undefined;
30
112
  }
31
113
  isConfigured() {
32
114
  return this.apiKey !== null;
@@ -49,7 +131,7 @@ export class ElevenLabsTTS {
49
131
  }
50
132
  try {
51
133
  const voicesController = new AbortController();
52
- const voicesTimeoutId = setTimeout(() => voicesController.abort(), 30000);
134
+ const voicesTimeoutId = setTimeout(() => voicesController.abort(), DEFAULT_TIMEOUT_MS);
53
135
  let response;
54
136
  try {
55
137
  response = await fetch(`${this.baseUrl}/voices`, {
@@ -64,7 +146,7 @@ export class ElevenLabsTTS {
64
146
  if (fetchErr instanceof Error && fetchErr.name === "AbortError") {
65
147
  throw new TTSError({
66
148
  code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
67
- message: "ElevenLabs voices request timed out after 30 seconds",
149
+ message: `ElevenLabs voices request timed out after ${DEFAULT_TIMEOUT_MS}ms`,
68
150
  category: ErrorCategory.NETWORK,
69
151
  severity: ErrorSeverity.MEDIUM,
70
152
  retriable: true,
@@ -145,65 +227,37 @@ export class ElevenLabsTTS {
145
227
  const startTime = Date.now();
146
228
  const elevenOptions = options;
147
229
  try {
148
- // Get voice ID (use default if not specified)
149
- const voiceId = options.voice ?? "21m00Tcm4TlvDq8ikWAM"; // Rachel voice as default
150
- // Determine model
151
- const model = elevenOptions.model ?? "eleven_multilingual_v2";
152
- // Build request body
230
+ const voiceId = options.voice ?? this.defaultVoiceId;
231
+ const model = elevenOptions.model ?? this.defaultModel;
232
+ const outputFormat = this.mapFormat(options.format ?? "mp3", elevenOptions);
233
+ const baseUrl = elevenOptions.baseUrl
234
+ ? ElevenLabsTTS.normalizeBaseUrl(elevenOptions.baseUrl)
235
+ : this.baseUrl;
236
+ const timeoutMs = ElevenLabsTTS.resolveTimeoutMs(elevenOptions.timeoutMs);
237
+ const retries = ElevenLabsTTS.resolveRetries(elevenOptions.retries);
153
238
  const requestBody = {
154
239
  text,
155
240
  model_id: model,
156
- voice_settings: {
157
- stability: elevenOptions.stability ?? 0.5,
158
- similarity_boost: elevenOptions.similarityBoost ?? 0.75,
159
- style: elevenOptions.style ?? 0.0,
160
- use_speaker_boost: elevenOptions.useSpeakerBoost ?? true,
161
- },
241
+ voice_settings: this.buildVoiceSettings(elevenOptions),
162
242
  };
163
- // Determine output format
164
- const outputFormat = this.mapFormat(options.format ?? "mp3");
165
- const controller = new AbortController();
166
- const timeoutId = setTimeout(() => controller.abort(), 30000);
167
- let response;
168
- try {
169
- response = await fetch(`${this.baseUrl}/text-to-speech/${voiceId}?output_format=${outputFormat}`, {
170
- method: "POST",
171
- headers: {
172
- "xi-api-key": this.apiKey,
173
- "Content-Type": "application/json",
174
- },
175
- body: JSON.stringify(requestBody),
176
- signal: controller.signal,
177
- });
178
- }
179
- catch (fetchErr) {
180
- if (fetchErr instanceof Error && fetchErr.name === "AbortError") {
181
- throw new TTSError({
182
- code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
183
- message: "ElevenLabs TTS request timed out after 30 seconds",
184
- category: ErrorCategory.NETWORK,
185
- severity: ErrorSeverity.HIGH,
186
- retriable: true,
187
- originalError: fetchErr,
188
- });
243
+ const languageCode = elevenOptions.languageCode ?? options.language;
244
+ if (languageCode) {
245
+ if (LANGUAGE_CODE_REJECTING_MODELS.has(model)) {
246
+ logger.warn(`[ElevenLabsTTSHandler] language_code "${languageCode}" dropped: model "${model}" does not accept it`);
247
+ }
248
+ else {
249
+ requestBody.language_code =
250
+ ElevenLabsTTS.toIso639Primary(languageCode);
189
251
  }
190
- throw fetchErr;
191
- }
192
- finally {
193
- clearTimeout(timeoutId);
194
- }
195
- if (!response.ok) {
196
- const errorData = await response
197
- .json()
198
- .catch(() => Object.create(null));
199
- const errorMessage = errorData.detail?.message ||
200
- `HTTP ${response.status}`;
201
- throw new Error(errorMessage);
202
252
  }
253
+ const url = `${baseUrl}/text-to-speech/${encodeURIComponent(voiceId)}?output_format=${outputFormat}`;
254
+ const body = JSON.stringify(requestBody);
255
+ const headers = {
256
+ "xi-api-key": this.apiKey,
257
+ "Content-Type": "application/json",
258
+ };
259
+ const audioBuffer = await this.fetchWithRetry(url, { method: "POST", headers, body }, timeoutMs, retries, options.signal);
203
260
  const latency = Date.now() - startTime;
204
- // Get audio buffer
205
- const arrayBuffer = await response.arrayBuffer();
206
- const audioBuffer = Buffer.from(arrayBuffer);
207
261
  const result = {
208
262
  buffer: audioBuffer,
209
263
  // Use the *effective* output format from outputFormat, not the
@@ -235,12 +289,293 @@ export class ElevenLabsTTS {
235
289
  message: `Synthesis failed: ${errorMessage}`,
236
290
  category: ErrorCategory.EXECUTION,
237
291
  severity: ErrorSeverity.HIGH,
238
- retriable: true,
292
+ retriable: err instanceof ElevenLabsHttpError
293
+ ? ElevenLabsTTS.isRetriableStatus(err.status)
294
+ : ElevenLabsTTS.isRetriableFailure(err),
239
295
  context: { textLength: text.length },
240
296
  originalError: err instanceof Error ? err : undefined,
241
297
  });
242
298
  }
243
299
  }
300
+ /**
301
+ * Issue the synthesis request, retrying on 429 / 5xx / network failure /
302
+ * per-attempt timeout up to `retries` more times. A `Retry-After` header
303
+ * sets the pause before the next attempt (capped); otherwise exponential
304
+ * backoff from 500 ms. Any other failure — 4xx, or a body that is not audio
305
+ * — is thrown on the first attempt.
306
+ *
307
+ * Total wall-clock is bounded by `timeoutMs × (1 + retries)` plus the pauses
308
+ * between attempts; `BaseProvider`'s outer synthesis timeout is raised to at
309
+ * least that figure (see `resolveTTSHandlerBudgetMs`).
310
+ *
311
+ * `signal` is the caller's cancellation: once it fires, the attempt in
312
+ * flight is aborted and no further attempt is made. Without it, a caller
313
+ * that had already given up (its own timeout, an aborted request) left the
314
+ * loop to pause and issue a second, billable request nobody consumed.
315
+ */
316
+ async fetchWithRetry(url, init, timeoutMs, retries, signal) {
317
+ const totalAttempts = 1 + retries;
318
+ let lastError;
319
+ for (let attempt = 1; attempt <= totalAttempts; attempt++) {
320
+ if (signal?.aborted) {
321
+ throw ElevenLabsTTS.cancelledError(signal);
322
+ }
323
+ try {
324
+ return await this.fetchOnce(url, init, timeoutMs, signal);
325
+ }
326
+ catch (err) {
327
+ lastError = err;
328
+ const retriable = !signal?.aborted && ElevenLabsTTS.isRetriableFailure(err);
329
+ if (!retriable || attempt >= totalAttempts) {
330
+ throw err;
331
+ }
332
+ const retryAfterMs = err instanceof ElevenLabsHttpError ? err.retryAfterMs : undefined;
333
+ const pauseMs = retryAfterMs !== undefined
334
+ ? Math.min(retryAfterMs, RETRY_AFTER_CAP_MS)
335
+ : calculateBackoff(attempt, RETRY_BASE_DELAY_MS, RETRY_MAX_DELAY_MS);
336
+ logger.warn(`[ElevenLabsTTSHandler] attempt ${attempt}/${totalAttempts} failed (${err instanceof Error ? err.message : String(err)}); retrying in ${Math.round(pauseMs)}ms`);
337
+ await ElevenLabsTTS.pause(pauseMs, signal);
338
+ }
339
+ }
340
+ throw lastError instanceof Error
341
+ ? lastError
342
+ : new Error("ElevenLabs synthesis failed");
343
+ }
344
+ /**
345
+ * A backoff pause that ends as soon as `signal` fires, so a cancelled
346
+ * caller never waits it out. The timer is owned here and cleared on the
347
+ * way out: racing a bare `delay()` against the signal settled the promise
348
+ * at the abort but left the backoff timer armed for up to `RETRY_AFTER_CAP_MS`,
349
+ * holding the event loop open after the caller had already been answered.
350
+ */
351
+ static pause(ms, signal) {
352
+ if (!signal) {
353
+ return delay(ms);
354
+ }
355
+ if (signal.aborted) {
356
+ return Promise.resolve();
357
+ }
358
+ return new Promise((resolve) => {
359
+ const timer = setTimeout(() => {
360
+ signal.removeEventListener("abort", onAbort);
361
+ resolve();
362
+ }, ms);
363
+ const onAbort = () => {
364
+ clearTimeout(timer);
365
+ resolve();
366
+ };
367
+ signal.addEventListener("abort", onAbort, { once: true });
368
+ });
369
+ }
370
+ /** The non-retriable error a caller-cancelled synthesis surfaces as. */
371
+ static cancelledError(signal) {
372
+ const reason = signal.reason;
373
+ return new TTSError({
374
+ code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
375
+ message: "ElevenLabs TTS synthesis cancelled by the caller's signal",
376
+ category: ErrorCategory.EXECUTION,
377
+ severity: ErrorSeverity.LOW,
378
+ retriable: false,
379
+ originalError: reason instanceof Error ? reason : undefined,
380
+ });
381
+ }
382
+ /**
383
+ * One attempt, end to end: the per-attempt timer covers the response
384
+ * headers AND the audio body, so a stalled or truncated body is a timed-out
385
+ * attempt the retry loop can resend rather than a hang that only the outer
386
+ * provider budget ends. The caller's `signal` aborts the same controller,
387
+ * and an abort it caused is reported as a cancellation, not a timeout.
388
+ */
389
+ async fetchOnce(url, init, timeoutMs, signal) {
390
+ const controller = new AbortController();
391
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
392
+ const onCallerAbort = () => controller.abort();
393
+ signal?.addEventListener("abort", onCallerAbort, { once: true });
394
+ const asAttemptError = (err) => signal?.aborted
395
+ ? ElevenLabsTTS.cancelledError(signal)
396
+ : ElevenLabsTTS.asAttemptError(err, timeoutMs);
397
+ try {
398
+ let response;
399
+ try {
400
+ response = await fetch(url, { ...init, signal: controller.signal });
401
+ }
402
+ catch (fetchErr) {
403
+ throw asAttemptError(fetchErr);
404
+ }
405
+ if (!response.ok) {
406
+ // A body that PARSES to `null` (not one that fails to parse) isn't
407
+ // caught by `.catch()`, and `.detail` on `null` throws — a 429/503
408
+ // with a `null` body then failed on the first attempt with a
409
+ // confusing TypeError instead of being retried.
410
+ const errorData = (await response.json().catch(() => null)) ??
411
+ Object.create(null);
412
+ const errorMessage = errorData.detail?.message ||
413
+ `HTTP ${response.status}`;
414
+ throw new ElevenLabsHttpError(errorMessage, response.status, ElevenLabsTTS.parseRetryAfter(response.headers.get("retry-after")));
415
+ }
416
+ try {
417
+ return Buffer.from(await response.arrayBuffer());
418
+ }
419
+ catch (bodyErr) {
420
+ // A body that stalls (abort) or breaks mid-stream is an attempt-level
421
+ // failure: retriable, like a dropped connection.
422
+ const attemptError = asAttemptError(bodyErr);
423
+ if (attemptError === bodyErr) {
424
+ throw new TTSError({
425
+ code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
426
+ message: `ElevenLabs TTS audio body failed: ${bodyErr instanceof Error ? bodyErr.message : String(bodyErr)}`,
427
+ category: ErrorCategory.NETWORK,
428
+ severity: ErrorSeverity.HIGH,
429
+ retriable: true,
430
+ originalError: bodyErr instanceof Error ? bodyErr : undefined,
431
+ });
432
+ }
433
+ throw attemptError;
434
+ }
435
+ }
436
+ finally {
437
+ clearTimeout(timeoutId);
438
+ signal?.removeEventListener("abort", onCallerAbort);
439
+ }
440
+ }
441
+ /** Map an abort from the per-attempt timer to a retriable timeout error. */
442
+ static asAttemptError(err, timeoutMs) {
443
+ if (err instanceof Error && err.name === "AbortError") {
444
+ return new TTSError({
445
+ code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
446
+ message: `ElevenLabs TTS request timed out after ${timeoutMs}ms`,
447
+ category: ErrorCategory.NETWORK,
448
+ severity: ErrorSeverity.HIGH,
449
+ retriable: true,
450
+ originalError: err,
451
+ });
452
+ }
453
+ return err;
454
+ }
455
+ static isRetriableStatus(status) {
456
+ return status === 429 || status >= 500;
457
+ }
458
+ /**
459
+ * 429 / 5xx, a per-attempt timeout or broken body (already a retriable
460
+ * `TTSError`), or a transport failure. Any other throw — a `TypeError` for
461
+ * an invalid header value or URL, thrown before a request exists — is
462
+ * permanent and is not retried; it used to be, on the grounds that it was
463
+ * an `Error`.
464
+ */
465
+ static isRetriableFailure(err) {
466
+ if (err instanceof ElevenLabsHttpError) {
467
+ return ElevenLabsTTS.isRetriableStatus(err.status);
468
+ }
469
+ if (err instanceof TTSError) {
470
+ return err.retriable === true;
471
+ }
472
+ return ElevenLabsTTS.isTransportError(err);
473
+ }
474
+ /**
475
+ * A socket-level failure: undici wraps one as `TypeError("fetch failed")`
476
+ * with the system error on `cause`, and the codes are checked on both.
477
+ */
478
+ static isTransportError(err) {
479
+ if (!(err instanceof Error)) {
480
+ return false;
481
+ }
482
+ const code = ElevenLabsTTS.errorCode(err) ?? ElevenLabsTTS.errorCode(err.cause);
483
+ if (code !== undefined && TRANSPORT_ERROR_CODES.has(code)) {
484
+ return true;
485
+ }
486
+ return err instanceof TypeError && /\bfetch failed\b/i.test(err.message);
487
+ }
488
+ static errorCode(value) {
489
+ if (typeof value !== "object" || value === null) {
490
+ return undefined;
491
+ }
492
+ const code = value.code;
493
+ return typeof code === "string" ? code : undefined;
494
+ }
495
+ /** A BCP-47 tag reduced to its ISO 639-1 primary subtag: `en-US` → `en`, `pt_BR` → `pt`. */
496
+ static toIso639Primary(language) {
497
+ const primary = language.trim().split(/[-_]/, 1)[0] ?? language;
498
+ return primary.toLowerCase();
499
+ }
500
+ /** One of `allowed`, else `fallback` with a warning — never an unlisted value in `output_format`. */
501
+ static resolveBitrate(value, allowed, fallback, label) {
502
+ if (value === undefined) {
503
+ return fallback;
504
+ }
505
+ if (allowed.has(value)) {
506
+ return value;
507
+ }
508
+ logger.warn(`[ElevenLabsTTSHandler] ${label} ${String(value)} is not one of ${[...allowed].join(", ")}; using ${fallback}`);
509
+ return fallback;
510
+ }
511
+ /** `Retry-After` as delta-seconds or an HTTP-date, in milliseconds. */
512
+ static parseRetryAfter(header) {
513
+ if (!header) {
514
+ return undefined;
515
+ }
516
+ const seconds = Number(header);
517
+ if (Number.isFinite(seconds) && seconds >= 0) {
518
+ return Math.round(seconds * 1000);
519
+ }
520
+ const at = Date.parse(header);
521
+ if (Number.isFinite(at)) {
522
+ return Math.max(0, at - Date.now());
523
+ }
524
+ return undefined;
525
+ }
526
+ /** A positive finite `timeoutMs`, clamped to the largest delay a timer honours; otherwise the default. */
527
+ static resolveTimeoutMs(timeoutMs) {
528
+ return typeof timeoutMs === "number" &&
529
+ Number.isFinite(timeoutMs) &&
530
+ timeoutMs > 0
531
+ ? Math.min(timeoutMs, MAX_TIMEOUT_MS)
532
+ : DEFAULT_TIMEOUT_MS;
533
+ }
534
+ static resolveRetries(retries) {
535
+ return typeof retries === "number" &&
536
+ Number.isInteger(retries) &&
537
+ retries >= 0
538
+ ? retries
539
+ : DEFAULT_RETRIES;
540
+ }
541
+ static normalizeBaseUrl(baseUrl) {
542
+ // A loop rather than /\/+$/: the input is caller-supplied and CodeQL
543
+ // flags that regex as polynomial on long runs of "/".
544
+ let end = baseUrl.length;
545
+ while (end > 0 && baseUrl.charCodeAt(end - 1) === 47) {
546
+ end--;
547
+ }
548
+ return baseUrl.slice(0, end);
549
+ }
550
+ /**
551
+ * Assemble `voice_settings`. The camelCase options are the documented
552
+ * surface; `voiceSettings` passes raw fields through and wins on conflict.
553
+ * `speed` comes from the generic `TTSOptions.speed` (0.25–4.0) clamped into
554
+ * ElevenLabs' accepted 0.7–1.2 — clamped with a warning rather than
555
+ * rejected, because the generic validator has already accepted the value.
556
+ */
557
+ buildVoiceSettings(options) {
558
+ // Only DEFINED raw fields win: a spread copies an own key whose value is
559
+ // `undefined`, which would erase the default (or the camelCase value)
560
+ // and drop the field from the request body.
561
+ const rawSettings = Object.fromEntries(Object.entries(options.voiceSettings ?? {}).filter(([, value]) => value !== undefined));
562
+ const settings = {
563
+ stability: options.stability ?? 0.5,
564
+ similarity_boost: options.similarityBoost ?? 0.75,
565
+ style: options.style ?? 0.0,
566
+ use_speaker_boost: options.useSpeakerBoost ?? true,
567
+ ...rawSettings,
568
+ };
569
+ const requestedSpeed = settings.speed ?? options.speed;
570
+ if (typeof requestedSpeed === "number" && Number.isFinite(requestedSpeed)) {
571
+ const clamped = Math.min(SPEED_MAX, Math.max(SPEED_MIN, requestedSpeed));
572
+ if (clamped !== requestedSpeed) {
573
+ logger.warn(`[ElevenLabsTTSHandler] speed ${requestedSpeed} clamped to ${clamped}: ElevenLabs accepts ${SPEED_MIN}–${SPEED_MAX}`);
574
+ }
575
+ settings.speed = clamped;
576
+ }
577
+ return settings;
578
+ }
244
579
  /**
245
580
  * Map gender string to standard type
246
581
  */
@@ -258,29 +593,35 @@ export class ElevenLabsTTS {
258
593
  return "neutral";
259
594
  }
260
595
  /**
261
- * Map TTSAudioFormat to ElevenLabs output format
596
+ * Map TTSAudioFormat to an ElevenLabs `output_format`.
597
+ *
598
+ * ElevenLabs has no `ogg_*` family: Ogg/Opus is `opus_48000_<kbps>`. Both
599
+ * `ogg` and `opus` map there. `wav` maps to raw `pcm_44100` (headerless —
600
+ * see `effectiveFormat`). Anything else falls back to `mp3_44100_128`.
262
601
  */
263
- mapFormat(format) {
264
- const formats = {
265
- mp3: "mp3_44100_128",
266
- wav: "pcm_44100",
267
- ogg: "ogg_22050",
268
- opus: "ogg_22050",
269
- };
270
- return formats[format] ?? "mp3_44100_128";
602
+ mapFormat(format, options) {
603
+ switch (format) {
604
+ case "mp3":
605
+ case "mpeg":
606
+ case "mpga":
607
+ return `mp3_44100_${ElevenLabsTTS.resolveBitrate(options.mp3Bitrate, MP3_BITRATES, DEFAULT_MP3_BITRATE, "mp3Bitrate")}`;
608
+ case "wav":
609
+ case "pcm16":
610
+ return "pcm_44100";
611
+ case "ogg":
612
+ case "opus":
613
+ return `opus_48000_${ElevenLabsTTS.resolveBitrate(options.opusBitrate, OPUS_BITRATES, DEFAULT_OPUS_BITRATE, "opusBitrate")}`;
614
+ default:
615
+ return "mp3_44100_128";
616
+ }
271
617
  }
272
618
  /**
273
619
  * Get sample rate from format string
274
620
  */
275
621
  getSampleRate(format) {
276
- if (format.includes("44100")) {
277
- return 44100;
278
- }
279
- if (format.includes("22050")) {
280
- return 22050;
281
- }
282
- if (format.includes("24000")) {
283
- return 24000;
622
+ const parsed = SAMPLE_RATE_SEGMENT.exec(format)?.[1];
623
+ if (parsed) {
624
+ return Number(parsed);
284
625
  }
285
626
  return 44100;
286
627
  }
@@ -302,7 +643,7 @@ export class ElevenLabsTTS {
302
643
  if (outputFormat.startsWith("pcm")) {
303
644
  return "pcm16";
304
645
  }
305
- if (outputFormat.startsWith("ogg")) {
646
+ if (outputFormat.startsWith("opus") || outputFormat.startsWith("ogg")) {
306
647
  return "opus";
307
648
  }
308
649
  return "mp3";