From b463b96070412773c79202c072fdc6f297bffbf7 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 10 Aug 2026 15:22:43 +0200 Subject: [PATCH 1/3] feat(elevenlabs): add ElevenLabs voice provider 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 --- .env.template | 5 + CLAUDE.md | 2 +- README.md | 3 +- config/config.example.yaml | 7 + docs/docs.json | 1 + docs/providers/elevenlabs.mdx | 77 ++++ docs/providers/overview.mdx | 7 + .../{index-SXsqspkt.js => index-D05Km9Si.js} | 2 +- .../admin/dashboard/static/dist/index.html | 2 +- internal/providers/elevenlabs/audio.go | 298 ++++++++++++++++ internal/providers/elevenlabs/audio_test.go | 335 ++++++++++++++++++ internal/providers/elevenlabs/elevenlabs.go | 206 +++++++++++ .../providers/elevenlabs/elevenlabs_test.go | 161 +++++++++ run/providers.go | 2 + run/providers_test.go | 4 +- .../src/pages/overview/providersLogic.js | 1 + 16 files changed, 1107 insertions(+), 6 deletions(-) create mode 100644 docs/providers/elevenlabs.mdx rename internal/admin/dashboard/static/dist/assets/{index-SXsqspkt.js => index-D05Km9Si.js} (99%) create mode 100644 internal/providers/elevenlabs/audio.go create mode 100644 internal/providers/elevenlabs/audio_test.go create mode 100644 internal/providers/elevenlabs/elevenlabs.go create mode 100644 internal/providers/elevenlabs/elevenlabs_test.go diff --git a/.env.template b/.env.template index e0dc935fb..dab9620da 100644 --- a/.env.template +++ b/.env.template @@ -495,6 +495,11 @@ # MINIMAX_API_KEY=... # MINIMAX_BASE_URL=https://api.minimax.io/v1 +# ElevenLabs (voice: text-to-speech + speech-to-text; default base URL: https://api.elevenlabs.io) +# The OpenAI "voice" field must be an ElevenLabs voice_id. +# ELEVENLABS_API_KEY=... +# ELEVENLABS_BASE_URL=https://api.elevenlabs.io + # Xiaomi MiMo (default base URL: https://api.xiaomimimo.com/v1) # XIAOMI_API_KEY=... # XIAOMI_BASE_URL=https://api.xiaomimimo.com/v1 diff --git a/CLAUDE.md b/CLAUDE.md index 46713faa4..5cb553b40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,5 +138,5 @@ Full reference: `.env.template` and `config/config.yaml` - **Guardrails:** Definitions are persisted in the `guardrail_definitions` store and managed via the admin API/dashboard; `config/config.yaml` entries are validated and upserted into that store at startup (a seed, not the source of truth). `GUARDRAILS_ENABLED` env var gates the feature. - **Provider API key rotation:** Any API-key provider accepts several keys: `[_SUFFIX]_API_KEY_` env vars (numbered from 2; `_1` is accepted as a synonym for the unsuffixed key) or `providers..api_keys` in `config.yaml` (merged after `api_key`, de-duplicated, unresolved `${...}` entries dropped; env replaces the whole YAML list). Identified sessions deterministically stay on one key by default, preserving provider prompt-cache affinity while spreading different sessions across the configured keys; sessionless requests remain round robin. Set `providers..session_sticky_keys: false` or untick **Session-sticky API keys** in the provider editor for strict per-request round robin. Realtime sessions use the same affinity. The trailing number names a key, not a provider: `OPENAI_API_KEY_2` is key 2 of `openai`, while `OPENAI_REGION_2_API_KEY` is the sole key of provider `openai-region-2`. Providers configured without keys are unaffected; Ollama is normally keyless, while SGLang and vLLM participate in rotation when optional API keys are configured. Non-API-key providers (Vertex, Bedrock) are unaffected. - **Provider credentials without env vars:** Every provider below can instead be configured from the admin dashboard's Providers page (or `/admin/provider-credentials` GET/PUT/DELETE), persisted to the `provider_credentials` store — the same declarative-shadows-store precedence as MCP servers: a provider name declared via env vars/`config.yaml` is read-only in the dashboard (`managed: true`), and a store row upsert/delete hot-registers or unregisters the provider into the live registry immediately, no restart. `GOMODEL` boots fine with zero providers configured (empty catalog) so this is a complete alternative to env-var credentials, not just a supplement. API keys (`api_keys`, an ordered rotation list, same semantics as `providers..api_keys`) and service-account secrets are redacted as `***********` on read; an upsert echoing any all-asterisk mask of at least three characters at a position preserves the stored value there (rejected if that position was never set). Disabling a row (`enabled: false`) unregisters it from routing without deleting the stored credentials. `GET /admin/provider-credentials/types` lists every constructible provider type with the credential form it accepts (`fields[]` of `name`/`required`/`advanced`/`options`, plus `default_base_url`), derived from each provider's `DiscoveryConfig` — the dashboard renders only those fields, so an OpenAI-type provider asks for an API key while a Vertex one asks for project/location/service account and no key at all. Upserts are validated against that form (and against Google's project-or-base-URL and service-account rules) *before* anything is persisted, so an unusable credential is rejected with a 400 naming the offending field in `error.param` rather than stored as a broken row. -- **Providers:** `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_DEFAULT_MAX_TOKENS` (optional default `max_tokens` for Anthropic-translated requests that omit it; default 4096), `GEMINI_API_KEY`, `USE_GOOGLE_GEMINI_NATIVE_API` (true by default; false uses Gemini's OpenAI-compatible chat API), `XAI_API_KEY`, `GROQ_API_KEY`, `FIREWORKS_API_KEY`, `FIREWORKS_BASE_URL` (optional Fireworks AI endpoint override; default `https://api.fireworks.ai/inference/v1`), `META_API_KEY`, `META_BASE_URL` (optional Meta Model API endpoint override; default `https://api.meta.ai/v1`; Muse Spark models, e.g. `muse-spark-1.1`), `OPENROUTER_API_KEY`, `OPENROUTER_SITE_URL`/`OPENROUTER_APP_NAME` (optional OpenRouter attribution headers), `ZAI_API_KEY`, `ZAI_BASE_URL` (optional Z.ai endpoint override), `MINIMAX_API_KEY`, `MINIMAX_BASE_URL` (optional MiniMax endpoint override), `XIAOMI_API_KEY`, `XIAOMI_BASE_URL` (optional Xiaomi MiMo endpoint override), `OPENCODE_GO_API_KEY`, `OPENCODE_GO_BASE_URL` (optional OpenCode Go/Zen endpoint override; default `https://opencode.ai/zen/go/v1`), `OPENCODE_GO_MESSAGES_MODELS` (optional comma-separated model IDs routed to the Anthropic-native `/messages` endpoint instead of `/chat/completions`; default `qwen3.7-max`), `BAILIAN_API_KEY`, `BAILIAN_BASE_URL` (optional Bailian base URL for region switching; default `https://dashscope.aliyuncs.com/compatible-mode/v1`), `AZURE_API_KEY`, `AZURE_BASE_URL` (Azure OpenAI deployment base URL), `AZURE_API_VERSION` (optional Azure API version), `ORACLE_API_KEY` (Oracle API key), `ORACLE_BASE_URL` (Oracle OpenAI-compatible base URL), `BEDROCK_BASE_URL` (Bedrock Runtime region or endpoint), `BEDROCK_MANTLE_API_KEY`, `BEDROCK_MANTLE_BASE_URL` (Mantle region or endpoint), `BEDROCK_MANTLE_API_MODE` (`auto`, `openai`, or `standard`), `[_SUFFIX]_MODELS` (comma-separated configured model list for any provider type), `OLLAMA_BASE_URL`, `SGLANG_BASE_URL`, `SGLANG_API_KEY` (optional upstream SGLang bearer token), `VLLM_BASE_URL`, `VLLM_API_KEY` (optional upstream vLLM bearer token), `LLMD_BASE_URL`, `LLMD_API_KEY` (optional Gateway bearer token), `LLMD_INFERENCE_OBJECTIVE`, `LLMD_FAIRNESS_FROM_USER_PATH` +- **Providers:** `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_DEFAULT_MAX_TOKENS` (optional default `max_tokens` for Anthropic-translated requests that omit it; default 4096), `GEMINI_API_KEY`, `USE_GOOGLE_GEMINI_NATIVE_API` (true by default; false uses Gemini's OpenAI-compatible chat API), `XAI_API_KEY`, `GROQ_API_KEY`, `FIREWORKS_API_KEY`, `FIREWORKS_BASE_URL` (optional Fireworks AI endpoint override; default `https://api.fireworks.ai/inference/v1`), `META_API_KEY`, `META_BASE_URL` (optional Meta Model API endpoint override; default `https://api.meta.ai/v1`; Muse Spark models, e.g. `muse-spark-1.1`), `OPENROUTER_API_KEY`, `OPENROUTER_SITE_URL`/`OPENROUTER_APP_NAME` (optional OpenRouter attribution headers), `ZAI_API_KEY`, `ZAI_BASE_URL` (optional Z.ai endpoint override), `MINIMAX_API_KEY`, `MINIMAX_BASE_URL` (optional MiniMax endpoint override), `XIAOMI_API_KEY`, `XIAOMI_BASE_URL` (optional Xiaomi MiMo endpoint override), `ELEVENLABS_API_KEY`, `ELEVENLABS_BASE_URL` (optional ElevenLabs endpoint override; default `https://api.elevenlabs.io`; voice-only provider exposing `/v1/audio/speech` and `/v1/audio/transcriptions` — no chat, `/responses`, or embeddings; the OpenAI `voice` field must be an ElevenLabs voice_id), `OPENCODE_GO_API_KEY`, `OPENCODE_GO_BASE_URL` (optional OpenCode Go/Zen endpoint override; default `https://opencode.ai/zen/go/v1`), `OPENCODE_GO_MESSAGES_MODELS` (optional comma-separated model IDs routed to the Anthropic-native `/messages` endpoint instead of `/chat/completions`; default `qwen3.7-max`), `BAILIAN_API_KEY`, `BAILIAN_BASE_URL` (optional Bailian base URL for region switching; default `https://dashscope.aliyuncs.com/compatible-mode/v1`), `AZURE_API_KEY`, `AZURE_BASE_URL` (Azure OpenAI deployment base URL), `AZURE_API_VERSION` (optional Azure API version), `ORACLE_API_KEY` (Oracle API key), `ORACLE_BASE_URL` (Oracle OpenAI-compatible base URL), `BEDROCK_BASE_URL` (Bedrock Runtime region or endpoint), `BEDROCK_MANTLE_API_KEY`, `BEDROCK_MANTLE_BASE_URL` (Mantle region or endpoint), `BEDROCK_MANTLE_API_MODE` (`auto`, `openai`, or `standard`), `[_SUFFIX]_MODELS` (comma-separated configured model list for any provider type), `OLLAMA_BASE_URL`, `SGLANG_BASE_URL`, `SGLANG_API_KEY` (optional upstream SGLang bearer token), `VLLM_BASE_URL`, `VLLM_API_KEY` (optional upstream vLLM bearer token), `LLMD_BASE_URL`, `LLMD_API_KEY` (optional Gateway bearer token), `LLMD_INFERENCE_OBJECTIVE`, `LLMD_FAIRNESS_FROM_USER_PATH` - **Provider model metadata:** `providers..models` accepts either model IDs (strings) or `{id, metadata}` objects. When `metadata` is supplied (`display_name`, `context_window`, `max_output_tokens`, `modes`, `capabilities`, `pricing`, …) it is merged onto the remote ai-model-list entry during enrichment, with operator values winning per-field. Primary use case: advertising context windows, capabilities, and pricing for local models (Ollama) and other custom endpoints whose IDs are not in the upstream registry. diff --git a/README.md b/README.md index 77bb1b579..c6afb5827 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,8 @@ GoModel supports OpenAI, Anthropic, Cohere, Google Gemini, Vertex AI, DeepSeek, Groq, Fireworks AI, Meta (Muse Spark), OpenRouter, Z.ai, xAI (Grok), Alibaba Cloud Model Studio (Bailian), Kilo AI, MiniMax, Xiaomi MiMo, OpenCode Go, Azure OpenAI, Oracle, Ollama, SGLang, vLLM, llm-d, Amazon Bedrock Runtime, Amazon -Bedrock Mantle, and all OpenAI-compatible providers. +Bedrock Mantle, and all OpenAI-compatible providers. Voice: ElevenLabs +(text-to-speech and speech-to-text). See the [Providers Overview](https://gomodel.enterpilot.io/docs/providers/overview?utm_source=readme) for the full per-provider feature matrix (chat, `/responses`, embeddings, files, batches, diff --git a/config/config.example.yaml b/config/config.example.yaml index 8142a5d38..b3bee386e 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -386,6 +386,13 @@ providers: # base_url defaults to "https://llm.chutes.ai/v1". # Set base_url when using a different compatible endpoint. + elevenlabs: + type: elevenlabs + api_key: "${ELEVENLABS_API_KEY}" + # base_url defaults to "https://api.elevenlabs.io". + # Voice-only provider: text-to-speech and speech-to-text, no chat. The + # OpenAI "voice" field must be an ElevenLabs voice_id. + meta: type: meta api_key: "..." diff --git a/docs/docs.json b/docs/docs.json index d6757c5a8..6ec9e0eed 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -170,6 +170,7 @@ "providers/bailian", "providers/xiaomi", "providers/minimax", + "providers/elevenlabs", "providers/opencode-go", "providers/sglang", "providers/vllm", diff --git a/docs/providers/elevenlabs.mdx b/docs/providers/elevenlabs.mdx new file mode 100644 index 000000000..68b0efdd2 --- /dev/null +++ b/docs/providers/elevenlabs.mdx @@ -0,0 +1,77 @@ +--- +title: "ElevenLabs" +description: "Configure ElevenLabs in GoModel: voice_id vs named voices, supported audio formats, and speech-to-text timestamps." +icon: "waveform-lines" +keywords: ["ElevenLabs", "text-to-speech", "TTS", "speech-to-text", "STT", "voice", "provider setup"] +--- + +ElevenLabs is a voice-only provider: it exposes text-to-speech and +speech-to-text behind the standard `/v1/audio/speech` and +`/v1/audio/transcriptions` endpoints. It has no chat, `/responses`, or +embeddings API, so those endpoints return `invalid_request_error` for +ElevenLabs-routed models. + +## Configure + +```bash +ELEVENLABS_API_KEY=... +``` + +Or in `config.yaml`: + +```yaml +providers: + elevenlabs: + type: elevenlabs + api_key: "${ELEVENLABS_API_KEY}" + # base_url defaults to "https://api.elevenlabs.io". +``` + +## Voices are IDs, not names + +Unlike OpenAI's fixed voice names (`alloy`, `verse`, ...), ElevenLabs has no +built-in named voices — every voice is an ID from your ElevenLabs voice +library (built-in, cloned, or shared). Pass that ID as the OpenAI-compatible +`voice` field: + +```json +{ + "model": "eleven_multilingual_v2", + "input": "Hello there", + "voice": "21m00Tcm4TlvDq8ikWAM" +} +``` + +List your available voice IDs from the ElevenLabs dashboard or the +`GET /v1/voices` API (available under `/p/elevenlabs/v1/voices` via +[passthrough](/features/passthrough-api) once `elevenlabs` is added to +`ENABLED_PASSTHROUGH_PROVIDERS`). + +## Supported speech formats + +`response_format` accepts `mp3` (default), `opus`, `pcm`, and `wav`; each maps +to a fixed ElevenLabs `output_format` (`mp3_44100_128`, `opus_48000_128`, +`pcm_44100`, `wav_44100`). `aac` and `flac` are not supported and return +`invalid_request_error`. `speed` must be between `0.7` and `1.2` when set, +matching ElevenLabs' voice setting range; `instructions` is not supported. + +## Speech-to-text models and timestamps + +Transcription models (`scribe_v2`, current; `scribe_v1`, still valid) are a +separate model family from the text-to-speech catalog and are not returned by +ElevenLabs' `/v1/models` listing — GoModel adds them to `/v1/models` output +itself. `response_format` accepts `json` (default), `text`, and +`verbose_json`; `srt`/`vtt` are not supported. Requesting `verbose_json`, or +`word` in `timestamp_granularities`, asks ElevenLabs for word-level timing, +which GoModel maps into the OpenAI `words` array. `prompt` is not supported. + +## Not supported by ElevenLabs + +All of these return `invalid_request_error` rather than silently dropping the +option: + +- Chat completions, `/v1/responses`, and embeddings. +- Speech `instructions`, and `response_format` values other than + `mp3`/`opus`/`pcm`/`wav`. +- Transcription `prompt`, and `response_format` values other than + `json`/`text`/`verbose_json`. diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index bce882554..fdac634b2 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -57,6 +57,7 @@ support, not every individual model capability exposed by an upstream provider. | Alibaba Cloud Model Studio (Bailian) | `BAILIAN_API_KEY` (`BAILIAN_BASE_URL` optional) | `qwen3-max` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | [Alibaba Cloud Model Studio](/providers/bailian) | | MiniMax | `MINIMAX_API_KEY` (`MINIMAX_BASE_URL` optional) | `MiniMax-M3` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | [MiniMax](/providers/minimax) | | Xiaomi MiMo | `XIAOMI_API_KEY` (`XIAOMI_BASE_URL` optional) | `mimo-v2.5-pro` | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | [Xiaomi MiMo](/providers/xiaomi) | +| ElevenLabs (voice only) | `ELEVENLABS_API_KEY` (`ELEVENLABS_BASE_URL` optional) | `eleven_multilingual_v2` | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | [ElevenLabs](/providers/elevenlabs) | | OpenCode Go | `OPENCODE_GO_API_KEY` (`OPENCODE_GO_BASE_URL` optional) | `glm-5.1` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | [OpenCode Go](/providers/opencode-go) | | Kimi Code | `KIMICODE_API_KEY` | `kimi-for-coding` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | [Kimi Code](/providers/kimicode) | | Azure OpenAI | `AZURE_API_KEY` + `AZURE_BASE_URL` (`AZURE_API_VERSION` optional) | `gpt-5` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | [Azure OpenAI](/providers/azure) | @@ -91,6 +92,12 @@ support, not every individual model capability exposed by an upstream provider. - **Fireworks AI** — model IDs are account-scoped paths such as `accounts/fireworks/models/gpt-oss-120b`; use them verbatim in requests and in `FIREWORKS_MODELS`. +- **ElevenLabs** — a voice-only provider: `/v1/audio/speech` (text-to-speech) + and `/v1/audio/transcriptions` (speech-to-text) are supported, but chat, + `/v1/responses`, and embeddings are not (the ❌s above reflect that, not a + gateway limitation). The OpenAI `voice` field must be an ElevenLabs + voice_id. See the [ElevenLabs guide](/providers/elevenlabs) for supported + audio formats and speech-to-text models. - **Chutes AI** — defaults to `https://llm.chutes.ai/v1` and discovers its current model IDs, context limits, capabilities, and pricing from the live catalog. GoModel translates `/v1/responses` requests to chat completions; diff --git a/internal/admin/dashboard/static/dist/assets/index-SXsqspkt.js b/internal/admin/dashboard/static/dist/assets/index-D05Km9Si.js similarity index 99% rename from internal/admin/dashboard/static/dist/assets/index-SXsqspkt.js rename to internal/admin/dashboard/static/dist/assets/index-D05Km9Si.js index 8f0e3778f..ff9b4635c 100644 --- a/internal/admin/dashboard/static/dist/assets/index-SXsqspkt.js +++ b/internal/admin/dashboard/static/dist/assets/index-D05Km9Si.js @@ -5,7 +5,7 @@ `)>-1?e.split(` `):e}function Av(e,t){let{element:n,datasetIndex:r,index:i}=t,a=e.getDatasetMeta(r).controller,{label:o,value:s}=a.getLabelAndValue(i);return{chart:e,label:o,parsed:a.getParsed(i),raw:e.data.datasets[r].data[i],formattedValue:s,dataset:a.getDataset(),dataIndex:i,datasetIndex:r,element:n}}function jv(e,t){let n=e.chart.ctx,{body:r,footer:i,title:a}=e,{boxWidth:o,boxHeight:s}=t,c=_f(t.bodyFont),l=_f(t.titleFont),u=_f(t.footerFont),d=a.length,f=i.length,p=r.length,m=gf(t.padding),h=m.height,g=0,_=r.reduce((e,t)=>e+t.before.length+t.lines.length+t.after.length,0);if(_+=e.beforeBody.length+e.afterBody.length,d&&(h+=d*l.lineHeight+(d-1)*t.titleSpacing+t.titleMarginBottom),_){let e=t.displayColors?Math.max(s,c.lineHeight):c.lineHeight;h+=p*e+(_-p)*c.lineHeight+(_-1)*t.bodySpacing}f&&(h+=t.footerMarginTop+f*u.lineHeight+(f-1)*t.footerSpacing);let v=0,y=function(e){g=Math.max(g,n.measureText(e).width+v)};return n.save(),n.font=l.string,hu(e.title,y),n.font=c.string,hu(e.beforeBody.concat(e.afterBody),y),v=t.displayColors?o+2+t.boxPadding:0,hu(r,e=>{hu(e.before,y),hu(e.lines,y),hu(e.after,y)}),v=0,n.font=u.string,hu(e.footer,y),n.restore(),g+=m.width,{width:g,height:h}}function Mv(e,t){let{y:n,height:r}=t;return ne.height-r/2?`bottom`:`center`}function Nv(e,t,n,r){let{x:i,width:a}=r,o=n.caretSize+n.caretPadding;if(e===`left`&&i+a+o>t.width||e===`right`&&i-a-o<0)return!0}function Pv(e,t,n,r){let{x:i,width:a}=n,{width:o,chartArea:{left:s,right:c}}=e,l=`center`;return r===`center`?l=i<=(s+c)/2?`left`:`right`:i<=a/2?l=`left`:i>=o-a/2&&(l=`right`),Nv(l,e,t,n)&&(l=`center`),l}function Fv(e,t,n){let r=n.yAlign||t.yAlign||Mv(e,n);return{xAlign:n.xAlign||t.xAlign||Pv(e,t,n,r),yAlign:r}}function Iv(e,t){let{x:n,width:r}=e;return t===`right`?n-=r:t===`center`&&(n-=r/2),n}function Lv(e,t,n){let{y:r,height:i}=e;return t===`top`?r+=n:t===`bottom`?r-=i+n:r-=i/2,r}function Rv(e,t,n,r){let{caretSize:i,caretPadding:a,cornerRadius:o}=e,{xAlign:s,yAlign:c}=n,l=i+a,{topLeft:u,topRight:d,bottomLeft:f,bottomRight:p}=hf(o),m=Iv(t,s),h=Lv(t,c,l);return c===`center`?s===`left`?m+=l:s===`right`&&(m-=l):s===`left`?m-=Math.max(u,f)+i:s===`right`&&(m+=Math.max(d,p)+i),{x:rd(m,0,r.width-t.width),y:rd(h,0,r.height-t.height)}}function zv(e,t,n){let r=gf(n.padding);return t===`center`?e.x+e.width/2:t===`right`?e.x+e.width-r.right:e.x+r.left}function Bv(e){return Ov([],kv(e))}function Vv(e,t,n){return bf(e,{tooltip:t,tooltipItems:n,type:`tooltip`})}function Hv(e,t){let n=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return n?e.override(n):e}var Uv={beforeTitle:iu,title(e){if(e.length>0){let t=e[0],n=t.chart.data.labels,r=n?n.length:0;if(this&&this.options&&this.options.mode===`dataset`)return t.dataset.label||``;if(t.label)return t.label;if(r>0&&t.dataIndex{let t={before:[],lines:[],after:[]},i=Hv(n,e);Ov(t.before,kv(Wv(i,`beforeLabel`,this,e))),Ov(t.lines,Wv(i,`label`,this,e)),Ov(t.after,kv(Wv(i,`afterLabel`,this,e))),r.push(t)}),r}getAfterBody(e,t){return Bv(Wv(t.callbacks,`afterBody`,this,e))}getFooter(e,t){let{callbacks:n}=t,r=Wv(n,`beforeFooter`,this,e),i=Wv(n,`footer`,this,e),a=Wv(n,`afterFooter`,this,e),o=[];return o=Ov(o,kv(r)),o=Ov(o,kv(i)),o=Ov(o,kv(a)),o}_createItems(e){let t=this._active,n=this.chart.data,r=[],i=[],a=[],o=[],s,c;for(s=0,c=t.length;se.filter(t,r,i,n))),e.itemSort&&(o=o.sort((t,r)=>e.itemSort(t,r,n))),hu(o,t=>{let n=Hv(e.callbacks,t);r.push(Wv(n,`labelColor`,this,t)),i.push(Wv(n,`labelPointStyle`,this,t)),a.push(Wv(n,`labelTextColor`,this,t))}),this.labelColors=r,this.labelPointStyles=i,this.labelTextColors=a,this.dataPoints=o,o}update(e,t){let n=this.options.setContext(this.getContext()),r=this._active,i,a=[];if(!r.length)this.opacity!==0&&(i={opacity:0});else{let e=Dv[n.position].call(this,r,this._eventPosition);a=this._createItems(n),this.title=this.getTitle(a,n),this.beforeBody=this.getBeforeBody(a,n),this.body=this.getBody(a,n),this.afterBody=this.getAfterBody(a,n),this.footer=this.getFooter(a,n);let t=this._size=jv(this,n),o=Object.assign({},e,t),s=Fv(this.chart,n,o),c=Rv(n,o,s,this.chart);this.xAlign=s.xAlign,this.yAlign=s.yAlign,i={opacity:1,x:c.x,y:c.y,width:t.width,height:t.height,caretX:e.x,caretY:e.y}}this._tooltipItems=a,this.$context=void 0,i&&this._resolveAnimations().update(this,i),e&&n.external&&n.external.call(this,{chart:this.chart,tooltip:this,replay:t})}drawCaret(e,t,n,r){let i=this.getCaretPosition(e,n,r);t.lineTo(i.x1,i.y1),t.lineTo(i.x2,i.y2),t.lineTo(i.x3,i.y3)}getCaretPosition(e,t,n){let{xAlign:r,yAlign:i}=this,{caretSize:a,cornerRadius:o}=n,{topLeft:s,topRight:c,bottomLeft:l,bottomRight:u}=hf(o),{x:d,y:f}=e,{width:p,height:m}=t,h,g,_,v,y,b;return i===`center`?(y=f+m/2,r===`left`?(h=d,g=h-a,v=y+a,b=y-a):(h=d+p,g=h+a,v=y-a,b=y+a),_=h):(g=r===`left`?d+Math.max(s,l)+a:r===`right`?d+p-Math.max(c,u)-a:this.caretX,i===`top`?(v=f,y=v-a,h=g-a,_=g+a):(v=f+m,y=v+a,h=g+a,_=g-a),b=v),{x1:h,x2:g,x3:_,y1:v,y2:y,y3:b}}drawTitle(e,t,n){let r=this.title,i=r.length,a,o,s;if(i){let c=vp(n.rtl,this.x,this.width);for(e.x=zv(this,n.titleAlign,n),t.textAlign=c.textAlign(n.titleAlign),t.textBaseline=`middle`,a=_f(n.titleFont),o=n.titleSpacing,t.fillStyle=n.titleColor,t.font=a.string,s=0;se!==0)?(e.beginPath(),e.fillStyle=i.multiKeyBackground,cf(e,{x:t,y:p,w:c,h:s,radius:o}),e.fill(),e.stroke(),e.fillStyle=a.backgroundColor,e.beginPath(),cf(e,{x:n,y:p+1,w:c-2,h:s-2,radius:o}),e.fill()):(e.fillStyle=i.multiKeyBackground,e.fillRect(t,p,c,s),e.strokeRect(t,p,c,s),e.fillStyle=a.backgroundColor,e.fillRect(n,p+1,c-2,s-2))}e.fillStyle=this.labelTextColors[n]}drawBody(e,t,n){let{body:r}=this,{bodySpacing:i,bodyAlign:a,displayColors:o,boxHeight:s,boxWidth:c,boxPadding:l}=n,u=_f(n.bodyFont),d=u.lineHeight,f=0,p=vp(n.rtl,this.x,this.width),m=function(n){t.fillText(n,p.x(e.x+f),e.y+d/2),e.y+=d+i},h=p.textAlign(a),g,_,v,y,b,x,S;for(t.textAlign=a,t.textBaseline=`middle`,t.font=u.string,e.x=zv(this,h,n),t.fillStyle=n.bodyColor,hu(this.beforeBody,m),f=o&&h!==`right`?a===`center`?c/2+l:c+2+l:0,y=0,x=r.length;y0&&t.stroke()}_updateAnimationTarget(e){let t=this.chart,n=this.$animations,r=n&&n.x,i=n&&n.y;if(r||i){let n=Dv[e.position].call(this,this._active,this._eventPosition);if(!n)return;let a=this._size=jv(this,e),o=Object.assign({},n,this._size),s=Fv(t,e,o),c=Rv(e,o,s,t);(r._to!==c.x||i._to!==c.y)&&(this.xAlign=s.xAlign,this.yAlign=s.yAlign,this.width=a.width,this.height=a.height,this.caretX=n.x,this.caretY=n.y,this._resolveAnimations().update(this,c))}}_willRender(){return!!this.opacity}draw(e){let t=this.options.setContext(this.getContext()),n=this.opacity;if(!n)return;this._updateAnimationTarget(t);let r={width:this.width,height:this.height},i={x:this.x,y:this.y};n=Math.abs(n)<.001?0:n;let a=gf(t.padding),o=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;t.enabled&&o&&(e.save(),e.globalAlpha=n,this.drawBackground(i,e,r,t),yp(e,t.textDirection),i.y+=a.top,this.drawTitle(i,e,t),this.drawBody(i,e,t),this.drawFooter(i,e,t),bp(e,t.textDirection),e.restore())}getActiveElements(){return this._active||[]}setActiveElements(e,t){let n=this._active,r=e.map(({datasetIndex:e,index:t})=>{let n=this.chart.getDatasetMeta(e);if(!n)throw Error(`Cannot find a dataset at index `+e);return{datasetIndex:e,element:n.data[t],index:t}}),i=!gu(n,r),a=this._positionChanged(r,t);(i||a)&&(this._active=r,this._eventPosition=t,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(e,t,n=!0){if(t&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;let r=this.options,i=this._active||[],a=this._getActiveElements(e,i,t,n),o=this._positionChanged(a,e),s=t||!gu(a,i)||o;return s&&(this._active=a,(r.enabled||r.external)&&(this._eventPosition={x:e.x,y:e.y},this.update(!0,t))),s}_getActiveElements(e,t,n,r){let i=this.options;if(e.type===`mouseout`)return[];if(!r)return t.filter(e=>this.chart.data.datasets[e.datasetIndex]&&this.chart.getDatasetMeta(e.datasetIndex).controller.getParsed(e.index)!==void 0);let a=this.chart.getElementsAtEventForMode(e,i.mode,i,n);return i.reverse&&a.reverse(),a}_positionChanged(e,t){let{caretX:n,caretY:r,options:i}=this,a=Dv[i.position].call(this,e,t);return a!==!1&&(n!==a.x||r!==a.y)}},Kv=Object.freeze({__proto__:null,Colors:k_,Decimation:F_,Filler:fv,Legend:xv,SubTitle:Ev,Title:wv,Tooltip:{id:`tooltip`,_element:Gv,positioners:Dv,afterInit(e,t,n){n&&(e.tooltip=new Gv({chart:e,options:n}))},beforeUpdate(e,t,n){e.tooltip&&e.tooltip.initialize(n)},reset(e,t,n){e.tooltip&&e.tooltip.initialize(n)},afterDraw(e){let t=e.tooltip;if(t&&t._willRender()){let n={tooltip:t};if(e.notifyPlugins(`beforeTooltipDraw`,{...n,cancelable:!0})===!1)return;t.draw(e.ctx),e.notifyPlugins(`afterTooltipDraw`,n)}},afterEvent(e,t){if(e.tooltip){let n=t.replay;e.tooltip.handleEvent(t.event,n,t.inChartArea)&&(t.changed=!0)}},defaults:{enabled:!0,external:null,position:`average`,backgroundColor:`rgba(0,0,0,0.8)`,titleColor:`#fff`,titleFont:{weight:`bold`},titleSpacing:2,titleMarginBottom:6,titleAlign:`left`,bodyColor:`#fff`,bodySpacing:2,bodyFont:{},bodyAlign:`left`,footerColor:`#fff`,footerSpacing:2,footerMarginTop:6,footerFont:{weight:`bold`},footerAlign:`left`,padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(e,t)=>t.bodyFont.size,boxWidth:(e,t)=>t.bodyFont.size,multiKeyBackground:`#fff`,displayColors:!0,boxPadding:0,borderColor:`rgba(0,0,0,0)`,borderWidth:0,animation:{duration:400,easing:`easeOutQuart`},animations:{numbers:{type:`number`,properties:[`x`,`y`,`width`,`height`,`caretX`,`caretY`]},opacity:{easing:`linear`,duration:200}},callbacks:Uv},defaultRoutes:{bodyFont:`font`,footerFont:`font`,titleFont:`font`},descriptors:{_scriptable:e=>e!==`filter`&&e!==`itemSort`&&e!==`external`,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:`animation`}},additionalOptionScopes:[`interaction`]}}),qv=(e,t,n,r)=>(typeof t==`string`?(n=e.push(t)-1,r.unshift({index:n,label:t})):isNaN(t)&&(n=null),n);function Jv(e,t,n,r){let i=e.indexOf(t);return i===-1?qv(e,t,n,r):i===e.lastIndexOf(t)?i:n}var Yv=(e,t)=>e===null?null:rd(Math.round(e),0,t);function Xv(e){let t=this.getLabels();return e>=0&&et.length-1?null:this.getPixelForValue(t[e].value)}getValueForPixel(e){return Math.round(this._startValue+this.getDecimalForPixel(e)*this._valueRange)}getBasePixel(){return this.bottom}};function Qv(e,t){let n=[],{bounds:r,step:i,min:a,max:o,precision:s,count:c,maxTicks:l,maxDigits:u,includeBounds:d}=e,f=i||1,p=l-1,{min:m,max:h}=t,g=!ou(a),_=!ou(o),v=!ou(c),y=(h-m)/(u+1),b=Uu((h-m)/p/f)*f,x,S,C,w;if(b<1e-14&&!g&&!_)return[{value:m},{value:h}];w=Math.ceil(h/b)-Math.floor(m/b),w>p&&(b=Uu(w*b/p/f)*f),ou(s)||(x=10**s,b=Math.ceil(b*x)/x),r===`ticks`?(S=Math.floor(m/b)*b,C=Math.ceil(h/b)*b):(S=m,C=h),g&&_&&i&&qu((o-a)/i,b/1e3)?(w=Math.round(Math.min((o-a)/b,l)),b=(o-a)/w,S=a,C=o):v?(S=g?a:S,C=_?o:C,w=c-1,b=(C-S)/w):(w=(C-S)/b,w=Hu(w,Math.round(w),b/1e3)?Math.round(w):Math.ceil(w));let T=Math.max(Zu(b),Zu(S));x=10**(ou(s)?T:s),S=Math.round(S*x)/x,C=Math.round(C*x)/x;let ee=0;for(g&&(d&&S!==a?(n.push({value:a}),So)break;n.push({value:e})}return _&&d&&C!==o?n.length&&Hu(n[n.length-1].value,o,$v(o,y,e))?n[n.length-1].value=o:n.push({value:o}):(!_||C===o)&&n.push({value:C}),n}function $v(e,t,{horizontal:n,minRotation:r}){let i=Yu(r),a=(n?Math.sin(i):Math.cos(i))||.001,o=.75*t*(``+e).length;return Math.min(t/a,o)}var ey=class extends Jh{constructor(e){super(e),this.start=void 0,this.end=void 0,this._startValue=void 0,this._endValue=void 0,this._valueRange=0}parse(e,t){return ou(e)||(typeof e==`number`||e instanceof Number)&&!isFinite(+e)?null:+e}handleTickRangeOptions(){let{beginAtZero:e}=this.options,{minDefined:t,maxDefined:n}=this.getUserBounds(),{min:r,max:i}=this,a=e=>r=t?r:e,o=e=>i=n?i:e;if(e){let e=Vu(r),t=Vu(i);e<0&&t<0?o(0):e>0&&t>0&&a(0)}if(r===i){let t=i===0?1:Math.abs(i*.05);o(i+t),e||a(r-t)}this.min=r,this.max=i}getTickLimit(){let{maxTicksLimit:e,stepSize:t}=this.options.ticks,n;return t?(n=Math.ceil(this.max/t)-Math.floor(this.min/t)+1,n>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${t} would result generating up to ${n} ticks. Limiting to 1000.`),n=1e3)):(n=this.computeTickLimit(),e||=11),e&&(n=Math.min(e,n)),n}computeTickLimit(){return 1/0}buildTicks(){let e=this.options,t=e.ticks,n=this.getTickLimit();n=Math.max(2,n);let r=Qv({maxTicks:n,bounds:e.bounds,min:e.min,max:e.max,precision:t.precision,step:t.stepSize,count:t.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:t.minRotation||0,includeBounds:t.includeBounds!==!1},this._range||this);return e.bounds===`ticks`&&Ju(r,this,`value`),e.reverse?(r.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),r}configure(){let e=this.ticks,t=this.min,n=this.max;if(super.configure(),this.options.offset&&e.length){let r=(n-t)/Math.max(e.length-1,1)/2;t-=r,n+=r}this._startValue=t,this._endValue=n,this._valueRange=n-t}getLabelForValue(e){return Fd(e,this.chart.options.locale,this.options.ticks.format)}},ty=class extends ey{static id=`linear`;static defaults={ticks:{callback:Rd.formatters.numeric}};determineDataLimits(){let{min:e,max:t}=this.getMinMax(!0);this.min=lu(e)?e:0,this.max=lu(t)?t:1,this.handleTickRangeOptions()}computeTickLimit(){let e=this.isHorizontal(),t=e?this.width:this.height,n=Yu(this.options.ticks.minRotation),r=(e?Math.sin(n):Math.cos(n))||.001,i=this._resolveTickFontOptions(0);return Math.ceil(t/Math.min(40,i.lineHeight/r))}getPixelForValue(e){return e===null?NaN:this.getPixelForDecimal((e-this._startValue)/this._valueRange)}getValueForPixel(e){return this._startValue+this.getDecimalForPixel(e)*this._valueRange}},ny=e=>Math.floor(Bu(e)),ry=(e,t)=>10**(ny(e)+t);function iy(e){return e/10**ny(e)==1}function ay(e,t,n){let r=10**n,i=Math.floor(e/r);return Math.ceil(t/r)-i}function oy(e,t){let n=ny(t-e);for(;ay(e,t,n)>10;)n++;for(;ay(e,t,n)<10;)n--;return Math.min(n,ny(e))}function sy(e,{min:t,max:n}){t=uu(e.min,t);let r=[],i=ny(t),a=oy(t,n),o=a<0?10**Math.abs(a):1,s=10**a,c=i>a?10**i:0,l=Math.round((t-c)*o)/o,u=Math.floor((t-c)/s/10)*s*10,d=Math.floor((l-u)/10**a),f=uu(e.min,Math.round((c+u+d*10**a)*o)/o);for(;f=10?d=d<15?15:20:d++,d>=20&&(a++,d=2,o=a>=0?1:o),f=Math.round((c+u+d*10**a)*o)/o;let p=uu(e.max,f);return r.push({value:p,major:iy(p),significand:d}),r}var cy=class extends Jh{static id=`logarithmic`;static defaults={ticks:{callback:Rd.formatters.logarithmic,major:{enabled:!0}}};constructor(e){super(e),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(e,t){let n=ey.prototype.parse.apply(this,[e,t]);if(n===0){this._zero=!0;return}return lu(n)&&n>0?n:null}determineDataLimits(){let{min:e,max:t}=this.getMinMax(!0);this.min=lu(e)?Math.max(0,e):null,this.max=lu(t)?Math.max(0,t):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!lu(this._userMin)&&(this.min=e===ry(this.min,0)?ry(this.min,-1):ry(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){let{minDefined:e,maxDefined:t}=this.getUserBounds(),n=this.min,r=this.max,i=t=>n=e?n:t,a=e=>r=t?r:e;n===r&&(n<=0?(i(1),a(10)):(i(ry(n,-1)),a(ry(r,1)))),n<=0&&i(ry(r,-1)),r<=0&&a(ry(n,1)),this.min=n,this.max=r}buildTicks(){let e=this.options,t=sy({min:this._userMin,max:this._userMax},this);return e.bounds===`ticks`&&Ju(t,this,`value`),e.reverse?(t.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),t}getLabelForValue(e){return e===void 0?`0`:Fd(e,this.chart.options.locale,this.options.ticks.format)}configure(){let e=this.min;super.configure(),this._startValue=Bu(e),this._valueRange=Bu(this.max)-Bu(e)}getPixelForValue(e){return(e===void 0||e===0)&&(e=this.min),e===null||isNaN(e)?NaN:this.getPixelForDecimal(e===this.min?0:(Bu(e)-this._startValue)/this._valueRange)}getValueForPixel(e){let t=this.getDecimalForPixel(e);return 10**(this._startValue+t*this._valueRange)}};function ly(e){let t=e.ticks;if(t.display&&e.display){let e=gf(t.backdropPadding);return du(t.font&&t.font.size,Wd.font.size)+e.height}return 0}function uy(e,t,n){return n=su(n)?n:[n],{w:qd(e,t.string,n),h:n.length*t.lineHeight}}function dy(e,t,n,r,i){return e===r||e===i?{start:t-n/2,end:t+n/2}:ei?{start:t-n,end:t}:{start:t,end:t+n}}function fy(e){let t={l:e.left+e._padding.left,r:e.right-e._padding.right,t:e.top+e._padding.top,b:e.bottom-e._padding.bottom},n=Object.assign({},t),r=[],i=[],a=e._pointLabels.length,o=e.options.pointLabels,s=o.centerPointLabels?Mu/a:0;for(let c=0;ct.r&&(s=(r.end-t.r)/a,e.r=Math.max(e.r,t.r+s)),i.startt.b&&(c=(i.end-t.b)/o,e.b=Math.max(e.b,t.b+c))}function my(e,t,n){let r=e.drawingArea,{extra:i,additionalAngle:a,padding:o,size:s}=n,c=e.getPointPosition(t,r+i+o,a),l=Math.round(Xu(td(c.angle+Lu))),u=yy(c.y,s.h,l),d=_y(l),f=vy(c.x,s.w,d);return{visible:!0,x:c.x,y:u,textAlign:d,left:f,top:u,right:f+s.w,bottom:u+s.h}}function hy(e,t){if(!t)return!0;let{left:n,top:r,right:i,bottom:a}=e;return!(Qd({x:n,y:r},t)||Qd({x:n,y:a},t)||Qd({x:i,y:r},t)||Qd({x:i,y:a},t))}function gy(e,t,n){let r=[],i=e._pointLabels.length,a=e.options,{centerPointLabels:o,display:s}=a.pointLabels,c={extra:ly(a)/2,additionalAngle:o?Mu/i:0},l;for(let a=0;a270||n<90)&&(e-=t),e}function by(e,t,n){let{left:r,top:i,right:a,bottom:o}=n,{backdropColor:s}=t;if(!ou(s)){let n=hf(t.borderRadius),c=gf(t.backdropPadding);e.fillStyle=s;let l=r-c.left,u=i-c.top,d=a-r+c.width,f=o-i+c.height;Object.values(n).some(e=>e!==0)?(e.beginPath(),cf(e,{x:l,y:u,w:d,h:f,radius:n}),e.fill()):e.fillRect(l,u,d,f)}}function xy(e,t){let{ctx:n,options:{pointLabels:r}}=e;for(let i=t-1;i>=0;i--){let t=e._pointLabelItems[i];if(!t.visible)continue;let a=r.setContext(e.getPointLabelContext(i));by(n,a,t);let o=_f(a.font),{x:s,y:c,textAlign:l}=t;sf(n,e._pointLabels[i],s,c+o.lineHeight/2,o,{color:a.color,textAlign:l,textBaseline:`middle`})}}function Sy(e,t,n,r){let{ctx:i}=e;if(n)i.arc(e.xCenter,e.yCenter,t,0,Nu);else{let n=e.getPointPosition(0,t);i.moveTo(n.x,n.y);for(let a=1;a{let n=mu(this.options.pointLabels.callback,[e,t],this);return n||n===0?n:``}).filter((e,t)=>this.chart.getDataVisibility(t))}fit(){let e=this.options;e.display&&e.pointLabels.display?fy(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(e,t,n,r){this.xCenter+=Math.floor((e-t)/2),this.yCenter+=Math.floor((n-r)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(e,t,n,r))}getIndexAngle(e){let t=Nu/(this._pointLabels.length||1),n=this.options.startAngle||0;return td(e*t+Yu(n))}getDistanceFromCenterForValue(e){if(ou(e))return NaN;let t=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-e)*t:(e-this.min)*t}getValueForDistanceFromCenter(e){if(ou(e))return NaN;let t=e/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-t:this.min+t}getPointLabelContext(e){let t=this._pointLabels||[];if(e>=0&&e{if(t!==0||t===0&&this.min<0){s=this.getDistanceFromCenterForValue(e.value);let n=this.getContext(t),o=r.setContext(n),c=i.setContext(n);Cy(this,o,s,a,c)}}),n.display){for(e.save(),o=a-1;o>=0;o--){let r=n.setContext(this.getPointLabelContext(o)),{color:i,lineWidth:a}=r;!a||!i||(e.lineWidth=a,e.strokeStyle=i,e.setLineDash(r.borderDash),e.lineDashOffset=r.borderDashOffset,s=this.getDistanceFromCenterForValue(t.reverse?this.min:this.max),c=this.getPointPosition(o,s),e.beginPath(),e.moveTo(this.xCenter,this.yCenter),e.lineTo(c.x,c.y),e.stroke())}e.restore()}}drawBorder(){}drawLabels(){let e=this.ctx,t=this.options,n=t.ticks;if(!n.display)return;let r=this.getIndexAngle(0),i,a;e.save(),e.translate(this.xCenter,this.yCenter),e.rotate(r),e.textAlign=`center`,e.textBaseline=`middle`,this.ticks.forEach((r,o)=>{if(o===0&&this.min>=0&&!t.reverse)return;let s=n.setContext(this.getContext(o)),c=_f(s.font);if(i=this.getDistanceFromCenterForValue(this.ticks[o].value),s.showLabelBackdrop){e.font=c.string,a=e.measureText(r.label).width,e.fillStyle=s.backdropColor;let t=gf(s.backdropPadding);e.fillRect(-a/2-t.left,-i-c.size/2-t.top,a+t.width,c.size+t.height)}sf(e,r.label,0,-i,c,{color:s.color,strokeColor:s.textStrokeColor,strokeWidth:s.textStrokeWidth})}),e.restore()}drawTitle(){}},Ey={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},Dy=Object.keys(Ey);function Oy(e,t){return e-t}function ky(e,t){if(ou(t))return null;let n=e._adapter,{parser:r,round:i,isoWeekday:a}=e._parseOpts,o=t;return typeof r==`function`&&(o=r(o)),lu(o)||(o=typeof r==`string`?n.parse(o,r):n.parse(o)),o===null?null:(i&&(o=i===`week`&&(Ku(a)||a===!0)?n.startOf(o,`isoWeek`,a):n.startOf(o,i)),+o)}function Ay(e,t,n,r){let i=Dy.length;for(let a=Dy.indexOf(e);a=Dy.indexOf(n);a--){let n=Dy[a];if(Ey[n].common&&e._adapter.diff(i,r,n)>=t-1)return n}return Dy[n?Dy.indexOf(n):0]}function My(e){for(let t=Dy.indexOf(e)+1,n=Dy.length;t=t?n[r]:n[i];e[a]=!0}}function Py(e,t,n,r){let i=e._adapter,a=+i.startOf(t[0].value,r),o=t[t.length-1].value,s,c;for(s=a;s<=o;s=+i.add(s,1,r))c=n[s],c>=0&&(t[c].major=!0);return t}function Fy(e,t,n){let r=[],i={},a=t.length,o,s;for(o=0;o+e.value))}initOffsets(e=[]){let t=0,n=0,r,i;this.options.offset&&e.length&&(r=this.getDecimalForValue(e[0]),t=e.length===1?1-r:(this.getDecimalForValue(e[1])-r)/2,i=this.getDecimalForValue(e[e.length-1]),n=e.length===1?i:(i-this.getDecimalForValue(e[e.length-2]))/2);let a=e.length<3?.5:.25;t=rd(t,0,a),n=rd(n,0,a),this._offsets={start:t,end:n,factor:1/(t+1+n)}}_generate(){let e=this._adapter,t=this.min,n=this.max,r=this.options,i=r.time,a=i.unit||Ay(i.minUnit,t,n,this._getLabelCapacity(t)),o=du(r.ticks.stepSize,1),s=a===`week`&&i.isoWeekday,c=Ku(s)||s===!0,l={},u=t,d,f;if(c&&(u=+e.startOf(u,`isoWeek`,s)),u=+e.startOf(u,c?`day`:a),e.diff(n,t,a)>1e5*o)throw Error(t+` and `+n+` are too far apart with stepSize of `+o+` `+a);let p=r.ticks.source===`data`&&this.getDataTimestamps();for(d=u,f=0;d+e)}getLabelForValue(e){let t=this._adapter,n=this.options.time;return n.tooltipFormat?t.format(e,n.tooltipFormat):t.format(e,n.displayFormats.datetime)}format(e,t){let n=this.options.time.displayFormats,r=this._unit,i=t||n[r];return this._adapter.format(e,i)}_tickFormatFunction(e,t,n,r){let i=this.options,a=i.ticks.callback;if(a)return mu(a,[e,t,n],this);let o=i.time.displayFormats,s=this._unit,c=this._majorUnit,l=s&&o[s],u=c&&o[c],d=n[t],f=c&&u&&d&&d.major;return this._adapter.format(e,r||(f?u:l))}generateTickLabels(e){let t,n,r;for(t=0,n=e.length;t0?o:1}getDataTimestamps(){let e=this._cache.data||[],t,n;if(e.length)return e;let r=this.getMatchingVisibleMetas();if(this._normalized&&r.length)return this._cache.data=r[0].controller.getAllParsedValues(this);for(t=0,n=r.length;t=e[r].pos&&t<=e[i].pos&&({lo:r,hi:i}=sd(e,`pos`,t)),{pos:a,time:s}=e[r],{pos:o,time:c}=e[i]):(t>=e[r].time&&t<=e[i].time&&({lo:r,hi:i}=sd(e,`time`,t)),{time:a,pos:s}=e[r],{time:o,pos:c}=e[i]);let l=o-a;return l?s+(c-s)*(t-a)/l:s}var Ry=class extends Iy{static id=`timeseries`;static defaults=Iy.defaults;constructor(e){super(e),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){let e=this._getTimestampsForTable(),t=this._table=this.buildLookupTable(e);this._minPos=Ly(t,this.min),this._tableRange=Ly(t,this.max)-this._minPos,super.initOffsets(e)}buildLookupTable(e){let{min:t,max:n}=this,r=[],i=[],a,o,s,c,l;for(a=0,o=e.length;a=t&&c<=n&&r.push(c);if(r.length<2)return[{time:t,pos:0},{time:n,pos:1}];for(a=0,o=r.length;ae-t)}_getTimestampsForTable(){let e=this._cache.all||[];if(e.length)return e;let t=this.getDataTimestamps(),n=this.getLabelTimestamps();return e=t.length&&n.length?this.normalize(t.concat(n)):t.length?t:n,e=this._cache.all=e,e}getDecimalForValue(e){return(Ly(this._table,e)-this._minPos)/this._tableRange}getValueForPixel(e){let t=this._offsets,n=this.getDecimalForPixel(e)/t.factor-t.end;return Ly(this._table,n*this._tableRange+this._minPos,!0)}},zy=[Am,__,Kv,Object.freeze({__proto__:null,CategoryScale:Zv,LinearScale:ty,LogarithmicScale:cy,RadialLinearScale:Ty,TimeScale:Iy,TimeSeriesScale:Ry})];Ig.register(...zy);var By=Ig,Vy=z(``);function Hy(e,t){D(t,!0);let n=K(t,`class`,3,``),r=K(t,`ariaLabel`,3,``);function i(e){if(Da.tick,typeof t.build!=`function`)return;let n=t.build();if(!n)return;let r=new By(e.getContext(`2d`),n);return()=>r.destroy()}var a=Vy();ji(a,()=>i),I(()=>{W(a,1,Pi(n())),G(a,`aria-label`,r())}),B(e,a),O()}var Uy=z(``),Wy=z(`
`);function Gy(e,t){D(t,!0);let n=K(t,`options`,19,()=>[]),r=K(t,`ariaLabel`,3,``),i=K(t,`class`,3,``);var a=Wy();U(a,21,n,e=>e.value,(e,n)=>{var r=Uy();let i;var a=N(r,!0);E(r),I(()=>{i=W(r,1,`segmented-btn svelte-92fh5i`,null,i,{active:t.value===L(n).value}),G(r,`aria-pressed`,t.value===L(n).value),V(a,L(n).label)}),R(`click`,r,()=>t.onchange?.(L(n).value)),B(e,r)}),E(a),I(()=>{W(a,1,Pi([`segmented-control`,i()]),`svelte-92fh5i`),G(a,`aria-label`,r())}),B(e,a),O()}Vr([`click`]);function Ky(e){return getComputedStyle(document.documentElement).getPropertyValue(e).trim()}function qy(){return{grid:Ky(`--chart-grid`),text:Ky(`--chart-text`),dayMarker:Ky(`--chart-day-marker`),tooltipBg:Ky(`--chart-tooltip-bg`),tooltipBorder:Ky(`--chart-tooltip-border`),tooltipText:Ky(`--chart-tooltip-text`)}}function Jy(){return{size:11,family:`'SF Mono', Menlo, Consolas, monospace`}}function Yy(e,t){return{backgroundColor:e.tooltipBg,borderColor:e.tooltipBorder,borderWidth:1,titleColor:e.tooltipText,bodyColor:e.tooltipText,callbacks:t}}function Xy(e){if(typeof document>`u`||!document.body)return e;let t=document.createElement(`span`);t.style.display=`none`,t.style.color=e,document.body.appendChild(t);let n=getComputedStyle(t).color;return document.body.removeChild(t),n||e}var Zy=[`#c2845a`,`#7a9e7e`,`#d4a574`,`#b8a98e`,`#8b9e6b`,`#7d8a97`,`#c47a5a`,`#6b8e6b`,`#a09486`,`#9b7ea4`,`#c49a6c`];function Qy(){return[...Zy]}function $y(e){let t=5381,n=String(e||``);for(let e=0;exc(e)}}var ab={seconds:{apiName:`second`,windowLabel:`Last 60 seconds`,refreshMs:2e3},minutes:{apiName:`minute`,windowLabel:`Last 60 minutes`,refreshMs:5e3},hours:{apiName:`hour`,windowLabel:`Last 24 hours`,refreshMs:2e4},days:{apiName:`day`,windowLabel:`Last 30 days`,refreshMs:6e4}},ob=[{value:`seconds`,label:`Seconds`},{value:`minutes`,label:`Minutes`},{value:`hours`,label:`Hours`},{value:`days`,label:`Days`}];function sb(){return{input:0,output:0,prompt:0,local:0}}function cb(e){return String(e).padStart(2,`0`)}function lb(e){let t=Number(e);return Number.isFinite(t)&&t>0?t:0}function ub(e,t){if(!Number.isFinite(t))return``;let n=new Date(t);switch(e){case`seconds`:return cb(n.getHours())+`:`+cb(n.getMinutes())+`:`+cb(n.getSeconds());case`minutes`:return cb(n.getHours())+`:`+cb(n.getMinutes());case`hours`:return cb(n.getHours())+`:00`;default:return cb(n.getMonth()+1)+`-`+cb(n.getDate())}}function db(e,t){let n=[],r=[],i={input:[],output:[],prompt:[],local:[]},a=sb();for(let o of e||[]){let e=Date.parse(o&&o.start),s=lb(o&&o.input_tokens),c=lb(o&&o.output_tokens),l=lb(o&&o.prompt_cached_tokens),u=lb(o&&o.locally_cached_tokens);n.push(ub(t,e)),r.push(Number.isFinite(e)?e:null),i.input.push(s),i.output.push(c),i.prompt.push(l),i.local.push(u),a.input+=s,a.output+=c,a.prompt+=l,a.local+=u}return{labels:n,stamps:r,cols:i,totals:a}}function fb(e){let t=e||sb();return t.input+t.output+t.prompt+t.local>0}function pb(e,t){return xc(Math.max(0,Math.round(e&&e[t]||0)))}function mb(e){return(ab[e]||ab.minutes).windowLabel}function hb(e,t){return`Live token throughput, `+mb(t).toLowerCase()+`. Input `+pb(e,`input`)+`, output `+pb(e,`output`)+`, prompt cached `+pb(e,`prompt`)+`, locally cached `+pb(e,`local`)+` tokens.`}function gb(e,t,n,r){let i=e=>xc(Math.max(0,Math.round(e))),a=n.stamps,o=(e,t,n)=>({label:e,data:t,backgroundColor:n,borderWidth:0,borderRadius:0,categoryPercentage:1,barPercentage:1,stack:`tokens`});return{type:`bar`,plugins:[{id:`liveTokensDayMarks`,afterDatasetsDraw:t=>{if(r===`days`)return;let n=t.getDatasetMeta(0),i=t.chartArea;if(!n||!n.data||!i)return;let o=t.ctx;o.save(),o.font=`10px 'SF Mono', Menlo, Consolas, monospace`;let s=null;for(let t=0;t{if(!e.length)return``;let t=a[e[0].dataIndex];if(!t)return e[0].label;let n=new Date(t);return r===`days`?n.toLocaleDateString():n.toLocaleString()},label:e=>e.dataset.label+`: `+i(e.parsed.y),footer:e=>{let t=0;return e.forEach(e=>{t+=Number(e.parsed.y)||0}),`Total: `+i(t)}})}}}}var _b=900,vb=new class{#e=A(`minutes`);get granularity(){return L(this.#e)}set granularity(e){j(this.#e,e,!0)}#t=A(M([]));get buckets(){return L(this.#t)}set buckets(e){j(this.#t,e,!0)}#n=A(!1);get active(){return L(this.#n)}set active(e){j(this.#n,e,!0)}#r=null;#i=null;#a=null;#o=0;#s=null;#c=!1;start(){this.stop(),this.active=!0,this.fetch(),this.#l(),this.#u()}stop(){this.active=!1,this.#r&&=(clearInterval(this.#r),null),this.#i&&=(clearTimeout(this.#i),null),this.#a&&=(clearTimeout(this.#a),null),this.#o=0,this.#s&&=(this.#s.abort(),null),this.buckets=[]}setGranularity(e){!ab[e]||e===this.granularity||(this.granularity=e,this.buckets=[],this.#l(),this.fetch())}#l(){this.#r&&=(clearInterval(this.#r),null);let e=ab[this.granularity]||ab.minutes;this.#r=setInterval(()=>{this.active&&this.fetch()},e.refreshMs)}noteUsageEvent(e){!this.active||e!==`usage.flushed`||(this.#i||=setTimeout(()=>{this.#i=null,this.fetch()},_b))}async fetch(){if(!this.active||this.#c)return;this.#c=!0;let e=this.granularity;try{let t=await Ts(`/admin/usage/throughput?granularity=`+(ab[e]||ab.minutes).apiName,{label:`token throughput`});if(t.stale||!t.ok||this.granularity!==e)return;this.buckets=t.data&&Array.isArray(t.data.buckets)?t.data.buckets:[]}catch(e){if(Ds(e))return;console.error(`Failed to fetch token throughput:`,e)}finally{this.#c=!1,this.active&&this.granularity!==e&&this.fetch()}}async#u(){await As.ensureLoaded(),this.active&&As.liveLogsVisible()&&(typeof ReadableStream>`u`||(this.#s&&this.#s.abort(),this.#s=new AbortController,this.#d(this.#s)))}async#d(e){let t=()=>{e.signal.aborted||this.#s!==e||this.#p()};try{let n=await Cs(`/admin/live/logs?types=usage`,{signal:e.signal});if(!n.ok||!n.body||typeof n.body.getReader!=`function`){t();return}this.#o=0,await tb(n.body.getReader(),e=>this.#f(e)),t()}catch(n){if(Ds(n)||e.signal.aborted)return;console.error(`Live usage stream failed:`,n),t()}}#f(e){if(!e||typeof e!=`object`)return;let t=String(e.type||``).trim();t.indexOf(`usage.`)===0&&this.noteUsageEvent(t)}#p(){if(!this.active||this.#a)return;let{attempt:e,delay:t}=rb(this.#o);this.#o=e,this.#a=setTimeout(()=>{this.#a=null,this.#u()},t)}},yb=z(`
`),bb=z(`
Waiting for live requests…
`),xb=z(`

Live Token Throughput

`);function Sb(e,t){D(t,!0);let n=k(()=>db(vb.buckets,vb.granularity)),r=k(()=>L(n).totals);function i(){return{input:Xy(`var(--token-input)`),output:Xy(`var(--token-output)`),prompt:Xy(`var(--token-prompt)`),local:Xy(`var(--token-local)`)}}let a=[{metric:`input`,label:`Input Tokens`,colorVar:`--token-input`},{metric:`output`,label:`Output Tokens`,colorVar:`--token-output`},{metric:`prompt`,label:`Prompt (Input) Cached`,colorVar:`--token-prompt`},{metric:`local`,label:`Locally Cached`,colorVar:`--token-local`}];var o=xb(),s=N(o),c=N(s),l=F(N(c),2),u=N(l);let d;var f=F(u,2),p=N(f,!0);E(f),E(l),E(c),Gy(F(c,2),{ariaLabel:`Live token throughput granularity`,get options(){return ob},get value(){return vb.granularity},onchange:e=>vb.setGranularity(e)}),E(s);var m=F(s,2);U(m,21,()=>a,e=>e.metric,(e,t)=>{var n=yb(),i=N(n),a=F(i,2),o=N(a,!0);E(a);var s=F(a,2),c=N(s,!0);E(s),E(n),I(e=>{Vi(i,`background: var(${L(t).colorVar??``})`),V(o,L(t).label),V(c,e)},[()=>pb(L(r),L(t).metric)]),B(e,n)}),E(m);var h=F(m,2),g=N(h);{let e=k(()=>hb(L(r),vb.granularity));Hy(g,{get ariaLabel(){return L(e)},build:()=>gb(qy(),i(),L(n),vb.granularity)})}var _=F(g,2),v=e=>{B(e,bb())},y=k(()=>!fb(L(r)));H(_,e=>{L(y)&&e(v)}),E(h),E(o),I(e=>{d=W(u,1,`live-dot`,null,d,{"is-streaming":vb.active}),V(p,e)},[()=>mb(vb.granularity)]),B(e,o),O()}function Cb(e){let t=e||{};if(t.total_tokens!==null&&t.total_tokens!==void 0){let e=Number(t.total_tokens);if(Number.isFinite(e))return e}let n=Number(t.total_input_tokens||0),r=Number(t.total_output_tokens||0);return(Number.isFinite(n)?n:0)+(Number.isFinite(r)?r:0)}function wb(e,t){if(!t)return 0;let n=e&&e.summary?e.summary:{},r=Number(n.total_hits||0);return Number.isFinite(r)&&r>0?r:0}function Tb(e,t,n){let r=Number(e&&e.total_requests||0);return(Number.isFinite(r)?r:0)+wb(t,n)}function Eb(e,t,n){let r=wb(t,n);return r<=0?``:_c(Tb(e,t,n)-r)+` to providers + `+_c(r)+` from cache`}function Db(e){let t=e&&e.summary?e.summary:{},n=Number(t.total_input_tokens||0),r=Number(t.total_output_tokens||0);return(Number.isFinite(n)?n:0)+(Number.isFinite(r)?r:0)}function Ob(e,t,n){let r=e=>{let t=Number(e||0);return Number.isFinite(t)&&t>0?t:0},i=e||{},a=r(i.uncached_input_tokens),o=r(i.cached_input_tokens),s=r(i.cache_write_input_tokens),c=t&&t.summary?t.summary:{},l=n?r(c.total_input_tokens):0;return[{key:`uncached`,label:`Regular`,tokens:a+s,colorVar:`--cache-meter-uncached`,note:s>0?`Includes `+_c(s)+` cache-write tokens`:``},{key:`prompt`,label:`Prompt cached`,tokens:o,colorVar:`--cache-meter-prompt`,note:`Provider prompt-cache reads`},{key:`local`,label:`Locally cached`,tokens:l,colorVar:`--cache-meter-local`,note:`Served from GoModel response cache`}]}function kb(e,t,n){return Ob(e,t,n).reduce((e,t)=>e+t.tokens,0)}function Ab(e,t,n){return kb(e,t,n)>0}function jb(e,t,n){let r=Ob(e,t,n),i=r.reduce((e,t)=>e+t.tokens,0);if(i<=0)return r.map(e=>Object.assign({},e,{pct:0}));let a=r.map(e=>{let t=e.tokens/i*100,n=Math.floor(t);return Object.assign({},e,{pct:n,remainder:t-n})}),o=100-a.reduce((e,t)=>e+t.pct,0);return a.map((e,t)=>({index:t,remainder:e.remainder,tokens:e.tokens})).filter(e=>e.tokens>0).sort((e,t)=>t.remainder-e.remainder).forEach(e=>{o>0&&(a[e.index].pct+=1,--o)}),a}function Mb(e,t,n){return jb(e,t,n).filter(e=>e.tokens>0)}function Nb(e){let t=[e.label+`: `+_c(e.tokens)+` input tokens (`+e.pct+`%)`];return e.note&&t.push(e.note),t.join(` -`)}function Pb(e){let t=(e||[]).map(e=>e.label+` `+e.pct+`%`);return`Cache breakdown of input tokens — `+(t.length?t.join(`, `):`no data`)}function Fb(e){return e.getUTCFullYear()+`-`+String(e.getUTCMonth()+1).padStart(2,`0`)+`-`+String(e.getUTCDate()).padStart(2,`0`)}function Ib(e,t,n,r){if(t!==`daily`||!n||!r)return e;let i={};(e||[]).forEach(e=>{i[e.date]=e});let a=[];for(let e=new Date(n);e<=r;e.setUTCDate(e.getUTCDate()+1)){let t=Fb(e);a.push(i[t]||{date:t,input_tokens:0,output_tokens:0,total_tokens:0,requests:0,input_cost:null,output_cost:null,total_cost:null})}return a}function Lb(e,t){let n=e=>Number(e)||0,r=e.map(e=>e.date),i=e.map(e=>n(e.uncached_input_tokens)+n(e.cache_write_input_tokens)+n(e.cached_input_tokens)>0?n(e.uncached_input_tokens)+n(e.cache_write_input_tokens):n(e.input_tokens)),a=e.map(e=>n(e.output_tokens)),o=e.map(e=>n(e.cached_input_tokens)),s={};return(t||[]).forEach(e=>{s[e.date]=e}),{labels:r,inputPaid:i,output:a,prompt:o,local:r.map(e=>{let t=s[e];return t?n(t.input_tokens)+n(t.output_tokens):0})}}function Rb(e){let t=e||{},n=Math.max(0,Number(t.uncached_input_tokens)||0),r=Math.max(0,Number(t.cached_input_tokens)||0),i=Math.max(0,Number(t.cache_write_input_tokens)||0),a=n+r+i;return a>0?r/a*100:0}function zb(e){let t=e||{};return(Number(t.uncached_input_tokens)||0)+(Number(t.cached_input_tokens)||0)+(Number(t.cache_write_input_tokens)||0)>0}function Bb(e){return zb(e)?Math.round(Rb(e))+`%`:`—`}function Vb(e,t,n={}){let r=!!n.cacheEnabled,i=n.resolve||(e=>e),a=(e,t)=>i(`color-mix(in srgb, `+e+` `+t+`%, transparent)`),o=(e,t,n,r)=>Object.assign({label:e,data:t,borderColor:n,backgroundColor:n,fill:!1,tension:.3,borderWidth:2,pointRadius:0,pointHoverRadius:4},r||{}),s=[o(`Input Tokens`,t.inputPaid,i(`var(--token-input)`),{fill:`origin`}),o(`Output Tokens`,t.output,i(`var(--token-output)`),{fill:`-1`}),o(`Prompt (Input) Cached`,t.prompt,i(`var(--token-prompt)`),{fill:`-1`,borderDash:[6,4]})];return r&&s.push(o(`Locally Cached`,t.local,a(`var(--info)`,35),{fill:`-1`,borderDash:[2,3]})),{type:`line`,data:{labels:t.labels,datasets:s},options:{responsive:!0,maintainAspectRatio:!1,animation:{duration:0},interaction:{mode:`index`,intersect:!1},plugins:{legend:{labels:{color:e.text,font:{size:12}}},tooltip:Yy(e,{label:e=>e.dataset.label+`: `+e.parsed.y.toLocaleString(),footer:e=>{let t=0;return e.forEach(e=>{t+=Number(e.parsed.y)||0}),`Total: `+t.toLocaleString()}})},scales:{x:{stacked:!0,grid:{color:e.grid},border:{display:!1},ticks:{color:e.text,font:Jy(),maxRotation:0,autoSkip:!0,maxTicksLimit:10}},y:{stacked:!0,beginAtZero:!0,grid:{color:e.grid},border:{display:!1},ticks:ib(e)}}}}}function Hb(e,t,n){let r=Math.max(0,Math.min(100,e));return{type:`doughnut`,data:{datasets:[{data:[r,100-r],backgroundColor:[t,n],borderWidth:0,spacing:0}]},options:{rotation:-90,circumference:180,cutout:`84%`,responsive:!0,maintainAspectRatio:!1,animation:{duration:0},layout:{padding:1},events:[],plugins:{legend:{display:!1},tooltip:{enabled:!1}}}}}var Ub=`gomodel_provider_status_details_expanded`,Wb=`gomodel_provider_card_expanded_overrides`,Gb=3e3,Kb=`https://gomodel.enterpilot.io/docs/providers/`,qb={anthropic:`anthropic`,azure:`azure`,bailian:`bailian`,bedrock:`bedrock`,"bedrock-mantle":`bedrock-mantle`,cohere:`cohere`,deepseek:`deepseek`,gemini:`gemini`,llmd:`llmd`,opencode_go:`opencode-go`,oracle:`oracle`,sglang:`sglang`,vertex:`vertex`,vllm:`vllm`,xiaomi:`xiaomi`};function Jb(){return{summary:{total:0,healthy:0,degraded:0,unhealthy:0,overall_status:`degraded`},providers:[]}}function Yb(e){let t={detailsExpanded:!1,cardOverrides:{}};try{if(e){let n=e.getItem(Ub);n===`true`||n===`false`?t.detailsExpanded=n===`true`:e.setItem(Ub,`false`);let r=JSON.parse(e.getItem(Wb)||`{}`);r&&typeof r==`object`&&!Array.isArray(r)&&(t.cardOverrides=r)}}catch{}return t}function Xb(e,t){if(e)try{e.setItem(Ub,t?`true`:`false`)}catch{}}function Zb(e,t){if(e)try{e.setItem(Wb,JSON.stringify(t))}catch{}}function Qb(e,t,n){let r=n&&n.name?String(n.name):``;return r&&Object.prototype.hasOwnProperty.call(e,r)?e[r]===!0:t}function $b(e){return`is-`+(String(e&&e.overall_status||`degraded`).trim()||`degraded`)}function ex(e){return`is-`+(String(e||`degraded`).trim()||`degraded`)}function tx(e){let t=e||{};return String(t.healthy||0)+`/`+String(t.total||0)}function nx(e){let t=e||{},n=Number(t.total||0),r=Number(t.healthy||0);return n>0&&rString(e&&e.status_label||``).trim().toLowerCase()===`starting`)}function ax(e){if(!e||!e.runtime)return``;let t=e.runtime.last_model_fetch_at||``,n=e.runtime.last_availability_check_at||``;return t?n&&Date.parse(n)>Date.parse(t)?n:t:n}function ox(e,t){let n=ax(e);if(!n||typeof t!=`function`)return`-`;let r=t(n);if(!r||r===`-`)return`-`;let i=String(r).split(` `);return i.length>1?i.slice(1).join(` `):r}function sx(e,t){let n=ax(e);return n?typeof t==`function`?t(n):String(n):``}function cx(e){if(!e)return``;let t=String(e.name||``).trim(),n=String(e.type||e.config&&e.config.type||``).trim();return!n||n===t?``:n}function lx(e){let t=String(e&&(e.type||e.config&&e.config.type)||``).trim().toLowerCase(),n=t?qb[t]:``;return n?Kb+n+`?utm_source=gomodel_dashboard`:``}function ux(e){let t=e&&e.config&&e.config.resilience?e.config.resilience.retry:null;return t?String(t.max_retries)+` retries, `+t.initial_backoff+` initial, `+t.max_backoff+` max, factor `+t.backoff_factor+`, jitter `+t.jitter_factor:`-`}function dx(e){let t=e&&e.config&&e.config.resilience?e.config.resilience.circuit_breaker:null;return t?String(t.failure_threshold)+` fail, `+String(t.success_threshold)+` success, `+t.timeout+` timeout`:`-`}function fx(e){let t=e&&e.config&&Array.isArray(e.config.models)?e.config.models.filter(Boolean):[];return t.length===0?`Automatic`:t.join(`, `)}function px(e){if(!e)return``;let t=[];return e.status_reason&&t.push(String(e.status_reason)),e.last_error&&t.push(`Last error: `+String(e.last_error)),t.join(` +`)}function Pb(e){let t=(e||[]).map(e=>e.label+` `+e.pct+`%`);return`Cache breakdown of input tokens — `+(t.length?t.join(`, `):`no data`)}function Fb(e){return e.getUTCFullYear()+`-`+String(e.getUTCMonth()+1).padStart(2,`0`)+`-`+String(e.getUTCDate()).padStart(2,`0`)}function Ib(e,t,n,r){if(t!==`daily`||!n||!r)return e;let i={};(e||[]).forEach(e=>{i[e.date]=e});let a=[];for(let e=new Date(n);e<=r;e.setUTCDate(e.getUTCDate()+1)){let t=Fb(e);a.push(i[t]||{date:t,input_tokens:0,output_tokens:0,total_tokens:0,requests:0,input_cost:null,output_cost:null,total_cost:null})}return a}function Lb(e,t){let n=e=>Number(e)||0,r=e.map(e=>e.date),i=e.map(e=>n(e.uncached_input_tokens)+n(e.cache_write_input_tokens)+n(e.cached_input_tokens)>0?n(e.uncached_input_tokens)+n(e.cache_write_input_tokens):n(e.input_tokens)),a=e.map(e=>n(e.output_tokens)),o=e.map(e=>n(e.cached_input_tokens)),s={};return(t||[]).forEach(e=>{s[e.date]=e}),{labels:r,inputPaid:i,output:a,prompt:o,local:r.map(e=>{let t=s[e];return t?n(t.input_tokens)+n(t.output_tokens):0})}}function Rb(e){let t=e||{},n=Math.max(0,Number(t.uncached_input_tokens)||0),r=Math.max(0,Number(t.cached_input_tokens)||0),i=Math.max(0,Number(t.cache_write_input_tokens)||0),a=n+r+i;return a>0?r/a*100:0}function zb(e){let t=e||{};return(Number(t.uncached_input_tokens)||0)+(Number(t.cached_input_tokens)||0)+(Number(t.cache_write_input_tokens)||0)>0}function Bb(e){return zb(e)?Math.round(Rb(e))+`%`:`—`}function Vb(e,t,n={}){let r=!!n.cacheEnabled,i=n.resolve||(e=>e),a=(e,t)=>i(`color-mix(in srgb, `+e+` `+t+`%, transparent)`),o=(e,t,n,r)=>Object.assign({label:e,data:t,borderColor:n,backgroundColor:n,fill:!1,tension:.3,borderWidth:2,pointRadius:0,pointHoverRadius:4},r||{}),s=[o(`Input Tokens`,t.inputPaid,i(`var(--token-input)`),{fill:`origin`}),o(`Output Tokens`,t.output,i(`var(--token-output)`),{fill:`-1`}),o(`Prompt (Input) Cached`,t.prompt,i(`var(--token-prompt)`),{fill:`-1`,borderDash:[6,4]})];return r&&s.push(o(`Locally Cached`,t.local,a(`var(--info)`,35),{fill:`-1`,borderDash:[2,3]})),{type:`line`,data:{labels:t.labels,datasets:s},options:{responsive:!0,maintainAspectRatio:!1,animation:{duration:0},interaction:{mode:`index`,intersect:!1},plugins:{legend:{labels:{color:e.text,font:{size:12}}},tooltip:Yy(e,{label:e=>e.dataset.label+`: `+e.parsed.y.toLocaleString(),footer:e=>{let t=0;return e.forEach(e=>{t+=Number(e.parsed.y)||0}),`Total: `+t.toLocaleString()}})},scales:{x:{stacked:!0,grid:{color:e.grid},border:{display:!1},ticks:{color:e.text,font:Jy(),maxRotation:0,autoSkip:!0,maxTicksLimit:10}},y:{stacked:!0,beginAtZero:!0,grid:{color:e.grid},border:{display:!1},ticks:ib(e)}}}}}function Hb(e,t,n){let r=Math.max(0,Math.min(100,e));return{type:`doughnut`,data:{datasets:[{data:[r,100-r],backgroundColor:[t,n],borderWidth:0,spacing:0}]},options:{rotation:-90,circumference:180,cutout:`84%`,responsive:!0,maintainAspectRatio:!1,animation:{duration:0},layout:{padding:1},events:[],plugins:{legend:{display:!1},tooltip:{enabled:!1}}}}}var Ub=`gomodel_provider_status_details_expanded`,Wb=`gomodel_provider_card_expanded_overrides`,Gb=3e3,Kb=`https://gomodel.enterpilot.io/docs/providers/`,qb={anthropic:`anthropic`,azure:`azure`,bailian:`bailian`,bedrock:`bedrock`,"bedrock-mantle":`bedrock-mantle`,cohere:`cohere`,deepseek:`deepseek`,elevenlabs:`elevenlabs`,gemini:`gemini`,llmd:`llmd`,opencode_go:`opencode-go`,oracle:`oracle`,sglang:`sglang`,vertex:`vertex`,vllm:`vllm`,xiaomi:`xiaomi`};function Jb(){return{summary:{total:0,healthy:0,degraded:0,unhealthy:0,overall_status:`degraded`},providers:[]}}function Yb(e){let t={detailsExpanded:!1,cardOverrides:{}};try{if(e){let n=e.getItem(Ub);n===`true`||n===`false`?t.detailsExpanded=n===`true`:e.setItem(Ub,`false`);let r=JSON.parse(e.getItem(Wb)||`{}`);r&&typeof r==`object`&&!Array.isArray(r)&&(t.cardOverrides=r)}}catch{}return t}function Xb(e,t){if(e)try{e.setItem(Ub,t?`true`:`false`)}catch{}}function Zb(e,t){if(e)try{e.setItem(Wb,JSON.stringify(t))}catch{}}function Qb(e,t,n){let r=n&&n.name?String(n.name):``;return r&&Object.prototype.hasOwnProperty.call(e,r)?e[r]===!0:t}function $b(e){return`is-`+(String(e&&e.overall_status||`degraded`).trim()||`degraded`)}function ex(e){return`is-`+(String(e||`degraded`).trim()||`degraded`)}function tx(e){let t=e||{};return String(t.healthy||0)+`/`+String(t.total||0)}function nx(e){let t=e||{},n=Number(t.total||0),r=Number(t.healthy||0);return n>0&&rString(e&&e.status_label||``).trim().toLowerCase()===`starting`)}function ax(e){if(!e||!e.runtime)return``;let t=e.runtime.last_model_fetch_at||``,n=e.runtime.last_availability_check_at||``;return t?n&&Date.parse(n)>Date.parse(t)?n:t:n}function ox(e,t){let n=ax(e);if(!n||typeof t!=`function`)return`-`;let r=t(n);if(!r||r===`-`)return`-`;let i=String(r).split(` `);return i.length>1?i.slice(1).join(` `):r}function sx(e,t){let n=ax(e);return n?typeof t==`function`?t(n):String(n):``}function cx(e){if(!e)return``;let t=String(e.name||``).trim(),n=String(e.type||e.config&&e.config.type||``).trim();return!n||n===t?``:n}function lx(e){let t=String(e&&(e.type||e.config&&e.config.type)||``).trim().toLowerCase(),n=t?qb[t]:``;return n?Kb+n+`?utm_source=gomodel_dashboard`:``}function ux(e){let t=e&&e.config&&e.config.resilience?e.config.resilience.retry:null;return t?String(t.max_retries)+` retries, `+t.initial_backoff+` initial, `+t.max_backoff+` max, factor `+t.backoff_factor+`, jitter `+t.jitter_factor:`-`}function dx(e){let t=e&&e.config&&e.config.resilience?e.config.resilience.circuit_breaker:null;return t?String(t.failure_threshold)+` fail, `+String(t.success_threshold)+` success, `+t.timeout+` timeout`:`-`}function fx(e){let t=e&&e.config&&Array.isArray(e.config.models)?e.config.models.filter(Boolean):[];return t.length===0?`Automatic`:t.join(`, `)}function px(e){if(!e)return``;let t=[];return e.status_reason&&t.push(String(e.status_reason)),e.last_error&&t.push(`Last error: `+String(e.last_error)),t.join(` `)}function mx(e){let t=e&&e.request_health;return t&&typeof t==`object`?t:null}function hx(e){let t=mx(e);return t?String(t.circuit_state||``).trim():``}function gx(e){let t=hx(e);return t?t.charAt(0).toUpperCase()+t.slice(1):``}function _x(e){let t=hx(e);return t===`open`?`is-unhealthy`:t===`half-open`?`is-degraded`:`is-healthy`}function vx(e){let t=mx(e);if(!t)return``;let n=Number(t.requests||0),r=Number(t.errors||0),i=Math.round(Number(t.window_seconds||0)/60),a=i>0?`last `+i+` min`:`recent`;return String(n)+` request`+(n===1?``:`s`)+` · `+String(r)+` error`+(r===1?``:`s`)+` (`+a+`)`}function yx(e){let t=mx(e);return t&&Array.isArray(t.models)?t.models:[]}function bx(e){return e?String(Number(e.errors||0))+`/`+String(Number(e.requests||0))+` failed`:``}function xx(e){let t=e&&e.last_error;return!t||!t.message?``:(t.status_code?`HTTP `+String(t.status_code)+`: `:``)+t.message}function Sx(){return{name:``,slug:``,url:``,transport:`http`,description:``,enabled:!0,headers:[],allowed_tools:``,disallowed_tools:``,user_paths:``,tool_timeout_seconds:``}}function Cx(){return{server:``,status:``,instructions:``,tools:[],prompts:[],resources:[],templates:[]}}function wx(e){return String(e&&(e.slug||e.name)||``).trim()}function Tx(e){return String(e&&e.status||``).trim()||`connecting`}function Ex(e){switch(Tx(e)){case`connected`:return`status-success`;case`degraded`:return String(e&&e.last_error||``).trim()?`status-error`:`status-warning`;case`connecting`:return`status-neutral`;default:return`status-unknown`}}function Dx(e,t){let n=Tx(e),r=String(e&&e.last_error||``).trim();return r&&n!==`connected`?r:n===`connected`&&e&&e.connected_at?`Connected since `+(typeof t==`function`?t:String)(e.connected_at):``}function Ox(e){return String(e&&e.transport||``)===`stdio`?`local command`:String(e&&e.url||``).trim()||`—`}function kx(e){let t=Number(e&&e.prompt_count||0),n=Number(e&&e.resource_count||0);return t+` prompts · `+n+` resources`}function Ax(e){let t=String(e||``).normalize(`NFKD`).toLowerCase(),n=t.replace(/[\u0300-\u036f]/g,``).replace(/[^a-z0-9]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,64).replace(/-+$/g,``);if(n)return n;let r=2166136261;for(let e of t)r=Math.imul((r^e.codePointAt(0))>>>0,16777619)>>>0;return`mcp-`+r.toString(16).padStart(8,`0`)}function jx(e){return String(e||``).split(` `).map(e=>e.trim()).filter(e=>e)}function Mx(e){return!e||typeof e!=`object`||Array.isArray(e)?[]:Object.keys(e).sort().map(t=>({name:t,value:String(e[t]||``)}))}function Nx(e){let t={};return(Array.isArray(e)?e:[]).forEach(e=>{let n=String(e&&e.name||``).trim();n&&(t[n]=String(e&&e.value||``))}),t}function Px(e,t){let n=Array.isArray(e)?e:[];if(!t)return n;let r=String(t).toLowerCase();return n.filter(e=>[e.name,e.slug,e.url,e.transport,e.description,e.status].some(e=>String(e||``).toLowerCase().includes(r)))}function Fx(e){return{name:String(e.name||``).trim(),slug:wx(e),url:String(e.url||``).trim(),transport:e.transport===`sse`?`sse`:`http`,description:String(e.description||``).trim(),enabled:e.enabled!==!1,headers:Mx(e.headers),allowed_tools:(Array.isArray(e.allowed_tools)?e.allowed_tools:[]).join(`, `),disallowed_tools:(Array.isArray(e.disallowed_tools)?e.disallowed_tools:[]).join(`, `),user_paths:(Array.isArray(e.user_paths)?e.user_paths:[]).join(` diff --git a/internal/admin/dashboard/static/dist/index.html b/internal/admin/dashboard/static/dist/index.html index e391351b6..ef7760f15 100644 --- a/internal/admin/dashboard/static/dist/index.html +++ b/internal/admin/dashboard/static/dist/index.html @@ -7,7 +7,7 @@ GoModel Dashboard - + diff --git a/internal/providers/elevenlabs/audio.go b/internal/providers/elevenlabs/audio.go new file mode 100644 index 000000000..55723bcb8 --- /dev/null +++ b/internal/providers/elevenlabs/audio.go @@ -0,0 +1,298 @@ +package elevenlabs + +import ( + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "net/url" + "strings" + + "github.com/goccy/go-json" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" +) + +// speechFormat maps an OpenAI-compatible response_format to the ElevenLabs +// output_format query value. ElevenLabs has no aac/flac encoders, so those +// formats are rejected rather than silently substituted. +func speechFormat(responseFormat string) (openAIFormat, outputFormat string, err error) { + format := strings.ToLower(strings.TrimSpace(responseFormat)) + if format == "" { + format = "mp3" + } + switch format { + case "mp3": + return format, "mp3_44100_128", nil + case "opus": + return format, "opus_48000_128", nil + case "pcm": + return format, "pcm_44100", nil + case "wav": + return format, "wav_44100", nil + default: + return "", "", core.NewInvalidRequestError("elevenlabs speech supports mp3, opus, pcm, or wav response formats", nil) + } +} + +// speechSpeed validates the OpenAI speed parameter against ElevenLabs' voice +// setting range. A zero value means "unset" and is left out of the request. +func speechSpeed(speed float64) (*float64, error) { + if speed == 0 { + return nil, nil + } + if speed < 0.7 || speed > 1.2 { + return nil, core.NewInvalidRequestError("elevenlabs speech speed must be between 0.7 and 1.2", nil) + } + return &speed, nil +} + +type speechRequest struct { + Text string `json:"text"` + ModelID string `json:"model_id"` + VoiceSetting *speechVoiceSetting `json:"voice_settings,omitempty"` +} + +type speechVoiceSetting struct { + Speed float64 `json:"speed"` +} + +// CreateSpeech translates the OpenAI-compatible request into ElevenLabs' +// POST /v1/text-to-speech/{voice_id} endpoint. The OpenAI "voice" field +// carries the ElevenLabs voice_id directly, since ElevenLabs has no fixed set +// of named voices. +func (p *Provider) CreateSpeech(ctx context.Context, req *core.AudioSpeechRequest) (*core.AudioResponse, error) { + if req == nil { + return nil, core.NewInvalidRequestError("audio speech request is required", nil) + } + model := strings.TrimSpace(req.Model) + if model == "" { + return nil, core.NewInvalidRequestError("model is required", nil) + } + if strings.TrimSpace(req.Input) == "" { + return nil, core.NewInvalidRequestError("input is required", nil) + } + voiceID := strings.TrimSpace(req.Voice) + if voiceID == "" { + return nil, core.NewInvalidRequestError("voice is required and must be an ElevenLabs voice_id", nil) + } + if strings.TrimSpace(req.Instructions) != "" { + return nil, core.NewInvalidRequestError("elevenlabs speech does not support instructions", nil) + } + + openAIFormat, outputFormat, err := speechFormat(req.ResponseFormat) + if err != nil { + return nil, err + } + speed, err := speechSpeed(req.Speed) + if err != nil { + return nil, err + } + + body := speechRequest{Text: req.Input, ModelID: model} + if speed != nil { + body.VoiceSetting = &speechVoiceSetting{Speed: *speed} + } + rawBody, err := json.Marshal(body) + if err != nil { + return nil, core.NewInvalidRequestError("failed to encode elevenlabs speech request", err) + } + + resp, err := p.client.DoRaw(ctx, llmclient.Request{ + Method: http.MethodPost, + Endpoint: "/v1/text-to-speech/" + url.PathEscape(voiceID) + "?output_format=" + outputFormat, + RawBody: rawBody, + Headers: http.Header{"Content-Type": {"application/json"}}, + }) + if err != nil { + return nil, err + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, core.ParseProviderError("elevenlabs", resp.StatusCode, resp.Body, nil) + } + if len(resp.Body) == 0 { + return nil, core.NewEmptyProviderResponseError("elevenlabs") + } + + return &core.AudioResponse{ + ContentType: core.SpeechResponseContentType(openAIFormat), + Data: resp.Body, + }, nil +} + +// timestampsGranularity maps the OpenAI timestamp_granularities/response_format +// fields to ElevenLabs' timestamps_granularity value. +func timestampsGranularity(req *core.AudioTranscriptionRequest) string { + for _, granularity := range req.TimestampGranularities { + if strings.EqualFold(strings.TrimSpace(granularity), "word") { + return "word" + } + } + if strings.EqualFold(strings.TrimSpace(req.ResponseFormat), "verbose_json") { + return "word" + } + return "none" +} + +type transcriptionResponse struct { + LanguageCode string `json:"language_code"` + LanguageProbability float64 `json:"language_probability"` + Text string `json:"text"` + AudioDurationSecs *float64 `json:"audio_duration_secs"` + Words []struct { + Text string `json:"text"` + Type string `json:"type"` + Start float64 `json:"start"` + End float64 `json:"end"` + } `json:"words"` +} + +// CreateTranscription translates the OpenAI-compatible multipart request to +// ElevenLabs' POST /v1/speech-to-text endpoint. +func (p *Provider) CreateTranscription(ctx context.Context, req *core.AudioTranscriptionRequest) (*core.AudioResponse, error) { + if req == nil { + return nil, core.NewInvalidRequestError("audio transcription request is required", nil) + } + model := strings.TrimSpace(req.Model) + if model == "" { + return nil, core.NewInvalidRequestError("model is required", nil) + } + switch strings.ToLower(strings.TrimSpace(req.ResponseFormat)) { + case "", "json", "text", "verbose_json": + default: + return nil, core.NewInvalidRequestError("elevenlabs transcription supports json, text, or verbose_json response formats", nil) + } + if strings.TrimSpace(req.Prompt) != "" { + return nil, core.NewInvalidRequestError("elevenlabs transcription does not support prompt", nil) + } + + content := req.FileReader + if content == nil && len(req.File) > 0 { + content = bytes.NewReader(req.File) + } + if content == nil { + return nil, core.NewInvalidRequestError("file is required", nil) + } + + 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}}, + }) + if err != nil { + return nil, err + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, core.ParseProviderError("elevenlabs", resp.StatusCode, resp.Body, nil) + } + + var upstream transcriptionResponse + if err := json.Unmarshal(resp.Body, &upstream); err != nil { + return nil, core.NewProviderError("elevenlabs", http.StatusBadGateway, "failed to parse transcription response", err) + } + + return transcriptionAudioResponse(req, &upstream) +} + +func transcriptionMultipart(req *core.AudioTranscriptionRequest, model string, content io.Reader) (io.Reader, string) { + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + go func() { + defer func() { _ = pw.Close() }() + + fields := [...][2]string{ + {"model_id", model}, + {"language_code", strings.TrimSpace(req.Language)}, + {"timestamps_granularity", timestampsGranularity(req)}, + } + for _, field := range fields { + if field[1] == "" { + continue + } + if err := writer.WriteField(field[0], field[1]); err != nil { + _ = pw.CloseWithError(core.NewInvalidRequestError("failed to write "+field[0]+" field", err)) + return + } + } + + filename := strings.TrimSpace(req.Filename) + if filename == "" { + filename = "audio" + } + part, err := writer.CreateFormFile("file", filename) + if err != nil { + _ = pw.CloseWithError(core.NewInvalidRequestError("failed to create multipart file field", err)) + return + } + if _, err := io.Copy(part, content); err != nil { + _ = pw.CloseWithError(core.NewInvalidRequestError("failed to stream file content", err)) + return + } + if err := writer.Close(); err != nil { + _ = pw.CloseWithError(core.NewInvalidRequestError("failed to finalize multipart payload", err)) + } + }() + return pr, writer.FormDataContentType() +} + +// transcriptionAudioResponse shapes ElevenLabs' transcription result into the +// OpenAI response_format the caller asked for. +func transcriptionAudioResponse(req *core.AudioTranscriptionRequest, upstream *transcriptionResponse) (*core.AudioResponse, error) { + format := strings.ToLower(strings.TrimSpace(req.ResponseFormat)) + if format == "text" { + return &core.AudioResponse{ + ContentType: core.TranscriptionResponseContentType(format), + Data: []byte(upstream.Text), + }, nil + } + + if format != "verbose_json" { + body, err := json.Marshal(map[string]string{"text": upstream.Text}) + if err != nil { + return nil, core.NewProviderError("elevenlabs", http.StatusBadGateway, "failed to encode transcription response", err) + } + return &core.AudioResponse{ContentType: core.TranscriptionResponseContentType(format), Data: body}, nil + } + + type word struct { + Word string `json:"word"` + Start float64 `json:"start"` + End float64 `json:"end"` + } + var duration float64 + if upstream.AudioDurationSecs != nil { + duration = *upstream.AudioDurationSecs + } + words := make([]word, 0, len(upstream.Words)) + for _, w := range upstream.Words { + if w.Type != "" && w.Type != "word" { + continue + } + words = append(words, word{Word: w.Text, Start: w.Start, End: w.End}) + if upstream.AudioDurationSecs == nil && w.End > duration { + duration = w.End + } + } + verbose := struct { + Task string `json:"task"` + Language string `json:"language"` + Duration float64 `json:"duration"` + Text string `json:"text"` + Words []word `json:"words,omitempty"` + }{ + Task: "transcribe", + Language: upstream.LanguageCode, + Duration: duration, + Text: upstream.Text, + Words: words, + } + body, err := json.Marshal(verbose) + if err != nil { + return nil, core.NewProviderError("elevenlabs", http.StatusBadGateway, "failed to encode transcription response", err) + } + return &core.AudioResponse{ContentType: core.TranscriptionResponseContentType(format), Data: body}, nil +} diff --git a/internal/providers/elevenlabs/audio_test.go b/internal/providers/elevenlabs/audio_test.go new file mode 100644 index 000000000..d4ade878a --- /dev/null +++ b/internal/providers/elevenlabs/audio_test.go @@ -0,0 +1,335 @@ +package elevenlabs + +import ( + "bytes" + "context" + "io" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/goccy/go-json" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" +) + +func TestCreateSpeech_UsesVoiceIDInPathAndDefaultsToMP3(t *testing.T) { + var gotPath, gotQuery, gotAuth string + var gotBody speechRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + gotAuth = r.Header.Get("xi-api-key") + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotBody) + w.Header().Set("Content-Type", "audio/mpeg") + _, _ = w.Write([]byte{0x49, 0x44, 0x33}) + })) + defer server.Close() + + provider := NewWithHTTPClient("elk_test", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.CreateSpeech(context.Background(), &core.AudioSpeechRequest{ + Model: "eleven_multilingual_v2", + Input: "hello there", + Voice: "21m00Tcm4TlvDq8ikWAM", + }) + if err != nil { + t.Fatalf("CreateSpeech() error = %v", err) + } + + if gotPath != "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM" { + t.Fatalf("path = %q, want voice_id in path", gotPath) + } + if gotQuery != "output_format=mp3_44100_128" { + t.Fatalf("query = %q, want mp3_44100_128 output format", gotQuery) + } + if gotAuth != "elk_test" { + t.Fatalf("xi-api-key = %q, want elk_test", gotAuth) + } + if gotBody.Text != "hello there" || gotBody.ModelID != "eleven_multilingual_v2" { + t.Fatalf("request body = %+v", gotBody) + } + if gotBody.VoiceSetting != nil { + t.Fatalf("voice_settings = %+v, want nil when speed unset", gotBody.VoiceSetting) + } + if resp.ContentType != "audio/mpeg" { + t.Fatalf("content type = %q, want audio/mpeg", resp.ContentType) + } + if !bytes.Equal(resp.Data, []byte{0x49, 0x44, 0x33}) { + t.Fatalf("audio data = %v", resp.Data) + } +} + +func TestCreateSpeech_MapsFormatsAndSpeed(t *testing.T) { + var gotQuery string + var gotBody speechRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotBody) + w.Header().Set("Content-Type", "audio/ogg") + _, _ = w.Write([]byte{0x01}) + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.CreateSpeech(context.Background(), &core.AudioSpeechRequest{ + Model: "eleven_flash_v2_5", + Input: "hi", + Voice: "voice-id", + ResponseFormat: "opus", + Speed: 1.1, + }) + if err != nil { + t.Fatalf("CreateSpeech() error = %v", err) + } + if gotQuery != "output_format=opus_48000_128" { + t.Fatalf("query = %q, want opus output format", gotQuery) + } + if gotBody.VoiceSetting == nil || gotBody.VoiceSetting.Speed != 1.1 { + t.Fatalf("voice_settings = %+v, want speed 1.1", gotBody.VoiceSetting) + } + if resp.ContentType != "audio/ogg" { + t.Fatalf("content type = %q, want audio/ogg", resp.ContentType) + } +} + +func TestCreateSpeech_SupportsWAV(t *testing.T) { + var gotQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "audio/wav") + _, _ = w.Write([]byte{0x52, 0x49, 0x46, 0x46}) + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.CreateSpeech(context.Background(), &core.AudioSpeechRequest{ + Model: "eleven_multilingual_v2", + Input: "hi", + Voice: "voice-id", + ResponseFormat: "wav", + }) + if err != nil { + t.Fatalf("CreateSpeech() error = %v", err) + } + if gotQuery != "output_format=wav_44100" { + t.Fatalf("query = %q, want wav_44100 output format", gotQuery) + } + if resp.ContentType != "audio/wav" { + t.Fatalf("content type = %q, want audio/wav", resp.ContentType) + } +} + +func TestCreateSpeech_ValidatesRequest(t *testing.T) { + provider := NewWithHTTPClient("key", "https://example.invalid", nil, llmclient.Hooks{}) + tests := []struct { + name string + req *core.AudioSpeechRequest + want string + }{ + {name: "nil request", req: nil, want: "request is required"}, + {name: "missing model", req: &core.AudioSpeechRequest{Input: "hi", Voice: "v"}, want: "model is required"}, + {name: "missing input", req: &core.AudioSpeechRequest{Model: "m", Voice: "v"}, want: "input is required"}, + {name: "missing voice", req: &core.AudioSpeechRequest{Model: "m", Input: "hi"}, want: "voice is required"}, + {name: "instructions", req: &core.AudioSpeechRequest{Model: "m", Input: "hi", Voice: "v", Instructions: "whisper"}, want: "does not support instructions"}, + {name: "format", req: &core.AudioSpeechRequest{Model: "m", Input: "hi", Voice: "v", ResponseFormat: "aac"}, want: "supports mp3, opus, pcm, or wav"}, + {name: "speed too low", req: &core.AudioSpeechRequest{Model: "m", Input: "hi", Voice: "v", Speed: 0.5}, want: "between 0.7 and 1.2"}, + {name: "speed too high", req: &core.AudioSpeechRequest{Model: "m", Input: "hi", Voice: "v", Speed: 2}, want: "between 0.7 and 1.2"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := provider.CreateSpeech(context.Background(), tt.req) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("CreateSpeech() error = %v, want substring %q", err, tt.want) + } + }) + } +} + +func TestCreateSpeech_ReturnsUpstreamError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"detail":{"status":"invalid_api_key","message":"bad key"}}`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("bad-key", server.URL, server.Client(), llmclient.Hooks{}) + _, err := provider.CreateSpeech(context.Background(), &core.AudioSpeechRequest{ + Model: "m", Input: "hi", Voice: "v", + }) + gatewayErr, ok := err.(*core.GatewayError) + if !ok { + t.Fatalf("error type = %T, want *core.GatewayError", err) + } + if gatewayErr.StatusCode != http.StatusUnauthorized || gatewayErr.Type != core.ErrorTypeAuthentication { + t.Fatalf("gateway error = %+v, want 401 authentication", gatewayErr) + } +} + +func TestCreateTranscription_SendsMultipartAndReturnsJSON(t *testing.T) { + var gotPath, gotAuth string + var gotModelID, gotLanguage, gotGranularity, gotFilename, gotFileContent string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("xi-api-key") + + _, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + http.Error(w, "bad content type", http.StatusBadRequest) + return + } + reader := multipart.NewReader(r.Body, params["boundary"]) + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + http.Error(w, "multipart error", http.StatusBadRequest) + return + } + data, _ := io.ReadAll(part) + switch part.FormName() { + case "model_id": + gotModelID = string(data) + case "language_code": + gotLanguage = string(data) + case "timestamps_granularity": + gotGranularity = string(data) + case "file": + gotFilename = part.FileName() + gotFileContent = string(data) + } + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"language_code":"en","text":"hello world","words":[{"text":"hello","type":"word","start":0,"end":0.5},{"text":" ","type":"spacing","start":0.5,"end":0.6},{"text":"world","type":"word","start":0.6,"end":1.1}]}`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("elk_test", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.CreateTranscription(context.Background(), &core.AudioTranscriptionRequest{ + Model: "scribe_v1", + Filename: "clip.mp3", + File: []byte("fake-audio-bytes"), + Language: "en", + }) + if err != nil { + t.Fatalf("CreateTranscription() error = %v", err) + } + + if gotPath != "/v1/speech-to-text" || gotAuth != "elk_test" { + t.Fatalf("path/auth = %q/%q", gotPath, gotAuth) + } + if gotModelID != "scribe_v1" || gotLanguage != "en" { + t.Fatalf("model_id/language_code = %q/%q", gotModelID, gotLanguage) + } + if gotGranularity != "none" { + t.Fatalf("timestamps_granularity = %q, want none", gotGranularity) + } + if gotFilename != "clip.mp3" || gotFileContent != "fake-audio-bytes" { + t.Fatalf("file = %q/%q", gotFilename, gotFileContent) + } + if resp.ContentType != "application/json" { + t.Fatalf("content type = %q, want application/json", resp.ContentType) + } + var decoded struct { + Text string `json:"text"` + } + if err := json.Unmarshal(resp.Data, &decoded); err != nil || decoded.Text != "hello world" { + t.Fatalf("response body = %s, err = %v", resp.Data, err) + } +} + +func TestCreateTranscription_VerboseJSONIncludesWordsAndDuration(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"language_code":"en","text":"hi there","words":[{"text":"hi","type":"word","start":0,"end":0.3},{"text":"there","type":"word","start":0.4,"end":0.9}]}`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.CreateTranscription(context.Background(), &core.AudioTranscriptionRequest{ + Model: "scribe_v1", + File: []byte("audio"), + ResponseFormat: "verbose_json", + }) + if err != nil { + t.Fatalf("CreateTranscription() error = %v", err) + } + + var decoded struct { + Language string `json:"language"` + Duration float64 `json:"duration"` + Text string `json:"text"` + Words []struct { + Word string `json:"word"` + Start float64 `json:"start"` + End float64 `json:"end"` + } `json:"words"` + } + if err := json.Unmarshal(resp.Data, &decoded); err != nil { + t.Fatalf("unmarshal error = %v, body = %s", err, resp.Data) + } + if decoded.Language != "en" || decoded.Text != "hi there" || decoded.Duration != 0.9 { + t.Fatalf("verbose response = %+v", decoded) + } + if len(decoded.Words) != 2 || decoded.Words[1].Word != "there" { + t.Fatalf("words = %+v", decoded.Words) + } +} + +func TestCreateTranscription_TextFormatReturnsPlainText(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"text":"plain text result"}`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.CreateTranscription(context.Background(), &core.AudioTranscriptionRequest{ + Model: "scribe_v1", + File: []byte("audio"), + ResponseFormat: "text", + }) + if err != nil { + t.Fatalf("CreateTranscription() error = %v", err) + } + if string(resp.Data) != "plain text result" { + t.Fatalf("data = %q, want plain text result", resp.Data) + } + if !strings.HasPrefix(resp.ContentType, "text/plain") { + t.Fatalf("content type = %q, want text/plain", resp.ContentType) + } +} + +func TestCreateTranscription_ValidatesRequest(t *testing.T) { + provider := NewWithHTTPClient("key", "https://example.invalid", nil, llmclient.Hooks{}) + tests := []struct { + name string + req *core.AudioTranscriptionRequest + want string + }{ + {name: "nil request", req: nil, want: "request is required"}, + {name: "missing model", req: &core.AudioTranscriptionRequest{File: []byte("a")}, want: "model is required"}, + {name: "bad format", req: &core.AudioTranscriptionRequest{Model: "scribe_v1", File: []byte("a"), ResponseFormat: "srt"}, want: "supports json, text, or verbose_json"}, + {name: "prompt", req: &core.AudioTranscriptionRequest{Model: "scribe_v1", File: []byte("a"), Prompt: "context"}, want: "does not support prompt"}, + {name: "missing file", req: &core.AudioTranscriptionRequest{Model: "scribe_v1"}, want: "file is required"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := provider.CreateTranscription(context.Background(), tt.req) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("CreateTranscription() error = %v, want substring %q", err, tt.want) + } + }) + } +} diff --git a/internal/providers/elevenlabs/elevenlabs.go b/internal/providers/elevenlabs/elevenlabs.go new file mode 100644 index 000000000..d981fc23e --- /dev/null +++ b/internal/providers/elevenlabs/elevenlabs.go @@ -0,0 +1,206 @@ +// Package elevenlabs provides ElevenLabs voice API integration for the LLM +// gateway. ElevenLabs is a voice-only provider: it exposes text-to-speech and +// speech-to-text, not chat, Responses, or embeddings. +package elevenlabs + +import ( + "context" + "io" + "net/http" + "strings" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" + "github.com/enterpilot/gomodel/internal/providers" +) + +const defaultBaseURL = "https://api.elevenlabs.io" + +// authHeader is ElevenLabs' credential header. It is not an Authorization +// bearer token like most providers. +const authHeader = "xi-api-key" + +// Registration provides factory registration for the ElevenLabs provider. +var Registration = providers.Registration{ + Type: "elevenlabs", + New: New, + Discovery: providers.DiscoveryConfig{ + DefaultBaseURL: defaultBaseURL, + }, +} + +// Provider implements ElevenLabs' native text-to-speech and speech-to-text +// APIs behind the OpenAI-compatible audio endpoints. It does not implement +// core.PassthroughProvider's chat/Responses/Embeddings surface since +// ElevenLabs has no such endpoints. +type Provider struct { + client *llmclient.Client + keys *providers.Keyring +} + +var _ core.Provider = (*Provider)(nil) +var _ core.AudioProvider = (*Provider)(nil) +var _ core.PassthroughProvider = (*Provider)(nil) + +// New creates an ElevenLabs provider using the shared resilience and +// observability settings. +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 +} + +// NewWithHTTPClient creates an ElevenLabs provider with a custom HTTP client. +func NewWithHTTPClient(apiKey, baseURL string, httpClient *http.Client, hooks llmclient.Hooks) *Provider { + if httpClient == nil { + httpClient = http.DefaultClient + } + p := &Provider{keys: providers.NewKeyring(apiKey)} + cfg := llmclient.DefaultConfig("elevenlabs", providers.ResolveBaseURL(baseURL, defaultBaseURL)) + cfg.Hooks = hooks + p.client = llmclient.NewWithHTTPClient(httpClient, cfg, p.setHeaders) + return p +} + +// SetBaseURL changes the ElevenLabs API base URL. +func (p *Provider) SetBaseURL(baseURL string) { + p.client.SetBaseURL(baseURL) +} + +func (p *Provider) setHeaders(req *http.Request) { + req.Header.Set(authHeader, p.keys.NextForContext(req.Context())) + if requestID := core.GetRequestID(req.Context()); requestID != "" { + req.Header.Set("X-Request-Id", requestID) + } +} + +// ChatCompletion reports that ElevenLabs has no chat API. +func (p *Provider) ChatCompletion(_ context.Context, _ *core.ChatRequest) (*core.ChatResponse, error) { + return nil, core.NewInvalidRequestError("elevenlabs does not support chat completions", nil) +} + +// StreamChatCompletion reports that ElevenLabs has no chat API. +func (p *Provider) StreamChatCompletion(_ context.Context, _ *core.ChatRequest) (io.ReadCloser, error) { + return nil, core.NewInvalidRequestError("elevenlabs does not support chat completions", nil) +} + +// Responses reports that ElevenLabs has no Responses API. +func (p *Provider) Responses(_ context.Context, _ *core.ResponsesRequest) (*core.ResponsesResponse, error) { + return nil, core.NewInvalidRequestError("elevenlabs does not support the responses API", nil) +} + +// StreamResponses reports that ElevenLabs has no Responses API. +func (p *Provider) StreamResponses(_ context.Context, _ *core.ResponsesRequest) (io.ReadCloser, error) { + return nil, core.NewInvalidRequestError("elevenlabs does not support the responses API", nil) +} + +// Embeddings reports that ElevenLabs has no embeddings API. +func (p *Provider) Embeddings(_ context.Context, _ *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { + return nil, core.NewInvalidRequestError("elevenlabs does not support embeddings", nil) +} + +// Passthrough forwards an ElevenLabs-native request without typed translation. +func (p *Provider) Passthrough(ctx context.Context, req *core.PassthroughRequest) (*core.PassthroughResponse, error) { + if req == nil { + return nil, core.NewInvalidRequestError("passthrough request is required", nil) + } + resp, err := p.client.DoPassthrough(ctx, llmclient.Request{ + Method: req.Method, + Endpoint: providers.PassthroughEndpoint(req.Endpoint), + RawBodyReader: req.Body, + Headers: req.Headers, + }) + if err != nil { + return nil, err + } + return &core.PassthroughResponse{ + StatusCode: resp.StatusCode, + Headers: providers.CloneHTTPHeaders(resp.Header), + Body: resp.Body, + }, nil +} + +// staticTranscriptionModels lists ElevenLabs' speech-to-text ("Scribe") +// models, which are not included in GET /v1/models (that endpoint only lists +// text-to-speech models). scribe_v2 is current; scribe_v1 remains valid but +// is superseded. +var staticTranscriptionModels = []core.Model{ + { + ID: "scribe_v2", + Object: "model", + OwnedBy: "elevenlabs", + Metadata: &core.ModelMetadata{ + DisplayName: "Scribe v2", + Modes: []string{"audio_transcription"}, + Categories: core.CategoriesForModes([]string{"audio_transcription"}), + }, + }, + { + ID: "scribe_v1", + Object: "model", + OwnedBy: "elevenlabs", + Metadata: &core.ModelMetadata{ + DisplayName: "Scribe v1", + Modes: []string{"audio_transcription"}, + Categories: core.CategoriesForModes([]string{"audio_transcription"}), + }, + }, +} + +type modelInfo struct { + ModelID string `json:"model_id"` + Name string `json:"name"` + CanDoTextToSpeech bool `json:"can_do_text_to_speech"` + Description string `json:"description"` + Languages []struct { + LanguageID string `json:"language_id"` + } `json:"languages"` +} + +// ListModels returns ElevenLabs' text-to-speech catalog (from GET /v1/models) +// plus the fixed speech-to-text model list. +func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) { + var upstream []modelInfo + if err := p.client.Do(ctx, llmclient.Request{ + Method: http.MethodGet, + Endpoint: "/v1/models", + }, &upstream); err != nil { + return nil, err + } + + models := make([]core.Model, 0, len(upstream)+len(staticTranscriptionModels)) + for _, model := range upstream { + id := strings.TrimSpace(model.ModelID) + if id == "" || !model.CanDoTextToSpeech { + continue + } + models = append(models, core.Model{ + ID: id, + Object: "model", + OwnedBy: "elevenlabs", + Metadata: &core.ModelMetadata{ + DisplayName: model.Name, + Description: model.Description, + Modes: []string{"audio_speech"}, + Categories: core.CategoriesForModes([]string{"audio_speech"}), + Capabilities: multilingualCapability(model), + }, + }) + } + models = append(models, staticTranscriptionModels...) + return &core.ModelsResponse{Object: "list", Data: models}, nil +} + +func multilingualCapability(model modelInfo) map[string]bool { + if len(model.Languages) <= 1 { + return nil + } + return map[string]bool{"multilingual": true} +} diff --git a/internal/providers/elevenlabs/elevenlabs_test.go b/internal/providers/elevenlabs/elevenlabs_test.go new file mode 100644 index 000000000..277f06992 --- /dev/null +++ b/internal/providers/elevenlabs/elevenlabs_test.go @@ -0,0 +1,161 @@ +package elevenlabs + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" + "github.com/enterpilot/gomodel/internal/providers" +) + +func TestNew_ConstructsRegisteredProvider(t *testing.T) { + provider, ok := New(providers.ProviderConfig{ + APIKey: "elk_test", + BaseURL: "https://elevenlabs.example", + }, providers.ProviderOptions{}).(*Provider) + if !ok || provider.client == nil { + t.Fatalf("New() = %T, want initialized *Provider", provider) + } + if Registration.Discovery.DefaultBaseURL != defaultBaseURL { + t.Fatalf("registration base URL = %q, want %q", Registration.Discovery.DefaultBaseURL, defaultBaseURL) + } +} + +func TestProvider_ImplementsExpectedInterfaces(t *testing.T) { + provider := NewWithHTTPClient("key", "", nil, llmclient.Hooks{}) + if _, ok := any(provider).(core.Provider); !ok { + t.Fatal("elevenlabs provider should implement core.Provider") + } + if _, ok := any(provider).(core.AudioProvider); !ok { + t.Fatal("elevenlabs provider should implement core.AudioProvider") + } + if _, ok := any(provider).(core.PassthroughProvider); !ok { + t.Fatal("elevenlabs provider should implement core.PassthroughProvider") + } +} + +func TestSetBaseURL_ChangesRequestTarget(t *testing.T) { + var gotMethod, gotPath, gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotAuth = r.Header.Get("xi-api-key") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("elk_test", "https://unused.example", server.Client(), llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + if _, err := provider.ListModels(context.Background()); err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if gotMethod != http.MethodGet || gotPath != "/v1/models" { + t.Fatalf("method/path = %q/%q, want GET /v1/models", gotMethod, gotPath) + } + if gotAuth != "elk_test" { + t.Fatalf("xi-api-key = %q, want elk_test", gotAuth) + } +} + +func TestListModels_FiltersToTextToSpeechAndAddsScribe(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"model_id":"eleven_multilingual_v2","name":"Eleven Multilingual v2","can_do_text_to_speech":true,"languages":[{"language_id":"en"},{"language_id":"es"}]}, + {"model_id":"eleven_english_sts_v2","name":"Eleven English STS v2","can_do_text_to_speech":false} + ]`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + + byID := make(map[string]core.Model, len(resp.Data)) + for _, model := range resp.Data { + byID[model.ID] = model + } + if _, ok := byID["eleven_english_sts_v2"]; ok { + t.Fatal("ListModels() should exclude models that cannot do text-to-speech") + } + tts, ok := byID["eleven_multilingual_v2"] + if !ok { + t.Fatal("ListModels() should include text-to-speech models") + } + if tts.Metadata == nil || len(tts.Metadata.Modes) != 1 || tts.Metadata.Modes[0] != "audio_speech" { + t.Fatalf("tts model metadata = %+v, want audio_speech mode", tts.Metadata) + } + if !tts.Metadata.Capabilities["multilingual"] { + t.Fatalf("tts model capabilities = %+v, want multilingual", tts.Metadata.Capabilities) + } + if _, ok := byID["scribe_v1"]; !ok { + t.Fatal("ListModels() should include the static scribe_v1 model") + } + scribe, ok := byID["scribe_v2"] + if !ok { + t.Fatal("ListModels() should include the static scribe_v2 model") + } + if scribe.Metadata == nil || len(scribe.Metadata.Modes) != 1 || scribe.Metadata.Modes[0] != "audio_transcription" { + t.Fatalf("scribe model metadata = %+v, want audio_transcription mode", scribe.Metadata) + } +} + +func TestUnsupportedCapabilities_ReturnInvalidRequestErrors(t *testing.T) { + provider := NewWithHTTPClient("key", "", nil, llmclient.Hooks{}) + + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{}); err == nil || !strings.Contains(err.Error(), "does not support chat") { + t.Fatalf("ChatCompletion() error = %v", err) + } + if _, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{}); err == nil || !strings.Contains(err.Error(), "does not support chat") { + t.Fatalf("StreamChatCompletion() error = %v", err) + } + if _, err := provider.Responses(context.Background(), &core.ResponsesRequest{}); err == nil || !strings.Contains(err.Error(), "does not support the responses") { + t.Fatalf("Responses() error = %v", err) + } + if _, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{}); err == nil || !strings.Contains(err.Error(), "does not support the responses") { + t.Fatalf("StreamResponses() error = %v", err) + } + if _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{}); err == nil || !strings.Contains(err.Error(), "does not support embeddings") { + t.Fatalf("Embeddings() error = %v", err) + } +} + +func TestPassthrough_ForwardsOpaqueRequest(t *testing.T) { + var gotPath, gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("xi-api-key") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"accepted":true}`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("elk_test", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ + Method: http.MethodGet, + Endpoint: "voices", + Headers: http.Header{}, + }) + if err != nil { + t.Fatalf("Passthrough() error = %v", err) + } + defer resp.Body.Close() + + if gotPath != "/voices" { + t.Fatalf("path = %q, want /voices", gotPath) + } + if gotAuth != "elk_test" { + t.Fatalf("xi-api-key = %q, want elk_test", gotAuth) + } + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("status = %d, want 202", resp.StatusCode) + } +} diff --git a/run/providers.go b/run/providers.go index d76388477..8df1ff882 100644 --- a/run/providers.go +++ b/run/providers.go @@ -12,6 +12,7 @@ import ( "github.com/enterpilot/gomodel/internal/providers/chutes" "github.com/enterpilot/gomodel/internal/providers/cohere" "github.com/enterpilot/gomodel/internal/providers/deepseek" + "github.com/enterpilot/gomodel/internal/providers/elevenlabs" "github.com/enterpilot/gomodel/internal/providers/fireworks" "github.com/enterpilot/gomodel/internal/providers/gemini" "github.com/enterpilot/gomodel/internal/providers/groq" @@ -53,6 +54,7 @@ func defaultProviderFactory(cfg *config.Config) *providers.ProviderFactory { factory.Add(chutes.Registration) factory.Add(cohere.Registration) factory.Add(deepseek.Registration) + factory.Add(elevenlabs.Registration) factory.Add(fireworks.Registration) factory.Add(gemini.Registration) factory.Add(vertex.Registration) diff --git a/run/providers_test.go b/run/providers_test.go index 3d3c27eec..a75373135 100644 --- a/run/providers_test.go +++ b/run/providers_test.go @@ -168,8 +168,8 @@ var credentialPayloadFields = []string{ func TestDefaultProviderFactoryRegistersAllProviderTypes(t *testing.T) { expected := []string{ - "anthropic", "azure", "bailian", "bedrock", "bedrock-mantle", "chutes", "cohere", "deepseek", "fireworks", - "gemini", "groq", "kilo", "kimicode", "llmd", "meta", "minimax", "ollama", "openai", "opencode_go", + "anthropic", "azure", "bailian", "bedrock", "bedrock-mantle", "chutes", "cohere", "deepseek", "elevenlabs", + "fireworks", "gemini", "groq", "kilo", "kimicode", "llmd", "meta", "minimax", "ollama", "openai", "opencode_go", "openrouter", "oracle", "sglang", "vertex", "vllm", "xai", "xiaomi", "zai", } diff --git a/web/dashboard/src/pages/overview/providersLogic.js b/web/dashboard/src/pages/overview/providersLogic.js index b2bb462cc..daca3876b 100644 --- a/web/dashboard/src/pages/overview/providersLogic.js +++ b/web/dashboard/src/pages/overview/providersLogic.js @@ -21,6 +21,7 @@ const PROVIDER_DOC_SLUGS = { "bedrock-mantle": "bedrock-mantle", cohere: "cohere", deepseek: "deepseek", + elevenlabs: "elevenlabs", gemini: "gemini", llmd: "llmd", opencode_go: "opencode-go", From e41ddeac89ba283ca15b29af2a4b1fb7c64366cf Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 10 Aug 2026 16:27:52 +0200 Subject: [PATCH 2/3] fix(elevenlabs): address review findings on catalog fallback and speed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/providers/elevenlabs.mdx | 5 +- internal/providers/elevenlabs/audio.go | 22 ++- internal/providers/elevenlabs/audio_test.go | 160 ++++++++++++------ internal/providers/elevenlabs/elevenlabs.go | 30 +++- .../providers/elevenlabs/elevenlabs_test.go | 86 ++++++++-- run/providers_test.go | 7 + 6 files changed, 223 insertions(+), 87 deletions(-) diff --git a/docs/providers/elevenlabs.mdx b/docs/providers/elevenlabs.mdx index 68b0efdd2..feb9f02c7 100644 --- a/docs/providers/elevenlabs.mdx +++ b/docs/providers/elevenlabs.mdx @@ -52,8 +52,9 @@ List your available voice IDs from the ElevenLabs dashboard or the `response_format` accepts `mp3` (default), `opus`, `pcm`, and `wav`; each maps to a fixed ElevenLabs `output_format` (`mp3_44100_128`, `opus_48000_128`, `pcm_44100`, `wav_44100`). `aac` and `flac` are not supported and return -`invalid_request_error`. `speed` must be between `0.7` and `1.2` when set, -matching ElevenLabs' voice setting range; `instructions` is not supported. +`invalid_request_error`. `speed`, when set, is clamped to ElevenLabs' `0.7`-`1.2` +voice setting range (OpenAI accepts `0.25`-`4.0`); `instructions` is not +supported. ## Speech-to-text models and timestamps diff --git a/internal/providers/elevenlabs/audio.go b/internal/providers/elevenlabs/audio.go index 55723bcb8..f4af2b1e7 100644 --- a/internal/providers/elevenlabs/audio.go +++ b/internal/providers/elevenlabs/audio.go @@ -37,16 +37,17 @@ func speechFormat(responseFormat string) (openAIFormat, outputFormat string, err } } -// speechSpeed validates the OpenAI speed parameter against ElevenLabs' voice -// setting range. A zero value means "unset" and is left out of the request. -func speechSpeed(speed float64) (*float64, error) { +// speechSpeed clamps the OpenAI speed parameter to ElevenLabs' voice setting +// range (0.7-1.2). OpenAI accepts a wider range (0.25-4.0); per Postel's Law, +// GoModel adapts the request to the provider's requirements rather than +// rejecting values OpenAI clients legitimately send. A zero value means +// "unset" and is left out of the request. +func speechSpeed(speed float64) *float64 { if speed == 0 { - return nil, nil + return nil } - if speed < 0.7 || speed > 1.2 { - return nil, core.NewInvalidRequestError("elevenlabs speech speed must be between 0.7 and 1.2", nil) - } - return &speed, nil + speed = min(max(speed, 0.7), 1.2) + return &speed } type speechRequest struct { @@ -86,10 +87,7 @@ func (p *Provider) CreateSpeech(ctx context.Context, req *core.AudioSpeechReques if err != nil { return nil, err } - speed, err := speechSpeed(req.Speed) - if err != nil { - return nil, err - } + speed := speechSpeed(req.Speed) body := speechRequest{Text: req.Input, ModelID: model} if speed != nil { diff --git a/internal/providers/elevenlabs/audio_test.go b/internal/providers/elevenlabs/audio_test.go index d4ade878a..2771ab9ec 100644 --- a/internal/providers/elevenlabs/audio_test.go +++ b/internal/providers/elevenlabs/audio_test.go @@ -65,64 +65,75 @@ func TestCreateSpeech_UsesVoiceIDInPathAndDefaultsToMP3(t *testing.T) { } } -func TestCreateSpeech_MapsFormatsAndSpeed(t *testing.T) { - var gotQuery string - var gotBody speechRequest - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotQuery = r.URL.RawQuery - body, _ := io.ReadAll(r.Body) - _ = json.Unmarshal(body, &gotBody) - w.Header().Set("Content-Type", "audio/ogg") - _, _ = w.Write([]byte{0x01}) - })) - defer server.Close() - - provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) - resp, err := provider.CreateSpeech(context.Background(), &core.AudioSpeechRequest{ - Model: "eleven_flash_v2_5", - Input: "hi", - Voice: "voice-id", - ResponseFormat: "opus", - Speed: 1.1, - }) - if err != nil { - t.Fatalf("CreateSpeech() error = %v", err) - } - if gotQuery != "output_format=opus_48000_128" { - t.Fatalf("query = %q, want opus output format", gotQuery) - } - if gotBody.VoiceSetting == nil || gotBody.VoiceSetting.Speed != 1.1 { - t.Fatalf("voice_settings = %+v, want speed 1.1", gotBody.VoiceSetting) +func TestCreateSpeech_MapsResponseFormats(t *testing.T) { + tests := []struct { + name string + responseFormat string + wantQuery string + wantContent string + }{ + {"default mp3", "", "output_format=mp3_44100_128", "audio/mpeg"}, + {"opus", "opus", "output_format=opus_48000_128", "audio/ogg"}, + {"pcm", "pcm", "output_format=pcm_44100", "audio/pcm"}, + {"wav", "wav", "output_format=wav_44100", "audio/wav"}, } - if resp.ContentType != "audio/ogg" { - t.Fatalf("content type = %q, want audio/ogg", resp.ContentType) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + _, _ = w.Write([]byte{0x01}) + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.CreateSpeech(context.Background(), &core.AudioSpeechRequest{ + Model: "eleven_multilingual_v2", Input: "hi", Voice: "voice-id", + ResponseFormat: tt.responseFormat, + }) + if err != nil { + t.Fatalf("CreateSpeech() error = %v", err) + } + if gotQuery != tt.wantQuery { + t.Fatalf("query = %q, want %q", gotQuery, tt.wantQuery) + } + if resp.ContentType != tt.wantContent { + t.Fatalf("content type = %q, want %q", resp.ContentType, tt.wantContent) + } + }) } } -func TestCreateSpeech_SupportsWAV(t *testing.T) { - var gotQuery string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotQuery = r.URL.RawQuery - w.Header().Set("Content-Type", "audio/wav") - _, _ = w.Write([]byte{0x52, 0x49, 0x46, 0x46}) - })) - defer server.Close() - - provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) - resp, err := provider.CreateSpeech(context.Background(), &core.AudioSpeechRequest{ - Model: "eleven_multilingual_v2", - Input: "hi", - Voice: "voice-id", - ResponseFormat: "wav", - }) - if err != nil { - t.Fatalf("CreateSpeech() error = %v", err) - } - if gotQuery != "output_format=wav_44100" { - t.Fatalf("query = %q, want wav_44100 output format", gotQuery) +func TestCreateSpeech_ClampsSpeedToSupportedRange(t *testing.T) { + tests := []struct { + name string + speed float64 + wantSpeed float64 + }{ + {"within range", 1.1, 1.1}, + {"too slow clamps to minimum", 0.3, 0.7}, + {"too fast clamps to maximum", 3.0, 1.2}, } - if resp.ContentType != "audio/wav" { - t.Fatalf("content type = %q, want audio/wav", resp.ContentType) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotBody speechRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotBody) + _, _ = w.Write([]byte{0x01}) + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + if _, err := provider.CreateSpeech(context.Background(), &core.AudioSpeechRequest{ + Model: "eleven_multilingual_v2", Input: "hi", Voice: "voice-id", Speed: tt.speed, + }); err != nil { + t.Fatalf("CreateSpeech() error = %v", err) + } + if gotBody.VoiceSetting == nil || gotBody.VoiceSetting.Speed != tt.wantSpeed { + t.Fatalf("voice_settings = %+v, want speed %v", gotBody.VoiceSetting, tt.wantSpeed) + } + }) } } @@ -139,8 +150,6 @@ func TestCreateSpeech_ValidatesRequest(t *testing.T) { {name: "missing voice", req: &core.AudioSpeechRequest{Model: "m", Input: "hi"}, want: "voice is required"}, {name: "instructions", req: &core.AudioSpeechRequest{Model: "m", Input: "hi", Voice: "v", Instructions: "whisper"}, want: "does not support instructions"}, {name: "format", req: &core.AudioSpeechRequest{Model: "m", Input: "hi", Voice: "v", ResponseFormat: "aac"}, want: "supports mp3, opus, pcm, or wav"}, - {name: "speed too low", req: &core.AudioSpeechRequest{Model: "m", Input: "hi", Voice: "v", Speed: 0.5}, want: "between 0.7 and 1.2"}, - {name: "speed too high", req: &core.AudioSpeechRequest{Model: "m", Input: "hi", Voice: "v", Speed: 2}, want: "between 0.7 and 1.2"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -249,6 +258,47 @@ func TestCreateTranscription_SendsMultipartAndReturnsJSON(t *testing.T) { } } +func TestCreateTranscription_WordGranularityFromRequest(t *testing.T) { + var gotGranularity string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + http.Error(w, "bad content type", http.StatusBadRequest) + return + } + reader := multipart.NewReader(r.Body, params["boundary"]) + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + http.Error(w, "multipart error", http.StatusBadRequest) + return + } + if part.FormName() == "timestamps_granularity" { + data, _ := io.ReadAll(part) + gotGranularity = string(data) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"text":"hi"}`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + if _, err := provider.CreateTranscription(context.Background(), &core.AudioTranscriptionRequest{ + Model: "scribe_v1", + File: []byte("audio"), + TimestampGranularities: []string{"word"}, + }); err != nil { + t.Fatalf("CreateTranscription() error = %v", err) + } + if gotGranularity != "word" { + t.Fatalf("timestamps_granularity = %q, want word", gotGranularity) + } +} + func TestCreateTranscription_VerboseJSONIncludesWordsAndDuration(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/internal/providers/elevenlabs/elevenlabs.go b/internal/providers/elevenlabs/elevenlabs.go index d981fc23e..a13d40337 100644 --- a/internal/providers/elevenlabs/elevenlabs.go +++ b/internal/providers/elevenlabs/elevenlabs.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "strings" + "sync/atomic" "github.com/enterpilot/gomodel/internal/core" "github.com/enterpilot/gomodel/internal/llmclient" @@ -30,12 +31,17 @@ var Registration = providers.Registration{ } // Provider implements ElevenLabs' native text-to-speech and speech-to-text -// APIs behind the OpenAI-compatible audio endpoints. It does not implement -// core.PassthroughProvider's chat/Responses/Embeddings surface since +// APIs behind the OpenAI-compatible audio endpoints, plus native passthrough. +// Chat, Responses, and embeddings return invalid_request_error because // ElevenLabs has no such endpoints. type Provider struct { client *llmclient.Client keys *providers.Keyring + // everFetchedCatalog tracks whether GET /v1/models has ever succeeded, so + // ListModels can tell a cold-start catalog failure (no prior inventory to + // fall back on) from a later transient one (where the registry's own + // stale-inventory carry-forward should keep the last known-good list). + everFetchedCatalog atomic.Bool } var _ core.Provider = (*Provider)(nil) @@ -165,15 +171,27 @@ type modelInfo struct { } // ListModels returns ElevenLabs' text-to-speech catalog (from GET /v1/models) -// plus the fixed speech-to-text model list. +// plus the fixed speech-to-text model list. On the very first fetch, a +// catalog failure would otherwise leave the registry with no ElevenLabs +// models at all — including the always-available static transcription +// models, which don't depend on this call — so a first-fetch failure still +// returns the static list. Once a fetch has succeeded, later failures +// propagate normally so the registry's existing stale-inventory carry-forward +// keeps the last known-good (larger) list instead of this call shrinking it +// down to the static-only fallback. func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) { var upstream []modelInfo - if err := p.client.Do(ctx, llmclient.Request{ + catalogErr := p.client.Do(ctx, llmclient.Request{ Method: http.MethodGet, Endpoint: "/v1/models", - }, &upstream); err != nil { - return nil, err + }, &upstream) + if catalogErr != nil { + if p.everFetchedCatalog.Load() { + return nil, catalogErr + } + return &core.ModelsResponse{Object: "list", Data: append([]core.Model{}, staticTranscriptionModels...)}, nil } + p.everFetchedCatalog.Store(true) models := make([]core.Model, 0, len(upstream)+len(staticTranscriptionModels)) for _, model := range upstream { diff --git a/internal/providers/elevenlabs/elevenlabs_test.go b/internal/providers/elevenlabs/elevenlabs_test.go index 277f06992..0729f2a7c 100644 --- a/internal/providers/elevenlabs/elevenlabs_test.go +++ b/internal/providers/elevenlabs/elevenlabs_test.go @@ -107,23 +107,85 @@ func TestListModels_FiltersToTextToSpeechAndAddsScribe(t *testing.T) { } } -func TestUnsupportedCapabilities_ReturnInvalidRequestErrors(t *testing.T) { - provider := NewWithHTTPClient("key", "", nil, llmclient.Hooks{}) +func TestListModels_FallsBackToStaticModelsOnFirstFetchFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer server.Close() - if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{}); err == nil || !strings.Contains(err.Error(), "does not support chat") { - t.Fatalf("ChatCompletion() error = %v", err) + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + resp, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v, want static fallback on first-ever fetch failure", err) + } + if len(resp.Data) != len(staticTranscriptionModels) { + t.Fatalf("ListModels() data = %+v, want only the static transcription models", resp.Data) } - if _, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{}); err == nil || !strings.Contains(err.Error(), "does not support chat") { - t.Fatalf("StreamChatCompletion() error = %v", err) + if _, ok := func() (core.Model, bool) { + for _, m := range resp.Data { + if m.ID == "scribe_v2" { + return m, true + } + } + return core.Model{}, false + }(); !ok { + t.Fatal("ListModels() fallback should include scribe_v2") } - if _, err := provider.Responses(context.Background(), &core.ResponsesRequest{}); err == nil || !strings.Contains(err.Error(), "does not support the responses") { - t.Fatalf("Responses() error = %v", err) +} + +func TestListModels_PropagatesErrorOnceCatalogHasSucceededOnce(t *testing.T) { + fail := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if fail { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"model_id":"eleven_multilingual_v2","name":"Eleven Multilingual v2","can_do_text_to_speech":true}]`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("key", server.URL, server.Client(), llmclient.Hooks{}) + if _, err := provider.ListModels(context.Background()); err != nil { + t.Fatalf("first ListModels() error = %v, want success", err) } - if _, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{}); err == nil || !strings.Contains(err.Error(), "does not support the responses") { - t.Fatalf("StreamResponses() error = %v", err) + + fail = true + if _, err := provider.ListModels(context.Background()); err == nil { + t.Fatal("ListModels() error = nil, want propagated catalog error once a fetch has already succeeded, so the registry's stale-inventory carry-forward keeps the larger prior list instead of this call shrinking it") } - if _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{}); err == nil || !strings.Contains(err.Error(), "does not support embeddings") { - t.Fatalf("Embeddings() error = %v", err) +} + +func TestUnsupportedCapabilities_ReturnInvalidRequestErrors(t *testing.T) { + provider := NewWithHTTPClient("key", "", nil, llmclient.Hooks{}) + + tests := []struct { + name string + call func() error + want string + }{ + {"chat", func() error { _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{}); return err }, "does not support chat"}, + {"chat stream", func() error { + _, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{}) + return err + }, "does not support chat"}, + {"responses", func() error { _, err := provider.Responses(context.Background(), &core.ResponsesRequest{}); return err }, "does not support the responses"}, + {"responses stream", func() error { + _, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{}) + return err + }, "does not support the responses"}, + {"embeddings", func() error { + _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{}) + return err + }, "does not support embeddings"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.call() + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want substring %q", err, tt.want) + } + }) } } diff --git a/run/providers_test.go b/run/providers_test.go index a75373135..8522ed741 100644 --- a/run/providers_test.go +++ b/run/providers_test.go @@ -43,6 +43,13 @@ func TestDefaultProviderFactoryCredentialForms(t *testing.T) { fields: []string{"api_keys", "base_url", "session_sticky_keys", "models"}, required: []string{"api_keys"}, }, + { + // Voice-only provider (no chat); same plain API-key shape. + providerType: "elevenlabs", + defaultURL: "https://api.elevenlabs.io", + fields: []string{"api_keys", "base_url", "session_sticky_keys", "models"}, + required: []string{"api_keys"}, + }, { // A deployment URL is the provider, so it is required, and Azure // is the one type that takes an API version. From b87c7357705fa36db20c22434d8ac42a2c9e7dd1 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 10 Aug 2026 17:29:25 +0200 Subject: [PATCH 3/3] fix(elevenlabs): unwrap upstream error detail; document gaps found in e2e testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/providers/elevenlabs.mdx | 40 ++++++++++++++-- internal/providers/elevenlabs/audio.go | 53 +++++++++++++++++---- internal/providers/elevenlabs/audio_test.go | 29 +++++++++++ 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/docs/providers/elevenlabs.mdx b/docs/providers/elevenlabs.mdx index feb9f02c7..90a532105 100644 --- a/docs/providers/elevenlabs.mdx +++ b/docs/providers/elevenlabs.mdx @@ -42,10 +42,16 @@ library (built-in, cloned, or shared). Pass that ID as the OpenAI-compatible } ``` -List your available voice IDs from the ElevenLabs dashboard or the -`GET /v1/voices` API (available under `/p/elevenlabs/v1/voices` via -[passthrough](/features/passthrough-api) once `elevenlabs` is added to -`ENABLED_PASSTHROUGH_PROVIDERS`). +List your available voice IDs from the ElevenLabs dashboard, or via +[passthrough](/features/passthrough-api) (once `elevenlabs` is added to +`ENABLED_PASSTHROUGH_PROVIDERS`) at `/p/elevenlabs/v2/voices` — the newer +`GET /v2/voices` search endpoint. `/p/elevenlabs/v1/voices` does not currently +work: GoModel's provider-passthrough router treats a leading `v1/` segment in +the path as an alias for providers whose base URL already embeds `/v1` (e.g. +OpenAI), and strips it before forwarding. ElevenLabs' base URL is +`https://api.elevenlabs.io` with no `/v1`, so every `/p/elevenlabs/v1/...` +passthrough call currently 404s. This affects all of ElevenLabs' native `/v1` +surface via passthrough, not just voice listing — see "Not implemented" below. ## Supported speech formats @@ -76,3 +82,29 @@ option: `mp3`/`opus`/`pcm`/`wav`. - Transcription `prompt`, and `response_format` values other than `json`/`text`/`verbose_json`. + +## Not implemented + +GoModel only implements the two ElevenLabs capabilities that map onto +OpenAI-compatible endpoints: text-to-speech and speech-to-text. Everything +else ElevenLabs offers has no typed support in GoModel today: + +- **Speech-to-speech (voice changer)** — `POST /v1/speech-to-speech/{voice_id}` + has no OpenAI-compatible equivalent to translate from, so there's no typed + endpoint for it. It could be added as a native passthrough route in the + future, but **passthrough for it does not currently work either** — see the + `/v1` alias limitation above; speech-to-speech has no `/v2` path to work + around it with, so it is entirely unreachable through GoModel right now. +- **Dubbing, voice cloning/design, projects (Studio), and conversational AI + (agents)** — same reasoning: no OpenAI-compatible shape to translate to, and + (for the `/v1`-only parts of these APIs) the same passthrough limitation + applies. These are reasonable candidates for future native passthrough + support once that limitation is fixed. +- **Realtime/streaming TTS and STT** (`/v1/text-to-speech/{voice_id}/stream`, + WebSocket streaming) — GoModel's `/v1/audio/speech` and + `/v1/audio/transcriptions` are synchronous request/response; no streaming + variant is implemented for ElevenLabs. + +None of this is ElevenLabs-specific scope creep avoidance — it reflects that +GoModel's audio surface is deliberately OpenAI-shaped, and ElevenLabs' API is +much larger than OpenAI's TTS/STT pair. diff --git a/internal/providers/elevenlabs/audio.go b/internal/providers/elevenlabs/audio.go index f4af2b1e7..081792a6c 100644 --- a/internal/providers/elevenlabs/audio.go +++ b/internal/providers/elevenlabs/audio.go @@ -3,6 +3,7 @@ package elevenlabs import ( "bytes" "context" + "errors" "io" "mime/multipart" "net/http" @@ -15,6 +16,48 @@ import ( "github.com/enterpilot/gomodel/internal/llmclient" ) +// refineElevenLabsError improves the message on a *core.GatewayError that +// llmclient.Client already built from a non-2xx response. ElevenLabs wraps +// errors as {"detail": "..."} or {"detail": {"message": "...", ...}} rather +// than the {"message": ...}/{"error": {...}} shapes the generic client-level +// parser recognizes, so without this the message is the raw JSON body. +// Non-GatewayErrors (e.g. transport failures) pass through unchanged. +func refineElevenLabsError(err error) error { + var gatewayErr *core.GatewayError + if !errors.As(err, &gatewayErr) || gatewayErr == nil { + return err + } + message := elevenlabsErrorDetailMessage(gatewayErr.ResponseBody) + if message == "" { + return err + } + refined := *gatewayErr + refined.Message = message + return &refined +} + +func elevenlabsErrorDetailMessage(body []byte) string { + var envelope struct { + Detail json.RawMessage `json:"detail"` + } + if err := json.Unmarshal(body, &envelope); err != nil || len(envelope.Detail) == 0 { + return "" + } + + var asString string + if err := json.Unmarshal(envelope.Detail, &asString); err == nil { + return asString + } + + var asObject struct { + Message string `json:"message"` + } + if err := json.Unmarshal(envelope.Detail, &asObject); err == nil { + return asObject.Message + } + return "" +} + // speechFormat maps an OpenAI-compatible response_format to the ElevenLabs // output_format query value. ElevenLabs has no aac/flac encoders, so those // formats are rejected rather than silently substituted. @@ -105,10 +148,7 @@ func (p *Provider) CreateSpeech(ctx context.Context, req *core.AudioSpeechReques Headers: http.Header{"Content-Type": {"application/json"}}, }) if err != nil { - return nil, err - } - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return nil, core.ParseProviderError("elevenlabs", resp.StatusCode, resp.Body, nil) + return nil, refineElevenLabsError(err) } if len(resp.Body) == 0 { return nil, core.NewEmptyProviderResponseError("elevenlabs") @@ -182,10 +222,7 @@ func (p *Provider) CreateTranscription(ctx context.Context, req *core.AudioTrans Headers: http.Header{"Content-Type": {contentType}}, }) if err != nil { - return nil, err - } - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return nil, core.ParseProviderError("elevenlabs", resp.StatusCode, resp.Body, nil) + return nil, refineElevenLabsError(err) } var upstream transcriptionResponse diff --git a/internal/providers/elevenlabs/audio_test.go b/internal/providers/elevenlabs/audio_test.go index 2771ab9ec..d7617eef5 100644 --- a/internal/providers/elevenlabs/audio_test.go +++ b/internal/providers/elevenlabs/audio_test.go @@ -180,6 +180,35 @@ func TestCreateSpeech_ReturnsUpstreamError(t *testing.T) { if gatewayErr.StatusCode != http.StatusUnauthorized || gatewayErr.Type != core.ErrorTypeAuthentication { t.Fatalf("gateway error = %+v, want 401 authentication", gatewayErr) } + if gatewayErr.Message != "bad key" { + t.Fatalf("message = %q, want the unwrapped detail.message, not the raw JSON body", gatewayErr.Message) + } +} + +func TestRefineElevenLabsError_UnwrapsDetailShapes(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {"string detail", `{"detail":"Not Found"}`, "Not Found"}, + {"object detail with message", `{"detail":{"type":"authorization_error","code":"subscription_required","message":"Output format 'wav_44100' is only available on the Pro tier and above.","status":"output_format_not_allowed"}}`, "Output format 'wav_44100' is only available on the Pro tier and above."}, + {"no detail field keeps the generic-parser message", `{"error":"something else"}`, "something else"}, + {"not JSON falls back to raw body", `plain text error`, "plain text error"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + original := core.ParseProviderError("elevenlabs", http.StatusBadRequest, []byte(tt.body), nil) + refined := refineElevenLabsError(original) + gatewayErr, ok := refined.(*core.GatewayError) + if !ok { + t.Fatalf("error type = %T, want *core.GatewayError", refined) + } + if gatewayErr.Message != tt.want { + t.Fatalf("message = %q, want %q", gatewayErr.Message, tt.want) + } + }) + } } func TestCreateTranscription_SendsMultipartAndReturnsJSON(t *testing.T) {