speechrevolutions 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,950 @@
1
+ import {
2
+ APIError,
3
+ AuthenticationError,
4
+ JobFailedError,
5
+ JobNotFoundError,
6
+ RateLimitError,
7
+ TimeoutError,
8
+ UploadError,
9
+ } from "./exceptions.js";
10
+ import { resolveProgress } from "./progress.js";
11
+ import { parseSSEStream } from "./sse.js";
12
+ import { parseTranscript, type Transcript } from "./transcript.js";
13
+ import {
14
+ byteProgressAdapter,
15
+ iterWithProgress,
16
+ type ByteProgressFn,
17
+ } from "./upload.js";
18
+ import {
19
+ makeProgressEvent,
20
+ resolveOptions,
21
+ type JobStatus,
22
+ type OutputType,
23
+ type ProgressCallback,
24
+ type STTClientOptions,
25
+ type TranscribeOptions,
26
+ type UploadJob,
27
+ } from "./types.js";
28
+
29
+ /**
30
+ * Identifies the SDK to the platform, which makes a client-side problem findable
31
+ * in our edge logs without the caller reproducing it. It is also insurance: the
32
+ * edge answers a request with NO User-Agent with a bare 403, which is how the C#
33
+ * client turned out to be unable to reach production at all while passing every
34
+ * test that pointed at a local mock.
35
+ */
36
+ const USER_AGENT = "speechrevolutions-node/0.2.0";
37
+
38
+ const DEFAULT_BASE_URL = "https://api.speechrevolutions.com";
39
+ const UPLOAD_PROGRESS_INTERVAL_MS = 10_000;
40
+ const UPLOAD_MAX_ATTEMPTS = 4;
41
+ const UPLOAD_BASE_DELAY_MS = 1_000;
42
+ const SSE_MAX_RECONNECTS = 10;
43
+ const SSE_RECONNECT_DELAY_MS = 3_000;
44
+ const POLL_INTERVAL_MS = 5_000;
45
+ const DEFAULT_MAX_RETRIES = 3;
46
+ const DEFAULT_RETRY_BACKOFF_MS = 500;
47
+ const RETRY_BACKOFF_MAX_MS = 30_000;
48
+ const RETRY_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
49
+ const REQUEST_ID_HEADERS = ["x-request-id", "x-amzn-requestid", "cf-ray"];
50
+
51
+ /**
52
+ * Endpoints that CREATE a job, and so are not safe to blindly retry.
53
+ *
54
+ * A job is created the moment the server handles one of these; the response
55
+ * carrying the job_id back is what can be lost. Retrying after the request may
56
+ * have arrived creates a SECOND job for the same audio — two transcripts, two
57
+ * charges — and the caller never learns about the orphan. The API has no
58
+ * idempotency key, so the only safe rule is to retry these solely when the
59
+ * request provably never reached the server.
60
+ *
61
+ * Every other endpoint either reads, or acts on a jobId the caller already
62
+ * holds, and stays fully retryable.
63
+ */
64
+ const JOB_CREATING_PATHS = new Set([
65
+ "/api/v1/upload",
66
+ "/api/v1/upload/multipart/create",
67
+ ]);
68
+
69
+ function createsJob(path: string): boolean {
70
+ const clean = path.split("?")[0].replace(/\/+$/, "");
71
+ return JOB_CREATING_PATHS.has(clean);
72
+ }
73
+
74
+ /**
75
+ * Network error codes that mean no connection was ever established, so the
76
+ * request cannot have been processed. Anything else (a reset mid-flight, a
77
+ * headers timeout) is ambiguous and must not be retried for a create.
78
+ */
79
+ const NEVER_SENT_CODES = new Set([
80
+ "ECONNREFUSED",
81
+ "ENOTFOUND",
82
+ "EAI_AGAIN",
83
+ "EHOSTUNREACH",
84
+ "ENETUNREACH",
85
+ "UND_ERR_CONNECT_TIMEOUT",
86
+ ]);
87
+
88
+ function neverReachedServer(err: unknown): boolean {
89
+ const code = (err as { cause?: { code?: string }; code?: string })?.cause?.code
90
+ ?? (err as { code?: string })?.code;
91
+ return typeof code === "string" && NEVER_SENT_CODES.has(code);
92
+ }
93
+
94
+ function extractRequestId(headers: Headers): string | undefined {
95
+ for (const name of REQUEST_ID_HEADERS) {
96
+ const value = headers.get(name);
97
+ if (value) return value;
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ /** Parse a Retry-After header (delta-seconds form) into milliseconds. */
103
+ function parseRetryAfterMs(value: string | null): number | undefined {
104
+ if (!value) return undefined;
105
+ const secs = Number(value);
106
+ return Number.isFinite(secs) ? Math.max(0, secs) * 1000 : undefined;
107
+ }
108
+
109
+ function resolveApiKey(apiKey?: string): string {
110
+ if (apiKey) return apiKey;
111
+ const env =
112
+ (typeof process !== "undefined" &&
113
+ (process.env.SPEECHREVOLUTIONS_API_KEY || process.env.STT_API_KEY)) ||
114
+ undefined;
115
+ if (env) return env;
116
+ throw new AuthenticationError(
117
+ "apiKey is required (pass apiKey or set SPEECHREVOLUTIONS_API_KEY / STT_API_KEY)",
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Resolves the API host: an explicit option, then the environment, then
123
+ * production.
124
+ *
125
+ * Symmetric with the API key — if a caller can supply a key from the
126
+ * environment, they can point it at an environment too. Needed for staging, for
127
+ * an egress proxy or gateway, and for running any published example against
128
+ * something that is not production.
129
+ */
130
+ function resolveBaseUrl(baseUrl?: string): string {
131
+ const env =
132
+ (typeof process !== "undefined" &&
133
+ (process.env.SPEECHREVOLUTIONS_BASE_URL || process.env.STT_BASE_URL)) ||
134
+ undefined;
135
+ return (baseUrl ?? env ?? DEFAULT_BASE_URL).replace(/\/$/, "");
136
+ }
137
+
138
+ /** Internal signal that a multipart upload should fall back to single-shot. */
139
+ class MultipartUnavailable extends Error {}
140
+
141
+ interface MultipartCreateResponse {
142
+ job_id: string;
143
+ upload_id: string;
144
+ download_url: string;
145
+ part_size: number;
146
+ num_parts: number;
147
+ parts: { part_number: number; url: string }[];
148
+ }
149
+
150
+ export class STTClient {
151
+ readonly apiKey: string;
152
+ readonly baseUrl: string;
153
+ readonly timeout: number;
154
+ private readonly fetchFn: typeof fetch;
155
+ private readonly maxRetries: number;
156
+ private readonly retryBackoffMs: number;
157
+ private readonly requestInit: RequestInit;
158
+ private readonly multipart: boolean;
159
+
160
+ constructor(opts: STTClientOptions | string = {}) {
161
+ const options: STTClientOptions =
162
+ typeof opts === "string" ? { apiKey: opts } : opts ?? {};
163
+ this.apiKey = resolveApiKey(options.apiKey);
164
+ this.baseUrl = resolveBaseUrl(options.baseUrl);
165
+ this.timeout = options.timeout ?? 600;
166
+ this.fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
167
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
168
+ this.retryBackoffMs = options.retryBackoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
169
+ this.requestInit = options.requestInit ?? {};
170
+ // Prefer multipart; falls back to a single presigned PUT if the server has
171
+ // multipart disabled (404) or a multipart upload fails mid-flight.
172
+ this.multipart = options.multipart ?? true;
173
+ }
174
+
175
+ private retryDelayMs(attempt: number, retryAfterMs?: number): number {
176
+ if (retryAfterMs !== undefined) return Math.min(retryAfterMs, RETRY_BACKOFF_MAX_MS);
177
+ return Math.min(this.retryBackoffMs * 2 ** (attempt - 1), RETRY_BACKOFF_MAX_MS);
178
+ }
179
+
180
+ // high-level
181
+
182
+ /**
183
+ * Transcribe a local file path, remote URL, bytes, or Blob. A URL is handed
184
+ * to the platform to fetch, so nothing is uploaded from here.
185
+ */
186
+ async transcribe(
187
+ audio: Uint8Array | ArrayBuffer | Blob | string,
188
+ options: TranscribeOptions = {},
189
+ onProgress?: ProgressCallback,
190
+ ): Promise<Transcript> {
191
+ const opts = resolveOptions(options);
192
+ const onTranscribeProgress = onProgress ?? options.onProgress;
193
+ const show = options.progress ?? false;
194
+
195
+ if (typeof audio === "string" && isUrl(audio)) {
196
+ const { jobId, downloadUrl } = await this.submitUrl(audio, opts);
197
+ return this.awaitTranscript(jobId, downloadUrl, opts, onTranscribeProgress, show);
198
+ }
199
+
200
+ const { data, fileSize } = await readAudio(audio);
201
+
202
+ // Upload phase: byte-level "Uploading" bar, then a "Transcribing" bar.
203
+ const upload = resolveProgress(options.onUploadProgress, show, {
204
+ label: "Uploading",
205
+ bytesMode: true,
206
+ });
207
+ let jobId: string;
208
+ let jobDownloadUrl: string;
209
+ try {
210
+ ({ jobId, downloadUrl: jobDownloadUrl } = await this.ingestUpload(
211
+ data,
212
+ fileSize,
213
+ opts,
214
+ upload.callback,
215
+ ));
216
+ } finally {
217
+ upload.printer?.close();
218
+ }
219
+
220
+ return this.awaitTranscript(jobId, jobDownloadUrl, opts, onTranscribeProgress, show);
221
+ }
222
+
223
+ /** Waits out the transcription phase and parses the result. */
224
+ private async awaitTranscript(
225
+ jobId: string,
226
+ jobDownloadUrl: string,
227
+ opts: ReturnType<typeof resolveOptions>,
228
+ onProgress: ProgressCallback | undefined,
229
+ show: boolean,
230
+ ): Promise<Transcript> {
231
+ const transcribeProgress = resolveProgress(onProgress, show, { label: "Transcribing" });
232
+ let content: Uint8Array;
233
+ let downloadUrl: string;
234
+ try {
235
+ ({ content, downloadUrl } = await this.waitForResult(
236
+ jobId,
237
+ jobDownloadUrl,
238
+ transcribeProgress.callback,
239
+ ));
240
+ } finally {
241
+ transcribeProgress.printer?.close();
242
+ }
243
+
244
+ return parseTranscript({
245
+ jobId,
246
+ content,
247
+ outputType: opts.outputType,
248
+ downloadUrl,
249
+ });
250
+ }
251
+
252
+ /** Registers a job the platform fetches itself. No bytes leave this process. */
253
+ private async submitUrl(
254
+ audioUrl: string,
255
+ opts: ReturnType<typeof resolveOptions>,
256
+ ): Promise<{ jobId: string; downloadUrl: string }> {
257
+ const data = await this.apiRequest<{ job_id: string; download_url: string }>(
258
+ "POST",
259
+ "/api/v1/upload",
260
+ { ...this.uploadBody(undefined, opts), audio_url: audioUrl },
261
+ );
262
+ return { jobId: String(data.job_id), downloadUrl: String(data.download_url) };
263
+ }
264
+
265
+ async transcribeUrl(
266
+ url: string,
267
+ options: TranscribeOptions = {},
268
+ onProgress?: ProgressCallback,
269
+ ): Promise<Transcript> {
270
+ return this.transcribe(url, options, onProgress);
271
+ }
272
+
273
+ async transcribeFile(
274
+ path: string,
275
+ options: TranscribeOptions = {},
276
+ onProgress?: ProgressCallback,
277
+ ): Promise<Transcript> {
278
+ return this.transcribe(path, options, onProgress);
279
+ }
280
+
281
+ /**
282
+ * Upload and enqueue a job, returning its `jobId` WITHOUT waiting for the
283
+ * result. Collect it later via a webhook (`callbackUrl`) or by polling
284
+ * `getJobStatus` / `getTranscript`. Ideal for batch workloads — submit many,
285
+ * then gather — since it holds no long-lived connection per job.
286
+ */
287
+ async submit(
288
+ audio: Uint8Array | ArrayBuffer | Blob | string,
289
+ options: TranscribeOptions = {},
290
+ ): Promise<string> {
291
+ const opts = resolveOptions(options);
292
+
293
+ if (typeof audio === "string" && isUrl(audio)) {
294
+ const { jobId } = await this.submitUrl(audio, opts);
295
+ return jobId;
296
+ }
297
+
298
+ const { data, fileSize } = await readAudio(audio);
299
+ const upload = resolveProgress(options.onUploadProgress, options.progress ?? false, {
300
+ label: "Uploading",
301
+ bytesMode: true,
302
+ });
303
+ let jobId: string;
304
+ try {
305
+ ({ jobId } = await this.ingestUpload(data, fileSize, opts, upload.callback));
306
+ } finally {
307
+ upload.printer?.close();
308
+ }
309
+ return jobId;
310
+ }
311
+
312
+ // upload flow
313
+
314
+ /** JSON body shared by the single-shot and multipart create endpoints. */
315
+ private uploadBody(
316
+ fileSize: number | undefined,
317
+ opts: ReturnType<typeof resolveOptions>,
318
+ ): Record<string, unknown> {
319
+ return {
320
+ ...(fileSize === undefined ? {} : { file_size: fileSize }),
321
+ output_type: opts.outputType,
322
+ word_timestamps: opts.wordTimestamps,
323
+ speaker_labels: opts.speakerLabels,
324
+ nltk: opts.nltk,
325
+ tier: opts.tier,
326
+ ...(opts.customVocabulary ? { custom_vocabulary: opts.customVocabulary } : {}),
327
+ ...(opts.callbackUrl ? { callback_url: opts.callbackUrl } : {}),
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Get audio into the platform and return `{ jobId, downloadUrl }`. Prefers a
333
+ * multipart upload (when enabled) and falls back to a single presigned PUT if
334
+ * the server has multipart disabled or a multipart upload fails mid-flight.
335
+ */
336
+ private async ingestUpload(
337
+ data: Uint8Array,
338
+ fileSize: number,
339
+ opts: ReturnType<typeof resolveOptions>,
340
+ onProgress?: ProgressCallback,
341
+ ): Promise<{ jobId: string; downloadUrl: string }> {
342
+ if (this.multipart) {
343
+ try {
344
+ return await this.uploadMultipart(data, fileSize, opts, onProgress);
345
+ } catch (err) {
346
+ if (!(err instanceof MultipartUnavailable)) throw err;
347
+ // multipart unavailable — fall through to the single-shot path
348
+ }
349
+ }
350
+ const job = await this.createUploadJob(fileSize, opts);
351
+ await this.uploadAudio(job.uploadUrl, data, { jobId: job.jobId, onProgress });
352
+ await this.completeUpload(job.jobId);
353
+ return { jobId: job.jobId, downloadUrl: job.downloadUrl };
354
+ }
355
+
356
+ /**
357
+ * S3 multipart flow: create -> PUT each part -> complete. Throws
358
+ * {@link MultipartUnavailable} if the server has multipart disabled (404) or a
359
+ * mid-flight failure means we should retry via the single-shot path.
360
+ */
361
+ private async uploadMultipart(
362
+ data: Uint8Array,
363
+ fileSize: number,
364
+ opts: ReturnType<typeof resolveOptions>,
365
+ onProgress?: ProgressCallback,
366
+ ): Promise<{ jobId: string; downloadUrl: string }> {
367
+ let created: MultipartCreateResponse;
368
+ try {
369
+ created = await this.apiRequest<MultipartCreateResponse>(
370
+ "POST",
371
+ "/api/v1/upload/multipart/create",
372
+ this.uploadBody(fileSize, opts),
373
+ );
374
+ } catch (err) {
375
+ // The route returns 404 when multipart is disabled.
376
+ if (err instanceof JobNotFoundError) throw new MultipartUnavailable();
377
+ throw err;
378
+ }
379
+
380
+ const jobId = String(created.job_id);
381
+ const partSize = Number(created.part_size);
382
+ const byteCb = byteProgressAdapter(onProgress);
383
+ const completedParts: { part_number: number; etag: string }[] = [];
384
+ let uploaded = 0;
385
+ try {
386
+ for (const part of created.parts) {
387
+ const number = Number(part.part_number);
388
+ const start = (number - 1) * partSize;
389
+ const chunk = data.subarray(start, start + partSize);
390
+ const etag = await this.putPart(part.url, chunk);
391
+ completedParts.push({ part_number: number, etag });
392
+ uploaded += chunk.length;
393
+ byteCb?.(uploaded, fileSize);
394
+ }
395
+ await this.apiRequest("POST", "/api/v1/upload/multipart/complete", {
396
+ job_id: jobId,
397
+ parts: completedParts,
398
+ });
399
+ } catch (err) {
400
+ // Roll back the partial upload, then fall back to a single-shot PUT.
401
+ try {
402
+ await this.apiRequest("POST", "/api/v1/upload/multipart/abort", { job_id: jobId });
403
+ } catch {
404
+ /* best effort */
405
+ }
406
+ throw new MultipartUnavailable(String(err));
407
+ }
408
+
409
+ return { jobId, downloadUrl: String(created.download_url) };
410
+ }
411
+
412
+ /** PUT one part to its presigned URL and return the S3 ETag. */
413
+ private async putPart(url: string, chunk: Uint8Array): Promise<string> {
414
+ const resp = await this.fetchFn(url, { method: "PUT", body: toArrayBuffer(chunk) });
415
+ if (resp.status !== 200 && resp.status !== 204) {
416
+ throw new UploadError(`part upload failed (HTTP ${resp.status})`);
417
+ }
418
+ const etag = resp.headers.get("ETag") ?? resp.headers.get("etag");
419
+ if (!etag) throw new UploadError("part upload response missing ETag header");
420
+ return etag;
421
+ }
422
+
423
+ async createUploadJob(
424
+ fileSize: number,
425
+ options: TranscribeOptions = {},
426
+ ): Promise<UploadJob> {
427
+ const opts = resolveOptions(options);
428
+ const body = this.uploadBody(fileSize, opts);
429
+
430
+ const data = await this.apiRequest<{
431
+ job_id: string;
432
+ upload_url: string;
433
+ download_url: string;
434
+ content_type?: string;
435
+ expires_in?: number;
436
+ }>("POST", "/api/v1/upload", body);
437
+
438
+ return {
439
+ jobId: String(data.job_id),
440
+ uploadUrl: data.upload_url,
441
+ downloadUrl: data.download_url,
442
+ contentType: data.content_type ?? "application/octet-stream",
443
+ expiresIn: data.expires_in ?? 0,
444
+ };
445
+ }
446
+
447
+ async touchUploadProgress(jobId: string): Promise<void> {
448
+ await this.apiRequest("POST", "/api/v1/upload/progress", { job_id: jobId });
449
+ }
450
+
451
+ async uploadAudio(
452
+ uploadUrl: string,
453
+ data: Uint8Array,
454
+ opts: {
455
+ jobId?: string;
456
+ contentType?: string;
457
+ onProgress?: ProgressCallback;
458
+ } = {},
459
+ ): Promise<void> {
460
+ const contentType = opts.contentType ?? "application/octet-stream";
461
+ const byteCb = byteProgressAdapter(opts.onProgress);
462
+ const stop = { stopped: false };
463
+ let heartbeat: ReturnType<typeof setInterval> | undefined;
464
+
465
+ if (opts.jobId) {
466
+ heartbeat = setInterval(() => {
467
+ if (stop.stopped) return;
468
+ void this.touchUploadProgress(opts.jobId!).catch(() => undefined);
469
+ }, UPLOAD_PROGRESS_INTERVAL_MS);
470
+ }
471
+
472
+ let lastError: unknown;
473
+ try {
474
+ for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt++) {
475
+ try {
476
+ // A fresh streamed body per attempt so retries restart progress from 0.
477
+ await this.putUpload(uploadUrl, data, contentType, byteCb);
478
+ return;
479
+ } catch (err) {
480
+ lastError = err;
481
+ if (attempt < UPLOAD_MAX_ATTEMPTS) {
482
+ await sleep(UPLOAD_BASE_DELAY_MS * 2 ** (attempt - 1));
483
+ }
484
+ }
485
+ }
486
+ } finally {
487
+ stop.stopped = true;
488
+ if (heartbeat) clearInterval(heartbeat);
489
+ }
490
+
491
+ throw new UploadError(
492
+ `Upload failed after ${UPLOAD_MAX_ATTEMPTS} attempts: ${String(lastError)}`,
493
+ );
494
+ }
495
+
496
+ async completeUpload(jobId: string): Promise<void> {
497
+ await this.apiRequest("POST", "/api/v1/upload/complete", { job_id: jobId });
498
+ }
499
+
500
+ // progress / result
501
+
502
+ async waitForResult(
503
+ jobId: string,
504
+ downloadUrl: string,
505
+ onProgress?: ProgressCallback,
506
+ timeout = this.timeout,
507
+ ): Promise<{ content: Uint8Array; downloadUrl: string }> {
508
+ const sseUrl = await this.waitSSE(jobId, downloadUrl, onProgress, timeout);
509
+ if (sseUrl === null) {
510
+ const content = await this.waitPoll(jobId, downloadUrl, timeout);
511
+ return { content, downloadUrl };
512
+ }
513
+ const content = await this.downloadResult(sseUrl);
514
+ return { content, downloadUrl: sseUrl };
515
+ }
516
+
517
+ async downloadResult(downloadUrl: string): Promise<Uint8Array> {
518
+ const resp = await this.fetchFn(downloadUrl);
519
+ if (!resp.ok) {
520
+ throw new APIError(`Download failed (HTTP ${resp.status})`, {
521
+ statusCode: resp.status,
522
+ body: await resp.text().then((t) => t.slice(0, 300)),
523
+ });
524
+ }
525
+ return new Uint8Array(await resp.arrayBuffer());
526
+ }
527
+
528
+ // job management
529
+
530
+ async cancelJob(jobId: string): Promise<void> {
531
+ await this.apiRequest("POST", "/api/v1/jobs/cancel", { job_id: jobId });
532
+ }
533
+
534
+ async checkFailed(jobIds: string[]): Promise<boolean[]> {
535
+ const data = await this.apiRequest<{ failed_jobs: boolean[] }>(
536
+ "POST",
537
+ "/api/v1/jobs/check-failed",
538
+ { job_ids: jobIds.map(String) },
539
+ );
540
+ return data.failed_jobs ?? [];
541
+ }
542
+
543
+ // retrieval (get by id / list)
544
+
545
+ /** Fetch a job's current status (and a fresh download URL once complete). */
546
+ async getJobStatus(jobId: string): Promise<JobStatus> {
547
+ const data = await this.apiRequest<{
548
+ job_id?: string;
549
+ status?: string;
550
+ download_url?: string;
551
+ failed_stage?: string;
552
+ reason?: string;
553
+ }>("GET", `/api/v1/jobs/${encodeURIComponent(jobId)}`);
554
+ return {
555
+ jobId: String(data.job_id ?? jobId),
556
+ status: String(data.status ?? ""),
557
+ downloadUrl: data.download_url,
558
+ failedStage: data.failed_stage,
559
+ reason: data.reason,
560
+ };
561
+ }
562
+
563
+ /**
564
+ * Fetch and parse a completed job's transcript by id. Throws
565
+ * {@link JobFailedError} if it failed, or {@link APIError} if still processing.
566
+ */
567
+ async getTranscript(
568
+ jobId: string,
569
+ opts: { outputType?: OutputType } = {},
570
+ ): Promise<Transcript> {
571
+ const status = await this.getJobStatus(jobId);
572
+ if (status.status === "failed") {
573
+ throw new JobFailedError(`Job ${jobId} failed`, {
574
+ step: status.failedStage,
575
+ reason: status.reason,
576
+ });
577
+ }
578
+ if (status.status !== "completed" || !status.downloadUrl) {
579
+ throw new APIError(`Job ${jobId} is not complete (status=${status.status})`);
580
+ }
581
+ const content = await this.downloadResult(status.downloadUrl);
582
+ return parseTranscript({
583
+ jobId,
584
+ content,
585
+ outputType: opts.outputType ?? "json",
586
+ downloadUrl: status.downloadUrl,
587
+ });
588
+ }
589
+
590
+ /** List the caller's most-recent jobs (newest first), cursor-paginated. */
591
+ async listJobs(
592
+ opts: { limit?: number; before?: string } = {},
593
+ ): Promise<{ jobs: { jobId: string; createdAt: string }[]; nextBefore: string | null }> {
594
+ const params = new URLSearchParams();
595
+ params.set("limit", String(opts.limit ?? 50));
596
+ if (opts.before) params.set("before", opts.before);
597
+ const data = await this.apiRequest<{
598
+ jobs?: { job_id: string; created_at: string }[];
599
+ next_before?: string | null;
600
+ }>("GET", `/api/v1/jobs?${params.toString()}`);
601
+ return {
602
+ jobs: (data.jobs ?? []).map((j) => ({ jobId: j.job_id, createdAt: j.created_at })),
603
+ nextBefore: data.next_before ?? null,
604
+ };
605
+ }
606
+
607
+ // internals
608
+
609
+ private headers(extra?: Record<string, string>): Record<string, string> {
610
+ return {
611
+ "X-API-Key": this.apiKey,
612
+ "Content-Type": "application/json",
613
+ "User-Agent": USER_AGENT,
614
+ ...extra,
615
+ };
616
+ }
617
+
618
+ private async apiRequest<T = unknown>(
619
+ method: string,
620
+ path: string,
621
+ body?: unknown,
622
+ ): Promise<T> {
623
+ const url = `${this.baseUrl}${path}`;
624
+ // Job-creating calls retry only when the request provably never landed;
625
+ // anything else would risk a duplicate job and a duplicate charge.
626
+ const creating = createsJob(path);
627
+ let attempt = 0;
628
+ while (true) {
629
+ attempt += 1;
630
+ let resp: Response;
631
+ try {
632
+ resp = await this.fetchFn(url, {
633
+ ...this.requestInit,
634
+ method,
635
+ headers: this.headers(),
636
+ body: body === undefined ? undefined : JSON.stringify(body),
637
+ });
638
+ } catch (err) {
639
+ if (isAbort(err)) throw err;
640
+ // Retry transient network failures with exponential backoff.
641
+ const safe = !creating || neverReachedServer(err);
642
+ if (safe && attempt <= this.maxRetries) {
643
+ await sleep(this.retryDelayMs(attempt));
644
+ continue;
645
+ }
646
+ throw new APIError(`Cannot connect to ${this.baseUrl}: ${String(err)}`);
647
+ }
648
+
649
+ // Retry throttling / transient server errors, honoring Retry-After.
650
+ // For a create, only 429 is safe: the server refused it outright, so no
651
+ // job exists. A 5xx may well have created one before failing.
652
+ if (
653
+ RETRY_STATUS_CODES.has(resp.status) &&
654
+ attempt <= this.maxRetries &&
655
+ (!creating || resp.status === 429)
656
+ ) {
657
+ const retryAfterMs = parseRetryAfterMs(resp.headers.get("Retry-After"));
658
+ await sleep(this.retryDelayMs(attempt, retryAfterMs));
659
+ continue;
660
+ }
661
+
662
+ await this.raiseForStatus(resp);
663
+ if (!resp.body || resp.status === 204) return {} as T;
664
+ const text = await resp.text();
665
+ if (!text) return {} as T;
666
+ try {
667
+ return JSON.parse(text) as T;
668
+ } catch {
669
+ return { raw: text } as T;
670
+ }
671
+ }
672
+ }
673
+
674
+ private async raiseForStatus(resp: Response): Promise<void> {
675
+ if (resp.status === 200 || resp.status === 204) return;
676
+ const body = await resp.text().then((t) => t.slice(0, 300)).catch(() => undefined);
677
+ const requestId = extractRequestId(resp.headers);
678
+ if (resp.status === 401) throw new AuthenticationError(undefined, { statusCode: 401, requestId, body });
679
+ if (resp.status === 404) throw new JobNotFoundError(undefined, { statusCode: 404, requestId, body });
680
+ if (resp.status === 429) {
681
+ throw new RateLimitError(undefined, {
682
+ statusCode: 429,
683
+ requestId,
684
+ body,
685
+ retryAfter: (parseRetryAfterMs(resp.headers.get("Retry-After")) ?? 0) / 1000 || undefined,
686
+ });
687
+ }
688
+ throw new APIError(`Unexpected response (HTTP ${resp.status})`, {
689
+ statusCode: resp.status,
690
+ requestId,
691
+ body,
692
+ });
693
+ }
694
+
695
+ private async putUpload(
696
+ uploadUrl: string,
697
+ data: Uint8Array,
698
+ contentType: string,
699
+ byteCb?: ByteProgressFn,
700
+ ): Promise<void> {
701
+ let resp: Response;
702
+ if (byteCb) {
703
+ // Presigned PUT with progress: stream the body in chunks and report after
704
+ // each. An explicit Content-Length keeps undici from switching to
705
+ // Transfer-Encoding: chunked (which S3 rejects); `duplex: "half"` is
706
+ // required by Node when the body is a stream / async iterable.
707
+ const init = {
708
+ method: "PUT",
709
+ headers: {
710
+ "Content-Type": contentType,
711
+ "Content-Length": String(data.byteLength),
712
+ },
713
+ body: iterWithProgress(data, byteCb),
714
+ duplex: "half",
715
+ };
716
+ resp = await this.fetchFn(uploadUrl, init as unknown as RequestInit);
717
+ } else {
718
+ resp = await this.fetchFn(uploadUrl, {
719
+ method: "PUT",
720
+ headers: { "Content-Type": contentType },
721
+ body: toArrayBuffer(data),
722
+ });
723
+ }
724
+
725
+ if (resp.status !== 200 && resp.status !== 204) {
726
+ const text = await resp.text().then((t) => t.slice(0, 200));
727
+ throw new UploadError(`HTTP ${resp.status}: ${text}`);
728
+ }
729
+ }
730
+
731
+ private async waitSSE(
732
+ jobId: string,
733
+ fallbackDownloadUrl: string,
734
+ onProgress: ProgressCallback | undefined,
735
+ timeout: number,
736
+ ): Promise<string | null> {
737
+ const start = Date.now();
738
+ let lastEventId: string | undefined;
739
+ let reconnects = 0;
740
+
741
+ while (true) {
742
+ const elapsed = (Date.now() - start) / 1000;
743
+ if (elapsed >= timeout) {
744
+ throw new TimeoutError(`Timed out after ${timeout}s waiting for job ${jobId}`);
745
+ }
746
+ if (reconnects > SSE_MAX_RECONNECTS) return null;
747
+ if (reconnects > 0) await sleep(SSE_RECONNECT_DELAY_MS);
748
+
749
+ const result = await this.sseAttempt(
750
+ jobId,
751
+ start,
752
+ timeout,
753
+ lastEventId,
754
+ fallbackDownloadUrl,
755
+ onProgress,
756
+ );
757
+ lastEventId = result.lastEventId;
758
+
759
+ if (result.outcome === "done") return result.downloadUrl ?? fallbackDownloadUrl;
760
+ if (result.outcome === "failed") throw new JobFailedError("Job failed");
761
+ if (result.outcome === "timeout") {
762
+ throw new TimeoutError(`Timed out after ${timeout}s waiting for job ${jobId}`);
763
+ }
764
+ reconnects += 1;
765
+ }
766
+ }
767
+
768
+ private async sseAttempt(
769
+ jobId: string,
770
+ start: number,
771
+ timeout: number,
772
+ lastEventId: string | undefined,
773
+ fallbackDownloadUrl: string,
774
+ onProgress?: ProgressCallback,
775
+ ): Promise<{
776
+ outcome: "done" | "failed" | "reconnect" | "timeout";
777
+ downloadUrl?: string;
778
+ lastEventId?: string;
779
+ }> {
780
+ const elapsed = (Date.now() - start) / 1000;
781
+ if (elapsed >= timeout) return { outcome: "timeout", lastEventId };
782
+
783
+ const headers: Record<string, string> = {
784
+ "X-API-Key": this.apiKey,
785
+ "User-Agent": USER_AGENT,
786
+ Accept: "text/event-stream",
787
+ };
788
+ if (lastEventId !== undefined) headers["Last-Event-ID"] = lastEventId;
789
+
790
+ let resp: Response;
791
+ try {
792
+ const controller = new AbortController();
793
+ const remainingMs = Math.max(1000, (timeout - elapsed) * 1000);
794
+ const timer = setTimeout(() => controller.abort(), remainingMs);
795
+ try {
796
+ resp = await this.fetchFn(`${this.baseUrl}/api/v1/jobs/${jobId}/stream`, {
797
+ method: "GET",
798
+ headers,
799
+ signal: controller.signal,
800
+ });
801
+ } finally {
802
+ clearTimeout(timer);
803
+ }
804
+ } catch {
805
+ return { outcome: "reconnect", lastEventId };
806
+ }
807
+
808
+ if (resp.status === 401) throw new AuthenticationError();
809
+ if (resp.status === 429) throw new RateLimitError();
810
+ if (resp.status !== 200) return { outcome: "reconnect", lastEventId };
811
+
812
+ if (!resp.body) return { outcome: "reconnect", lastEventId };
813
+
814
+ const reader = resp.body.getReader();
815
+ try {
816
+ for await (const sse of parseSSEStream(reader)) {
817
+ if (sse.id) lastEventId = sse.id;
818
+ const nowElapsed = (Date.now() - start) / 1000;
819
+ if (nowElapsed >= timeout) return { outcome: "timeout", lastEventId };
820
+
821
+ const eventType = sse.event ?? "message";
822
+ let data: Record<string, unknown> = {};
823
+ try {
824
+ data = JSON.parse(sse.data ?? "{}") as Record<string, unknown>;
825
+ } catch {
826
+ data = { raw: sse.data };
827
+ }
828
+
829
+ if (eventType === "progress") {
830
+ // Fire the callback inline so progress is live, not replayed in a
831
+ // burst after the stream closes.
832
+ onProgress?.(
833
+ makeProgressEvent({
834
+ completed: asInt(data.completed),
835
+ total: asInt(data.total),
836
+ step: typeof data.step === "string" ? data.step : undefined,
837
+ elapsedSeconds: nowElapsed,
838
+ raw: data,
839
+ }),
840
+ );
841
+ } else if (eventType === "completed") {
842
+ const dl =
843
+ typeof data.download_url === "string"
844
+ ? data.download_url
845
+ : fallbackDownloadUrl;
846
+ return { outcome: "done", downloadUrl: dl, lastEventId };
847
+ } else if (eventType === "failed") {
848
+ const step = String(data.step ?? "unknown");
849
+ const reason = String(data.reason ?? "unknown");
850
+ throw new JobFailedError(`Job failed at step=${step}: ${reason}`, {
851
+ step,
852
+ reason,
853
+ });
854
+ }
855
+ }
856
+ return { outcome: "reconnect", lastEventId };
857
+ } catch (err) {
858
+ if (err instanceof JobFailedError || err instanceof AuthenticationError) throw err;
859
+ return { outcome: "reconnect", lastEventId };
860
+ } finally {
861
+ reader.releaseLock();
862
+ }
863
+ }
864
+
865
+ private async waitPoll(
866
+ jobId: string,
867
+ downloadUrl: string,
868
+ timeout: number,
869
+ ): Promise<Uint8Array> {
870
+ const start = Date.now();
871
+ const maxAttempts = Math.max(1, Math.floor(timeout / (POLL_INTERVAL_MS / 1000)));
872
+
873
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
874
+ if ((Date.now() - start) / 1000 >= timeout) break;
875
+
876
+ try {
877
+ const failed = await this.checkFailed([jobId]);
878
+ if (failed[0]) throw new JobFailedError(`Job ${jobId} has failed`);
879
+ } catch (err) {
880
+ if (err instanceof AuthenticationError || err instanceof JobFailedError) throw err;
881
+ }
882
+
883
+ try {
884
+ const resp = await this.fetchFn(downloadUrl);
885
+ if (resp.ok) return new Uint8Array(await resp.arrayBuffer());
886
+ } catch {
887
+ // ignore probe errors
888
+ }
889
+
890
+ if (attempt < maxAttempts) await sleep(POLL_INTERVAL_MS);
891
+ }
892
+
893
+ throw new TimeoutError(`Job ${jobId} did not complete within ${timeout}s`);
894
+ }
895
+ }
896
+
897
+ // helpers
898
+
899
+ function sleep(ms: number): Promise<void> {
900
+ return new Promise((resolve) => setTimeout(resolve, ms));
901
+ }
902
+
903
+ /** True for an aborted request, which must propagate rather than be retried. */
904
+ function isAbort(err: unknown): boolean {
905
+ return (
906
+ err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError")
907
+ );
908
+ }
909
+
910
+ function toArrayBuffer(data: Uint8Array): ArrayBuffer {
911
+ const copy = new Uint8Array(data.byteLength);
912
+ copy.set(data);
913
+ return copy.buffer;
914
+ }
915
+
916
+ function asInt(value: unknown): number | undefined {
917
+ if (value === undefined || value === null) return undefined;
918
+ const n = Number(value);
919
+ return Number.isFinite(n) ? n : undefined;
920
+ }
921
+
922
+ function isUrl(value: string): boolean {
923
+ return /^https?:\/\//i.test(value);
924
+ }
925
+
926
+ async function readAudio(
927
+ audio: Uint8Array | ArrayBuffer | Blob | string,
928
+ ): Promise<{ data: Uint8Array; fileSize: number }> {
929
+ let data: Uint8Array;
930
+
931
+ if (typeof audio === "string") {
932
+ const { readFile } = await import("node:fs/promises");
933
+ data = new Uint8Array(await readFile(audio));
934
+ } else if (audio instanceof ArrayBuffer) {
935
+ data = new Uint8Array(audio);
936
+ } else if (audio instanceof Uint8Array) {
937
+ data = audio;
938
+ } else if (typeof Blob !== "undefined" && audio instanceof Blob) {
939
+ data = new Uint8Array(await audio.arrayBuffer());
940
+ } else {
941
+ throw new TypeError(`Unsupported audio type: ${typeof audio}`);
942
+ }
943
+
944
+ if (data.byteLength === 0) throw new Error("Audio is empty");
945
+ return { data, fileSize: data.byteLength };
946
+ }
947
+
948
+ /** @deprecated Use STTClient — alias for Deepgram / ElevenLabs naming. */
949
+ export class SpeechRevolutions extends STTClient {}
950
+ export { SpeechRevolutions as SpeechRevolutionsClient };