Files
suaveplan/openspec/archive/2026-05-22-third-party-elevenlabs/proposal.md
2026-05-27 23:29:53 +01:00

225 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OpenSpec Change: third-party-elevenlabs
- **Change ID**: third-party-elevenlabs
- **Package**: `@suaveplan/third-party-elevenlabs`
- **Category**: universal
- **Target path**: `packages/audio/third-party-elevenlabs`
- **Status**: PROPOSED
- **Date**: 2026-05-02
- **Reference spec**: none — new capability (genesis-original)
---
## Summary
Deliver `@suaveplan/third-party-elevenlabs` on the genesis branch from scratch at enterprise quality. The package is a **typed thin client for the entire ElevenLabs platform API** — REST and WebSocket. It owns HTTP plumbing (auth, base URL, fetch wrapper), retry + circuit breaker via `@suaveplan/retry`, rate-limit-header parsing, structured error mapping, OpenTelemetry instrumentation, and Zod-validated request/response DTOs for every documented endpoint.
It is **not** a voice abstraction. It does not implement `@suaveplan/voice` interfaces. It is the substrate that domain adapter packages — `@suaveplan/voice-elevenlabs`, future `@suaveplan/conversational-elevenlabs`, `@suaveplan/music-elevenlabs`, `@suaveplan/dubbing-elevenlabs` — depend on. Domain adapters consume DTOs from this package and re-shape them into provider-agnostic domain types.
This split exists because the ElevenLabs API surface spans far beyond TTS/STT — Music, Dubbing, Studio, Audio Native, Conversational AI agents, History, Pronunciation Dictionaries, Workspace administration. Fusing HTTP plumbing into a single voice adapter would force every future surface to reinvent auth, retry, rate-limiting, OTel, and error mapping.
---
## Why a separate package
1. **DRY HTTP plumbing.** Auth (`xi-api-key` header), base URL handling, idempotent retry, `Retry-After` and `X-RateLimit-Remaining` parsing, 401/422/429/5xx → `SuaveplanError` mapping, OTel spans/counters/histograms — all live in one place.
2. **Layering matches the genesis precedent.** `payment` + `payment-providers`, `currency` + `currency-providers`, `sms` + `sms-providers` already separate domain abstraction from provider HTTP. ElevenLabs is the inverse — the *vendor* surfaces multiple domains, so the vendor client is the shared layer.
3. **Independent versioning of vendor changes.** ElevenLabs ships rapid API changes (new models, new headers, new endpoints). The thin client absorbs them; downstream adapters only update when domain semantics shift.
4. **Reusable across workspaces.** The same client backs voice, music, dubbing, conversational-AI, and any future surface. No two adapters re-implement the same `fetch` wrapper.
---
## Out of scope
- Provider-agnostic abstractions. The client returns ElevenLabs-shaped DTOs (validated via Zod). Mapping to `@suaveplan/voice` types happens in `@suaveplan/voice-elevenlabs`.
- Voice-domain factories or composite engines. Those live in `@suaveplan/voice-elevenlabs`.
- API key management or secret storage. The caller injects `apiKey` at construction time; this package never reads from `process.env`.
- Rate-limit *enforcement*. The client surfaces rate-limit metadata via headers + errors; quota planning is the caller's responsibility.
---
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/error` (`workspace:^`) | `SuaveplanError` base + `defineCodes` for `THIRD_PARTY_ELEVENLABS.*` codes |
| `@suaveplan/types` (`workspace:^`) | `Clock`, `LoggerLike`, `EventBusLike`, `CancellableOptions` |
| `@suaveplan/streams` (`workspace:^`) | `toBytes` for buffering `ReadableStream` audio uploads |
| `@suaveplan/retry` (`workspace:^`) | Retry policies, circuit breaker, backoff with jitter |
| `@suaveplan/logger` (`workspace:^`) | Optional structured logging hook |
| `@suaveplan/serialize` (`workspace:^`) | Multipart form-data helpers and binary-safe encoders if needed |
| `zod` (`catalog:forms`) | DTO schemas for every endpoint |
| `@opentelemetry/api` (`catalog:observability`) | Spans, counters, histograms |
| `tslib` (`catalog:build`) | Runtime helpers |
Dev deps: `@suaveplan/testing` (`workspace:^`, dev-only), `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`).
---
## Public API surface
### Errors
```ts
class ElevenLabsApiError extends SuaveplanError<ElevenLabsErrorCode> {}
```
Codes (under namespace `THIRD_PARTY_ELEVENLABS`):
- `AUTH.UNAUTHORIZED` — 401, invalid/missing API key
- `AUTH.FORBIDDEN` — 403, key valid but lacks scope/quota
- `REQUEST.INVALID` — 4xx (excluding 401/403/404/422/429), client-side request shape error
- `REQUEST.VALIDATION_FAILED` — 422, ElevenLabs validation rejection
- `REQUEST.NOT_FOUND` — 404
- `RATE_LIMIT.EXCEEDED` — 429 (with `retryAfterMs` context)
- `RATE_LIMIT.QUOTA_EXHAUSTED` — quota header indicates exhaustion (subclass for caller branch)
- `SERVER.TRANSIENT` — 5xx, retryable
- `SERVER.UNAVAILABLE` — 503 / circuit-open
- `NETWORK.TIMEOUT` — request timeout
- `NETWORK.ABORTED` — caller-supplied `AbortSignal` aborted
- `NETWORK.UNREACHABLE` — DNS / connect failure
- `RESPONSE.MALFORMED` — JSON parse failure or Zod schema mismatch
- `RESPONSE.UNSUPPORTED_FORMAT` — server returned content-type the client cannot handle
- `STREAM.INTERRUPTED` — server closed the streaming response mid-flight
- `WEBSOCKET.CONNECTION_FAILED` — handshake failure
- `WEBSOCKET.PROTOCOL` — server sent unexpected frame / close code
Each code has a typed subclass (`UnauthorizedError`, `RateLimitExceededError`, etc.) so callers can branch with `instanceof`.
### Client
```ts
class ElevenLabsClient {
constructor(options: ElevenLabsClientOptions)
fetchJson<TBody>(req: JsonRequest, schema: ZodSchema<TBody>): Promise<TBody>
fetchBinary(req: BinaryRequest): Promise<BinaryResponse>
fetchStream(req: StreamRequest): Promise<ReadableStream<Uint8Array>>
fetchMultipart<TBody>(req: MultipartRequest, schema: ZodSchema<TBody>): Promise<TBody>
openSocket(req: SocketRequest): Promise<ElevenLabsSocket>
}
interface ElevenLabsClientOptions {
apiKey: string
baseUrl?: string
timeoutMs?: number
retryPolicy?: RetryPolicy
circuitBreaker?: CircuitBreaker
fetchImpl?: typeof fetch
webSocketImpl?: WebSocketImpl
clock?: Clock
logger?: LoggerLike
events?: EventBusLike<ElevenLabsClientEvents>
userAgent?: string
defaultHeaders?: Record<string, string>
}
```
### Feature modules (REST)
Each ElevenLabs API surface gets its own subpath module. Every module exports a class taking `ElevenLabsClient` in its constructor; Zod schemas in `*.schemas.ts`; TypeScript types inferred from Zod; sibling `.md`.
| Module | Subpath | ElevenLabs endpoints |
|---|---|---|
| `tts` | `./tts/tts` | `POST /v1/text-to-speech/{voice_id}`, `/stream`, `/with-timestamps`, `/stream/with-timestamps` |
| `tts-websocket` | `./tts-websocket/tts-websocket` | `wss://api.elevenlabs.io/v1/text-to-speech/{voice_id}/stream-input` |
| `stt` | `./stt/stt` | `POST /v1/speech-to-text` (multipart) |
| `voices` | `./voices/voices` | `GET /v1/voices`, `/v1/voices/{id}`, `/v1/shared-voices`, settings get/edit, default settings |
| `voice-cloning` | `./voice-cloning/voice-cloning` | instant clone, edit, delete, noise-reduced add |
| `voice-design` | `./voice-design/voice-design` | generate-voice, create-voice, design, create-from-design |
| `speech-to-speech` | `./speech-to-speech/speech-to-speech` | speech-to-speech (sync + stream) |
| `sound-generation` | `./sound-generation/sound-generation` | `POST /v1/sound-generation` |
| `audio-isolation` | `./audio-isolation/audio-isolation` | `POST /v1/audio-isolation`, `/stream` |
| `music` | `./music/music` | music, music/stream, composition-plan, music/detailed |
| `dubbing` | `./dubbing/dubbing` | project, asset, transcript, language audio, dubbing-resource CRUD |
| `studio` | `./studio/studio` | projects + chapters + snapshots |
| `audio-native` | `./audio-native/audio-native` | settings, content-update |
| `history` | `./history/history` | list, get, audio, delete, download |
| `user` | `./user/user` | user info, subscription |
| `workspace` | `./workspace/workspace` | invites, members, groups, secrets, webhooks |
| `pronunciation-dictionaries` | `./pronunciation-dictionaries/pronunciation-dictionaries` | CRUD + version listing |
| `conversational-ai` | `./conversational-ai/conversational-ai` | agents, conversations, knowledge bases, tools, phone numbers, secrets, batch calls, dashboard |
| `models` | `./models/models` | `GET /v1/models` |
### WebSocket transport
```ts
interface ElevenLabsSocket {
readonly url: string
readonly readyState: 'connecting' | 'open' | 'closing' | 'closed'
send(frame: SocketFrame): void
close(code?: number, reason?: string): void
onMessage(handler: (frame: SocketFrame) => void): () => void
onClose(handler: (event: SocketCloseEvent) => void): () => void
onError(handler: (err: ElevenLabsApiError) => void): () => void
}
```
WebSocket modules (`tts-websocket`, `conversational-ai`) expose typed wrappers that emit/parse domain frames over this transport. The transport is responsible for ping/pong, surfacing protocol errors as `WEBSOCKET.PROTOCOL` errors, and clean shutdown.
---
## Design decisions
1. **Native `fetch` only.** No `axios`, `node-fetch`, `undici` direct dep. Bun's `fetch` supports streaming bodies, multipart uploads, `AbortSignal`, and `ReadableStream` responses out of the box.
2. **DTOs validated at the boundary.** Every JSON response is parsed through a Zod schema before reaching the caller. Malformed responses throw `RESPONSE.MALFORMED` with the raw body in `context` (truncated for logging).
3. **Retry only on documented retryable codes.** 429, 502, 503, 504 and `NETWORK.UNREACHABLE` retry by default. 5xx that aren't gateway/throttling errors do not auto-retry — domain code decides whether to escalate.
4. **`Retry-After` strict parsing.** Both seconds and HTTP-date formats. Surface in `RateLimitExceededError.context.retryAfterMs`.
5. **`X-RateLimit-Remaining` and `X-Character-Cost` exposed via response metadata.** Every successful call returns a `metadata` envelope alongside the body so adapters can attribute usage.
6. **Streams are never buffered server-side.** `fetchStream` returns `response.body` as `ReadableStream<Uint8Array>` directly. Callers can pipe into Web Audio, MediaSource, or buffer on demand via `@suaveplan/streams`.
7. **Multipart uses `FormData`.** Audio uploads accept `Uint8Array | Blob | ReadableStream | string` (URL). `ReadableStream` is buffered to bytes via `@suaveplan/streams.toBytes` because ElevenLabs STT requires a complete file. URLs are fetched first.
8. **OTel on every call.** Span name `elevenlabs.<feature>.<operation>`, attributes `elevenlabs.endpoint`, `elevenlabs.method`, `http.response.status_code`, `elevenlabs.model_id` (where present). Counter `third_party_elevenlabs.requests_total{feature, operation, status}`. Histogram `third_party_elevenlabs.request_duration_ms{feature, operation}`.
9. **Versioned base path.** Each module is constructed with the `ElevenLabsClient`; the path version (`/v1/`) is owned by the module so a v2 migration touches only the affected module.
10. **No singleton state.** No global registry. Every feature class is constructed per-`ElevenLabsClient` instance.
11. **Structured error context preservation.** Every error carries `context.requestId`, `context.endpoint`, `context.statusCode` (where applicable), and `context.retryAfterMs` (for 429). The Zod-parsed body is carried under `context.body` for `VALIDATION_FAILED` only.
12. **No conditional `process.env` reads.** This package is universal-tier and must work in browsers and edge runtimes; environment variables are the caller's concern.
---
## Implementation phases
The full ElevenLabs surface is large. Implementation lands in waves; the package ships at version `0.1.0` between waves.
| Wave | Scope | Status target |
|---|---|---|
| **Wave 1 — voice surface** | client, errors, schemas, OTel, retry, `tts`, `stt`, `voices`, `sound-generation`, `audio-isolation`, `speech-to-speech`, `models` | **This OpenSpec change ships Wave 1**; required by `@suaveplan/voice-elevenlabs` v1 |
| Wave 2 — voice extensions | `tts-websocket`, `voice-cloning`, `voice-design`, `pronunciation-dictionaries` | Follow-up `third-party-elevenlabs-voice-extensions` |
| Wave 3 — Conversational AI | `conversational-ai`, agent WebSocket frames | Follow-up `third-party-elevenlabs-conversational` |
| Wave 4 — content ops | `music`, `dubbing`, `studio`, `audio-native` | Follow-up `third-party-elevenlabs-content-ops` |
| Wave 5 — admin + history | `history`, `user`, `workspace` | Follow-up `third-party-elevenlabs-admin` |
Each subsequent wave gets its own `openspec/changes/<id>/` folder with delta `## ADDED Requirements` against `openspec/specs/third-party-elevenlabs/spec.md`.
---
## Parity Checklist
Genesis-original package — no upstream `@suaveplan/third-party-elevenlabs` exists in suaveplan main. Reference for behaviour: ElevenLabs API documentation at <https://third-party-elevenlabs.io/docs/api-reference/>.
| Capability | Upstream | This proposal | Notes |
|---|---|---|---|
| Typed REST client | n/a | ✅ Wave 1 | |
| Streaming TTS | n/a | ✅ Wave 1 | `ReadableStream<Uint8Array>` |
| WebSocket transport | n/a | ⏳ Wave 2 + Wave 3 | Plumbing in Wave 2 (tts-websocket); agents in Wave 3 |
| Retry/backoff/circuit breaker | n/a | ✅ Wave 1 | via `@suaveplan/retry` |
| OTel instrumentation | n/a | ✅ Wave 1 | spans + counters + histograms |
| Rate-limit header parsing | n/a | ✅ Wave 1 | `Retry-After`, `X-RateLimit-Remaining`, `X-Character-Cost` |
| All ElevenLabs endpoints | n/a | ✅ planned across waves 1–5 | this proposal lists every documented endpoint |
---
## Impact
### Affected dependents
This is a new package — no existing dependents. After Wave 1 ships, the only direct consumer will be `@suaveplan/voice-elevenlabs` (rebuilt as a thin adapter under change `voice-elevenlabs`).
- `@suaveplan/voice-elevenlabs` — [x] tests run / [x] docs reviewed / [ ] no action needed (the rebuild lands together; see change `voice-elevenlabs`)
Future changes that consume this package: `@suaveplan/conversational-elevenlabs`, `@suaveplan/music-elevenlabs`, `@suaveplan/dubbing-elevenlabs`, `@suaveplan/studio-elevenlabs` — each will land via its own OpenSpec change.
### Migration
This is the first version of the package; no migration notes apply. When endpoints change across ElevenLabs API revisions, follow-up OpenSpec changes will document the migration in their `## Migration` sections.