Files
2026-05-27 23:29:53 +01:00

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 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