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

169 lines
9.4 KiB
Markdown

# Capability Spec Delta: 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: <apiKey>`
#### 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: <HTTP-date>` 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<Uint8Array>` 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<Uint8Array> | 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: <string|undefined> } }`
### Requirement: OpenTelemetry instrumentation per call
The client SHALL emit, for every public call:
- A span named `elevenlabs.<feature>.<operation>` 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.<feature>.<operation>` 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: Wave 1 feature modules cover the voice surface
The package SHALL expose, in Wave 1, feature modules `tts`, `stt`, `voices`, `sound-generation`, `audio-isolation`, `speech-to-speech`, and `models`. Each module SHALL be subpath-exported per genesis convention and depend solely on `ElevenLabsClient` for transport.
#### Scenario: Voice surface modules are independently importable
- **GIVEN** a consumer that only needs TTS
- **WHEN** they import `import { ElevenLabsTts } from '@suaveplan/third-party-elevenlabs/tts/tts'`
- **THEN** the import succeeds without pulling in `stt`, `voices`, `sound-generation`, `audio-isolation`, `speech-to-speech`, or `models` modules into the bundle
- **AND** tree-shaking removes the unused modules