Skip to content

feat(elevenlabs): add ElevenLabs voice provider - #672

Merged
SantiagoDePolonia merged 3 commits into
mainfrom
feat/provider-1labs
Aug 10, 2026
Merged

feat(elevenlabs): add ElevenLabs voice provider#672
SantiagoDePolonia merged 3 commits into
mainfrom
feat/provider-1labs

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add a new elevenlabs provider type: a voice-only provider exposing OpenAI-compatible /v1/audio/speech (text-to-speech) and /v1/audio/transcriptions (speech-to-text). Chat, /v1/responses, and embeddings return invalid_request_error since ElevenLabs has no such APIs.
  • The OpenAI voice field carries the ElevenLabs voice_id directly (ElevenLabs has no named voices); auth uses the xi-api-key header rather than Bearer.
  • ListModels merges ElevenLabs' live TTS catalog (GET /v1/models, filtered to can_do_text_to_speech) with the fixed speech-to-text model list (scribe_v2, scribe_v1), which is not included in that listing.
  • Speech supports mp3/opus/pcm/wav response formats and speed 0.7–1.2 (ElevenLabs' voice-setting range); transcription supports json/text/verbose_json with word-level timestamps mapped from ElevenLabs' words array.
  • Wired into run/providers.go, dashboard help-icon doc link, .env.template, config/config.example.yaml, README, CLAUDE.md, and a new dedicated docs/providers/elevenlabs.mdx guide (added to nav).
  • Request/response shapes were cross-checked against ElevenLabs' official Python SDK type definitions (Fern-generated, source of truth) rather than relying solely on prose docs.

Test plan

  • go build ./...
  • go test ./... (full suite green, including new internal/providers/elevenlabs package tests)
  • go vet ./internal/providers/elevenlabs/...
  • Dashboard JS unit tests (node --test tests/*.test.js, 477 passing)
  • Pre-commit hooks (fmt, lint, dashboard dist sync, mint validate) all pass

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added ElevenLabs support for text-to-speech and speech-to-text.
    • Supports MP3 and WAV audio, transcription formats, timestamps, voice IDs, and model discovery.
    • Added API key and optional custom base URL configuration.
    • Added passthrough support for native ElevenLabs requests.
    • Unsupported chat, responses, and embeddings operations now return clear errors.
  • Documentation

    • Added setup guidance, capability details, configuration examples, and provider documentation links.

Adds a voice-only provider exposing OpenAI-compatible text-to-speech
(/v1/audio/speech) and speech-to-text (/v1/audio/transcriptions);
chat, /v1/responses, and embeddings are not supported since
ElevenLabs has no such APIs. Voice IDs pass through the OpenAI
"voice" field directly, xi-api-key auth is used instead of Bearer,
and the transcription model catalog (scribe_v1/scribe_v2) is merged
in since ElevenLabs' /v1/models only lists TTS models.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@mintlify

mintlify Bot commented Aug 10, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
gomodel 🟢 Ready View Preview Aug 10, 2026, 1:24 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2c28c344-96ac-46fc-85c0-fe6914910c32

📥 Commits

Reviewing files that changed from the base of the PR and between e41ddea and b87c735.

📒 Files selected for processing (3)
  • docs/providers/elevenlabs.mdx
  • internal/providers/elevenlabs/audio.go
  • internal/providers/elevenlabs/audio_test.go

📝 Walkthrough

Walkthrough

Adds ElevenLabs as a registered voice provider. It supports text-to-speech, speech-to-text, passthrough requests, model discovery, configuration, documentation, dashboard links, and comprehensive tests.

Changes

ElevenLabs provider

Layer / File(s) Summary
Provider core and model discovery
internal/providers/elevenlabs/elevenlabs.go, internal/providers/elevenlabs/elevenlabs_test.go
Defines provider construction, authentication, base URLs, passthrough handling, unsupported operations, model discovery, and interface conformance.
Speech synthesis
internal/providers/elevenlabs/audio.go, internal/providers/elevenlabs/audio_test.go
Adds request validation, format and speed mapping, ElevenLabs text-to-speech requests, audio responses, and error handling.
Audio transcription
internal/providers/elevenlabs/audio.go, internal/providers/elevenlabs/audio_test.go
Adds multipart transcription requests and text, JSON, and verbose JSON response conversion with word timestamps.
Registration and documentation
run/providers.go, run/providers_test.go, config/config.example.yaml, .env.template, CLAUDE.md, README.md, docs/providers/*, docs/docs.json, web/dashboard/src/pages/overview/providersLogic.js
Registers ElevenLabs, documents configuration and capabilities, adds provider navigation, and maps dashboard documentation links.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Poem

A rabbit heard ElevenLabs sing,
Then sent a transcript hopping in.
With voice IDs and formats bright,
API keys tucked safely tight,
Scribe words danced from left to right.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: adding an ElevenLabs voice provider.
Description check ✅ Passed The description clearly explains the provider, supported APIs, configuration, tests, and implementation details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/provider-1labs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

codecov-commenter commented Aug 10, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 86.76471% with 36 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/providers/elevenlabs/audio.go 83.95% 17 Missing and 13 partials ⚠️
internal/providers/elevenlabs/elevenlabs.go 92.85% 3 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/providers/elevenlabs/audio_test.go`:
- Around line 68-127: Update TestCreateSpeech_MapsFormatsAndSpeed and
TestCreateSpeech_SupportsWAV into a table-driven
TestCreateSpeech_MapsResponseFormats covering default MP3, opus, pcm, and wav
query mappings, while preserving the speed assertion separately if needed. Add
TestCreateTranscription_WordGranularityFromRequest to verify CreateTranscription
sends TimestampGranularities []string{"word"} as timestamps_granularity=word,
using multipart request inspection.

In `@internal/providers/elevenlabs/audio.go`:
- Around line 40-50: Update speechSpeed to clamp nonzero speed values to
ElevenLabs’ supported 0.7–1.2 range instead of returning an error for
out-of-range OpenAI values; preserve the nil result for speed == 0 and return
the clamped value.
- Around line 179-185: Update the transcription request flow around
transcriptionMultipart and DoRaw to construct the multipart payload as buffered
bytes and assign it to RawBody instead of passing the pipe-backed RawBodyReader.
Preserve the existing content type and request endpoint, and propagate any
payload-construction error so retries can replay the same body safely.

In `@internal/providers/elevenlabs/elevenlabs_test.go`:
- Around line 110-128: Refactor
TestUnsupportedCapabilities_ReturnInvalidRequestErrors into a table-driven test
covering the five unsupported provider methods, with each case storing its
invocation and expected error substring. Iterate over the cases and apply one
shared assertion for the returned error while preserving the existing method
calls and message expectations.

In `@internal/providers/elevenlabs/elevenlabs.go`:
- Around line 47-58: Update the ElevenLabs New constructor to store the shared
keyring from opts.Keys on Provider instead of creating one with
opts.Keyring(cfg.APIKey). Preserve the existing llmclient.Config setup and
client initialization so key rotation uses the factory-provided keyring.
- Around line 32-43: Update the comment above Provider to accurately state that
it implements the audio and core.PassthroughProvider surfaces, while not
providing chat, Responses, or Embeddings endpoints; keep the existing type
assertions and Passthrough implementation unchanged.

In `@run/providers_test.go`:
- Around line 171-172: Add an ElevenLabs table entry to
TestDefaultProviderFactoryCredentialForms that verifies its credential fields,
required API key, default base URL, and provider-specific parameter mapping,
while matching the existing test-case structure and expectations.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d5300ba2-4eba-48e9-9056-6a506b387da8

📥 Commits

Reviewing files that changed from the base of the PR and between 8ef7ff8 and b463b96.

⛔ Files ignored due to path filters (2)
  • internal/admin/dashboard/static/dist/assets/index-D05Km9Si.js is excluded by !**/dist/**
  • internal/admin/dashboard/static/dist/index.html is excluded by !**/dist/**
📒 Files selected for processing (14)
  • .env.template
  • CLAUDE.md
  • README.md
  • config/config.example.yaml
  • docs/docs.json
  • docs/providers/elevenlabs.mdx
  • docs/providers/overview.mdx
  • internal/providers/elevenlabs/audio.go
  • internal/providers/elevenlabs/audio_test.go
  • internal/providers/elevenlabs/elevenlabs.go
  • internal/providers/elevenlabs/elevenlabs_test.go
  • run/providers.go
  • run/providers_test.go
  • web/dashboard/src/pages/overview/providersLogic.js

Comment thread internal/providers/elevenlabs/audio_test.go Outdated
Comment thread internal/providers/elevenlabs/audio.go Outdated
Comment on lines +179 to +185
body, contentType := transcriptionMultipart(req, model, content)
resp, err := p.client.DoRaw(ctx, llmclient.Request{
Method: http.MethodPost,
Endpoint: "/v1/speech-to-text",
RawBodyReader: body,
Headers: http.Header{"Content-Type": {contentType}},
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -nP -C8 'RawBodyReader' internal/llmclient
rg -nP -C10 'func .*DoRaw\(' internal/llmclient
rg -nP -C6 'GetBody|retr(y|ies)|Attempt' internal/llmclient
# Do other providers stream multipart bodies through a pipe?
rg -nP -C4 'io\.Pipe\(\)' internal/providers

Repository: ENTERPILOT/GoModel

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== internal/providers/elevenlabs/audio.go relevant sections =="
wc -l internal/providers/elevenlabs/audio.go
sed -n '1,80p' internal/providers/elevenlabs/audio.go
sed -n '160,245p' internal/providers/elevenlabs/audio.go
sed -n '290,350p' internal/providers/elevenlabs/audio.go

echo "== llmclient request building raw reader refs =="
sed -n '120,170p' internal/llmclient/client.go
sed -n '376,420p' internal/llmclient/client.go
sed -n '680,755p' internal/llmclient/client.go
sed -n '1835,1900p' internal/llmclient/client_test.go
sed -n '402,428p' internal/llmclient/client_test.go

echo "== static verifier: Request.Body/RawBody/RawBodyReader usage and closing =="
python3 - <<'PY'
from pathlib import Path
import re

files = {p.name: p.read_text(errors='replace') for p in Path('internal').rglob('*.go')}
print("audio.go line counts with transcriptionMultipart caller:", {
    "caller_line": next(i+1 for i,l in enumerate(files.get('audio.go','')) if 'transcriptionMultipart(req, model, content)' in l),
    "reader_type": next(i+1 for i,l in enumerate(files.get('audio.go','')) if 'transcriptionMultipart(req, model, content)' in l and 'io.Reader' in files.get('audio.go',''.join(files['audio.go'].splitlines()[:i+5][1:])))
})
for name, text in files.items():
    if 'RawBodyReader' in text or 'transcriptionMultipart' in text:
        print(f"--- {name} ---")
        for i,l in enumerate(text.splitlines(),1):
            if 'RawBodyReader' in l or 'transcriptionMultipart' in l or 'Close()' in l:
                print(f"{i}: {l}")
PY

Repository: ENTERPILOT/GoModel

Length of output: 15298


Use a buffered multipart payload for transcription requests.

This pipe-backed body is not retry-safe, and if the build/circuit-breaker path rejects it, the producer can remain blocked until RawBodyReader is closed. Build the payload into RawBody so DoRaw can replay the same body on retry; build errors already close RawBodyReader, which removes this leak for streaming readers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/providers/elevenlabs/audio.go` around lines 179 - 185, Update the
transcription request flow around transcriptionMultipart and DoRaw to construct
the multipart payload as buffered bytes and assign it to RawBody instead of
passing the pipe-backed RawBodyReader. Preserve the existing content type and
request endpoint, and propagate any payload-construction error so retries can
replay the same body safely.

Comment thread internal/providers/elevenlabs/elevenlabs_test.go Outdated
Comment thread internal/providers/elevenlabs/elevenlabs.go
Comment on lines +47 to +58
func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider {
p := &Provider{keys: opts.Keyring(cfg.APIKey)}
clientCfg := llmclient.Config{
ProviderName: "elevenlabs",
BaseURL: providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL),
Retry: opts.Resilience.Retry,
Hooks: opts.Hooks,
CircuitBreaker: opts.Resilience.CircuitBreaker,
}
p.client = llmclient.New(clientCfg, p.setHeaders)
return p
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Keyring/ProviderOptions definitions
ast-grep run --pattern 'func ($_ $_) Keyring($$$) $$$' --lang go internal/providers || true
rg -nP -C4 'func .*Keyring\(|func NewKeyring\(|SessionStickyKeys' internal/providers
# How other providers construct their keyring
rg -nP -C2 'opts\.Keyring\(' internal/providers

Repository: ENTERPILOT/GoModel

Length of output: 31981


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== elevenlabs.go relevant sections =="
sed -n '1,260p' internal/providers/elevenlabs/elevenlabs.go | cat -n

echo
echo "== keyring.go relevant sections =="
sed -n '1,180p' internal/providers/keyring.go | cat -n

echo
echo "== factory.go relevant section =="
sed -n '1,170p' internal/providers/factory.go | cat -n

echo
echo "== ProviderConfig relevant section =="
sed -n '1,60p' internal/providers/config.go | cat -n

echo
echo "== config env/APIKs resolver section =="
sed -n '182,240p' internal/providers/config.go | cat -n

Repository: ENTERPILOT/GoModel

Length of output: 28453


Use the factory keyring for ElevenLabs rotation.

New builds opts.Keyring(cfg.APIKey), so factory-created ElevenLabs providers use only the primary key instead of the shared keyring built from cfg.APIKeys in ProviderFactory.Create. Store opts.Keys on the provider (as sibling providers do for shared clients) instead of creating a single-key fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/providers/elevenlabs/elevenlabs.go` around lines 47 - 58, Update the
ElevenLabs New constructor to store the shared keyring from opts.Keys on
Provider instead of creating one with opts.Keyring(cfg.APIKey). Preserve the
existing llmclient.Config setup and client initialization so key rotation uses
the factory-provided keyring.

Source: Coding guidelines

Comment thread run/providers_test.go
@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

Not safe to merge until static Scribe transcription models remain available when live ElevenLabs model discovery fails.

The failure was reproduced with a local HTTP server that returned a catalog error while successfully serving transcription. A comparison run that retained the static model inventory restored registry initialization and Scribe model resolution under the same upstream responses.

Files Needing Attention: internal/providers/elevenlabs/elevenlabs.go needs a fallback path in ListModels that returns the fixed transcription inventory after a /v1/models failure.

T-Rex T-Rex Logs

What T-Rex did

  • T-Rex prepared a focused httptest validation source to exercise ElevenLabs catalog failure and transcription success.
  • T-Rex inspected the current-code request results when the ElevenLabs model catalog was unavailable.
  • The Go reproduction command for TestPR672ModelInventoryFailureAndTranscription was executed and exited with code 0.
  • The overlay validation was executed and exited with code 0, showing registry.Initialize as nil before overlay and registry resolves elevenlabs/scribe_v2 true after applying the overlay, and the authored validation test was removed after execution while its exact source was retained as an artifact.

View all artifacts

T-Rex Ran code and verified through T-Rex

Comments Outside Diff (1)

  1. General comment

    P1 ElevenLabs Scribe models disappear when the text-to-speech catalog request fails

    • Bug
      • When GET /v1/models returns HTTP 503 Service Unavailable with model catalog unavailable, ListModels returns the error rather than publishing known static Scribe models. Registry initialization then fails and cannot resolve elevenlabs/scribe_v2, although POST /v1/speech-to-text returns HTTP 200 OK and successfully transcribes the request.
    • Cause
      • ListModels at internal/providers/elevenlabs/elevenlabs.go:174-176 immediately returns on the text-to-speech catalog error. staticTranscriptionModels are appended only after that return path.
    • Fix
      • On failure of GET /v1/models, return a successful core.ModelsResponse containing a defensive copy of staticTranscriptionModels (or otherwise preserve the fixed Scribe inventory), while optionally recording/logging the catalog failure.

    T-Rex Ran code and verified through T-Rex

Reviews (1): Last reviewed commit: "feat(elevenlabs): add ElevenLabs voice p..." | Re-trigger Greptile

Comment on lines +174 to +176
}, &upstream); err != nil {
return nil, err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Static transcription models disappear

When GET /v1/models is temporarily unavailable, ListModels returns the catalog error before appending the fixed Scribe models. Registry initialization therefore cannot resolve elevenlabs/scribe_v2, even though the independent /v1/speech-to-text endpoint remains healthy. Return the known static transcription inventory when catalog discovery fails so transcription remains available during a TTS catalog outage.

Artifacts

Focused httptest validation source for ElevenLabs catalog failure and transcription success

  • Authored test source used one local httptest server to return HTTP 503 for the model catalog and HTTP 200 for transcription, showing whether model resolution remains available.

Current-code request results when ElevenLabs model catalog is unavailable

  • Executed current-code focused test showing GET /v1/models returned HTTP 503 Service Unavailable while POST /v1/speech-to-text returned HTTP 200 OK, but scribe_v2 model resolution failed.

Overlay comparison results preserving Scribe models on catalog failure

  • Executed the same focused test through a non-persistent ListModels fallback overlay, showing the same HTTP 503 catalog and HTTP 200 transcription responses now leave scribe_v2 resolvable.

ElevenLabs provider package test suite result

  • Executed the existing ElevenLabs provider package suite after the focused validation cleanup, and it passed.

View artifacts

T-Rex Ran code and verified through T-Rex

- ListModels: a live TTS catalog failure on the very first fetch now
  still returns the static Scribe transcription models (which don't
  depend on that call) instead of leaving the registry with nothing to
  resolve elevenlabs/scribe_v2 against. Once a fetch has succeeded,
  later failures propagate normally so the registry's existing
  stale-inventory carry-forward keeps the larger prior list instead of
  this call shrinking it (Greptile).
- speechSpeed: clamp to ElevenLabs' 0.7-1.2 range instead of rejecting
  values OpenAI clients legitimately send (0.25-4.0), per Postel's Law
  (CodeRabbit).
- Fix a stale doc comment claiming the provider doesn't implement
  core.PassthroughProvider when it does (CodeRabbit).
- Add missing test coverage: pcm/word-granularity parameter mapping,
  wav response format, speed clamping, table-driven unsupported-
  capability checks, an elevenlabs credential-schema case in
  run/providers_test.go, and the new catalog-fallback behavior.

Skipped two CodeRabbit suggestions after verification: buffering the
transcription multipart body instead of streaming it through io.Pipe
(llmclient.DoRaw already forces maxAttempts=1 for RawBodyReader and
closes the reader on early failure, so it's neither a retry-replay
risk nor a goroutine leak — the same pattern cohere's audio.go already
uses), and storing opts.Keys directly instead of opts.Keyring(cfg.APIKey)
(opts.Keyring already returns opts.Keys when the factory set it, only
falling back to a single-key ring outside the factory — same pattern
cohere.go uses).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Reviewed and verified each finding against the actual code/behavior; pushed fixes for the valid ones.

Fixed:

  • Greptile (P1) — Static Scribe transcription models could disappear if the very first GET /v1/models fetch fails (cold start), since ListModels returned early before appending them. Fixed with a narrower fallback: on a fetch failure with no prior successful fetch, return the static models; once a fetch has succeeded, later failures propagate normally so internal/providers/registry_provider_refresh.go's existing stale-inventory carry-forward keeps the larger prior list — returning the static-only list unconditionally on every failure would have been a regression there (it would silently shrink an already-populated catalog instead of leaving it stale-but-intact).
  • CodeRabbitspeechSpeed now clamps to ElevenLabs' 0.7–1.2 range instead of rejecting values OpenAI clients legitimately send (0.25–4.0), matching this repo's Postel's Law principle (and the same clamping pattern minimax's temperature handling already uses).
  • CodeRabbit — Fixed a stale comment claiming the provider doesn't implement core.PassthroughProvider, when it does.
  • CodeRabbit — Added the missing test coverage it flagged (pcm/word-granularity mapping, wav format, speed clamping, table-driven unsupported-capability checks, an elevenlabs row in TestDefaultProviderFactoryCredentialForms), plus new tests for the catalog-fallback fix above.

Skipped after verification (false positives):

  • CodeRabbit — "buffer the multipart transcription body instead of streaming through io.Pipe, it's not retry-safe." llmclient.DoRaw already forces maxAttempts = 1 whenever RawBodyReader is set, and calls closeRawBodyReader (which closes the pipe reader, unblocking the writer goroutine) on every early-exit path. This is the identical pattern cohere/audio.go's cohereTranscriptionMultipart already uses in this codebase.
  • CodeRabbit — "use opts.Keys instead of opts.Keyring(cfg.APIKey), so factory-created providers use only the primary key." ProviderOptions.Keyring() already returns opts.Keys when the factory set it, only falling back to a single-key ring when constructed outside the factory (tests, NewWithHTTPClient). Same pattern cohere.go already uses.

Also independently double-checked the original implementation against ElevenLabs' actual API surface (via their Fern-generated SDK source, not just prose docs) before this review pass and found two real bugs in the original PR body, already fixed in an earlier commit: wav was wrongly rejected as an unsupported speech format, and the static transcription model list had a nonexistent scribe_v1_experimental while missing the current scribe_v2.

… e2e testing

E2E-tested TTS, STT, and passthrough against the real ElevenLabs API
(round-tripped synthesized audio through transcription, exercised every
supported format/model, error paths, and speed clamping). Found and
fixed one real code bug along the way:

- CreateSpeech/CreateTranscription had a dead status-code check copied
  from a Passthrough-style pattern: llmclient.Client.DoRaw already
  parses any non-2xx response into an error before returning, so
  `resp.StatusCode` can only ever be 200 by the time that check ran —
  it never executed. Replaced it with refineElevenLabsError, which
  unwraps ElevenLabs' actual error shape ({"detail": "..."} or
  {"detail": {"message": "...", ...}}) from the GatewayError's
  preserved ResponseBody, since the generic client-level parser only
  recognizes {"message": ...}/{"error": {...}} and previously fell
  back to dumping the raw JSON body as the message. Verified against
  live 400/401/403 responses.

Documented two out-of-scope findings rather than fixing them here,
since both are shared-infrastructure issues, not ElevenLabs-specific
code:
- The provider-passthrough router's ALLOW_PASSTHROUGH_V1_ALIAS
  handling strips a leading "v1/" path segment for every provider
  uniformly, assuming the provider's own base URL already embeds
  "/v1" (true for OpenAI-shaped providers). ElevenLabs' base URL does
  not, so every /p/elevenlabs/v1/... passthrough call 404s today;
  /v2/... paths are unaffected and verified working. This blocks
  voice listing via the v1 endpoint and makes speech-to-speech
  (voice changer) entirely unreachable, since it has no /v2 path.
  Documented in docs/providers/elevenlabs.mdx with the exact
  mechanism and impact.
- Audio endpoint audit log entries don't populate the top-level
  `provider` field (confirmed against internal/server/audio_service.go,
  which doesn't set it for any audio-capable provider) — a
  pre-existing gap, not introduced here.

Also added the "Not implemented" section requested in review: speech-
to-speech, dubbing, voice cloning/design, projects, conversational
agents, and realtime streaming all have no OpenAI-compatible shape to
translate to and are candidates for native passthrough once the v1
alias issue above is fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

E2E test results against the live ElevenLabs API

Ran the gateway locally with a real ELEVENLABS_API_KEY and exercised TTS, STT, and passthrough extensively (not mocked). Full round-trip worked: synthesized "The quick brown fox jumps over the lazy dog." → transcribed it back → got the same text. Also tested a ~40s / 625KB longer synthesis → transcription (verbose_json, 85 words, accurate timestamps), all 4 speech formats, all 6 TTS models, error paths, and speed clamping (confirmed the clamped value actually changes synthesized audio duration, not just accepted silently).

Found and fixed one real bug (pushed): CreateSpeech/CreateTranscription had a status-code check that was dead code — llmclient.Client.DoRaw already converts any non-2xx response into an error before returning, so resp.StatusCode can only ever be 200 by the time my check ran. This meant ElevenLabs' actual error detail ({"detail": "..."} or {"detail": {"message": "...", ...}}) was never unwrapped; the generic client-level parser only recognizes {"message": ...}/{"error": {...}} and fell back to dumping the raw JSON body as the error message, e.g. "message": "{\"detail\":{\"message\":\"bad key\",...}}" instead of "message": "bad key". Fixed with refineElevenLabsError, which post-processes the *core.GatewayError DoRaw already built (using its preserved ResponseBody). Verified against live 400/401/403 responses — messages are now clean.

Found a real bug outside this PR's scope (documented, not fixed here): the provider-passthrough router's ALLOW_PASSTHROUGH_V1_ALIAS handling in internal/server/passthrough_support.go strips a leading v1/ path segment for every provider uniformly, assuming the provider's own base URL already embeds /v1 (true for OpenAI-shaped providers, which is presumably why this was never caught). ElevenLabs' base URL is https://api.elevenlabs.io with no /v1, so every /p/elevenlabs/v1/... passthrough call 404s — verified live (/p/elevenlabs/v1/voices → 404, /p/elevenlabs/voices → also 404, /p/elevenlabs/v2/voices → 200, confirming the mechanism precisely). Practical impact: speech-to-speech (voice changer) is completely unreachable through GoModel today since it only has a /v1 path with no /v2 alternative to route around the bug with. I documented this (mechanism + impact + workaround) in docs/providers/elevenlabs.mdx under a new "Not implemented" section, since fixing it properly means teaching the passthrough router which providers' base URLs already embed /v1 — a small but genuinely cross-cutting change affecting every provider's passthrough routing, not something I want to drive-by in an ElevenLabs-scoped PR. Happy to open a follow-up for it if useful.

Also noted (not fixed, pre-existing, confirmed via internal/server/audio_service.go): audit log entries for /v1/audio/speech and /v1/audio/transcriptions don't populate the top-level provider field for any audio-capable provider, not just ElevenLabs — requested_model is populated correctly, just not provider.

@SantiagoDePolonia
SantiagoDePolonia merged commit 5b02a0a into main Aug 10, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants