diff --git a/CHANGELOG.md b/CHANGELOG.md index 8259fe1..aef68a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.1.1 (unreleased) + +- Token clients (`accessToken`) retry only `429` and connections that never opened. The + service spends the token before running the request, so retrying a later `5xx` or dropped + connection only got `401 token_invalid` and hid the real error. +- A `402` from the credit check inside synthesis has `code === "insufficient_credits"` again, + not the generic `payment_required`. `code` documents every code the API returns. +- Realtime: text that would make a frame larger than 1 MiB is split across `task_continue` + frames (never inside a character), instead of the service closing the socket with `1009`. +- Realtime: Deno 2.5+ sends the API key as a header. Where the key still goes in the + deprecated `?api_key=` parameter (browsers, older Deno), the SDK logs a warning once. +- Types: `Voice` drops `metadata` and `deleted_at`, `CloneJob` drops `result` (the API no longer + returns them); `Voice` gains `created_via_key_prefix`. +- Docs: `num_step` is 1-32, `sample_rate` 8000-48000, clone clips at least 10 s, `ref_text` at + most 4000 characters, and media URLs may be short-lived signed URLs. + ## 0.1.0 (unreleased) - Voices, cloning with job waiting, one-shot and streamed speech. diff --git a/README.md b/README.md index 9985c14..784666c 100644 --- a/README.md +++ b/README.md @@ -185,14 +185,14 @@ const audio = await client.speech.create({ text: "The quick brown fox jumps over the lazy dog.", language: "en", // optional; defaults to the voice's language output_format: "wav", // default; or raw PCM: "s16le", "s24le", "s32le", "f32le", "f64le" - num_step: 32, // optional; fewer steps is faster, more is higher quality + num_step: 32, // optional; 1-32, default 32; fewer steps is faster }); audio.data; // Uint8Array audio.contentType; // "audio/wav" audio.sampleRate; // 24000 audio.generationId; // id of this clip in your generation history -audio.audioURL; // stored copy of the clip, when storage is enabled +audio.audioURL; // stored copy of the clip, when storage is enabled (may be short-lived) ``` Identical requests are served from a cache at no charge. Speech is billed per character of @@ -369,14 +369,14 @@ try { | --------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------- | | `BadRequestError` | 400 | Invalid input, text over 10,000 characters, voice not enrolled | | `AuthenticationError` | 401 | Missing, unknown or revoked key; `key_expired`; `token_invalid` | -| `PaymentRequiredError` | 402 | `insufficient_credits` or `quota_exhausted` | +| `PaymentRequiredError` | 402 | `insufficient_credits`, `quota_exhausted` or `payment_required` | | `PermissionDeniedError` | 403 | `insufficient_scope`, `voice_not_allowed` (token), `feature_disabled`, or `plan_limit_reached` (see `.feature`) | | `NotFoundError` | 404 | No such voice or job, or not visible to your workspace | | `ConflictError` | 409 | A request with the same `Idempotency-Key` is still running | | `PayloadTooLargeError` | 413 | Request body over 21 MiB | | `UnprocessableEntityError` | 422 | `invalid_input`, or `idempotency_key_reused` | | `RateLimitError` | 429 | `rate_limited` / `concurrency_limited` (see `retryAfter`), or `too_many_sessions` | -| `InternalServerError` | 5xx | `upstream_error` (502) or `upstream_busy` (503, see `retryAfter`): retry | +| `InternalServerError` | 5xx | `upstream_error` (502), `upstream_busy` / `unavailable` (503, see `retryAfter`) or `internal_error`: retry | | `APIConnectionError` | | No response; `APITimeoutError` when it timed out | | `StreamError` | | A speech stream failed after it started | | `RealtimeError` | | A realtime context or session failed | @@ -400,6 +400,10 @@ yourself, pass `{ headers: { "Idempotency-Key": "..." } }`. connection never opened, 429, 502 or 503. These are also the rules for keyed calls until the service confirms it honours keys. +With an `accessToken`, only a `429` or a connection that never opened is retried. The service +spends the token just before running the request, so a retry after anything later would only +get `token_invalid`; the original error is thrown instead. + ```ts const client = new Finesse({ maxRetries: 5 }); // every request await client.speech.create({ voice_id, text: "Hello!" }, { maxRetries: 0 }); // one request @@ -528,17 +532,18 @@ const client = new Finesse({ defaultHeaders: { "X-Finesse-Client": "my-app/1.0.0 ## Runtimes -| Runtime | HTTP | Realtime | -| -------- | ------------------ | --------------------------------------------------------- | -| Node 18+ | built-in `fetch` | needs `ws`; the API key goes in a header | -| Bun | built-in | built-in `WebSocket`; the API key goes in a header | -| Deno | built-in | built-in `WebSocket`; use a token (a key goes in the URL) | -| Browsers | with `accessToken` | built-in `WebSocket`; the token is a subprotocol | +| Runtime | HTTP | Realtime | +| -------- | ------------------ | ------------------------------------------------------- | +| Node 18+ | built-in `fetch` | needs `ws`; the API key goes in a header | +| Bun | built-in | built-in `WebSocket`; the API key goes in a header | +| Deno | built-in | built-in `WebSocket`; from 2.5 the key goes in a header | +| Browsers | with `accessToken` | built-in `WebSocket`; the token is a subprotocol | Browsers can't keep an API key secret, so a client holding a key refuses to run in one. Use a [short-lived token](#browsers-and-short-lived-tokens) instead. For internal tools only, `new Finesse({ dangerouslyAllowBrowser: true })` lifts this; realtime then sends the key in a -deprecated URL parameter, because browsers can't set WebSocket headers. +deprecated URL parameter, because browsers can't set WebSocket headers (as does Deno before +2.5), and logs a warning once. ## Examples diff --git a/package.json b/package.json index f40b8a3..9a89449 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flam/finesse", - "version": "0.1.0", + "version": "0.1.1", "description": "TypeScript SDK for the Finesse voice API: voice cloning, text-to-speech and realtime streaming.", "license": "UNLICENSED", "private": true, diff --git a/src/core.ts b/src/core.ts index 1eddd13..e510914 100644 --- a/src/core.ts +++ b/src/core.ts @@ -14,6 +14,9 @@ const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); // 429, 502 (engine unavailable or warming up; failed synthesis is refunded) and 503, or when // the connection never opened. A timeout after the request was sent is not retried. const POST_RETRY_STATUSES = new Set([429, 502, 503]); +// A short-lived token is spent just before the handler runs, so any later failure leaves it +// used and a retry would only get 401 token_invalid. Only 429 is refused before the spend. +const TOKEN_RETRY_STATUSES = new Set([429]); const UNSENT_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"]); const MAX_BACKOFF_MS = 8_000; @@ -32,7 +35,8 @@ export interface ClientOptions { timeout?: number; /** Retries for transient failures. Default 2. Reads, updates and deletes retry on network * errors, timeouts, 408, 429 and 5xx. Creates, uploads and synthesis retry only when the - * server did no work: the connection never opened, 429, 502 or 503. */ + * server did no work: the connection never opened, 429, 502 or 503. With an `accessToken` + * only 429 and a connection that never opened are retried, since the token is single use. */ maxRetries?: number; /** Extra headers sent on every request. */ defaultHeaders?: Record; @@ -226,7 +230,8 @@ export class Core { const retryAfterError = async (err: unknown, sent: boolean): Promise => { throwIfAborted(signal); const timedOut = controller.signal.reason instanceof TimeoutSignal; - const retry = retryAs(null) !== "POST" || (!sent && !timedOut && isUnsent(err)); + const cautious = this.accessToken !== null || retryAs(null) === "POST"; + const retry = !cautious || (!sent && !timedOut && isUnsent(err)); if (attempt < maxRetries && retry) { await sleep(retryDelay(attempt, null), signal); return; @@ -301,7 +306,12 @@ export class Core { continue; } } - const statuses = retryAs(response) === "POST" ? POST_RETRY_STATUSES : RETRY_STATUSES; + const statuses = + this.accessToken !== null + ? TOKEN_RETRY_STATUSES + : retryAs(response) === "POST" + ? POST_RETRY_STATUSES + : RETRY_STATUSES; if (attempt < maxRetries && statuses.has(response.status)) { const delay = retryDelay(attempt, response); this.info( diff --git a/src/errors.ts b/src/errors.ts index 4be5ab3..bc7a6cc 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -39,11 +39,12 @@ export class APIStatusError extends FinesseError { /** HTTP status. */ readonly status: number; /** Machine-readable code. Branch on this, never on the message. Envelope codes: - * `invalid_request`, `unauthorized`, `key_expired`, `token_invalid`, `insufficient_scope`, - * `voice_not_allowed`, `not_found`, `payload_too_large`, `invalid_input`, `rate_limited`, - * `concurrency_limited`, `upstream_error`, `upstream_busy`, ...; plan denials: - * `insufficient_credits`, `quota_exhausted`, `feature_disabled`, `plan_limit_reached`; - * realtime: `too_many_sessions`. `null` when absent. */ + * `invalid_request`, `unauthorized`, `key_expired`, `token_invalid`, `payment_required`, + * `forbidden`, `insufficient_scope`, `voice_not_allowed`, `not_found`, `conflict`, + * `idempotency_in_progress`, `payload_too_large`, `invalid_input`, `idempotency_key_reused`, + * `rate_limited`, `concurrency_limited`, `internal_error`, `upstream_error`, `upstream_busy`, + * `unavailable`; plan denials: `insufficient_credits`, `quota_exhausted`, `feature_disabled`, + * `plan_limit_reached`; realtime: `too_many_sessions`. `null` when absent. */ readonly code: string | null; /** The decoded JSON body, or the raw text when it is not JSON. */ readonly body: unknown; @@ -95,7 +96,8 @@ export class BadRequestError extends APIStatusError {} /** 401: the API key is missing, unknown or revoked; `key_expired`; or a short-lived token is * invalid, expired or already used (`token_invalid`). */ export class AuthenticationError extends APIStatusError {} -/** 402: out of credits (`insufficient_credits`) or quota (`quota_exhausted`). */ +/** 402: out of credits (`insufficient_credits`) or quota (`quota_exhausted`), or another + * payment problem (`payment_required`). */ export class PaymentRequiredError extends APIStatusError {} /** 403: the key lacks the route's scope (`insufficient_scope`), a token is limited to other * voices (`voice_not_allowed`), the plan lacks the feature (`feature_disabled`), or a cap is @@ -189,7 +191,8 @@ export function makeStatusError( if (/^[a-z]+(_[a-z]+)+$/.test(b.message)) code = b.message; } if (typeof b.code === "string" && b.code) { - code = b.code; + // It also sends the generic `payment_required` code; keep the specific one. + if (!(status === 402 && b.message === "insufficient_credits")) code = b.code; } else if (typeof b.error === "string" && b.error) { code = b.error === "plan limit reached" ? "plan_limit_reached" : b.error; message ??= b.error.replace(/_/g, " "); diff --git a/src/realtime.ts b/src/realtime.ts index 6e1bd8e..fab12d2 100644 --- a/src/realtime.ts +++ b/src/realtime.ts @@ -12,22 +12,24 @@ * ``` * * In Node this needs the `ws` package (`npm i ws`), which sends the API key as a header. Bun's - * built-in WebSocket does too. + * built-in WebSocket does too, and so does Deno's from 2.5. * * In a browser, never use the API key: mint a short-lived token on your server * (`tokens.create`) and connect with `{ accessToken }` or `{ getAccessToken }`. The token rides * in the WebSocket subprotocol list (`["finesse.v1", token]`), the one place a browser can put - * a credential on a handshake. (With a key, Deno and browsers fall back to a deprecated URL - * parameter.) + * a credential on a handshake. (With a key, browsers and Deno before 2.5 fall back to a + * deprecated URL parameter, and the SDK logs a warning once.) */ -import { API_PREFIX, isBrowser, type Core } from "./core.js"; +import { API_PREFIX, isBrowser, type Core, type Logger } from "./core.js"; import { APIConnectionError, APITimeoutError, FinesseError, makeStatusError } from "./errors.js"; export const DEFAULT_OPEN_TIMEOUT_MS = 60_000; /** The protocol the service selects. A short-lived token travels as a second subprotocol. */ export const SUBPROTOCOL = "finesse.v1"; const CLOSE_TIMEOUT_MS = 5_000; +/** The service closes the socket (1009) on a client frame over 1 MiB, ending every context. */ +export const MAX_FRAME_BYTES = 1 << 20; // Going Away, abnormal closure, internal error, service restart, try again later, engine busy. const RETRYABLE_CLOSES = new Set([1001, 1006, 1011, 1012, 1013, 4429]); @@ -178,8 +180,10 @@ export interface ContextOptions { language?: string; /** `s16le` (default), `s24le`, `s32le`, `f32le`, `f64le` or `opus`. */ output_format?: "s16le" | "s24le" | "s32le" | "f32le" | "f64le" | "opus"; - /** Output rate in Hz; the native rate is 24000 and lower values still produce 24000. */ + /** Output rate in Hz, 8000-48000; the native rate is 24000 and lower values still produce + * 24000. */ sample_rate?: number; + /** Generation steps, 1-32 (default 32); fewer is faster. Values over 32 are capped. */ num_step?: number; guidance_scale?: number; /** Speaking rate. */ @@ -263,7 +267,7 @@ export class RealtimeContext implements AsyncIterable { throw err; } } else { - this.session._send({ type: "task_continue", context_id: this.id, text, finalize }); + this.session._sendText({ type: "task_continue", context_id: this.id }, text, finalize); } if (finalize) this.finished = true; }); @@ -406,11 +410,19 @@ function globalDialer(headers: boolean): Dialer { }; } +/** Deno's WebSocket takes handshake headers from 2.5; older versions reject the option. */ +function denoHasHeaders(version: string | undefined): boolean { + const [major = 0, minor = 0] = (version ?? "").split(".").map(Number); + return major > 2 || (major === 2 && minor >= 5); +} + async function defaultDialer(): Promise { if (isBrowser()) return globalDialer(false); // browsers cannot set handshake headers const v = versions(); if (v.bun) return globalDialer(true); - if ((globalThis as { Deno?: unknown }).Deno || !v.node) return globalDialer(false); + const deno = (globalThis as { Deno?: { version?: { deno?: string } } }).Deno; + if (deno) return globalDialer(denoHasHeaders(deno.version?.deno)); + if (!v.node) return globalDialer(false); let mod: { default?: unknown; WebSocket?: unknown }; try { mod = (await import("ws")) as typeof mod; @@ -539,7 +551,7 @@ export class RealtimeSession { this.contexts.set(ctx.id, ctx); ctx.socket = this.ws; try { - this._send({ type: "task_start", context_id: ctx.id, ...ctx.options, text, finalize }); + this._sendText({ type: "task_start", context_id: ctx.id, ...ctx.options }, text, finalize); } catch (err) { this.contexts.delete(ctx.id); throw err; @@ -554,6 +566,32 @@ export class RealtimeSession { this.ws.send(JSON.stringify(msg)); } + /** @internal Send a `task_start` / `task_continue` frame with `text`. Text that would make + * the frame larger than {@link MAX_FRAME_BYTES} is split across `task_continue` frames for + * the same context; only the last one carries `finalize`. */ + _sendText(msg: Record, text: string, finalize: boolean): void { + const frame = { ...msg, text, finalize }; + const json = JSON.stringify(frame); + // Each UTF-16 unit is at most 3 bytes of UTF-8, so most frames need no encoding to check. + if (json.length * 3 <= MAX_FRAME_BYTES || utf8Bytes(json) <= MAX_FRAME_BYTES) { + this._send(frame); + return; + } + const next = { type: "task_continue", context_id: msg.context_id }; + const overhead = Math.max( + utf8Bytes(JSON.stringify({ ...msg, text: "", finalize: false })), + utf8Bytes(JSON.stringify({ ...next, text: "", finalize: false })), + ); + const pieces = splitText(text, MAX_FRAME_BYTES - overhead); + pieces.forEach((piece, i) => + this._send({ + ...(i ? next : msg), + text: piece, + finalize: finalize && i === pieces.length - 1, + }), + ); + } + /** @internal */ _forget(ctx: RealtimeContext): void { if (this.contexts.get(ctx.id) === ctx) this.contexts.delete(ctx.id); @@ -613,8 +651,9 @@ export class RealtimeSession { headers = { ...this.core.headers }; delete headers.Accept; } else { - // No handshake headers here (browsers, Deno): the service also accepts the key and - // the client name as query parameters. + // No handshake headers here (browsers, Deno before 2.5): the service also accepts the key + // and the client name as query parameters, but marks that deprecated. + if (this.core.logLevel !== "off") warnQueryKey(this.core.logger); const u = new URL(url); u.searchParams.set("api_key", this.core.apiKey ?? ""); u.searchParams.set("client", this.core.headers["X-Finesse-Client"] ?? ""); @@ -781,6 +820,55 @@ export class Realtime { } } +const warnedLoggers = new WeakSet(); + +/** Logged once per logger unless logging is off: the key in the URL is deprecated. */ +function warnQueryKey(logger: Logger): void { + if (warnedLoggers.has(logger)) return; + warnedLoggers.add(logger); + logger.info( + "[finesse] This runtime cannot send WebSocket headers, so the API key goes in the URL " + + "(?api_key=), which the service has deprecated. Mint a short-lived token on your server " + + "(tokens.create) and connect with { accessToken } or { getAccessToken } instead.", + ); +} + +function utf8Bytes(s: string): number { + return new TextEncoder().encode(s).length; +} + +/** Split `text` into pieces of at most `budget` bytes once JSON-escaped and UTF-8 encoded, + * never inside a code point or a surrogate pair. Every piece holds at least one code point. */ +export function splitText(text: string, budget: number): string[] { + const pieces: string[] = []; + let start = 0; + let size = 0; + for (let i = 0; i < text.length;) { + const c = text.charCodeAt(i); + const pair = c >= 0xd800 && c <= 0xdbff && (text.charCodeAt(i + 1) & 0xfc00) === 0xdc00 ? 2 : 1; + const bytes = pair === 2 ? 4 : escapedBytes(c); + if (size + bytes > budget && i > start) { + pieces.push(text.slice(start, i)); + start = i; + size = 0; + } + size += bytes; + i += pair; + } + pieces.push(text.slice(start)); + return pieces; +} + +/** UTF-8 bytes of one UTF-16 unit as `JSON.stringify` writes it (surrogate pairs aside). */ +function escapedBytes(c: number): number { + if (c === 0x22 || c === 0x5c) return 2; // \" and \\ + if (c < 0x20) return c === 8 || c === 9 || c === 10 || c === 12 || c === 13 ? 2 : 6; + if (c < 0x80) return 1; + if (c < 0x800) return 2; + if (c >= 0xd800 && c <= 0xdfff) return 6; // a lone surrogate is escaped as \uXXXX + return 3; +} + function randomId(): string { const c = (globalThis as { crypto?: Crypto }).crypto; if (c?.randomUUID) return c.randomUUID().replace(/-/g, ""); diff --git a/src/resources/voices.ts b/src/resources/voices.ts index 55a67b9..2c159b3 100644 --- a/src/resources/voices.ts +++ b/src/resources/voices.ts @@ -35,18 +35,20 @@ export interface ConsentParams { export interface CloneVoiceParams extends ConsentParams { name: string; - /** Reference clip, at most 20 MiB; 10-30 s of clear speech works best. */ + /** Reference clip, at most 20 MiB: at least 10 s of clear speech (10-30 s works best). */ file: FileInput; description?: string; language?: string; labels?: Record; - /** Transcript of the reference clip. */ + /** Transcript of the reference clip, at most 4000 characters. */ ref_text?: string; remove_background_noise?: boolean; } export interface EnrollVoiceParams extends ConsentParams { + /** Reference clip, at most 20 MiB: at least 10 s of clear speech (10-30 s works best). */ file: FileInput; + /** Transcript of the reference clip, at most 4000 characters. */ ref_text?: string; language?: string; } diff --git a/src/types.ts b/src/types.ts index f03a6b1..9209a9d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,6 @@ -/** Response shapes. Field names match the API (snake_case); timestamps are Unix milliseconds. */ +/** Response shapes. Field names match the API (snake_case); timestamps are Unix milliseconds. + * Media URLs (`original_audio_url`, `sample_audio_url`, `avatar_url`, `audio_url`) may be + * short-lived signed URLs: download them promptly, or fetch the object again for a fresh one. */ export interface VoiceSettings { stability?: number; @@ -33,7 +35,6 @@ export interface Voice { /** Labels as a plain object (the API sends `[{key, value}]`; the SDK converts it). */ labels: Record; settings?: VoiceSettings; - metadata?: Record; external_ref?: string; original_audio_url?: string; sample_audio_url?: string; @@ -43,7 +44,8 @@ export interface Voice { updated_at?: number; last_used_at?: number; expires_at?: number; - deleted_at?: number; + /** Prefix of the API key that created the voice; only on your own voices. */ + created_via_key_prefix?: string; [key: string]: unknown; } @@ -60,7 +62,6 @@ export interface CloneJob { attempts: number; /** Failure detail, `{message, status_code}`. */ error?: Record | null; - result?: Record | null; started_at?: number; finished_at?: number; created_at?: number; @@ -81,6 +82,7 @@ export interface Audio { sampleRate: number | null; /** Id of the generation history record (when object storage is enabled). */ generationId: string | null; + /** The stored clip, when object storage is enabled; may be a short-lived signed URL. */ audioURL: string | null; billingRemaining: number | null; } @@ -121,9 +123,9 @@ export interface SpeechParams { text: string; language?: string; output_format?: OutputFormat; - /** Output rate in Hz. The native rate is 24000; lower values still return 24000. */ + /** Output rate in Hz, 8000-48000. The native rate is 24000; lower values still return 24000. */ sample_rate?: number; - /** Generation steps (default 32); fewer is faster, more is higher quality. */ + /** Generation steps, 1-32 (default and maximum 32; over 32 is a 400); fewer is faster. */ num_step?: number; } diff --git a/src/version.ts b/src/version.ts index 52c905c..6fa18df 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.1.0"; +export const VERSION = "0.1.1"; diff --git a/tests/errors.test.ts b/tests/errors.test.ts index 2da357a..b5de86d 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -46,6 +46,24 @@ describe("errors", () => { expect(e2.code).toBeNull(); }); + it("prefers insufficient_credits over payment_required on a 402", async () => { + const credits = { status: 402, error: true, message: "insufficient_credits" }; + const m = mock( + jsonResponse(402, { ...credits, code: "payment_required" }), + jsonResponse(402, { + status: 402, + error: true, + message: "card declined", + code: "payment_required", + }), + ); + const e = await m.client.speech.create({ voice_id: VOICE.id, text: "hi" }).catch((x) => x); + expect(e).toBeInstanceOf(ff.PaymentRequiredError); + expect(e.code).toBe("insufficient_credits"); + const e2 = await m.client.speech.create({ voice_id: VOICE.id, text: "hi" }).catch((x) => x); + expect(e2.code).toBe("payment_required"); + }); + it("reads plan limits", async () => { const m = mock( jsonResponse(403, { error: "plan limit reached", feature: "max_voices", limit: 3 }), diff --git a/tests/realtime.test.ts b/tests/realtime.test.ts index 75b8531..33cd610 100644 --- a/tests/realtime.test.ts +++ b/tests/realtime.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocketServer, type WebSocket } from "ws"; import * as ff from "../src/index.js"; import type { RealtimeSession } from "../src/index.js"; +import { MAX_FRAME_BYTES, splitText } from "../src/realtime.js"; const VID = "665f1a2b3c4d5e6f7a8b9c0d"; const PATH = "/flam-voice-svc/api/v1/public/stream"; @@ -193,6 +194,43 @@ describe("realtime", () => { expect(engine.received[0]!.idle_flush_ms).toBe(300); }); + it("splits text over the 1 MiB frame cap across task_continue frames", async () => { + const engine = new Engine(); + engine.onMessage = () => undefined; + const client = await start(engine); + // Multi-byte text with quotes (escaped) and emoji (surrogate pairs) near every boundary. + const unit = 'a"é€😀'; + const long = unit.repeat(Math.ceil((2.5 * MAX_FRAME_BYTES) / 12)); + await session(client, async (rt) => { + const ctx = rt.context({ voice_id: VID, context_id: "big" }); + await ctx.send(long, { finalize: true }); + await vi.waitFor(() => expect(engine.received.length).toBeGreaterThanOrEqual(3)); + await ctx.cancel(); + }); + const frames = engine.received.filter((m) => m.context_id === "big" && "text" in m); + expect(frames.map((m) => m.type)).toEqual([ + "task_start", + ...Array(frames.length - 1).fill("task_continue"), + ]); + expect(frames.length).toBeGreaterThanOrEqual(3); + expect(frames.map((m) => m.finalize)).toEqual([...Array(frames.length - 1).fill(false), true]); + expect(frames.map((m) => m.text).join("")).toBe(long); + for (const m of frames) { + expect(Buffer.byteLength(JSON.stringify(m))).toBeLessThanOrEqual(MAX_FRAME_BYTES); + expect(m.text).not.toMatch(/^[\udc00-\udfff]|[\ud800-\udbff]$/); + } + expect(frames[0]!.voice_id).toBe(VID); + }); + + it("splits text without breaking code points", () => { + expect(splitText("ab", 1)).toEqual(["a", "b"]); + expect(splitText("😀😀", 5)).toEqual(["😀", "😀"]); + expect(splitText('"\\', 3)).toEqual(['"', "\\"]); + expect(splitText("\ud800x", 6)).toEqual(["\ud800", "x"]); // a lone surrogate is escaped + expect(splitText("😀", 1)).toEqual(["😀"]); // at least one code point per piece + expect(splitText("", 10)).toEqual([""]); + }); + it("demultiplexes concurrent contexts", async () => { const engine = new Engine(); const client = await start(engine); @@ -708,5 +746,47 @@ describe("realtime", () => { expect(url.searchParams.get("client")).toMatch(/^flam-finesse-node\//); expect(calls[0]).toHaveLength(1); }); + + it("Deno 2.5+: the global WebSocket with headers", async () => { + const { calls, Fake } = fakeWebSocket(); + vi.stubGlobal("WebSocket", Fake); + vi.stubGlobal("Deno", { version: { deno: "2.5.0" } }); + const client = new ff.Finesse({ apiKey: "flv_k", baseURL: "https://x.test" }); + await expect(client.realtime.connect()).rejects.toThrow("fake socket"); + expect(String(calls[0]![0])).toBe("wss://x.test/flam-voice-svc/api/v1/public/stream"); + expect((calls[0]![1] as { headers: Record }).headers["X-API-Key"]).toBe( + "flv_k", + ); + }); + + it("warns once about a key in the URL when logging is on", async () => { + const { Fake } = fakeWebSocket(); + vi.stubGlobal("WebSocket", Fake); + vi.stubGlobal("Deno", { version: { deno: "2.4.5" } }); + const quiet = { debug: vi.fn(), info: vi.fn() }; + const off = new ff.Finesse({ apiKey: "flv_k", baseURL: "https://x.test", logger: quiet }); + await expect(off.realtime.connect()).rejects.toThrow("fake socket"); + expect(quiet.info).not.toHaveBeenCalled(); + const logger = { debug: vi.fn(), info: vi.fn() }; + const client = new ff.Finesse({ + apiKey: "flv_k", + baseURL: "https://x.test", + logger, + logLevel: "info", + }); + await expect(client.realtime.connect()).rejects.toThrow("fake socket"); + await expect(client.realtime.connect()).rejects.toThrow("fake socket"); + expect(logger.info).toHaveBeenCalledTimes(1); + const line = String(logger.info.mock.calls[0]![0]); + expect(line).toMatch(/deprecated.*accessToken/s); + expect(line).not.toContain("flv_k"); + const token = new ff.Finesse({ + accessToken: "fin_tok_x", + baseURL: "https://x.test", + logger: { debug: vi.fn(), info: vi.fn() }, + }); + await expect(token.realtime.connect()).rejects.toThrow("fake socket"); + expect(token._core.logger.info).not.toHaveBeenCalled(); + }); }); }); diff --git a/tests/retries.test.ts b/tests/retries.test.ts index 5dec853..d031276 100644 --- a/tests/retries.test.ts +++ b/tests/retries.test.ts @@ -301,3 +301,51 @@ describe("servers that don't honour keys", () => { expect(m.requests).toHaveLength(3); }); }); + +describe("short-lived tokens", () => { + // The server spends the token just before the handler, so only failures before that point + // can be retried; anything later would come back as 401 token_invalid. + const tokenClient = (m: ReturnType, opts: Record = {}) => + m.make({ apiKey: undefined, accessToken: "fin_tok_abc", ...opts }); + + it("retries 429 and connections that never opened", async () => { + const m = mock(error(429, "slow"), netError("ECONNREFUSED"), ok()); + await tokenClient(m).speech.create({ voice_id: VOICE.id, text: "hi" }); + expect(m.requests).toHaveLength(3); + }); + + it.each([500, 502, 503, 504])("throws %i as-is", async (status) => { + const m = mock(echo(error(status, "upstream")), ok()); + const e = await tokenClient(m) + .speech.create({ voice_id: VOICE.id, text: "hi" }) + .catch((x) => x); + expect(e).toBeInstanceOf(ff.InternalServerError); + expect(e.status).toBe(status); + expect(m.requests).toHaveLength(1); + }); + + it("does not retry a stream after a 5xx, even once keys are honoured", async () => { + const m = mock(echo(ok()), echo(error(502, "upstream")), ok()); + const c = tokenClient(m); + await c.speech.create({ voice_id: VOICE.id, text: "a" }); // teaches the client keys work + const e = await (async () => { + for await (const _ of c.speech.stream({ voice_id: VOICE.id, text: "b" })) void _; + })().catch((x) => x); + expect(e).toBeInstanceOf(ff.InternalServerError); + expect(m.requests).toHaveLength(2); + }); + + it("does not retry errors after the request was sent", async () => { + const m = mock(netError("ECONNRESET"), ok()); + await expect( + tokenClient(m).speech.create({ voice_id: VOICE.id, text: "hi" }), + ).rejects.toBeInstanceOf(ff.APIConnectionError); + expect(m.requests).toHaveLength(1); + const hang = () => new Promise(() => undefined); + const m2 = mock(hang, ok()); + await expect( + tokenClient(m2, { timeout: 10 }).speech.create({ voice_id: VOICE.id, text: "hi" }), + ).rejects.toBeInstanceOf(ff.APITimeoutError); + expect(m2.requests).toHaveLength(1); + }); +});