# Capability Spec: third-party-elevenlabs This delta is the first revision of `openspec/specs/third-party-elevenlabs/spec.md`. On archival of change `third-party-elevenlabs` (Wave 1), the `## ADDED Requirements` block below is folded into a fresh canonical spec at `openspec/specs/third-party-elevenlabs/spec.md`. Subsequent waves (`tts-websocket`, `voice-cloning`, `voice-design`, `pronunciation-dictionaries`, `conversational-ai`, `music`, `dubbing`, `studio`, `audio-native`, `history`, `user`, `workspace`) ship their own delta blocks against this canonical spec. --- ## ADDED Requirements ### Requirement: Typed thin client for ElevenLabs platform API The package SHALL provide a typed HTTP client (`ElevenLabsClient`) for the ElevenLabs platform API at `https://api.elevenlabs.io`, owning auth, retry, rate-limit-header parsing, error mapping, OpenTelemetry instrumentation, and Zod-validated request/response DTOs. #### Scenario: Client constructs with API key - **GIVEN** a caller with a valid ElevenLabs API key - **WHEN** the caller constructs `new ElevenLabsClient({ apiKey })` - **THEN** the client retains the key, applies a default base URL of `https://api.elevenlabs.io`, a default `30_000` ms timeout, and a default retry policy of three exponential-backoff attempts on retryable status codes - **AND** every outbound request automatically includes the header `xi-api-key: ` #### Scenario: Client must not read API key from environment - **GIVEN** a caller that omits `apiKey` in the options object - **WHEN** they construct `new ElevenLabsClient({} as ElevenLabsClientOptions)` - **THEN** the constructor throws `RequestInvalidError` (`THIRD_PARTY_ELEVENLABS.REQUEST.INVALID`) - **AND** the package never reads `process.env.ELEVENLABS_API_KEY` (or any environment variable) at any point ### Requirement: Structured error mapping per HTTP status The client SHALL map every non-2xx HTTP response to a typed `ElevenLabsApiError` subclass under namespace `THIRD_PARTY_ELEVENLABS`. The mapping MUST be total and disjoint over the `[400, 600)` range. #### Scenario: 401 maps to UnauthorizedError - **GIVEN** the server responds with HTTP `401` - **WHEN** the client awaits `fetchJson`/`fetchBinary`/`fetchStream`/`fetchMultipart` - **THEN** the client throws `UnauthorizedError` with code `THIRD_PARTY_ELEVENLABS.AUTH.UNAUTHORIZED` - **AND** `error.context.statusCode === 401` and `error.context.endpoint` is the requested path #### Scenario: 422 maps to RequestValidationFailedError with body context - **GIVEN** the server responds with HTTP `422` and a JSON error body - **WHEN** the client awaits the call - **THEN** the client throws `RequestValidationFailedError` (`REQUEST.VALIDATION_FAILED`) - **AND** the parsed JSON body is preserved on `error.context.body` (truncated to 4 KB if larger) #### Scenario: 429 with Retry-After (seconds) sets retryAfterMs - **GIVEN** the server responds with HTTP `429` and header `Retry-After: 5` - **WHEN** the client awaits the call - **THEN** the client throws `RateLimitExceededError` (`RATE_LIMIT.EXCEEDED`) - **AND** `error.context.retryAfterMs === 5000` #### Scenario: 429 with Retry-After (HTTP-date) sets retryAfterMs - **GIVEN** the server responds with HTTP `429` and header `Retry-After: ` exactly 12 seconds in the future according to the injected `Clock` - **WHEN** the client awaits the call - **THEN** the client throws `RateLimitExceededError` with `error.context.retryAfterMs` between `11_500` and `12_500` #### Scenario: Retryable 5xx is automatically retried - **GIVEN** the server responds with HTTP `503` then HTTP `200` - **AND** the default retry policy of three attempts is in effect - **WHEN** the client awaits the call - **THEN** the client retries once, observes the `200`, and resolves with the parsed body - **AND** an OTel span event `retry.attempt` is emitted with attempt index `1` #### Scenario: Non-retryable 5xx surfaces ServerTransientError - **GIVEN** the server responds with HTTP `500` (not in the retryable set `{502, 503, 504}`) - **WHEN** the client awaits the call - **THEN** the client throws `ServerTransientError` (`SERVER.TRANSIENT`) without retrying - **AND** the caller is responsible for any further retry decision ### Requirement: Streaming responses are passed through unbuffered The `fetchStream` API SHALL return `response.body` as `ReadableStream` directly, without buffering server-side bytes into memory. #### Scenario: Stream body is consumed lazily - **GIVEN** the server returns HTTP `200` with `Content-Type: audio/mpeg` and a chunked body - **WHEN** the caller awaits `client.fetchStream(req)` and pulls one chunk - **THEN** only that chunk has been consumed from the wire - **AND** the caller may pipe the stream into Web Audio, MediaSource, or buffer on demand ### Requirement: Multipart uploads accept all four file shapes The `fetchMultipart` API SHALL accept `MultipartFile = Uint8Array | Blob | ReadableStream | string` (where `string` is interpreted as a URL). #### Scenario: Uint8Array attaches without copying - **GIVEN** an upload with `audio: new Uint8Array([0x01, 0x02, 0x03])` - **WHEN** the client builds the multipart body - **THEN** the resulting `FormData` part contains exactly those three bytes #### Scenario: ReadableStream is buffered before upload - **GIVEN** an upload with `audio` as a `ReadableStream` of two chunks - **WHEN** the client builds the multipart body - **THEN** the stream is fully drained via `@suaveplan/streams.toBytes` before sending - **AND** the resulting `FormData` part contains the concatenated bytes #### Scenario: URL is fetched first then uploaded - **GIVEN** an upload with `audio: 'https://example.com/sample.wav'` - **AND** that URL returns HTTP `200` with bytes `B` - **WHEN** the client builds the multipart body - **THEN** the URL is fetched first via the injected `fetchImpl` - **AND** the resulting `FormData` part contains exactly `B` ### Requirement: Rate-limit metadata is exposed on success Every successful response from `fetchJson`/`fetchBinary`/`fetchStream`/`fetchMultipart` SHALL return both the parsed body and a `metadata` envelope containing `requestId`, `rateLimitRemaining` (when present), and `characterCost` (when present). #### Scenario: Metadata is populated from response headers - **GIVEN** the server returns `X-RateLimit-Remaining: 100` and `X-Character-Cost: 42` - **WHEN** the caller awaits `fetchJson` - **THEN** the resolved value is `{ body, metadata: { rateLimitRemaining: 100, characterCost: 42, requestId: } }` ### Requirement: OpenTelemetry instrumentation per call The client SHALL emit, for every public call: - A span named `elevenlabs..` with attributes `elevenlabs.endpoint`, `elevenlabs.method`, `http.response.status_code`, and `elevenlabs.model_id` (when present). - An increment of counter `third_party_elevenlabs.requests_total` tagged by `feature`, `operation`, and `status` (`ok`, `client_error`, `server_error`, `network_error`, `cancelled`). - A histogram observation on `third_party_elevenlabs.request_duration_ms` tagged by `feature` and `operation`. #### Scenario: OTel span is emitted on success - **GIVEN** a configured tracer - **WHEN** the caller awaits `client.fetchJson` and the call returns `200` - **THEN** exactly one span named `elevenlabs..` is emitted with status `ok` - **AND** the counter `third_party_elevenlabs.requests_total{status=ok}` is incremented exactly once ### Requirement: AbortSignal is honoured end-to-end Every public call SHALL accept an optional `AbortSignal` (via the request envelope) and merge it with the internal timeout signal. Aborting either signal SHALL cause the call to throw `NetworkAbortedError` or `NetworkTimeoutError` respectively. #### Scenario: Caller AbortSignal cancels in-flight request - **GIVEN** a caller-supplied `AbortController` and an in-flight `fetchJson` call - **WHEN** the caller invokes `controller.abort()` - **THEN** the call throws `NetworkAbortedError` (`NETWORK.ABORTED`) - **AND** no retry is attempted (abort is not a retryable code) - **AND** the OTel counter records `status=cancelled` ### Requirement: WebSocket transport surfaces typed protocol errors `openSocket` SHALL return an `ElevenLabsSocket` that surfaces handshake failures as `WebSocketConnectionFailedError` and unexpected frames or abnormal close codes as `WebSocketProtocolError`. #### Scenario: Handshake failure surfaces ConnectionFailedError - **GIVEN** an injected `WebSocketImpl` that synthetically fails the handshake with code `4001` - **WHEN** the caller awaits `client.openSocket(req)` - **THEN** the call rejects with `WebSocketConnectionFailedError` - **AND** `error.context.code === 4001` ### Requirement: Transport-only surface — no voice-domain types The package SHALL expose only the raw HTTP/WebSocket transport, the `ElevenLabsApiError` family, and DTO types. It SHALL NOT depend on `@suaveplan/voice` at runtime and SHALL NOT export any class that implements a `@suaveplan/voice` engine interface. Domain adapters (voice, future music / dubbing / conversational-ai) live in their own packages and compose against `ElevenLabsClient`. #### Scenario: package.json dependencies exclude @suaveplan/voice - **WHEN** `package.json` `dependencies` is read - **THEN** no key equals `@suaveplan/voice` #### Scenario: root barrel exposes no voice-engine class - **WHEN** the root barrel `src/index.ts` is inspected - **THEN** no export name matches the patterns `ElevenLabsVoiceSynthesizer`, `ElevenLabsTranscriptionEngine`, `ElevenLabsVoiceRepository`, `ElevenLabsSoundEffectEngine`, `ElevenLabsVoiceIsolationEngine`, or `createElevenLabsVoice` - **AND** the only exports are the client (`ElevenLabsClient`, `buildHeaders`, `buildUrl`, `extractMetadata`, `parseRetryAfter`), the error family (`ElevenLabsApiError` plus subclasses, `ELEVENLABS_CODES`, `ElevenLabsErrorCode`), and the DTO types **Verified by:** `tests/dep-graph.test.ts` AND `tests/tree-shake.test.ts` (and `packages/media/audio/voice-elevenlabs/tests/dep-graph.test.ts` which asserts the opposing constraint on the consumer side).