188 lines
10 KiB
Markdown
188 lines
10 KiB
Markdown
# 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: <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: 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).
|