/
/
/
1"""Setup flow for the OpenAI Compatible provider."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING
6
7from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
8from music_assistant_models.enums import ConfigEntryType
9from music_assistant_models.errors import LoginFailed, MusicAssistantError
10
11from music_assistant.models.setup_flow import SetupFlowError
12from music_assistant.providers.openai_compatible.constants import (
13 CONF_API_KEY,
14 CONF_BASE_URL,
15 CONF_SERVICE,
16 DEFAULT_SERVICE,
17 MODELS_REQUEST_TIMEOUT,
18 SERVICE_BASE_URLS,
19 SERVICE_CUSTOM,
20)
21from music_assistant.providers.openai_compatible.helpers import list_models
22
23if TYPE_CHECKING:
24 from music_assistant_models.config_entries import ConfigValueType
25
26 from music_assistant.models.setup_flow import SetupSession
27
28CONF_CLEAR_API_KEY = "clear_api_key"
29
30
31async def run_setup(session: SetupSession) -> None:
32 """Run the setup flow: pick a service, collect its details and create the provider."""
33 setup_data = dict(session.context.setup_data)
34 service = await _select_service(session, setup_data)
35 errors: dict[str, str] | None = None
36 while True:
37 submitted = await session.form(
38 _connection_entries(setup_data, service),
39 step_id="connection",
40 errors=errors,
41 last_step=True,
42 )
43 if submitted.pop(CONF_CLEAR_API_KEY, False):
44 setup_data[CONF_API_KEY] = ""
45 submitted.pop(CONF_API_KEY, None)
46 elif not submitted.get(CONF_API_KEY):
47 # the stored key is never sent to the client, so an empty field on a
48 # reconfigure means "keep the current one"; clearing it is explicit
49 submitted.pop(CONF_API_KEY, None)
50 setup_data.update(submitted)
51 setup_data[CONF_SERVICE] = service
52 if error := await _probe(session, setup_data):
53 errors = {"base": error}
54 continue
55 try:
56 await session.finish(setup_data)
57 return
58 except SetupFlowError as err:
59 errors = {"base": err.translation_key or str(err)}
60
61
62async def _select_service(session: SetupSession, setup_data: dict[str, ConfigValueType]) -> str:
63 """Return the service the user picked for this instance."""
64 submitted = await session.form(
65 [
66 ConfigEntry(
67 key=CONF_SERVICE,
68 type=ConfigEntryType.STRING,
69 required=True,
70 default_value=DEFAULT_SERVICE,
71 value=str(setup_data.get(CONF_SERVICE) or DEFAULT_SERVICE),
72 options=[
73 ConfigValueOption(service) for service in (*SERVICE_BASE_URLS, SERVICE_CUSTOM)
74 ],
75 )
76 ],
77 step_id="service",
78 )
79 return str(submitted[CONF_SERVICE])
80
81
82def _connection_entries(setup_data: dict[str, ConfigValueType], service: str) -> list[ConfigEntry]:
83 """Return the entries collecting the endpoint details for the chosen service."""
84 stored_service = str(setup_data.get(CONF_SERVICE) or "")
85 if service != stored_service and service in SERVICE_BASE_URLS:
86 # switching service, so the address that belongs to it wins over the stored one
87 base_url = SERVICE_BASE_URLS[service]
88 else:
89 base_url = str(setup_data.get(CONF_BASE_URL) or SERVICE_BASE_URLS.get(service, ""))
90 entries = [
91 ConfigEntry(
92 key=CONF_BASE_URL,
93 type=ConfigEntryType.STRING,
94 required=True,
95 value=base_url,
96 ),
97 ConfigEntry(key=CONF_API_KEY, type=ConfigEntryType.SECURE_STRING, required=False),
98 ]
99 if setup_data.get(CONF_API_KEY):
100 entries.append(
101 ConfigEntry(
102 key=CONF_CLEAR_API_KEY,
103 type=ConfigEntryType.BOOLEAN,
104 required=False,
105 default_value=False,
106 )
107 )
108 return entries
109
110
111async def _probe(session: SetupSession, setup_data: dict[str, ConfigValueType]) -> str | None:
112 """Return the error slug for an endpoint that cannot be used, None when it can."""
113 base_url = str(setup_data.get(CONF_BASE_URL) or "").strip().rstrip("/")
114 api_key = str(setup_data.get(CONF_API_KEY) or "").strip()
115 try:
116 # a mistyped address is the easiest thing to get wrong here, so anything short
117 # of a real answer from the listing endpoint stops the setup
118 await list_models(session.mass, base_url, api_key, MODELS_REQUEST_TIMEOUT)
119 except LoginFailed:
120 return "invalid_api_key"
121 except MusicAssistantError:
122 return "cannot_connect"
123 return None
124