diff --git a/src/core/app/controllers/models_controller.py b/src/core/app/controllers/models_controller.py index 4f97be86..7ed19980 100644 --- a/src/core/app/controllers/models_controller.py +++ b/src/core/app/controllers/models_controller.py @@ -255,27 +255,100 @@ async def _list_models_impl( # Use the injected config service from src.core.config.app_config import AppConfig - if not isinstance(config, AppConfig): - # Fallback to default config if we got a different config type - config = AppConfig() + # Determine which backend configuration views are available. Prefer the + # injected configuration object but gracefully fall back to a default + # AppConfig instance when the provided config does not expose a + # compatible `backends` attribute. This preserves compatibility with + # lightweight test doubles that only implement the public IConfig + # interface while avoiding the previous behaviour of discarding the + # injected configuration altogether. + backend_views: list[Any] = [] + if hasattr(config, "backends"): + try: + backend_views.append(getattr(config, "backends")) + except Exception as exc: # pragma: no cover - defensive guard + logger.debug( + "Unable to access backends on %s: %s", + type(config).__name__, + exc, + exc_info=True, + ) + else: + logger.debug( + "Configuration %s lacks 'backends' attribute; using fallback AppConfig", + type(config).__name__, + ) + + if not backend_views: + fallback_config = AppConfig() + backend_views.append(fallback_config.backends) # Ensure backend service is at least resolved for DI side effects _ = backend_service - try: - functional_backends = set(config.backends.functional_backends) - except Exception as exc: # pragma: no cover - defensive guard - logger.debug( - "Unable to determine functional backends: %s", exc, exc_info=True - ) - functional_backends = set() + functional_backends: set[str] = set() + for view in backend_views: + try: + candidates = getattr(view, "functional_backends") + except AttributeError: + logger.debug( + "Backend configuration %s does not expose functional_backends", + type(view).__name__, + ) + continue + except Exception as exc: # pragma: no cover - defensive guard + logger.debug( + "Error accessing functional_backends on %s: %s", + type(view).__name__, + exc, + exc_info=True, + ) + continue + + # Support both property-based and plain attribute implementations. + try: + if callable(candidates): + candidates = candidates() + except Exception as exc: # pragma: no cover - defensive guard + logger.debug( + "Failed to invoke functional_backends on %s: %s", + type(view).__name__, + exc, + exc_info=True, + ) + continue + + try: + if isinstance(candidates, set) or isinstance(candidates, (list, tuple)): + functional_backends.update(candidates) + elif isinstance(candidates, dict): + functional_backends.update(candidates.keys()) + elif candidates is None: + continue + else: + functional_backends.update(set(candidates)) + except TypeError: + logger.debug( + "functional_backends on %s is not iterable", type(view).__name__ + ) + continue # Iterate through dynamically discovered backend types from the registry for backend_type in backend_registry.get_registered_backends(): backend_config: Any | None = None - if config.backends: - # Access backend config dynamically using getattr - backend_config = getattr(config.backends, backend_type, None) + for view in backend_views: + candidate: Any | None = None + if isinstance(view, dict): + candidate = view.get(backend_type) + else: + try: + candidate = getattr(view, backend_type, None) + except AttributeError: + candidate = None + + if candidate is not None: + backend_config = candidate + break has_credentials = False if isinstance(backend_config, dict): diff --git a/tests/unit/test_models_endpoint.py b/tests/unit/test_models_endpoint.py index 2d286f42..54e968f4 100644 --- a/tests/unit/test_models_endpoint.py +++ b/tests/unit/test_models_endpoint.py @@ -72,3 +72,64 @@ def create_backend( model_ids = {model["id"] for model in result["data"]} assert "gemini-oauth-plan:gemini-2.5-pro" in model_ids assert created_backends == ["gemini-oauth-plan"] + + +def test_model_listing_respects_injected_config(monkeypatch) -> None: + """Ensure the controller honours custom configurations supplied via DI.""" + + import asyncio + from types import SimpleNamespace + from unittest.mock import Mock + + from src.core.app.controllers import models_controller + from src.core.app.controllers.models_controller import _list_models_impl + + monkeypatch.setattr( + models_controller.backend_registry, + "get_registered_backends", + lambda: ["dummy"], + ) + + class DummyBackend: + def get_available_models(self) -> list[str]: + return ["dummy-model"] + + class DummyFactory: + def __init__(self) -> None: + self.created_with: list[tuple[str, object]] = [] + + def create_backend(self, backend_type: str, config_obj: object) -> DummyBackend: + self.created_with.append((backend_type, config_obj)) + return DummyBackend() + + class CustomBackends: + def __init__(self) -> None: + self.functional_backends = {"dummy"} + self.dummy = SimpleNamespace(api_key="token") + + class CustomConfig: + def __init__(self) -> None: + self.backends = CustomBackends() + + def get(self, key: str, default: object | None = None) -> object | None: + if key == "backends": + return self.backends + return default + + def set(self, key: str, value: object) -> None: + setattr(self, key, value) + + factory = DummyFactory() + config = CustomConfig() + + result = asyncio.run( + _list_models_impl( + backend_service=Mock(), + config=config, # type: ignore[arg-type] + backend_factory=factory, # type: ignore[arg-type] + ) + ) + + model_ids = {model["id"] for model in result["data"]} + assert "dummy:dummy-model" in model_ids + assert factory.created_with == [("dummy", config)]