Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/edge-api/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions apps/edge-api/internal/audio/handler_voices.go
Original file line number Diff line number Diff line change
@@ -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})
}
}
52 changes: 52 additions & 0 deletions apps/edge-api/internal/audio/handler_voices_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 21 additions & 0 deletions deploy/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 28 additions & 5 deletions deploy/docker/owui-patches/hive_rag_env_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -66,6 +68,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
Expand Down Expand Up @@ -135,7 +151,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
Expand All @@ -150,6 +168,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",
),
)


Expand Down
47 changes: 47 additions & 0 deletions docs/proof/tts-readaloud/capture.log
Original file line number Diff line number Diff line change
@@ -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/.
122 changes: 121 additions & 1 deletion scripts/test_owui_rag_env_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down