diff --git a/CHANGELOG.md b/CHANGELOG.md index dec95f1..1d9d0fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [4.41.0] + +- `client.sync.transcribe()` now sends audio over the same live connection as `transcribeLive()` / `openLive()`: `POST /v1/transcribe/live` (also served at `/v1/transcribe/stream`), chunked multipart, `config` first (always present, `{}` when empty) then `audio`. A clip already held is sent as a single chunk. There is no separate buffered request any more — nothing posts to `/v1/transcribe` +- `transcribe()`'s third argument (`SyncTranscribeOptions`) is now `{ timeout, signal }`, matching the live methods: `timeout` (default 180 000 ms, was 60 000) is a total deadline from the start of the request spanning the upload and the transcription, and `signal` (an `AbortSignal`) drops the request when aborted. `warm()` is unchanged +- Raise sync config caps to match the service: `prompt` to 6000 characters (was 4096), `keyterms_prompt` to 100 terms and 8000 characters total (was 2048 characters, uncapped terms), and `conversation_context` to 500 turns / 16 000 characters (was 100 / 4096) — still trimmed oldest-first rather than rejected. Over-cap `prompt`/`keyterms_prompt` are still rejected before any request is sent +- Raise dictation config caps to match the service: `stt_prompt` to 6000 characters (was 4096) and `keyterms_prompt` to 100 terms and 8000 characters total (was 2048, uncapped terms); `llm_instruction` is unchanged at 2048 +- A failure body of the form `{"error": "...", "error_code": "..."}` now surfaces that `error` string as the thrown error's message, for both `SyncTranscriptError` and `DictationError` +- Multipart file names are now escaped per the HTML5 form-encoding rules, so a `"`, backslash or control character in a file name can no longer break out of the part header + ## [4.40.0] - Add `client.dictation` — dictation transcription for short spoken notes: `transcribeLive(audio, config?, options?)` uploads audio as it is spoken and resolves with one finished transcript, `openLive(config?, options?)` is the push-style counterpart for callback-driven sources, and `warm()` opens the connection ahead of the request. Targets `dictation.assemblyai.com`, overridable with the new `dictationBaseUrl` client option. Audio is WAV or raw 16-bit PCM, up to 120 s diff --git a/CLAUDE.md b/CLAUDE.md index cf59d61..aa9c659 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,8 +185,11 @@ const vtt = await client.transcripts.subtitles(id, "vtt"); ## Sync transcription (pre-recorded, single request) -`client.sync` posts a whole audio file and returns the finished transcript in one -round trip — no job id, no polling, no status enum. It targets the sync API host +`client.sync` sends audio over one live connection and returns the finished +transcript in one round trip — no job id, no polling, no status enum. +`transcribe()` sends a clip you already hold as a single chunk over that +connection; `transcribeLive()` / `openLive()` upload it as it is still being +recorded. It targets the sync API host (`sync.assemblyai.com`, override with the `syncBaseUrl` client option), distinct from `client.transcripts`' async job API. Use it for short clips where you want the answer inline; use `client.transcripts` for long-form audio, URLs, or the rich @@ -209,11 +212,11 @@ a Blob/File, or a readable stream. **Not** a URL — pass a path/bytes or use ```typescript const result = await client.sync.transcribe("./call.wav", { - prompt: "Transcribe verbatim. Preserve disfluencies.", // max 4096 chars, rejected over - keyterms_prompt: ["AssemblyAI", "Lemur", "U3-Pro"], // max 2048 chars total, rejected over + prompt: "Transcribe verbatim. Preserve disfluencies.", // max 6000 chars, rejected over + keyterms_prompt: ["AssemblyAI", "Lemur", "U3-Pro"], // max 100 terms, 8000 chars total, rejected over language_codes: ["es"], // or e.g. ["en", "es"] for multilingual; defaults to English; ignored when prompt is set conversation_context: [ - // prior turns oldest-first; capped at 100 turns / 4096 chars — trimmed, not rejected + // prior turns oldest-first; capped at 500 turns / 16 000 chars — trimmed, not rejected "I'd like to book a flight to Denver.", "Sure, what date were you thinking?", ], @@ -257,10 +260,13 @@ it is still being recorded); it returns `true` once the socket is open, `false` a transport failure. Call it shortly before `transcribe()` — the pooled connection idles out after a few seconds. -**Live upload**: `transcribeLive()` and `openLive()` post to `/v1/transcribe/stream` with a -chunked multipart body, so the request starts before the audio exists and chunks upload as they -arrive. Authorization, the upload and every speech segment but the last resolve while the caller -is still recording; only the final segment is left to wait for. `transcribeLive()` pulls from an +**Live upload**: `transcribe()`, `transcribeLive()` and `openLive()` all send audio over one live +connection — `POST /v1/transcribe/live` (also served at `/v1/transcribe/stream`) — with a +chunked multipart body, `config` sent first (always present, `{}` when +empty) then `audio`. `transcribe()` sends a clip already held whole as a single chunk. For +`transcribeLive()`/`openLive()` the request starts before the audio exists and chunks upload as +they arrive, so authorization, the upload and every speech segment but the last resolve while the +caller is still recording; only the final segment is left to wait for. `transcribeLive()` pulls from an async iterable (Node streams included), a sync iterable, or a web `ReadableStream` — bytes, a Blob or a path are rejected with a TypeError pointing at `transcribe()`. `openLive()` is the push-style counterpart for callback-driven sources (microphone library, WebRTC track, telephony @@ -291,9 +297,11 @@ long enough is aborted server-side); auth, rate-limit and capacity failures can through the upload rather than only at the end (a malformed key at once, a key that fails deeper checks a few seconds in), and `warm()` opens the connection but does not validate the key. -**Client-side timeout**: third argument — `client.sync.transcribe(audio, {}, { timeout: 30_000 })` -(default 60 s, kept above the server's 30 s deadline). Live requests take `SyncLiveOptions` -instead — `{ timeout, signal }`. +**Client-side timeout**: third argument, `SyncTranscribeOptions` — `{ timeout, signal }`, e.g. +`client.sync.transcribe(audio, {}, { timeout: 30_000 })`. `timeout` (default 180 000 ms) is a +total deadline from the start of the request, spanning the upload and the transcription — the +same basis `transcribeLive()`/`openLive()` use via `SyncLiveOptions`. `signal` drops the request +when aborted. ## Dictation (short spoken notes, optional LLM rewrite) @@ -345,8 +353,8 @@ const result = await client.dictation.transcribeLive(mic, { sample_rate: 16_000, // raw 16-bit PCM; setting either PCM field requires both; unset for WAV channels: 1, // 1 mono / 2 stereo language_codes: ["es"], // ISO 639-1; or e.g. ["en", "es"]; unset leaves the language to the server - stt_prompt: "A doctor dictating a patient visit note.", // max 4096 chars; describes the recording - keyterms_prompt: ["AssemblyAI", "U3-Pro"], // whitespace stripped, empties dropped, max 2048 chars total + stt_prompt: "A doctor dictating a patient visit note.", // max 6000 chars; describes the recording + keyterms_prompt: ["AssemblyAI", "U3-Pro"], // whitespace stripped, empties dropped, max 100 terms, 8000 chars total llm_instruction: "Format this as a SOAP note.", // max 2048 chars; follow-up LLM pass over the transcript }); ``` @@ -418,7 +426,7 @@ strings, when present). - **speech_models takes an array** with fallback ordering: ["universal-3-5-pro", "universal-2"] - **Streaming uses universal-3-5-pro** as the speech model - **Never expose API keys client-side** — use temporary auth tokens for browser streaming -- **Live upload timeout is a total deadline** — `transcribeLive()`/`openLive()` default to 180 s covering the whole recording plus transcription (not per-response like `transcribe()`'s 60 s), and the sync API still caps audio at 120 s +- **Sync timeout is a total deadline** — `transcribe()`, `transcribeLive()` and `openLive()` all default to a 180 s deadline spanning the whole request (upload plus transcription), and the sync API still caps audio at 120 s - **Dictation timeout is a total 300 s deadline** — `client.dictation` spans the upload, the transcription and the LLM pass in one deadline; the service caps audio at 120 s and accepts WAV or raw 16-bit PCM only - **Node >= 18 required** - **Only runtime dependency**: ws (WebSocket library) diff --git a/README.md b/README.md index 3381e67..7a5634b 100644 --- a/README.md +++ b/README.md @@ -313,11 +313,13 @@ const res = await client.transcripts.delete(transcript.id); ### Transcribe audio synchronously -`client.sync` posts a whole audio file and returns the finished transcript in one -round trip — no job id, no polling — or uploads the audio live, as it is still -being recorded (`transcribeLive()` / `openLive()`). Use it for short clips where -you want the answer inline; use `client.transcripts` for long-form audio, URLs, -or the rich audio-intelligence features the sync API doesn't expose. +`client.sync` sends audio over one live connection and returns the finished +transcript in one round trip — no job id, no polling. `transcribe()` sends a +clip you already hold as a single chunk over that connection; +`transcribeLive()` / `openLive()` upload it as it is still being recorded. Use +it for short clips where you want the answer inline; use `client.transcripts` +for long-form audio, URLs, or the rich audio-intelligence features the sync API +doesn't expose. ```typescript const result = await client.sync.transcribe("./call.wav"); @@ -332,8 +334,8 @@ stream — but not a URL. ```typescript const result = await client.sync.transcribe("./call.wav", { - prompt: "Transcribe verbatim. Preserve disfluencies.", // max 4096 chars - keyterms_prompt: ["AssemblyAI", "Lemur"], // max 2048 chars total + prompt: "Transcribe verbatim. Preserve disfluencies.", // max 6000 chars + keyterms_prompt: ["AssemblyAI", "Lemur"], // max 100 terms, 8000 chars total language_codes: ["es"], // or e.g. ["en", "es"] for multilingual; defaults to English conversation_context: [ // prior turns, oldest first @@ -490,9 +492,9 @@ console.log(result.text); ``` `options.timeout` (default 180 000 ms) is a **total** deadline spanning the -whole upload, unlike `transcribe()`'s 60 s, so it has to exceed the length of -the recording as well as the transcription. `options.signal` drops the request -from outside, the way `abort()` does for a session. +whole upload, so it has to exceed the length of the recording as well as the +transcription. `options.signal` drops the request from outside, the way +`abort()` does for a session. Caveats, for both live entry points: @@ -682,8 +684,8 @@ const result = await client.dictation.transcribeLive(micStream, { sample_rate: 16_000, // raw 16-bit PCM only; required together with channels channels: 1, // 1 mono, 2 stereo; leave both unset for WAV language_codes: ["es"], // ISO 639-1; or e.g. ["en", "es"]; defaults to the server's choice - stt_prompt: "A doctor dictating a patient visit note.", // max 4096 chars - keyterms_prompt: ["AssemblyAI", "Universal-3"], // max 2048 chars total + stt_prompt: "A doctor dictating a patient visit note.", // max 6000 chars + keyterms_prompt: ["AssemblyAI", "Universal-3"], // max 100 terms, 8000 chars total llm_instruction: "Format this as a SOAP note.", // max 2048 chars }); ``` diff --git a/package.json b/package.json index 603a30e..d6a6a7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "assemblyai", - "version": "4.40.0", + "version": "4.41.0", "description": "The AssemblyAI JavaScript SDK provides an easy-to-use interface for interacting with the AssemblyAI API, which supports async and real-time transcription.", "engines": { "node": ">=18" diff --git a/src/services/dictation/service.ts b/src/services/dictation/service.ts index 0720f0e..1f224e3 100644 --- a/src/services/dictation/service.ts +++ b/src/services/dictation/service.ts @@ -37,8 +37,11 @@ const warmEndpoint = "/v1/warm"; // total request budget; the audio itself is capped at 120 s. const defaultLiveTimeoutMs = 300_000; const warmTimeoutMs = 10_000; -const maxSttPromptLength = 4096; -const maxKeytermsPromptLength = 2048; +// Caps mirror the dictation service's `config` part; a field over its cap is +// rejected before any request is sent. +const maxSttPromptLength = 6000; +const maxKeytermsPromptLength = 8000; +const maxKeytermsCount = 100; const maxLlmInstructionLength = 2048; // What to call the config in the message when raw PCM is missing a field. const configName = "the config"; @@ -411,6 +414,11 @@ function normalizeKeytermsPrompt( const terms = keytermsPrompt .map((term) => term.trim()) .filter((term) => term.length > 0); + if (terms.length > maxKeytermsCount) { + throw new Error( + `keyterms_prompt exceeds ${maxKeytermsCount} terms (got ${terms.length})`, + ); + } const total = terms.reduce((sum, term) => sum + term.length, 0); if (total > maxKeytermsPromptLength) { throw new Error( diff --git a/src/services/sync/service.ts b/src/services/sync/service.ts index 94a3198..0bb1d98 100644 --- a/src/services/sync/service.ts +++ b/src/services/sync/service.ts @@ -31,22 +31,24 @@ import { } from "../../utils/live"; import { SyncLiveSession, checkLiveInput } from "./live"; -// Canonical paths since the sync API gained a /v1 prefix (#18103); the -// unprefixed routes remain served for SDK versions that predate it. -const transcribeEndpoint = "/v1/transcribe"; -const transcribeLiveEndpoint = "/v1/transcribe/stream"; +// The one endpoint the client posts audio to. The service also serves it at +// /v1/transcribe/stream. +const transcribeLiveEndpoint = "/v1/transcribe/live"; const warmEndpoint = "/v1/warm"; const modelHeader = "X-AAI-Model"; -// Kept above the server's 30 s deadline so the client doesn't race it. -const defaultTimeoutMs = 60_000; -// A fetch deadline spans the whole request, upload included, so the live -// default must clear the 120 s audio cap plus the final segment. +// A fetch deadline spans the whole request, upload included, so this must +// clear the 120 s audio cap plus the final segment. It is the default for +// every transcription request. const defaultLiveTimeoutMs = 180_000; const warmTimeoutMs = 10_000; -const maxPromptLength = 4096; -const maxKeytermsPromptLength = 2048; -const maxContextTurns = 100; -const maxContextLength = 4096; +// Caps mirror the sync service's `config` part. `prompt` and +// `keyterms_prompt` over their caps are rejected; `conversation_context` over +// its caps is trimmed, oldest turns first. +const maxPromptLength = 6000; +const maxKeytermsPromptLength = 8000; +const maxKeytermsCount = 100; +const maxContextTurns = 500; +const maxContextLength = 16000; // The sync route decodes a WAV container or raw S16LE PCM, so no extension // maps to a container content type of its own. const contentTypes: Record = {}; @@ -54,14 +56,16 @@ const contentTypes: Record = {}; const configName = "the config"; /** - * The synchronous transcription service: audio in, transcript out, - * one request. + * The synchronous transcription service: audio in, transcript out, one + * connection. * * Unlike `client.transcripts` (which submits a job to the async API and - * polls for completion), `SyncTranscriber` posts the audio to the sync - * API and returns the finished transcript in the HTTP response. There is no - * job id or status to poll. Accepts a local file path, raw audio bytes, a - * Blob, or a readable stream — but not a URL. + * polls for completion), `SyncTranscriber` posts audio over a live upload to + * the sync API and returns the finished transcript in the HTTP response. + * There is no job id or status to poll. `transcribe()` accepts a local file + * path, raw audio bytes, a Blob, or a readable stream — but not a URL — and + * sends it as a single chunk over the same connection `transcribeLive()` and + * `openLive()` use for audio still being produced. */ export class SyncTranscriber extends BaseService { /** @@ -74,11 +78,19 @@ export class SyncTranscriber extends BaseService { /** * Transcribe audio and return the finished transcript in one request. + * + * The audio travels as a single chunk over the same live upload + * `transcribeLive()` uses — this is the ergonomic shape for audio you + * already hold whole, rather than audio still being produced. * @param audio - A local file path, raw audio bytes, a Blob, or a readable * stream. Raw PCM also requires `sample_rate` and `channels` on the config. * @param config - Options for this transcription request. - * @param options - Client-side options, such as the request timeout. + * @param options - Client-side options: the request deadline and an + * optional abort signal. * @returns A promise that resolves to the finished transcript. + * @throws Error when `audio` is a URL, when raw PCM is missing + * `sample_rate` or `channels`, or when `prompt` or `keyterms_prompt` + * exceeds its cap. * @throws SyncTranscriptError when the request fails. */ async transcribe( @@ -87,47 +99,32 @@ export class SyncTranscriber extends BaseService { options: SyncTranscribeOptions = {}, ): Promise { const { bytes, filename, contentType } = await resolveAudio(audio, config); - - const body = new FormData(); - body.append( - "audio", - new Blob([bytes as BlobPart], { type: contentType }), - filename, + const signal = deadlineSignal( + options.timeout ?? defaultLiveTimeoutMs, + options.signal, ); - const configJson = buildConfigJson(config); - if (configJson) { - body.append( - "config", - new Blob([JSON.stringify(configJson)], { type: "application/json" }), - ); - } - - const response = await this.fetchResponse(transcribeEndpoint, { - method: "POST", - body, - headers: { [modelHeader]: config.model ?? defaultSyncSpeechModel }, - signal: AbortSignal.timeout(options.timeout ?? defaultTimeoutMs), + return await this.postLive(singleChunk(bytes), audio, config, signal, { + filename, + contentType, }); - if (response.status !== 200) { - throw await syncError(response); - } - return (await response.json()) as SyncTranscriptResponse; } /** * Transcribe audio uploaded as it is produced. * - * Where `transcribe()` needs the whole clip before it can send anything, - * this starts the request immediately and uploads chunks as they arrive, - * so authorization, the upload and every speech segment but the last - * resolve while the caller is still recording. What is left to wait for - * once they stop is the final segment. + * For audio that is still being produced — a live microphone, an + * in-progress call — this starts the request immediately and uploads + * chunks as they arrive, so authorization, the upload and every speech + * segment but the last resolve while the caller is still recording. What + * is left to wait for once they stop is the final segment. Audio already + * held whole travels the same connection as a single chunk in + * `transcribe()`, the ergonomic shape for that case. * - * That only pays off when the audio is genuinely still being produced: a - * live microphone, an in-progress call. Streaming a file already on disk - * is slower than `transcribe()`, which uploads it in one piece. The saving - * also needs enough audio to have segments to release early; below roughly - * a minute only the elided upload counts. The same 120 s audio cap applies. + * The saving only pays off when the audio is genuinely still being + * produced; audio you already hold whole belongs in `transcribe()`. It + * also needs enough audio to have segments to release early; below + * roughly a minute only the elided upload counts. The same 120 s audio + * cap applies. * * The caller must keep producing: an upload that goes silent for long * enough is aborted server-side. Stop by ending the stream, not by pausing @@ -197,15 +194,18 @@ export class SyncTranscriber extends BaseService { /** * Post a live upload whose audio arrives from `chunks`. `source` is the - * caller's original input, consulted only for a file name. + * caller's original input, consulted only for a file name when `format` is + * not already resolved. */ private async postLive( chunks: AsyncIterable, source: unknown, config: SyncTranscriptionConfig, signal: AbortSignal, + format?: { filename: string; contentType: string }, ): Promise { - const { filename, contentType } = resolveLiveFormat(source, config); + const { filename, contentType } = + format ?? resolveLiveFormat(source, config); const boundary = multipartBoundary(); const body = liveBody({ head: multipartHead({ @@ -284,6 +284,11 @@ type ResolvedAudio = { contentType: string; }; +/** Presents audio that is already complete as the upload's one chunk. */ +async function* singleChunk(bytes: Uint8Array): AsyncGenerator { + yield bytes; +} + /** * Read the audio input into bytes and decide its multipart content type. * @@ -371,7 +376,8 @@ function resolveLiveFormat( * Serialize the config to the JSON `config` part, validating and normalizing * field values to match the server's caps. The routing `model` is never * included — it travels in the `X-AAI-Model` header. Returns `undefined` - * when there is nothing to send, so the part can be omitted entirely. + * when there are no options; the multipart head then sends an empty `{}` + * part. */ function buildConfigJson( config: SyncTranscriptionConfig, @@ -405,6 +411,11 @@ function normalizeKeytermsPrompt( const terms = keytermsPrompt .map((term) => term.trim()) .filter((term) => term.length > 0); + if (terms.length > maxKeytermsCount) { + throw new Error( + `keyterms_prompt exceeds ${maxKeytermsCount} terms (got ${terms.length})`, + ); + } const total = terms.reduce((sum, term) => sum + term.length, 0); if (total > maxKeytermsPromptLength) { throw new Error( diff --git a/src/types/dictation/index.ts b/src/types/dictation/index.ts index ca77de1..ffe6ce2 100644 --- a/src/types/dictation/index.ts +++ b/src/types/dictation/index.ts @@ -59,13 +59,13 @@ export type DictationConfig = { * e.g. `"A doctor dictating a patient visit note."`. It describes the * situation rather than instructing the model, and steers the decoder as it * writes the transcript — where `llm_instruction` reshapes the transcript - * afterwards. Maximum 4096 characters; longer prompts are rejected. + * afterwards. Maximum 6000 characters; longer prompts are rejected. */ stt_prompt?: string; /** * Terms to bias the decoder towards. Whitespace is stripped and empty terms - * are dropped. Maximum 2048 characters in total — longer lists are - * rejected. + * are dropped. Maximum 100 terms and 8000 characters in total — lists over + * either cap are rejected. */ keyterms_prompt?: string[]; /** diff --git a/src/types/sync/index.ts b/src/types/sync/index.ts index eb4c876..4196494 100644 --- a/src/types/sync/index.ts +++ b/src/types/sync/index.ts @@ -55,19 +55,19 @@ export type SyncTranscriptionConfig = { */ model?: SyncSpeechModel; /** - * Custom transcription instruction. Maximum 4096 characters — longer + * Custom transcription instruction. Maximum 6000 characters — longer * prompts are rejected. */ prompt?: string; /** * Terms to bias the decoder towards. Whitespace is stripped and empty - * terms are dropped. Maximum 2048 characters in total — longer lists are - * rejected. + * terms are dropped. Maximum 100 terms and 8000 characters in total — + * longer lists are rejected. */ keyterms_prompt?: string[]; /** * Prior turns from the same conversation, oldest first, most recent last. - * A single string is treated as one turn. Capped at 100 turns and 4096 + * A single string is treated as one turn. Capped at 500 turns and 16000 * characters in total — over-cap context is trimmed (oldest turns dropped * first), not rejected. */ @@ -103,10 +103,15 @@ export type SyncTranscriptionConfig = { */ export type SyncTranscribeOptions = { /** - * The request timeout in milliseconds. Defaults to 60 000, which is kept - * above the server's 30 s deadline so the client doesn't race it. + * The request deadline in milliseconds, measured from the start of the + * request. Spans the upload and the transcription. Defaults to 180 000; + * the sync API caps audio at 120 seconds. */ timeout?: number; + /** + * An `AbortSignal` that drops the request when aborted. + */ + signal?: AbortSignal; }; /** @@ -116,9 +121,8 @@ export type SyncTranscribeOptions = { export type SyncLiveOptions = { /** * The request deadline in milliseconds, measured from the start of the - * request. Unlike the buffered path this spans the whole upload, so it - * must exceed the length of the recording as well as the transcription. - * Defaults to 180 000; the sync API caps audio at 120 seconds. + * request. Spans the upload and the transcription. Defaults to 180 000; + * the sync API caps audio at 120 seconds. */ timeout?: number; /** diff --git a/src/utils/live.ts b/src/utils/live.ts index 25007e0..08e78f5 100644 --- a/src/utils/live.ts +++ b/src/utils/live.ts @@ -2,10 +2,9 @@ * Multipart framing, streaming request bodies and the push-style session * behind every live upload route. * - * A buffered request hands fetch a `FormData` and lets it encode the whole - * body up front. A live upload cannot: the audio does not exist yet when the - * request starts. This module emits the framing itself as a `ReadableStream`, - * so the `config` part goes out immediately and audio follows as it arrives. + * The audio does not exist yet when the request starts, so this module emits + * the framing itself as a `ReadableStream`: the `config` part goes out + * immediately and audio follows as it arrives. * * Part order is significant and is enforced here by construction. The server * decodes audio as it lands, so it needs `sample_rate` and `channels` before @@ -32,12 +31,19 @@ export function multipartBoundary(): string { } /** - * Escape a value for a quoted `Content-Disposition` parameter, as - * `FormData` serialization does: `"` becomes `%22`, CR and LF become `%0D` - * and `%0A`, so a file name cannot break out of the part header. + * Escape a value for a quoted `Content-Disposition` parameter, per the HTML5 + * multipart form-encoding rules: `"` becomes `%22`, `\` is doubled to `\\`, + * and every control character U+0000-U+001F other than ESC (U+001B) becomes + * `%XX` (two uppercase hex digits), so a file name cannot break out of the + * part header. */ function escapeFormParam(value: string): string { - return value.replace(/"/g, "%22").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); + // eslint-disable-next-line no-control-regex -- control characters are the escape target + return value.replace(/["\\\x00-\x1a\x1c-\x1f]/g, (char) => { + if (char === '"') return "%22"; + if (char === "\\") return "\\\\"; + return `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`; + }); } /** @@ -448,7 +454,9 @@ export class LiveSession { /** * Build a product error from a non-200 response. The primary format is an * RFC 9457 problem-details body (`status`/`title`/`detail`); legacy - * `{error_code, message}` and `{detail}`-only bodies are also accepted. + * `{error_code, message}` and `{error_code, error}` bodies are also accepted. + * The message is taken from `detail`, then `message`, then `error`, in that + * order of precedence. * @typeParam TError - The product's error class. * @param response - The failed response, whose body is read here. * @param factory - The error class to construct. @@ -479,6 +487,7 @@ export async function errorFromResponse( } if (typeof body.detail === "string") message = body.detail; else if (typeof body.message === "string") message = body.message; + else if (typeof body.error === "string") message = body.error; } } catch { if (text) message = text; @@ -509,8 +518,8 @@ const defaultContentType = "audio/wav"; * `sample_rate`/`channels` are set on the config (the fields these APIs * require only for raw PCM), and both must then be present. Any other suffix * takes the content type `contentTypes` maps it to, and `audio/wav` when it - * is unknown or absent. Needs no audio bytes, so it serves a live upload as - * well as a buffered one. + * is unknown or absent. Decided from the config and file name alone, before + * any audio bytes are available. * @param config - The request config, read for `sample_rate` and `channels`. * @param suffix - The source's lowercased file extension, with the dot, or * `""`. diff --git a/tests/unit/dictation.test.ts b/tests/unit/dictation.test.ts index 9088d6e..4f79627 100644 --- a/tests/unit/dictation.test.ts +++ b/tests/unit/dictation.test.ts @@ -1,5 +1,6 @@ -import { createReadStream } from "fs"; +import { createReadStream, mkdtempSync, rmSync, writeFileSync } from "fs"; import fetchMock from "jest-fetch-mock"; +import { tmpdir } from "os"; import path from "path"; import { Readable } from "stream"; import { AssemblyAI, DictationError, SyncTranscriptError } from "../../src"; @@ -210,6 +211,37 @@ describe("dictation upload", () => { expect(parts[1].filename).toBe("we%22ird%0D%0Aname.wav"); expect(decode(parts[1].body)).toBe("RIFF"); }); + + it("should escape a tab in a real path file name, passing ESC through", async () => { + mockOk(); + const dir = mkdtempSync(path.join(tmpdir(), "aai-dictation-")); + // A tab and an ESC control character — both valid bytes in a POSIX file + // name. No backslash here: the SDK's basename() splits a path on both + // `/` and `\`, so a path input can never carry one into the file name + // (see the File-based test below for that case). + const weirdName = `b\tc\x1bd.wav`; + const filePath = path.join(dir, weirdName); + writeFileSync(filePath, Buffer.from("RIFFfake")); + try { + await assembly.dictation.transcribeLive(filePath); + const parts = await requestParts(); + expect(parts[1].filename).toBe(`b%09c\x1bd.wav`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("should escape a quote and tab in a File name", async () => { + mockOk(); + // basename() is applied to a File/Blob `.name` the same as a path, so a + // backslash in it is also split off rather than reaching the escaper — + // there is no public input that carries a backslash through to it. + const file: NamedBlob = new Blob([bytes("RIFFfake") as BlobPart]); + file.name = `b"c\td.wav`; + await assembly.dictation.transcribeLive(file); + const parts = await requestParts(); + expect(parts[1].filename).toBe(`b%22c%09d.wav`); + }); }); describe("dictation audio inputs", () => { @@ -434,12 +466,21 @@ describe("dictation config", () => { it("should reject an oversized stt_prompt before any request", async () => { await expect( assembly.dictation.transcribeLive(chunks(bytes("RIFF")), { - stt_prompt: "a".repeat(4097), + stt_prompt: "a".repeat(6001), }), - ).rejects.toThrow("stt_prompt exceeds 4096 characters (got 4097)"); + ).rejects.toThrow("stt_prompt exceeds"); expect(fetchMock).not.toHaveBeenCalled(); }); + it("should accept a 5000-char stt_prompt", async () => { + mockOk(); + const sttPrompt = "a".repeat(5000); + await assembly.dictation.transcribeLive(chunks(bytes("RIFF")), { + stt_prompt: sttPrompt, + }); + expect(await configPart()).toEqual({ stt_prompt: sttPrompt }); + }); + it("should reject an oversized llm_instruction before any request", async () => { await expect( assembly.dictation.transcribeLive(chunks(bytes("RIFF")), { @@ -452,11 +493,31 @@ describe("dictation config", () => { it("should reject an oversized keyterms_prompt before any request", async () => { await expect( assembly.dictation.transcribeLive(chunks(bytes("RIFF")), { - keyterms_prompt: ["a".repeat(1024), "b".repeat(1025)], + keyterms_prompt: ["a".repeat(4000), "b".repeat(4001)], }), - ).rejects.toThrow("keyterms_prompt exceeds 2048 characters (got 2049)"); + ).rejects.toThrow("characters"); expect(fetchMock).not.toHaveBeenCalled(); }); + + it("should reject more than 100 keyterms_prompt terms", async () => { + const terms = Array.from({ length: 101 }, (_, i) => `term${i}`); + await expect( + assembly.dictation.transcribeLive(chunks(bytes("RIFF")), { + keyterms_prompt: terms, + }), + ).rejects.toThrow("keyterms_prompt exceeds 100 terms"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should accept exactly 100 keyterms_prompt terms", async () => { + mockOk(); + const terms = Array.from({ length: 100 }, (_, i) => `term${i}`); + await assembly.dictation.transcribeLive(chunks(bytes("RIFF")), { + keyterms_prompt: terms, + }); + const config = await configPart(); + expect(config.keyterms_prompt).toEqual(terms); + }); }); describe("dictation response", () => { @@ -535,6 +596,19 @@ describe("dictation errors", () => { expect(error.message).toBe("could not decode"); }); + it("should map a bare error/error_code envelope to DictationError", async () => { + mockError(401, { + error: "Invalid API key", + error_code: "unauthorized", + }); + const error = await assembly.dictation + .transcribeLive(chunks(bytes("RIFF"))) + .catch((e) => e); + expect(error).toBeInstanceOf(DictationError); + expect(error.message).toBe("Invalid API key"); + expect(error.errorCode).toBe("unauthorized"); + }); + it("should map a problem-details envelope to DictationError", async () => { mockError(413, { status: 413, diff --git a/tests/unit/sync-live.test.ts b/tests/unit/sync-live.test.ts index 6b4428b..b8134ee 100644 --- a/tests/unit/sync-live.test.ts +++ b/tests/unit/sync-live.test.ts @@ -3,7 +3,7 @@ import fetchMock from "jest-fetch-mock"; import path from "path"; import { Readable } from "stream"; import { SyncTranscriptError } from "../../src"; -import { createClient, requestMatches } from "./utils"; +import { createClient, defaultBaseUrl, requestMatches } from "./utils"; fetchMock.enableMocks(); @@ -20,7 +20,7 @@ const okResponse = { request_time_ms: 243.7, }; -const liveUrl = "/v1/transcribe/stream"; +const liveUrl = "/v1/transcribe/live"; function mockOk() { fetchMock.doMockOnceIf( @@ -530,3 +530,22 @@ describe("sync live session", () => { expect(decode(parts[1].body)).toBe("RIFFfake"); }); }); + +describe("sync routing", () => { + it("should never request the buffered transcription endpoint", async () => { + mockOk(); + mockOk(); + mockOk(); + + await assembly.sync.transcribe(bytes("RIFFfake")); + await assembly.sync.transcribeLive(chunks(bytes("RIFF"))); + const session = assembly.sync.openLive(); + session.write(bytes("RIFF")); + session.close(); + await session.result(); + + expect(fetchMock.mock.calls).toHaveLength(3); + const urls = new Set(fetchMock.mock.calls.map(([url]) => url)); + expect(urls).toEqual(new Set([defaultBaseUrl + liveUrl])); + }); +}); diff --git a/tests/unit/sync.test.ts b/tests/unit/sync.test.ts index 534665d..491c331 100644 --- a/tests/unit/sync.test.ts +++ b/tests/unit/sync.test.ts @@ -1,8 +1,9 @@ -import { createReadStream } from "fs"; +import { createReadStream, mkdtempSync, rmSync, writeFileSync } from "fs"; import fetchMock from "jest-fetch-mock"; +import { tmpdir } from "os"; import path from "path"; import { SyncTranscriptError } from "../../src"; -import { createClient, requestMatches } from "./utils"; +import { createClient, defaultBaseUrl, requestMatches } from "./utils"; fetchMock.enableMocks(); @@ -24,32 +25,127 @@ const okResponse = { request_time_ms: 243.7, }; +// `transcribe()` rides the same chunked-multipart live upload as +// `transcribeLive()` / `openLive()` — the buffered endpoint is never used. +const liveUrl = "/v1/transcribe/live"; + function mockOk() { fetchMock.doMockOnceIf( - requestMatches({ url: "/v1/transcribe", method: "POST" }), + requestMatches({ url: liveUrl, method: "POST" }), JSON.stringify(okResponse), ); } +/** Behaves like a transport: gives up when the request's signal fires. */ +function mockAbortable() { + fetchMock.doMockOnceIf( + requestMatches({ url: liveUrl, method: "POST" }), + async (request) => { + await new Promise((resolve) => { + request.signal.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + throw Object.assign(new Error("The operation was aborted."), { + name: "AbortError", + }); + }, + ); +} + type NamedBlob = Blob & { name?: string }; -function requestBody(): FormData { - return fetchMock.mock.calls[0][1]!.body as FormData; +function requestInit(index = 0): RequestInit & { duplex?: string } { + return fetchMock.mock.calls[index][1] as RequestInit & { duplex?: string }; +} + +function requestHeaders(index = 0): Record { + return requestInit(index).headers as Record; +} + +/** Drains a `ReadableStream` the way a transport would. */ +async function drainStream( + stream: ReadableStream, +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const total = chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return out; } -function requestHeaders(): Record { - return fetchMock.mock.calls[0][1]!.headers as Record; +// A `ReadableStream` can only be read once, but several helpers (and +// several assertions within one test) may each want the drained bytes — +// cache the drain per request index so any number of calls is safe. +const bodyBytesCache = new Map>(); + +/** Drains the streamed request body the way a transport would. */ +function requestBodyBytes(index = 0): Promise { + let cached = bodyBytesCache.get(index); + if (!cached) { + cached = drainStream(requestInit(index).body as ReadableStream); + bodyBytesCache.set(index, cached); + } + return cached; } -async function configPart(): Promise | null> { - const part = requestBody().get("config"); - if (part === null) return null; - return JSON.parse(await (part as Blob).text()); +type Part = { + name: string; + filename?: string; + type?: string; + body: Uint8Array; +}; + +/** Parses multipart body bytes into their parts, in order. */ +function parseParts(bytes: Uint8Array, index = 0): Part[] { + const contentType = requestHeaders(index)["Content-Type"]; + const boundary = /boundary=([^;]+)/.exec(contentType)![1]; + const text = new TextDecoder("latin1").decode(bytes); + const parts: Part[] = []; + for (const raw of text.split(`--${boundary}`)) { + if (raw === "" || raw.startsWith("--")) continue; + const [headerText, ...rest] = raw.replace(/^\r\n/, "").split("\r\n\r\n"); + const bodyText = rest.join("\r\n\r\n").replace(/\r\n$/, ""); + const name = /name="([^"]*)"/.exec(headerText)![1]; + const filename = /filename="([^"]*)"/.exec(headerText)?.[1]; + const type = /Content-Type: ([^\r\n]+)/.exec(headerText)?.[1]; + parts.push({ + name, + filename, + type, + body: Uint8Array.from(bodyText, (ch) => ch.charCodeAt(0)), + }); + } + return parts; +} + +/** Drains the streamed request body and parses it into parts. */ +async function requestParts(index = 0): Promise { + return parseParts(await requestBodyBytes(index), index); } +/** Parses the JSON `config` part of a request. */ +async function configPart(index = 0): Promise> { + const parts = await requestParts(index); + return JSON.parse(decode(parts[0].body)); +} + +const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + beforeEach(() => { fetchMock.resetMocks(); fetchMock.doMock(); + bodyBytesCache.clear(); }); describe("sync", () => { @@ -68,20 +164,27 @@ describe("sync", () => { const response: Partial = { ...okResponse }; delete response.request_time_ms; fetchMock.doMockOnceIf( - requestMatches({ url: "/v1/transcribe", method: "POST" }), + requestMatches({ url: liveUrl, method: "POST" }), JSON.stringify(response), ); const result = await assembly.sync.transcribe(fakeWavBytes); expect(result.request_time_ms).toBeUndefined(); }); - it("should send the model header and a WAV part", async () => { + it("should stream a chunked multipart body with the config part first", async () => { mockOk(); await assembly.sync.transcribe(fakeWavBytes); + expect(fetchMock.mock.calls[0][0]).toBe(defaultBaseUrl + liveUrl); + expect(requestInit().duplex).toBe("half"); + expect(requestInit().body).toBeInstanceOf(ReadableStream); expect(requestHeaders()["X-AAI-Model"]).toBe("universal-3-5-pro"); - const audio = requestBody().get("audio") as Blob; - expect(audio.type).toBe("audio/wav"); - expect(requestBody().get("config")).toBeNull(); + expect(requestHeaders()["Content-Type"]).toMatch(/^multipart\/form-data;/); + + const parts = await requestParts(); + expect(parts.map((p) => p.name)).toEqual(["config", "audio"]); + expect(decode(parts[0].body)).toBe("{}"); + expect(parts[0].type).toBe("application/json"); + expect(parts[1].type).toBe("audio/wav"); }); it("should send the prompt and normalized keyterms_prompt", async () => { @@ -108,12 +211,12 @@ describe("sync", () => { expect(config).not.toHaveProperty("model"); }); - it("should omit the config part when only the model is set", async () => { + it("should send an empty config part when only the model is set", async () => { mockOk(); await assembly.sync.transcribe(fakeWavBytes, { model: "some-other-model", }); - expect(requestBody().get("config")).toBeNull(); + expect(await configPart()).toEqual({}); }); it("should send conversation_context turns, stripped with empties dropped", async () => { @@ -146,15 +249,15 @@ describe("sync", () => { it("should trim the oldest conversation turns over the char cap", async () => { mockOk(); await assembly.sync.transcribe(fakeWavBytes, { - conversation_context: ["a".repeat(3000), "b".repeat(3000)], + conversation_context: ["a".repeat(10000), "b".repeat(10000)], }); const config = await configPart(); - expect(config?.conversation_context).toEqual(["b".repeat(3000)]); + expect(config?.conversation_context).toEqual(["b".repeat(10000)]); }); it("should trim the oldest conversation turns over the turn cap", async () => { mockOk(); - const turns = Array.from({ length: 120 }, (_, i) => `turn ${i}`); + const turns = Array.from({ length: 520 }, (_, i) => `turn ${i}`); await assembly.sync.transcribe(fakeWavBytes, { conversation_context: turns, }); @@ -165,9 +268,10 @@ describe("sync", () => { it("should trim to nothing when a single turn is over the char cap", async () => { mockOk(); await assembly.sync.transcribe(fakeWavBytes, { - conversation_context: ["a".repeat(5000)], + conversation_context: ["a".repeat(20000)], }); - expect(requestBody().get("config")).toBeNull(); + const config = await configPart(); + expect(config).not.toHaveProperty("conversation_context"); }); it("should send a single-element language_codes list", async () => { @@ -188,10 +292,10 @@ describe("sync", () => { expect(config?.language_codes).toEqual(["en", "es"]); }); - it("should omit the config part for a default config", async () => { + it("should send an empty config part for a default config", async () => { mockOk(); await assembly.sync.transcribe(fakeWavBytes); - expect(requestBody().get("config")).toBeNull(); + expect(await configPart()).toEqual({}); }); it("should send the timestamps flag when opted in", async () => { @@ -212,7 +316,7 @@ describe("sync", () => { ], }; fetchMock.doMockOnceIf( - requestMatches({ url: "/v1/transcribe", method: "POST" }), + requestMatches({ url: liveUrl, method: "POST" }), JSON.stringify(response), ); const result = await assembly.sync.transcribe(fakeWavBytes); @@ -228,8 +332,8 @@ describe("sync", () => { sample_rate: 16000, channels: 1, }); - const audio = requestBody().get("audio") as Blob; - expect(audio.type).toBe("audio/pcm"); + const parts = await requestParts(); + expect(parts[1].type).toBe("audio/pcm"); const config = await configPart(); expect(config).toEqual({ sample_rate: 16000, channels: 1 }); }); @@ -254,9 +358,9 @@ describe("sync", () => { path.join(testDir, "gore-short.wav"), ); expect(result.text).toBe("hello world"); - const audio = requestBody().get("audio") as NamedBlob; - expect(audio.name).toBe("gore-short.wav"); - expect(audio.type).toBe("audio/wav"); + const parts = await requestParts(); + expect(parts[1].filename).toBe("gore-short.wav"); + expect(parts[1].type).toBe("audio/wav"); }); it("should transcribe a Node stream and use its file name", async () => { @@ -264,8 +368,8 @@ describe("sync", () => { const stream = createReadStream(path.join(testDir, "gore-short.wav")); const result = await assembly.sync.transcribe(stream); expect(result.text).toBe("hello world"); - const audio = requestBody().get("audio") as NamedBlob; - expect(audio.name).toBe("gore-short.wav"); + const parts = await requestParts(); + expect(parts[1].filename).toBe("gore-short.wav"); }); it("should transcribe a web ReadableStream", async () => { @@ -287,22 +391,70 @@ describe("sync", () => { file.name = "call.wav"; const result = await assembly.sync.transcribe(file); expect(result.text).toBe("hello world"); - const audio = requestBody().get("audio") as NamedBlob; - expect(audio.name).toBe("call.wav"); + const parts = await requestParts(); + expect(parts[1].filename).toBe("call.wav"); + }); + + it("should escape a tab in a real path file name, passing ESC through", async () => { + mockOk(); + const dir = mkdtempSync(path.join(tmpdir(), "aai-sync-")); + // A tab and an ESC control character — both valid bytes in a POSIX file + // name. No backslash here: the SDK's basename() splits a path on both + // `/` and `\`, so a path input can never carry one into the file name + // (see the File-based test below for that case). + const weirdName = `b\tc\x1bd.wav`; + const filePath = path.join(dir, weirdName); + writeFileSync(filePath, Buffer.from("RIFFfake")); + try { + const result = await assembly.sync.transcribe(filePath); + expect(result.text).toBe("hello world"); + const parts = await requestParts(); + expect(parts[1].filename).toBe(`b%09c\x1bd.wav`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("should escape a quote and tab in a File name", async () => { + mockOk(); + // basename() is applied to a File/Blob `.name` the same as a path, so a + // backslash in it is also split off rather than reaching the escaper — + // there is no public input that carries a backslash through to it. + const file: NamedBlob = new Blob([fakeWavBytes as BlobPart]); + file.name = `b"c\td.wav`; + await assembly.sync.transcribe(file); + const parts = await requestParts(); + expect(parts[1].filename).toBe(`b%22c%09d.wav`); }); it("should reject an oversized keyterms_prompt", async () => { + const promise = assembly.sync.transcribe(fakeWavBytes, { + keyterms_prompt: ["x".repeat(9000)], + }); + await expect(promise).rejects.toThrow("keyterms_prompt exceeds"); + await expect(promise).rejects.toThrow("characters"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should reject more than 100 keyterms_prompt terms", async () => { + const terms = Array.from({ length: 101 }, (_, i) => `term${i}`); await expect( - assembly.sync.transcribe(fakeWavBytes, { - keyterms_prompt: ["x".repeat(3000)], - }), - ).rejects.toThrow("keyterms_prompt exceeds"); + assembly.sync.transcribe(fakeWavBytes, { keyterms_prompt: terms }), + ).rejects.toThrow("keyterms_prompt exceeds 100 terms"); expect(fetchMock).not.toHaveBeenCalled(); }); + it("should accept exactly 100 keyterms_prompt terms", async () => { + mockOk(); + const terms = Array.from({ length: 100 }, (_, i) => `term${i}`); + await assembly.sync.transcribe(fakeWavBytes, { keyterms_prompt: terms }); + const config = await configPart(); + expect(config.keyterms_prompt).toEqual(terms); + }); + it("should reject an oversized prompt", async () => { await expect( - assembly.sync.transcribe(fakeWavBytes, { prompt: "x".repeat(5000) }), + assembly.sync.transcribe(fakeWavBytes, { prompt: "x".repeat(7000) }), ).rejects.toThrow("prompt exceeds"); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -337,6 +489,48 @@ describe("sync", () => { }); }); + it("should map a bare error/error_code envelope to a SyncTranscriptError", async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + error: "Invalid API key", + error_code: "unauthorized", + }), + { status: 401 }, + ); + await expect(assembly.sync.transcribe(fakeWavBytes)).rejects.toMatchObject({ + message: "Invalid API key", + errorCode: "unauthorized", + }); + }); + + it("should prefer detail over a bare error field", async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + detail: "detail wins", + error: "error field", + error_code: "x", + }), + { status: 400 }, + ); + await expect(assembly.sync.transcribe(fakeWavBytes)).rejects.toMatchObject({ + message: "detail wins", + }); + }); + + it("should prefer message over a bare error field", async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + message: "message wins", + error: "error field", + error_code: "x", + }), + { status: 400 }, + ); + await expect(assembly.sync.transcribe(fakeWavBytes)).rejects.toMatchObject({ + message: "message wins", + }); + }); + it("should surface retryAfter on rate limits", async () => { fetchMock.mockResponseOnce( JSON.stringify({ @@ -353,6 +547,46 @@ describe("sync", () => { }); }); + it("should surface a rate-limited response and still upload the whole clip as a single chunk", async () => { + // Drain the body inside the handler, the way a real transport does, so + // the assertion does not race an early response against the writer + // still producing the (single) audio chunk. + let uploaded: Uint8Array | undefined; + fetchMock.doMockOnceIf( + requestMatches({ url: liveUrl, method: "POST" }), + async () => { + uploaded = await requestBodyBytes(); + return { + body: JSON.stringify({ + status: 429, + title: "Too Many Requests", + detail: "slow down", + }), + init: { status: 429, headers: { "Retry-After": "7" } }, + }; + }, + ); + const error = await assembly.sync.transcribe(fakeWavBytes).catch((e) => e); + expect(error).toBeInstanceOf(SyncTranscriptError); + expect(error.status).toBe(429); + expect(error.errorCode).toBe("too_many_requests"); + expect(error.retryAfter).toBe(7); + + // The "single chunk" half of the contract holds even on a rejected + // request: one config part, then one audio part carrying the whole + // input, then the closing boundary. + const parts = parseParts(uploaded!); + expect(parts.map((p) => p.name)).toEqual(["config", "audio"]); + expect(decode(parts[0].body)).toBe("{}"); + expect(decode(parts[1].body)).toBe(decode(fakeWavBytes)); + + const boundary = /boundary=([^;]+)/.exec( + requestHeaders()["Content-Type"], + )![1]; + const raw = new TextDecoder("latin1").decode(uploaded!); + expect(raw.trimEnd().endsWith(`--${boundary}--`)).toBe(true); + }); + it("should map a detail-only envelope without an error code", async () => { fetchMock.mockResponseOnce(JSON.stringify({ detail: "Invalid API key" }), { status: 401, @@ -387,3 +621,24 @@ describe("sync", () => { expect(await assembly.sync.warm()).toBe(false); }); }); + +describe("sync deadlines", () => { + it("should honour an external abort signal on transcribe()", async () => { + mockAbortable(); + const controller = new AbortController(); + const pending = assembly.sync.transcribe( + fakeWavBytes, + {}, + { signal: controller.signal }, + ); + controller.abort(); + await expect(pending).rejects.toThrow("aborted"); + }); + + it("should give up once the timeout elapses on transcribe()", async () => { + mockAbortable(); + await expect( + assembly.sync.transcribe(fakeWavBytes, {}, { timeout: 1 }), + ).rejects.toThrow("aborted"); + }); +});