/
/
/
1"""Setup flow for the OpenAI Text-to-speech provider."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING
6
7from aiohttp import ClientError, ClientTimeout
8from music_assistant_models.config_entries import ConfigEntry
9from music_assistant_models.enums import ConfigEntryType
10
11from music_assistant.models.setup_flow import SetupFlowError
12
13from . import (
14 CONF_API_KEY,
15 CONF_BASE_URL,
16 CONF_MODEL,
17 DEFAULT_BASE_URL,
18 DEFAULT_MODEL,
19 DEFAULT_VOICES,
20 RESPONSE_FORMAT,
21 fetch_backend_voices,
22)
23
24if TYPE_CHECKING:
25 from music_assistant_models.config_entries import ConfigValueType
26
27 from music_assistant.models.setup_flow import SetupSession
28
29# short throwaway phrase, only rendered to verify the endpoint and credentials
30VALIDATION_MESSAGE = "Hello"
31# the user is waiting on the form, so do not sit on an unresponsive endpoint
32VALIDATION_TIMEOUT = ClientTimeout(total=30)
33
34
35async def run_setup(session: SetupSession) -> None:
36 """
37 Run the OpenAI Text-to-speech setup flow.
38
39 Collects the API endpoint, the (optional) API key and the model to use, then verifies
40 them by rendering a short phrase on the speech endpoint.
41
42 :param session: The setup session driving the flow.
43 """
44 setup_data = dict(session.context.setup_data)
45 errors: dict[str, str] | None = None
46 while True:
47 values = await session.form(
48 [
49 ConfigEntry(
50 key=CONF_BASE_URL,
51 type=ConfigEntryType.STRING,
52 required=True,
53 default_value=DEFAULT_BASE_URL,
54 value=setup_data.get(CONF_BASE_URL),
55 ),
56 ConfigEntry(
57 key=CONF_API_KEY,
58 type=ConfigEntryType.SECURE_STRING,
59 required=False,
60 value=setup_data.get(CONF_API_KEY),
61 ),
62 # free text: self-hosted backends serve arbitrary model names
63 ConfigEntry(
64 key=CONF_MODEL,
65 type=ConfigEntryType.STRING,
66 required=True,
67 default_value=DEFAULT_MODEL,
68 value=setup_data.get(CONF_MODEL),
69 ),
70 ],
71 step_id="user",
72 errors=errors,
73 last_step=True,
74 )
75 setup_data.update(values)
76 base_url = str(values[CONF_BASE_URL]).strip().rstrip("/")
77 api_key = str(values.get(CONF_API_KEY) or "").strip()
78 model = str(values[CONF_MODEL]).strip()
79
80 try:
81 await _validate_credentials(session, base_url, api_key, model)
82 except SetupFlowError as err:
83 errors = {"base": err.translation_key or str(err)}
84 continue
85
86 finish_values: dict[str, ConfigValueType] = {
87 CONF_BASE_URL: base_url,
88 CONF_MODEL: model,
89 }
90 if api_key:
91 finish_values[CONF_API_KEY] = api_key
92 try:
93 await session.finish(finish_values)
94 return
95 except SetupFlowError as err:
96 errors = {"base": err.translation_key or str(err)}
97
98
99async def _validate_credentials(
100 session: SetupSession, base_url: str, api_key: str, model: str
101) -> None:
102 """
103 Render a short throwaway phrase to verify the endpoint accepts the given credentials.
104
105 :param session: The setup session driving the flow.
106 :param base_url: The API endpoint to validate, without trailing slash.
107 :param api_key: The API key to authenticate with, empty for backends without auth.
108 :param model: The speech model to validate.
109 """
110 headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
111 # self-hosted backends serve their own voice names, so a standard voice may not exist
112 advertised = await fetch_backend_voices(session.mass.http_session, base_url, api_key)
113 payload = {
114 "model": model,
115 "voice": advertised[0] if advertised else DEFAULT_VOICES[0],
116 "input": VALIDATION_MESSAGE,
117 "response_format": RESPONSE_FORMAT,
118 }
119 try:
120 async with session.mass.http_session.post(
121 f"{base_url}/audio/speech",
122 headers=headers,
123 json=payload,
124 timeout=VALIDATION_TIMEOUT,
125 ) as response:
126 if response.status in (401, 403):
127 raise SetupFlowError("Authentication failed", "invalid_auth")
128 if response.status == 404:
129 raise SetupFlowError("Speech endpoint not found", "endpoint_not_found")
130 if response.status != 200:
131 detail = (await response.text())[:200]
132 raise SetupFlowError(f"Unexpected response: {detail}", "cannot_connect")
133 await response.read()
134 except (ClientError, TimeoutError) as err:
135 raise SetupFlowError(f"Connection failed: {err}", "cannot_connect") from err
136