@juspay/neurolink 12.48.1 → 12.49.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/browser/neurolink.min.js +616 -595
  3. package/dist/cli/commands/serve.d.ts +5 -3
  4. package/dist/cli/commands/serve.js +113 -20
  5. package/dist/cli/commands/server.js +13 -6
  6. package/dist/cli/commands/transcribe.d.ts +24 -0
  7. package/dist/cli/commands/transcribe.js +718 -0
  8. package/dist/cli/factories/commandFactory.d.ts +10 -0
  9. package/dist/cli/factories/commandFactory.js +13 -0
  10. package/dist/cli/parser.js +3 -0
  11. package/dist/cli/utils/serverUtils.d.ts +28 -0
  12. package/dist/cli/utils/serverUtils.js +71 -0
  13. package/dist/factories/mediaHandlerCatalog.js +11 -9
  14. package/dist/factories/sttDescriptors.d.ts +63 -0
  15. package/dist/factories/sttDescriptors.js +286 -0
  16. package/dist/index.d.ts +4 -1
  17. package/dist/index.js +7 -1
  18. package/dist/neurolink.d.ts +68 -1
  19. package/dist/neurolink.js +202 -4
  20. package/dist/processors/media/AudioProcessor.d.ts +15 -56
  21. package/dist/processors/media/AudioProcessor.js +105 -276
  22. package/dist/processors/media/VideoProcessor.d.ts +3 -5
  23. package/dist/processors/media/VideoProcessor.js +52 -51
  24. package/dist/server/abstract/baseServerAdapter.d.ts +28 -1
  25. package/dist/server/abstract/baseServerAdapter.js +84 -4
  26. package/dist/server/adapters/expressAdapter.d.ts +1 -0
  27. package/dist/server/adapters/expressAdapter.js +50 -10
  28. package/dist/server/adapters/fastifyAdapter.d.ts +1 -0
  29. package/dist/server/adapters/fastifyAdapter.js +51 -8
  30. package/dist/server/adapters/honoAdapter.d.ts +1 -4
  31. package/dist/server/adapters/honoAdapter.js +95 -8
  32. package/dist/server/adapters/koaAdapter.d.ts +7 -0
  33. package/dist/server/adapters/koaAdapter.js +71 -10
  34. package/dist/server/index.d.ts +3 -2
  35. package/dist/server/index.js +3 -2
  36. package/dist/server/routes/index.d.ts +1 -0
  37. package/dist/server/routes/index.js +3 -0
  38. package/dist/server/routes/transcribeRoutes.d.ts +23 -0
  39. package/dist/server/routes/transcribeRoutes.js +506 -0
  40. package/dist/server/utils/rawBody.d.ts +32 -0
  41. package/dist/server/utils/rawBody.js +76 -0
  42. package/dist/server/utils/validation.d.ts +192 -0
  43. package/dist/server/utils/validation.js +100 -0
  44. package/dist/server/websocket/transcribeWebSocket.d.ts +42 -0
  45. package/dist/server/websocket/transcribeWebSocket.js +264 -0
  46. package/dist/types/cli.d.ts +47 -0
  47. package/dist/types/index.d.ts +1 -0
  48. package/dist/types/index.js +1 -0
  49. package/dist/types/providers.d.ts +7 -1
  50. package/dist/types/server.d.ts +117 -1
  51. package/dist/types/stt.d.ts +160 -7
  52. package/dist/types/transcribe.d.ts +506 -0
  53. package/dist/types/transcribe.js +29 -0
  54. package/dist/utils/speakerTurns.d.ts +28 -0
  55. package/dist/utils/speakerTurns.js +93 -0
  56. package/dist/utils/sttProcessor.d.ts +20 -2
  57. package/dist/utils/sttProcessor.js +73 -5
  58. package/dist/voice/correction/dictionary.d.ts +49 -0
  59. package/dist/voice/correction/dictionary.js +134 -0
  60. package/dist/voice/correction/guard.d.ts +35 -0
  61. package/dist/voice/correction/guard.js +161 -0
  62. package/dist/voice/correction/index.d.ts +47 -0
  63. package/dist/voice/correction/index.js +211 -0
  64. package/dist/voice/correction/rewrite.d.ts +92 -0
  65. package/dist/voice/correction/rewrite.js +322 -0
  66. package/dist/voice/errors.d.ts +15 -0
  67. package/dist/voice/errors.js +45 -0
  68. package/dist/voice/index.d.ts +20 -2
  69. package/dist/voice/index.js +75 -9
  70. package/dist/voice/providers/AzureSTT.d.ts +10 -7
  71. package/dist/voice/providers/AzureSTT.js +23 -75
  72. package/dist/voice/providers/DeepgramSTT.d.ts +17 -2
  73. package/dist/voice/providers/DeepgramSTT.js +109 -13
  74. package/dist/voice/providers/ElevenLabsSTT.d.ts +12 -2
  75. package/dist/voice/providers/ElevenLabsSTT.js +34 -8
  76. package/dist/voice/providers/GoogleSTT.d.ts +10 -8
  77. package/dist/voice/providers/GoogleSTT.js +21 -75
  78. package/dist/voice/providers/OpenAISTT.d.ts +30 -9
  79. package/dist/voice/providers/OpenAISTT.js +196 -21
  80. package/dist/voice/providers/WhistleSTT.d.ts +87 -0
  81. package/dist/voice/providers/WhistleSTT.js +313 -0
  82. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +19 -0
  83. package/dist/voice/streaming/chunkedStreamAdapter.js +519 -0
  84. package/dist/voice/streaming/endpointer.d.ts +81 -0
  85. package/dist/voice/streaming/endpointer.js +247 -0
  86. package/dist/voice/streaming/localAgreement.d.ts +30 -0
  87. package/dist/voice/streaming/localAgreement.js +44 -0
  88. package/dist/voice/sttEnvCredentials.d.ts +12 -0
  89. package/dist/voice/sttEnvCredentials.js +12 -0
  90. package/dist/voice/transcribe.d.ts +47 -0
  91. package/dist/voice/transcribe.js +519 -0
  92. package/dist/voice/transcribeStream.d.ts +4 -0
  93. package/dist/voice/transcribeStream.js +584 -0
  94. package/dist/voice/whistle/assets.d.ts +47 -0
  95. package/dist/voice/whistle/assets.js +270 -0
  96. package/dist/voice/whistle/audio.d.ts +32 -0
  97. package/dist/voice/whistle/audio.js +267 -0
  98. package/dist/voice/whistle/engine.d.ts +32 -0
  99. package/dist/voice/whistle/engine.js +193 -0
  100. package/dist/voice/whistle/worker.d.ts +18 -0
  101. package/dist/voice/whistle/worker.js +294 -0
  102. package/docs-site/static/search-index.json +69 -10
  103. package/models/whistle/README.md +18 -0
  104. package/models/whistle/needle.js +2 -0
  105. package/models/whistle/needle.wasm +0 -0
  106. package/models/whistle/whistle.cact +0 -0
  107. package/package.json +7 -3
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Transcribe Routes
3
+ *
4
+ * - `POST <basePath>/agent/transcribe` — JSON in (`audio` base64, `audioUrl`
5
+ * or an allow-listed `audioPath`), `TranscribeResult` out, in the adapters'
6
+ * usual `{ data, metadata }` envelope.
7
+ * - `POST /v1/audio/transcriptions` — OpenAI-compatible: `multipart/form-data`
8
+ * in, OpenAI's `json` / `verbose_json` / `text` out (no envelope), with
9
+ * NeuroLink's extras on `verbose_json`.
10
+ *
11
+ * Both call `neurolink.transcribe()`. Neither accepts credentials: engines use
12
+ * the server's own configuration.
13
+ */
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { withSpan } from "../../telemetry/withSpan.js";
17
+ import { tracers } from "../../telemetry/tracers.js";
18
+ import { logger } from "../../utils/logger.js";
19
+ import { MAX_AUDIO_BYTES } from "../../utils/sizeGuard.js";
20
+ import { RawBodyTooLargeError } from "../utils/rawBody.js";
21
+ import { createErrorResponse, TranscribeConfigSchema, TranscribeRequestSchema, validateRequest, } from "../utils/validation.js";
22
+ const FORMAT_BY_MIME = {
23
+ "audio/mpeg": "mp3",
24
+ "audio/mp3": "mp3",
25
+ "audio/wav": "wav",
26
+ "audio/wave": "wav",
27
+ "audio/x-wav": "wav",
28
+ "audio/ogg": "ogg",
29
+ "audio/opus": "opus",
30
+ "audio/mp4": "m4a",
31
+ "audio/m4a": "m4a",
32
+ "audio/x-m4a": "m4a",
33
+ "audio/aac": "m4a",
34
+ "audio/flac": "flac",
35
+ "audio/x-flac": "flac",
36
+ "audio/webm": "webm",
37
+ "video/webm": "webm",
38
+ "video/mp4": "mp4",
39
+ "audio/l16": "pcm16",
40
+ };
41
+ /** OpenAI model names an OpenAI client sends by default; they mean "the server's default engine". */
42
+ const OPENAI_DEFAULT_MODELS = new Set([
43
+ "",
44
+ "auto",
45
+ "default",
46
+ "whisper-1",
47
+ "gpt-4o-transcribe",
48
+ "gpt-4o-mini-transcribe",
49
+ ]);
50
+ /** STT error codes that describe the request, not the server. */
51
+ const CLIENT_ERROR_CODES = new Set([
52
+ "STT_AUDIO_EMPTY",
53
+ "STT_AUDIO_TOO_LONG",
54
+ "STT_INVALID_AUDIO_FORMAT",
55
+ "STT_LANGUAGE_NOT_SUPPORTED",
56
+ "STT_PROVIDER_NOT_CONFIGURED",
57
+ "STT_PROVIDER_NOT_SUPPORTED",
58
+ "STT_STREAMING_NOT_SUPPORTED",
59
+ ]);
60
+ /** A request that cannot be served, with the HTTP status that says why. */
61
+ class TranscribeRequestError extends Error {
62
+ status;
63
+ code;
64
+ constructor(message, status, code) {
65
+ super(message);
66
+ this.status = status;
67
+ this.code = code;
68
+ this.name = "TranscribeRequestError";
69
+ }
70
+ }
71
+ /** The container a file name, path or URL names by its extension. */
72
+ async function formatFromName(name) {
73
+ if (!name) {
74
+ return undefined;
75
+ }
76
+ // Loaded on use, as NeuroLink itself loads it, to keep the voice stack off
77
+ // the server's static import graph.
78
+ const { audioFormatFromName } = await import("../../voice/transcribe.js");
79
+ try {
80
+ return audioFormatFromName(new URL(name).pathname);
81
+ }
82
+ catch {
83
+ return audioFormatFromName(name); // Not a URL — a file name or path.
84
+ }
85
+ }
86
+ function formatFromMime(mime) {
87
+ if (!mime) {
88
+ return undefined;
89
+ }
90
+ return FORMAT_BY_MIME[mime.split(";")[0].trim().toLowerCase()];
91
+ }
92
+ function tooLarge(limit) {
93
+ return new TranscribeRequestError(`Audio exceeds the ${limit}-byte limit`, 413, "PAYLOAD_TOO_LARGE");
94
+ }
95
+ /** Base64 or a `data:` URL → bytes, refusing more than `limit` before decoding. */
96
+ function decodeBase64Audio(value, limit) {
97
+ let payload = value.trim();
98
+ let format;
99
+ const dataUrl = payload.match(/^data:([^;,]+)?(?:;[^,]*)?;base64,/i);
100
+ if (dataUrl) {
101
+ format = formatFromMime(dataUrl[1]);
102
+ payload = payload.slice(dataUrl[0].length);
103
+ }
104
+ if (Math.floor((payload.length * 3) / 4) > limit + 3) {
105
+ throw tooLarge(limit);
106
+ }
107
+ if (!/^[A-Za-z0-9+/_-]*={0,2}$/.test(payload.replace(/\s+/g, ""))) {
108
+ throw new TranscribeRequestError("audio is not valid base64", 400, "VALIDATION_ERROR");
109
+ }
110
+ const audio = Buffer.from(payload, "base64");
111
+ if (audio.length === 0) {
112
+ throw new TranscribeRequestError("audio is empty", 400, "VALIDATION_ERROR");
113
+ }
114
+ if (audio.length > limit) {
115
+ throw tooLarge(limit);
116
+ }
117
+ return { audio, format };
118
+ }
119
+ /** A path inside one of the allowed roots, after resolving symlinks; refused otherwise. */
120
+ async function readAllowedPath(requested, roots, limit) {
121
+ if (!roots || roots.length === 0) {
122
+ throw new TranscribeRequestError("audioPath is disabled on this server; send audio (base64) or audioUrl instead", 403, "FORBIDDEN");
123
+ }
124
+ let real;
125
+ try {
126
+ real = await fs.promises.realpath(path.resolve(requested));
127
+ }
128
+ catch {
129
+ throw new TranscribeRequestError("audioPath does not exist", 404, "NOT_FOUND");
130
+ }
131
+ const allowed = await Promise.all(roots.map(async (root) => {
132
+ try {
133
+ const realRoot = await fs.promises.realpath(path.resolve(root));
134
+ return real === realRoot || real.startsWith(realRoot + path.sep);
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }));
140
+ if (!allowed.some(Boolean)) {
141
+ throw new TranscribeRequestError("audioPath is outside the directories this server allows", 403, "FORBIDDEN");
142
+ }
143
+ const stat = await fs.promises.stat(real);
144
+ if (!stat.isFile()) {
145
+ throw new TranscribeRequestError("audioPath is not a file", 400, "VALIDATION_ERROR");
146
+ }
147
+ if (stat.size > limit) {
148
+ throw tooLarge(limit);
149
+ }
150
+ return fs.promises.readFile(real);
151
+ }
152
+ async function resolveAudio(request, options, limit) {
153
+ if (request.audio !== undefined) {
154
+ const decoded = decodeBase64Audio(request.audio, limit);
155
+ return { audio: decoded.audio, format: request.format ?? decoded.format };
156
+ }
157
+ if (request.audioUrl !== undefined) {
158
+ if (!/^https?:\/\//i.test(request.audioUrl)) {
159
+ throw new TranscribeRequestError("audioUrl must be an http(s) URL", 400, "VALIDATION_ERROR");
160
+ }
161
+ const { safeDownload } = await import("../../utils/safeFetch.js");
162
+ let audio;
163
+ try {
164
+ audio = await safeDownload(request.audioUrl, {
165
+ maxBytes: limit,
166
+ label: "transcription audio",
167
+ });
168
+ }
169
+ catch (error) {
170
+ throw new TranscribeRequestError(`could not download audioUrl: ${error instanceof Error ? error.message : String(error)}`, 400, "VALIDATION_ERROR");
171
+ }
172
+ return {
173
+ audio,
174
+ format: request.format ?? (await formatFromName(request.audioUrl)),
175
+ };
176
+ }
177
+ if (request.audioPath !== undefined) {
178
+ const audio = await readAllowedPath(request.audioPath, options.allowedAudioRoots, limit);
179
+ return {
180
+ audio,
181
+ format: request.format ?? (await formatFromName(request.audioPath)),
182
+ };
183
+ }
184
+ throw new TranscribeRequestError("Pass exactly one of audio, audioUrl or audioPath", 400, "VALIDATION_ERROR");
185
+ }
186
+ /** Validated config → SDK options (no credentials, ever). */
187
+ function toTranscribeOptions(config, audio, format, maxAudioBytes) {
188
+ return {
189
+ audio,
190
+ // The route already accepted this many bytes; without passing it on the
191
+ // SDK would re-check against its own 25 MB default and refuse the file.
192
+ maxAudioBytes,
193
+ ...(format ? { format } : {}),
194
+ ...(config.provider ? { provider: config.provider } : {}),
195
+ ...(config.model ? { model: config.model } : {}),
196
+ ...(config.language ? { language: config.language } : {}),
197
+ ...(config.prompt ? { prompt: config.prompt } : {}),
198
+ ...(config.dictionary ? { dictionary: config.dictionary } : {}),
199
+ ...(config.correction ? { correction: config.correction } : {}),
200
+ ...(config.fallback ? { fallback: config.fallback } : {}),
201
+ ...(config.diarization !== undefined
202
+ ? { diarization: config.diarization }
203
+ : {}),
204
+ ...(config.wordTimestamps !== undefined
205
+ ? { wordTimestamps: config.wordTimestamps }
206
+ : {}),
207
+ ...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}),
208
+ };
209
+ }
210
+ /** Status and code for an error thrown by `transcribe()` or by this file. */
211
+ function classifyError(error) {
212
+ const message = error instanceof Error ? error.message : String(error);
213
+ if (error instanceof TranscribeRequestError) {
214
+ return { status: error.status, code: error.code, message };
215
+ }
216
+ const code = typeof error === "object" && error !== null && "code" in error
217
+ ? String(error.code)
218
+ : undefined;
219
+ if (code && CLIENT_ERROR_CODES.has(code)) {
220
+ return { status: 400, code: "VALIDATION_ERROR", message };
221
+ }
222
+ return { status: 500, code: "EXECUTION_FAILED", message };
223
+ }
224
+ async function runTranscription(ctx, route, options) {
225
+ return withSpan({
226
+ name: "neurolink.http.transcribe",
227
+ tracer: tracers.http,
228
+ attributes: {
229
+ "http.route": route,
230
+ "stt.provider": options.provider ?? "default",
231
+ "stt.model": options.model ?? "default",
232
+ "stt.audio_bytes": Buffer.isBuffer(options.audio)
233
+ ? options.audio.length
234
+ : undefined,
235
+ },
236
+ }, () => ctx.neurolink.transcribe(options));
237
+ }
238
+ // ---------------------------------------------------------------------------
239
+ // OpenAI-compatible route
240
+ // ---------------------------------------------------------------------------
241
+ function openAIError(status, message, param = null) {
242
+ return new Response(JSON.stringify({
243
+ error: {
244
+ message,
245
+ type: status >= 500 ? "server_error" : "invalid_request_error",
246
+ param,
247
+ code: null,
248
+ },
249
+ }), { status, headers: { "content-type": "application/json" } });
250
+ }
251
+ function isTruthyField(value) {
252
+ return (typeof value === "string" &&
253
+ ["true", "1", "yes", "on"].includes(value.trim().toLowerCase()));
254
+ }
255
+ function textField(form, name) {
256
+ const value = form.get(name);
257
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
258
+ }
259
+ /**
260
+ * OpenAI's `model` field names the engine here: `"<provider>"` or
261
+ * `"<provider>/<model>"`. OpenAI's own model names (and `auto`) mean the
262
+ * server's default engine, so an unmodified OpenAI client works.
263
+ */
264
+ function engineFromModel(model) {
265
+ const value = (model ?? "").trim();
266
+ if (OPENAI_DEFAULT_MODELS.has(value.toLowerCase())) {
267
+ return {};
268
+ }
269
+ const slash = value.indexOf("/");
270
+ if (slash > 0) {
271
+ return { provider: value.slice(0, slash), model: value.slice(slash + 1) };
272
+ }
273
+ return { provider: value };
274
+ }
275
+ function seconds(...values) {
276
+ return values.find((v) => typeof v === "number" && Number.isFinite(v));
277
+ }
278
+ function toVerboseJson(result) {
279
+ const segments = (result.segments ?? []).map((segment, id) => ({
280
+ id,
281
+ start: seconds(segment.start, segment.startTime) ?? 0,
282
+ end: seconds(segment.end, segment.endTime) ?? 0,
283
+ text: segment.text,
284
+ ...(segment.speaker ? { speaker: segment.speaker } : {}),
285
+ }));
286
+ const words = (result.words ?? []).flatMap((word) => {
287
+ const start = seconds(word.start, word.startTime);
288
+ if (start === undefined) {
289
+ return [];
290
+ }
291
+ return [
292
+ {
293
+ word: word.word,
294
+ start,
295
+ end: seconds(word.end, word.endTime) ?? start,
296
+ ...(word.speaker ? { speaker: word.speaker } : {}),
297
+ },
298
+ ];
299
+ });
300
+ return {
301
+ task: "transcribe",
302
+ text: result.text,
303
+ ...(result.language ? { language: result.language } : {}),
304
+ ...(result.duration !== undefined ? { duration: result.duration } : {}),
305
+ ...(segments.length > 0 ? { segments } : {}),
306
+ ...(words.length > 0 ? { words } : {}),
307
+ raw: result.raw,
308
+ ...(result.corrected !== undefined ? { corrected: result.corrected } : {}),
309
+ ...(result.decisions ? { decisions: result.decisions } : {}),
310
+ engine: result.engine,
311
+ ...(result.languageDetected !== undefined
312
+ ? { language_detected: result.languageDetected }
313
+ : {}),
314
+ steps: result.steps,
315
+ timings: result.timings,
316
+ };
317
+ }
318
+ async function handleOpenAITranscription(ctx, options, limit, route) {
319
+ const contentType = ctx.headers["content-type"] ?? "";
320
+ if (!/^\s*multipart\/form-data/i.test(contentType)) {
321
+ return openAIError(400, "Send multipart/form-data with a `file` field, as OpenAI's /v1/audio/transcriptions takes");
322
+ }
323
+ if (ctx.body instanceof RawBodyTooLargeError) {
324
+ return openAIError(413, ctx.body.message, "file");
325
+ }
326
+ if (!Buffer.isBuffer(ctx.body)) {
327
+ return openAIError(400, "Empty or unreadable multipart body");
328
+ }
329
+ let form;
330
+ try {
331
+ form = await new Response(new Uint8Array(ctx.body), {
332
+ headers: { "content-type": contentType },
333
+ }).formData();
334
+ }
335
+ catch {
336
+ return openAIError(400, "Malformed multipart/form-data body");
337
+ }
338
+ const file = form.get("file");
339
+ if (!file || typeof file === "string") {
340
+ return openAIError(400, "Missing `file`", "file");
341
+ }
342
+ if (file.size === 0) {
343
+ return openAIError(400, "`file` is empty", "file");
344
+ }
345
+ if (file.size > limit) {
346
+ return openAIError(413, `Audio exceeds the ${limit}-byte limit`, "file");
347
+ }
348
+ const responseFormat = textField(form, "response_format") ?? "json";
349
+ if (!["json", "verbose_json", "text"].includes(responseFormat)) {
350
+ return openAIError(400, "response_format must be json, verbose_json or text", "response_format");
351
+ }
352
+ let dictionary;
353
+ const dictionaryField = textField(form, "dictionary");
354
+ if (dictionaryField) {
355
+ try {
356
+ dictionary = JSON.parse(dictionaryField);
357
+ }
358
+ catch {
359
+ return openAIError(400, "`dictionary` is not valid JSON", "dictionary");
360
+ }
361
+ }
362
+ const engine = engineFromModel(textField(form, "model"));
363
+ const granularities = form
364
+ .getAll("timestamp_granularities[]")
365
+ .concat(form.getAll("timestamp_granularities"));
366
+ const secondOpinion = textField(form, "second_opinion");
367
+ const correct = isTruthyField(form.get("correct"));
368
+ const fallback = textField(form, "fallback");
369
+ const language = textField(form, "language");
370
+ const prompt = textField(form, "prompt");
371
+ const candidate = {
372
+ provider: textField(form, "provider") ?? engine.provider,
373
+ model: engine.model,
374
+ language,
375
+ prompt,
376
+ dictionary,
377
+ ...(correct || secondOpinion
378
+ ? {
379
+ correction: {
380
+ enabled: true,
381
+ ...(secondOpinion
382
+ ? { secondOpinion: { provider: secondOpinion } }
383
+ : {}),
384
+ },
385
+ }
386
+ : {}),
387
+ ...(fallback ? { fallback: { provider: fallback } } : {}),
388
+ ...(isTruthyField(form.get("diarize")) ? { diarization: true } : {}),
389
+ ...(granularities.includes("word") ? { wordTimestamps: true } : {}),
390
+ };
391
+ const parsed = TranscribeConfigSchema.safeParse(candidate);
392
+ if (!parsed.success) {
393
+ const issue = parsed.error.issues[0];
394
+ return openAIError(400, `Invalid ${issue?.path.join(".") || "request"}: ${issue?.message ?? "invalid value"}`, issue?.path[0] !== undefined ? String(issue.path[0]) : null);
395
+ }
396
+ const audio = Buffer.from(await file.arrayBuffer());
397
+ const format = (await formatFromName(file.name)) ?? formatFromMime(file.type);
398
+ let result;
399
+ try {
400
+ result = await runTranscription(ctx, route, toTranscribeOptions(parsed.data, audio, format, limit));
401
+ }
402
+ catch (error) {
403
+ const classified = classifyError(error);
404
+ logger.warn("[transcribeRoutes] OpenAI-compatible transcription failed", {
405
+ requestId: ctx.requestId,
406
+ error: classified.message,
407
+ });
408
+ return openAIError(classified.status, classified.message);
409
+ }
410
+ if (responseFormat === "text") {
411
+ return new Response(result.text, {
412
+ status: 200,
413
+ headers: { "content-type": "text/plain; charset=utf-8" },
414
+ });
415
+ }
416
+ const body = responseFormat === "verbose_json"
417
+ ? toVerboseJson(result)
418
+ : { text: result.text };
419
+ return new Response(JSON.stringify(body), {
420
+ status: 200,
421
+ headers: { "content-type": "application/json" },
422
+ });
423
+ }
424
+ // ---------------------------------------------------------------------------
425
+ // Route group
426
+ // ---------------------------------------------------------------------------
427
+ const TRANSCRIBE_REQUEST_JSON_SCHEMA = {
428
+ type: "object",
429
+ description: "Exactly one of audio (base64 or data: URL), audioUrl (http/https) or audioPath (server-allowed directories only).",
430
+ properties: {
431
+ audio: { type: "string", description: "Base64 audio or a data: URL" },
432
+ audioUrl: { type: "string", format: "uri" },
433
+ audioPath: { type: "string" },
434
+ format: { type: "string" },
435
+ provider: { type: "string" },
436
+ model: { type: "string" },
437
+ language: { type: "string", description: "Language code or auto" },
438
+ prompt: { type: "string" },
439
+ dictionary: {
440
+ type: "array",
441
+ items: {
442
+ type: "object",
443
+ properties: {
444
+ term: { type: "string" },
445
+ heardAs: { type: "array", items: { type: "string" } },
446
+ meaning: { type: "string" },
447
+ },
448
+ required: ["term"],
449
+ },
450
+ },
451
+ correction: { type: "object" },
452
+ fallback: { type: "object" },
453
+ diarization: { type: "boolean" },
454
+ wordTimestamps: { type: "boolean" },
455
+ timeoutMs: { type: "number" },
456
+ },
457
+ };
458
+ /**
459
+ * Create the transcription routes.
460
+ *
461
+ * @param basePath - Prefix of the NeuroLink route (default `/api`).
462
+ * @param options - `allowedAudioRoots` (default: `audioPath` refused),
463
+ * `maxAudioBytes` (default 50 MB) and `openaiBasePath` (default `""`, so the
464
+ * OpenAI-compatible route is `/v1/audio/transcriptions`).
465
+ */
466
+ export function createTranscribeRoutes(basePath = "/api", options = {}) {
467
+ const limit = options.maxAudioBytes ?? MAX_AUDIO_BYTES;
468
+ const nativePath = `${basePath}/agent/transcribe`;
469
+ const openaiPath = `${options.openaiBasePath ?? ""}/v1/audio/transcriptions`;
470
+ return {
471
+ // Empty prefix: the two routes live under different roots, and every
472
+ // route path already starts with "", so the adapters add nothing.
473
+ prefix: "",
474
+ routes: [
475
+ {
476
+ method: "POST",
477
+ path: nativePath,
478
+ handler: async (ctx) => {
479
+ const validation = validateRequest(TranscribeRequestSchema, ctx.body, ctx.requestId);
480
+ if (!validation.success) {
481
+ return validation.error;
482
+ }
483
+ const request = validation.data;
484
+ try {
485
+ const { audio, format } = await resolveAudio(request, options, limit);
486
+ return await runTranscription(ctx, nativePath, toTranscribeOptions(request, audio, format, limit));
487
+ }
488
+ catch (error) {
489
+ const classified = classifyError(error);
490
+ return createErrorResponse(classified.code, classified.message, undefined, ctx.requestId, classified.status);
491
+ }
492
+ },
493
+ description: "Transcribe audio (base64, URL or allowed path) with the transcribe inference type",
494
+ requestSchema: TRANSCRIBE_REQUEST_JSON_SCHEMA,
495
+ tags: ["agent", "transcribe"],
496
+ },
497
+ {
498
+ method: "POST",
499
+ path: openaiPath,
500
+ handler: (ctx) => handleOpenAITranscription(ctx, options, limit, openaiPath),
501
+ description: "OpenAI-compatible transcription (multipart/form-data: file, model, language, prompt, response_format; extensions: provider, diarize, dictionary, correct, fallback, second_opinion)",
502
+ tags: ["transcribe", "openai-compatible"],
503
+ },
504
+ ],
505
+ };
506
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Raw request bodies and raw responses for the server adapters.
3
+ *
4
+ * The adapters parse JSON (and url-encoded forms) into `ctx.body`. Routes that
5
+ * take uploads — `POST /v1/audio/transcriptions` takes `multipart/form-data`
6
+ * the way OpenAI's API does — need the bytes instead, so for the content types
7
+ * below every adapter hands the route the unparsed body as a `Buffer`, bounded
8
+ * by `bodyParser.maxSize`. A route that must answer in a wire format other than
9
+ * the adapters' `{ data, metadata }` envelope (an OpenAI-compatible route, a
10
+ * plain-text response) returns a web `Response`, which every adapter sends as is.
11
+ *
12
+ * @module server/utils/rawBody
13
+ */
14
+ /** `true` for a content type whose body the adapters pass through unparsed. */
15
+ export declare function isRawBodyContentType(contentType: unknown): boolean;
16
+ /** "10mb" / "512kb" / "1gb" / a byte count → bytes. Falls back to 10 MB. */
17
+ export declare function parseByteLimit(limit: string | number | undefined): number;
18
+ /** Thrown when a raw body grows past the adapter's `maxSize`. */
19
+ export declare class RawBodyTooLargeError extends Error {
20
+ readonly status = 413;
21
+ constructor(limit: number);
22
+ }
23
+ /** Read a Node request stream into one `Buffer`, refusing more than `maxBytes`. */
24
+ export declare function readRawBody(stream: AsyncIterable<Buffer | string>, maxBytes: number): Promise<Buffer>;
25
+ /** A web `Response` returned by a route handler. */
26
+ export declare function isWebResponse(value: unknown): value is Response;
27
+ /** Status, headers and body bytes of a web `Response`, for adapters that write to a Node response. */
28
+ export declare function readWebResponse(response: Response): Promise<{
29
+ status: number;
30
+ headers: Record<string, string>;
31
+ body: Buffer;
32
+ }>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Raw request bodies and raw responses for the server adapters.
3
+ *
4
+ * The adapters parse JSON (and url-encoded forms) into `ctx.body`. Routes that
5
+ * take uploads — `POST /v1/audio/transcriptions` takes `multipart/form-data`
6
+ * the way OpenAI's API does — need the bytes instead, so for the content types
7
+ * below every adapter hands the route the unparsed body as a `Buffer`, bounded
8
+ * by `bodyParser.maxSize`. A route that must answer in a wire format other than
9
+ * the adapters' `{ data, metadata }` envelope (an OpenAI-compatible route, a
10
+ * plain-text response) returns a web `Response`, which every adapter sends as is.
11
+ *
12
+ * @module server/utils/rawBody
13
+ */
14
+ /** Content types whose body reaches the route as a `Buffer`. */
15
+ const RAW_BODY_CONTENT_TYPE = /^\s*(multipart\/form-data|application\/octet-stream|audio\/[\w.+-]+)\s*(;|$)/i;
16
+ /** `true` for a content type whose body the adapters pass through unparsed. */
17
+ export function isRawBodyContentType(contentType) {
18
+ return (typeof contentType === "string" && RAW_BODY_CONTENT_TYPE.test(contentType));
19
+ }
20
+ /** "10mb" / "512kb" / "1gb" / a byte count → bytes. Falls back to 10 MB. */
21
+ export function parseByteLimit(limit) {
22
+ if (typeof limit === "number" && Number.isFinite(limit) && limit > 0) {
23
+ return limit;
24
+ }
25
+ const match = typeof limit === "string"
26
+ ? limit.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i)
27
+ : null;
28
+ if (!match) {
29
+ return 10 * 1024 * 1024;
30
+ }
31
+ const units = {
32
+ b: 1,
33
+ kb: 1024,
34
+ mb: 1024 * 1024,
35
+ gb: 1024 * 1024 * 1024,
36
+ };
37
+ return Math.floor(Number(match[1]) * units[(match[2] ?? "b").toLowerCase()]);
38
+ }
39
+ /** Thrown when a raw body grows past the adapter's `maxSize`. */
40
+ export class RawBodyTooLargeError extends Error {
41
+ status = 413;
42
+ constructor(limit) {
43
+ super(`Request body exceeds the server's ${limit}-byte limit`);
44
+ this.name = "RawBodyTooLargeError";
45
+ }
46
+ }
47
+ /** Read a Node request stream into one `Buffer`, refusing more than `maxBytes`. */
48
+ export async function readRawBody(stream, maxBytes) {
49
+ const chunks = [];
50
+ let total = 0;
51
+ for await (const chunk of stream) {
52
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
53
+ total += buffer.length;
54
+ if (total > maxBytes) {
55
+ throw new RawBodyTooLargeError(maxBytes);
56
+ }
57
+ chunks.push(buffer);
58
+ }
59
+ return Buffer.concat(chunks);
60
+ }
61
+ /** A web `Response` returned by a route handler. */
62
+ export function isWebResponse(value) {
63
+ return typeof Response !== "undefined" && value instanceof Response;
64
+ }
65
+ /** Status, headers and body bytes of a web `Response`, for adapters that write to a Node response. */
66
+ export async function readWebResponse(response) {
67
+ const headers = {};
68
+ response.headers.forEach((value, key) => {
69
+ headers[key] = value;
70
+ });
71
+ return {
72
+ status: response.status,
73
+ headers,
74
+ body: Buffer.from(await response.arrayBuffer()),
75
+ };
76
+ }