9.4 KiB
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 default30_000ms 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
apiKeyin 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
UnauthorizedErrorwith codeTHIRD_PARTY_ELEVENLABS.AUTH.UNAUTHORIZED - AND
error.context.statusCode === 401anderror.context.endpointis the requested path
Scenario: 422 maps to RequestValidationFailedError with body context
- GIVEN the server responds with HTTP
422and 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
429and headerRetry-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
429and headerRetry-After: <HTTP-date>exactly 12 seconds in the future according to the injectedClock - WHEN the client awaits the call
- THEN the client throws
RateLimitExceededErrorwitherror.context.retryAfterMsbetween11_500and12_500
Scenario: Retryable 5xx is automatically retried
- GIVEN the server responds with HTTP
503then HTTP200 - 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.attemptis emitted with attempt index1
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
200withContent-Type: audio/mpegand 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
FormDatapart contains exactly those three bytes
Scenario: ReadableStream is buffered before upload
- GIVEN an upload with
audioas aReadableStreamof two chunks - WHEN the client builds the multipart body
- THEN the stream is fully drained via
@suaveplan/streams.toBytesbefore sending - AND the resulting
FormDatapart 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
200with bytesB - WHEN the client builds the multipart body
- THEN the URL is fetched first via the injected
fetchImpl - AND the resulting
FormDatapart contains exactlyB
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: 100andX-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 attributeselevenlabs.endpoint,elevenlabs.method,http.response.status_code, andelevenlabs.model_id(when present). - An increment of counter
third_party_elevenlabs.requests_totaltagged byfeature,operation, andstatus(ok,client_error,server_error,network_error,cancelled). - A histogram observation on
third_party_elevenlabs.request_duration_mstagged byfeatureandoperation.
Scenario: OTel span is emitted on success
- GIVEN a configured tracer
- WHEN the caller awaits
client.fetchJsonand the call returns200 - THEN exactly one span named
elevenlabs.<feature>.<operation>is emitted with statusok - 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
AbortControllerand an in-flightfetchJsoncall - 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
WebSocketImplthat synthetically fails the handshake with code4001 - 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, ormodelsmodules into the bundle - AND tree-shaking removes the unused modules