Skip to content

Commit 2b014b2

Browse files
clementb49claude
andcommitted
fix(models): address code review issues in dynamic model loading
- Fix type mismatch: store str(e) in _LAST_LOAD_ERROR instead of Exception - Add threading.Lock to protect _CACHE and _LAST_LOAD_ERROR from races - Remove import wx from presenter; view wraps callback with wx.CallAfter - Show error modal only when model list is empty (not on stale cache hit) - Remove bare except Exception around SetStatusText - Annotate _pending_model_account_id and account_id params as UUID Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1123ef0 commit 2b014b2

4 files changed

Lines changed: 49 additions & 54 deletions

File tree

basilisk/presenters/base_conversation_presenter.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@
1111
import threading
1212
from typing import TYPE_CHECKING, Callable
1313

14-
import wx
15-
1614
import basilisk.config as config
1715
from basilisk.provider_ai_model import ProviderAIModel
1816
from basilisk.services.account_model_service import AccountModelService
1917

2018
if TYPE_CHECKING:
19+
from uuid import UUID
20+
2121
from basilisk.provider_engine.base_engine import BaseEngine
2222

2323
log = logging.getLogger(__name__)
@@ -50,7 +50,7 @@ def __init__(
5050
self._model_loading_cancel_event: threading.Event | None = None
5151
self._model_loading_generation: int = 0
5252
self._pending_model_id: str | None = None
53-
self._pending_model_account_id = None
53+
self._pending_model_account_id: UUID | None = None
5454

5555
def get_engine(self, account: config.Account) -> BaseEngine:
5656
"""Get or create an engine for the given account.
@@ -140,7 +140,7 @@ def _load_models_in_background(
140140
return
141141
if generation != self._model_loading_generation:
142142
return
143-
wx.CallAfter(on_loaded, account_id, models, error_message)
143+
on_loaded(account_id, models, error_message)
144144

145145
def shutdown_model_loading(self) -> None:
146146
"""Cancel and invalidate any in-flight model loading worker."""
@@ -151,7 +151,7 @@ def shutdown_model_loading(self) -> None:
151151
self._model_loading_thread = None
152152
self._model_loading_cancel_event = None
153153

154-
def set_pending_model(self, model_id: str, account_id) -> None:
154+
def set_pending_model(self, model_id: str, account_id: UUID) -> None:
155155
"""Store a deferred model selection to be applied once models load.
156156
157157
Args:
@@ -162,7 +162,7 @@ def set_pending_model(self, model_id: str, account_id) -> None:
162162
self._pending_model_account_id = account_id
163163

164164
def pop_pending_model(
165-
self, displayed_models: list[ProviderAIModel], account_id
165+
self, displayed_models: list[ProviderAIModel], account_id: UUID
166166
) -> ProviderAIModel | None:
167167
"""Find and clear the pending model selection.
168168

basilisk/provider_engine/dynamic_model_loader.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from __future__ import annotations
1212

1313
import logging
14+
import threading
1415
import time
1516
from enum import StrEnum, auto
1617
from functools import cached_property
@@ -33,7 +34,8 @@
3334
log = logging.getLogger(__name__)
3435

3536
_CACHE: dict[str, tuple[list[ProviderAIModel], float]] = {}
36-
_LAST_LOAD_ERROR: dict[str, Exception | None] = {}
37+
_CACHE_LOCK = threading.Lock()
38+
_LAST_LOAD_ERROR: dict[str, str | None] = {}
3739

3840

3941
class ArchitectureModalityToken(StrEnum):
@@ -276,27 +278,30 @@ def load_models_from_url(url: str) -> list[ProviderAIModel]:
276278
"""Fetch and parse models from URL, with caching."""
277279
now = time.monotonic()
278280
ttl = _get_cache_ttl_seconds()
279-
if url in _CACHE:
280-
cached_models, cached_at = _CACHE[url]
281-
if now - cached_at < ttl:
282-
_LAST_LOAD_ERROR[url] = None
283-
return cached_models
281+
with _CACHE_LOCK:
282+
if url in _CACHE:
283+
cached_models, cached_at = _CACHE[url]
284+
if now - cached_at < ttl:
285+
_LAST_LOAD_ERROR[url] = None
286+
return cached_models
284287

285288
try:
286289
provider_metadata = fetch_models_json(url)
287290
models = provider_metadata.get_provider_models()
288-
_CACHE[url] = (models, now)
289-
_LAST_LOAD_ERROR[url] = None
291+
with _CACHE_LOCK:
292+
_CACHE[url] = (models, now)
293+
_LAST_LOAD_ERROR[url] = None
290294
log.debug("Loaded %d models from %s", len(models), url)
291295
return models
292296
except (httpx.HTTPError, ValidationError, ValueError, TypeError) as e:
293297
log.warning("Failed to load models from %s: %s", url, e)
294-
_LAST_LOAD_ERROR[url] = e
295-
if url in _CACHE:
296-
return _CACHE[url][0]
297-
return []
298+
with _CACHE_LOCK:
299+
_LAST_LOAD_ERROR[url] = str(e)
300+
stale = _CACHE.get(url)
301+
return stale[0] if stale else []
298302

299303

300-
def get_last_load_error(url: str) -> Exception | None:
304+
def get_last_load_error(url: str) -> str | None:
301305
"""Get the most recent load error for a metadata URL."""
302-
return _LAST_LOAD_ERROR.get(url)
306+
with _CACHE_LOCK:
307+
return _LAST_LOAD_ERROR.get(url)

basilisk/views/base_conversation.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,9 @@ def update_model_list(self):
226226
return
227227
self.model_list.Append((_("Loading..."), "", "", ""))
228228
self.base_conv_presenter.start_model_loading(
229-
account, engine, self._on_models_loaded
229+
account,
230+
engine,
231+
lambda *args: wx.CallAfter(self._on_models_loaded, *args),
230232
)
231233

232234
def get_display_models(self) -> list[tuple[str, str, str]]:
@@ -344,21 +346,16 @@ def _on_models_loaded(
344346
for model in models:
345347
self.model_list.Append(model.display_model)
346348
if error_message:
349+
if hasattr(self, "SetStatusText"):
350+
self.SetStatusText(_("Model loading failed"))
347351
if not models:
348352
self.model_list.Append((_("Error loading models"), "", "", ""))
349-
if hasattr(self, "SetStatusText"):
350-
try:
351-
self.SetStatusText(_("Model loading failed"))
352-
except Exception:
353-
log.debug(
354-
"Could not set status text for model loading error"
353+
if hasattr(self, "show_error"):
354+
self.show_error(
355+
_("An error occurred while loading models:\n%s")
356+
% error_message,
357+
_("Model loading error"),
355358
)
356-
if hasattr(self, "show_error"):
357-
self.show_error(
358-
_("An error occurred while loading models:\n%s")
359-
% error_message,
360-
_("Model loading error"),
361-
)
362359
self._try_select_pending_model()
363360

364361
def _try_select_pending_model(self):

tests/presenters/test_base_conversation_presenter.py

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -318,9 +318,8 @@ def _make_engine(self, models=None, error=None):
318318
engine.get_model_loading_error.return_value = error
319319
return engine
320320

321-
def test_calls_on_loaded_with_models(self, presenter, mocker):
322-
"""Calls wx.CallAfter(on_loaded, account_id, models, None) on success."""
323-
mock_call_after = mocker.patch("wx.CallAfter")
321+
def test_calls_on_loaded_with_models(self, presenter):
322+
"""Calls on_loaded(account_id, models, None) directly on success."""
324323
on_loaded = MagicMock()
325324
model = _make_provider_model("gpt-4")
326325
engine = self._make_engine(models=[model])
@@ -329,25 +328,21 @@ def test_calls_on_loaded_with_models(self, presenter, mocker):
329328
presenter._load_models_in_background(
330329
"acct-1", engine, 0, cancel_event, on_loaded
331330
)
332-
mock_call_after.assert_called_once_with(
333-
on_loaded, "acct-1", [model], None
334-
)
331+
on_loaded.assert_called_once_with("acct-1", [model], None)
335332

336-
def test_skips_callback_when_cancelled(self, presenter, mocker):
337-
"""Does not call wx.CallAfter when cancel_event is set."""
338-
mock_call_after = mocker.patch("wx.CallAfter")
333+
def test_skips_callback_when_cancelled(self, presenter):
334+
"""Does not call on_loaded when cancel_event is set."""
339335
on_loaded = MagicMock()
340336
engine = self._make_engine(models=[_make_provider_model("gpt-4")])
341337
cancel_event = MagicMock()
342338
cancel_event.is_set.return_value = True
343339
presenter._load_models_in_background(
344340
"acct-1", engine, 0, cancel_event, on_loaded
345341
)
346-
mock_call_after.assert_not_called()
342+
on_loaded.assert_not_called()
347343

348-
def test_skips_callback_when_generation_stale(self, presenter, mocker):
349-
"""Does not call wx.CallAfter when generation counter has advanced."""
350-
mock_call_after = mocker.patch("wx.CallAfter")
344+
def test_skips_callback_when_generation_stale(self, presenter):
345+
"""Does not call on_loaded when generation counter has advanced."""
351346
on_loaded = MagicMock()
352347
engine = self._make_engine(models=[_make_provider_model("gpt-4")])
353348
cancel_event = MagicMock()
@@ -356,11 +351,10 @@ def test_skips_callback_when_generation_stale(self, presenter, mocker):
356351
presenter._load_models_in_background(
357352
"acct-1", engine, 0, cancel_event, on_loaded
358353
)
359-
mock_call_after.assert_not_called()
354+
on_loaded.assert_not_called()
360355

361-
def test_invalidates_cache_on_error_with_no_models(self, presenter, mocker):
356+
def test_invalidates_cache_on_error_with_no_models(self, presenter):
362357
"""Calls invalidate_models_cache() when there is an error but no models."""
363-
mocker.patch("wx.CallAfter")
364358
on_loaded = MagicMock()
365359
engine = self._make_engine(models=[], error="Network error")
366360
cancel_event = MagicMock()
@@ -370,9 +364,8 @@ def test_invalidates_cache_on_error_with_no_models(self, presenter, mocker):
370364
)
371365
engine.invalidate_models_cache.assert_called_once()
372366

373-
def test_exception_yields_empty_models_and_error(self, presenter, mocker):
374-
"""On exception, calls wx.CallAfter with empty models and an error message."""
375-
mock_call_after = mocker.patch("wx.CallAfter")
367+
def test_exception_yields_empty_models_and_error(self, presenter):
368+
"""On exception, calls on_loaded with empty models and an error message."""
376369
on_loaded = MagicMock()
377370
engine = MagicMock()
378371
engine.models = MagicMock(side_effect=RuntimeError("boom"))
@@ -381,8 +374,8 @@ def test_exception_yields_empty_models_and_error(self, presenter, mocker):
381374
presenter._load_models_in_background(
382375
"acct-1", engine, 0, cancel_event, on_loaded
383376
)
384-
mock_call_after.assert_called_once()
385-
__, account_id, models, error_message = mock_call_after.call_args[0]
377+
on_loaded.assert_called_once()
378+
account_id, models, error_message = on_loaded.call_args[0]
386379
assert account_id == "acct-1"
387380
assert models == []
388381
assert error_message is not None

0 commit comments

Comments
 (0)