From 3668d2436480e5c1c78be8b2ce10f16cc59e390a Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:04:43 +0000 Subject: [PATCH 01/22] SDK regeneration --- .fern/metadata.json | 8 +- .github/workflows/manual-publish.yml | 44 -- AGENTS.md | 101 ---- README.md | 49 +- package.json | 26 +- reference.md | 282 ++++++++- src/BaseClient.ts | 6 +- src/Client.ts | 6 + src/api/errors/ContentTooLargeError.ts | 22 + src/api/errors/index.ts | 1 + src/api/resources/audio/client/Client.ts | 149 ++++- .../audio/client/requests/GetSpeechRequest.ts | 16 +- .../audio/client/requests/GetStreamRequest.ts | 60 -- .../client/requests/StreamAudioRequest.ts | 26 + .../StreamWithTimestampsAudioRequest.ts | 24 + .../resources/audio/client/requests/index.ts | 3 +- .../StreamWithTimestampsAudioRequestAccept.ts | 10 + src/api/resources/audio/types/index.ts | 1 + src/api/resources/index.ts | 2 + src/api/resources/models/client/Client.ts | 117 ++++ src/api/resources/models/client/index.ts | 1 + src/api/resources/models/exports.ts | 4 + src/api/resources/models/index.ts | 1 + src/api/resources/voices/client/Client.ts | 63 +- .../client/requests/CreateVoicesRequest.ts | 22 +- .../client/requests/ListVoicesRequest.ts | 25 +- src/api/resources/voices/exports.ts | 1 + src/api/resources/voices/index.ts | 2 + .../consentChallenges/client/Client.ts | 149 +++++ .../consentChallenges/client/index.ts | 1 + .../requests/CreateConsentChallengeRequest.ts | 34 ++ .../client/requests/index.ts | 1 + .../resources/consentChallenges/exports.ts | 4 + .../resources/consentChallenges/index.ts | 1 + src/api/resources/voices/resources/index.ts | 2 + .../voices/types/ListVoicesRequestGender.ts | 8 + .../voices/types/ListVoicesRequestType.ts | 7 + src/api/resources/voices/types/index.ts | 2 + src/api/types/ConsentChallenge.ts | 23 + src/api/types/ErrorCode.ts | 18 + src/api/types/ErrorDetail.ts | 7 + src/api/types/Error_.ts | 4 +- src/api/types/GetStreamRequest.ts | 46 ++ src/api/types/Model.ts | 66 ++ src/api/types/ModelsResponse.ts | 20 + src/api/types/index.ts | 4 + src/core/index.ts | 1 + src/core/stream/Stream.ts | 235 ++++++++ src/core/stream/index.ts | 1 + src/version.ts | 2 +- tests/unit/stream/Stream.test.ts | 563 ++++++++++++++++++ tests/wire/audio.test.ts | 316 +++++++++- tests/wire/models.test.ts | 183 ++++++ tests/wire/voices.test.ts | 5 +- tests/wire/voices/consentChallenges.test.ts | 282 +++++++++ 55 files changed, 2771 insertions(+), 286 deletions(-) delete mode 100644 .github/workflows/manual-publish.yml delete mode 100644 AGENTS.md create mode 100644 src/api/errors/ContentTooLargeError.ts delete mode 100644 src/api/resources/audio/client/requests/GetStreamRequest.ts create mode 100644 src/api/resources/audio/client/requests/StreamAudioRequest.ts create mode 100644 src/api/resources/audio/client/requests/StreamWithTimestampsAudioRequest.ts create mode 100644 src/api/resources/audio/types/StreamWithTimestampsAudioRequestAccept.ts create mode 100644 src/api/resources/models/client/Client.ts create mode 100644 src/api/resources/models/client/index.ts create mode 100644 src/api/resources/models/exports.ts create mode 100644 src/api/resources/models/index.ts create mode 100644 src/api/resources/voices/resources/consentChallenges/client/Client.ts create mode 100644 src/api/resources/voices/resources/consentChallenges/client/index.ts create mode 100644 src/api/resources/voices/resources/consentChallenges/client/requests/CreateConsentChallengeRequest.ts create mode 100644 src/api/resources/voices/resources/consentChallenges/client/requests/index.ts create mode 100644 src/api/resources/voices/resources/consentChallenges/exports.ts create mode 100644 src/api/resources/voices/resources/consentChallenges/index.ts create mode 100644 src/api/resources/voices/resources/index.ts create mode 100644 src/api/resources/voices/types/ListVoicesRequestGender.ts create mode 100644 src/api/resources/voices/types/ListVoicesRequestType.ts create mode 100644 src/api/resources/voices/types/index.ts create mode 100644 src/api/types/ConsentChallenge.ts create mode 100644 src/api/types/GetStreamRequest.ts create mode 100644 src/api/types/Model.ts create mode 100644 src/api/types/ModelsResponse.ts create mode 100644 src/core/stream/Stream.ts create mode 100644 src/core/stream/index.ts create mode 100644 tests/unit/stream/Stream.test.ts create mode 100644 tests/wire/models.test.ts create mode 100644 tests/wire/voices/consentChallenges.test.ts diff --git a/.fern/metadata.json b/.fern/metadata.json index 78d1f36..7159737 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,9 +7,9 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "9f50f669dbb3d0db633a6d904db8a5077b94764f", - "originGitCommitIsDirty": true, + "originGitCommit": "10a6db0bf8fe7358d20861af81ae01b0c177f5f0", + "originGitCommitIsDirty": false, "invokedBy": "ci", - "ciProvider": "unknown", - "sdkVersion": "2.0.1" + "ciProvider": "github", + "sdkVersion": "3.0.2" } diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml deleted file mode 100644 index 61685b4..0000000 --- a/.github/workflows/manual-publish.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: manual-publish - -# Manual, one-off publisher for an already-tagged release whose automatic -# publish did not run (or failed). Checks out an explicit tag/ref, asserts -# package.json version + repository.url, then publishes to npm with provenance. -on: - workflow_dispatch: - inputs: - ref: - description: "Git tag/ref to publish (e.g. 3.0.0)" - required: true - default: "3.0.0" - expected_version: - description: "Version package.json MUST declare" - required: true - default: "3.0.0" - -jobs: - publish: - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - steps: - - name: Checkout ref - uses: actions/checkout@v4 - with: - ref: ${{ inputs.ref }} - - name: Set up node - uses: actions/setup-node@v4 - with: - node-version: "24" - registry-url: "https://registry.npmjs.org" - - name: Enable corepack - run: corepack enable - - name: Assert version + repository casing - run: | - node -e 'const p=require("./package.json"); if (p.version !== "${{ inputs.expected_version }}") throw new Error("version "+p.version); if (p.repository.url !== "git+https://github.com/SpeechifyInc/speechify-api-sdk-typescript.git") throw new Error("repository.url "+p.repository.url)' - - name: Install dependencies - run: pnpm install --frozen-lockfile - - name: Build - run: pnpm build - - name: Publish to npm - run: npm publish --provenance --access public diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 11c5a9f..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,101 +0,0 @@ -# Agent instructions — @speechify/api (TypeScript SDK) - -This is a **Fern-generated** SDK. It is published to **npm** as `@speechify/api`. -The source of truth for the API surface lives in the `SpeechifyInc/speechify-api` -repo (`fern/`); this repo receives generated code on the `sdk-release` branch. - -**Read this before doing anything that touches a release.** A botched release here -publishes to a public registry and breaks the contract with every customer who -installs the package. This file exists because we already did that once — see -"Postmortem" below. - -## The golden rule - -**Publishing to npm is effectively IRREVERSIBLE.** Unpublish is restricted to a -72-hour window and you can never republish the same version number. Treat every -publish as permanent. - -Never trigger a publish until you have, in order: - -1. **Audited the release plumbing** (this file, the workflow, the config, npm auth). -2. **Confirmed the version is correct in EVERY version-bearing file** (see checklist). -3. **Dry-run built and asserted the artifact version** (`npm publish --dry-run`). -4. **Got explicit human sign-off** for the publish itself. - -Do NOT admin-merge release PRs to force a publish. Do NOT bypass required reviews. - -## Version-surface checklist — the mistake we keep making - -The #1 failure mode is **a generated version string not getting bumped**, so the -package publishes under the wrong number (or reports a false version to the API). -A single stale string is a published contract break, not a typo. - -When any version changes, **confirm ALL of these agree** before release. Never -check one and assume the rest: - -- `.release-please-manifest.json` → `"."` -- `package.json` → `version` -- `src/version.ts` → `SDK_VERSION` (sent as the SDK version header on requests) -- the git tag created for the release -- the version in the built tarball (`npm publish --dry-run` prints `version:`) - -Fast audit: - -```bash -grep -rnE '"version"|SDK_VERSION|"\."' \ - .release-please-manifest.json package.json src/version.ts -``` - -`SDK_VERSION` is reported to the API. If it lies, telemetry, version-gating, and -support debugging are all wrong for that release. (In the 3.0.0 release, the -published tarball shipped `SDK_VERSION = "2.0.1"` — a live contract break.) - -## Known plumbing traps - -- **npm provenance requires case-exact `repository.url`.** `npm publish - --provenance` fails with **HTTP 422** unless `package.json` `repository.url` - matches the GitHub repo slug case-sensitively — it is `SpeechifyInc`, not - `speechifyinc`. The casing is set by the generator config in - `SpeechifyInc/speechify-api` (`fern/generators.yml`); fix it there so regens - carry it. -- **npm auth must exist for the publish to succeed.** The publish uses - `--provenance` with `id-token: write`. That signs provenance via OIDC but does - **NOT** by itself authenticate the upload — you still need EITHER a configured - **npm Trusted Publisher (OIDC)** for `@speechify/api` linked to this repo + - workflow, OR an `NPM_TOKEN` secret wired as `NODE_AUTH_TOKEN`. Without one, the - publish fails with **HTTP 404 "no permission"**. Verify auth BEFORE relying on - the automatic publish. -- **`type: "generic"` extra-files no-op silently** unless the target line carries - an `x-release-please-version` marker comment. `src/version.ts` is bumped this - way — confirm it actually changed after a release, or fix it manually. -- **npm account 2FA is a passkey.** The CLI `--otp=` flow only accepts TOTP, not - passkeys, so a terminal `npm publish` prompts `EOTP` and cannot proceed with a - passkey. Use `npm login --auth-type=web` (browser passkey) for a local publish, - or an automation token / Trusted Publisher for CI. - -## If a release has already gone wrong - -- **Wrong version on npm:** treat as permanent. Fix the version wiring, cut a NEW - correct version. Do not count on unpublish. -- **Tag points at a bad commit:** recreate the tag/GitHub release on the corrected - commit (public history mutation — back up the old SHA + notes first). Publishing - from a stale tag re-ships the old bug (e.g. the lowercase `repository.url`). -- There is a `manual-publish.yml` workflow that checks out an explicit tag and - asserts `version` + `repository.url` before publishing — prefer it for one-off - republishes. - -## Postmortem — the 3.0.0 release (why this file exists) - -A routine regeneration was pushed straight to publish. Failures, in order: - -1. Breaking-change PRs were admin-merged past the review gate. -2. The automatic publish failed provenance (HTTP 422) because `repository.url` was - lowercase `speechifyinc` — npm was left at 2.0.0 while the tag said 3.0.0. -3. After fixing casing, publish failed again (HTTP 404) — no npm auth was - configured (no `NPM_TOKEN`, no Trusted Publisher). -4. When it finally published, the tarball still shipped `SDK_VERSION = "2.0.1"` - because the generic updater had silently no-oped — found reactively, late. - -Lesson: **audit the whole release surface first (versions AND auth AND -provenance), dry-run, get sign-off, then publish.** Treat one stale version -string as a signal to check every other one. diff --git a/README.md b/README.md index 00c54a4..92bfda0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Speechify TypeScript Library -[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=https%3A%2F%2Fgithub.com%2Fspeechifyinc%2Fspeechify-api-sdk-typescript) +[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=https%3A%2F%2Fgithub.com%2Fspeechify-ai%2Fsdk-typescript) [![npm shield](https://img.shields.io/npm/v/@speechify/api)](https://www.npmjs.com/package/@speechify/api) The Speechify TypeScript library provides convenient access to the Speechify APIs from TypeScript. @@ -14,6 +14,7 @@ The Speechify TypeScript library provides convenient access to the Speechify API - [Environments](#environments) - [Request and Response Types](#request-and-response-types) - [Exception Handling](#exception-handling) +- [Streaming Response](#streaming-response) - [File Uploads](#file-uploads) - [Binary Response](#binary-response) - [Pagination](#pagination) @@ -42,7 +43,7 @@ npm i -s @speechify/api ## Reference -A full reference for this library is available [here](https://github.com/speechifyinc/speechify-api-sdk-typescript/blob/HEAD/./reference.md). +A full reference for this library is available [here](https://github.com/speechify-ai/sdk-typescript/blob/HEAD/./reference.md). ## Usage @@ -51,12 +52,12 @@ Instantiate and use the client with the following: ```typescript import { SpeechifyClient } from "@speechify/api"; -const client = new SpeechifyClient({ token: "YOUR_TOKEN", version: "2026-07-07" }); +const client = new SpeechifyClient({ token: "YOUR_TOKEN", version: "2026-09-13" }); await client.audio.speech({ audio_format: "mp3", input: "Hello! This is the Speechify text-to-speech API.", - model: "simba-english", - voice_id: "george" + model: "simba-3.2", + voice_id: "geffen_32" }); ``` @@ -105,6 +106,27 @@ try { } ``` +## Streaming Response + +Some endpoints return streaming responses instead of returning the full response at once. +The SDK uses async iterators, so you can consume the responses using a `for await...of` loop. + +```typescript +import { SpeechifyClient } from "@speechify/api"; + +const client = new SpeechifyClient({ token: "YOUR_TOKEN", version: "2026-09-13" }); +const response = await client.audio.streamWithTimestamps({ + body: { + input: "Streaming long-form audio with the Speechify API.", + model: "simba-3.2", + voice_id: "geffen_32" + } +}); +for await (const item of response) { + console.log(item); +} +``` + ## File Uploads You can upload files using the client: @@ -114,13 +136,14 @@ import { createReadStream } from "fs"; import * as fs from "fs"; import { SpeechifyClient } from "@speechify/api"; -const client = new SpeechifyClient({ token: "YOUR_TOKEN", version: "2026-07-07" }); +const client = new SpeechifyClient({ token: "YOUR_TOKEN", version: "2026-09-13" }); await client.voices.create({ sample: fs.createReadStream("/path/to/your/file"), + consent_recording: fs.createReadStream("/path/to/your/file"), "Idempotency-Key": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", name: "name", gender: "male", - consent: "consent" + consent_challenge_id: "consent_challenge_id" }); ``` The client accepts a variety of types for file upload parameters: @@ -549,14 +572,20 @@ List endpoints are paginated. The SDK provides an iterator so that you can simpl ```typescript import { SpeechifyClient } from "@speechify/api"; -const client = new SpeechifyClient({ token: "YOUR_TOKEN", version: "2026-07-07" }); -const pageableResponse = await client.voices.list(); +const client = new SpeechifyClient({ token: "YOUR_TOKEN", version: "2026-09-13" }); +const pageableResponse = await client.voices.list({ + locale: "en", + model: "simba-3.2" +}); for await (const item of pageableResponse) { console.log(item); } // Or you can manually iterate page-by-page -let page = await client.voices.list(); +let page = await client.voices.list({ + locale: "en", + model: "simba-3.2" +}); while (page.hasNextPage()) { page = page.getNextPage(); } diff --git a/package.json b/package.json index 2ef7387..7b12553 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "@speechify/api", - "version": "3.0.1", + "version": "3.0.2", "private": false, "repository": { "type": "git", - "url": "git+https://github.com/SpeechifyInc/speechify-api-sdk-typescript.git" + "url": "git+https://github.com/speechify-ai/sdk-typescript.git" }, "type": "commonjs", "main": "./dist/cjs/index.js", @@ -33,6 +33,17 @@ }, "default": "./dist/cjs/api/resources/audio/exports.js" }, + "./models": { + "import": { + "types": "./dist/esm/api/resources/models/exports.d.mts", + "default": "./dist/esm/api/resources/models/exports.mjs" + }, + "require": { + "types": "./dist/cjs/api/resources/models/exports.d.ts", + "default": "./dist/cjs/api/resources/models/exports.js" + }, + "default": "./dist/cjs/api/resources/models/exports.js" + }, "./voices": { "import": { "types": "./dist/esm/api/resources/voices/exports.d.mts", @@ -44,6 +55,17 @@ }, "default": "./dist/cjs/api/resources/voices/exports.js" }, + "./voices/consentChallenges": { + "import": { + "types": "./dist/esm/api/resources/voices/resources/consentChallenges/exports.d.mts", + "default": "./dist/esm/api/resources/voices/resources/consentChallenges/exports.mjs" + }, + "require": { + "types": "./dist/cjs/api/resources/voices/resources/consentChallenges/exports.d.ts", + "default": "./dist/cjs/api/resources/voices/resources/consentChallenges/exports.js" + }, + "default": "./dist/cjs/api/resources/voices/resources/consentChallenges/exports.js" + }, "./package.json": "./package.json" }, "files": [ diff --git a/reference.md b/reference.md index 8ff4747..28c6a5b 100644 --- a/reference.md +++ b/reference.md @@ -34,8 +34,8 @@ Set `output_format` for explicit sample-rate/bitrate control (e.g. await client.audio.speech({ audio_format: "mp3", input: "Hello! This is the Speechify text-to-speech API.", - model: "simba-english", - voice_id: "george" + model: "simba-3.2", + voice_id: "geffen_32" }); ``` @@ -106,8 +106,10 @@ POST /v1/audio/speech. ```typescript await client.audio.stream({ - input: "input", - voice_id: "voice_id" + body: { + input: "input", + voice_id: "voice_id" + } }); ``` @@ -124,7 +126,7 @@ await client.audio.stream({
-**request:** `Speechify.GetStreamRequest` +**request:** `Speechify.StreamAudioRequest`
@@ -140,6 +142,166 @@ await client.audio.stream({ + + + + +
client.audio.streamWithTimestamps({ ...params }) -> core.Stream<string> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Synthesize speech and stream it back together with word-level speech +marks, for text highlighting, captions and audio-text synchronization +while the audio is still arriving. + +The response is a Server-Sent Events stream. Each `speech.chunk` event +carries a Base64-encoded run of audio, the speech marks that became +final with it, or both - a chunk may carry only one of the two, and the +last chunk of a stream is often marks-only. A terminal `speech.done` +event ends the stream; there is no `[DONE]` sentinel. Ignore any event +type you do not recognize, so that new event types do not break your +integration. + +Speech-mark times are absolute milliseconds from the start of the +synthesis, so concatenate the audio chunks into one stream and apply the +marks against that single timeline. Which chunk a mark arrives on is a +delivery detail and carries no meaning. Times stay correct for every +`output_format`: changing the codec or sample rate does not change the +duration. + +Speech marks are produced by the streaming-native models. The default +`simba-3.0` and `simba-3.2` both serve this route; the legacy +`simba-english` and `simba-multilingual` models return 400 +`speech_marks_unsupported` here. +For Base64-encoded audio and speech marks in one non-streamed JSON +response, on any model, use POST /v1/audio/speech. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +const response = await client.audio.streamWithTimestamps({ + body: { + input: "Streaming long-form audio with the Speechify API.", + model: "simba-3.2", + voice_id: "geffen_32" + } +}); +for await (const item of response) { + console.log(item); +} + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `Speechify.StreamWithTimestampsAudioRequest` + +
+
+ +
+
+ +**requestOptions:** `AudioClient.RequestOptions` + +
+
+
+
+ + +
+
+
+ +## models +
client.models.list() -> Speechify.ModelsResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List the text-to-speech models available for synthesis. Drive a model +picker from this response, then pass a model `id` as the `model` +parameter to POST /v1/audio/speech or /v1/audio/stream. The response +marks the default model (used when a request omits `model`), the +routes each model may be passed to, and which voices it accepts. +Multi-speaker models arrive in a separate `dialogue_models` array +because they are valid only on POST /v1/audio/dialogue. Returns +the full set in a single response: the model catalog is static +platform reference data, so it is intentionally not paginated. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.models.list(); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**requestOptions:** `ModelsClient.RequestOptions` + +
+
+
+
+ +
@@ -158,11 +320,13 @@ await client.audio.stream({
Lists the voices available to the caller - the shared voice -catalog plus the workspace's personal cloned voices. By default +catalog plus the workspace's cloned voices, whichever member or +service-account key created them. By default the full catalogue is returned in one response. Pagination is opt-in: pass `limit` (and then `cursor` from the previous response) to page through the list while `has_more` is true. Max -page size is 200. +page size is 200. Narrow the list with the `type` and `locale` +filters (applied before pagination, so pages stay full).
@@ -177,13 +341,19 @@ page size is 200.
```typescript -const pageableResponse = await client.voices.list(); +const pageableResponse = await client.voices.list({ + locale: "en", + model: "simba-3.2" +}); for await (const item of pageableResponse) { console.log(item); } // Or you can manually iterate page-by-page -let page = await client.voices.list(); +let page = await client.voices.list({ + locale: "en", + model: "simba-3.2" +}); while (page.hasNextPage()) { page = page.getNextPage(); } @@ -237,7 +407,13 @@ const response = page.response;
-Create a personal (cloned) voice for the user +Create a cloned voice for the workspace from a 10-30 second audio sample, with verified consent from the speaker. + +Cloning requires proof that the speaker agreed to it. Create a consent challenge with `POST /v1/voices/consent-challenges`, show the returned `phrase` to the speaker, record them reading it aloud, and send that recording here as `consent_recording` together with the challenge's `consent_challenge_id`. Speechify transcribes the recording, checks it against the phrase it issued, and keeps it as the consent record for the voice. A challenge is single use and short-lived, so record and submit in one sitting. + +The clone belongs to the workspace rather than the member who created it, and access follows the caller's workspace role and API-key scopes exactly as for any other voice: voices scopes to list it, audio scopes to synthesize with it, and the content-management permission plus a write scope on the key to delete it. Cloned voices are usable self-serve on `simba-3.0`, `simba-english` and `simba-multilingual`. `simba-3.2` also serves cloned voices, currently as a limited release enabled per workspace; contact Speechify to have it enabled for yours. + +Callers pinned before `Speechify-Version: 2026-09-13` use the previous flow instead: no challenge, and a `consent` form field carrying the speaker's name and email as a JSON string. That flow is deprecated and will be removed after a sunset window announced in the changelog.
@@ -254,10 +430,11 @@ Create a personal (cloned) voice for the user ```typescript await client.voices.create({ sample: fs.createReadStream("/path/to/your/file"), + consent_recording: fs.createReadStream("/path/to/your/file"), "Idempotency-Key": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", name: "name", gender: "male", - consent: "consent" + consent_challenge_id: "consent_challenge_id" }); ``` @@ -307,9 +484,9 @@ await client.voices.create({
Fetch a single voice by id - a shared catalogue voice or one of -the caller's own personal (cloned) voices. A personal voice that -belongs to another workspace returns 404, identical to an -unknown id, so voice inventory is never enumerable across tenants. +the workspace's cloned voices. A cloned voice that belongs to +another workspace returns 404, identical to an unknown id, so +voice inventory is never enumerable across tenants.
@@ -374,7 +551,9 @@ await client.voices.get({
-Delete a personal (cloned) voice +Delete one of the workspace's cloned voices. Requires the +`content.manage` permission (owner, admin, or member); a +service-account key is authorized by its scopes instead.
@@ -492,3 +671,76 @@ await client.voices.downloadSample({ +## Voices ConsentChallenges +
client.voices.consentChallenges.create({ ...params }) -> Speechify.ConsentChallenge +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Start the consent check for a voice clone. + +Returns a `phrase` for the speaker to read aloud and an `id` that identifies this challenge. Show the phrase to the speaker exactly as returned, record them reading it, then send the recording and the `id` to `POST /v1/voices`, which verifies the recording against the phrase and keeps it as the consent record. + +A challenge is single use, is bound to the workspace that created it, and expires at `expires_at` - it is proof that a speaker was in front of a microphone just now, so create it when you are ready to record, not at the start of your flow. If it expires, create another one and record again. + +Challenge creation is rate limited per workspace at a few dozen per hour, far more tightly than the rest of the voice surface, because each one precedes a person recording themselves - mint it when your speaker is ready, not speculatively. Read the live ceiling off `RateLimit-*` rather than hard-coding it. **On a `429`, always honour `Retry-After` rather than a fixed backoff of your own**: the wait is measured in minutes and can run to most of an hour. `RateLimit-*` are omitted rather than reporting a bucket that is not the one refusing. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.voices.consentChallenges.create({ + "Idempotency-Key": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + full_name: "Jane Doe" +}); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `Speechify.voices.CreateConsentChallengeRequest` + +
+
+ +
+
+ +**requestOptions:** `ConsentChallengesClient.RequestOptions` + +
+
+
+
+ + +
+
+
+ diff --git a/src/BaseClient.ts b/src/BaseClient.ts index d76ebe7..2d206f1 100644 --- a/src/BaseClient.ts +++ b/src/BaseClient.ts @@ -63,11 +63,11 @@ export function normalizeClientOptions; protected _audio: AudioClient | undefined; + protected _models: ModelsClient | undefined; protected _voices: VoicesClient | undefined; constructor(options: SpeechifyClient.Options = {}) { @@ -25,6 +27,10 @@ export class SpeechifyClient { return (this._audio ??= new AudioClient(this._options)); } + public get models(): ModelsClient { + return (this._models ??= new ModelsClient(this._options)); + } + public get voices(): VoicesClient { return (this._voices ??= new VoicesClient(this._options)); } diff --git a/src/api/errors/ContentTooLargeError.ts b/src/api/errors/ContentTooLargeError.ts new file mode 100644 index 0000000..2ebbf8e --- /dev/null +++ b/src/api/errors/ContentTooLargeError.ts @@ -0,0 +1,22 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as core from "../../core/index.js"; +import * as errors from "../../errors/index.js"; +import type * as Speechify from "../index.js"; + +export class ContentTooLargeError extends errors.SpeechifyError { + constructor(body: Speechify.Error_, rawResponse?: core.RawResponse) { + super({ + message: "ContentTooLargeError", + statusCode: 413, + body: body, + rawResponse: rawResponse, + }); + Object.setPrototypeOf(this, new.target.prototype); + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + + this.name = this.constructor.name; + } +} diff --git a/src/api/errors/index.ts b/src/api/errors/index.ts index 8ab0208..efeb100 100644 --- a/src/api/errors/index.ts +++ b/src/api/errors/index.ts @@ -1,6 +1,7 @@ export * from "./BadGatewayError.js"; export * from "./BadRequestError.js"; export * from "./ConflictError.js"; +export * from "./ContentTooLargeError.js"; export * from "./ForbiddenError.js"; export * from "./InternalServerError.js"; export * from "./NotFoundError.js"; diff --git a/src/api/resources/audio/client/Client.ts b/src/api/resources/audio/client/Client.ts index 054eb4d..053283c 100644 --- a/src/api/resources/audio/client/Client.ts +++ b/src/api/resources/audio/client/Client.ts @@ -51,8 +51,8 @@ export class AudioClient { * await client.audio.speech({ * audio_format: "mp3", * input: "Hello! This is the Speechify text-to-speech API.", - * model: "simba-english", - * voice_id: "george" + * model: "simba-3.2", + * voice_id: "geffen_32" * }) */ public speech( @@ -71,7 +71,7 @@ export class AudioClient { _authRequest.headers, this._options?.headers, mergeOnlyDefinedHeaders({ - "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-07-07", + "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-09-13", }), requestOptions?.headers, ); @@ -165,24 +165,24 @@ export class AudioClient { * @throws {@link Speechify.ServiceUnavailableError} */ public stream( - request: Speechify.GetStreamRequest, + request: Speechify.StreamAudioRequest, requestOptions?: AudioClient.RequestOptions, ): core.HttpResponsePromise { return core.HttpResponsePromise.fromPromise(this.__stream(request, requestOptions)); } private async __stream( - request: Speechify.GetStreamRequest, + request: Speechify.StreamAudioRequest, requestOptions?: AudioClient.RequestOptions, ): Promise> { - const { Accept: accept, ..._body } = request; + const { Accept: accept, body: _body } = request; const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); const _headers: core.Fetcher.Args["headers"] = mergeHeaders( _authRequest.headers, this._options?.headers, mergeOnlyDefinedHeaders({ Accept: accept, - "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-07-07", + "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-09-13", }), requestOptions?.headers, ); @@ -256,4 +256,139 @@ export class AudioClient { return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/v1/audio/stream"); } + + /** + * Synthesize speech and stream it back together with word-level speech + * marks, for text highlighting, captions and audio-text synchronization + * while the audio is still arriving. + * + * The response is a Server-Sent Events stream. Each `speech.chunk` event + * carries a Base64-encoded run of audio, the speech marks that became + * final with it, or both - a chunk may carry only one of the two, and the + * last chunk of a stream is often marks-only. A terminal `speech.done` + * event ends the stream; there is no `[DONE]` sentinel. Ignore any event + * type you do not recognize, so that new event types do not break your + * integration. + * + * Speech-mark times are absolute milliseconds from the start of the + * synthesis, so concatenate the audio chunks into one stream and apply the + * marks against that single timeline. Which chunk a mark arrives on is a + * delivery detail and carries no meaning. Times stay correct for every + * `output_format`: changing the codec or sample rate does not change the + * duration. + * + * Speech marks are produced by the streaming-native models. The default + * `simba-3.0` and `simba-3.2` both serve this route; the legacy + * `simba-english` and `simba-multilingual` models return 400 + * `speech_marks_unsupported` here. + * For Base64-encoded audio and speech marks in one non-streamed JSON + * response, on any model, use POST /v1/audio/speech. + */ + public streamWithTimestamps( + request: Speechify.StreamWithTimestampsAudioRequest, + requestOptions?: AudioClient.RequestOptions, + ): core.HttpResponsePromise> { + return core.HttpResponsePromise.fromPromise(this.__streamWithTimestamps(request, requestOptions)); + } + + private async __streamWithTimestamps( + request: Speechify.StreamWithTimestampsAudioRequest, + requestOptions?: AudioClient.RequestOptions, + ): Promise>> { + const { Accept: accept, body: _body } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + mergeOnlyDefinedHeaders({ + Accept: accept, + "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-09-13", + }), + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.SpeechifyEnvironment.Default, + "v1/audio/stream/with-timestamps", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryString: core.url.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(), + requestType: "json", + body: _body, + responseType: "sse", + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: new core.Stream({ + stream: _response.body, + parse: (data) => data as any, + signal: requestOptions?.abortSignal, + eventShape: { + type: "sse", + }, + }), + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 400: + throw new Speechify.BadRequestError(_response.error.body as unknown, _response.rawResponse); + case 401: + throw new Speechify.UnauthorizedError(_response.error.body as unknown, _response.rawResponse); + case 402: + throw new Speechify.PaymentRequiredError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); + case 403: + throw new Speechify.ForbiddenError(_response.error.body as Speechify.Error_, _response.rawResponse); + case 404: + throw new Speechify.NotFoundError(_response.error.body as unknown, _response.rawResponse); + case 429: + throw new Speechify.TooManyRequestsError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); + case 500: + throw new Speechify.InternalServerError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); + case 502: + throw new Speechify.BadGatewayError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); + case 503: + throw new Speechify.ServiceUnavailableError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); + default: + throw new errors.SpeechifyError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/v1/audio/stream/with-timestamps", + ); + } } diff --git a/src/api/resources/audio/client/requests/GetSpeechRequest.ts b/src/api/resources/audio/client/requests/GetSpeechRequest.ts index ae3594e..8f0f9fa 100644 --- a/src/api/resources/audio/client/requests/GetSpeechRequest.ts +++ b/src/api/resources/audio/client/requests/GetSpeechRequest.ts @@ -7,8 +7,8 @@ import type * as Speechify from "../../../../index.js"; * { * audio_format: "mp3", * input: "Hello! This is the Speechify text-to-speech API.", - * model: "simba-english", - * voice_id: "george" + * model: "simba-3.2", + * voice_id: "geffen_32" * } */ export interface GetSpeechRequest { @@ -25,7 +25,7 @@ export interface GetSpeechRequest { * Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. */ language?: string; - /** Model used for audio synthesis. `simba-english` is optimized for English, `simba-multilingual` for non-English or mixed input. `simba-3.2` is the streaming-native model with lower TTFB and richer expressivity, and the recommended Simba 3 model. `simba-3.0` is the earlier Simba 3.0 model, still available. `simba-3.0` and `simba-3.2` are currently English only; multilingual coming soon, and non-English voices return 400 until it ships. */ + /** Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. */ model?: GetSpeechRequest.Model; options?: Speechify.GetSpeechOptionsRequest; /** The output audio format as a `codec_sampleRate_bitrate` string. Takes precedence over `audio_format` when set. */ @@ -44,19 +44,19 @@ export namespace GetSpeechRequest { Pcm: "pcm", } as const; export type AudioFormat = (typeof AudioFormat)[keyof typeof AudioFormat]; - /** Model used for audio synthesis. `simba-english` is optimized for English, `simba-multilingual` for non-English or mixed input. `simba-3.2` is the streaming-native model with lower TTFB and richer expressivity, and the recommended Simba 3 model. `simba-3.0` is the earlier Simba 3.0 model, still available. `simba-3.0` and `simba-3.2` are currently English only; multilingual coming soon, and non-English voices return 400 until it ships. */ + /** Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. */ export const Model = { /** - * simba-english is optimized for English words. */ + * simba-english is the legacy Simba 1.6 English model. It accepts cloned voices self-serve; prefer simba-3.2 for new integrations. */ SimbaEnglish: "simba-english", /** - * simba-multilingual is optimized for non-English words or mixed languages. */ + * simba-multilingual is the legacy Simba 1.6 multilingual model, covering the full 30+ locale set and mixed-language input. Prefer simba-3.0 for the languages it supports. */ SimbaMultilingual: "simba-multilingual", /** - * simba-3.0 is the earlier Simba 3.0 model, still available. Prefer simba-3.2 for the latest quality. Currently English only; non-English voices return 400. */ + * simba-3.0 is the streaming-native multilingual model, and the default when `model` is omitted. Officially supports English plus de-DE, es-ES, es-MX, fr-FR, it-IT and pt-BR; the request language (or the voice's locale when it is omitted) selects the English or the multilingual training. Prefer simba-3.2 for English-only integrations. Cloned/personal voices work self-serve on simba-3.0. */ Simba30: "simba-3.0", /** - * simba-3.2 is the streaming-native model with lower TTFB and richer expressivity. Currently English only; multilingual coming soon. Non-English voices return 400 until multilingual support ships. */ + * simba-3.2 is the streaming-native model with the lowest TTFB and richest expressivity. English only; a non-English voice returns 400 - use simba-3.0 for the other supported languages. Cloned voices are supported on simba-3.2 alongside the curated stock roster, currently as a limited release enabled per workspace - contact Speechify to have it enabled for yours. */ Simba32: "simba-3.2", } as const; export type Model = (typeof Model)[keyof typeof Model]; diff --git a/src/api/resources/audio/client/requests/GetStreamRequest.ts b/src/api/resources/audio/client/requests/GetStreamRequest.ts deleted file mode 100644 index ef84084..0000000 --- a/src/api/resources/audio/client/requests/GetStreamRequest.ts +++ /dev/null @@ -1,60 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -import type * as Speechify from "../../../../index.js"; - -/** - * @example - * { - * input: "input", - * voice_id: "voice_id" - * } - */ -export interface GetStreamRequest { - /** - * Selects the audio container/codec for the streamed response when - * `output_format` is not set in the request body. The response - * Content-Type echoes this value, except `audio/pcm` returns - * `audio/L16` with rate and channels parameters (raw 16-bit linear - * PCM, 24 kHz mono, little-endian). For explicit sample-rate/bitrate - * control (e.g. `pcm_16000`, `ulaw_8000`), set `output_format` in the - * body instead; it takes precedence over this header. - */ - Accept?: Speechify.StreamAudioRequestAccept; - /** - * Plain text or SSML to be synthesized to speech. - * Refer to https://docs.speechify.ai/docs/api-limits for the input size limits. - * Emotion, Pitch and Speed Rate are configured in the ssml input, please refer to the ssml documentation for more information: https://docs.speechify.ai/docs/ssml#prosody - */ - input: string; - /** - * Language of the input. Follow the format of an ISO 639-1 language code and an ISO 3166-1 region code, separated by a hyphen, e.g. en-US. - * Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. - */ - language?: string; - /** Model used for audio synthesis. `simba-english` is optimized for English, `simba-multilingual` for non-English or mixed input. `simba-3.2` is the streaming-native model with lower TTFB and richer expressivity, and the recommended Simba 3 model. `simba-3.0` is the earlier Simba 3.0 model, still available. `simba-3.0` and `simba-3.2` are currently English only; multilingual coming soon, and non-English voices return 400 until it ships. */ - model?: GetStreamRequest.Model; - options?: Speechify.GetStreamOptionsRequest; - /** The output audio format as a `codec_sampleRate_bitrate` string. Takes precedence over the `Accept` header when set, so you can request formats the `Accept` enum does not cover (e.g. `pcm_16000`, `ulaw_8000`). `wav_*` formats are not supported on streaming - use `POST /v1/audio/speech` for wav. */ - output_format?: Speechify.AudioStreamOutputFormat; - /** Id of the voice to be used for synthesizing speech. Refer to /v1/voices endpoint for available voices */ - voice_id: string; -} - -export namespace GetStreamRequest { - /** Model used for audio synthesis. `simba-english` is optimized for English, `simba-multilingual` for non-English or mixed input. `simba-3.2` is the streaming-native model with lower TTFB and richer expressivity, and the recommended Simba 3 model. `simba-3.0` is the earlier Simba 3.0 model, still available. `simba-3.0` and `simba-3.2` are currently English only; multilingual coming soon, and non-English voices return 400 until it ships. */ - export const Model = { - /** - * simba-english is optimized for English words. */ - SimbaEnglish: "simba-english", - /** - * simba-multilingual is optimized for non-English words or mixed languages. */ - SimbaMultilingual: "simba-multilingual", - /** - * simba-3.0 is the earlier Simba 3.0 model, still available. Prefer simba-3.2 for the latest quality. Currently English only; non-English voices return 400. */ - Simba30: "simba-3.0", - /** - * simba-3.2 is the streaming-native model with lower TTFB and richer expressivity. Currently English only; multilingual coming soon. Non-English voices return 400 until multilingual support ships. */ - Simba32: "simba-3.2", - } as const; - export type Model = (typeof Model)[keyof typeof Model]; -} diff --git a/src/api/resources/audio/client/requests/StreamAudioRequest.ts b/src/api/resources/audio/client/requests/StreamAudioRequest.ts new file mode 100644 index 0000000..e749980 --- /dev/null +++ b/src/api/resources/audio/client/requests/StreamAudioRequest.ts @@ -0,0 +1,26 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as Speechify from "../../../../index.js"; + +/** + * @example + * { + * body: { + * input: "input", + * voice_id: "voice_id" + * } + * } + */ +export interface StreamAudioRequest { + /** + * Selects the audio container/codec for the streamed response when + * `output_format` is not set in the request body. The response + * Content-Type echoes this value, except `audio/pcm` returns + * `audio/L16` with rate and channels parameters (raw 16-bit linear + * PCM, 24 kHz mono, little-endian). For explicit sample-rate/bitrate + * control (e.g. `pcm_16000`, `ulaw_8000`), set `output_format` in the + * body instead; it takes precedence over this header. + */ + Accept?: Speechify.StreamAudioRequestAccept; + body: Speechify.GetStreamRequest; +} diff --git a/src/api/resources/audio/client/requests/StreamWithTimestampsAudioRequest.ts b/src/api/resources/audio/client/requests/StreamWithTimestampsAudioRequest.ts new file mode 100644 index 0000000..d16cefa --- /dev/null +++ b/src/api/resources/audio/client/requests/StreamWithTimestampsAudioRequest.ts @@ -0,0 +1,24 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as Speechify from "../../../../index.js"; + +/** + * @example + * { + * body: { + * input: "Streaming long-form audio with the Speechify API.", + * model: "simba-3.2", + * voice_id: "geffen_32" + * } + * } + */ +export interface StreamWithTimestampsAudioRequest { + /** + * Selects the audio container/codec carried inside the events when + * `output_format` is not set in the request body. The selected media + * type is echoed on the `Speechify-Audio-Content-Type` response + * header, since the response's own Content-Type is `text/event-stream`. + */ + Accept?: Speechify.StreamWithTimestampsAudioRequestAccept; + body: Speechify.GetStreamRequest; +} diff --git a/src/api/resources/audio/client/requests/index.ts b/src/api/resources/audio/client/requests/index.ts index 1bb80ec..0e34d51 100644 --- a/src/api/resources/audio/client/requests/index.ts +++ b/src/api/resources/audio/client/requests/index.ts @@ -1,2 +1,3 @@ export { GetSpeechRequest } from "./GetSpeechRequest.js"; -export { GetStreamRequest } from "./GetStreamRequest.js"; +export type { StreamAudioRequest } from "./StreamAudioRequest.js"; +export type { StreamWithTimestampsAudioRequest } from "./StreamWithTimestampsAudioRequest.js"; diff --git a/src/api/resources/audio/types/StreamWithTimestampsAudioRequestAccept.ts b/src/api/resources/audio/types/StreamWithTimestampsAudioRequestAccept.ts new file mode 100644 index 0000000..953459c --- /dev/null +++ b/src/api/resources/audio/types/StreamWithTimestampsAudioRequestAccept.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +export const StreamWithTimestampsAudioRequestAccept = { + AudioMpeg: "audio/mpeg", + AudioOgg: "audio/ogg", + AudioAac: "audio/aac", + AudioPcm: "audio/pcm", +} as const; +export type StreamWithTimestampsAudioRequestAccept = + (typeof StreamWithTimestampsAudioRequestAccept)[keyof typeof StreamWithTimestampsAudioRequestAccept]; diff --git a/src/api/resources/audio/types/index.ts b/src/api/resources/audio/types/index.ts index 47152c8..40471d7 100644 --- a/src/api/resources/audio/types/index.ts +++ b/src/api/resources/audio/types/index.ts @@ -1 +1,2 @@ export * from "./StreamAudioRequestAccept.js"; +export * from "./StreamWithTimestampsAudioRequestAccept.js"; diff --git a/src/api/resources/index.ts b/src/api/resources/index.ts index 44d6348..98f85e0 100644 --- a/src/api/resources/index.ts +++ b/src/api/resources/index.ts @@ -1,5 +1,7 @@ export * from "./audio/client/requests/index.js"; export * as audio from "./audio/index.js"; export * from "./audio/types/index.js"; +export * as models from "./models/index.js"; export * from "./voices/client/requests/index.js"; export * as voices from "./voices/index.js"; +export * from "./voices/types/index.js"; diff --git a/src/api/resources/models/client/Client.ts b/src/api/resources/models/client/Client.ts new file mode 100644 index 0000000..18dd6f4 --- /dev/null +++ b/src/api/resources/models/client/Client.ts @@ -0,0 +1,117 @@ +// This file was auto-generated by Fern from our API Definition. + +import type { BaseClientOptions, BaseRequestOptions } from "../../../../BaseClient.js"; +import { type NormalizedClientOptionsWithAuth, normalizeClientOptionsWithAuth } from "../../../../BaseClient.js"; +import { mergeHeaders, mergeOnlyDefinedHeaders } from "../../../../core/headers.js"; +import * as core from "../../../../core/index.js"; +import * as environments from "../../../../environments.js"; +import { handleNonStatusCodeError } from "../../../../errors/handleNonStatusCodeError.js"; +import * as errors from "../../../../errors/index.js"; +import * as Speechify from "../../../index.js"; + +export declare namespace ModelsClient { + export type Options = BaseClientOptions; + + export interface RequestOptions extends BaseRequestOptions {} +} + +/** + * The catalog of selectable text-to-speech models and their metadata. + * Static platform reference data consumed when choosing a `model` for + * synthesis. + */ +export class ModelsClient { + protected readonly _options: NormalizedClientOptionsWithAuth; + + constructor(options: ModelsClient.Options = {}) { + this._options = normalizeClientOptionsWithAuth(options); + } + + /** + * List the text-to-speech models available for synthesis. Drive a model + * picker from this response, then pass a model `id` as the `model` + * parameter to POST /v1/audio/speech or /v1/audio/stream. The response + * marks the default model (used when a request omits `model`), the + * routes each model may be passed to, and which voices it accepts. + * Multi-speaker models arrive in a separate `dialogue_models` array + * because they are valid only on POST /v1/audio/dialogue. Returns + * the full set in a single response: the model catalog is static + * platform reference data, so it is intentionally not paginated. + * + * @param {ModelsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link Speechify.BadRequestError} + * @throws {@link Speechify.UnauthorizedError} + * @throws {@link Speechify.ForbiddenError} + * @throws {@link Speechify.TooManyRequestsError} + * @throws {@link Speechify.InternalServerError} + * + * @example + * await client.models.list() + */ + public list(requestOptions?: ModelsClient.RequestOptions): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__list(requestOptions)); + } + + private async __list( + requestOptions?: ModelsClient.RequestOptions, + ): Promise> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + mergeOnlyDefinedHeaders({ + "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-09-13", + }), + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.SpeechifyEnvironment.Default, + "v1/audio/models", + ), + method: "GET", + headers: _headers, + queryString: core.url.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(), + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as Speechify.ModelsResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 400: + throw new Speechify.BadRequestError(_response.error.body as unknown, _response.rawResponse); + case 401: + throw new Speechify.UnauthorizedError(_response.error.body as unknown, _response.rawResponse); + case 403: + throw new Speechify.ForbiddenError(_response.error.body as Speechify.Error_, _response.rawResponse); + case 429: + throw new Speechify.TooManyRequestsError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); + case 500: + throw new Speechify.InternalServerError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); + default: + throw new errors.SpeechifyError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/v1/audio/models"); + } +} diff --git a/src/api/resources/models/client/index.ts b/src/api/resources/models/client/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/api/resources/models/client/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/api/resources/models/exports.ts b/src/api/resources/models/exports.ts new file mode 100644 index 0000000..32c5dea --- /dev/null +++ b/src/api/resources/models/exports.ts @@ -0,0 +1,4 @@ +// This file was auto-generated by Fern from our API Definition. + +export { ModelsClient } from "./client/Client.js"; +export * from "./client/index.js"; diff --git a/src/api/resources/models/index.ts b/src/api/resources/models/index.ts new file mode 100644 index 0000000..914b8c3 --- /dev/null +++ b/src/api/resources/models/index.ts @@ -0,0 +1 @@ +export * from "./client/index.js"; diff --git a/src/api/resources/voices/client/Client.ts b/src/api/resources/voices/client/Client.ts index 98e2857..15d8350 100644 --- a/src/api/resources/voices/client/Client.ts +++ b/src/api/resources/voices/client/Client.ts @@ -8,6 +8,7 @@ import * as environments from "../../../../environments.js"; import { handleNonStatusCodeError } from "../../../../errors/handleNonStatusCodeError.js"; import * as errors from "../../../../errors/index.js"; import * as Speechify from "../../../index.js"; +import { ConsentChallengesClient } from "../resources/consentChallenges/client/Client.js"; export declare namespace VoicesClient { export type Options = BaseClientOptions; @@ -21,18 +22,25 @@ export declare namespace VoicesClient { */ export class VoicesClient { protected readonly _options: NormalizedClientOptionsWithAuth; + protected _consentChallenges: ConsentChallengesClient | undefined; constructor(options: VoicesClient.Options = {}) { this._options = normalizeClientOptionsWithAuth(options); } + public get consentChallenges(): ConsentChallengesClient { + return (this._consentChallenges ??= new ConsentChallengesClient(this._options)); + } + /** * Lists the voices available to the caller - the shared voice - * catalog plus the workspace's personal cloned voices. By default + * catalog plus the workspace's cloned voices, whichever member or + * service-account key created them. By default * the full catalogue is returned in one response. Pagination is * opt-in: pass `limit` (and then `cursor` from the previous * response) to page through the list while `has_more` is true. Max - * page size is 200. + * page size is 200. Narrow the list with the `type` and `locale` + * filters (applied before pagination, so pages stay full). * * @param {Speechify.ListVoicesRequest} request * @param {VoicesClient.RequestOptions} requestOptions - Request-specific configuration. @@ -44,7 +52,10 @@ export class VoicesClient { * @throws {@link Speechify.InternalServerError} * * @example - * await client.voices.list() + * await client.voices.list({ + * locale: "en", + * model: "simba-3.2" + * }) */ public async list( request: Speechify.ListVoicesRequest = {}, @@ -54,17 +65,21 @@ export class VoicesClient { async ( request: Speechify.ListVoicesRequest, ): Promise> => { - const { cursor, limit } = request; + const { cursor, limit, type: type_, locale, gender, model } = request; const _queryParams: Record = { cursor, limit, + type: type_ != null ? type_ : undefined, + locale, + gender: gender != null ? gender : undefined, + model, }; const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); const _headers: core.Fetcher.Args["headers"] = mergeHeaders( _authRequest.headers, this._options?.headers, mergeOnlyDefinedHeaders({ - "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-07-07", + "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-09-13", }), requestOptions?.headers, ); @@ -141,7 +156,13 @@ export class VoicesClient { } /** - * Create a personal (cloned) voice for the user + * Create a cloned voice for the workspace from a 10-30 second audio sample, with verified consent from the speaker. + * + * Cloning requires proof that the speaker agreed to it. Create a consent challenge with `POST /v1/voices/consent-challenges`, show the returned `phrase` to the speaker, record them reading it aloud, and send that recording here as `consent_recording` together with the challenge's `consent_challenge_id`. Speechify transcribes the recording, checks it against the phrase it issued, and keeps it as the consent record for the voice. A challenge is single use and short-lived, so record and submit in one sitting. + * + * The clone belongs to the workspace rather than the member who created it, and access follows the caller's workspace role and API-key scopes exactly as for any other voice: voices scopes to list it, audio scopes to synthesize with it, and the content-management permission plus a write scope on the key to delete it. Cloned voices are usable self-serve on `simba-3.0`, `simba-english` and `simba-multilingual`. `simba-3.2` also serves cloned voices, currently as a limited release enabled per workspace; contact Speechify to have it enabled for yours. + * + * Callers pinned before `Speechify-Version: 2026-09-13` use the previous flow instead: no challenge, and a `consent` form field carrying the speaker's name and email as a JSON string. That flow is deprecated and will be removed after a sunset window announced in the changelog. * * @param {Speechify.CreateVoicesRequest} request * @param {VoicesClient.RequestOptions} requestOptions - Request-specific configuration. @@ -151,6 +172,7 @@ export class VoicesClient { * @throws {@link Speechify.PaymentRequiredError} * @throws {@link Speechify.ForbiddenError} * @throws {@link Speechify.ConflictError} + * @throws {@link Speechify.ContentTooLargeError} * @throws {@link Speechify.UnprocessableEntityError} * @throws {@link Speechify.TooManyRequestsError} * @throws {@link Speechify.InternalServerError} @@ -161,10 +183,11 @@ export class VoicesClient { * import { createReadStream } from "fs"; * await client.voices.create({ * sample: fs.createReadStream("/path/to/your/file"), + * consent_recording: fs.createReadStream("/path/to/your/file"), * "Idempotency-Key": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", * name: "name", * gender: "male", - * consent: "consent" + * consent_challenge_id: "consent_challenge_id" * }) */ public create( @@ -190,7 +213,8 @@ export class VoicesClient { await _body.appendFile("avatar", request.avatar); } - _body.append("consent", request.consent); + _body.append("consent_challenge_id", request.consent_challenge_id); + await _body.appendFile("consent_recording", request.consent_recording); const _maybeEncodedRequest = await _body.getRequest(); const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); const _headers: core.Fetcher.Args["headers"] = mergeHeaders( @@ -198,7 +222,7 @@ export class VoicesClient { this._options?.headers, mergeOnlyDefinedHeaders({ "Idempotency-Key": request["Idempotency-Key"], - "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-07-07", + "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-09-13", ..._maybeEncodedRequest.headers, }), requestOptions?.headers, @@ -241,6 +265,11 @@ export class VoicesClient { throw new Speechify.ForbiddenError(_response.error.body as Speechify.Error_, _response.rawResponse); case 409: throw new Speechify.ConflictError(_response.error.body as unknown, _response.rawResponse); + case 413: + throw new Speechify.ContentTooLargeError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); case 422: throw new Speechify.UnprocessableEntityError( _response.error.body as Speechify.Error_, @@ -280,9 +309,9 @@ export class VoicesClient { /** * Fetch a single voice by id - a shared catalogue voice or one of - * the caller's own personal (cloned) voices. A personal voice that - * belongs to another workspace returns 404, identical to an - * unknown id, so voice inventory is never enumerable across tenants. + * the workspace's cloned voices. A cloned voice that belongs to + * another workspace returns 404, identical to an unknown id, so + * voice inventory is never enumerable across tenants. * * @param {Speechify.GetVoicesRequest} request * @param {VoicesClient.RequestOptions} requestOptions - Request-specific configuration. @@ -317,7 +346,7 @@ export class VoicesClient { _authRequest.headers, this._options?.headers, mergeOnlyDefinedHeaders({ - "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-07-07", + "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-09-13", }), requestOptions?.headers, ); @@ -382,7 +411,9 @@ export class VoicesClient { } /** - * Delete a personal (cloned) voice + * Delete one of the workspace's cloned voices. Requires the + * `content.manage` permission (owner, admin, or member); a + * service-account key is authorized by its scopes instead. * * @param {Speechify.DeleteVoicesRequest} request * @param {VoicesClient.RequestOptions} requestOptions - Request-specific configuration. @@ -418,7 +449,7 @@ export class VoicesClient { _authRequest.headers, this._options?.headers, mergeOnlyDefinedHeaders({ - "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-07-07", + "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-09-13", }), requestOptions?.headers, ); @@ -513,7 +544,7 @@ export class VoicesClient { _authRequest.headers, this._options?.headers, mergeOnlyDefinedHeaders({ - "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-07-07", + "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-09-13", }), requestOptions?.headers, ); diff --git a/src/api/resources/voices/client/requests/CreateVoicesRequest.ts b/src/api/resources/voices/client/requests/CreateVoicesRequest.ts index 69ae92c..491d810 100644 --- a/src/api/resources/voices/client/requests/CreateVoicesRequest.ts +++ b/src/api/resources/voices/client/requests/CreateVoicesRequest.ts @@ -6,10 +6,11 @@ import type * as core from "../../../../../core/index.js"; * @example * { * sample: fs.createReadStream("/path/to/your/file"), + * consent_recording: fs.createReadStream("/path/to/your/file"), * "Idempotency-Key": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", * name: "name", * gender: "male", - * consent: "consent" + * consent_challenge_id: "consent_challenge_id" * } */ export interface CreateVoicesRequest { @@ -33,16 +34,25 @@ export interface CreateVoicesRequest { * not_specified GenderNotSpecified */ gender: CreateVoicesRequest.Gender; - /** Audio sample file */ + /** Audio sample of the voice to clone, 10-30 seconds of clean speech. */ sample: core.file.Uploadable; /** Avatar image file */ avatar?: core.file.Uploadable | undefined; /** - * A **string** representing the user consent information in JSON format - * This should include the fullName and email of the consenting individual. - * For example, `{"fullName": "John Doe", "email": "john@example.com"}` + * The `id` of the consent challenge this create consumes, from + * `POST /v1/voices/consent-challenges`. Single use: once a + * create has consumed it, whether or not that create + * succeeded, it cannot be used again. */ - consent: string; + consent_challenge_id: string; + /** + * Recording of the speaker reading the challenge's `phrase` + * aloud. This is the consent record for the voice, not a + * second voice sample: it must be the same person as in + * `sample`, and it is retained as evidence. 5-30 seconds, at + * most 25 MB, in any common audio container. + */ + consent_recording: core.file.Uploadable; } export namespace CreateVoicesRequest { diff --git a/src/api/resources/voices/client/requests/ListVoicesRequest.ts b/src/api/resources/voices/client/requests/ListVoicesRequest.ts index d369065..6c727dc 100644 --- a/src/api/resources/voices/client/requests/ListVoicesRequest.ts +++ b/src/api/resources/voices/client/requests/ListVoicesRequest.ts @@ -1,12 +1,35 @@ // This file was auto-generated by Fern from our API Definition. +import type * as Speechify from "../../../../index.js"; + /** * @example - * {} + * { + * locale: "en", + * model: "simba-3.2" + * } */ export interface ListVoicesRequest { /** Opaque pagination cursor from a previous response. */ cursor?: string; /** Max items per page (default 50, max 200). */ limit?: number; + /** + * Filter by voice type: `personal` (the workspace's cloned voices) + * or `shared` (the public catalogue). Omit to return both. + */ + type?: Speechify.ListVoicesRequestType; + /** + * Filter to voices whose locale matches this BCP-47 language range, + * prefix-matched: `en` matches `en-US` and `en-GB`; `en-US` matches + * only `en-US`. Case-insensitive. Omit to return all locales. + */ + locale?: string; + /** Filter by voice gender. Omit to return all genders. */ + gender?: Speechify.ListVoicesRequestGender; + /** + * Filter to voices that support this model (as listed in each voice's + * `models[]`), e.g. `simba-3.2`. Omit to return voices for all models. + */ + model?: string; } diff --git a/src/api/resources/voices/exports.ts b/src/api/resources/voices/exports.ts index 8b184ae..0faadc7 100644 --- a/src/api/resources/voices/exports.ts +++ b/src/api/resources/voices/exports.ts @@ -2,3 +2,4 @@ export { VoicesClient } from "./client/Client.js"; export * from "./client/index.js"; +export * from "./resources/index.js"; diff --git a/src/api/resources/voices/index.ts b/src/api/resources/voices/index.ts index 914b8c3..0ef16e7 100644 --- a/src/api/resources/voices/index.ts +++ b/src/api/resources/voices/index.ts @@ -1 +1,3 @@ export * from "./client/index.js"; +export * from "./resources/index.js"; +export * from "./types/index.js"; diff --git a/src/api/resources/voices/resources/consentChallenges/client/Client.ts b/src/api/resources/voices/resources/consentChallenges/client/Client.ts new file mode 100644 index 0000000..fc1a3a8 --- /dev/null +++ b/src/api/resources/voices/resources/consentChallenges/client/Client.ts @@ -0,0 +1,149 @@ +// This file was auto-generated by Fern from our API Definition. + +import type { BaseClientOptions, BaseRequestOptions } from "../../../../../../BaseClient.js"; +import { type NormalizedClientOptionsWithAuth, normalizeClientOptionsWithAuth } from "../../../../../../BaseClient.js"; +import { mergeHeaders, mergeOnlyDefinedHeaders } from "../../../../../../core/headers.js"; +import * as core from "../../../../../../core/index.js"; +import * as environments from "../../../../../../environments.js"; +import { handleNonStatusCodeError } from "../../../../../../errors/handleNonStatusCodeError.js"; +import * as errors from "../../../../../../errors/index.js"; +import * as Speechify from "../../../../../index.js"; + +export declare namespace ConsentChallengesClient { + export type Options = BaseClientOptions; + + export interface RequestOptions extends BaseRequestOptions {} +} + +export class ConsentChallengesClient { + protected readonly _options: NormalizedClientOptionsWithAuth; + + constructor(options: ConsentChallengesClient.Options = {}) { + this._options = normalizeClientOptionsWithAuth(options); + } + + /** + * Start the consent check for a voice clone. + * + * Returns a `phrase` for the speaker to read aloud and an `id` that identifies this challenge. Show the phrase to the speaker exactly as returned, record them reading it, then send the recording and the `id` to `POST /v1/voices`, which verifies the recording against the phrase and keeps it as the consent record. + * + * A challenge is single use, is bound to the workspace that created it, and expires at `expires_at` - it is proof that a speaker was in front of a microphone just now, so create it when you are ready to record, not at the start of your flow. If it expires, create another one and record again. + * + * Challenge creation is rate limited per workspace at a few dozen per hour, far more tightly than the rest of the voice surface, because each one precedes a person recording themselves - mint it when your speaker is ready, not speculatively. Read the live ceiling off `RateLimit-*` rather than hard-coding it. **On a `429`, always honour `Retry-After` rather than a fixed backoff of your own**: the wait is measured in minutes and can run to most of an hour. `RateLimit-*` are omitted rather than reporting a bucket that is not the one refusing. + * + * @param {Speechify.voices.CreateConsentChallengeRequest} request + * @param {ConsentChallengesClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link Speechify.BadRequestError} + * @throws {@link Speechify.UnauthorizedError} + * @throws {@link Speechify.PaymentRequiredError} + * @throws {@link Speechify.ForbiddenError} + * @throws {@link Speechify.ConflictError} + * @throws {@link Speechify.TooManyRequestsError} + * @throws {@link Speechify.InternalServerError} + * @throws {@link Speechify.BadGatewayError} + * @throws {@link Speechify.ServiceUnavailableError} + * + * @example + * await client.voices.consentChallenges.create({ + * "Idempotency-Key": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + * full_name: "Jane Doe" + * }) + */ + public create( + request: Speechify.voices.CreateConsentChallengeRequest, + requestOptions?: ConsentChallengesClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__create(request, requestOptions)); + } + + private async __create( + request: Speechify.voices.CreateConsentChallengeRequest, + requestOptions?: ConsentChallengesClient.RequestOptions, + ): Promise> { + const { "Idempotency-Key": idempotencyKey, ..._body } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + mergeOnlyDefinedHeaders({ + "Idempotency-Key": idempotencyKey, + "Speechify-Version": requestOptions?.version ?? this._options?.version ?? "2026-09-13", + }), + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.SpeechifyEnvironment.Default, + "v1/voices/consent-challenges", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryString: core.url.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(), + requestType: "json", + body: _body, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as Speechify.ConsentChallenge, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 400: + throw new Speechify.BadRequestError(_response.error.body as unknown, _response.rawResponse); + case 401: + throw new Speechify.UnauthorizedError(_response.error.body as unknown, _response.rawResponse); + case 402: + throw new Speechify.PaymentRequiredError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); + case 403: + throw new Speechify.ForbiddenError(_response.error.body as Speechify.Error_, _response.rawResponse); + case 409: + throw new Speechify.ConflictError(_response.error.body as unknown, _response.rawResponse); + case 429: + throw new Speechify.TooManyRequestsError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); + case 500: + throw new Speechify.InternalServerError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); + case 502: + throw new Speechify.BadGatewayError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); + case 503: + throw new Speechify.ServiceUnavailableError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); + default: + throw new errors.SpeechifyError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/v1/voices/consent-challenges", + ); + } +} diff --git a/src/api/resources/voices/resources/consentChallenges/client/index.ts b/src/api/resources/voices/resources/consentChallenges/client/index.ts new file mode 100644 index 0000000..195f9aa --- /dev/null +++ b/src/api/resources/voices/resources/consentChallenges/client/index.ts @@ -0,0 +1 @@ +export * from "./requests/index.js"; diff --git a/src/api/resources/voices/resources/consentChallenges/client/requests/CreateConsentChallengeRequest.ts b/src/api/resources/voices/resources/consentChallenges/client/requests/CreateConsentChallengeRequest.ts new file mode 100644 index 0000000..e078816 --- /dev/null +++ b/src/api/resources/voices/resources/consentChallenges/client/requests/CreateConsentChallengeRequest.ts @@ -0,0 +1,34 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * "Idempotency-Key": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + * full_name: "Jane Doe" + * } + */ +export interface CreateConsentChallengeRequest { + /** + * A client-generated key (an opaque string, max 255 chars) that makes a + * side-effect POST safe to retry: the server runs the operation exactly + * once and replays the first response (its status and body) for 24 hours. + * Reusing a key with a different request body, or while the first request + * is still in flight, returns `409 idempotency_conflict`. A replayed + * response carries the `Idempotent-Replayed: true` header. + */ + "Idempotency-Key"?: string; + /** + * Full name of the person consenting to have their voice cloned. + * Speechify binds it to the challenge and stores it with the consent + * record, so the create that consumes the challenge does not carry it + * and cannot change it. + * + * At most 120 bytes once UTF-8 encoded, which is 120 characters of + * Latin script but around 40 of Chinese, Japanese or Korean. Stated in + * bytes rather than as a `maxLength` because the two only agree on + * single-byte scripts, and a character count that never over-accepts + * would have to refuse Latin names at 30. A name over the limit comes + * back as `validation_failed` reporting its measured length. + */ + full_name: string; +} diff --git a/src/api/resources/voices/resources/consentChallenges/client/requests/index.ts b/src/api/resources/voices/resources/consentChallenges/client/requests/index.ts new file mode 100644 index 0000000..7a3beff --- /dev/null +++ b/src/api/resources/voices/resources/consentChallenges/client/requests/index.ts @@ -0,0 +1 @@ +export type { CreateConsentChallengeRequest } from "./CreateConsentChallengeRequest.js"; diff --git a/src/api/resources/voices/resources/consentChallenges/exports.ts b/src/api/resources/voices/resources/consentChallenges/exports.ts new file mode 100644 index 0000000..977de34 --- /dev/null +++ b/src/api/resources/voices/resources/consentChallenges/exports.ts @@ -0,0 +1,4 @@ +// This file was auto-generated by Fern from our API Definition. + +export { ConsentChallengesClient } from "./client/Client.js"; +export * from "./client/index.js"; diff --git a/src/api/resources/voices/resources/consentChallenges/index.ts b/src/api/resources/voices/resources/consentChallenges/index.ts new file mode 100644 index 0000000..914b8c3 --- /dev/null +++ b/src/api/resources/voices/resources/consentChallenges/index.ts @@ -0,0 +1 @@ +export * from "./client/index.js"; diff --git a/src/api/resources/voices/resources/index.ts b/src/api/resources/voices/resources/index.ts new file mode 100644 index 0000000..0254dfa --- /dev/null +++ b/src/api/resources/voices/resources/index.ts @@ -0,0 +1,2 @@ +export * from "./consentChallenges/client/requests/index.js"; +export * as consentChallenges from "./consentChallenges/index.js"; diff --git a/src/api/resources/voices/types/ListVoicesRequestGender.ts b/src/api/resources/voices/types/ListVoicesRequestGender.ts new file mode 100644 index 0000000..ed45e94 --- /dev/null +++ b/src/api/resources/voices/types/ListVoicesRequestGender.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +export const ListVoicesRequestGender = { + Male: "male", + Female: "female", + NotSpecified: "not_specified", +} as const; +export type ListVoicesRequestGender = (typeof ListVoicesRequestGender)[keyof typeof ListVoicesRequestGender]; diff --git a/src/api/resources/voices/types/ListVoicesRequestType.ts b/src/api/resources/voices/types/ListVoicesRequestType.ts new file mode 100644 index 0000000..dcde580 --- /dev/null +++ b/src/api/resources/voices/types/ListVoicesRequestType.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +export const ListVoicesRequestType = { + Personal: "personal", + Shared: "shared", +} as const; +export type ListVoicesRequestType = (typeof ListVoicesRequestType)[keyof typeof ListVoicesRequestType]; diff --git a/src/api/resources/voices/types/index.ts b/src/api/resources/voices/types/index.ts new file mode 100644 index 0000000..cef4f63 --- /dev/null +++ b/src/api/resources/voices/types/index.ts @@ -0,0 +1,2 @@ +export * from "./ListVoicesRequestGender.js"; +export * from "./ListVoicesRequestType.js"; diff --git a/src/api/types/ConsentChallenge.ts b/src/api/types/ConsentChallenge.ts new file mode 100644 index 0000000..3405b5a --- /dev/null +++ b/src/api/types/ConsentChallenge.ts @@ -0,0 +1,23 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface ConsentChallenge { + /** + * Identifier for this challenge, sent back as `consent_challenge_id` + * on the create. Treat it as an opaque string - the format is not part + * of the contract and will not stay stable. + */ + id: string; + /** + * The sentence the speaker must read aloud. Show it exactly as + * returned - the recording is transcribed and matched against this + * text, so re-wording, re-casing or re-punctuating it will fail the + * check. + */ + phrase: string; + /** + * When the challenge stops being usable. This is the only authority on + * the window - do not hard-code a duration. Past it, create a new + * challenge and record the new phrase. + */ + expires_at: string; +} diff --git a/src/api/types/ErrorCode.ts b/src/api/types/ErrorCode.ts index e3fd5da..a4d8a60 100644 --- a/src/api/types/ErrorCode.ts +++ b/src/api/types/ErrorCode.ts @@ -49,15 +49,33 @@ export const ErrorCode = { PhoneNumberQuotaReached: "phone_number_quota_reached", BatchCallsNotIncluded: "batch_calls_not_included", VoiceCloningNotIncluded: "voice_cloning_not_included", + ConsentChallengeNotFound: "consent_challenge_not_found", + ConsentChallengeExpired: "consent_challenge_expired", + ConsentChallengeAlreadyUsed: "consent_challenge_already_used", + ConsentPhraseMismatch: "consent_phrase_mismatch", + ConsentSpeakerMismatch: "consent_speaker_mismatch", + ConsentRecordingUnusable: "consent_recording_unusable", + ConsentVerificationUnavailable: "consent_verification_unavailable", WorkspaceLastOwner: "workspace_last_owner", WorkspaceLastWorkspace: "workspace_last_workspace", + AccountDeletionBlocked: "account_deletion_blocked", + WorkspaceFreeLimit: "workspace_free_limit", + WorkspaceSingleOwner: "workspace_single_owner", InviteEmailMismatch: "invite_email_mismatch", InviteAlreadyPending: "invite_already_pending", ServiceAccountLimitReached: "service_account_limit_reached", ServiceAccountsNotInPlan: "service_accounts_not_in_plan", + SpeechMarksUnsupported: "speech_marks_unsupported", + TooManyVoices: "too_many_voices", + ContentPolicyViolation: "content_policy_violation", + TopupNotInPlan: "topup_not_in_plan", + CreditPurchaseUnpaid: "credit_purchase_unpaid", ToolConfigShared: "tool_config_shared", SpendCapExceeded: "spend_cap_exceeded", SpendBudgetExceeded: "spend_budget_exceeded", + ShareLinkNotFound: "share_link_not_found", + ShareLinkExhausted: "share_link_exhausted", + ShareLinkLimitReached: "share_link_limit_reached", DestinationNotAllowed: "destination_not_allowed", InternationalDialingNotEnabled: "international_dialing_not_enabled", } as const; diff --git a/src/api/types/ErrorDetail.ts b/src/api/types/ErrorDetail.ts index cce779d..f7a6239 100644 --- a/src/api/types/ErrorDetail.ts +++ b/src/api/types/ErrorDetail.ts @@ -29,4 +29,11 @@ export interface ErrorDetail { * it - the `code` + `message` contract is unchanged. */ details?: Record | undefined; + /** + * Link to the documentation that resolves this class of + * error, when a stable page exists. Rate and concurrency + * 429s link the API limits reference, which lists each + * plan's limits and how to raise them. + */ + docs_url?: string | undefined; } diff --git a/src/api/types/Error_.ts b/src/api/types/Error_.ts index 5793109..3928694 100644 --- a/src/api/types/Error_.ts +++ b/src/api/types/Error_.ts @@ -8,7 +8,7 @@ import type * as Speechify from "../index.js"; * Anthropic / Stripe style: a machine-readable `error.code` for * SDK consumers to switch on, a human `error.message` for UI, * and an optional `error.fields` map for per-field validation - * errors. `request_id` matches the `X-Request-ID` response + * errors. `request_id` matches the `Speechify-Request-Id` response * header and is what customers quote when filing support * tickets. */ @@ -16,7 +16,7 @@ export interface Error_ { error: Speechify.ErrorDetail; /** * Server-side request identifier. Echoes the - * `X-Request-ID` response header. Stable across the + * `Speechify-Request-Id` response header. Stable across the * request's lifetime, written to structured logs, and * useful when reporting issues. */ diff --git a/src/api/types/GetStreamRequest.ts b/src/api/types/GetStreamRequest.ts new file mode 100644 index 0000000..aac0523 --- /dev/null +++ b/src/api/types/GetStreamRequest.ts @@ -0,0 +1,46 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as Speechify from "../index.js"; + +/** + * GetStreamRequest is the wrapper for request parameters to the client + */ +export interface GetStreamRequest { + /** + * Plain text or SSML to be synthesized to speech. + * Refer to https://docs.speechify.ai/docs/api-limits for the input size limits. + * Emotion, Pitch and Speed Rate are configured in the ssml input, please refer to the ssml documentation for more information: https://docs.speechify.ai/docs/ssml#prosody + */ + input: string; + /** + * Language of the input. Follow the format of an ISO 639-1 language code and an ISO 3166-1 region code, separated by a hyphen, e.g. en-US. + * Please refer to the list of the supported languages and recommendations regarding this parameter: https://docs.speechify.ai/docs/language-support. + */ + language?: string | undefined; + /** Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. */ + model?: GetStreamRequest.Model | undefined; + options?: Speechify.GetStreamOptionsRequest | undefined; + /** The output audio format as a `codec_sampleRate_bitrate` string. Takes precedence over the `Accept` header when set, so you can request formats the `Accept` enum does not cover (e.g. `pcm_16000`, `ulaw_8000`). `wav_*` formats are not supported on streaming - use `POST /v1/audio/speech` for wav. */ + output_format?: Speechify.AudioStreamOutputFormat | undefined; + /** Id of the voice to be used for synthesizing speech. Refer to /v1/voices endpoint for available voices */ + voice_id: string; +} + +export namespace GetStreamRequest { + /** Model used for audio synthesis. Defaults to `simba-3.0`, which is streaming-native and multilingual: it officially supports English plus `de-DE`, `es-ES`, `es-MX`, `fr-FR`, `it-IT` and `pt-BR`, and routes each request to its English or its multilingual training based on `language` (falling back to the voice's locale when `language` is omitted). `simba-3.2` is the streaming-native model with the lowest TTFB and richest expressivity, and the recommended Simba 3 model; it is English only, so a non-English voice returns 400. `simba-english` and `simba-multilingual` are the legacy Simba 1.6 models, kept for compatibility. */ + export const Model = { + /** + * simba-english is the legacy Simba 1.6 English model. It accepts cloned voices self-serve; prefer simba-3.2 for new integrations. */ + SimbaEnglish: "simba-english", + /** + * simba-multilingual is the legacy Simba 1.6 multilingual model, covering the full 30+ locale set and mixed-language input. Prefer simba-3.0 for the languages it supports. */ + SimbaMultilingual: "simba-multilingual", + /** + * simba-3.0 is the streaming-native multilingual model, and the default when `model` is omitted. Officially supports English plus de-DE, es-ES, es-MX, fr-FR, it-IT and pt-BR; the request language (or the voice's locale when it is omitted) selects the English or the multilingual training. Prefer simba-3.2 for English-only integrations. Cloned/personal voices work self-serve on simba-3.0. */ + Simba30: "simba-3.0", + /** + * simba-3.2 is the streaming-native model with the lowest TTFB and richest expressivity. English only; a non-English voice returns 400 - use simba-3.0 for the other supported languages. Cloned voices are supported on simba-3.2 alongside the curated stock roster, currently as a limited release enabled per workspace - contact Speechify to have it enabled for yours. */ + Simba32: "simba-3.2", + } as const; + export type Model = (typeof Model)[keyof typeof Model]; +} diff --git a/src/api/types/Model.ts b/src/api/types/Model.ts new file mode 100644 index 0000000..4a3ecc0 --- /dev/null +++ b/src/api/types/Model.ts @@ -0,0 +1,66 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * One selectable text-to-speech model. + */ +export interface Model { + /** + * Model identifier. Pass this as the `model` parameter to + * POST /v1/audio/speech or /v1/audio/stream. + */ + id: string; + /** Human-readable model name, for a model picker. */ + name: string; + /** + * Whether this is the model used when a synthesis request omits + * `model`. Exactly one model in the list is the default. Distinct + * from `recommended`: the default accepts every voice, while the + * recommended model may serve a curated or English-only set. + */ + default: boolean; + /** + * Whether this is the model we recommend for new integrations. + * Exactly one model in the list is recommended, and it may differ + * from the `default`. + */ + recommended: boolean; + /** + * Whether this is a legacy model. Advisory only: a deprecated model + * stays selectable and behaves exactly as before, and nothing is + * scheduled for removal. De-emphasise it in a picker and steer new + * integrations to a current model. + */ + deprecated: boolean; + /** One-line summary of the model, for a model picker. */ + description: string; + /** + * Languages the model can synthesize, as BCP-47 locale strings + * matching the `language` request parameter (e.g. `en`, `fr-FR`). + * English-only models return `["en"]`. This set reflects current + * capability and can grow over time. + */ + languages: string[]; + /** + * The synthesis routes this model may be passed to. Only the + * streaming-native models serve `/v1/audio/stream/with-timestamps`; + * passing a model this list omits is a 400 rather than a degraded + * response, so branch on it instead of discovering it at call time. + */ + endpoints: string[]; + /** + * Whether the model's stock voices are restricted to the set curated + * for it. When true, pick a stock voice whose `models` array in + * GET /v1/voices names this model; any other stock voice is rejected. + * When false, every stock catalogue voice works. Cloned voices are + * governed separately - always read each voice's own `models` array in + * GET /v1/voices, which reflects what your workspace may actually + * synthesize. + */ + curated_voices: boolean; + /** + * Whether the model rejects a non-English voice. Independent of + * `languages`: a model can publish English only and still accept any + * voice. + */ + english_voices_only: boolean; +} diff --git a/src/api/types/ModelsResponse.ts b/src/api/types/ModelsResponse.ts new file mode 100644 index 0000000..81bc073 --- /dev/null +++ b/src/api/types/ModelsResponse.ts @@ -0,0 +1,20 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as Speechify from "../index.js"; + +/** + * The catalog of text-to-speech models available for synthesis. + */ +export interface ModelsResponse { + /** The models selectable on the single-utterance synthesis endpoints. */ + models: Speechify.Model[]; + /** + * The multi-speaker models selectable on POST /v1/audio/dialogue. + * Disjoint from `models`: a dialogue model consumes a + * speaker-attributed script rather than one utterance, so it is + * rejected on the single-utterance endpoints and vice versa. Its + * `default` marks the model that endpoint resolves to when a request + * omits `model`, independently of the `models` default. + */ + dialogue_models: Speechify.Model[]; +} diff --git a/src/api/types/index.ts b/src/api/types/index.ts index 74a954b..be37ea1 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -1,15 +1,19 @@ export * from "./AudioOutputFormat.js"; export * from "./AudioStreamOutputFormat.js"; +export * from "./ConsentChallenge.js"; export * from "./Error_.js"; export * from "./ErrorCode.js"; export * from "./ErrorDetail.js"; export * from "./GetSpeechOptionsRequest.js"; export * from "./GetSpeechResponse.js"; export * from "./GetStreamOptionsRequest.js"; +export * from "./GetStreamRequest.js"; export * from "./GetVoice.js"; export * from "./GetVoiceLanguage.js"; export * from "./GetVoicesModel.js"; export * from "./ListVoicesResponse.js"; +export * from "./Model.js"; +export * from "./ModelsResponse.js"; export * from "./NestedChunk.js"; export * from "./PaginationMeta.js"; export * from "./SpeechMarks.js"; diff --git a/src/core/index.ts b/src/core/index.ts index 7b3d0dd..0f99d56 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -6,5 +6,6 @@ export * from "./form-data-utils/index.js"; export * as logging from "./logging/index.js"; export * from "./pagination/index.js"; export * from "./runtime/index.js"; +export * from "./stream/index.js"; export * as url from "./url/index.js"; export * from "./utils/index.js"; diff --git a/src/core/stream/Stream.ts b/src/core/stream/Stream.ts new file mode 100644 index 0000000..8ccdecf --- /dev/null +++ b/src/core/stream/Stream.ts @@ -0,0 +1,235 @@ +import { fromJson } from "../json.js"; +import { RUNTIME } from "../runtime/index.js"; + +export declare namespace Stream { + interface Args { + /** + * The HTTP response stream to read from. + */ + + stream: ReadableStream; + + /** + * The event shape to use for parsing the stream data. + */ + eventShape: JsonEvent | SseEvent; + /** + * An abort signal to stop the stream. + */ + signal?: AbortSignal; + } + + interface JsonEvent { + type: "json"; + messageTerminator: string; + } + + interface SseEvent { + type: "sse"; + streamTerminator?: string; + eventDiscriminator?: string; + } +} + +const DATA_PREFIX = "data:"; +const EVENT_PREFIX = "event:"; + +export class Stream implements AsyncIterable { + private stream: ReadableStream; + + private parse: (val: unknown) => Promise; + /** + * The prefix to use for each message. For example, + * for SSE, the prefix is "data: ". + */ + private prefix: string | undefined; + private messageTerminator: string; + private streamTerminator: string | undefined; + private eventDiscriminator: string | undefined; + private controller: AbortController = new AbortController(); + private decoder: TextDecoder | undefined; + + constructor({ stream, parse, eventShape, signal }: Stream.Args & { parse: (val: unknown) => Promise }) { + this.stream = stream; + this.parse = parse; + if (eventShape.type === "sse") { + this.prefix = DATA_PREFIX; + this.messageTerminator = "\n"; + this.streamTerminator = eventShape.streamTerminator; + this.eventDiscriminator = eventShape.eventDiscriminator; + } else { + this.messageTerminator = eventShape.messageTerminator; + } + signal?.addEventListener("abort", () => this.controller.abort()); + + // Initialize shared TextDecoder + if (typeof TextDecoder !== "undefined") { + this.decoder = new TextDecoder("utf-8"); + } + } + + private async *iterMessages(): AsyncGenerator { + if (this.eventDiscriminator != null) { + yield* this.iterSseEvents(); + } else { + yield* this.iterDataMessages(); + } + } + + private async *iterDataMessages(): AsyncGenerator { + const stream = readableStreamAsyncIterable(this.stream); + let buf = ""; + let prefixSeen = false; + for await (const chunk of stream) { + buf += this.decodeChunk(chunk); + + let terminatorIndex: number; + while ((terminatorIndex = buf.indexOf(this.messageTerminator)) >= 0) { + let line = buf.slice(0, terminatorIndex); + buf = buf.slice(terminatorIndex + this.messageTerminator.length); + + if (!line.trim()) { + continue; + } + + if (!prefixSeen && this.prefix != null) { + const prefixIndex = line.indexOf(this.prefix); + if (prefixIndex === -1) { + continue; + } + prefixSeen = true; + line = line.slice(prefixIndex + this.prefix.length); + } + + if (this.streamTerminator != null && line.includes(this.streamTerminator)) { + return; + } + const message = await this.parse(fromJson(line)); + yield message; + prefixSeen = false; + } + } + } + + private async *iterSseEvents(): AsyncGenerator { + const stream = readableStreamAsyncIterable(this.stream); + let buf = ""; + let eventType: string | undefined; + let dataValue: string | undefined; + + for await (const chunk of stream) { + buf += this.decodeChunk(chunk); + + let terminatorIndex: number; + while ((terminatorIndex = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, terminatorIndex).replace(/\r$/, ""); + buf = buf.slice(terminatorIndex + 1); + + if (!line.trim()) { + if (dataValue != null) { + const message = await this.dispatchSseEvent(dataValue, eventType); + if (message == null) { + return; + } + yield message; + } + eventType = undefined; + dataValue = undefined; + continue; + } + + if (line.startsWith(EVENT_PREFIX)) { + eventType = line.slice(EVENT_PREFIX.length).trim(); + } else if (line.startsWith(DATA_PREFIX)) { + const val = line.slice(DATA_PREFIX.length).trim(); + dataValue = dataValue != null ? `${dataValue}\n${val}` : val; + } + } + } + + if (dataValue != null) { + const message = await this.dispatchSseEvent(dataValue, eventType); + if (message != null) { + yield message; + } + } + } + + /** + * Parses and returns a single SSE event, or returns null if the event is a stream terminator. + */ + private async dispatchSseEvent(dataValue: string, eventType: string | undefined): Promise { + if (this.streamTerminator != null && dataValue.includes(this.streamTerminator)) { + return null; + } + return this.parse(this.injectDiscriminator(fromJson(dataValue), eventType)); + } + + private injectDiscriminator(parsed: unknown, eventType: string | undefined): unknown { + if (this.eventDiscriminator == null || eventType == null) { + return parsed; + } + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { + return parsed; + } + const obj = parsed as Record; + if (this.eventDiscriminator in obj) { + return parsed; + } + return { [this.eventDiscriminator]: eventType, ...obj }; + } + + async *[Symbol.asyncIterator](): AsyncIterator { + for await (const message of this.iterMessages()) { + yield message; + } + } + + private decodeChunk(chunk: any): string { + let decoded = ""; + // If TextDecoder is available, use the streaming decoder instance + if (this.decoder != null) { + decoded += this.decoder.decode(chunk, { stream: true }); + } + // Buffer is present in Node.js environment + else if (RUNTIME.type === "node" && typeof chunk !== "undefined") { + decoded += Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + } + return decoded; + } +} + +/** + * Browser polyfill for ReadableStream + */ +// biome-ignore lint/suspicious/noExplicitAny: allow explicit any +export function readableStreamAsyncIterable(stream: any): AsyncIterableIterator { + if (stream[Symbol.asyncIterator]) { + return stream; + } + + const reader = stream.getReader(); + return { + async next() { + try { + const result = await reader.read(); + if (result?.done) { + reader.releaseLock(); + } // release lock when stream becomes closed + return result; + } catch (e) { + reader.releaseLock(); // release lock when stream becomes errored + throw e; + } + }, + async return() { + const cancelPromise = reader.cancel(); + reader.releaseLock(); + await cancelPromise; + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} diff --git a/src/core/stream/index.ts b/src/core/stream/index.ts new file mode 100644 index 0000000..4e28b34 --- /dev/null +++ b/src/core/stream/index.ts @@ -0,0 +1 @@ +export { Stream } from "./Stream.js"; diff --git a/src/version.ts b/src/version.ts index 68e5be6..af16f12 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const SDK_VERSION = "3.0.1"; +export const SDK_VERSION = "3.0.2"; diff --git a/tests/unit/stream/Stream.test.ts b/tests/unit/stream/Stream.test.ts new file mode 100644 index 0000000..83575f0 --- /dev/null +++ b/tests/unit/stream/Stream.test.ts @@ -0,0 +1,563 @@ +import { Stream } from "../../../src/core/stream/Stream"; + +describe("Stream", () => { + describe("JSON streaming", () => { + it("should parse single JSON message", async () => { + const mockStream = createReadableStream(['{"value": 1}\n']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "json", messageTerminator: "\n" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ value: 1 }]); + }); + + it("should parse multiple JSON messages", async () => { + const mockStream = createReadableStream(['{"value": 1}\n{"value": 2}\n{"value": 3}\n']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "json", messageTerminator: "\n" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ value: 1 }, { value: 2 }, { value: 3 }]); + }); + + it("should handle messages split across chunks", async () => { + const mockStream = createReadableStream(['{"val', 'ue": 1}\n{"value":', " 2}\n"]); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "json", messageTerminator: "\n" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ value: 1 }, { value: 2 }]); + }); + + it("should skip empty lines", async () => { + const mockStream = createReadableStream(['{"value": 1}\n\n\n{"value": 2}\n']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "json", messageTerminator: "\n" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ value: 1 }, { value: 2 }]); + }); + + it("should handle custom message terminator", async () => { + const mockStream = createReadableStream(['{"value": 1}|||{"value": 2}|||']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "json", messageTerminator: "|||" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ value: 1 }, { value: 2 }]); + }); + }); + + describe("SSE streaming", () => { + it("should parse SSE data with prefix", async () => { + const mockStream = createReadableStream(['data: {"value": 1}\n']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "sse" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ value: 1 }]); + }); + + it("should parse multiple SSE events", async () => { + const mockStream = createReadableStream(['data: {"value": 1}\ndata: {"value": 2}\ndata: {"value": 3}\n']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "sse" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ value: 1 }, { value: 2 }, { value: 3 }]); + }); + + it("should stop at stream terminator", async () => { + const mockStream = createReadableStream(['data: {"value": 1}\ndata: [DONE]\ndata: {"value": 2}\n']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "sse", streamTerminator: "[DONE]" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ value: 1 }]); + }); + + it("should skip lines without data prefix", async () => { + const mockStream = createReadableStream([ + 'event: message\ndata: {"value": 1}\nid: 123\ndata: {"value": 2}\n', + ]); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "sse" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ value: 1 }, { value: 2 }]); + }); + }); + + describe("SSE event-level discrimination (inject discriminator)", () => { + it("should inject event type as discriminator into JSON data", async () => { + const mockStream = createReadableStream([ + 'event: completion\ndata: {"content": "hello"}\n\nevent: completion\ndata: {"content": "world"}\n\n', + ]); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val, + eventShape: { type: "sse", eventDiscriminator: "type" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([ + { type: "completion", content: "hello" }, + { type: "completion", content: "world" }, + ]); + }); + + it("should inject different event types for mixed events", async () => { + const mockStream = createReadableStream([ + 'event: completion\ndata: {"content": "hi"}\n\nevent: error\ndata: {"message": "fail"}\n\n', + ]); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val, + eventShape: { type: "sse", eventDiscriminator: "event" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([ + { event: "completion", content: "hi" }, + { event: "error", message: "fail" }, + ]); + }); + + it("should not inject if data already contains discriminator key", async () => { + const mockStream = createReadableStream([ + 'event: completion\ndata: {"type": "existing", "content": "hello"}\n\n', + ]); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val, + eventShape: { type: "sse", eventDiscriminator: "type" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ type: "existing", content: "hello" }]); + }); + + it("should not false-positive when discriminator key appears inside a value", async () => { + const mockStream = createReadableStream([ + 'event: completion\ndata: {"description": "type: foo", "content": "hello"}\n\n', + ]); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val, + eventShape: { type: "sse", eventDiscriminator: "type" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ type: "completion", description: "type: foo", content: "hello" }]); + }); + + it("should not inject if no event field is present", async () => { + const mockStream = createReadableStream(['data: {"content": "hello"}\n\n']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val, + eventShape: { type: "sse", eventDiscriminator: "type" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ content: "hello" }]); + }); + + it("should handle empty JSON object", async () => { + const mockStream = createReadableStream(["event: heartbeat\ndata: {}\n\n"]); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val, + eventShape: { type: "sse", eventDiscriminator: "type" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ type: "heartbeat" }]); + }); + + it("should stop at stream terminator", async () => { + const mockStream = createReadableStream([ + 'event: completion\ndata: {"content": "hi"}\n\nevent: done\ndata: [DONE]\n\nevent: completion\ndata: {"content": "bye"}\n\n', + ]); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val, + eventShape: { type: "sse", eventDiscriminator: "type", streamTerminator: "[DONE]" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ type: "completion", content: "hi" }]); + }); + + it("should concatenate multiline data fields", async () => { + const mockStream = createReadableStream(['event: completion\ndata: {"delta":\ndata: "hello"}\n\n']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val, + eventShape: { type: "sse", eventDiscriminator: "type" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ type: "completion", delta: "hello" }]); + }); + + it("should handle events split across chunks", async () => { + const mockStream = createReadableStream(["event: comple", 'tion\ndata: {"con', 'tent": "hi"}\n\n']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val, + eventShape: { type: "sse", eventDiscriminator: "type" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ type: "completion", content: "hi" }]); + }); + + it("should handle last event without trailing blank line", async () => { + const mockStream = createReadableStream(['event: completion\ndata: {"content": "hi"}\n']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val, + eventShape: { type: "sse", eventDiscriminator: "type" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ type: "completion", content: "hi" }]); + }); + + it("should handle CRLF line endings", async () => { + const mockStream = createReadableStream([ + 'event: completion\r\ndata: {"content": "hi"}\r\n\r\nevent: completion\r\ndata: {"content": "world"}\r\n\r\n', + ]); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val, + eventShape: { type: "sse", eventDiscriminator: "type" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([ + { type: "completion", content: "hi" }, + { type: "completion", content: "world" }, + ]); + }); + + it("should inject empty string discriminator when event field is present but empty", async () => { + const mockStream = createReadableStream(['event: \ndata: {"content": "hello"}\n\n']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val, + eventShape: { type: "sse", eventDiscriminator: "type" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ type: "", content: "hello" }]); + }); + }); + + describe("encoding and decoding", () => { + it("should decode UTF-8 text using TextDecoder", async () => { + const encoder = new TextEncoder(); + const mockStream = createReadableStream([encoder.encode('{"text": "café"}\n')]); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { text: string }, + eventShape: { type: "json", messageTerminator: "\n" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ text: "café" }]); + }); + + it("should decode emoji correctly", async () => { + const encoder = new TextEncoder(); + const mockStream = createReadableStream([encoder.encode('{"emoji": "🎉"}\n')]); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { emoji: string }, + eventShape: { type: "json", messageTerminator: "\n" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ emoji: "🎉" }]); + }); + + it("should handle binary data chunks", async () => { + const encoder = new TextEncoder(); + const mockStream = createReadableStream([encoder.encode('{"val'), encoder.encode('ue": 1}\n')]); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "json", messageTerminator: "\n" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ value: 1 }]); + }); + + it("should handle multi-byte UTF-8 characters split across chunk boundaries", async () => { + // Test string with Japanese (3 bytes), Russian (2 bytes), German (2 bytes), and Chinese (3 bytes) + const testString = '{"text": "こんにちは Привет Größe 你好"}\n'; + const fullBytes = new TextEncoder().encode(testString); + + // Split the bytes in the middle of multi-byte characters + // Japanese "こ" starts at byte 11, is 3 bytes (E3 81 93) + // Split after first byte of "こ" to test mid-character splitting + const splitPoint = 12; // This splits "こ" in the middle + const chunk1 = fullBytes.slice(0, splitPoint); + const chunk2 = fullBytes.slice(splitPoint); + + const mockStream = createReadableStream([chunk1, chunk2]); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { text: string }, + eventShape: { type: "json", messageTerminator: "\n" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ text: "こんにちは Привет Größe 你好" }]); + }); + }); + + describe("abort signal", () => { + it("should handle abort signal", async () => { + const controller = new AbortController(); + const mockStream = createReadableStream(['{"value": 1}\n{"value": 2}\n{"value": 3}\n']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "json", messageTerminator: "\n" }, + signal: controller.signal, + }); + + const messages: unknown[] = []; + let count = 0; + for await (const message of stream) { + messages.push(message); + count++; + if (count === 2) { + controller.abort(); + break; + } + } + + expect(messages.length).toBe(2); + }); + }); + + describe("async iteration", () => { + it("should support async iterator protocol", async () => { + const mockStream = createReadableStream(['{"value": 1}\n{"value": 2}\n']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "json", messageTerminator: "\n" }, + }); + + const iterator = stream[Symbol.asyncIterator](); + const first = await iterator.next(); + expect(first.done).toBe(false); + expect(first.value).toEqual({ value: 1 }); + + const second = await iterator.next(); + expect(second.done).toBe(false); + expect(second.value).toEqual({ value: 2 }); + + const third = await iterator.next(); + expect(third.done).toBe(true); + }); + }); + + describe("edge cases", () => { + it("should handle empty stream", async () => { + const mockStream = createReadableStream([]); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "json", messageTerminator: "\n" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([]); + }); + + it("should handle stream with only whitespace", async () => { + const mockStream = createReadableStream([" \n\n\t\n "]); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "json", messageTerminator: "\n" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([]); + }); + + it("should handle incomplete message at end of stream", async () => { + const mockStream = createReadableStream(['{"value": 1}\n{"incomplete']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "json", messageTerminator: "\n" }, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages).toEqual([{ value: 1 }]); + }); + }); +}); + +// Helper function to create a ReadableStream from string chunks +function createReadableStream(chunks: (string | Uint8Array)[]): ReadableStream { + // For standard type, return ReadableStream + let index = 0; + return new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + const chunk = chunks[index++]; + controller.enqueue(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk); + } else { + controller.close(); + } + }, + }); +} diff --git a/tests/wire/audio.test.ts b/tests/wire/audio.test.ts index 9009900..8d7e96c 100644 --- a/tests/wire/audio.test.ts +++ b/tests/wire/audio.test.ts @@ -16,8 +16,8 @@ describe("AudioClient", () => { const rawRequestBody = { audio_format: "mp3", input: "Hello! This is the Speechify text-to-speech API.", - model: "simba-english", - voice_id: "george", + model: "simba-3.2", + voice_id: "geffen_32", }; const rawResponseBody = { audio_data: "example", @@ -47,8 +47,8 @@ describe("AudioClient", () => { const response = await client.audio.speech({ audio_format: "mp3", input: "Hello! This is the Speechify text-to-speech API.", - model: "simba-english", - voice_id: "george", + model: "simba-3.2", + voice_id: "geffen_32", }); expect(response).toEqual(rawResponseBody); }); @@ -304,4 +304,312 @@ describe("AudioClient", () => { }); }).rejects.toThrow(Speechify.ServiceUnavailableError); }); + + test("streamWithTimestamps (1)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { + input: "Streaming long-form audio with the Speechify API.", + model: "simba-3.2", + voice_id: "geffen_32", + }; + const rawResponseBody = 'event: \ndata: "string"\n\n'; + + server + .mockEndpoint() + .post("/v1/audio/stream/with-timestamps") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(200) + .sseBody(rawResponseBody) + .build(); + + const response = await client.audio.streamWithTimestamps({ + body: { + input: "Streaming long-form audio with the Speechify API.", + model: "simba-3.2", + voice_id: "geffen_32", + }, + }); + const events: unknown[] = []; + for await (const event of response) { + events.push(event); + } + expect(events).toEqual(["string"]); + }); + + test("streamWithTimestamps (2)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { input: "input", voice_id: "voice_id" }; + const rawResponseBody = { key: "value" }; + + server + .mockEndpoint() + .post("/v1/audio/stream/with-timestamps") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(400) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.audio.streamWithTimestamps({ + body: { + input: "input", + voice_id: "voice_id", + }, + }); + }).rejects.toThrow(Speechify.BadRequestError); + }); + + test("streamWithTimestamps (3)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { input: "input", voice_id: "voice_id" }; + const rawResponseBody = { key: "value" }; + + server + .mockEndpoint() + .post("/v1/audio/stream/with-timestamps") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(401) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.audio.streamWithTimestamps({ + body: { + input: "input", + voice_id: "voice_id", + }, + }); + }).rejects.toThrow(Speechify.UnauthorizedError); + }); + + test("streamWithTimestamps (4)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { input: "input", voice_id: "voice_id" }; + const rawResponseBody = { error: { code: "bad_request", message: "message" } }; + + server + .mockEndpoint() + .post("/v1/audio/stream/with-timestamps") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(402) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.audio.streamWithTimestamps({ + body: { + input: "input", + voice_id: "voice_id", + }, + }); + }).rejects.toThrow(Speechify.PaymentRequiredError); + }); + + test("streamWithTimestamps (5)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { input: "input", voice_id: "voice_id" }; + const rawResponseBody = { error: { code: "bad_request", message: "message" } }; + + server + .mockEndpoint() + .post("/v1/audio/stream/with-timestamps") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(403) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.audio.streamWithTimestamps({ + body: { + input: "input", + voice_id: "voice_id", + }, + }); + }).rejects.toThrow(Speechify.ForbiddenError); + }); + + test("streamWithTimestamps (6)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { input: "input", voice_id: "voice_id" }; + const rawResponseBody = { key: "value" }; + + server + .mockEndpoint() + .post("/v1/audio/stream/with-timestamps") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(404) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.audio.streamWithTimestamps({ + body: { + input: "input", + voice_id: "voice_id", + }, + }); + }).rejects.toThrow(Speechify.NotFoundError); + }); + + test("streamWithTimestamps (7)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { input: "input", voice_id: "voice_id" }; + const rawResponseBody = { error: { code: "bad_request", message: "message" } }; + + server + .mockEndpoint() + .post("/v1/audio/stream/with-timestamps") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(429) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.audio.streamWithTimestamps({ + body: { + input: "input", + voice_id: "voice_id", + }, + }); + }).rejects.toThrow(Speechify.TooManyRequestsError); + }); + + test("streamWithTimestamps (8)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { input: "input", voice_id: "voice_id" }; + const rawResponseBody = { error: { code: "bad_request", message: "message" } }; + + server + .mockEndpoint() + .post("/v1/audio/stream/with-timestamps") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(500) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.audio.streamWithTimestamps({ + body: { + input: "input", + voice_id: "voice_id", + }, + }); + }).rejects.toThrow(Speechify.InternalServerError); + }); + + test("streamWithTimestamps (9)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { input: "input", voice_id: "voice_id" }; + const rawResponseBody = { error: { code: "bad_request", message: "message" } }; + + server + .mockEndpoint() + .post("/v1/audio/stream/with-timestamps") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(502) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.audio.streamWithTimestamps({ + body: { + input: "input", + voice_id: "voice_id", + }, + }); + }).rejects.toThrow(Speechify.BadGatewayError); + }); + + test("streamWithTimestamps (10)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { input: "input", voice_id: "voice_id" }; + const rawResponseBody = { error: { code: "bad_request", message: "message" } }; + + server + .mockEndpoint() + .post("/v1/audio/stream/with-timestamps") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(503) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.audio.streamWithTimestamps({ + body: { + input: "input", + voice_id: "voice_id", + }, + }); + }).rejects.toThrow(Speechify.ServiceUnavailableError); + }); }); diff --git a/tests/wire/models.test.ts b/tests/wire/models.test.ts new file mode 100644 index 0000000..00efd8d --- /dev/null +++ b/tests/wire/models.test.ts @@ -0,0 +1,183 @@ +// This file was auto-generated by Fern from our API Definition. + +import * as Speechify from "../../src/api/index"; +import { SpeechifyClient } from "../../src/Client"; +import { mockServerPool } from "../mock-server/MockServerPool"; + +describe("ModelsClient", () => { + test("list (1)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + + const rawResponseBody = { + models: [ + { + id: "simba-english", + name: "Simba English", + default: false, + recommended: false, + deprecated: true, + description: "Legacy Simba 1.6 English model, kept for compatibility. Prefer simba-3.2.", + languages: ["en"], + endpoints: ["/v1/audio/speech", "/v1/audio/stream"], + curated_voices: false, + english_voices_only: false, + }, + { + id: "simba-multilingual", + name: "Simba Multilingual", + default: false, + recommended: false, + deprecated: true, + description: + "Legacy Simba 1.6 multilingual model covering 30+ languages, including mixed-language input. Prefer simba-3.0 for the languages it supports.", + languages: ["en", "fr-FR", "de-DE", "es-MX", "pt-BR", "ja-JP"], + endpoints: ["/v1/audio/speech", "/v1/audio/stream"], + curated_voices: false, + english_voices_only: false, + }, + { + id: "simba-3.0", + name: "Simba 3.0", + default: true, + recommended: false, + deprecated: false, + description: + "Streaming-native synthesis in English and six European languages, routed by the request `language`. The default when a request omits `model`.", + languages: ["en", "de-DE", "es-ES", "es-MX", "fr-FR", "it-IT", "pt-BR"], + endpoints: ["/v1/audio/speech", "/v1/audio/stream", "/v1/audio/stream/with-timestamps"], + curated_voices: false, + english_voices_only: false, + }, + { + id: "simba-3.2", + name: "Simba 3.2", + default: false, + recommended: true, + deprecated: false, + description: + "Streaming-native model with the lowest time-to-first-byte and richest expressivity, English only today. Serves the curated voice roster, plus your workspace's own cloned voices where cloning has been enabled for it.", + languages: ["en"], + endpoints: ["/v1/audio/speech", "/v1/audio/stream", "/v1/audio/stream/with-timestamps"], + curated_voices: true, + english_voices_only: true, + }, + ], + dialogue_models: [ + { + id: "simba-dialogue-1.0", + name: "Simba Dialogue 1.0", + default: true, + recommended: false, + deprecated: false, + description: + "Multi-speaker model that renders a speaker-attributed script as one conversation with natural turn-taking.", + languages: ["en"], + endpoints: ["/v1/audio/dialogue"], + curated_voices: false, + english_voices_only: true, + }, + ], + }; + + server.mockEndpoint().get("/v1/audio/models").respondWith().statusCode(200).jsonBody(rawResponseBody).build(); + + const response = await client.models.list(); + expect(response).toEqual(rawResponseBody); + }); + + test("list (2)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + + const rawResponseBody = { key: "value" }; + + server.mockEndpoint().get("/v1/audio/models").respondWith().statusCode(400).jsonBody(rawResponseBody).build(); + + await expect(async () => { + return await client.models.list(); + }).rejects.toThrow(Speechify.BadRequestError); + }); + + test("list (3)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + + const rawResponseBody = { key: "value" }; + + server.mockEndpoint().get("/v1/audio/models").respondWith().statusCode(401).jsonBody(rawResponseBody).build(); + + await expect(async () => { + return await client.models.list(); + }).rejects.toThrow(Speechify.UnauthorizedError); + }); + + test("list (4)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + + const rawResponseBody = { error: { code: "bad_request", message: "message" } }; + + server.mockEndpoint().get("/v1/audio/models").respondWith().statusCode(403).jsonBody(rawResponseBody).build(); + + await expect(async () => { + return await client.models.list(); + }).rejects.toThrow(Speechify.ForbiddenError); + }); + + test("list (5)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + + const rawResponseBody = { error: { code: "bad_request", message: "message" } }; + + server.mockEndpoint().get("/v1/audio/models").respondWith().statusCode(429).jsonBody(rawResponseBody).build(); + + await expect(async () => { + return await client.models.list(); + }).rejects.toThrow(Speechify.TooManyRequestsError); + }); + + test("list (6)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + + const rawResponseBody = { error: { code: "bad_request", message: "message" } }; + + server.mockEndpoint().get("/v1/audio/models").respondWith().statusCode(500).jsonBody(rawResponseBody).build(); + + await expect(async () => { + return await client.models.list(); + }).rejects.toThrow(Speechify.InternalServerError); + }); +}); diff --git a/tests/wire/voices.test.ts b/tests/wire/voices.test.ts index fa0e04d..7f0f981 100644 --- a/tests/wire/voices.test.ts +++ b/tests/wire/voices.test.ts @@ -41,7 +41,10 @@ describe("VoicesClient", () => { .build(); const expected = rawResponseBody; - const page = await client.voices.list(); + const page = await client.voices.list({ + locale: "en", + model: "simba-3.2", + }); expect(expected.voices).toEqual(page.data); expect(page.hasNextPage()).toBe(true); diff --git a/tests/wire/voices/consentChallenges.test.ts b/tests/wire/voices/consentChallenges.test.ts new file mode 100644 index 0000000..489118b --- /dev/null +++ b/tests/wire/voices/consentChallenges.test.ts @@ -0,0 +1,282 @@ +// This file was auto-generated by Fern from our API Definition. + +import * as Speechify from "../../../src/api/index"; +import { SpeechifyClient } from "../../../src/Client"; +import { mockServerPool } from "../../mock-server/MockServerPool"; + +describe("ConsentChallengesClient", () => { + test("create (1)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { full_name: "Jane Doe" }; + const rawResponseBody = { + id: "9f8a1c04e7b24d1e8a3f", + phrase: "I agree to have my voice cloned by Speechify. My verification code is four seven two nine.", + expires_at: "2026-10-01T09:05:00Z", + }; + + server + .mockEndpoint() + .post("/v1/voices/consent-challenges") + .header("Idempotency-Key", "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(200) + .jsonBody(rawResponseBody) + .build(); + + const response = await client.voices.consentChallenges.create({ + "Idempotency-Key": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + full_name: "Jane Doe", + }); + expect(response).toEqual(rawResponseBody); + }); + + test("create (2)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { full_name: "full_name" }; + const rawResponseBody = { key: "value" }; + + server + .mockEndpoint() + .post("/v1/voices/consent-challenges") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(400) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.voices.consentChallenges.create({ + full_name: "full_name", + }); + }).rejects.toThrow(Speechify.BadRequestError); + }); + + test("create (3)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { full_name: "full_name" }; + const rawResponseBody = { key: "value" }; + + server + .mockEndpoint() + .post("/v1/voices/consent-challenges") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(401) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.voices.consentChallenges.create({ + full_name: "full_name", + }); + }).rejects.toThrow(Speechify.UnauthorizedError); + }); + + test("create (4)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { full_name: "full_name" }; + const rawResponseBody = { error: { code: "bad_request", message: "message" } }; + + server + .mockEndpoint() + .post("/v1/voices/consent-challenges") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(402) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.voices.consentChallenges.create({ + full_name: "full_name", + }); + }).rejects.toThrow(Speechify.PaymentRequiredError); + }); + + test("create (5)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { full_name: "full_name" }; + const rawResponseBody = { error: { code: "bad_request", message: "message" } }; + + server + .mockEndpoint() + .post("/v1/voices/consent-challenges") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(403) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.voices.consentChallenges.create({ + full_name: "full_name", + }); + }).rejects.toThrow(Speechify.ForbiddenError); + }); + + test("create (6)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { full_name: "full_name" }; + const rawResponseBody = { key: "value" }; + + server + .mockEndpoint() + .post("/v1/voices/consent-challenges") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(409) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.voices.consentChallenges.create({ + full_name: "full_name", + }); + }).rejects.toThrow(Speechify.ConflictError); + }); + + test("create (7)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { full_name: "full_name" }; + const rawResponseBody = { error: { code: "bad_request", message: "message" } }; + + server + .mockEndpoint() + .post("/v1/voices/consent-challenges") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(429) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.voices.consentChallenges.create({ + full_name: "full_name", + }); + }).rejects.toThrow(Speechify.TooManyRequestsError); + }); + + test("create (8)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { full_name: "full_name" }; + const rawResponseBody = { error: { code: "bad_request", message: "message" } }; + + server + .mockEndpoint() + .post("/v1/voices/consent-challenges") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(500) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.voices.consentChallenges.create({ + full_name: "full_name", + }); + }).rejects.toThrow(Speechify.InternalServerError); + }); + + test("create (9)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { full_name: "full_name" }; + const rawResponseBody = { error: { code: "bad_request", message: "message" } }; + + server + .mockEndpoint() + .post("/v1/voices/consent-challenges") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(502) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.voices.consentChallenges.create({ + full_name: "full_name", + }); + }).rejects.toThrow(Speechify.BadGatewayError); + }); + + test("create (10)", async () => { + const server = mockServerPool.createServer(); + const client = new SpeechifyClient({ + maxRetries: 0, + token: "test", + version: "test", + environment: server.baseUrl, + }); + const rawRequestBody = { full_name: "full_name" }; + const rawResponseBody = { error: { code: "bad_request", message: "message" } }; + + server + .mockEndpoint() + .post("/v1/voices/consent-challenges") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(503) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.voices.consentChallenges.create({ + full_name: "full_name", + }); + }).rejects.toThrow(Speechify.ServiceUnavailableError); + }); +}); From 42f1b8a63c45c7b8deb4c608e7a298e4d3c099b1 Mon Sep 17 00:00:00 2001 From: Luke Oliff <289678208+luke-speechify@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:07:00 +0100 Subject: [PATCH 02/22] fix: wire release-please to bump every version-bearing property Covers .fern/metadata.json, stamps the Fern-generated version strings from the release tag at publish time, corrects repository.url casing for npm provenance, restores AGENTS.md + manual-publish.yml, and gates publish on a version/tag assertion. src/BaseClient.ts is Fern-generated and cannot be .fernignore'd, so an x-release-please-version marker does not survive there: every regeneration strips it and the generic updater goes back to silently no-opping, leaving X-Fern-SDK-Version and User-Agent reporting a version the SDK is not. That is why main ships 3.0.1 while sending 2.0.1. src/version.ts was shielded in .fernignore as a workaround for the same problem, but 3e8ea41 had already proven regen wins. Both generic extra-files entries and both markers are gone, and src/version.ts leaves .fernignore so Fern owns it again. The publish job now rewrites those literals from the release tag, and the stamp fails when a target literal is not found, so a regeneration that renames or restructures those lines breaks the release loudly instead of shipping a stale version silently. Order is stamp, build, assert, publish: the build emits dist/, which is what npm ships, so a stamp after it would fix the sources and still publish stale strings inside the artifact. The assertion covers dist/ in both CJS and ESM as well as the sources, so a build that missed the stamp cannot pass. The stamp applies to the CI checkout only and is never committed back, so those strings read stale on main between releases by design; the published artifact is always built from the stamped and asserted tree. AGENTS.md documents this so it does not get "fixed" later. --- .fernignore | 21 ++ .github/workflows/manual-publish.yml | 86 ++++++++ .github/workflows/release-please.yml | 286 +++++++++++++++++++++++++++ AGENTS.md | 285 ++++++++++++++++++++++++++ package.json | 2 +- release-please-config.json | 5 +- 6 files changed, 682 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/manual-publish.yml create mode 100644 AGENTS.md diff --git a/.fernignore b/.fernignore index 7f697f3..0ae32ea 100644 --- a/.fernignore +++ b/.fernignore @@ -10,5 +10,26 @@ release-please-config.json .release-please-manifest.json CHANGELOG.md +# Manual workflow_dispatch publisher for an already-tagged release whose +# automatic publish did not run. Not generated by Fern — keep it listed or +# regen deletes the recovery path. +.github/workflows/manual-publish.yml + +# Release plumbing runbook. Not generated by Fern. +AGENTS.md + +# NOTE: src/version.ts and src/BaseClient.ts are deliberately NOT listed. Both +# carry the SDK version as a hardcoded literal, and release-please can only +# rewrite those through an `x-release-please-version` marker comment that regen +# strips every time. The version strings are stamped from the release tag in +# release-please.yml instead, so neither file needs shielding and Fern can keep +# ownership of both. + +# NOTE: package.json is deliberately NOT listed. Fern owns its `exports` map +# and dependency ranges, and freezing it would silently drop new subpath +# exports on regen. The cost is that regen keeps reintroducing a lowercase +# `repository.url`, which fails `npm publish --provenance` with a 422 — the +# pre-publish assertion in release-please.yml catches that before it ships. + # ignore context7 config file context7.json diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml new file mode 100644 index 0000000..4187f23 --- /dev/null +++ b/.github/workflows/manual-publish.yml @@ -0,0 +1,86 @@ +name: manual-publish + +# Manual, one-off publisher for an already-tagged release whose automatic +# publish did not run (or failed). Checks out an explicit tag/ref, asserts +# package.json version + repository.url, then publishes to npm with provenance. +on: + workflow_dispatch: + inputs: + ref: + description: "Git tag/ref to publish (e.g. 3.0.0)" + required: true + default: "3.0.0" + expected_version: + description: "Version package.json MUST declare" + required: true + default: "3.0.0" + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout ref + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + - name: Set up node + uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + - name: Enable corepack + run: corepack enable + # The expected repository URL is derived from the repo this workflow runs + # in, so a rename or a re-org can never leave a stale literal here. + - name: Assert version + repository casing + env: + EXPECTED_VERSION: ${{ inputs.expected_version }} + EXPECTED_REPOSITORY: ${{ github.repository }} + run: | + cat > "${RUNNER_TEMP}/assert-manual-publish.cjs" <<'NODE' + const fs = require("node:fs"); + const path = require("node:path"); + + const packageJson = JSON.parse( + fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"), + ); + const expectedVersion = process.env.EXPECTED_VERSION; + const expectedRepositoryUrl = `git+https://github.com/${process.env.EXPECTED_REPOSITORY}.git`; + + const failures = []; + if (packageJson.version !== expectedVersion) { + failures.push( + `package.json version is "${packageJson.version}", expected "${expectedVersion}"`, + ); + } + if (packageJson.repository?.url !== expectedRepositoryUrl) { + failures.push( + `package.json repository.url is "${packageJson.repository?.url}", expected ` + + `"${expectedRepositoryUrl}" — npm publish --provenance rejects a ` + + `case-inexact URL with HTTP 422`, + ); + } + + console.log(`package.json version: ${packageJson.version}`); + console.log(`package.json repository.url: ${packageJson.repository?.url}`); + console.log(`expected version: ${expectedVersion}`); + console.log(`expected repository.url: ${expectedRepositoryUrl}`); + + if (failures.length > 0) { + console.error("\nRefusing to publish:"); + for (const failure of failures) { + console.error(` - ${failure}`); + } + process.exit(1); + } + NODE + node "${RUNNER_TEMP}/assert-manual-publish.cjs" + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build + run: pnpm build + - name: Publish to npm + run: npm publish --provenance --access public diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 4280d79..8f1183c 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -76,8 +76,294 @@ jobs: run: corepack enable - name: Install dependencies run: pnpm install --frozen-lockfile + # src/BaseClient.ts and src/version.ts are Fern-generated and cannot be + # .fernignore'd, so an in-repo release-please marker does not survive: + # every regeneration strips the "// x-release-please-version" comments, + # the generic updater silently stops bumping these literals, and the SDK + # reports a version it is not. The release tag is the one input a + # regeneration cannot touch, so the versions are stamped from it. + # + # This runs BEFORE Build deliberately. `pnpm build` emits dist/, and dist/ + # is what npm actually ships; a stamp placed after it would fix the + # sources and still publish the stale strings inside the artifact. + # + # These rewrites apply to the CI checkout ONLY and are never committed + # back, so main keeps whatever version Fern last generated and those + # strings are expected to read stale between releases. That is by design, + # not drift — do not "fix" them on main. + - name: Stamp Fern-generated version strings from the release tag + env: + TAG_NAME: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + cat > "${RUNNER_TEMP}/stamp-release-versions.cjs" <<'NODE' + const fs = require("node:fs"); + const path = require("node:path"); + + const fail = (message) => { + console.error(`::error::${message}`); + process.exit(1); + }; + + // include-v-in-tag is false, so the tag is bare semver. A leading "v" is + // tolerated so flipping that setting later cannot fail a valid release. + // A prerelease or build suffix is legal and must survive verbatim: + // 4.0.0-alpha.1 stamps as 4.0.0-alpha.1, never as 4.0.0. Anything that is + // not semver means the tag is not what we think it is — stop before writing. + const BARE_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + + const rawTag = (process.env.TAG_NAME || "").trim(); + if (!rawTag) { + fail("release-please produced an empty tag_name — refusing to stamp"); + } + const tag = rawTag.startsWith("v") ? rawTag.slice(1) : rawTag; + if (!BARE_SEMVER.test(tag)) { + fail(`tag "${rawTag}" is not bare semver — refusing to stamp`); + } + + // Each pattern captures the literal surrounding the version so it is written + // back untouched; only the "version" group is replaced. They are anchored on + // the exact header names, which is what keeps "Speechify-Version" — the API + // date-version, not the SDK version — out of scope. + const STAMP_TARGETS = [ + { + file: "src/version.ts", + label: "SDK_VERSION", + pattern: /(?SDK_VERSION\s*=\s*")(?[^"]*)(?")/g, + }, + { + file: "src/BaseClient.ts", + label: "X-Fern-SDK-Version", + pattern: /(?"X-Fern-SDK-Version":\s*")(?[^"]*)(?")/g, + }, + { + file: "src/BaseClient.ts", + label: "User-Agent", + pattern: /(?"User-Agent":\s*"@speechify\/api\/)(?[^"]*)(?")/g, + }, + ]; + + const readTextOrFail = (relativePath) => { + try { + return fs.readFileSync(path.join(process.cwd(), relativePath), "utf8"); + } catch (error) { + return fail(`cannot read ${relativePath}: ${error.message}`); + } + }; + + const stampTargetInto = (source, target) => { + const matches = [...source.matchAll(target.pattern)]; + if (matches.length === 0) { + // Never skip. A regeneration that renames or restructures this line has + // to break the release here, loudly — a stamp that quietly finds nothing + // is exactly how 2.0.1 shipped under a 3.0.0 tag. + fail( + `${target.file}: no ${target.label} version literal matched ` + + `${target.pattern} — Fern regeneration has changed this file. Update ` + + `the stamp step and the publish assertion before releasing.`, + ); + } + for (const match of matches) { + const previous = match.groups.version; + const status = previous === tag ? "unchanged" : "stamped"; + console.log(` [${status}] ${target.file} ${target.label}: ${previous} -> ${tag}`); + } + return source.replace(target.pattern, `$${tag}$`); + }; + + console.log(`release tag: ${rawTag} (normalised: ${tag})`); + + const originalFiles = new Map(); + const stampedFiles = new Map(); + for (const target of STAMP_TARGETS) { + if (!originalFiles.has(target.file)) { + const contents = readTextOrFail(target.file); + originalFiles.set(target.file, contents); + stampedFiles.set(target.file, contents); + } + stampedFiles.set(target.file, stampTargetInto(stampedFiles.get(target.file), target)); + } + + for (const [file, stamped] of stampedFiles) { + if (stamped === originalFiles.get(file)) { + console.log(`${file} already at ${tag}; nothing to rewrite.`); + continue; + } + fs.writeFileSync(path.join(process.cwd(), file), stamped); + console.log(`${file} stamped to ${tag}.`); + } + + // Defensive: release-please already commits this bump via release-type + // "node". Stamping it too makes the tag the single source of truth for + // every version the published artifact carries. + const PACKAGE_JSON = "package.json"; + const packageJsonText = readTextOrFail(PACKAGE_JSON); + let packageJson; + try { + packageJson = JSON.parse(packageJsonText); + } catch (error) { + fail(`cannot parse ${PACKAGE_JSON}: ${error.message}`); + } + if (typeof packageJson.version !== "string") { + fail(`${PACKAGE_JSON}: no string "version" field — refusing to stamp`); + } + + const previousPackageVersion = packageJson.version; + const packageStatus = previousPackageVersion === tag ? "unchanged" : "stamped"; + console.log( + ` [${packageStatus}] ${PACKAGE_JSON} version: ${previousPackageVersion} -> ${tag}`, + ); + if (previousPackageVersion === tag) { + console.log(`${PACKAGE_JSON} already at ${tag}; nothing to rewrite.`); + } else { + packageJson.version = tag; + fs.writeFileSync( + path.join(process.cwd(), PACKAGE_JSON), + `${JSON.stringify(packageJson, null, 4)}\n`, + ); + console.log(`${PACKAGE_JSON} stamped to ${tag}.`); + } + NODE + node "${RUNNER_TEMP}/stamp-release-versions.cjs" - name: Build run: pnpm build + # Every version-bearing property must agree with the release tag before + # anything reaches npm. This runs AFTER Build so it can check dist/ — the + # code that actually ships — as well as the sources the stamp step rewrote. + # It is an independent check of the stamp's result, not a restatement of + # it: the patterns are declared separately on purpose, and it still covers + # the files nothing stamps. + - name: Assert versions match release tag + env: + TAG_NAME: ${{ needs.release-please.outputs.tag_name }} + EXPECTED_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + cat > "${RUNNER_TEMP}/assert-release-versions.cjs" <<'NODE' + const fs = require("node:fs"); + const path = require("node:path"); + + const fail = (message) => { + console.error(`::error::${message}`); + process.exit(1); + }; + + // include-v-in-tag is false, so the tag is bare semver. A leading "v" is + // tolerated so flipping that setting later cannot fail a valid release, and + // a prerelease suffix (4.0.0-alpha.1) is compared verbatim. Normalisation + // matches the stamp step exactly, or the two would disagree by construction. + const BARE_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + + const rawTag = (process.env.TAG_NAME || "").trim(); + if (!rawTag) { + fail("TAG_NAME is empty — refusing to publish an unidentified release"); + } + const tag = rawTag.startsWith("v") ? rawTag.slice(1) : rawTag; + if (!BARE_SEMVER.test(tag)) { + fail(`tag "${rawTag}" is not bare semver — refusing to publish`); + } + + const readTextOrFail = (relativePath, hint) => { + const absolutePath = path.join(process.cwd(), relativePath); + if (!fs.existsSync(absolutePath)) { + fail(`${relativePath} does not exist — ${hint}`); + } + return fs.readFileSync(absolutePath, "utf8"); + }; + + const readJsonOrFail = (relativePath, hint) => { + const text = readTextOrFail(relativePath, hint); + try { + return JSON.parse(text); + } catch (error) { + return fail(`cannot parse ${relativePath}: ${error.message}`); + } + }; + + const SDK_VERSION_PATTERN = /SDK_VERSION\s*=\s*"([^"]+)"/; + const FERN_SDK_VERSION_PATTERN = /"X-Fern-SDK-Version":\s*"([^"]+)"/; + const USER_AGENT_PATTERN = /"User-Agent":\s*"@speechify\/api\/([^"]+)"/; + + // src/ is what the stamp step rewrote; dist/ is what npm actually ships. + // Both are checked because they can disagree — a dist/ built before the + // stamp, or left over from an earlier build, carries the old strings while + // the sources look correct. + const TEXT_TARGETS = [ + ["src/version.ts", "SDK_VERSION", SDK_VERSION_PATTERN], + ["src/BaseClient.ts", "X-Fern-SDK-Version", FERN_SDK_VERSION_PATTERN], + ["src/BaseClient.ts", "User-Agent", USER_AGENT_PATTERN], + ["dist/cjs/version.js", "SDK_VERSION", SDK_VERSION_PATTERN], + ["dist/cjs/BaseClient.js", "X-Fern-SDK-Version", FERN_SDK_VERSION_PATTERN], + ["dist/cjs/BaseClient.js", "User-Agent", USER_AGENT_PATTERN], + ["dist/esm/version.mjs", "SDK_VERSION", SDK_VERSION_PATTERN], + ["dist/esm/BaseClient.mjs", "X-Fern-SDK-Version", FERN_SDK_VERSION_PATTERN], + ["dist/esm/BaseClient.mjs", "User-Agent", USER_AGENT_PATTERN], + ]; + + const packageJson = readJsonOrFail("package.json", "the checkout is incomplete"); + const metadata = readJsonOrFail(".fern/metadata.json", "the checkout is incomplete"); + if (typeof metadata.sdkVersion !== "string") { + fail(".fern/metadata.json has no string sdkVersion — the json updater is not running"); + } + + const observedVersions = [ + ["package.json version", packageJson.version], + [".fern/metadata.json sdkVersion", metadata.sdkVersion], + ]; + + for (const [file, label, pattern] of TEXT_TARGETS) { + const hint = file.startsWith("dist/") + ? "the Build step did not produce it" + : "Fern regeneration has moved or renamed it"; + const match = readTextOrFail(file, hint).match(pattern); + // A missing match means the literal moved. That is the exact failure this + // step exists to catch, so it is fatal rather than skipped. + if (!match) { + fail( + `${file}: no ${label} version literal matched ${pattern} — the code has ` + + `moved, so the stamp step is no longer rewriting it`, + ); + } + observedVersions.push([`${file} ${label}`, match[1]]); + } + + // npm resolves repository.url to a repo slug for provenance and rejects a + // case-inexact one with HTTP 422, so compare case-sensitively. + const expectedRepositoryUrl = `https://github.com/${process.env.EXPECTED_REPOSITORY}`; + const declaredRepositoryUrl = String(packageJson.repository?.url) + .replace(/^git\+/, "") + .replace(/\.git$/, ""); + + console.log(`release tag: ${rawTag} (normalised: ${tag})`); + for (const [label, version] of observedVersions) { + console.log(` [${version === tag ? "ok" : "MISMATCH"}] ${label}: ${version}`); + } + console.log(`package.json repository.url: ${packageJson.repository?.url}`); + console.log(`expected repository.url: ${expectedRepositoryUrl}`); + + const failures = observedVersions + .filter(([, version]) => version !== tag) + .map(([label, version]) => `${label} is "${version}", expected "${tag}"`); + + if (declaredRepositoryUrl !== expectedRepositoryUrl) { + failures.push( + `package.json repository.url resolves to "${declaredRepositoryUrl}", expected ` + + `"${expectedRepositoryUrl}" — npm publish --provenance rejects a ` + + `case-inexact URL with HTTP 422`, + ); + } + + if (failures.length > 0) { + console.error("\nRefusing to publish:"); + for (const failure of failures) { + console.error(` - ${failure}`); + } + process.exit(1); + } + + console.log(`\nAll version-bearing properties agree with ${tag}.`); + NODE + node "${RUNNER_TEMP}/assert-release-versions.cjs" - name: Publish to npm env: TAG_NAME: ${{ needs.release-please.outputs.tag_name }} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9e75dcb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,285 @@ +# Agent instructions — @speechify/api (TypeScript SDK) + +This is a **Fern-generated** SDK. It is published to **npm** as `@speechify/api`. +The source of truth for the API surface lives in the `SpeechifyInc/speechify-api` +repo (`fern/`); this repo receives generated code on the `sdk-release` branch. + +**Read this before doing anything that touches a release.** A botched release here +publishes to a public registry and breaks the contract with every customer who +installs the package. This file exists because we already did that once — see +"Postmortem" below. + +## The golden rule + +**Publishing to npm is effectively IRREVERSIBLE.** Unpublish is restricted to a +72-hour window and you can never republish the same version number. Treat every +publish as permanent. + +Never trigger a publish until you have, in order: + +1. **Audited the release plumbing** (this file, the workflow, the config, npm auth). +2. **Confirmed the version is correct in EVERY version-bearing file** (see checklist). +3. **Dry-run built and asserted the artifact version** (`npm publish --dry-run`). +4. **Got explicit human sign-off** for the publish itself. + +Do NOT admin-merge release PRs to force a publish. Do NOT bypass required reviews. + +## Version-surface checklist — the mistake we keep making + +The #1 failure mode is **a version string not getting bumped**, so the package +publishes under the wrong number (or reports a false version to the API). A single +stale string is a published contract break, not a typo. + +Every one of these must agree with the release tag **in the published artifact**. +Where each is set differs, and so does whether it is meaningful on `main`: + +| Property | File | Set by | Correct on `main`? | +|---|---|---|---| +| `"."` | `.release-please-manifest.json` | release-please (built in) | yes | +| `version` | `package.json` | release-please (`release-type: node`) | yes | +| `sdkVersion` | `.fern/metadata.json` | `json` extra-file (jsonpath `$.sdkVersion`) | yes | +| `SDK_VERSION` | `src/version.ts` | **stamped from the tag at publish** | **no — stale by design** | +| `X-Fern-SDK-Version` | `src/BaseClient.ts` | **stamped from the tag at publish** | **no — stale by design** | +| `User-Agent` | `src/BaseClient.ts` | **stamped from the tag at publish** | **no — stale by design** | +| the git tag | — | release-please | — | +| tarball version | — | `npm publish --dry-run` prints `version:` | — | + +Do **not** touch `"Speechify-Version"` in `src/BaseClient.ts`. That is the API +date-version (e.g. `2026-09-13`), not the SDK version, and it moves independently. + +Fast audit: + +```bash +grep -nE '"version"' package.json .release-please-manifest.json +grep -n 'sdkVersion' .fern/metadata.json +# stamped at publish — expected to disagree with the last release on main +grep -n 'SDK_VERSION' src/version.ts +grep -nE 'X-Fern-SDK-Version|User-Agent' src/BaseClient.ts +``` + +`SDK_VERSION` and `X-Fern-SDK-Version` are reported to the API. If they lie, +telemetry, version-gating, and support debugging are all wrong for that release. +(In the 3.0.0 release, the published tarball shipped `SDK_VERSION = "2.0.1"` — a +live contract break. `.fern/metadata.json` was stale at `2.0.1` on `main` for just +as long.) + +## How versions get set + +Two mechanisms, split by who owns the file. + +**release-please commits these to `main`** as part of the release PR: + +- `.release-please-manifest.json` — built in +- `package.json` `version` — `release-type: node` +- `.fern/metadata.json` `sdkVersion` — `json` extra-file, jsonpath `$.sdkVersion` + +All three are addressed structurally — a manifest key, a known field, a jsonpath. +None of them needs an in-file marker, so **a Fern regen cannot disarm them.** + +**The publish job stamps these from the release tag**, in the CI checkout only: + +- `src/version.ts` `SDK_VERSION` +- `src/BaseClient.ts` `X-Fern-SDK-Version` +- `src/BaseClient.ts` `User-Agent` +- `package.json` `version` — defensive; release-please has already set it + +### Why the stamped files cannot use a marker + +A `type: "generic"` extra-file **silently does nothing** unless the target line +carries an inline `x-release-please-version` comment. The updater walks the file +line by line, replaces the first semver-looking match on a marked line, and copies +every unmarked line through untouched. No marker means no error, no warning, no +change. + +`src/BaseClient.ts` is Fern-generated and **Fern legitimately owns it** — it +regenerates meaningfully, so `.fernignore` would strand real changes. The markers +are not in the generator templates, so **every regen strips them** and the generic +updater goes back to no-opping in silence. That is exactly how `main` came to ship +`3.0.1` while sending `X-Fern-SDK-Version: 2.0.1`. Commit `269d666` added the +marker, the regen in `69ff4e4` stripped it, and nothing restored it. + +The file cannot be shielded, and re-adding a marker after every regen is a manual +step that has already been missed. So the marker mechanism is gone: both `generic` +entries were deleted from `release-please-config.json` and both comments were +removed from the source. Do not add either back. + +`src/version.ts` was in `.fernignore` as a workaround for the same problem — +commit `3e8ea41` had already proven that a regen does not replay release-please's +commits. With the stamp in place the file no longer needs shielding, so it was +**removed from `.fernignore`** and Fern owns it again. One mechanism, no +exceptions. + +**The release tag is the source of truth.** It is the one input a regeneration +cannot touch. + +### The stamp runs BEFORE the build — this ordering is not negotiable + +`release-please.yml`'s `publish` job runs: + + install → stamp → build → assert → publish + +`pnpm build` emits `dist/`, and `dist/` is what npm ships. A stamp placed after +the build would fix the sources and still publish the stale strings baked into the +artifact. The assertion checks `dist/` (CJS **and** ESM) for exactly this reason, +so a build that did not pick up the stamp fails the job. + +The stamp **fails when a target literal is not found.** A regen that renames or +restructures those lines breaks the release loudly instead of shipping a stale +version silently. If it fires, update the pattern — never make the stamp tolerant. + +`include-v-in-tag` is false, so the tag is bare semver. A prerelease suffix is +legal and survives verbatim: `4.0.0-alpha.1` stamps as `4.0.0-alpha.1`, never as +`4.0.0`. An empty or non-semver tag fails the step. + +### Those strings read stale on `main` — by design + +The stamp rewrites the CI checkout and is **never committed back.** Between +releases `main` therefore carries whatever version Fern last generated in +`src/version.ts` and `src/BaseClient.ts`, which will usually not match the last +published version. + +**That is expected, not drift.** Do not "fix" it on `main`, do not open a PR to +sync it, and do not add a marker back. The published artifact is always correct +because it is built from the stamped and asserted tree. + +### `.fernignore` — what is shielded and what is not + +- `src/version.ts`, `src/BaseClient.ts`, `package.json` — **NOT listed, + deliberately.** Fern owns all three; their version strings are stamped at + publish, so freezing them would buy nothing and would strand real regenerated + changes (`BaseClient.ts`) or silently drop new subpath exports (`package.json` + `exports`). The cost for `package.json` is that regen keeps reintroducing a + lowercase `repository.url` (see below). +- `AGENTS.md`, `.github/workflows/release-please.yml`, + `.github/workflows/manual-publish.yml`, `.github/workflows/ci.yml`, + `release-please-config.json`, `.release-please-manifest.json`, `CHANGELOG.md`, + `context7.json` — all listed. A regen deletes anything not listed; that is how + `AGENTS.md` and `manual-publish.yml` went missing once already. + +## Operational checklist + +**After a Fern regen, before merging:** + +- [ ] `grep -rn 'x-release-please-version' src/ release-please-config.json` returns + **nothing** — a regen cannot add one, but a well-meaning human can. +- [ ] `release-please-config.json` `extra-files` has **no** `"type": "generic"` + entry; the only entry is the `.fern/metadata.json` jsonpath. +- [ ] `src/BaseClient.ts` still has `"X-Fern-SDK-Version": "…"` and + `"User-Agent": "@speechify/api/…"` as single-line literals, and + `src/version.ts` still has `SDK_VERSION = "…"`. If a regen restructured any + of them, update the stamp patterns **and** the assertion in + `release-please.yml` — the release will fail otherwise. +- [ ] `package.json` `repository.url` is `Speechify-AI`, not `speechify-ai`. +- [ ] `src/version.ts` / `src/BaseClient.ts` disagreeing with the last release is + **fine** — see above. Leave them. + +**Before a release:** + +- [ ] npm auth exists (Trusted Publisher or `NPM_TOKEN`). +- [ ] Publish job step order is still `stamp → build → assert → publish`. +- [ ] Human sign-off for the publish itself. + +**CI runs `pnpm build` and `pnpm test` only.** `pnpm check` (biome) is *not* wired +into CI and currently reports pre-existing findings — 3 format errors on +`release-please-config.json`, `.release-please-manifest.json` and `context7.json`, +plus lint warnings on generated sources. Do not treat those as a regression. + +## Known plumbing traps + +- **npm provenance requires case-exact `repository.url`.** `npm publish + --provenance` fails with **HTTP 422** unless `package.json` `repository.url` + matches the GitHub repo slug case-sensitively. The org is **`Speechify-AI`**, not + `speechify-ai`, so the correct value is + `git+https://github.com/Speechify-AI/sdk-typescript.git`. Commit `337aec1` exists + solely to fix this. The casing comes out of the generator config in + `SpeechifyInc/speechify-api` (`fern/generators.yml`) — fix it **there** so regens + carry it, otherwise every regen reintroduces the lowercase form and the assertion + blocks the release until someone corrects it by hand. +- **npm auth must exist for the publish to succeed.** The publish uses + `--provenance` with `id-token: write`. That signs provenance via OIDC but does + **NOT** by itself authenticate the upload — you still need EITHER a configured + **npm Trusted Publisher (OIDC)** for `@speechify/api` linked to this repo + + workflow, OR an `NPM_TOKEN` secret wired as `NODE_AUTH_TOKEN`. Without one, the + publish fails with **HTTP 404 "no permission"**. Verify auth BEFORE relying on + the automatic publish. +- **npm account 2FA is a passkey.** The CLI `--otp=` flow only accepts TOTP, not + passkeys, so a terminal `npm publish` prompts `EOTP` and cannot proceed with a + passkey. Use `npm login --auth-type=web` (browser passkey) for a local publish, + or an automation token / Trusted Publisher for CI. + +## The publish assertion + +`release-please.yml`'s `publish` job runs **Assert versions match release tag** +after the build and before `npm publish`. It is an independent check of the +stamp's result, not a restatement of it, and it also covers the files nothing +stamps. It fails the job when any of the following disagrees with +`needs.release-please.outputs.tag_name`: + +- `version` in `package.json` +- `sdkVersion` in `.fern/metadata.json` +- `SDK_VERSION` in `src/version.ts` +- `X-Fern-SDK-Version` and the `User-Agent` version in `src/BaseClient.ts` +- the same three literals in the **built output** — `dist/cjs/version.js`, + `dist/cjs/BaseClient.js`, `dist/esm/version.mjs`, `dist/esm/BaseClient.mjs` + +The `dist/` checks are what catch a build that ran before the stamp, or a stale +`dist/` left over from an earlier build. Source can look perfect while the tarball +ships the old strings; that is the failure mode the ordering exists to prevent, +and this is its backstop. + +It also fails when `repository.url` does not resolve to +`https://github.com//` for the repo it is running in, case included. +It prints every value and the tag, and it treats a **missing** literal or a +missing `dist/` file as a failure too. + +`include-v-in-tag` is false, so the tag is bare semver and may carry a prerelease +suffix (`4.0.0-alpha.1`); the comparison is verbatim against the tag. + +Do not weaken this step to unblock a release. If it fires, the release really is +mis-wired — fix the version wiring and let release-please cut a new PR. + +`manual-publish.yml` runs the same class of check for a one-off republish, and +derives the expected repository URL from `${{ github.repository }}` so it cannot go +stale on a rename. + +## Merging release PRs + +This repo allows **squash merge only**, and is configured with +`squash_merge_commit_title: PR_TITLE` and `squash_merge_commit_message: PR_BODY`. + +Consequences you must respect: + +- The **PR title** becomes the commit subject on `main`. It must be a valid + conventional-commit subject (`feat!:`, `fix:`, …) or release-please will not + classify the release. +- The **PR body** becomes the commit body, so footers in the body — `BREAKING + CHANGE:`, `Release-As:` — are what actually reach `main` and drive the next + version. A `Release-As: X.Y.Z` footer must be the **final line**, standalone, + unindented, and not inside backticks. +- Individual commit messages on the branch are discarded. Do not rely on them. + +## If a release has already gone wrong + +- **Wrong version on npm:** treat as permanent. Fix the version wiring, cut a NEW + correct version. Do not count on unpublish. +- **Tag points at a bad commit:** recreate the tag/GitHub release on the corrected + commit (public history mutation — back up the old SHA + notes first). Publishing + from a stale tag re-ships the old bug (e.g. the lowercase `repository.url`). +- Use `manual-publish.yml` for a one-off republish. It checks out an explicit tag + and asserts `version` + `repository.url` before publishing. + +## Postmortem — the 3.0.0 release (why this file exists) + +A routine regeneration was pushed straight to publish. Failures, in order: + +1. Breaking-change PRs were admin-merged past the review gate. +2. The automatic publish failed provenance (HTTP 422) because `repository.url` was + lowercase — npm was left at 2.0.0 while the tag said 3.0.0. +3. After fixing casing, publish failed again (HTTP 404) — no npm auth was + configured (no `NPM_TOKEN`, no Trusted Publisher). +4. When it finally published, the tarball still shipped `SDK_VERSION = "2.0.1"` + because the generic updater had silently no-oped — found reactively, late. + +Lesson: **audit the whole release surface first (versions AND auth AND +provenance), dry-run, get sign-off, then publish.** Treat one stale version +string as a signal to check every other one. diff --git a/package.json b/package.json index 7b12553..0ab9286 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": false, "repository": { "type": "git", - "url": "git+https://github.com/speechify-ai/sdk-typescript.git" + "url": "git+https://github.com/Speechify-AI/sdk-typescript.git" }, "type": "commonjs", "main": "./dist/cjs/index.js", diff --git a/release-please-config.json b/release-please-config.json index 8f382fb..bf2c853 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -8,8 +8,9 @@ "package-name": "@speechify/api", "extra-files": [ { - "type": "generic", - "path": "src/version.ts" + "type": "json", + "path": ".fern/metadata.json", + "jsonpath": "$.sdkVersion" } ] } From 285595dcbc0656eea77105c44f08b9767c885225 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:51:30 +0000 Subject: [PATCH 03/22] SDK regeneration --- .fern/metadata.json | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index 7159737..a5691ce 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,7 +7,7 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "10a6db0bf8fe7358d20861af81ae01b0c177f5f0", + "originGitCommit": "b9e4f81fa645383a553c8f2a55ceaeec4b109a6d", "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", diff --git a/README.md b/README.md index 92bfda0..704bffd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Speechify TypeScript Library -[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=https%3A%2F%2Fgithub.com%2Fspeechify-ai%2Fsdk-typescript) +[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=https%3A%2F%2Fgithub.com%2FSpeechify-AI%2Fsdk-typescript) [![npm shield](https://img.shields.io/npm/v/@speechify/api)](https://www.npmjs.com/package/@speechify/api) The Speechify TypeScript library provides convenient access to the Speechify APIs from TypeScript. @@ -43,7 +43,7 @@ npm i -s @speechify/api ## Reference -A full reference for this library is available [here](https://github.com/speechify-ai/sdk-typescript/blob/HEAD/./reference.md). +A full reference for this library is available [here](https://github.com/Speechify-AI/sdk-typescript/blob/HEAD/./reference.md). ## Usage From 35113277276efba8581fb76088a4cda6c199f70f Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:16:05 +0000 Subject: [PATCH 04/22] SDK regeneration --- .fern/metadata.json | 2 +- reference.md | 4 ++-- src/api/resources/voices/client/Client.ts | 2 +- .../voices/resources/consentChallenges/client/Client.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index a5691ce..d1e78b3 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,7 +7,7 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "b9e4f81fa645383a553c8f2a55ceaeec4b109a6d", + "originGitCommit": "cc4d5632fb69e345eca40b7320a5058aeff2b609", "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", diff --git a/reference.md b/reference.md index 28c6a5b..6a75213 100644 --- a/reference.md +++ b/reference.md @@ -409,7 +409,7 @@ const response = page.response; Create a cloned voice for the workspace from a 10-30 second audio sample, with verified consent from the speaker. -Cloning requires proof that the speaker agreed to it. Create a consent challenge with `POST /v1/voices/consent-challenges`, show the returned `phrase` to the speaker, record them reading it aloud, and send that recording here as `consent_recording` together with the challenge's `consent_challenge_id`. Speechify transcribes the recording, checks it against the phrase it issued, and keeps it as the consent record for the voice. A challenge is single use and short-lived, so record and submit in one sitting. +Cloning requires proof that the speaker agreed to it. Create a consent challenge with `POST /v1/voices/consent-challenges`, show the returned `phrase` to the speaker, record them reading it aloud, and send that recording here as `consent_recording` together with the challenge's `consent_challenge_id`. Speechify transcribes the recording, checks it against the phrase it issued, checks that its speaker is the speaker in your `sample`, and keeps it as the consent record for the voice. The person consenting therefore has to be the person being cloned. A challenge is single use and short-lived, so record and submit in one sitting. The clone belongs to the workspace rather than the member who created it, and access follows the caller's workspace role and API-key scopes exactly as for any other voice: voices scopes to list it, audio scopes to synthesize with it, and the content-management permission plus a write scope on the key to delete it. Cloned voices are usable self-serve on `simba-3.0`, `simba-english` and `simba-multilingual`. `simba-3.2` also serves cloned voices, currently as a limited release enabled per workspace; contact Speechify to have it enabled for yours. @@ -686,7 +686,7 @@ await client.voices.downloadSample({ Start the consent check for a voice clone. -Returns a `phrase` for the speaker to read aloud and an `id` that identifies this challenge. Show the phrase to the speaker exactly as returned, record them reading it, then send the recording and the `id` to `POST /v1/voices`, which verifies the recording against the phrase and keeps it as the consent record. +Returns a `phrase` for the speaker to read aloud and an `id` that identifies this challenge. Show the phrase to the speaker exactly as returned, record them reading it, then send the recording and the `id` to `POST /v1/voices`, which verifies the recording against the phrase and against the voice sample being cloned, then keeps it as the consent record. A challenge is single use, is bound to the workspace that created it, and expires at `expires_at` - it is proof that a speaker was in front of a microphone just now, so create it when you are ready to record, not at the start of your flow. If it expires, create another one and record again. diff --git a/src/api/resources/voices/client/Client.ts b/src/api/resources/voices/client/Client.ts index 15d8350..9226485 100644 --- a/src/api/resources/voices/client/Client.ts +++ b/src/api/resources/voices/client/Client.ts @@ -158,7 +158,7 @@ export class VoicesClient { /** * Create a cloned voice for the workspace from a 10-30 second audio sample, with verified consent from the speaker. * - * Cloning requires proof that the speaker agreed to it. Create a consent challenge with `POST /v1/voices/consent-challenges`, show the returned `phrase` to the speaker, record them reading it aloud, and send that recording here as `consent_recording` together with the challenge's `consent_challenge_id`. Speechify transcribes the recording, checks it against the phrase it issued, and keeps it as the consent record for the voice. A challenge is single use and short-lived, so record and submit in one sitting. + * Cloning requires proof that the speaker agreed to it. Create a consent challenge with `POST /v1/voices/consent-challenges`, show the returned `phrase` to the speaker, record them reading it aloud, and send that recording here as `consent_recording` together with the challenge's `consent_challenge_id`. Speechify transcribes the recording, checks it against the phrase it issued, checks that its speaker is the speaker in your `sample`, and keeps it as the consent record for the voice. The person consenting therefore has to be the person being cloned. A challenge is single use and short-lived, so record and submit in one sitting. * * The clone belongs to the workspace rather than the member who created it, and access follows the caller's workspace role and API-key scopes exactly as for any other voice: voices scopes to list it, audio scopes to synthesize with it, and the content-management permission plus a write scope on the key to delete it. Cloned voices are usable self-serve on `simba-3.0`, `simba-english` and `simba-multilingual`. `simba-3.2` also serves cloned voices, currently as a limited release enabled per workspace; contact Speechify to have it enabled for yours. * diff --git a/src/api/resources/voices/resources/consentChallenges/client/Client.ts b/src/api/resources/voices/resources/consentChallenges/client/Client.ts index fc1a3a8..f36fc04 100644 --- a/src/api/resources/voices/resources/consentChallenges/client/Client.ts +++ b/src/api/resources/voices/resources/consentChallenges/client/Client.ts @@ -25,7 +25,7 @@ export class ConsentChallengesClient { /** * Start the consent check for a voice clone. * - * Returns a `phrase` for the speaker to read aloud and an `id` that identifies this challenge. Show the phrase to the speaker exactly as returned, record them reading it, then send the recording and the `id` to `POST /v1/voices`, which verifies the recording against the phrase and keeps it as the consent record. + * Returns a `phrase` for the speaker to read aloud and an `id` that identifies this challenge. Show the phrase to the speaker exactly as returned, record them reading it, then send the recording and the `id` to `POST /v1/voices`, which verifies the recording against the phrase and against the voice sample being cloned, then keeps it as the consent record. * * A challenge is single use, is bound to the workspace that created it, and expires at `expires_at` - it is proof that a speaker was in front of a microphone just now, so create it when you are ready to record, not at the start of your flow. If it expires, create another one and record again. * From 2869f3fc74cc4a73e3d0702dd97abb35244cc19b Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:30:24 +0000 Subject: [PATCH 05/22] SDK regeneration --- .fern/metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index d1e78b3..7f96a3d 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,7 +7,7 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "cc4d5632fb69e345eca40b7320a5058aeff2b609", + "originGitCommit": "0e2aaa30e940ac7502d929aeff4d91f9415cfdf2", "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", From bd196f26c38a8dd16bca4a51a003415396d64947 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:44:05 +0000 Subject: [PATCH 06/22] SDK regeneration --- .fern/metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index 7f96a3d..96c5415 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,7 +7,7 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "0e2aaa30e940ac7502d929aeff4d91f9415cfdf2", + "originGitCommit": "03857fb6310960cacbae81e719c41f35010d0ce9", "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", From 6ba4ad5250116e08dcf7a8409e0f4768636f0c3d Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:01:09 +0000 Subject: [PATCH 07/22] SDK regeneration --- .fern/metadata.json | 2 +- reference.md | 2 +- src/api/resources/audio/client/Client.ts | 4 ++-- src/api/types/SpeechChunkEvent.ts | 26 +++++++++++++++++++++++ src/api/types/SpeechDoneEvent.ts | 12 +++++++++++ src/api/types/SpeechErrorEvent.ts | 17 +++++++++++++++ src/api/types/SpeechStreamEvent.ts | 27 ++++++++++++++++++++++++ src/api/types/index.ts | 4 ++++ tests/wire/audio.test.ts | 20 ++++++++++++++++-- 9 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 src/api/types/SpeechChunkEvent.ts create mode 100644 src/api/types/SpeechDoneEvent.ts create mode 100644 src/api/types/SpeechErrorEvent.ts create mode 100644 src/api/types/SpeechStreamEvent.ts diff --git a/.fern/metadata.json b/.fern/metadata.json index 96c5415..cf3135a 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,7 +7,7 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "03857fb6310960cacbae81e719c41f35010d0ce9", + "originGitCommit": "d33e7cfed6bc7c034884ec3ccf898f742cf1abf4", "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", diff --git a/reference.md b/reference.md index 6a75213..c1966b8 100644 --- a/reference.md +++ b/reference.md @@ -146,7 +146,7 @@ await client.audio.stream({ -
client.audio.streamWithTimestamps({ ...params }) -> core.Stream<string> +
client.audio.streamWithTimestamps({ ...params }) -> core.Stream<Speechify.SpeechStreamEvent>
diff --git a/src/api/resources/audio/client/Client.ts b/src/api/resources/audio/client/Client.ts index 053283c..c041972 100644 --- a/src/api/resources/audio/client/Client.ts +++ b/src/api/resources/audio/client/Client.ts @@ -287,14 +287,14 @@ export class AudioClient { public streamWithTimestamps( request: Speechify.StreamWithTimestampsAudioRequest, requestOptions?: AudioClient.RequestOptions, - ): core.HttpResponsePromise> { + ): core.HttpResponsePromise> { return core.HttpResponsePromise.fromPromise(this.__streamWithTimestamps(request, requestOptions)); } private async __streamWithTimestamps( request: Speechify.StreamWithTimestampsAudioRequest, requestOptions?: AudioClient.RequestOptions, - ): Promise>> { + ): Promise>> { const { Accept: accept, body: _body } = request; const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); const _headers: core.Fetcher.Args["headers"] = mergeHeaders( diff --git a/src/api/types/SpeechChunkEvent.ts b/src/api/types/SpeechChunkEvent.ts new file mode 100644 index 0000000..e02900d --- /dev/null +++ b/src/api/types/SpeechChunkEvent.ts @@ -0,0 +1,26 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as Speechify from "../index.js"; + +/** + * A run of synthesized audio, the speech marks that became final with it, + * or both - a chunk may carry only one of the two, and the last chunk of + * a stream is often marks-only. Mark times are absolute milliseconds from + * the start of the synthesis: concatenate the audio chunks into one + * stream and apply the marks against that single timeline. Which chunk a + * mark arrives on is a delivery detail and carries no meaning. + */ +export interface SpeechChunkEvent { + /** + * A run of the synthesized audio, Base64-encoded, in the format the + * request selected (echoed on the `Speechify-Audio-Content-Type` + * response header). Absent on a marks-only chunk. + */ + audio?: string | undefined; + /** + * Word timings addressing the original input text, with absolute + * millisecond times from the start of the synthesis. Absent when the + * chunk carries only audio. + */ + speech_marks?: Speechify.NestedChunk[] | undefined; +} diff --git a/src/api/types/SpeechDoneEvent.ts b/src/api/types/SpeechDoneEvent.ts new file mode 100644 index 0000000..174ce8b --- /dev/null +++ b/src/api/types/SpeechDoneEvent.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * Terminal event: the synthesis completed and no further events follow. + * There is no `[DONE]` sentinel. + */ +export interface SpeechDoneEvent { + /** Number of billable characters processed. */ + billable_characters_count: number; + /** Duration of the synthesized audio in milliseconds. */ + audio_duration_ms: number; +} diff --git a/src/api/types/SpeechErrorEvent.ts b/src/api/types/SpeechErrorEvent.ts new file mode 100644 index 0000000..3109b98 --- /dev/null +++ b/src/api/types/SpeechErrorEvent.ts @@ -0,0 +1,17 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as Speechify from "../index.js"; + +/** + * Terminal event carrying the standard error envelope, emitted when a + * failure happens after the stream has started and the status code is + * already committed. + */ +export interface SpeechErrorEvent { + error: Speechify.ErrorDetail; + /** + * Server-side request identifier. Echoes the `Speechify-Request-Id` + * response header. + */ + request_id?: string | undefined; +} diff --git a/src/api/types/SpeechStreamEvent.ts b/src/api/types/SpeechStreamEvent.ts new file mode 100644 index 0000000..165581f --- /dev/null +++ b/src/api/types/SpeechStreamEvent.ts @@ -0,0 +1,27 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as Speechify from "../index.js"; + +/** + * One event on the POST /v1/audio/stream/with-timestamps stream. The + * `type` field discriminates the variants and mirrors the SSE `event:` + * name, so an event is identifiable from its `data:` payload alone. + */ +export type SpeechStreamEvent = + | Speechify.SpeechStreamEvent.SpeechChunk + | Speechify.SpeechStreamEvent.SpeechDone + | Speechify.SpeechStreamEvent.SpeechError; + +export namespace SpeechStreamEvent { + export interface SpeechChunk extends Speechify.SpeechChunkEvent { + type: "speech.chunk"; + } + + export interface SpeechDone extends Speechify.SpeechDoneEvent { + type: "speech.done"; + } + + export interface SpeechError extends Speechify.SpeechErrorEvent { + type: "speech.error"; + } +} diff --git a/src/api/types/index.ts b/src/api/types/index.ts index be37ea1..6793d96 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -16,4 +16,8 @@ export * from "./Model.js"; export * from "./ModelsResponse.js"; export * from "./NestedChunk.js"; export * from "./PaginationMeta.js"; +export * from "./SpeechChunkEvent.js"; +export * from "./SpeechDoneEvent.js"; +export * from "./SpeechErrorEvent.js"; export * from "./SpeechMarks.js"; +export * from "./SpeechStreamEvent.js"; diff --git a/tests/wire/audio.test.ts b/tests/wire/audio.test.ts index 8d7e96c..132e360 100644 --- a/tests/wire/audio.test.ts +++ b/tests/wire/audio.test.ts @@ -318,7 +318,8 @@ describe("AudioClient", () => { model: "simba-3.2", voice_id: "geffen_32", }; - const rawResponseBody = 'event: \ndata: "string"\n\n'; + const rawResponseBody = + 'event: \ndata: {"audio":"SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYxLjcuMTAw...","speech_marks":[{"end":5,"end_time":320,"start":0,"start_time":0,"type":"word","value":"Hello"}],"type":"speech.chunk"}\n\n'; server .mockEndpoint() @@ -340,7 +341,22 @@ describe("AudioClient", () => { for await (const event of response) { events.push(event); } - expect(events).toEqual(["string"]); + expect(events).toEqual([ + { + type: "speech.chunk", + audio: "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYxLjcuMTAw...", + speech_marks: [ + { + end: 5, + end_time: 320, + start: 0, + start_time: 0, + type: "word", + value: "Hello", + }, + ], + }, + ]); }); test("streamWithTimestamps (2)", async () => { From 815185f26d03d4e51c7ca17af02863edb0942cca Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:17:55 +0000 Subject: [PATCH 08/22] SDK regeneration --- .fern/metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index cf3135a..856661a 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,7 +7,7 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "d33e7cfed6bc7c034884ec3ccf898f742cf1abf4", + "originGitCommit": "181fb6e145a080cb2cb044fa64a7c5e2638bd66d", "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", From 596ea1ed065b0582ec8192e2c887c46d42936a49 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:46:50 +0000 Subject: [PATCH 09/22] SDK regeneration --- .fern/metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index 856661a..7a15477 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,7 +7,7 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "181fb6e145a080cb2cb044fa64a7c5e2638bd66d", + "originGitCommit": "9b87dd733251b3bb18e005106fbe7c1ba651bc7a", "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", From e1bafd2514eda040741e5fc1414417d2d9be7cc2 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:49:19 +0000 Subject: [PATCH 10/22] SDK regeneration From 4212218f1a09ad2d1d8e78858d464843827e3fb2 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:51:30 +0000 Subject: [PATCH 11/22] SDK regeneration --- .fern/metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index 7a15477..235620f 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,7 +7,7 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "9b87dd733251b3bb18e005106fbe7c1ba651bc7a", + "originGitCommit": "03f284ab3f90b26e4bb7a82c8aa9ac7573d2d7e0", "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", From 618663af95ae9ffe3c340c171dab140110c6c49b Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:21:35 +0000 Subject: [PATCH 12/22] SDK regeneration --- .fern/metadata.json | 2 +- tests/wire/models.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index 235620f..f6979c0 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,7 +7,7 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "03f284ab3f90b26e4bb7a82c8aa9ac7573d2d7e0", + "originGitCommit": "950720e1d28678ca996c35f429b566b81aeef303", "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", diff --git a/tests/wire/models.test.ts b/tests/wire/models.test.ts index 00efd8d..931027b 100644 --- a/tests/wire/models.test.ts +++ b/tests/wire/models.test.ts @@ -18,7 +18,7 @@ describe("ModelsClient", () => { models: [ { id: "simba-english", - name: "Simba English", + name: "Simba 1.6 English", default: false, recommended: false, deprecated: true, @@ -30,7 +30,7 @@ describe("ModelsClient", () => { }, { id: "simba-multilingual", - name: "Simba Multilingual", + name: "Simba 1.6 Multilingual", default: false, recommended: false, deprecated: true, From b5db64f2416c75acf4873473f706514c6aea151f Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:09:43 +0000 Subject: [PATCH 13/22] SDK regeneration --- .fern/metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index f6979c0..b49b33c 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,7 +7,7 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "950720e1d28678ca996c35f429b566b81aeef303", + "originGitCommit": "70772af113ee9325533287ba25c90589a4eb99b8", "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", From 56de1777df31929ce00d85cdf45d2ed2ab8c790a Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:37:28 +0000 Subject: [PATCH 14/22] SDK regeneration --- .fern/metadata.json | 2 +- src/api/types/AudioOutputFormat.ts | 8 +++++++- src/api/types/AudioStreamOutputFormat.ts | 8 +++++++- src/api/types/GetSpeechResponse.ts | 2 +- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index b49b33c..54cb320 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,7 +7,7 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "70772af113ee9325533287ba25c90589a4eb99b8", + "originGitCommit": "87d84f2414b409b5f97f46bd95b076864ce9d39f", "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", diff --git a/src/api/types/AudioOutputFormat.ts b/src/api/types/AudioOutputFormat.ts index b24502e..f1c7c5c 100644 --- a/src/api/types/AudioOutputFormat.ts +++ b/src/api/types/AudioOutputFormat.ts @@ -1,6 +1,10 @@ // This file was auto-generated by Fern from our API Definition. -/** Audio output format as a `codec_sampleRate_bitrate` string, giving explicit control over sample rate and bitrate. `pcm_*` and `ulaw_8000` are headerless raw audio; `pcm_16000` and `ulaw_8000` are the telephony formats Twilio/LiveKit SIP expect. */ +/** + * Audio output format as a `codec_sampleRate_bitrate` string, giving explicit control over sample rate and bitrate. `pcm_*` and `ulaw_8000` are headerless raw audio; `pcm_16000` and `ulaw_8000` are the telephony formats Twilio/LiveKit SIP expect. + * + * 160 kbps is the highest bitrate an mp3 can carry at 22.05 and 24 kHz, so `mp3_22050_160` and `mp3_24000_160` are the maximum-fidelity mp3 formats; a request for `mp3_*_192` is encoded at 160 kbps and reported as the matching `mp3_*_160`. The two `mp3_*_160` formats are served by the Simba 3 models only. + */ export const AudioOutputFormat = { Pcm8000: "pcm_8000", Pcm16000: "pcm_16000", @@ -12,11 +16,13 @@ export const AudioOutputFormat = { Mp32205064: "mp3_22050_64", Mp32205096: "mp3_22050_96", Mp322050128: "mp3_22050_128", + Mp322050160: "mp3_22050_160", Mp322050192: "mp3_22050_192", Mp32400032: "mp3_24000_32", Mp32400064: "mp3_24000_64", Mp32400096: "mp3_24000_96", Mp324000128: "mp3_24000_128", + Mp324000160: "mp3_24000_160", Mp324000192: "mp3_24000_192", Wav24000: "wav_24000", Wav48000: "wav_48000", diff --git a/src/api/types/AudioStreamOutputFormat.ts b/src/api/types/AudioStreamOutputFormat.ts index 0013c28..fa16e83 100644 --- a/src/api/types/AudioStreamOutputFormat.ts +++ b/src/api/types/AudioStreamOutputFormat.ts @@ -1,6 +1,10 @@ // This file was auto-generated by Fern from our API Definition. -/** Audio output format for the streaming endpoint (`POST /v1/audio/stream`), as a `codec_sampleRate_bitrate` string. Same as `AudioOutputFormat` minus the `wav_*` formats: wav is only available on `POST /v1/audio/speech`. `pcm_*` and `ulaw_8000` are headerless raw audio; `pcm_16000` and `ulaw_8000` are the telephony formats Twilio/LiveKit SIP expect. */ +/** + * Audio output format for the streaming endpoint (`POST /v1/audio/stream`), as a `codec_sampleRate_bitrate` string. Same as `AudioOutputFormat` minus the `wav_*` formats: wav is only available on `POST /v1/audio/speech`. `pcm_*` and `ulaw_8000` are headerless raw audio; `pcm_16000` and `ulaw_8000` are the telephony formats Twilio/LiveKit SIP expect. + * + * 160 kbps is the highest bitrate an mp3 can carry at 22.05 and 24 kHz, so `mp3_22050_160` and `mp3_24000_160` are the maximum-fidelity mp3 formats; a request for `mp3_*_192` is encoded at 160 kbps. The two `mp3_*_160` formats are served by the Simba 3 models only. + */ export const AudioStreamOutputFormat = { Pcm8000: "pcm_8000", Pcm16000: "pcm_16000", @@ -12,11 +16,13 @@ export const AudioStreamOutputFormat = { Mp32205064: "mp3_22050_64", Mp32205096: "mp3_22050_96", Mp322050128: "mp3_22050_128", + Mp322050160: "mp3_22050_160", Mp322050192: "mp3_22050_192", Mp32400032: "mp3_24000_32", Mp32400064: "mp3_24000_64", Mp32400096: "mp3_24000_96", Mp324000128: "mp3_24000_128", + Mp324000160: "mp3_24000_160", Mp324000192: "mp3_24000_192", Ulaw8000: "ulaw_8000", Ogg24000: "ogg_24000", diff --git a/src/api/types/GetSpeechResponse.ts b/src/api/types/GetSpeechResponse.ts index 83cada3..b4b58c9 100644 --- a/src/api/types/GetSpeechResponse.ts +++ b/src/api/types/GetSpeechResponse.ts @@ -9,7 +9,7 @@ export interface GetSpeechResponse { audio_format: GetSpeechResponse.AudioFormat; /** The number of billable characters processed in the request. */ billable_characters_count: number; - /** The full `codec_sampleRate_bitrate` format, echoed back when the request set `output_format`. */ + /** The full `codec_sampleRate_bitrate` format the audio was encoded in, returned when the request set `output_format`. It is the requested value unless the request named a bitrate above the mp3 ceiling, in which case it reports the bitrate actually delivered. */ output_format?: Speechify.AudioOutputFormat | undefined; speech_marks: Speechify.SpeechMarks; } From c6943308e855b4b7dfae96af2afc007142fe5bb5 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:20:04 +0000 Subject: [PATCH 15/22] SDK regeneration --- .fern/metadata.json | 8 ++++---- package.json | 2 +- src/BaseClient.ts | 4 ++-- src/version.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index 54cb320..5821670 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,9 +7,9 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "87d84f2414b409b5f97f46bd95b076864ce9d39f", + "originGitCommit": "469722e153faa1a1c50aa63ea9cdca853f588721", "originGitCommitIsDirty": false, - "invokedBy": "ci", - "ciProvider": "github", - "sdkVersion": "3.0.2" + "invokedBy": "manual", + "requestedVersion": "4.0.0", + "sdkVersion": "4.0.0" } diff --git a/package.json b/package.json index 0ab9286..beff59d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@speechify/api", - "version": "3.0.2", + "version": "4.0.0", "private": false, "repository": { "type": "git", diff --git a/src/BaseClient.ts b/src/BaseClient.ts index 2d206f1..56fc834 100644 --- a/src/BaseClient.ts +++ b/src/BaseClient.ts @@ -63,8 +63,8 @@ export function normalizeClientOptions Date: Tue, 18 Aug 2026 19:03:12 +0100 Subject: [PATCH 16/22] fix: discriminate SSE events on type and harden stream abort streamWithTimestamps constructed core.Stream with no eventDiscriminator, routing SSE through iterDataMessages(). That parser drops the event: name, cannot concatenate a multi-line data: field, and discards a trailing event with no terminating blank line. The discarded trailing event is speech.done, which carries billable_characters_count. Passing eventDiscriminator: "type" routes to iterSseEvents() and fixes all three. The live API sends a populated event: speech.chunk name and also duplicates type in the payload. The generated wire fixture showing an empty event: does not match the API. core.Stream assigned a private AbortController that nothing read, and registered an abort listener closing over this. The handler was therefore a no-op, and a reused AbortSignal retained every Stream. Both are replaced with throwIfAborted(). The abort unit test passed on its own break statement rather than on the abort. It is rewritten so it fails when abort breaks. README and reference streaming examples now narrow on item.type, handle speech.error (yielded, never thrown, because the 200 is already committed) and show the base64 decode. .fernignore shields these until the Fern TypeScript generator emits the discriminator. fern-python-sdk@5.14.20 already generates a correct discriminated union on type from the same API definition; fern-typescript-sdk@3.70.1 emits none. --- .fernignore | 49 +++++++ README.md | 37 ++++- reference.md | 37 ++++- src/api/resources/audio/client/Client.ts | 1 + src/core/stream/Stream.ts | 27 +++- tests/unit/stream/Stream.test.ts | 176 +++++++++++++++++++++-- 6 files changed, 314 insertions(+), 13 deletions(-) diff --git a/.fernignore b/.fernignore index 0ae32ea..4a884ac 100644 --- a/.fernignore +++ b/.fernignore @@ -33,3 +33,52 @@ AGENTS.md # ignore context7 config file context7.json + +# SSE streaming fixes for POST /v1/audio/stream/with-timestamps. Each entry +# below records what has to land upstream before it can be deleted again. + +# Passes `eventDiscriminator: "type"` to core.Stream, which routes the response +# through iterSseEvents() instead of the defective iterDataMessages(). The live +# API sends a populated `event: speech.chunk` name, so the discriminator is +# correct. Without it, regen drops back to `{ type: "sse" }` and silently loses +# multi-line `data:` concatenation, the trailing flush that carries speech.done +# and its billable_characters_count, and CRLF handling. +# Remove once SpeechifyInc/speechify-api declares the SSE event discriminator on +# the with-timestamps endpoint (response-stream `format: sse` plus +# `event-discriminator: type`) so the generator emits it. +# COST: this freezes the whole audio resource client. Future API changes to any +# audio endpoint will not land on regen while this entry is listed. +src/api/resources/audio/client/Client.ts + +# Fixes abort handling in core.Stream. The generated version assigns a private +# AbortController that nothing ever reads, and registers an abort listener that +# closes over `this`, so the handler is a no-op and a reused AbortSignal retains +# every Stream built from it. The shielded version holds the signal and polls +# `aborted`, raising AbortError rather than ending iteration as a silent +# truncated success. +# This is generator template code, not API surface, so no change to +# SpeechifyInc/speechify-api can fix it. Remove once the fix ships in +# fernapi/fern-typescript-sdk (generated here at 3.70.1) and regen emits it. +src/core/stream/Stream.ts + +# Covers the shielded Stream.ts. Holds an abort test that actually fails when +# abort breaks (the generated one passed on its own `break` statement), plus +# coverage for the SSE shape the with-timestamps endpoint really sends. +# Remove together with src/core/stream/Stream.ts once the generator fix lands. +tests/unit/stream/Stream.test.ts + +# Streaming example narrows on `item.type` and handles speech.error explicitly. +# A mid-stream speech.error is yielded as a plain event, never thrown, because +# the 200 is already committed by then. The generated example reported that as a +# successful call with truncated audio. Also shows the +# Buffer.from(audio, "base64") decode the SDK does not do for the caller. +# Remove once the upstream endpoint example in SpeechifyInc/speechify-api +# carries the same handling, or once the SDKs agree to raise speech.error from +# the iterator, which is a cross-SDK decision affecting the Python SDK too. +README.md + +# Same streaming example fix as README.md, in the generated endpoint reference. +# COST: this freezes the entire reference. New endpoints, parameter changes and +# description edits will not appear here on regen while this entry is listed. +# Remove on the same upstream change as README.md. +reference.md diff --git a/README.md b/README.md index 704bffd..ca8a6f4 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,44 @@ const response = await client.audio.streamWithTimestamps({ voice_id: "geffen_32" } }); + +const audioChunks: Buffer[] = []; + for await (const item of response) { - console.log(item); + switch (item.type) { + case "speech.chunk": + // `audio` is Base64 and the SDK does not decode it for you. It is + // absent on a marks-only chunk, which the last chunk often is. + if (item.audio != null) { + audioChunks.push(Buffer.from(item.audio, "base64")); + } + // Mark times are absolute ms from the start of the synthesis, so + // they apply to the concatenated audio, not to this chunk. + for (const mark of item.speech_marks ?? []) { + console.log(mark.start_time, mark.value); + } + break; + + case "speech.error": + // A failure after the stream has opened arrives as an event, NOT as + // a thrown error: the 200 status is already committed. The SDK does + // not raise it for you, so handle it explicitly — otherwise the loop + // ends normally and you treat truncated audio as a success. + throw new Error(`${item.error.code}: ${item.error.message}`); + + case "speech.done": + // Terminal event. There is no `[DONE]` sentinel. + console.log("billable characters:", item.billable_characters_count); + console.log("audio duration (ms):", item.audio_duration_ms); + break; + + default: + // Ignore unrecognized event types so new ones cannot break you. + break; + } } + +const audio = Buffer.concat(audioChunks); ``` ## File Uploads diff --git a/reference.md b/reference.md index c1966b8..7c3caf1 100644 --- a/reference.md +++ b/reference.md @@ -204,10 +204,45 @@ const response = await client.audio.streamWithTimestamps({ voice_id: "geffen_32" } }); + +const audioChunks: Buffer[] = []; + for await (const item of response) { - console.log(item); + switch (item.type) { + case "speech.chunk": + // `audio` is Base64 and the SDK does not decode it for you. It is + // absent on a marks-only chunk, which the last chunk often is. + if (item.audio != null) { + audioChunks.push(Buffer.from(item.audio, "base64")); + } + // Mark times are absolute ms from the start of the synthesis, so + // they apply to the concatenated audio, not to this chunk. + for (const mark of item.speech_marks ?? []) { + console.log(mark.start_time, mark.value); + } + break; + + case "speech.error": + // A failure after the stream has opened arrives as an event, NOT as + // a thrown error: the 200 status is already committed. The SDK does + // not raise it for you, so handle it explicitly — otherwise the loop + // ends normally and you treat truncated audio as a success. + throw new Error(`${item.error.code}: ${item.error.message}`); + + case "speech.done": + // Terminal event. There is no `[DONE]` sentinel. + console.log("billable characters:", item.billable_characters_count); + console.log("audio duration (ms):", item.audio_duration_ms); + break; + + default: + // Ignore unrecognized event types so new ones cannot break you. + break; + } } +const audio = Buffer.concat(audioChunks); + ```
diff --git a/src/api/resources/audio/client/Client.ts b/src/api/resources/audio/client/Client.ts index c041972..c96bc8e 100644 --- a/src/api/resources/audio/client/Client.ts +++ b/src/api/resources/audio/client/Client.ts @@ -334,6 +334,7 @@ export class AudioClient { signal: requestOptions?.abortSignal, eventShape: { type: "sse", + eventDiscriminator: "type", }, }), rawResponse: _response.rawResponse, diff --git a/src/core/stream/Stream.ts b/src/core/stream/Stream.ts index 8ccdecf..2ca31dc 100644 --- a/src/core/stream/Stream.ts +++ b/src/core/stream/Stream.ts @@ -46,7 +46,7 @@ export class Stream implements AsyncIterable { private messageTerminator: string; private streamTerminator: string | undefined; private eventDiscriminator: string | undefined; - private controller: AbortController = new AbortController(); + private signal: AbortSignal | undefined; private decoder: TextDecoder | undefined; constructor({ stream, parse, eventShape, signal }: Stream.Args & { parse: (val: unknown) => Promise }) { @@ -60,7 +60,10 @@ export class Stream implements AsyncIterable { } else { this.messageTerminator = eventShape.messageTerminator; } - signal?.addEventListener("abort", () => this.controller.abort()); + // Held rather than subscribed to. An "abort" listener closes over `this`, + // so a long-lived reused signal would retain every Stream built from it; + // the read loop polls `aborted` instead and nothing outlives this object. + this.signal = signal; // Initialize shared TextDecoder if (typeof TextDecoder !== "undefined") { @@ -179,8 +182,28 @@ export class Stream implements AsyncIterable { return { [this.eventDiscriminator]: eventType, ...obj }; } + /** + * Halts iteration as soon as the caller's signal is aborted, so an aborted + * stream raises an AbortError instead of ending as a silent, truncated + * success. Rethrows the caller's own abort reason when one was given. + */ + private throwIfAborted(): void { + if (this.signal?.aborted !== true) { + return; + } + const abortReason: unknown = this.signal.reason; + if (abortReason != null) { + throw abortReason; + } + const abortError = new Error("The stream was aborted"); + abortError.name = "AbortError"; + throw abortError; + } + async *[Symbol.asyncIterator](): AsyncIterator { + this.throwIfAborted(); for await (const message of this.iterMessages()) { + this.throwIfAborted(); yield message; } } diff --git a/tests/unit/stream/Stream.test.ts b/tests/unit/stream/Stream.test.ts index 83575f0..a6a6e6d 100644 --- a/tests/unit/stream/Stream.test.ts +++ b/tests/unit/stream/Stream.test.ts @@ -1,3 +1,4 @@ +import { vi } from "vitest"; import { Stream } from "../../../src/core/stream/Stream"; describe("Stream", () => { @@ -366,6 +367,88 @@ describe("Stream", () => { }); }); + // The shape POST /v1/audio/stream/with-timestamps actually sends, verified + // against the live API: a populated `event:` name, a payload that already + // carries `type`, and a blank line after every frame. + describe("streamWithTimestamps SSE shape", () => { + const speechEventShape = { type: "sse", eventDiscriminator: "type" } as const; + + async function collect(chunks: string[]): Promise { + const stream = new Stream({ + stream: createReadableStream(chunks), + parse: async (val: unknown) => val, + eventShape: speechEventShape, + }); + + const messages: unknown[] = []; + for await (const message of stream) { + messages.push(message); + } + return messages; + } + + it("should keep the payload's own type when the event name agrees", async () => { + const messages = await collect(['event: speech.chunk\ndata: {"type":"speech.chunk","audio":"QUJD"}\n\n']); + + expect(messages).toEqual([{ type: "speech.chunk", audio: "QUJD" }]); + }); + + it("should keep the payload's own type when the event name is empty", async () => { + // The generated wire fixture still shows an empty `event:` name, so + // the discriminator must never overwrite an already-parsed type. + const messages = await collect(['event: \ndata: {"type":"speech.chunk","audio":"QUJD"}\n\n']); + + expect(messages).toEqual([{ type: "speech.chunk", audio: "QUJD" }]); + }); + + it("should concatenate a data field split across multiple lines", async () => { + const messages = await collect([ + 'event: speech.chunk\ndata: {"type":"speech.chunk",\ndata: "audio":"QUJD"}\n\n', + ]); + + expect(messages).toEqual([{ type: "speech.chunk", audio: "QUJD" }]); + }); + + it("should yield a final event that has no trailing blank line", async () => { + // speech.done carries billable_characters_count. Dropping the last + // event because the stream ended without a terminator loses it. + const messages = await collect([ + 'event: speech.done\ndata: {"type":"speech.done","billable_characters_count":31,"audio_duration_ms":1710}\n', + ]); + + expect(messages).toEqual([{ type: "speech.done", billable_characters_count: 31, audio_duration_ms: 1710 }]); + }); + + it("should not treat a 'data:' substring inside a payload value as a field", async () => { + const messages = await collect([ + 'event: speech.chunk\ndata: {"type":"speech.chunk","note":"data: not a field"}\n\n', + ]); + + expect(messages).toEqual([{ type: "speech.chunk", note: "data: not a field" }]); + }); + + it("should ignore a non-data line containing a 'data:' substring", async () => { + const messages = await collect([ + 'event: speech.chunk\n: keepalive data: ping\ndata: {"type":"speech.chunk","audio":"QUJD"}\n\n', + ]); + + expect(messages).toEqual([{ type: "speech.chunk", audio: "QUJD" }]); + }); + + it("should read a chunk/done sequence across chunk boundaries", async () => { + const messages = await collect([ + 'event: speech.chunk\ndata: {"type":"speech.chunk","audio":"QU', + 'JD"}\n\n\nevent: speech.done\ndata: {"type":"speech.done",', + '"billable_characters_count":31,"audio_duration_ms":1710}\n\n\n', + ]); + + expect(messages).toEqual([ + { type: "speech.chunk", audio: "QUJD" }, + { type: "speech.done", billable_characters_count: 31, audio_duration_ms: 1710 }, + ]); + }); + }); + describe("encoding and decoding", () => { it("should decode UTF-8 text using TextDecoder", async () => { const encoder = new TextEncoder(); @@ -447,7 +530,9 @@ describe("Stream", () => { }); describe("abort signal", () => { - it("should handle abort signal", async () => { + // No `break` in these loops on purpose: the only thing that can end + // iteration is the abort itself, so removing the abort fails the test. + it("should raise an AbortError and stop yielding when aborted mid-stream", async () => { const controller = new AbortController(); const mockStream = createReadableStream(['{"value": 1}\n{"value": 2}\n{"value": 3}\n']); const stream = new Stream({ @@ -458,17 +543,90 @@ describe("Stream", () => { }); const messages: unknown[] = []; - let count = 0; - for await (const message of stream) { - messages.push(message); - count++; - if (count === 2) { - controller.abort(); - break; + let caught: unknown; + try { + for await (const message of stream) { + messages.push(message); + if (messages.length === 2) { + controller.abort(); + } + } + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).name).toBe("AbortError"); + expect(messages).toEqual([{ value: 1 }, { value: 2 }]); + }); + + it("should yield nothing when the signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + const mockStream = createReadableStream(['{"value": 1}\n{"value": 2}\n']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "json", messageTerminator: "\n" }, + signal: controller.signal, + }); + + const messages: unknown[] = []; + let caught: unknown; + try { + for await (const message of stream) { + messages.push(message); + } + } catch (error) { + caught = error; + } + + expect((caught as Error).name).toBe("AbortError"); + expect(messages).toEqual([]); + }); + + it("should propagate the caller's own abort reason", async () => { + const controller = new AbortController(); + const reason = new Error("caller changed their mind"); + const mockStream = createReadableStream(['{"value": 1}\n{"value": 2}\n']); + const stream = new Stream({ + stream: mockStream, + parse: async (val: unknown) => val as { value: number }, + eventShape: { type: "json", messageTerminator: "\n" }, + signal: controller.signal, + }); + + const messages: unknown[] = []; + let caught: unknown; + try { + for await (const message of stream) { + messages.push(message); + controller.abort(reason); } + } catch (error) { + caught = error; } - expect(messages.length).toBe(2); + expect(caught).toBe(reason); + expect(messages).toEqual([{ value: 1 }]); + }); + + it("should not subscribe to the signal, so a reused signal retains no streams", () => { + const controller = new AbortController(); + const addEventListener = vi.spyOn(controller.signal, "addEventListener"); + + const streams = [1, 2, 3].map( + () => + new Stream({ + stream: createReadableStream([]), + parse: async (val: unknown) => val, + eventShape: { type: "json", messageTerminator: "\n" }, + signal: controller.signal, + }), + ); + + expect(streams).toHaveLength(3); + expect(addEventListener).not.toHaveBeenCalled(); }); }); From 68982979ddf1d80952bc3647a02a27f3e17dd7db Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:08:16 +0000 Subject: [PATCH 17/22] SDK regeneration --- .fern/metadata.json | 8 ++++---- package.json | 2 +- src/BaseClient.ts | 4 ++-- src/api/types/ErrorCode.ts | 4 ++++ src/version.ts | 2 +- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index 5821670..7eb75d1 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,9 +7,9 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "469722e153faa1a1c50aa63ea9cdca853f588721", + "originGitCommit": "cd9527ba61b3dd9af668b75ad5806f1774d6e7ee", "originGitCommitIsDirty": false, - "invokedBy": "manual", - "requestedVersion": "4.0.0", - "sdkVersion": "4.0.0" + "invokedBy": "ci", + "ciProvider": "github", + "sdkVersion": "3.0.2" } diff --git a/package.json b/package.json index beff59d..0ab9286 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@speechify/api", - "version": "4.0.0", + "version": "3.0.2", "private": false, "repository": { "type": "git", diff --git a/src/BaseClient.ts b/src/BaseClient.ts index 56fc834..2d206f1 100644 --- a/src/BaseClient.ts +++ b/src/BaseClient.ts @@ -63,8 +63,8 @@ export function normalizeClientOptions Date: Wed, 19 Aug 2026 14:36:19 +0100 Subject: [PATCH 18/22] chore: restore 4.0.0 version pin after regeneration rollback The fern-api[bot] regeneration in commit 6898297 reverted the version-bearing properties from 4.0.0 back to 3.0.2. This restores 4.0.0 in package.json, src/version.ts, .fern/metadata.json sdkVersion, and the X-Fern-SDK-Version and User-Agent literals in src/BaseClient.ts. This PR is a major release: it carries a "Release-As: 4.0.0" footer plus three "BREAKING CHANGE:" footers in its body, which is what reaches main on squash merge and drives the next version. .release-please-manifest.json is deliberately left at 3.0.1. Setting it to 4.0.0 would make release-please treat 4.0.0 as already released and never cut the tag. No files were added to .fernignore. package.json and .fern/metadata.json must stay regenerable, and a future regen clobbering these strings again is a known two-writer conflict between Fern and release-please, not something to fix by shielding. --- .fern/metadata.json | 2 +- package.json | 2 +- src/BaseClient.ts | 4 ++-- src/version.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index 7eb75d1..d17c0d4 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -11,5 +11,5 @@ "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", - "sdkVersion": "3.0.2" + "sdkVersion": "4.0.0" } diff --git a/package.json b/package.json index 0ab9286..beff59d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@speechify/api", - "version": "3.0.2", + "version": "4.0.0", "private": false, "repository": { "type": "git", diff --git a/src/BaseClient.ts b/src/BaseClient.ts index 2d206f1..56fc834 100644 --- a/src/BaseClient.ts +++ b/src/BaseClient.ts @@ -63,8 +63,8 @@ export function normalizeClientOptions Date: Wed, 19 Aug 2026 14:41:55 +0100 Subject: [PATCH 19/22] Revert "chore: restore 4.0.0 version pin after regeneration rollback" This reverts commit d267778ba7d930aabc240ce04995cdd62ca0e53b. The fern-api[bot] rollback to 3.0.2 in 6898297 was correct by design. AGENTS.md is explicit that SDK_VERSION in src/version.ts and the X-Fern-SDK-Version and User-Agent literals in src/BaseClient.ts are stamped from the release tag in the CI checkout at publish time and never committed back, so they are expected to read stale on the branch. Pinning them by hand buys nothing and is called out as something not to do. package.json version and .fern/metadata.json sdkVersion are owned by release-please and set as part of the release PR. Both are addressed structurally, by a known field and a jsonpath, so a regen cannot disarm them. The Release-As: 4.0.0 footer in the PR body is what drives the next version. --- .fern/metadata.json | 2 +- package.json | 2 +- src/BaseClient.ts | 4 ++-- src/version.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index d17c0d4..7eb75d1 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -11,5 +11,5 @@ "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", - "sdkVersion": "4.0.0" + "sdkVersion": "3.0.2" } diff --git a/package.json b/package.json index beff59d..0ab9286 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@speechify/api", - "version": "4.0.0", + "version": "3.0.2", "private": false, "repository": { "type": "git", diff --git a/src/BaseClient.ts b/src/BaseClient.ts index 56fc834..2d206f1 100644 --- a/src/BaseClient.ts +++ b/src/BaseClient.ts @@ -63,8 +63,8 @@ export function normalizeClientOptions Date: Wed, 19 Aug 2026 13:44:31 +0000 Subject: [PATCH 20/22] SDK regeneration --- .fern/metadata.json | 2 +- src/api/types/ErrorCode.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index 7eb75d1..4179b7e 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,7 +7,7 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "cd9527ba61b3dd9af668b75ad5806f1774d6e7ee", + "originGitCommit": "5bad5fd56908ff01eecc7c80b8a1dc7a95ede569", "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", diff --git a/src/api/types/ErrorCode.ts b/src/api/types/ErrorCode.ts index 407d8f4..cc192a6 100644 --- a/src/api/types/ErrorCode.ts +++ b/src/api/types/ErrorCode.ts @@ -48,6 +48,7 @@ export const ErrorCode = { AgentTestNotFound: "agent_test_not_found", WorkspaceNotFound: "workspace_not_found", InviteNotFound: "invite_not_found", + ProjectNotFound: "project_not_found", InsufficientScope: "insufficient_scope", PurchasedNumbersNotIncluded: "purchased_numbers_not_included", PhoneNumberQuotaReached: "phone_number_quota_reached", From 0a8cdacfa731a57caface14d53be6dbf0bdc3786 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:49:43 +0000 Subject: [PATCH 21/22] SDK regeneration --- .fern/metadata.json | 2 +- src/api/types/ErrorCode.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index 4179b7e..79b62ef 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,7 +7,7 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "5bad5fd56908ff01eecc7c80b8a1dc7a95ede569", + "originGitCommit": "d18d0fff37ec3446bf44071a930fcad840b961e6", "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", diff --git a/src/api/types/ErrorCode.ts b/src/api/types/ErrorCode.ts index cc192a6..1507fee 100644 --- a/src/api/types/ErrorCode.ts +++ b/src/api/types/ErrorCode.ts @@ -49,6 +49,7 @@ export const ErrorCode = { WorkspaceNotFound: "workspace_not_found", InviteNotFound: "invite_not_found", ProjectNotFound: "project_not_found", + CrossProjectReference: "cross_project_reference", InsufficientScope: "insufficient_scope", PurchasedNumbersNotIncluded: "purchased_numbers_not_included", PhoneNumberQuotaReached: "phone_number_quota_reached", From dc7c92d509766fdfac27259cab143a604996ffd1 Mon Sep 17 00:00:00 2001 From: Luke Oliff <289678208+luke-speechify@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:20:32 +0100 Subject: [PATCH 22/22] fix: throw ContentTooLargeError on oversized audio bodies POST /v1/audio/speech and streamWithTimestamps return 413 payload_too_large for oversized bodies, but the audio client surfaced it as a generic SpeechifyError. Wire the typed ContentTooLargeError on the audio client to match the voices client. Client.ts is .fernignore-shielded so this survives regeneration. --- src/api/resources/audio/client/Client.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/api/resources/audio/client/Client.ts b/src/api/resources/audio/client/Client.ts index c96bc8e..9f729bf 100644 --- a/src/api/resources/audio/client/Client.ts +++ b/src/api/resources/audio/client/Client.ts @@ -113,6 +113,11 @@ export class AudioClient { throw new Speechify.ForbiddenError(_response.error.body as Speechify.Error_, _response.rawResponse); case 404: throw new Speechify.NotFoundError(_response.error.body as unknown, _response.rawResponse); + case 413: + throw new Speechify.ContentTooLargeError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); case 429: throw new Speechify.TooManyRequestsError( _response.error.body as Speechify.Error_, @@ -225,6 +230,11 @@ export class AudioClient { throw new Speechify.ForbiddenError(_response.error.body as Speechify.Error_, _response.rawResponse); case 404: throw new Speechify.NotFoundError(_response.error.body as unknown, _response.rawResponse); + case 413: + throw new Speechify.ContentTooLargeError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); case 429: throw new Speechify.TooManyRequestsError( _response.error.body as Speechify.Error_, @@ -356,6 +366,11 @@ export class AudioClient { throw new Speechify.ForbiddenError(_response.error.body as Speechify.Error_, _response.rawResponse); case 404: throw new Speechify.NotFoundError(_response.error.body as unknown, _response.rawResponse); + case 413: + throw new Speechify.ContentTooLargeError( + _response.error.body as Speechify.Error_, + _response.rawResponse, + ); case 429: throw new Speechify.TooManyRequestsError( _response.error.body as Speechify.Error_,