@happyvertical/speech 0.96.1 → 0.97.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/dist/index.js CHANGED
@@ -24,22 +24,416 @@ var InvalidSpeechAdapterError = class extends SpeechError {
24
24
  var SpeechProviderError = class extends SpeechError {
25
25
  status;
26
26
  responseBody;
27
+ /** Parsed `Retry-After` delay in milliseconds, when the provider sent one. */
28
+ retryAfterMs;
27
29
  constructor(adapter, message, options = {}) {
28
30
  super(message, "SPEECH_PROVIDER_ERROR", adapter);
29
31
  this.name = "SpeechProviderError";
30
32
  this.status = options.status;
31
33
  this.responseBody = options.responseBody;
34
+ this.retryAfterMs = options.retryAfterMs;
32
35
  if (options.cause !== void 0) this.cause = options.cause;
33
36
  }
34
37
  };
35
38
  //#endregion
39
+ //#region src/shared/audio.ts
40
+ /**
41
+ * Audio input normalisation shared by speech adapters: accepts the input
42
+ * shapes callers actually have (Blob, Buffer, Uint8Array, ArrayBuffer,
43
+ * ReadableStream), enforces byte limits while buffering, and derives a
44
+ * multipart filename whose extension matches the MIME type.
45
+ */
46
+ /** OpenAI caps `/audio/transcriptions` uploads at 25 MB. */
47
+ var DEFAULT_MAX_AUDIO_BYTES = 25 * 1024 * 1024;
48
+ var MIME_EXTENSIONS = {
49
+ "audio/webm": "webm",
50
+ "video/webm": "webm",
51
+ "audio/mp4": "m4a",
52
+ "audio/m4a": "m4a",
53
+ "audio/x-m4a": "m4a",
54
+ "video/mp4": "mp4",
55
+ "audio/wav": "wav",
56
+ "audio/wave": "wav",
57
+ "audio/x-wav": "wav",
58
+ "audio/vnd.wave": "wav",
59
+ "audio/ogg": "ogg",
60
+ "application/ogg": "ogg",
61
+ "audio/mpeg": "mp3",
62
+ "audio/mp3": "mp3",
63
+ "audio/mpga": "mp3",
64
+ "audio/flac": "flac",
65
+ "audio/x-flac": "flac"
66
+ };
67
+ /**
68
+ * Strips parameters (e.g. `;codecs=opus`) and lower-cases a MIME type.
69
+ * Returns `undefined` for blank input.
70
+ */
71
+ function normalizeMimeType(mimeType) {
72
+ const essence = mimeType?.split(";")[0]?.trim().toLowerCase();
73
+ return essence ? essence : void 0;
74
+ }
75
+ /**
76
+ * Maps an audio MIME type to the file extension OpenAI-compatible servers use
77
+ * to detect the container format. Codec parameters are ignored, so
78
+ * `audio/webm;codecs=opus` maps to `webm`. Returns `undefined` when unknown.
79
+ */
80
+ function mimeTypeToAudioExtension(mimeType) {
81
+ const essence = normalizeMimeType(mimeType);
82
+ return essence ? MIME_EXTENSIONS[essence] : void 0;
83
+ }
84
+ /**
85
+ * Accepts either an {@link AudioInput} wrapper or a bare {@link AudioSource}
86
+ * and returns a size-checked Blob plus the metadata needed for multipart
87
+ * uploads.
88
+ */
89
+ async function normalizeAudioInput(input, options = {}) {
90
+ const wrapped = isAudioInput(input) ? input : { data: input };
91
+ const { adapter, signal } = options;
92
+ signal?.throwIfAborted();
93
+ const maxBytes = resolveMaxBytes(options.maxBytes, adapter);
94
+ const data = wrapped.data;
95
+ const blobType = typeof Blob !== "undefined" && data instanceof Blob ? data.type : void 0;
96
+ const mimeType = options.mimeType ?? wrapped.mimeType ?? wrapped.contentType ?? (blobType || void 0) ?? "application/octet-stream";
97
+ let blob;
98
+ if (typeof Blob !== "undefined" && data instanceof Blob) {
99
+ assertWithinLimit(data.size, maxBytes, adapter);
100
+ blob = data.type === mimeType ? data : new Blob([data], { type: mimeType });
101
+ } else if (data instanceof Uint8Array) {
102
+ assertWithinLimit(data.byteLength, maxBytes, adapter);
103
+ blob = new Blob([copyBytes(data)], { type: mimeType });
104
+ } else if (data instanceof ArrayBuffer) {
105
+ assertWithinLimit(data.byteLength, maxBytes, adapter);
106
+ blob = new Blob([data], { type: mimeType });
107
+ } else if (isReadableStream(data)) {
108
+ const chunks = await readStreamWithLimit(data, maxBytes, adapter, signal);
109
+ blob = new Blob(chunks, { type: mimeType });
110
+ } else throw new SpeechConfigurationError("Unsupported audio input: expected Blob, Buffer, Uint8Array, ArrayBuffer, or ReadableStream", adapter);
111
+ if (blob.size === 0) throw new SpeechConfigurationError("Audio input is empty", adapter);
112
+ const extension = options.deriveExtension === false ? void 0 : mimeTypeToAudioExtension(mimeType);
113
+ const basename = options.defaultBasename ?? "audio";
114
+ const filename = wrapped.filename ?? (extension ? `${basename}.${extension}` : basename);
115
+ return {
116
+ blob,
117
+ mimeType,
118
+ bytes: blob.size,
119
+ filename,
120
+ durationSeconds: wrapped.durationSeconds
121
+ };
122
+ }
123
+ function isAudioInput(value) {
124
+ return typeof value === "object" && value !== null && "data" in value && !(typeof Blob !== "undefined" && value instanceof Blob) && !(value instanceof Uint8Array) && !(value instanceof ArrayBuffer) && !isReadableStream(value);
125
+ }
126
+ function isReadableStream(value) {
127
+ return typeof value === "object" && value !== null && typeof value.getReader === "function";
128
+ }
129
+ function resolveMaxBytes(maxBytes, adapter) {
130
+ if (maxBytes === void 0) return Number.POSITIVE_INFINITY;
131
+ if (Number.isNaN(maxBytes) || maxBytes <= 0) throw new SpeechConfigurationError("maxBytes must be a positive number", adapter);
132
+ return maxBytes;
133
+ }
134
+ function assertWithinLimit(bytes, maxBytes, adapter) {
135
+ if (bytes > maxBytes) throw new SpeechConfigurationError(`Audio input exceeds maxBytes (${bytes} > ${maxBytes} bytes)`, adapter);
136
+ }
137
+ async function readStreamWithLimit(stream, maxBytes, adapter, signal) {
138
+ const reader = stream.getReader();
139
+ const chunks = [];
140
+ let total = 0;
141
+ const onAbort = () => {
142
+ reader.cancel(signal?.reason).catch(() => void 0);
143
+ };
144
+ signal?.addEventListener("abort", onAbort, { once: true });
145
+ try {
146
+ for (;;) {
147
+ const { done, value } = await reader.read();
148
+ signal?.throwIfAborted();
149
+ if (done) break;
150
+ total += chunkByteLength(value, adapter);
151
+ if (total > maxBytes) {
152
+ await reader.cancel().catch(() => void 0);
153
+ throw new SpeechConfigurationError(`Audio stream exceeds maxBytes (more than ${maxBytes} bytes)`, adapter);
154
+ }
155
+ chunks.push(toBytes(value));
156
+ }
157
+ } finally {
158
+ signal?.removeEventListener("abort", onAbort);
159
+ reader.releaseLock();
160
+ }
161
+ return chunks;
162
+ }
163
+ function chunkByteLength(value, adapter) {
164
+ if (value instanceof Uint8Array || value instanceof ArrayBuffer) return value.byteLength;
165
+ throw new SpeechConfigurationError("Audio stream chunks must be Uint8Array or ArrayBuffer", adapter);
166
+ }
167
+ function toBytes(value) {
168
+ return value instanceof Uint8Array ? copyBytes(value) : new Uint8Array(value.slice(0));
169
+ }
170
+ function copyBytes(bytes) {
171
+ const copy = new Uint8Array(bytes.byteLength);
172
+ copy.set(bytes);
173
+ return copy;
174
+ }
175
+ //#endregion
176
+ //#region src/shared/env.ts
177
+ /**
178
+ * Environment configuration for speech adapters.
179
+ *
180
+ * Transcriber settings resolve in this order (first wins):
181
+ * explicit options → factory context → `HAVE_SPEECH_TRANSCRIBER_*` →
182
+ * legacy `HAVE_SPEECH_STT_*` → gateway `STT_*` aliases.
183
+ */
184
+ /** Environment variable names read for each transcriber setting, in priority order. */
185
+ var TRANSCRIBER_ENV_KEYS = {
186
+ type: [
187
+ "HAVE_SPEECH_TRANSCRIBER_TYPE",
188
+ "HAVE_SPEECH_STT_TYPE",
189
+ "HAVE_SPEECH_STT_ADAPTER",
190
+ "STT_ADAPTER"
191
+ ],
192
+ baseUrl: [
193
+ "HAVE_SPEECH_TRANSCRIBER_BASE_URL",
194
+ "HAVE_SPEECH_STT_BASE_URL",
195
+ "STT_BASE_URL"
196
+ ],
197
+ model: [
198
+ "HAVE_SPEECH_TRANSCRIBER_MODEL",
199
+ "HAVE_SPEECH_STT_MODEL",
200
+ "STT_MODEL"
201
+ ],
202
+ apiKey: [
203
+ "HAVE_SPEECH_TRANSCRIBER_API_KEY",
204
+ "HAVE_SPEECH_STT_API_KEY",
205
+ "STT_API_KEY",
206
+ "SPEECH_API_KEY"
207
+ ],
208
+ timeoutMs: [
209
+ "HAVE_SPEECH_TRANSCRIBER_TIMEOUT",
210
+ "HAVE_SPEECH_TRANSCRIBER_TIMEOUT_MS",
211
+ "HAVE_SPEECH_STT_TIMEOUT_MS",
212
+ "STT_TIMEOUT_MS"
213
+ ],
214
+ maxBytes: ["HAVE_SPEECH_TRANSCRIBER_MAX_BYTES", "STT_MAX_BYTES"],
215
+ headers: ["HAVE_SPEECH_TRANSCRIBER_HEADERS", "STT_HEADERS"],
216
+ path: [
217
+ "HAVE_SPEECH_TRANSCRIBER_PATH",
218
+ "HAVE_SPEECH_STT_PATH",
219
+ "STT_PATH"
220
+ ]
221
+ };
222
+ function readTranscriberEnv(env) {
223
+ return {
224
+ type: readEnv(env, ...TRANSCRIBER_ENV_KEYS.type),
225
+ baseUrl: readEnv(env, ...TRANSCRIBER_ENV_KEYS.baseUrl),
226
+ model: readEnv(env, ...TRANSCRIBER_ENV_KEYS.model),
227
+ apiKey: readEnv(env, ...TRANSCRIBER_ENV_KEYS.apiKey),
228
+ timeoutMs: parseOptionalInteger(readEnv(env, ...TRANSCRIBER_ENV_KEYS.timeoutMs)),
229
+ maxBytes: parseOptionalInteger(readEnv(env, ...TRANSCRIBER_ENV_KEYS.maxBytes)),
230
+ headers: parseHeadersEnv(env, TRANSCRIBER_ENV_KEYS.headers),
231
+ path: readEnv(env, ...TRANSCRIBER_ENV_KEYS.path)
232
+ };
233
+ }
234
+ function hasTranscriberEnv(env) {
235
+ return hasAnyEnv(env, ...TRANSCRIBER_ENV_KEYS.type, ...TRANSCRIBER_ENV_KEYS.baseUrl);
236
+ }
237
+ /**
238
+ * Merges explicit transcriber options with the factory context and the
239
+ * environment. Explicit options always win; headers are merged so a
240
+ * per-deployment env header (e.g. a gateway virtual key) can be combined with
241
+ * explicit ones.
242
+ */
243
+ function resolveTranscriberConfig(options = {}, context = {}) {
244
+ const fromEnv = readTranscriberEnv(context.env ?? defaultEnv());
245
+ const headers = mergeHeaderInits(fromEnv.headers, context.headers, options.headers);
246
+ return {
247
+ type: options.type ?? fromEnv.type,
248
+ baseUrl: (options.baseUrl ?? fromEnv.baseUrl)?.trim(),
249
+ model: options.model ?? fromEnv.model,
250
+ apiKey: options.apiKey ?? fromEnv.apiKey,
251
+ timeoutMs: options.timeoutMs ?? fromEnv.timeoutMs,
252
+ maxBytes: options.maxBytes ?? fromEnv.maxBytes,
253
+ headers,
254
+ fetch: options.fetch ?? context.fetch,
255
+ path: fromEnv.path
256
+ };
257
+ }
258
+ /** Merges header sets left to right; later sets win per header name. */
259
+ function mergeHeaderInits(...sets) {
260
+ const present = sets.filter((set) => Boolean(set));
261
+ if (present.length === 0) return;
262
+ const merged = new Headers();
263
+ for (const set of present) new Headers(set).forEach((value, key) => {
264
+ merged.set(key, value);
265
+ });
266
+ return merged;
267
+ }
268
+ function readEnv(env, ...keys) {
269
+ for (const key of keys) {
270
+ const value = env[key];
271
+ if (value?.trim()) return value.trim();
272
+ }
273
+ }
274
+ function hasAnyEnv(env, ...keys) {
275
+ return keys.some((key) => Boolean(env[key]?.trim()));
276
+ }
277
+ function parseOptionalInteger(value) {
278
+ if (!value) return;
279
+ const parsed = Number.parseInt(value, 10);
280
+ return Number.isFinite(parsed) ? parsed : void 0;
281
+ }
282
+ function defaultEnv() {
283
+ return globalThis.process?.env ?? {};
284
+ }
285
+ /**
286
+ * Parses a JSON object of header names to string values. The raw value is
287
+ * never echoed in errors because it may carry credentials.
288
+ */
289
+ function parseHeadersEnv(env, keys) {
290
+ for (const key of keys) {
291
+ const raw = env[key]?.trim();
292
+ if (!raw) continue;
293
+ let parsed;
294
+ try {
295
+ parsed = JSON.parse(raw);
296
+ } catch {
297
+ throw new SpeechConfigurationError(`${key} must be a JSON object of header names to string values`);
298
+ }
299
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Object.values(parsed).every((value) => typeof value === "string")) throw new SpeechConfigurationError(`${key} must be a JSON object of header names to string values`);
300
+ return parsed;
301
+ }
302
+ }
303
+ //#endregion
304
+ //#region src/shared/retry.ts
305
+ /**
306
+ * Retry with exponential backoff for speech provider calls. Retries only
307
+ * rate-limit (429) and server (5xx) responses, honours `Retry-After`, and
308
+ * stops immediately when the caller aborts.
309
+ */
310
+ var DEFAULT_SPEECH_RETRY = {
311
+ maxRetries: 2,
312
+ initialDelayMs: 500,
313
+ maxDelayMs: 3e4
314
+ };
315
+ function resolveRetryOptions(retry, defaults = DEFAULT_SPEECH_RETRY) {
316
+ if (retry === false) return false;
317
+ if (retry === void 0) return defaults;
318
+ const base = defaults || DEFAULT_SPEECH_RETRY;
319
+ return {
320
+ maxRetries: Math.floor(finiteRetryValue("maxRetries", retry.maxRetries, base.maxRetries)),
321
+ initialDelayMs: finiteRetryValue("initialDelayMs", retry.initialDelayMs, base.initialDelayMs),
322
+ maxDelayMs: finiteRetryValue("maxDelayMs", retry.maxDelayMs, base.maxDelayMs)
323
+ };
324
+ }
325
+ /**
326
+ * Rejects non-finite values (`NaN`, `Infinity`): they would make the retry
327
+ * budget or delay cap unbounded. Negative values clamp to `0`.
328
+ */
329
+ function finiteRetryValue(name, value, fallback) {
330
+ if (value === void 0) return Math.max(0, fallback);
331
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new SpeechConfigurationError(`Speech retry option ${name} must be a finite number`);
332
+ return Math.max(0, value);
333
+ }
334
+ function isRetryableStatus(status) {
335
+ return status === 429 || status !== void 0 && status >= 500;
336
+ }
337
+ /**
338
+ * Parses a `Retry-After` header (delta-seconds or HTTP-date) into
339
+ * milliseconds. Returns `undefined` when absent or unparseable.
340
+ */
341
+ function parseRetryAfter(value, now = Date.now()) {
342
+ if (!value?.trim()) return;
343
+ const trimmed = value.trim();
344
+ if (/^\d+(\.\d+)?$/.test(trimmed)) return Math.round(Number(trimmed) * 1e3);
345
+ const date = Date.parse(trimmed);
346
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - now);
347
+ }
348
+ /**
349
+ * Runs `attempt` until it succeeds, throws a non-retryable error, or the
350
+ * retry budget is exhausted. Retryable errors are {@link SpeechProviderError}
351
+ * instances whose `status` is 429 or 5xx.
352
+ */
353
+ async function withSpeechRetry(attempt, retry, signal) {
354
+ const maxRetries = retry ? retry.maxRetries : 0;
355
+ for (let attemptNumber = 0;; attemptNumber += 1) try {
356
+ return await attempt(attemptNumber);
357
+ } catch (error) {
358
+ if (!retry || attemptNumber >= maxRetries || signal?.aborted || !(error instanceof SpeechProviderError) || !isRetryableStatus(error.status)) throw error;
359
+ const backoff = Math.min(retry.maxDelayMs, retry.initialDelayMs * 2 ** attemptNumber);
360
+ const retryAfterMs = error.retryAfterMs;
361
+ if (retryAfterMs !== void 0 && retryAfterMs > retry.maxDelayMs) throw error;
362
+ await sleep(Math.max(backoff, retryAfterMs ?? 0), signal);
363
+ }
364
+ }
365
+ function sleep(ms, signal) {
366
+ return new Promise((resolve, reject) => {
367
+ if (signal?.aborted) {
368
+ reject(signal.reason);
369
+ return;
370
+ }
371
+ const onAbort = () => {
372
+ clearTimeout(timer);
373
+ reject(signal?.reason);
374
+ };
375
+ const timer = setTimeout(() => {
376
+ signal?.removeEventListener("abort", onAbort);
377
+ resolve();
378
+ }, ms);
379
+ signal?.addEventListener("abort", onAbort, { once: true });
380
+ });
381
+ }
382
+ //#endregion
36
383
  //#region src/shared/http.ts
384
+ /**
385
+ * Parses a base URL, dropping any fragment and keeping any query string.
386
+ * Throws {@link SpeechConfigurationError} for a blank or unparseable value.
387
+ * The value is not echoed in the error because it may carry credentials.
388
+ */
389
+ function parseBaseUrl(baseUrl) {
390
+ const trimmed = baseUrl.trim();
391
+ if (!trimmed) throw new SpeechConfigurationError("Speech adapter baseUrl is required");
392
+ let url;
393
+ try {
394
+ url = new URL(trimmed);
395
+ } catch {
396
+ throw new SpeechConfigurationError("Speech adapter baseUrl is not a valid absolute URL");
397
+ }
398
+ url.hash = "";
399
+ return url;
400
+ }
401
+ /**
402
+ * Returns `baseUrl` with a trailing `/` on its path so relative paths resolve
403
+ * beneath it. The query string is preserved and any fragment is dropped.
404
+ */
37
405
  function normalizeBaseUrl(baseUrl) {
38
- if (!baseUrl.trim()) throw new SpeechConfigurationError("Speech adapter baseUrl is required");
39
- return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
406
+ const url = parseBaseUrl(baseUrl);
407
+ if (!url.pathname.endsWith("/")) url.pathname = `${url.pathname}/`;
408
+ return url.toString();
40
409
  }
410
+ /**
411
+ * Resolves `path` against `baseUrl`. A relative path without its own query
412
+ * inherits the base URL's query (for example a gateway `api-version`); an
413
+ * absolute URL is returned unchanged.
414
+ */
41
415
  function resolveSpeechUrl(baseUrl, path) {
42
- return new URL(path.replace(/^\//, ""), normalizeBaseUrl(baseUrl)).toString();
416
+ const base = new URL(normalizeBaseUrl(baseUrl));
417
+ const isAbsolute = /^[a-z][a-z\d+.-]*:/i.test(path);
418
+ const resolved = new URL(isAbsolute ? path : path.replace(/^\//, ""), base);
419
+ if (!isAbsolute && !resolved.search && base.search) resolved.search = base.search;
420
+ return resolved.toString();
421
+ }
422
+ /**
423
+ * Resolves an OpenAI-compatible endpoint from a base URL. Accepts a server
424
+ * root (`https://api.openai.com` → `/v1/<resource>`), an API root ending in a
425
+ * version segment (`http://gateway/stt/v1` → `/<resource>`), or the full
426
+ * endpoint URL (used as-is). Only the path is rewritten: the query string is
427
+ * preserved and any fragment is dropped.
428
+ */
429
+ function resolveOpenAICompatibleUrl(baseUrl, resource) {
430
+ const trimmedResource = resource.replace(/^\/+|\/+$/g, "");
431
+ const url = parseBaseUrl(baseUrl);
432
+ const pathname = url.pathname.replace(/\/+$/, "");
433
+ if (pathname.endsWith(`/${trimmedResource}`)) url.pathname = pathname;
434
+ else if (/\/v\d+$/.test(pathname)) url.pathname = `${pathname}/${trimmedResource}`;
435
+ else url.pathname = `${pathname}/v1/${trimmedResource}`;
436
+ return url.toString();
43
437
  }
44
438
  function resolveFetch(fetchOverride) {
45
439
  if (fetchOverride) return fetchOverride;
@@ -68,11 +462,21 @@ var HttpSpeechAdapter = class {
68
462
  this.headers = options.headers;
69
463
  this.timeoutMs = options.timeoutMs;
70
464
  }
71
- async post(adapterName, path, init, readResponse) {
465
+ /**
466
+ * POSTs to `path` (relative to `baseUrl`, or an absolute URL). The optional
467
+ * `retry` policy retries 429/5xx responses; `timeoutMs` applies per attempt.
468
+ * The request body must be re-sendable (string, Blob, FormData) when
469
+ * retries are enabled.
470
+ */
471
+ async post(adapterName, path, init, readResponse, retry = false) {
472
+ return withSpeechRetry(() => this.postOnce(adapterName, path, init, readResponse), retry, init.signal ?? void 0);
473
+ }
474
+ async postOnce(adapterName, path, init, readResponse) {
72
475
  const timeoutController = this.timeoutMs && this.timeoutMs > 0 ? new AbortController() : void 0;
73
476
  const timeout = timeoutController && this.timeoutMs ? setTimeout(() => timeoutController.abort(), this.timeoutMs) : void 0;
74
477
  const requestSignal = composeAbortSignals(init.signal, timeoutController?.signal);
75
478
  try {
479
+ requestSignal.signal?.throwIfAborted();
76
480
  const response = await this.fetchImpl(resolveSpeechUrl(this.baseUrl, path), {
77
481
  ...init,
78
482
  method: "POST",
@@ -82,7 +486,7 @@ var HttpSpeechAdapter = class {
82
486
  }, init.headers),
83
487
  signal: requestSignal.signal
84
488
  });
85
- await assertOk(response, adapterName);
489
+ await assertOk(response, adapterName, this.apiKey);
86
490
  return await readResponse(response);
87
491
  } finally {
88
492
  if (timeout) clearTimeout(timeout);
@@ -122,26 +526,19 @@ function composeAbortSignals(...signals) {
122
526
  cleanup
123
527
  };
124
528
  }
125
- async function assertOk(response, adapterName) {
529
+ async function assertOk(response, adapterName, apiKey) {
126
530
  if (response.ok) return;
127
531
  const responseBody = await response.text().catch(() => void 0);
128
532
  throw new SpeechProviderError(adapterName, `${adapterName} speech request failed with HTTP ${response.status}`, {
129
533
  status: response.status,
130
- responseBody
534
+ responseBody: redactSecret(responseBody, apiKey),
535
+ retryAfterMs: parseRetryAfter(response.headers.get("retry-after"))
131
536
  });
132
537
  }
133
- function appendAudioInput(form, audio, fieldName = "audio") {
134
- const filename = audio.filename ?? "audio";
135
- const data = audio.data;
136
- if (typeof Blob !== "undefined" && data instanceof Blob) {
137
- const contentType = audio.contentType ?? (data.type || "application/octet-stream");
138
- const blob = data.type === contentType ? data : new Blob([data], { type: contentType });
139
- form.append(fieldName, blob, filename);
140
- return;
141
- }
142
- const contentType = audio.contentType ?? "application/octet-stream";
143
- const blobPart = data instanceof Uint8Array ? arrayBufferFromBytes(data) : data;
144
- form.append(fieldName, new Blob([blobPart], { type: contentType }), filename);
538
+ /** Removes an API key from provider-supplied text before it is surfaced. */
539
+ function redactSecret(text, secret) {
540
+ if (!text || !secret) return text;
541
+ return text.split(secret).join("[REDACTED]");
145
542
  }
146
543
  function appendOptionalFormValue(form, name, value) {
147
544
  if (value === void 0 || value === null) return;
@@ -314,6 +711,137 @@ var OpenAICompatibleSpeechSynthesizer = class extends HttpSpeechAdapter {
314
711
  }
315
712
  };
316
713
  //#endregion
714
+ //#region src/shared/usage.ts
715
+ /**
716
+ * Invokes every configured usage callback in order (adapter-level first, then
717
+ * request-level). Callback errors propagate to the caller so that billing
718
+ * hooks never fail silently.
719
+ */
720
+ async function reportSpeechUsage(usage, ...callbacks) {
721
+ for (const callback of callbacks) if (callback) await callback(usage);
722
+ }
723
+ /**
724
+ * Extracts audio seconds from an OpenAI-style `usage` block
725
+ * (`{ type: 'duration', seconds }`).
726
+ */
727
+ function audioSecondsFromProviderUsage(providerUsage) {
728
+ if (!providerUsage) return;
729
+ const seconds = providerUsage.seconds;
730
+ return typeof seconds === "number" && Number.isFinite(seconds) ? seconds : void 0;
731
+ }
732
+ //#endregion
733
+ //#region src/adapters/openai-compatible-transcriber.ts
734
+ var DEFAULT_MODEL = "whisper-1";
735
+ /**
736
+ * Models that only accept `json`/`text` and reject `verbose_json` and
737
+ * `timestamp_granularities[]` (e.g. `gpt-4o-transcribe`,
738
+ * `gpt-4o-mini-transcribe`, optionally gateway-prefixed like
739
+ * `openai/gpt-4o-transcribe`).
740
+ */
741
+ var JSON_ONLY_MODEL = /(^|\/)gpt-4o[\w.-]*-transcribe/i;
742
+ /** Provider 400/422 bodies that indicate an unsupported format option. */
743
+ var UNSUPPORTED_FORMAT_ERROR = /verbose_json|response_format|timestamp_granularit/i;
744
+ function isJsonOnlyTranscriptionModel(model) {
745
+ return JSON_ONLY_MODEL.test(model);
746
+ }
747
+ /**
748
+ * OpenAI-compatible speech-to-text adapter (`POST <base>/audio/transcriptions`).
749
+ * Works with OpenAI, Groq, Fireworks, LiteLLM/Bifrost gateways, vLLM,
750
+ * speaches/faster-whisper-server, and whisper.cpp server.
751
+ *
752
+ * Server-side only: the adapter holds the API key.
753
+ */
754
+ var OpenAICompatibleTranscriber = class extends HttpSpeechAdapter {
755
+ type = "openai-compatible";
756
+ endpoint;
757
+ model;
758
+ responseFormat;
759
+ maxBytes;
760
+ retry;
761
+ onUsage;
762
+ constructor(options) {
763
+ super(options);
764
+ this.endpoint = resolveOpenAICompatibleUrl(options.baseUrl, "audio/transcriptions");
765
+ this.model = options.model?.trim() || DEFAULT_MODEL;
766
+ this.responseFormat = options.responseFormat;
767
+ this.maxBytes = options.maxBytes ?? 26214400;
768
+ this.retry = resolveRetryOptions(options.retry);
769
+ this.onUsage = options.onUsage;
770
+ }
771
+ async transcribe(request) {
772
+ const audio = await normalizeAudioInput(request.audio, {
773
+ mimeType: request.mimeType,
774
+ maxBytes: request.maxBytes ?? this.maxBytes,
775
+ adapter: this.type,
776
+ signal: request.signal
777
+ });
778
+ const wire = this.resolveWireOptions(request);
779
+ const send = (options) => {
780
+ const form = new FormData();
781
+ form.append("file", audio.blob, audio.filename);
782
+ form.set("model", options.model);
783
+ appendOptionalFormValue(form, "language", request.language);
784
+ appendOptionalFormValue(form, "prompt", request.prompt);
785
+ appendOptionalFormValue(form, "temperature", request.temperature);
786
+ form.set("response_format", options.responseFormat);
787
+ for (const granularity of options.timestampGranularities ?? []) form.append("timestamp_granularities[]", granularity);
788
+ return this.post(this.type, this.endpoint, {
789
+ body: form,
790
+ headers: request.headers,
791
+ signal: request.signal
792
+ }, (response) => readTranscriptResponse(response, this.type), this.retry);
793
+ };
794
+ let transcript;
795
+ try {
796
+ transcript = await send(wire);
797
+ } catch (error) {
798
+ if (!isUnsupportedFormatError(error, wire)) throw error;
799
+ transcript = await send({
800
+ model: wire.model,
801
+ responseFormat: "json"
802
+ });
803
+ }
804
+ const providerUsage = extractProviderUsage(transcript.raw);
805
+ const usage = {
806
+ operation: "transcription",
807
+ provider: this.type,
808
+ model: transcript.model ?? wire.model,
809
+ audioSeconds: transcript.durationSeconds ?? audioSecondsFromProviderUsage(providerUsage) ?? audio.durationSeconds,
810
+ bytes: audio.bytes,
811
+ providerUsage
812
+ };
813
+ const result = {
814
+ ...transcript,
815
+ text: transcript.raw === void 0 ? transcript.text.trim() : transcript.text,
816
+ provider: this.type,
817
+ model: transcript.model ?? wire.model,
818
+ usage
819
+ };
820
+ await reportSpeechUsage(usage, this.onUsage, request.onUsage);
821
+ return result;
822
+ }
823
+ resolveWireOptions(request) {
824
+ const model = request.model?.trim() || this.model;
825
+ const jsonOnly = isJsonOnlyTranscriptionModel(model);
826
+ let responseFormat = request.responseFormat ?? this.responseFormat ?? (jsonOnly ? "json" : "verbose_json");
827
+ if (jsonOnly && responseFormat === "verbose_json") responseFormat = "json";
828
+ const timestampGranularities = responseFormat === "verbose_json" && request.timestampGranularities?.length ? [...new Set(request.timestampGranularities)] : void 0;
829
+ return {
830
+ model,
831
+ responseFormat,
832
+ timestampGranularities
833
+ };
834
+ }
835
+ };
836
+ function isUnsupportedFormatError(error, wire) {
837
+ return error instanceof SpeechProviderError && (error.status === 400 || error.status === 422) && (wire.responseFormat === "verbose_json" || Boolean(wire.timestampGranularities?.length)) && UNSUPPORTED_FORMAT_ERROR.test(error.responseBody ?? "");
838
+ }
839
+ function extractProviderUsage(raw) {
840
+ if (!raw || typeof raw !== "object") return;
841
+ const usage = raw.usage;
842
+ return usage && typeof usage === "object" && !Array.isArray(usage) ? usage : void 0;
843
+ }
844
+ //#endregion
317
845
  //#region src/adapters/qwen3.ts
318
846
  var Qwen3SpeechSynthesizer = class extends HttpSpeechAdapter {
319
847
  type = "qwen3-tts";
@@ -349,11 +877,19 @@ var StudioServerTranscriber = class extends HttpSpeechAdapter {
349
877
  this.transcribePath = options.transcribePath ?? "/v1/transcribe";
350
878
  }
351
879
  async transcribe(request) {
880
+ const audio = await normalizeAudioInput(request.audio, {
881
+ mimeType: request.mimeType,
882
+ maxBytes: request.maxBytes,
883
+ adapter: this.type,
884
+ signal: request.signal,
885
+ deriveExtension: false
886
+ });
352
887
  const form = new FormData();
353
- appendAudioInput(form, request.audio);
888
+ form.append("audio", audio.blob, audio.filename);
354
889
  appendOptionalFormValue(form, "language", request.language);
355
890
  return this.post(this.type, this.transcribePath, {
356
891
  body: form,
892
+ headers: request.headers,
357
893
  signal: request.signal
358
894
  }, (response) => readTranscriptResponse(response, this.type));
359
895
  }
@@ -404,6 +940,7 @@ async function getTranscriber(options = {}, context = {}) {
404
940
  const resolved = normalizeTranscriberOptions(options, context);
405
941
  switch (resolved.type) {
406
942
  case "studio-server": return new StudioServerTranscriber(resolved);
943
+ case "openai-compatible": return new OpenAICompatibleTranscriber(resolved);
407
944
  default: throw new InvalidSpeechAdapterError(resolved.type ?? "unknown", "STT");
408
945
  }
409
946
  }
@@ -418,7 +955,7 @@ async function getSpeechSynthesizer(options, context = {}) {
418
955
  }
419
956
  function getAvailableSpeechAdapters() {
420
957
  return {
421
- transcribers: ["studio-server"],
958
+ transcribers: ["studio-server", "openai-compatible"],
422
959
  synthesizers: [
423
960
  "studio-server",
424
961
  "qwen3-tts",
@@ -435,20 +972,30 @@ async function getOptionalSpeechSynthesizer(options, context) {
435
972
  return getSpeechSynthesizer(options, context);
436
973
  }
437
974
  function normalizeTranscriberOptions(options = {}, context) {
438
- const env = context.env ?? defaultEnv();
439
- const type = options.type ?? readEnv(env, "HAVE_SPEECH_STT_TYPE", "HAVE_SPEECH_STT_ADAPTER", "STT_ADAPTER") ?? "studio-server";
440
- const baseUrl = options.baseUrl ?? readEnv(env, "HAVE_SPEECH_STT_BASE_URL", "STT_BASE_URL");
441
- if (type !== "studio-server") throw new InvalidSpeechAdapterError(type, "STT");
442
- if (!baseUrl?.trim()) throw new SpeechConfigurationError("STT baseUrl is required", type);
975
+ const resolved = resolveTranscriberConfig(options, context);
976
+ const type = resolved.type ?? "studio-server";
977
+ if (type !== "studio-server" && type !== "openai-compatible") throw new InvalidSpeechAdapterError(type, "STT");
978
+ if (!resolved.baseUrl) throw new SpeechConfigurationError("STT baseUrl is required", type);
979
+ const shared = {
980
+ baseUrl: resolved.baseUrl,
981
+ fetch: resolved.fetch,
982
+ headers: resolved.headers,
983
+ apiKey: resolved.apiKey,
984
+ timeoutMs: resolved.timeoutMs
985
+ };
986
+ if (type === "openai-compatible") return {
987
+ ...shared,
988
+ type,
989
+ model: resolved.model,
990
+ responseFormat: options.responseFormat,
991
+ maxBytes: resolved.maxBytes,
992
+ retry: options.retry,
993
+ onUsage: options.onUsage
994
+ };
443
995
  return {
444
- ...options,
996
+ ...shared,
445
997
  type,
446
- baseUrl: baseUrl.trim(),
447
- fetch: options.fetch ?? context.fetch,
448
- headers: options.headers ?? context.headers,
449
- apiKey: options.apiKey ?? readEnv(env, "HAVE_SPEECH_STT_API_KEY", "STT_API_KEY", "SPEECH_API_KEY"),
450
- timeoutMs: options.timeoutMs ?? parseOptionalInteger(readEnv(env, "HAVE_SPEECH_STT_TIMEOUT_MS", "STT_TIMEOUT_MS")),
451
- transcribePath: options.transcribePath ?? readEnv(env, "HAVE_SPEECH_STT_PATH", "STT_PATH")
998
+ transcribePath: options.transcribePath ?? resolved.path
452
999
  };
453
1000
  }
454
1001
  function normalizeSpeechSynthesizerOptions(options, context) {
@@ -488,30 +1035,10 @@ function normalizeSpeechSynthesizerOptions(options, context) {
488
1035
  };
489
1036
  throw new InvalidSpeechAdapterError(type, "TTS");
490
1037
  }
491
- function hasTranscriberEnv(env) {
492
- return hasAnyEnv(env, "HAVE_SPEECH_STT_TYPE", "HAVE_SPEECH_STT_ADAPTER", "HAVE_SPEECH_STT_BASE_URL", "STT_ADAPTER", "STT_BASE_URL");
493
- }
494
1038
  function hasSynthesizerEnv(env) {
495
1039
  return hasAnyEnv(env, "HAVE_SPEECH_TTS_TYPE", "HAVE_SPEECH_TTS_ADAPTER", "HAVE_SPEECH_TTS_BASE_URL", "TTS_ADAPTER", "TTS_BASE_URL");
496
1040
  }
497
- function readEnv(env, ...keys) {
498
- for (const key of keys) {
499
- const value = env[key];
500
- if (value?.trim()) return value.trim();
501
- }
502
- }
503
- function hasAnyEnv(env, ...keys) {
504
- return keys.some((key) => Boolean(env[key]?.trim()));
505
- }
506
- function parseOptionalInteger(value) {
507
- if (!value) return;
508
- const parsed = Number.parseInt(value, 10);
509
- return Number.isFinite(parsed) ? parsed : void 0;
510
- }
511
- function defaultEnv() {
512
- return globalThis.process?.env ?? {};
513
- }
514
1041
  //#endregion
515
- export { InvalidSpeechAdapterError, SpeechConfigurationError, SpeechError, SpeechProviderError, getAvailableSpeechAdapters, getSpeech, getSpeechSynthesizer, getTranscriber };
1042
+ export { DEFAULT_MAX_AUDIO_BYTES, InvalidSpeechAdapterError, SpeechConfigurationError, SpeechError, SpeechProviderError, TRANSCRIBER_ENV_KEYS, getAvailableSpeechAdapters, getSpeech, getSpeechSynthesizer, getTranscriber, mimeTypeToAudioExtension };
516
1043
 
517
1044
  //# sourceMappingURL=index.js.map