diff --git a/.fern/metadata.json b/.fern/metadata.json index 78d1f36..79b62ef 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -7,9 +7,9 @@ "skipResponseValidation": true, "namespaceExport": "Speechify" }, - "originGitCommit": "9f50f669dbb3d0db633a6d904db8a5077b94764f", - "originGitCommitIsDirty": true, + "originGitCommit": "d18d0fff37ec3446bf44071a930fcad840b961e6", + "originGitCommitIsDirty": false, "invokedBy": "ci", - "ciProvider": "unknown", - "sdkVersion": "2.0.1" + "ciProvider": "github", + "sdkVersion": "3.0.2" } diff --git a/.fernignore b/.fernignore index 7f697f3..4a884ac 100644 --- a/.fernignore +++ b/.fernignore @@ -10,5 +10,75 @@ 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 + +# 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/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index 61685b4..4187f23 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -33,9 +33,51 @@ jobs: 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: | - 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)' + 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 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 index 11c5a9f..9e75dcb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,38 +26,175 @@ Do NOT admin-merge release PRs to force a publish. Do NOT bypass required review ## 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. +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. -When any version changes, **confirm ALL of these agree** before release. Never -check one and assume the rest: +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`: -- `.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:`) +| 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 -rnE '"version"|SDK_VERSION|"\."' \ - .release-please-manifest.json package.json src/version.ts +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` 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.) +`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 — 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. + 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 @@ -65,14 +202,62 @@ published tarball shipped `SDK_VERSION = "2.0.1"` — a live contract break.) 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. +## 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 @@ -80,9 +265,8 @@ published tarball shipped `SDK_VERSION = "2.0.1"` — a live contract break.) - **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. +- 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) @@ -90,7 +274,7 @@ 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. + 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"` diff --git a/README.md b/README.md index 00c54a4..ca8a6f4 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,62 @@ 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" + } +}); + +const audioChunks: Buffer[] = []; + +for await (const item of response) { + 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 You can upload files using the client: @@ -114,13 +171,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 +607,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..0ab9286 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..7c3caf1 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,201 @@ await client.audio.stream({ + + + + +
client.audio.streamWithTimestamps({ ...params }) -> core.Stream<Speechify.SpeechStreamEvent> +
+
+ +#### 📝 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" + } +}); + +const audioChunks: Buffer[] = []; + +for await (const item of response) { + 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); + +``` +
+
+
+
+ +#### ⚙️ 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 +355,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 +376,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 +442,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, 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. + +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 +465,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 +519,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 +586,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 +706,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 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. + +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/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" } ] } 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..9f729bf 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, ); @@ -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_, @@ -165,24 +170,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, ); @@ -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_, @@ -256,4 +266,145 @@ 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", + eventDiscriminator: "type", + }, + }), + 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 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_, + _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..9226485 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, 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. + * + * 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..f36fc04 --- /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 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. + * + * 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/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/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..1507fee 100644 --- a/src/api/types/ErrorCode.ts +++ b/src/api/types/ErrorCode.ts @@ -27,6 +27,10 @@ export const ErrorCode = { UpstreamFailure: "upstream_failure", ServiceUnavailable: "service_unavailable", CallerNotFound: "caller_not_found", + ContactNotFound: "contact_not_found", + ContactIdentifierNotFound: "contact_identifier_not_found", + ContactIdentifierConflict: "contact_identifier_conflict", + ContactResolverNotFound: "contact_resolver_not_found", CredentialNotFound: "credential_not_found", CredentialInUse: "credential_in_use", AgentNotFound: "agent_not_found", @@ -44,20 +48,40 @@ export const ErrorCode = { AgentTestNotFound: "agent_test_not_found", 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", 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/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; } 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/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 74a954b..6793d96 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -1,15 +1,23 @@ 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 "./SpeechChunkEvent.js"; +export * from "./SpeechDoneEvent.js"; +export * from "./SpeechErrorEvent.js"; export * from "./SpeechMarks.js"; +export * from "./SpeechStreamEvent.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..2ca31dc --- /dev/null +++ b/src/core/stream/Stream.ts @@ -0,0 +1,258 @@ +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 signal: AbortSignal | undefined; + 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; + } + // 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") { + 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 }; + } + + /** + * 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; + } + } + + 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..a6a6e6d --- /dev/null +++ b/tests/unit/stream/Stream.test.ts @@ -0,0 +1,721 @@ +import { vi } from "vitest"; +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" }]); + }); + }); + + // 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(); + 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", () => { + // 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({ + 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); + 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(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(); + }); + }); + + 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..132e360 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,328 @@ 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: {"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() + .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([ + { + 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 () => { + 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..931027b --- /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 1.6 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 1.6 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); + }); +});