"""Tests for engine discovery.""" from __future__ import annotations from unittest import mock from openjarvis.core.config import JarvisConfig from openjarvis.core.registry import EngineRegistry from openjarvis.engine._base import InferenceEngine from openjarvis.engine._discovery import ( discover_engines, discover_models, get_engine, ) class _FakeEngine(InferenceEngine): engine_id = "fake" def __init__( self, *, healthy: bool = True, models: list | None = None, **kwargs, # noqa: ANN003 ) -> None: self._healthy = healthy self._models = models or [] def generate(self, messages, *, model, **kwargs): # noqa: ANN001, ANN003 return {"content": "ok", "usage": {}} async def stream(self, messages, *, model, **kwargs): # noqa: ANN001, ANN003 yield "ok" def list_models(self) -> list: return self._models def health(self) -> bool: return self._healthy def _reg(key: str, eid: str) -> None: """Register a fake engine type under *key*.""" cls = type(key.title(), (_FakeEngine,), {"engine_id": eid}) EngineRegistry.register_value(key, cls) class TestDiscoverEngines: def test_only_healthy_returned(self) -> None: _reg("healthy", "healthy") _reg("sick", "sick") cfg = JarvisConfig() with mock.patch( "openjarvis.engine._discovery._make_engine", side_effect=lambda k, c: _FakeEngine(healthy=(k == "healthy")), ): result = discover_engines(cfg) assert len(result) == 1 assert result[0][0] == "healthy" def test_default_engine_first(self) -> None: _reg("a", "a") _reg("b", "b") cfg = JarvisConfig() cfg.engine.default = "b" with mock.patch( "openjarvis.engine._discovery._make_engine", side_effect=lambda k, c: _FakeEngine(healthy=True), ): result = discover_engines(cfg) assert result[0][0] == "b" def test_health_checks_run_concurrently(self) -> None: """Regression for #263 — discovery must probe engines in parallel. Each engine's health() does a blocking network probe with its own timeout, so serial discovery cost = sum of probe times. With N engines each sleeping S, parallel discovery wall-time must stay far below N*S (closer to S). We don't measure real time precisely (CI is noisy); instead we record concurrency: the max number of health() calls in flight simultaneously must exceed 1. """ import threading import time n_engines = 6 sleep_s = 0.15 for i in range(n_engines): _reg(f"slow{i}", f"slow{i}") lock = threading.Lock() in_flight = 0 max_in_flight = 0 class _SlowEngine(_FakeEngine): def health(self) -> bool: nonlocal in_flight, max_in_flight with lock: in_flight += 1 max_in_flight = max(max_in_flight, in_flight) time.sleep(sleep_s) with lock: in_flight -= 1 return True cfg = JarvisConfig() with mock.patch( "openjarvis.engine._discovery._make_engine", side_effect=lambda k, c: _SlowEngine(healthy=True), ): start = time.monotonic() result = discover_engines(cfg) elapsed = time.monotonic() - start # All slow engines were discovered (plus any real registered ones). assert len([r for r in result if r[0].startswith("slow")]) == n_engines # Concurrency actually happened — more than one probe overlapped. assert max_in_flight > 1, f"probes ran serially (max_in_flight={max_in_flight})" # Wall-time is well under the serial sum (n*sleep), allowing slack. assert elapsed < n_engines * sleep_s * 0.7 class TestDiscoverModels: def test_aggregate_models(self) -> None: e1 = _FakeEngine(models=["m1", "m2"]) e2 = _FakeEngine(models=["m3"]) result = discover_models([("ollama", e1), ("vllm", e2)]) assert result == {"ollama": ["m1", "m2"], "vllm": ["m3"]} class TestGetEngine: def test_fallback_when_default_unhealthy(self) -> None: _reg("bad", "bad") _reg("good", "good") cfg = JarvisConfig() cfg.engine.default = "bad" def _make(k, c): # noqa: ANN001 return _FakeEngine(healthy=(k == "good")) with mock.patch( "openjarvis.engine._discovery._make_engine", side_effect=_make, ): result = get_engine(cfg) assert result is not None assert result[0] == "good" def test_explicit_key_falls_back_to_any_healthy(self) -> None: """When an explicit engine_key fails, fallback to any healthy engine. Fixes #73: LM Studio running but not found because get_engine() returned None when the explicitly-requested key failed. """ _reg("requested", "requested") _reg("running", "running") cfg = JarvisConfig() cfg.engine.default = "requested" def _make(k, c): # noqa: ANN001 return _FakeEngine(healthy=(k == "running")) with mock.patch( "openjarvis.engine._discovery._make_engine", side_effect=_make, ): # Explicit key "requested" is unhealthy, but "running" is healthy result = get_engine(cfg, engine_key="requested") assert result is not None assert result[0] == "running" def test_skips_engine_that_cannot_serve_model(self) -> None: """#532: a healthy engine that can't serve the requested model is skipped for one that can — this is what stops the cloud fallback being chosen (when the local engine is down) for a model whose provider client is missing. """ _reg("picky", "picky") _reg("local", "local") class _Picky(_FakeEngine): def can_serve(self, model: str) -> bool: return model == "servable" cfg = JarvisConfig() cfg.engine.default = "picky" def _make(k, c): # noqa: ANN001 if k == "picky": return _Picky(healthy=True) return _FakeEngine(healthy=(k == "local")) with mock.patch( "openjarvis.engine._discovery._make_engine", side_effect=_make, ): # "picky" is healthy but cannot serve "other" -> fall back to "local" result = get_engine(cfg, model="other") assert result is not None assert result[0] == "local" def test_dummy_openai_key_does_not_misroute_local_model( self, monkeypatch: object ) -> None: """#335: a present-but-dummy OPENAI_API_KEY + a down local engine must NOT cause a local Ollama model to be routed to the cloud engine. Before the fix, CloudEngine.can_serve('qwen3.5:0.8b') returned True whenever any OpenAI client existed (even a junk key), so get_engine picked 'cloud' and the request later died with "OpenAI client not available". With the strict _client_for_model fall-through it returns None for unrecognized names, so get_engine declines cloud and (with the local engine down) returns None — surfacing a "start your local engine" failure instead. """ from openjarvis.engine.cloud import CloudEngine _reg("ollama", "ollama") EngineRegistry.register_value("cloud", CloudEngine) cfg = JarvisConfig() cfg.engine.default = "ollama" def _make(k, c): # noqa: ANN001 if k == "ollama": # Local engine is down (post-restart Ollama not yet up). return _FakeEngine(healthy=False, models=["qwen3.5:0.8b"]) # Real CloudEngine with only a (dummy) OpenAI client wired. eng = CloudEngine.__new__(CloudEngine) for name in ( "_openai_client", "_anthropic_client", "_google_client", "_openrouter_client", "_minimax_client", "_deepseek_client", "_codex_client", ): setattr(eng, name, object() if name == "_openai_client" else None) return eng with mock.patch( "openjarvis.engine._discovery._make_engine", side_effect=_make, ): result = get_engine(cfg, model="qwen3.5:0.8b") # Cloud must NOT be selected for a local model name. assert result is None def test_model_none_preserves_model_agnostic_selection(self) -> None: """model=None keeps the legacy behaviour: first healthy engine wins.""" _reg("primary", "primary") cfg = JarvisConfig() cfg.engine.default = "primary" with mock.patch( "openjarvis.engine._discovery._make_engine", side_effect=lambda k, c: _FakeEngine(healthy=True), # noqa: ANN001 ): result = get_engine(cfg, model=None) assert result is not None assert result[0] == "primary" class TestMiningSidecarEngineHandoff: """Engine discovery picks up (or ignores) a mining sidecar at runtime.""" def test_engine_discovery_picks_up_mining_sidecar( self, written_sidecar, monkeypatch ) -> None: """When a mining sidecar exists with vllm_endpoint, discovery registers a ``vllm-pearl-mining`` engine in the EngineRegistry. """ from openjarvis.mining import _constants as mining_const monkeypatch.setattr(mining_const, "SIDECAR_PATH", written_sidecar) cfg = JarvisConfig() with mock.patch( "openjarvis.engine._discovery._make_engine", side_effect=lambda k, c: _FakeEngine(healthy=True), ): discover_engines(cfg) assert EngineRegistry.contains("vllm-pearl-mining") def test_engine_discovery_no_mining_engine_when_sidecar_absent( self, tmp_path, monkeypatch ) -> None: """No mining sidecar → no ``vllm-pearl-mining`` engine registered.""" from openjarvis.mining import _constants as mining_const missing = tmp_path / "no-such-mining.json" monkeypatch.setattr(mining_const, "SIDECAR_PATH", missing) cfg = JarvisConfig() with mock.patch( "openjarvis.engine._discovery._make_engine", side_effect=lambda k, c: _FakeEngine(healthy=True), ): discover_engines(cfg) assert not EngineRegistry.contains("vllm-pearl-mining") def test_engine_discovery_skips_when_sidecar_missing_vllm_endpoint( self, tmp_path, monkeypatch ) -> None: """Sidecar present but no ``vllm_endpoint`` field → skip registration. Data-driven gate: a future cpu-pearl provider writes a sidecar that doesn't replace an inference engine (no vllm_endpoint field). """ import json as _json sidecar = tmp_path / "mining.json" sidecar.write_text( _json.dumps( { "provider": "cpu-pearl", "wallet_address": "prl1q...", "started_at": 1234567890, # deliberately omit vllm_endpoint } ) ) from openjarvis.mining import _constants as mining_const monkeypatch.setattr(mining_const, "SIDECAR_PATH", sidecar) cfg = JarvisConfig() with mock.patch( "openjarvis.engine._discovery._make_engine", side_effect=lambda k, c: _FakeEngine(healthy=True), ): discover_engines(cfg) assert not EngineRegistry.contains("vllm-pearl-mining")