@juspay/neurolink 12.6.0 → 12.7.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.
@@ -229,6 +229,10 @@ export async function* interleaveTTSStream(params) {
229
229
  // `return()`, so this side channel is the only thing that actually
230
230
  // reaches — and closes — the provider stream at the bottom.
231
231
  cancelStream(stream);
232
+ // The audio side has its own wrapper chain. Native TTS can be parked in a
233
+ // response-body read even after text ingestion stops, so its cancel hook
234
+ // must be reached directly rather than waiting for queued `.return()`.
235
+ cancelStream(audioIterator);
232
236
  }
233
237
  textQueue.end();
234
238
  const releases = [];
@@ -5,14 +5,7 @@
5
5
  *
6
6
  * @module voice/providers/OpenAITTS
7
7
  */
8
- import type { TTSHandler, TTSOptions, TTSResult, TTSVoice } from "../../types/index.js";
9
- /**
10
- * OpenAI Text-to-Speech Handler
11
- *
12
- * Supports high-quality neural TTS with multiple voices.
13
- *
14
- * @see https://platform.openai.com/docs/api-reference/audio/createSpeech
15
- */
8
+ import type { TTSChunk, TTSHandler, TTSOptions, TTSResult, TTSVoice } from "../../types/index.js";
16
9
  export declare class OpenAITTS implements TTSHandler {
17
10
  private readonly apiKey;
18
11
  private readonly baseUrl;
@@ -27,7 +20,32 @@ export declare class OpenAITTS implements TTSHandler {
27
20
  constructor(apiKey?: string);
28
21
  isConfigured(): boolean;
29
22
  getVoices(languageCode?: string): Promise<TTSVoice[]>;
23
+ private requireApiKey;
24
+ private prepareRequest;
25
+ /**
26
+ * Issue the speech request and reject non-2xx responses.
27
+ *
28
+ * `onHeaders` runs as soon as the request settles, before the response body
29
+ * is touched at all. The buffered path clears its request timeout there,
30
+ * which is where it has always been cleared: the 30-second bound covers
31
+ * getting a response, not downloading one. The native path passes no
32
+ * callback, keeping its own timeout armed across the body reads it owns.
33
+ */
34
+ private createSpeechResponse;
35
+ private synthesisError;
30
36
  synthesize(text: string, options?: TTSOptions): Promise<TTSResult>;
37
+ /**
38
+ * Stream one segment's audio as the response body arrives.
39
+ *
40
+ * Returns `undefined` for any format without direct wire proof of
41
+ * incremental delivery, which selects the buffered `synthesize()` path.
42
+ *
43
+ * Every non-empty body read is yielded as soon as it is available and
44
+ * carries `isFinal: false`: assigning finality here would require a
45
+ * one-read lookahead, delaying every fragment by a full body read, and
46
+ * `TTSProcessor` recomputes finality globally anyway.
47
+ */
48
+ synthesizeStream(text: string, options?: TTSOptions): AsyncIterable<TTSChunk> | undefined;
31
49
  /**
32
50
  * Map TTSAudioFormat to OpenAI response_format.
33
51
  *
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { ErrorCategory, ErrorSeverity } from "../../constants/enums.js";
9
9
  import { logger } from "../../utils/logger.js";
10
+ import { attachStreamCancel } from "../../utils/streamCancellation.js";
10
11
  import { TTS_ERROR_CODES, TTSError } from "../../utils/ttsProcessor.js";
11
12
  /**
12
13
  * OpenAI Text-to-Speech Handler
@@ -15,6 +16,109 @@ import { TTS_ERROR_CODES, TTSError } from "../../utils/ttsProcessor.js";
15
16
  *
16
17
  * @see https://platform.openai.com/docs/api-reference/audio/createSpeech
17
18
  */
19
+ /**
20
+ * Converts an arbitrary thrown value to a loggable string without running
21
+ * code the value controls unguarded — `.message` can be a throwing accessor
22
+ * and `String()` can hit a hostile `toString`/`Symbol.toPrimitive` or a
23
+ * null-prototype object. Mirrors the processor-side helper of the same name.
24
+ */
25
+ function safeErrorMessage(error) {
26
+ try {
27
+ if (error instanceof Error) {
28
+ return String(error.message);
29
+ }
30
+ return String(error);
31
+ }
32
+ catch {
33
+ return "[unprintable error]";
34
+ }
35
+ }
36
+ /**
37
+ * `instanceof` performs a prototype lookup, which a `Proxy` with a throwing
38
+ * `getPrototypeOf` trap — or a revoked one — turns into a throw of its own.
39
+ * Classifying a transport failure must never become the failure. Mirrors the
40
+ * processor-side helper of the same name.
41
+ */
42
+ function safeInstanceOf(value, ctor) {
43
+ try {
44
+ return value instanceof ctor;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ /**
51
+ * Both the class test and the `.name` read can execute code the thrown value
52
+ * controls, and the abort branch needs the answer to both.
53
+ */
54
+ function isAbortError(error) {
55
+ try {
56
+ return error instanceof Error && error.name === "AbortError";
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ /**
63
+ * Reduce a caught value to an `Error` that is safe to attach as
64
+ * `originalError`. The structured-error constructor reads `.stack` and
65
+ * `.message` unguarded, so a throwing accessor there escapes the shaper
66
+ * itself. Forward the original only once those reads are proven to succeed,
67
+ * and otherwise carry the already-safe message on a plain `Error` with no
68
+ * accessors of its own. Mirrors the processor-side helper of the same name.
69
+ */
70
+ function toSafeOriginalError(error, safeMessage) {
71
+ if (!safeInstanceOf(error, Error)) {
72
+ return undefined;
73
+ }
74
+ try {
75
+ String(error.name);
76
+ String(error.message);
77
+ String(error.stack);
78
+ return error;
79
+ }
80
+ catch {
81
+ return new Error(safeMessage);
82
+ }
83
+ }
84
+ /**
85
+ * Build the shaped error, retrying without the original if attaching it
86
+ * throws. A one-shot accessor can answer one way when it is checked and
87
+ * another when the structured-error constructor reads it; the retry drops the
88
+ * original rather than the shaped failure. Mirrors the processor-side helper
89
+ * of the same name.
90
+ */
91
+ function buildTTSError(details, originalError) {
92
+ try {
93
+ return new TTSError({ ...details, originalError });
94
+ }
95
+ catch {
96
+ return new TTSError(details);
97
+ }
98
+ }
99
+ /**
100
+ * A value may pass `safeInstanceOf` by LYING — a `Proxy` whose
101
+ * `getPrototypeOf` answers `TTSError.prototype` while every `get` detonates.
102
+ * Passing one through as "already shaped" hands the hostile value to every
103
+ * downstream consumer (mirrors the processor-side helper). Trust the claim only when the reads those consumers
104
+ * perform succeed here, where a throw is contained.
105
+ */
106
+ function isReadableTTSError(value) {
107
+ if (!safeInstanceOf(value, TTSError)) {
108
+ return false;
109
+ }
110
+ try {
111
+ String(value.name);
112
+ String(value.message);
113
+ String(value.code);
114
+ void value.retriable;
115
+ String(value.stack);
116
+ return true;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ }
18
122
  export class OpenAITTS {
19
123
  apiKey;
20
124
  baseUrl = "https://api.openai.com/v1";
@@ -90,7 +194,7 @@ export class OpenAITTS {
90
194
  }
91
195
  return OpenAITTS.VOICES;
92
196
  }
93
- async synthesize(text, options = {}) {
197
+ requireApiKey() {
94
198
  if (!this.apiKey) {
95
199
  throw new TTSError({
96
200
  code: TTS_ERROR_CODES.PROVIDER_NOT_CONFIGURED,
@@ -100,115 +204,253 @@ export class OpenAITTS {
100
204
  retriable: false,
101
205
  });
102
206
  }
103
- const startTime = Date.now();
207
+ return this.apiKey;
208
+ }
209
+ prepareRequest(text, options) {
104
210
  const openaiOptions = options;
105
- try {
106
- // Determine model based on quality
107
- const model = openaiOptions.model ??
108
- (options.quality === "hd" ? "tts-1-hd" : "tts-1");
109
- // Determine voice
110
- const voice = options.voice ?? "alloy";
111
- // Determine format
112
- const responseFormat = this.mapFormat(options.format ?? "mp3");
113
- // Build request
114
- const requestBody = {
211
+ const model = openaiOptions.model ?? (options.quality === "hd" ? "tts-1-hd" : "tts-1");
212
+ const voice = options.voice ?? "alloy";
213
+ const responseFormat = this.mapFormat(options.format ?? "mp3");
214
+ return {
215
+ model,
216
+ voice,
217
+ responseFormat,
218
+ effectiveFormat: this.effectiveFormat(responseFormat),
219
+ body: {
115
220
  model,
116
221
  input: text,
117
222
  voice,
118
223
  response_format: responseFormat,
119
224
  speed: options.speed ?? 1.0,
120
- };
121
- const controller = new AbortController();
122
- const timeoutId = setTimeout(() => controller.abort(), 30000);
123
- let response;
124
- try {
125
- response = await fetch(`${this.baseUrl}/audio/speech`, {
126
- method: "POST",
127
- headers: {
128
- Authorization: `Bearer ${this.apiKey}`,
129
- "Content-Type": "application/json",
130
- },
131
- body: JSON.stringify(requestBody),
132
- signal: controller.signal,
133
- });
134
- }
135
- catch (fetchErr) {
136
- if (fetchErr instanceof Error && fetchErr.name === "AbortError") {
137
- throw new TTSError({
138
- code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
139
- message: "OpenAI TTS request timed out after 30 seconds",
140
- category: ErrorCategory.NETWORK,
141
- severity: ErrorSeverity.HIGH,
142
- retriable: true,
143
- originalError: fetchErr,
144
- });
145
- }
146
- throw fetchErr;
147
- }
148
- finally {
225
+ },
226
+ };
227
+ }
228
+ /**
229
+ * Issue the speech request and reject non-2xx responses.
230
+ *
231
+ * `onHeaders` runs as soon as the request settles, before the response body
232
+ * is touched at all. The buffered path clears its request timeout there,
233
+ * which is where it has always been cleared: the 30-second bound covers
234
+ * getting a response, not downloading one. The native path passes no
235
+ * callback, keeping its own timeout armed across the body reads it owns.
236
+ */
237
+ async createSpeechResponse(request, signal, onHeaders) {
238
+ let response;
239
+ try {
240
+ response = await fetch(`${this.baseUrl}/audio/speech`, {
241
+ method: "POST",
242
+ headers: {
243
+ Authorization: `Bearer ${this.requireApiKey()}`,
244
+ "Content-Type": "application/json",
245
+ },
246
+ body: JSON.stringify(request.body),
247
+ signal,
248
+ });
249
+ }
250
+ finally {
251
+ onHeaders?.();
252
+ }
253
+ if (!response.ok) {
254
+ const errorData = await response
255
+ .json()
256
+ .catch(() => Object.create(null));
257
+ const errorMessage = errorData.error?.message ||
258
+ `HTTP ${response.status}`;
259
+ const retriable = response.status === 408 ||
260
+ response.status === 429 ||
261
+ response.status >= 500;
262
+ throw new TTSError({
263
+ code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
264
+ message: errorMessage,
265
+ category: retriable ? ErrorCategory.NETWORK : ErrorCategory.EXECUTION,
266
+ severity: ErrorSeverity.HIGH,
267
+ retriable,
268
+ context: {
269
+ status: response.status,
270
+ model: request.model,
271
+ responseFormat: request.responseFormat,
272
+ },
273
+ });
274
+ }
275
+ return response;
276
+ }
277
+ synthesisError(error, textLength, timedOut) {
278
+ if (isReadableTTSError(error)) {
279
+ return error;
280
+ }
281
+ if (isAbortError(error) && timedOut) {
282
+ return buildTTSError({
283
+ code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
284
+ message: "OpenAI TTS request timed out after 30 seconds",
285
+ category: ErrorCategory.NETWORK,
286
+ severity: ErrorSeverity.HIGH,
287
+ retriable: true,
288
+ }, toSafeOriginalError(error, safeErrorMessage(error)));
289
+ }
290
+ const errorMessage = error ? safeErrorMessage(error) : "Unknown error";
291
+ logger.error(`[OpenAITTSHandler] Synthesis failed: ${errorMessage}`);
292
+ return buildTTSError({
293
+ code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
294
+ message: `Synthesis failed: ${errorMessage}`,
295
+ category: ErrorCategory.EXECUTION,
296
+ severity: ErrorSeverity.HIGH,
297
+ retriable: true,
298
+ context: { textLength },
299
+ }, toSafeOriginalError(error, errorMessage));
300
+ }
301
+ async synthesize(text, options = {}) {
302
+ this.requireApiKey();
303
+ const startTime = Date.now();
304
+ const request = this.prepareRequest(text, options);
305
+ const controller = new AbortController();
306
+ let timedOut = false;
307
+ let timeoutId = setTimeout(() => {
308
+ timedOut = true;
309
+ controller.abort();
310
+ }, 30000);
311
+ // Disarmed the moment a response comes back, so the timeout bounds the
312
+ // request and not the audio download that follows it. Bounding the
313
+ // download too would fail a large but perfectly healthy synthesis that
314
+ // takes more than 30 seconds to transfer.
315
+ const clearRequestTimeout = () => {
316
+ if (timeoutId !== undefined) {
149
317
  clearTimeout(timeoutId);
318
+ timeoutId = undefined;
150
319
  }
151
- if (!response.ok) {
152
- const errorData = await response
153
- .json()
154
- .catch(() => Object.create(null));
155
- const errorMessage = errorData.error?.message ||
156
- `HTTP ${response.status}`;
157
- // Preserve HTTP status so the outer catch doesn't mark a permanent
158
- // 4xx (auth, bad input) as retriable and trigger pointless retry loops.
159
- const retriable = response.status === 408 ||
160
- response.status === 429 ||
161
- response.status >= 500;
162
- throw new TTSError({
163
- code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
164
- message: errorMessage,
165
- category: retriable ? ErrorCategory.NETWORK : ErrorCategory.EXECUTION,
166
- severity: ErrorSeverity.HIGH,
167
- retriable,
168
- context: { status: response.status, model, responseFormat },
169
- });
170
- }
320
+ };
321
+ try {
322
+ const response = await this.createSpeechResponse(request, controller.signal, clearRequestTimeout);
323
+ // Measured BEFORE the body download, exactly as it was before this
324
+ // method was refactored: `metadata.latency` reports time-to-response,
325
+ // not time-to-last-byte. Moving it past `arrayBuffer()` silently changed
326
+ // a public metric `TTSResult.metadata` reaches callers through
327
+ // `TTSProcessor.synthesize()` and through `generate({ tts })`.
171
328
  const latency = Date.now() - startTime;
172
- // Get audio buffer
173
329
  const arrayBuffer = await response.arrayBuffer();
174
330
  const audioBuffer = Buffer.from(arrayBuffer);
175
- // Use the *effective* output format (post-mapFormat fallback), not the
176
- // requested format — otherwise mp3-coerced "m4a" requests would mislabel
177
- // the buffer and break consumer file-extension routing.
178
- const effectiveFormat = this.effectiveFormat(responseFormat);
179
331
  const result = {
180
332
  buffer: audioBuffer,
181
- format: effectiveFormat,
333
+ format: request.effectiveFormat,
182
334
  size: audioBuffer.length,
183
- voice,
184
- sampleRate: this.getSampleRate(effectiveFormat),
335
+ voice: request.voice,
336
+ sampleRate: this.getSampleRate(request.effectiveFormat),
185
337
  metadata: {
186
338
  latency,
187
339
  provider: "openai-tts",
188
- model,
340
+ model: request.model,
189
341
  requestedFormat: options.format,
190
- responseFormat,
342
+ responseFormat: request.responseFormat,
191
343
  },
192
344
  };
193
345
  logger.info(`[OpenAITTSHandler] Synthesized ${audioBuffer.length} bytes in ${latency}ms`);
194
346
  return result;
195
347
  }
196
348
  catch (err) {
197
- if (err instanceof TTSError) {
198
- throw err;
199
- }
200
- const errorMessage = err instanceof Error ? err.message : String(err || "Unknown error");
201
- logger.error(`[OpenAITTSHandler] Synthesis failed: ${errorMessage}`);
202
- throw new TTSError({
203
- code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
204
- message: `Synthesis failed: ${errorMessage}`,
205
- category: ErrorCategory.EXECUTION,
206
- severity: ErrorSeverity.HIGH,
207
- retriable: true,
208
- context: { textLength: text.length },
209
- originalError: err instanceof Error ? err : undefined,
210
- });
349
+ throw this.synthesisError(err, text.length, timedOut);
350
+ }
351
+ finally {
352
+ clearRequestTimeout();
353
+ }
354
+ }
355
+ /**
356
+ * Stream one segment's audio as the response body arrives.
357
+ *
358
+ * Returns `undefined` for any format without direct wire proof of
359
+ * incremental delivery, which selects the buffered `synthesize()` path.
360
+ *
361
+ * Every non-empty body read is yielded as soon as it is available and
362
+ * carries `isFinal: false`: assigning finality here would require a
363
+ * one-read lookahead, delaying every fragment by a full body read, and
364
+ * `TTSProcessor` recomputes finality globally anyway.
365
+ */
366
+ synthesizeStream(text, options = {}) {
367
+ const requestedFormat = options.format ?? "mp3";
368
+ if (requestedFormat !== "mp3" && requestedFormat !== "pcm16") {
369
+ return undefined;
211
370
  }
371
+ const request = this.prepareRequest(text, options);
372
+ const handler = this;
373
+ let controller;
374
+ let reader;
375
+ let cancelled = false;
376
+ let timedOut = false;
377
+ let bodyComplete = false;
378
+ const stream = (async function* () {
379
+ handler.requireApiKey();
380
+ const startedAt = Date.now();
381
+ controller = new AbortController();
382
+ const timeoutId = setTimeout(() => {
383
+ timedOut = true;
384
+ controller?.abort();
385
+ }, 30000);
386
+ try {
387
+ const response = await handler.createSpeechResponse(request, controller.signal);
388
+ if (!response.body) {
389
+ throw new TTSError({
390
+ code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
391
+ message: "OpenAI TTS response did not include an audio body",
392
+ category: ErrorCategory.NETWORK,
393
+ severity: ErrorSeverity.HIGH,
394
+ retriable: true,
395
+ });
396
+ }
397
+ reader = response.body.getReader();
398
+ let index = 0;
399
+ let cumulativeSize = 0;
400
+ while (true) {
401
+ const result = await reader.read();
402
+ if (result.done) {
403
+ bodyComplete = true;
404
+ break;
405
+ }
406
+ const data = Buffer.from(result.value);
407
+ if (data.length === 0) {
408
+ continue;
409
+ }
410
+ cumulativeSize += data.length;
411
+ yield {
412
+ data,
413
+ format: request.effectiveFormat,
414
+ index: index++,
415
+ // Deliberately never `true`. Marking the last read would mean
416
+ // holding one read back until the next one arrives, which delays
417
+ // every fragment by a full body read; `TTSProcessor` already
418
+ // recomputes exactly one final chunk across all segments and
419
+ // discards whatever finality a handler reports. See the
420
+ // `synthesizeStream` contract in `TTSHandler`.
421
+ isFinal: false,
422
+ cumulativeSize,
423
+ voice: request.voice,
424
+ sampleRate: handler.getSampleRate(request.effectiveFormat),
425
+ };
426
+ }
427
+ logger.info(`[OpenAITTSHandler] Streamed ${cumulativeSize} bytes in ${Date.now() - startedAt}ms`);
428
+ }
429
+ catch (err) {
430
+ if (cancelled && isAbortError(err)) {
431
+ return;
432
+ }
433
+ throw handler.synthesisError(err, text.length, timedOut);
434
+ }
435
+ finally {
436
+ clearTimeout(timeoutId);
437
+ if (!bodyComplete) {
438
+ controller?.abort();
439
+ }
440
+ try {
441
+ reader?.releaseLock();
442
+ }
443
+ catch {
444
+ // The reader may already be errored or released during cancellation.
445
+ }
446
+ reader = undefined;
447
+ controller = undefined;
448
+ }
449
+ })();
450
+ return attachStreamCancel(stream, () => {
451
+ cancelled = true;
452
+ controller?.abort();
453
+ });
212
454
  }
213
455
  /**
214
456
  * Map TTSAudioFormat to OpenAI response_format.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.6.0",
3
+ "version": "12.7.0",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {