@juspay/neurolink 12.48.2 → 12.49.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 (106) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/dist/browser/neurolink.min.js +616 -595
  3. package/dist/cli/commands/serve.d.ts +0 -3
  4. package/dist/cli/commands/serve.js +85 -3
  5. package/dist/cli/commands/transcribe.d.ts +24 -0
  6. package/dist/cli/commands/transcribe.js +718 -0
  7. package/dist/cli/factories/commandFactory.d.ts +10 -0
  8. package/dist/cli/factories/commandFactory.js +13 -0
  9. package/dist/cli/parser.js +3 -0
  10. package/dist/factories/mediaHandlerCatalog.js +11 -9
  11. package/dist/factories/providerDescriptors.js +5 -4
  12. package/dist/factories/sttDescriptors.d.ts +63 -0
  13. package/dist/factories/sttDescriptors.js +286 -0
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.js +7 -1
  16. package/dist/neurolink.d.ts +68 -1
  17. package/dist/neurolink.js +202 -4
  18. package/dist/processors/media/AudioProcessor.d.ts +15 -56
  19. package/dist/processors/media/AudioProcessor.js +105 -276
  20. package/dist/processors/media/VideoProcessor.d.ts +3 -5
  21. package/dist/processors/media/VideoProcessor.js +52 -51
  22. package/dist/providers/cloudflareClef.js +9 -1
  23. package/dist/server/abstract/baseServerAdapter.d.ts +7 -0
  24. package/dist/server/abstract/baseServerAdapter.js +13 -1
  25. package/dist/server/adapters/expressAdapter.d.ts +1 -0
  26. package/dist/server/adapters/expressAdapter.js +48 -4
  27. package/dist/server/adapters/fastifyAdapter.d.ts +1 -0
  28. package/dist/server/adapters/fastifyAdapter.js +38 -1
  29. package/dist/server/adapters/honoAdapter.d.ts +1 -4
  30. package/dist/server/adapters/honoAdapter.js +83 -1
  31. package/dist/server/adapters/koaAdapter.d.ts +1 -0
  32. package/dist/server/adapters/koaAdapter.js +43 -0
  33. package/dist/server/index.d.ts +3 -2
  34. package/dist/server/index.js +3 -2
  35. package/dist/server/routes/index.d.ts +1 -0
  36. package/dist/server/routes/index.js +3 -0
  37. package/dist/server/routes/transcribeRoutes.d.ts +23 -0
  38. package/dist/server/routes/transcribeRoutes.js +506 -0
  39. package/dist/server/utils/rawBody.d.ts +32 -0
  40. package/dist/server/utils/rawBody.js +76 -0
  41. package/dist/server/utils/validation.d.ts +192 -0
  42. package/dist/server/utils/validation.js +100 -0
  43. package/dist/server/websocket/transcribeWebSocket.d.ts +42 -0
  44. package/dist/server/websocket/transcribeWebSocket.js +264 -0
  45. package/dist/types/cli.d.ts +46 -0
  46. package/dist/types/index.d.ts +1 -0
  47. package/dist/types/index.js +1 -0
  48. package/dist/types/providers.d.ts +7 -1
  49. package/dist/types/server.d.ts +117 -1
  50. package/dist/types/stt.d.ts +160 -7
  51. package/dist/types/transcribe.d.ts +506 -0
  52. package/dist/types/transcribe.js +29 -0
  53. package/dist/utils/speakerTurns.d.ts +28 -0
  54. package/dist/utils/speakerTurns.js +93 -0
  55. package/dist/utils/sttProcessor.d.ts +20 -2
  56. package/dist/utils/sttProcessor.js +73 -5
  57. package/dist/voice/correction/dictionary.d.ts +49 -0
  58. package/dist/voice/correction/dictionary.js +134 -0
  59. package/dist/voice/correction/guard.d.ts +35 -0
  60. package/dist/voice/correction/guard.js +161 -0
  61. package/dist/voice/correction/index.d.ts +47 -0
  62. package/dist/voice/correction/index.js +211 -0
  63. package/dist/voice/correction/rewrite.d.ts +92 -0
  64. package/dist/voice/correction/rewrite.js +322 -0
  65. package/dist/voice/errors.d.ts +15 -0
  66. package/dist/voice/errors.js +45 -0
  67. package/dist/voice/index.d.ts +20 -2
  68. package/dist/voice/index.js +75 -9
  69. package/dist/voice/providers/AzureSTT.d.ts +10 -7
  70. package/dist/voice/providers/AzureSTT.js +23 -75
  71. package/dist/voice/providers/DeepgramSTT.d.ts +17 -2
  72. package/dist/voice/providers/DeepgramSTT.js +109 -13
  73. package/dist/voice/providers/ElevenLabsSTT.d.ts +12 -2
  74. package/dist/voice/providers/ElevenLabsSTT.js +34 -8
  75. package/dist/voice/providers/GoogleSTT.d.ts +10 -8
  76. package/dist/voice/providers/GoogleSTT.js +21 -75
  77. package/dist/voice/providers/OpenAISTT.d.ts +30 -9
  78. package/dist/voice/providers/OpenAISTT.js +196 -21
  79. package/dist/voice/providers/WhistleSTT.d.ts +87 -0
  80. package/dist/voice/providers/WhistleSTT.js +313 -0
  81. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +19 -0
  82. package/dist/voice/streaming/chunkedStreamAdapter.js +519 -0
  83. package/dist/voice/streaming/endpointer.d.ts +81 -0
  84. package/dist/voice/streaming/endpointer.js +247 -0
  85. package/dist/voice/streaming/localAgreement.d.ts +30 -0
  86. package/dist/voice/streaming/localAgreement.js +44 -0
  87. package/dist/voice/sttEnvCredentials.d.ts +12 -0
  88. package/dist/voice/sttEnvCredentials.js +12 -0
  89. package/dist/voice/transcribe.d.ts +47 -0
  90. package/dist/voice/transcribe.js +519 -0
  91. package/dist/voice/transcribeStream.d.ts +4 -0
  92. package/dist/voice/transcribeStream.js +584 -0
  93. package/dist/voice/whistle/assets.d.ts +47 -0
  94. package/dist/voice/whistle/assets.js +270 -0
  95. package/dist/voice/whistle/audio.d.ts +32 -0
  96. package/dist/voice/whistle/audio.js +267 -0
  97. package/dist/voice/whistle/engine.d.ts +32 -0
  98. package/dist/voice/whistle/engine.js +193 -0
  99. package/dist/voice/whistle/worker.d.ts +18 -0
  100. package/dist/voice/whistle/worker.js +294 -0
  101. package/docs-site/static/search-index.json +71 -12
  102. package/models/whistle/README.md +18 -0
  103. package/models/whistle/needle.js +2 -0
  104. package/models/whistle/needle.wasm +0 -0
  105. package/models/whistle/whistle.cact +0 -0
  106. package/package.json +7 -3
@@ -93,7 +93,7 @@ OpenAPIGenerator, OpenAPISchemas, ProviderInfoSchema, ReadyResponseSchema, Sessi
93
93
  // ============================================
94
94
  // Routes
95
95
  // ============================================
96
- export { createAgentRoutes, createAllRoutes, createHealthRoutes, createMCPRoutes, createMemoryRoutes, createOpenApiRoutes, createToolRoutes, registerAllRoutes,
96
+ export { createAgentRoutes, createAllRoutes, createHealthRoutes, createMCPRoutes, createMemoryRoutes, createOpenApiRoutes, createToolRoutes, createTranscribeRoutes, registerAllRoutes,
97
97
  // The proxy doors. All were reachable only from the deep path
98
98
  // ./routes/index.js, which is not a package export — so a consumer of
99
99
  // "@juspay/neurolink/server" could reach them only indirectly through
@@ -120,8 +120,9 @@ export { createStreamRedactor, redactStreamChunk } from "./utils/redaction.js";
120
120
  // ============================================
121
121
  // Validation Utilities
122
122
  // ============================================
123
- export { AgentExecuteRequestSchema, createErrorResponse, ServerNameParamSchema, SessionIdParamSchema, ToolArgumentsSchema, ToolExecuteRequestSchema, ToolNameParamSchema, ToolSearchQuerySchema, validateParams, validateQuery, validateRequest, } from "./utils/validation.js";
123
+ export { AgentExecuteRequestSchema, createErrorResponse, ServerNameParamSchema, SessionIdParamSchema, ToolArgumentsSchema, ToolExecuteRequestSchema, ToolNameParamSchema, ToolSearchQuerySchema, TranscribeConfigSchema, TranscribeRequestSchema, TranscribeStreamConfigSchema, validateParams, validateQuery, validateRequest, } from "./utils/validation.js";
124
124
  // ============================================
125
125
  // WebSocket
126
126
  // ============================================
127
127
  export { createAgentWebSocketHandler, WebSocketConnectionManager, WebSocketMessageRouter, } from "./websocket/index.js";
128
+ export { attachTranscribeWebSocket } from "./websocket/transcribeWebSocket.js";
@@ -13,6 +13,7 @@ export { createMCPRoutes } from "./mcpRoutes.js";
13
13
  export { createMemoryRoutes } from "./memoryRoutes.js";
14
14
  export { createOpenApiRoutes } from "./openApiRoutes.js";
15
15
  export { createToolRoutes } from "./toolRoutes.js";
16
+ export { createTranscribeRoutes } from "./transcribeRoutes.js";
16
17
  /**
17
18
  * Create all standard routes
18
19
  * Convenience method that combines all route groups
@@ -13,6 +13,7 @@ import { createMCPRoutes } from "./mcpRoutes.js";
13
13
  import { createMemoryRoutes } from "./memoryRoutes.js";
14
14
  import { createOpenApiRoutes } from "./openApiRoutes.js";
15
15
  import { createToolRoutes } from "./toolRoutes.js";
16
+ import { createTranscribeRoutes } from "./transcribeRoutes.js";
16
17
  // Re-export route builders from individual files
17
18
  export { createAgentRoutes } from "./agentRoutes.js";
18
19
  export { createClaudeProxyRoutes } from "./claudeProxyRoutes.js";
@@ -25,6 +26,7 @@ export { createMCPRoutes } from "./mcpRoutes.js";
25
26
  export { createMemoryRoutes } from "./memoryRoutes.js";
26
27
  export { createOpenApiRoutes } from "./openApiRoutes.js";
27
28
  export { createToolRoutes } from "./toolRoutes.js";
29
+ export { createTranscribeRoutes } from "./transcribeRoutes.js";
28
30
  /**
29
31
  * Create all standard routes
30
32
  * Convenience method that combines all route groups
@@ -44,6 +46,7 @@ export function createAllRoutes(basePath = "/api", options) {
44
46
  createMCPRoutes(basePath),
45
47
  createMemoryRoutes(basePath),
46
48
  createHealthRoutes(basePath),
49
+ createTranscribeRoutes(basePath, options?.transcribe),
47
50
  ];
48
51
  // Conditionally add OpenAPI/Swagger routes
49
52
  if (options?.enableSwagger) {
@@ -0,0 +1,23 @@
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 type { RouteGroup, ServerTranscribeRouteOptions } from "../../types/index.js";
15
+ /**
16
+ * Create the transcription routes.
17
+ *
18
+ * @param basePath - Prefix of the NeuroLink route (default `/api`).
19
+ * @param options - `allowedAudioRoots` (default: `audioPath` refused),
20
+ * `maxAudioBytes` (default 50 MB) and `openaiBasePath` (default `""`, so the
21
+ * OpenAI-compatible route is `/v1/audio/transcriptions`).
22
+ */
23
+ export declare function createTranscribeRoutes(basePath?: string, options?: ServerTranscribeRouteOptions): RouteGroup;
@@ -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
+ }>;