/
/
/
1"""Tests for the OpenAI Compatible plugin provider."""
2
3import json
4from types import SimpleNamespace
5from typing import TYPE_CHECKING, Any, cast
6from unittest.mock import AsyncMock, MagicMock, patch
7
8import pytest
9from aiohttp import ClientError
10from music_assistant_models.errors import (
11 InvalidDataError,
12 LoginFailed,
13 ProviderUnavailableError,
14 RateLimited,
15 SetupFailedError,
16 UnsupportedFeaturedException,
17)
18
19from music_assistant.providers.openai_compatible import (
20 SUPPORTED_FEATURES,
21 OpenAICompatibleProvider,
22 helpers,
23 setup_flow,
24)
25from music_assistant.providers.openai_compatible.constants import (
26 CONF_MODELS,
27 SERVICE_BASE_URLS,
28 SERVICE_CUSTOM,
29)
30
31if TYPE_CHECKING:
32 from music_assistant.models.setup_flow import SetupSession
33
34BASE_URL = "https://api.example.com/v1"
35INSTANCE_ID = "openai_compatible--test_instance"
36
37# a minimal valid chat completion, for tests that only care about the request side
38_CHAT_PAYLOAD = {"choices": [{"message": {"content": "42"}}]}
39
40
41@pytest.fixture
42def mass() -> AsyncMock:
43 """Return a mocked MusicAssistant instance with a mockable HTTP session."""
44 ma = AsyncMock()
45 ma.http_session = MagicMock()
46 return ma
47
48
49@pytest.fixture
50def provider(mass: AsyncMock) -> OpenAICompatibleProvider:
51 """Return an OpenAICompatibleProvider with mocked dependencies and no models selected."""
52 manifest = MagicMock()
53 manifest.domain = "openai_compatible"
54 config = MagicMock()
55 config.values = {}
56 config.instance_id = INSTANCE_ID
57 config.get_value = MagicMock(return_value="GLOBAL")
58 prov = OpenAICompatibleProvider(mass, manifest, config, SUPPORTED_FEATURES)
59 prov._base_url = BASE_URL
60 prov._api_key = ""
61 return prov
62
63
64async def test_list_models_returns_sorted_ids(mass: AsyncMock) -> None:
65 """The discovered model ids come back sorted."""
66 mass.http_session.request = MagicMock(
67 return_value=_response_cm(
68 response=_json_response(200, {"data": [{"id": "c"}, {"id": "a"}, {"id": "b"}]})
69 )
70 )
71
72 assert await helpers.list_models(mass, BASE_URL, "", timeout=10) == ["a", "b", "c"]
73
74
75async def test_list_models_skips_entries_without_a_usable_id(mass: AsyncMock) -> None:
76 """Entries missing an id, or with a blank/non-string one, are dropped rather than raising."""
77 mass.http_session.request = MagicMock(
78 return_value=_response_cm(
79 response=_json_response(
80 200,
81 {
82 "data": [
83 {"id": "good-model"},
84 {"id": ""},
85 {"id": 123},
86 {"no_id": "x"},
87 "not-a-dict",
88 ]
89 },
90 )
91 )
92 )
93
94 assert await helpers.list_models(mass, BASE_URL, "", timeout=10) == ["good-model"]
95
96
97async def test_list_models_raises_on_an_http_error(mass: AsyncMock) -> None:
98 """A 404 usually means a wrong address, so it must not pass as "nothing to offer"."""
99 mass.http_session.request = MagicMock(
100 return_value=_response_cm(response=_json_response(404, {}))
101 )
102
103 with pytest.raises(InvalidDataError):
104 await helpers.list_models(mass, BASE_URL, "", timeout=10)
105
106
107async def test_list_models_returns_empty_list_when_the_answer_carries_no_list(
108 mass: AsyncMock,
109) -> None:
110 """An endpoint answering at the right address without a list has nothing to offer."""
111 mass.http_session.request = MagicMock(
112 return_value=_response_cm(response=_json_response(200, {}))
113 )
114
115 assert await helpers.list_models(mass, BASE_URL, "", timeout=10) == []
116
117
118async def test_list_models_raises_login_failed_on_401(mass: AsyncMock) -> None:
119 """Bad credentials must be reported, not swallowed into an empty list."""
120 mass.http_session.request = MagicMock(
121 return_value=_response_cm(response=_json_response(401, {}))
122 )
123
124 with pytest.raises(LoginFailed):
125 await helpers.list_models(mass, BASE_URL, "bad-key", timeout=10)
126
127
128async def test_login_failed_message_includes_the_upstream_error_detail(mass: AsyncMock) -> None:
129 """The service's own error message reaches the user, not just its status code."""
130 body = {"error": {"message": "Incorrect API key provided"}}
131 mass.http_session.request = MagicMock(
132 return_value=_response_cm(response=_json_response(401, body))
133 )
134
135 with pytest.raises(LoginFailed, match="Incorrect API key provided"):
136 await helpers.list_models(mass, BASE_URL, "bad-key", timeout=10)
137
138
139async def test_list_models_raises_provider_unavailable_on_connection_error(
140 mass: AsyncMock,
141) -> None:
142 """An unreachable host must be reported, not swallowed into an empty list."""
143 mass.http_session.request = MagicMock(
144 return_value=_response_cm(exc=ClientError("connection refused"))
145 )
146
147 with pytest.raises(ProviderUnavailableError):
148 await helpers.list_models(mass, BASE_URL, "", timeout=10)
149
150
151async def test_list_models_raises_provider_unavailable_on_timeout(mass: AsyncMock) -> None:
152 """A hung request must be reported, not swallowed into an empty list."""
153 mass.http_session.request = MagicMock(return_value=_response_cm(exc=TimeoutError()))
154
155 with pytest.raises(ProviderUnavailableError):
156 await helpers.list_models(mass, BASE_URL, "", timeout=10)
157
158
159async def test_chat_completion_returns_trimmed_content(mass: AsyncMock) -> None:
160 """The answer is returned with surrounding whitespace trimmed."""
161 mass.http_session.request = MagicMock(
162 return_value=_response_cm(
163 response=_json_response(
164 200, {"choices": [{"message": {"content": " The answer is 42. "}}]}
165 )
166 )
167 )
168
169 result = await helpers.chat_completion(
170 mass, BASE_URL, "", model="my-model", prompt="What is the answer?", timeout=30
171 )
172
173 assert result == "The answer is 42."
174
175
176async def test_chat_completion_strips_leading_think_block(mass: AsyncMock) -> None:
177 """A reasoning model's <think> preamble is removed, leaving only the answer."""
178 content = "<think>Let me reason about this.</think>Paris is the capital of France."
179 mass.http_session.request = MagicMock(
180 return_value=_response_cm(
181 response=_json_response(200, {"choices": [{"message": {"content": content}}]})
182 )
183 )
184
185 result = await helpers.chat_completion(
186 mass, BASE_URL, "", model="my-model", prompt="capital of France?", timeout=30
187 )
188
189 assert result == "Paris is the capital of France."
190
191
192async def test_chat_completion_raises_on_think_only_content(mass: AsyncMock) -> None:
193 """A reply that is only a think block leaves no answer, which is an error."""
194 content = "<think>Still thinking about it...</think>"
195 mass.http_session.request = MagicMock(
196 return_value=_response_cm(
197 response=_json_response(200, {"choices": [{"message": {"content": content}}]})
198 )
199 )
200
201 with pytest.raises(InvalidDataError):
202 await helpers.chat_completion(mass, BASE_URL, "", model="my-model", prompt="hi", timeout=30)
203
204
205async def test_chat_completion_raises_on_blank_content(mass: AsyncMock) -> None:
206 """Whitespace-only content is treated the same as no answer at all."""
207 mass.http_session.request = MagicMock(
208 return_value=_response_cm(
209 response=_json_response(200, {"choices": [{"message": {"content": " "}}]})
210 )
211 )
212
213 with pytest.raises(InvalidDataError):
214 await helpers.chat_completion(mass, BASE_URL, "", model="my-model", prompt="hi", timeout=30)
215
216
217async def test_chat_completion_raises_when_choices_missing(mass: AsyncMock) -> None:
218 """A response without a choices list is malformed, not a valid empty answer."""
219 mass.http_session.request = MagicMock(
220 return_value=_response_cm(response=_json_response(200, {}))
221 )
222
223 with pytest.raises(InvalidDataError):
224 await helpers.chat_completion(mass, BASE_URL, "", model="my-model", prompt="hi", timeout=30)
225
226
227async def test_chat_completion_raises_rate_limited_with_retry_after(mass: AsyncMock) -> None:
228 """A 429 is raised as RateLimited, honouring a numeric Retry-After as the backoff time."""
229 mass.http_session.request = MagicMock(
230 return_value=_response_cm(response=_json_response(429, {}, retry_after="45"))
231 )
232
233 with pytest.raises(RateLimited) as excinfo:
234 await helpers.chat_completion(mass, BASE_URL, "", model="my-model", prompt="hi", timeout=30)
235
236 assert excinfo.value.backoff_time == 45
237
238
239async def test_chat_completion_request_body_has_no_max_tokens(mass: AsyncMock) -> None:
240 """The model and prompt are sent as a single user message, deliberately without max_tokens."""
241 mass.http_session.request = MagicMock(
242 return_value=_response_cm(response=_json_response(200, _CHAT_PAYLOAD))
243 )
244
245 await helpers.chat_completion(
246 mass, BASE_URL, "", model="my-model", prompt="What is up?", timeout=30
247 )
248
249 sent = mass.http_session.request.call_args.kwargs["json"]
250 assert sent["model"] == "my-model"
251 assert sent["messages"] == [{"role": "user", "content": "What is up?"}]
252 assert "max_tokens" not in sent
253
254
255def test_error_detail_falls_back_to_raw_text_for_non_json_body() -> None:
256 """A body that is not JSON at all still yields something readable."""
257 assert helpers._error_detail(b"Service Unavailable") == "Service Unavailable"
258
259
260def test_error_detail_returns_placeholder_for_empty_body() -> None:
261 """An empty response body must not surface as an empty, unhelpful error message."""
262 assert helpers._error_detail(b"") == "no details provided"
263
264
265def test_error_detail_truncates_overlong_messages() -> None:
266 """An unbounded upstream message is capped so it cannot bloat the raised error."""
267 long_message = "x" * (helpers.MAX_ERROR_DETAIL_CHARS + 50)
268 body = json.dumps({"error": {"message": long_message}}).encode()
269
270 result = helpers._error_detail(body)
271
272 assert result == long_message[: helpers.MAX_ERROR_DETAIL_CHARS]
273
274
275async def test_handle_async_init_strips_trailing_slash_from_base_url() -> None:
276 """Every request path is built by appending to the stored base URL."""
277 provider = _provider_for_setup({"base_url": "https://api.example.com/v1/"})
278
279 await provider.handle_async_init()
280
281 assert provider._base_url == "https://api.example.com/v1"
282
283
284async def test_handle_async_init_strips_whitespace_from_base_url() -> None:
285 """Whitespace around a stored base URL is stripped."""
286 provider = _provider_for_setup({"base_url": " https://api.example.com/v1 "})
287
288 await provider.handle_async_init()
289
290 assert provider._base_url == "https://api.example.com/v1"
291
292
293async def test_handle_async_init_raises_when_base_url_missing() -> None:
294 """A never-configured base URL cannot be initialized into a working provider."""
295 provider = _provider_for_setup({})
296
297 with pytest.raises(SetupFailedError):
298 await provider.handle_async_init()
299
300
301async def test_handle_async_init_raises_when_base_url_blank() -> None:
302 """A blank base URL is treated the same as a missing one."""
303 provider = _provider_for_setup({"base_url": " "})
304
305 with pytest.raises(SetupFailedError):
306 await provider.handle_async_init()
307
308
309async def test_handle_async_init_defaults_missing_api_key_to_empty_string() -> None:
310 """No stored key means the no-auth local-server case, not None or the text "None"."""
311 provider = _provider_for_setup({"base_url": BASE_URL})
312
313 await provider.handle_async_init()
314
315 assert provider._api_key == ""
316
317
318async def test_handle_async_init_keeps_the_stored_api_key() -> None:
319 """A stored key is used once decrypted, with surrounding whitespace removed."""
320 provider = _provider_for_setup({"base_url": BASE_URL, "api_key": " sk-secret "})
321
322 await provider.handle_async_init()
323
324 assert provider._api_key == "sk-secret"
325
326
327async def test_probe_returns_invalid_api_key_slug_on_login_failed() -> None:
328 """A rejected key must map to the frontend's invalid_api_key error slug."""
329 session = cast("SetupSession", SimpleNamespace(mass=MagicMock()))
330 with patch.object(setup_flow, "list_models", AsyncMock(side_effect=LoginFailed("bad key"))):
331 result = await setup_flow._probe(session, {"base_url": BASE_URL, "api_key": "bad"})
332
333 assert result == "invalid_api_key"
334
335
336async def test_probe_returns_cannot_connect_slug_on_provider_unavailable() -> None:
337 """An unreachable host must map to the frontend's cannot_connect error slug."""
338 session = cast("SetupSession", SimpleNamespace(mass=MagicMock()))
339 with patch.object(
340 setup_flow, "list_models", AsyncMock(side_effect=ProviderUnavailableError("down"))
341 ):
342 result = await setup_flow._probe(session, {"base_url": BASE_URL})
343
344 assert result == "cannot_connect"
345
346
347async def test_probe_succeeds_when_endpoint_has_no_model_listing() -> None:
348 """An empty model listing is a valid, working endpoint, not a probe failure."""
349 session = cast("SetupSession", SimpleNamespace(mass=MagicMock()))
350 with patch.object(setup_flow, "list_models", AsyncMock(return_value=[])):
351 result = await setup_flow._probe(session, {"base_url": BASE_URL})
352
353 assert result is None
354
355
356async def test_probe_reports_an_address_that_answers_with_an_error() -> None:
357 """A wrong address must be caught during setup, not on the first AI query."""
358 session = cast("SetupSession", SimpleNamespace(mass=MagicMock()))
359 with patch.object(setup_flow, "list_models", AsyncMock(side_effect=InvalidDataError("404"))):
360 result = await setup_flow._probe(session, {"base_url": BASE_URL})
361
362 assert result == "cannot_connect"
363
364
365async def test_run_setup_prefills_the_address_of_the_chosen_service() -> None:
366 """Picking a known service fills in its address so it need not be looked up."""
367 session = _FakeSession(setup_data={}, submissions=[{"service": "groq"}, {}])
368
369 await _run_setup(session)
370
371 assert _entry_value(session, "base_url") == SERVICE_BASE_URLS["groq"]
372
373
374async def test_run_setup_leaves_the_address_empty_for_a_custom_service() -> None:
375 """There is nothing to prefill for a service we do not know the address of."""
376 session = _FakeSession(setup_data={}, submissions=[{"service": SERVICE_CUSTOM}, {}])
377
378 await _run_setup(session)
379
380 assert _entry_value(session, "base_url") == ""
381
382
383async def test_run_setup_prefers_the_new_services_address_when_switching() -> None:
384 """Moving an instance to another service must not keep the previous address."""
385 session = _FakeSession(
386 setup_data={"service": "openai", "base_url": SERVICE_BASE_URLS["openai"]},
387 submissions=[{"service": "ollama"}, {}],
388 )
389
390 await _run_setup(session)
391
392 assert _entry_value(session, "base_url") == SERVICE_BASE_URLS["ollama"]
393
394
395async def test_run_setup_keeps_an_edited_address_for_the_same_service() -> None:
396 """A hand-corrected address survives a reconfigure that keeps the same service."""
397 edited = "http://nas.local:11434/v1"
398 session = _FakeSession(
399 setup_data={"service": "ollama", "base_url": edited},
400 submissions=[{"service": "ollama"}, {}],
401 )
402
403 await _run_setup(session)
404
405 assert _entry_value(session, "base_url") == edited
406
407
408async def test_run_setup_keeps_the_stored_api_key_when_the_field_is_left_empty() -> None:
409 """The stored key is never sent to the client, so an empty field must not wipe it."""
410 session = _FakeSession(
411 setup_data={"service": "openai", "base_url": BASE_URL, "api_key": "sk-existing"},
412 submissions=[{"service": "openai"}, {"base_url": BASE_URL, "api_key": ""}],
413 )
414
415 await _run_setup(session)
416
417 assert session.finished == {
418 "service": "openai",
419 "base_url": BASE_URL,
420 "api_key": "sk-existing",
421 }
422
423
424async def test_run_setup_replaces_the_stored_api_key_when_a_new_one_is_given() -> None:
425 """A freshly typed key overwrites the stored one."""
426 session = _FakeSession(
427 setup_data={"service": "openai", "base_url": BASE_URL, "api_key": "sk-existing"},
428 submissions=[{"service": "openai"}, {"base_url": BASE_URL, "api_key": "sk-new"}],
429 )
430
431 await _run_setup(session)
432
433 assert session.finished == {"service": "openai", "base_url": BASE_URL, "api_key": "sk-new"}
434
435
436async def test_run_setup_clears_the_stored_api_key_on_request() -> None:
437 """Ticking the clear option removes the key, for a move to a keyless server."""
438 session = _FakeSession(
439 setup_data={"service": "openai", "base_url": BASE_URL, "api_key": "sk-existing"},
440 submissions=[
441 {"service": "ollama"},
442 {"base_url": BASE_URL, "api_key": "", "clear_api_key": True},
443 ],
444 )
445
446 await _run_setup(session)
447
448 assert session.finished == {"service": "ollama", "base_url": BASE_URL, "api_key": ""}
449
450
451async def test_run_setup_offers_the_clear_option_only_when_a_key_is_stored() -> None:
452 """A first-time setup has nothing to clear, so the option stays out of the form."""
453 fresh = _FakeSession(setup_data={}, submissions=[{"service": "openai"}, {}])
454 stored = _FakeSession(
455 setup_data={"service": "openai", "base_url": BASE_URL, "api_key": "sk-existing"},
456 submissions=[{"service": "openai"}, {"base_url": BASE_URL, "api_key": ""}],
457 )
458
459 await _run_setup(fresh)
460 await _run_setup(stored)
461
462 assert setup_flow.CONF_CLEAR_API_KEY not in [entry.key for entry in fresh.entries]
463 assert setup_flow.CONF_CLEAR_API_KEY in [entry.key for entry in stored.entries]
464
465
466async def test_get_ai_engines_returns_one_engine_per_configured_model(
467 provider: OpenAICompatibleProvider,
468) -> None:
469 """Each configured model becomes its own engine, keyed by its bare model id."""
470 _configure_models(provider, ["model-a", "model-b"])
471
472 engines = await provider.get_ai_engines()
473
474 assert [engine.id for engine in engines] == ["model-a", "model-b"]
475 assert all(engine.id == engine.name for engine in engines)
476 assert [engine.uid for engine in engines] == [
477 f"{INSTANCE_ID}/model-a",
478 f"{INSTANCE_ID}/model-b",
479 ]
480
481
482async def test_get_ai_engines_empty_when_no_models_configured(
483 provider: OpenAICompatibleProvider,
484) -> None:
485 """No selection yields no engines rather than a default model."""
486 _configure_models(provider, [])
487
488 assert await provider.get_ai_engines() == []
489
490
491async def test_get_ai_engines_empty_when_config_value_absent(
492 provider: OpenAICompatibleProvider,
493) -> None:
494 """A provider whose options form was never saved yields no engines instead of raising."""
495 provider.config.get_value = MagicMock( # type: ignore[method-assign]
496 side_effect=lambda _key, default=None: default
497 )
498
499 assert await provider.get_ai_engines() == []
500
501
502async def test_get_ai_engines_ignores_non_string_junk(
503 provider: OpenAICompatibleProvider,
504) -> None:
505 """Malformed stored entries are dropped instead of raising or producing bad engines."""
506 _configure_models(provider, ["model-a", 123, None, " ", ""])
507
508 engines = await provider.get_ai_engines()
509
510 assert [engine.id for engine in engines] == ["model-a"]
511
512
513async def test_ai_query_uses_the_given_engine_id(
514 provider: OpenAICompatibleProvider, mass: AsyncMock
515) -> None:
516 """An explicit engine id is the model sent in the request, regardless of the selection."""
517 _configure_models(provider, ["model-a", "model-b"])
518 mass.http_session.request = MagicMock(
519 return_value=_response_cm(response=_json_response(200, _CHAT_PAYLOAD))
520 )
521
522 await provider.ai_query("hello", engine_id="model-b")
523
524 assert mass.http_session.request.call_args.kwargs["json"]["model"] == "model-b"
525
526
527async def test_ai_query_falls_back_to_first_configured_model(
528 provider: OpenAICompatibleProvider, mass: AsyncMock
529) -> None:
530 """No engine id falls back to the first configured model, in sorted order."""
531 _configure_models(provider, ["model-b", "model-a"])
532 mass.http_session.request = MagicMock(
533 return_value=_response_cm(response=_json_response(200, _CHAT_PAYLOAD))
534 )
535
536 await provider.ai_query("hello", engine_id=None)
537
538 assert mass.http_session.request.call_args.kwargs["json"]["model"] == "model-a"
539
540
541async def test_ai_query_raises_when_no_models_configured(
542 provider: OpenAICompatibleProvider,
543) -> None:
544 """No selected model at all is a hard failure, not a silent default."""
545 _configure_models(provider, [])
546
547 with pytest.raises(UnsupportedFeaturedException):
548 await provider.ai_query("hello")
549
550
551async def test_get_config_entries_models_option_union_survives_vanished_selection(
552 provider: OpenAICompatibleProvider, mass: AsyncMock
553) -> None:
554 """A selected model missing from the endpoint's current listing is still offered."""
555 _configure_models(provider, ["stale-model"])
556 mass.http_session.request = MagicMock(
557 return_value=_response_cm(response=_json_response(200, {"data": [{"id": "fresh-model"}]}))
558 )
559
560 entries = await provider.get_config_entries()
561
562 models_entry = next(entry for entry in entries if entry.key == CONF_MODELS)
563 assert models_entry.multi_value is True
564 assert {option.value for option in models_entry.options} == {"stale-model", "fresh-model"}
565
566
567async def test_get_config_entries_options_empty_when_discovery_fails(
568 provider: OpenAICompatibleProvider, mass: AsyncMock
569) -> None:
570 """A failed listing leaves no options, which is what lets the frontend accept free text."""
571 _configure_models(provider, [])
572 mass.http_session.request = MagicMock(
573 return_value=_response_cm(exc=ClientError("connection refused"))
574 )
575
576 entries = await provider.get_config_entries()
577
578 models_entry = next(entry for entry in entries if entry.key == CONF_MODELS)
579 assert models_entry.options == []
580
581
582async def test_get_config_entries_survives_an_endpoint_without_a_listing(
583 provider: OpenAICompatibleProvider, mass: AsyncMock
584) -> None:
585 """Setup rejects a listing error, but an already-configured provider must still load."""
586 _configure_models(provider, ["typed-by-hand"])
587 mass.http_session.request = MagicMock(
588 return_value=_response_cm(response=_json_response(404, {}))
589 )
590
591 entries = await provider.get_config_entries()
592
593 models_entry = next(entry for entry in entries if entry.key == CONF_MODELS)
594 assert [option.value for option in models_entry.options] == ["typed-by-hand"]
595
596
597def _response_cm(*, response: MagicMock | None = None, exc: Exception | None = None) -> MagicMock:
598 """Build a fake async context manager mimicking aiohttp's session.request()."""
599 cm = MagicMock()
600 cm.__aenter__ = AsyncMock(return_value=response, side_effect=exc)
601 cm.__aexit__ = AsyncMock(return_value=False)
602 return cm
603
604
605def _json_response(status: int, payload: dict[str, Any], *, retry_after: str = "") -> MagicMock:
606 """Build a fake aiohttp response exposing the attributes _request reads."""
607 response = MagicMock()
608 response.status = status
609 response.headers = {"Retry-After": retry_after} if retry_after else {}
610 response.read = AsyncMock(return_value=json.dumps(payload).encode())
611 return response
612
613
614def _configure_models(provider: OpenAICompatibleProvider, models: list[Any]) -> None:
615 """Make the provider report the given stored value for the models config entry."""
616 provider.config.get_value = MagicMock( # type: ignore[method-assign]
617 side_effect=lambda key, default=None: models if key == CONF_MODELS else default
618 )
619
620
621def _provider_for_setup(setup_data: dict[str, Any]) -> OpenAICompatibleProvider:
622 """Build a provider backed by the given setup_data, with nothing stored in its options."""
623 mass = AsyncMock()
624 mass.http_session = MagicMock()
625 mass.config = MagicMock()
626 mass.config.get = MagicMock(return_value=dict(setup_data))
627 mass.config.decrypt_string = MagicMock(side_effect=lambda value: value)
628 manifest = MagicMock()
629 manifest.domain = "openai_compatible"
630 config = MagicMock()
631 config.values = {}
632 config.instance_id = INSTANCE_ID
633 config.get_value = MagicMock(return_value="GLOBAL")
634 provider = OpenAICompatibleProvider(mass, manifest, config, SUPPORTED_FEATURES)
635 # the log level is read during construction; past that nothing is stored in the options
636 config.get_value = MagicMock(side_effect=lambda _key, default=None: default)
637 return provider
638
639
640def _entry_value(session: _FakeSession, key: str) -> Any:
641 """Return the prefilled value of an entry on the last form the flow rendered."""
642 return next(entry.value for entry in session.entries if entry.key == key)
643
644
645async def _run_setup(session: _FakeSession) -> None:
646 """Drive the setup flow against a fake session, with a reachable endpoint."""
647 with patch.object(setup_flow, "list_models", AsyncMock(return_value=[])):
648 await setup_flow.run_setup(cast("SetupSession", session))
649
650
651class _FakeSession:
652 """Minimal stand-in for SetupSession that replays scripted form submissions."""
653
654 def __init__(self, setup_data: dict[str, Any], submissions: list[dict[str, Any]]) -> None:
655 """Initialize with the stored setup_data and the submissions to replay in order."""
656 self.context = SimpleNamespace(setup_data=setup_data)
657 self.mass = MagicMock()
658 self.entries: list[Any] = []
659 self.finished: dict[str, Any] | None = None
660 self._submissions = list(submissions)
661
662 async def form(self, entries: list[Any], **kwargs: Any) -> dict[str, Any]:
663 """Record the rendered entries and return the next scripted submission."""
664 self.entries = entries
665 return dict(self._submissions.pop(0))
666
667 async def finish(self, values: dict[str, Any]) -> dict[str, str]:
668 """Record the values the flow decided to persist."""
669 self.finished = dict(values)
670 return {"instance_id": "openai_compatible--new"}
671