From 7dc953039745f0b73e3b2daefb130f8b3d550385 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Sun, 23 Aug 2026 09:25:10 -0400 Subject: [PATCH 1/3] fix: wire read-aloud TTS to the gateway instead of a dead default Open WebUI's text-to-speech was never wired to the gateway (#997): every audio.tts.* key is persistent config, so a first boot seeded engine="" (bundled browser speech synthesis), api.openai.com, an empty key, model tts-1 and voice alloy, and no compose change could reach an already-booted volume afterwards. The STT side of this exact reconcile existed; the TTS side had never been written. - owui-patches/hive_rag_env_config.py reconciles audio.tts.engine, model, voice, base URL and API key from the environment, mirroring the STT block, including the paired base-URL-without-key refusal and secret-free logging. - docker-compose.yml sets the five AUDIO_TTS_* variables (OWUI_TTS_ALIAS and OWUI_TTS_VOICE knobs, defaults hive-tts / autumn) following the STT vars. - edge-api serves GET /v1/audio/voices with the provider's real roster (autumn diana hannah austin daniel troy). Open WebUI's get_available_voices fetches that endpoint unauthenticated on a non-OpenAI base URL and falls back to its hardcoded alloy-style list when it fails, which is how the UI kept offering a voice hive-tts rejects (#996). - scripts/test_owui_rag_env_config.py pins all of the above; new Go unit tests cover the voices handler. deploy/litellm/config.yaml verified, no change needed: route-groq-tts exists with api_key os.environ/GROQ_API_KEY and a literal groq/orpheus model id, matching exactly how route-groq-stt is handled. Proof: docs/proof/tts-readaloud/capture.log. Live round trip through LiteLLM route-groq-tts passed via TestLiveVoiceRoundTripThroughLiteLLM (non-empty, non-silent WAV, verbatim STT round trip); direct request returned 200 with 149830 bytes. UI playback capture pending-live-capture post-merge. Fixes #997. Partially addresses #996 by removing alloy from Hive's own surfaces; the 400-to-500 mapping itself remains tracked there. --- apps/edge-api/cmd/server/main.go | 8 ++ .../edge-api/internal/audio/handler_voices.go | 50 +++++++ .../internal/audio/handler_voices_test.go | 52 ++++++++ deploy/docker/docker-compose.yml | 21 +++ .../owui-patches/hive_rag_env_config.py | 23 +++- docs/proof/tts-readaloud/capture.log | 47 +++++++ scripts/test_owui_rag_env_config.py | 122 +++++++++++++++++- 7 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 apps/edge-api/internal/audio/handler_voices.go create mode 100644 apps/edge-api/internal/audio/handler_voices_test.go create mode 100644 docs/proof/tts-readaloud/capture.log diff --git a/apps/edge-api/cmd/server/main.go b/apps/edge-api/cmd/server/main.go index 1796b4c0b..c13b6cd68 100644 --- a/apps/edge-api/cmd/server/main.go +++ b/apps/edge-api/cmd/server/main.go @@ -270,6 +270,14 @@ func main() { voiceMW := voiceGateForAPIKeys(featureGate.Require(featuregate.FeatureVoice)) registerMediaFileBatchRoutes(mux, imagesHandler, audioHandler, filesHandler, batchesHandler, voiceMW) + // Issue #997: Open WebUI's voice dropdowns fetch GET /v1/audio/voices with + // no Authorization header at all, so this route is registered without the + // hk_-key authorizer or the tenant voice gate. Serving the provider's real + // roster here is what keeps Open WebUI's get_available_voices from falling + // back to its hardcoded alloy-style list (#996); gating it would silently + // reinstate that fallback. See audio.VoicesHandler for the full rationale. + mux.Handle("/v1/audio/voices", audio.VoicesHandler()) + log.Printf("S3 storage enabled: images=%s, files=%s", storageCfg.ImagesBucket, storageCfg.FilesBucket) // RAG routes (#232): always registered behind FeatureRAG so the gate returns diff --git a/apps/edge-api/internal/audio/handler_voices.go b/apps/edge-api/internal/audio/handler_voices.go new file mode 100644 index 000000000..65e68a55d --- /dev/null +++ b/apps/edge-api/internal/audio/handler_voices.go @@ -0,0 +1,50 @@ +package audio + +import ( + "encoding/json" + "net/http" + + apierrors "github.com/sakibsadmanshajib/hive/apps/edge-api/internal/errors" +) + +// Voice catalog for the hive-tts alias (groq/orpheus-v1-english). These are +// the only voice ids the upstream model accepts; OpenAI's stock names (alloy, +// echo, ...) are rejected with an upstream 400 that today surfaces as a +// sanitized 500 (#996), so every client we control must offer exactly these. +// +// ponytail: static slice rather than a config knob or DB table. The list +// changes only when the provider swaps its voice roster, at which point this +// file changes with it; nothing needs to configure six fixed strings at boot. +var orpheusVoices = []voiceEntry{ + {"autumn", "Autumn"}, + {"diana", "Diana"}, + {"hannah", "Hannah"}, + {"austin", "Austin"}, + {"daniel", "Daniel"}, + {"troy", "Troy"}, +} + +type voiceEntry struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// VoicesHandler serves GET /v1/audio/voices, the endpoint Open WebUI's +// get_available_voices fetches when audio.tts.engine is openai and the base +// URL is not api.openai.com (routers/audio.py). It sends no Authorization +// header of any kind, so this route is deliberately registered without the +// hk_-key authorizer or the tenant voice gate: there is no credential to +// validate and nothing here is per-account data, only the six public voice +// names the provider publishes. Gating it would silently break the Settings > +// Audio voice dropdowns back to Open WebUI's hardcoded alloy-style fallback, +// which is the exact defect (#996) this exists to prevent. +func VoicesHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + apierrors.WriteError(w, http.StatusMethodNotAllowed, "invalid_request_error", "Method not allowed", nil) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"voices": orpheusVoices}) + } +} diff --git a/apps/edge-api/internal/audio/handler_voices_test.go b/apps/edge-api/internal/audio/handler_voices_test.go new file mode 100644 index 000000000..c1fbb627e --- /dev/null +++ b/apps/edge-api/internal/audio/handler_voices_test.go @@ -0,0 +1,52 @@ +package audio + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestVoicesHandlerGetReturnsProviderRoster(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + VoicesHandler()(rec, httptest.NewRequest(http.MethodGet, "/v1/audio/voices", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /v1/audio/voices: got %d want 200, body %q", rec.Code, rec.Body.String()) + } + var body struct { + Voices []voiceEntry `json:"voices"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if len(body.Voices) != len(orpheusVoices) { + t.Fatalf("got %d voices, want %d", len(body.Voices), len(orpheusVoices)) + } + ids := make(map[string]bool, len(body.Voices)) + for _, v := range body.Voices { + if v.ID == "" || v.Name == "" { + t.Fatalf("voice entry missing id or name: %+v", v) + } + ids[v.ID] = true + } + for _, want := range []string{"autumn", "diana", "hannah", "austin", "daniel", "troy"} { + if !ids[want] { + t.Errorf("roster missing voice %q", want) + } + } + if ids["alloy"] { + t.Error("alloy must never be offered; groq/orpheus rejects it (#996)") + } +} + +func TestVoicesHandlerRejectsNonGet(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + VoicesHandler()(rec, httptest.NewRequest(http.MethodPost, "/v1/audio/voices", nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("POST /v1/audio/voices: got %d want 405", rec.Code) + } +} diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index a204023dc..05bce3285 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -1124,6 +1124,27 @@ services: AUDIO_STT_OPENAI_API_BASE_URL: "http://edge-api:8080/v1" AUDIO_STT_OPENAI_API_KEY: ${OWUI_SHIM_KEY:-} AUDIO_STT_MODEL: ${OWUI_STT_ALIAS:-hive-stt} + # Read-aloud (issue #997). Same first-boot-wins trap as STT directly + # above: all five audio.tts.* keys are in Open WebUI's DEFAULT_CONFIG, + # so a volume seeded `engine=""` (bundled browser speech synthesis), + # api.openai.com, an empty key, model tts-1 and voice alloy on its first + # boot and compose alone could never move any of them afterwards. The + # defaults here name what the gateway actually serves: hive-tts resolves + # to groq/orpheus, which rejects alloy (#996), so the deployment-wide + # default voice is one Orpheus actually has. The list the Settings > + # Audio voice dropdowns offer comes from GET /v1/audio/voices on + # edge-api, which answers without credentials because Open WebUI's own + # voices fetch sends none. + # + # An Enterprise box running the sovereign `voice` profile points + # OWUI_TTS_ALIAS at its own catalog alias, or clears AUDIO_TTS_ENGINE to + # keep whatever its administrator configured; a blank value never + # clobbers a persisted one. + AUDIO_TTS_ENGINE: "openai" + AUDIO_TTS_OPENAI_API_BASE_URL: "http://edge-api:8080/v1" + AUDIO_TTS_OPENAI_API_KEY: ${OWUI_SHIM_KEY:-} + AUDIO_TTS_MODEL: ${OWUI_TTS_ALIAS:-hive-tts} + AUDIO_TTS_VOICE: ${OWUI_TTS_VOICE:-autumn} VECTOR_DB: "pgvector" # TRANSACTION-mode pooler when one is configured. This is the connection # that has actually been failing: a recreated open-webui could not get a diff --git a/deploy/docker/owui-patches/hive_rag_env_config.py b/deploy/docker/owui-patches/hive_rag_env_config.py index e42bd55f8..df84229f3 100644 --- a/deploy/docker/owui-patches/hive_rag_env_config.py +++ b/deploy/docker/owui-patches/hive_rag_env_config.py @@ -66,6 +66,20 @@ "audio.stt.model": "AUDIO_STT_MODEL", "audio.stt.openai.api_base_url": "AUDIO_STT_OPENAI_API_BASE_URL", "audio.stt.openai.api_key": "AUDIO_STT_OPENAI_API_KEY", + # Text-to-speech, the read-aloud speaker button (#997). Same trap as STT + # directly above: all five keys are in DEFAULT_CONFIG, so a first boot + # seeded `engine=""` (Open WebUI's own bundled speech synthesis), a base + # URL of api.openai.com, an empty key, model tts-1 and voice alloy, and no + # compose change could reach any already-booted volume after that. The + # gateway's hive-tts alias (groq/orpheus) accepts none of those defaults: + # alloy is rejected upstream and api.openai.com is not served by anyone. + # The voice default must name one the provider actually has; the list the + # UI offers comes from GET /v1/audio/voices on edge-api (see its handler). + "audio.tts.engine": "AUDIO_TTS_ENGINE", + "audio.tts.model": "AUDIO_TTS_MODEL", + "audio.tts.openai.api_base_url": "AUDIO_TTS_OPENAI_API_BASE_URL", + "audio.tts.openai.api_key": "AUDIO_TTS_OPENAI_API_KEY", + "audio.tts.voice": "AUDIO_TTS_VOICE", # Not a RAG key, and the only non-RAG one here. ponytail: reusing this # reconcile rather than minting a second identical module, because the # failure is identical to #722, right down to the mechanism. Every account @@ -135,7 +149,9 @@ # this failure mode never produced anywhere: Open WebUI logs only aiohttp's # bare "404, message='Not Found'" for it, having discarded the response body # that names the model. -SECRET_KEYS = frozenset({"rag.openai.api_key", "audio.stt.openai.api_key"}) +SECRET_KEYS = frozenset( + {"rag.openai.api_key", "audio.stt.openai.api_key", "audio.tts.openai.api_key"} +) # Destination keys that must never be written without their credential, and the # environment variables an operator has to fix when one is missing. Open WebUI @@ -150,6 +166,11 @@ "audio.stt.openai.api_key", "Open WebUI's speech-to-text", ), + ( + "audio.tts.openai.api_base_url", + "audio.tts.openai.api_key", + "Open WebUI's text-to-speech", + ), ) diff --git a/docs/proof/tts-readaloud/capture.log b/docs/proof/tts-readaloud/capture.log new file mode 100644 index 000000000..52b23ff84 --- /dev/null +++ b/docs/proof/tts-readaloud/capture.log @@ -0,0 +1,47 @@ +TTS read-aloud wiring proof (issues #997, #996). Captured 2026-08-23, WSL2 dev box. + +Environment: LiteLLM proxy started from this branch's deploy/litellm/config.yaml +(pinned image ghcr.io/berriai/litellm:v1.98.0@sha256:20b5...ddaf4), master key +redacted, GROQ_API_KEY supplied from the local .env and never printed. +The edge-api handler ran unmodified via the repo's own production-twin harness +(TestLiveVoiceRoundTripThroughLiteLLM, apps/edge-api/internal/audio/ +live_voice_integration_test.go): Handler -> LiteLLM route-groq-tts -> Groq +Orpheus. No other Hive service was involved or affected. + +1) Live integration test (Handler -> LiteLLM -> Groq), voice troy: + --- PASS: TestLiveVoiceRoundTripThroughLiteLLM (2.86s) + Speech leg returned non-empty WAV with non-silent PCM; STT leg transcribed + the same sentence back verbatim ("The quick brown fox jumps over the lazy dog"). + +2) Direct POST /v1/audio/speech against the same proxy (route-groq-tts): + supported voice -> http=200 bytes=149830 content_type=audio/mpeg + WAV check: RIFF PCM 16-bit mono 24000 Hz, peak sample amplitude 29632 + (non-silent audio confirmed by decoding, not just byte count). + +3) Unsupported voice (alloy), same route: http=500, body + {"error":{"message":"Internal server error","type":"internal_server_error"}} + Documents the remaining #996 contract gap (400-class provider rejection + surfaced as a 500). Unchanged by this PR on purpose; the PR prevents Hive's + own surfaces from offering alloy instead (see below). + +4) GET /v1/audio/voices (new edge-api route in this branch): covered by unit + tests TestVoicesHandlerGetReturnsProviderRoster / + TestVoicesHandlerRejectsNonGet (apps/edge-api/internal/audio/ + handler_voices_test.go): returns {voices:[{id,name}...]} with exactly + autumn diana hannah austin daniel troy, no alloy, and 405 for non-GET. + Not exercised against a live edge-api container here because that needs a + full core stack (DB authz chain); the handler is a static catalog with no + dependencies, so httptest coverage exercises it fully. + +5) Patch self-check: python3 scripts/test_owui_rag_env_config.py + ok: owui RAG env-config reconcile (issue #722) + Includes new tests pinning the compose TTS lines, the paired-credential + refusal for audio.tts.openai.*, and the /v1/audio/voices registration. + +Pending-live-capture: the read-aloud button producing audible playback in the +Open WebUI chat surface was NOT captured here; it requires the deployed demo +stack running this branch's open-webui + edge-api images. Post-merge steps: +deploy-demo-box.yml auto-deploys main to the box; then sign in at +chat-hive.scubed.co, open any assistant message, click the speaker button, and +confirm playback plus a Settings > Audio TTS Voice dropdown listing autumn, +diana, hannah, austin, daniel, troy. Capture screenshot into docs/proof/tts-readaloud/. diff --git a/scripts/test_owui_rag_env_config.py b/scripts/test_owui_rag_env_config.py index 4ad50805d..7b281f464 100644 --- a/scripts/test_owui_rag_env_config.py +++ b/scripts/test_owui_rag_env_config.py @@ -358,6 +358,121 @@ def test_no_deployment_wide_speech_language_is_configured() -> None: ) +def test_text_to_speech_is_pointed_at_the_gateway() -> None: + """Issue #997. Open WebUI ships audio.tts.engine as "" (its bundled browser + speech synthesis), api_base_url api.openai.com, an empty key, model tts-1 + and voice alloy, and seeds all five on first boot. The gateway's hive-tts + alias accepts none of those: alloy is rejected upstream (#996) and + api.openai.com is not served by anyone, so read-aloud was dead.""" + config = FakeConfig( + { + "audio.tts.engine": "", + "audio.tts.model": "tts-1", + "audio.tts.voice": "alloy", + "audio.tts.openai.api_base_url": "https://api.openai.com/v1", + "audio.tts.openai.api_key": "", + } + ) + applied = reconcile( + config, + { + "AUDIO_TTS_ENGINE": "openai", + "AUDIO_TTS_MODEL": "hive-tts", + "AUDIO_TTS_VOICE": "autumn", + "AUDIO_TTS_OPENAI_API_BASE_URL": "http://edge-api:8080/v1", + "AUDIO_TTS_OPENAI_API_KEY": "hk_example", + }, + ) + assert config.stored["audio.tts.engine"] == "openai", config.stored + assert config.stored["audio.tts.model"] == "hive-tts", config.stored + assert config.stored["audio.tts.voice"] == "autumn", config.stored + assert config.stored["audio.tts.openai.api_base_url"] == "http://edge-api:8080/v1", config.stored + assert config.stored["audio.tts.openai.api_key"] == "hk_example", config.stored + assert applied["audio.tts.voice"] == "autumn", applied + + +def test_tts_base_url_without_a_key_is_refused() -> None: + """Same rule as the RAG and STT pairs: a credential must not outlive the + destination it was issued for. Only the supplied keys are written, so a + base URL on its own would repoint read-aloud while Open WebUI kept sending + the key persisted for the previous destination.""" + for key_value in (None, "", " "): + environ = { + "AUDIO_TTS_ENGINE": "openai", + "AUDIO_TTS_OPENAI_API_BASE_URL": "http://somewhere-else:8080/v1", + } + if key_value is not None: + environ["AUDIO_TTS_OPENAI_API_KEY"] = key_value + + config = FakeConfig( + { + "audio.tts.openai.api_base_url": "http://edge-api:8080/v1", + "audio.tts.openai.api_key": "hk_issued_for_edge_api", + } + ) + try: + reconcile(config, environ) + except RuntimeError as exc: + message = str(exc) + else: + raise AssertionError(f"expected a refusal for key_value={key_value!r}") + + assert "AUDIO_TTS_OPENAI_API_BASE_URL" in message, message + assert "AUDIO_TTS_OPENAI_API_KEY" in message, message + assert "hk_issued_for_edge_api" not in message, message + assert config.upsert_calls == 0 + assert config.stored["audio.tts.openai.api_base_url"] == "http://edge-api:8080/v1" + assert config.stored["audio.tts.openai.api_key"] == "hk_issued_for_edge_api" + + +def test_unset_tts_env_leaves_the_persisted_voice_alone() -> None: + """An Enterprise box running the sovereign `voice` profile configures Open + WebUI's text-to-speech itself. An unset variable must never clobber that + back to the gateway.""" + config = FakeConfig({"audio.tts.engine": "openai", "audio.tts.model": "sovereign-tts", "audio.tts.voice": "daniel"}) + applied = reconcile(config, {"AUDIO_TTS_ENGINE": " "}) + assert applied == {}, applied + assert config.upsert_calls == 0 + assert config.stored["audio.tts.model"] == "sovereign-tts" + assert config.stored["audio.tts.voice"] == "daniel" + + +def test_compose_routes_chat_read_aloud_through_the_gateway() -> None: + """The reconcile only helps if compose names the values. Asserted against + the file because the whole defect (#997) was an unset variable set letting + upstream's own defaults win by omission, and the voice default has to name + a voice the provider actually has (#996).""" + compose = ( + Path(__file__).resolve().parents[1] / "deploy" / "docker" / "docker-compose.yml" + ).read_text(encoding="utf-8") + for line in ( + 'AUDIO_TTS_ENGINE: "openai"', + 'AUDIO_TTS_OPENAI_API_BASE_URL: "http://edge-api:8080/v1"', + "AUDIO_TTS_OPENAI_API_KEY: ${OWUI_SHIM_KEY:-}", + "AUDIO_TTS_MODEL: ${OWUI_TTS_ALIAS:-hive-tts}", + "AUDIO_TTS_VOICE: ${OWUI_TTS_VOICE:-autumn}", + ): + assert line in compose, f"docker-compose.yml must set {line}" + + +def test_gateway_serves_the_voice_roster_the_ui_offers() -> None: + """Issue #996 via the UI. Open WebUI's get_available_voices, for an openai + engine on a non-OpenAI base URL, fetches GET {base_url}/audio/voices and + falls back to Open WebUI's hardcoded alloy-style list when that fetch + fails. edge-api now serves that endpoint with the provider's real roster, + so the Settings > Audio dropdowns can only offer voices hive-tts accepts. + Asserted against main.go because a dropped registration line would send + every dropdown silently back to the alloy fallback.""" + main_go = ( + Path(__file__).resolve().parents[1] + / "apps" / "edge-api" / "cmd" / "server" / "main.go" + ).read_text(encoding="utf-8") + assert 'mux.Handle("/v1/audio/voices", audio.VoicesHandler())' in main_go, ( + "edge-api must serve GET /v1/audio/voices or Open WebUI's voice " + "dropdowns fall back to OpenAI's alloy-style list (#996)" + ) + + def test_compose_routes_chat_transcription_through_the_gateway() -> None: """The reconcile only helps if compose names the values. Asserted against the file because the whole defect was an unset variable letting upstream's @@ -402,14 +517,19 @@ def test_reconciled_keys_are_loggable_without_the_secret() -> None: "rag.openai.api_key": "hk_secret", "audio.stt.model": "hive-stt", "audio.stt.openai.api_key": "hk_secret", + "audio.tts.model": "hive-tts", + "audio.tts.openai.api_key": "hk_secret", } ) assert ALIAS in summary, summary assert "hk_secret" not in summary, summary assert "rag.openai.api_key" in summary, summary - # Same for the transcription pair: the alias is the signal, the key is not. + # Same for the transcription and read-aloud pairs: the alias is the + # signal, the key is not. assert "hive-stt" in summary, summary assert "audio.stt.openai.api_key" in summary, summary + assert "hive-tts" in summary, summary + assert "audio.tts.openai.api_key" in summary, summary def main() -> None: From 136f322809e8e08fc228b8416fba3e15fbe282dd Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Sun, 23 Aug 2026 09:25:17 -0400 Subject: [PATCH 2/3] fix: wire read-aloud TTS to the gateway From 7a5093720009f5ec08764352949c66a0688eeb37 Mon Sep 17 00:00:00 2001 From: Sakib Sadman Shajib Date: Sun, 23 Aug 2026 09:34:07 -0400 Subject: [PATCH 3/3] docs: count the TTS keys in the reconcile module docstring --- deploy/docker/owui-patches/hive_rag_env_config.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/deploy/docker/owui-patches/hive_rag_env_config.py b/deploy/docker/owui-patches/hive_rag_env_config.py index df84229f3..9d2b697ca 100644 --- a/deploy/docker/owui-patches/hive_rag_env_config.py +++ b/deploy/docker/owui-patches/hive_rag_env_config.py @@ -17,10 +17,12 @@ apply_rag_env_config_patch.py. The environment wins for the four keys that point Open WebUI's embedder at the Hive gateway, the four that point its speech-to-text at the same gateway (the Bengali dictation fix, see their entry -below), plus `ui.enable_login_form` (same mechanism, different symptom) and the -three product-surface feature flags below (#772), and for nothing else: an -administrator's other Open WebUI settings still persist normally, which is -why this is a per-key reconcile rather than `ENABLE_PERSISTENT_CONFIG=false`. +below), the five that point its text-to-speech there too (#997, see their +entry below), plus `ui.enable_login_form` (same mechanism, different symptom) +and the three product-surface feature flags below (#772), and for nothing +else: an administrator's other Open WebUI settings still persist normally, +which is why this is a per-key reconcile rather than +`ENABLE_PERSISTENT_CONFIG=false`. The embedding model itself is never hardcoded here. It comes from `RAG_EMBEDDING_MODEL` (compose derives it from `OWUI_RAG_EMBEDDING_ALIAS`), so