/
/
/
1"""OpenAI Compatible plugin provider for Music Assistant."""
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, ProviderFeature
9from music_assistant_models.errors import (
10 MusicAssistantError,
11 SetupFailedError,
12 UnsupportedFeaturedException,
13)
14
15from music_assistant.models.plugin import AIEngine, PluginProvider
16
17from .constants import (
18 CHAT_REQUEST_TIMEOUT,
19 CONF_API_KEY,
20 CONF_BASE_URL,
21 CONF_MODELS,
22 MODELS_REQUEST_TIMEOUT,
23)
24from .helpers import chat_completion, list_models
25
26if TYPE_CHECKING:
27 from music_assistant_models.config_entries import ProviderConfig
28 from music_assistant_models.provider import ProviderManifest
29
30 from music_assistant.mass import MusicAssistant
31 from music_assistant.models import ProviderInstanceType
32
33
34SUPPORTED_FEATURES = {ProviderFeature.AI_QUERY}
35
36
37async def setup(
38 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
39) -> ProviderInstanceType:
40 """Initialize provider(instance) with given configuration."""
41 return OpenAICompatibleProvider(mass, manifest, config, SUPPORTED_FEATURES)
42
43
44class OpenAICompatibleProvider(PluginProvider):
45 """Exposes the models of an OpenAI-compatible API as selectable AI engines."""
46
47 _base_url: str
48 _api_key: str
49
50 async def handle_async_init(self) -> None:
51 """Handle async initialization of the provider."""
52 base_url = str(self.get_setup_value(CONF_BASE_URL) or "").strip().rstrip("/")
53 if not base_url:
54 msg = "No API base URL is configured"
55 raise SetupFailedError(msg)
56 self._base_url = base_url
57 api_key = self.get_setup_value(CONF_API_KEY)
58 self._api_key = api_key.strip() if isinstance(api_key, str) else ""
59
60 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
61 """Return the (options) config entries to configure this provider instance."""
62 selected = self._configured_models()
63 # the listing is the natural source for the options, but it is optional in the
64 # OpenAI API: with no options at all the frontend accepts freely typed names,
65 # and keeping the current selection in the list survives a model disappearing
66 models = sorted({*await self._discover_models(), *selected})
67 return (
68 ConfigEntry(
69 key=CONF_MODELS,
70 type=ConfigEntryType.STRING,
71 multi_value=True,
72 required=False,
73 default_value=[],
74 options=[ConfigValueOption(model, title=model) for model in models],
75 category="features",
76 ),
77 )
78
79 async def get_ai_engines(self) -> list[AIEngine]:
80 """Return the AI engines this plugin exposes."""
81 # the engine name carries no provider name: consumers label a picker option
82 # as "<provider name> | <engine name>" themselves
83 return [
84 AIEngine(id=model, name=model, provider=self) for model in self._configured_models()
85 ]
86
87 async def ai_query(self, query: str, engine_id: str | None = None) -> str:
88 """Handle an AI query."""
89 model = engine_id or next(iter(self._configured_models()), None)
90 if model is None:
91 msg = "No model is selected for this provider"
92 raise UnsupportedFeaturedException(msg)
93 return await chat_completion(
94 self.mass,
95 self._base_url,
96 self._api_key,
97 model=model,
98 prompt=query,
99 timeout=CHAT_REQUEST_TIMEOUT,
100 )
101
102 def _configured_models(self) -> list[str]:
103 """Return the models the user selected, in a stable order."""
104 value = self.get_config_value(CONF_MODELS)
105 if not isinstance(value, list):
106 return []
107 return sorted(
108 {model.strip() for model in value if isinstance(model, str) and model.strip()}
109 )
110
111 async def _discover_models(self) -> list[str]:
112 """Return the models the endpoint advertises, empty when it cannot be asked."""
113 try:
114 return await list_models(
115 self.mass, self._base_url, self._api_key, MODELS_REQUEST_TIMEOUT
116 )
117 except MusicAssistantError as err:
118 self.logger.warning("Could not retrieve the list of available models: %s", err)
119 return []
120