Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
16 changes: 13 additions & 3 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<string, string>;
Expand Down Expand Up @@ -226,7 +230,8 @@ export class Core {
const retryAfterError = async (err: unknown, sent: boolean): Promise<void> => {
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;
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 10 additions & 7 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, " ");
Expand Down
Loading
Loading