"""Real-behavior tests for adapters.inference.fallback_adapter. FallbackInferenceAdapter orchestrates an ordered list of InferencePort adapters: it tries them in order (online ones first) and falls through to the next when one fails, returns None / a response flagged with the typed ``is_error`` sentinel, or raises. When all fail, each method returns its own documented default (or raises, for generate_structured). These tests use REAL subclasses of InferencePort (not MagicMock) so the adapter's capability-detection (`_is_method_overridden`, which explicitly ignores mocks) actually registers the fakes — letting us assert the genuine fallback ORDER: provider A fails -> B is tried -> B's result returned; all fail -> default. No network, no real sleeps of consequence. """ import pytest from adapters.inference.fallback_adapter import FallbackInferenceAdapter from core.domain.entities.ai_schemas import ( InferenceMetadata, InferenceResponse, TokenLogProb, ) from core.domain.exceptions import InferenceError from core.ports.inference_port import InferenceNotImplementedError, InferencePort # --- test doubles ------------------------------------------------------------ class BaseFake(InferencePort): """Concrete InferencePort whose abstract methods are no-ops by default. Being a real (non-mock) subclass means overridden methods are detected by the capability cache. Each test subclass overrides only what it needs. """ def __init__(self, name="Fake", online=True): super().__init__() self._name = name self._online = online self.calls = [] # Give each instance a distinct class name so logs/ordering are readable. def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) def health_check(self) -> dict: return {"status": "online" if self._online else "offline"} def generate(self, prompt, system_prompt="sys", **kwargs): # pragma: no cover raise NotImplementedError def stream_generate(self, prompt, system_prompt="sys", **kwargs): # noqa raise NotImplementedError def get_text_embedding(self, text): # pragma: no cover raise NotImplementedError def _resp(text, usage=None, logprobs=None, is_error=False): meta = InferenceMetadata(usage=usage, logprobs=logprobs) return InferenceResponse(text=text, metadata=meta, is_error=is_error) # === typed failure sentinel ================================================== def test_inference_response_failure_factory_sets_is_error(): resp = InferenceResponse.failure("engine offline") assert resp.is_error is True assert resp.text == "engine offline" def test_inference_response_defaults_to_not_error(): assert InferenceResponse(text="ok").is_error is False # === generate: fallback order ================================================ def test_generate_first_success_short_circuits(): class A(BaseFake): def generate(self, prompt, system_prompt="sys", **kwargs): self.calls.append(prompt) return _resp("from A") class B(BaseFake): def generate(self, prompt, system_prompt="sys", **kwargs): self.calls.append(prompt) return _resp("from B") a, b = A("A"), B("B") fb = FallbackInferenceAdapter([a, b]) res = fb.generate("Q") assert res.text == "from A" # B was never consulted. assert a.calls == ["Q"] assert b.calls == [] def test_generate_falls_through_on_failure_sentinel(): class A(BaseFake): def generate(self, prompt, system_prompt="sys", **kwargs): self.calls.append(prompt) return _resp("moteur indisponible", is_error=True) class B(BaseFake): def generate(self, prompt, system_prompt="sys", **kwargs): self.calls.append(prompt) return _resp("recovered by B") a, b = A("A"), B("B") fb = FallbackInferenceAdapter([a, b]) res = fb.generate("Q") # A's is_error sentinel is treated as failure -> B used. assert res.text == "recovered by B" assert a.calls == ["Q"] and b.calls == ["Q"] def test_generate_accepts_answer_starting_with_erreur(): # Regression: a genuine answer that merely *starts with* "Erreur" must be # returned as a success. The old startswith("Erreur") heuristic misrouted it # to the next engine. class A(BaseFake): def generate(self, prompt, system_prompt="sys", **kwargs): self.calls.append(prompt) return _resp("Erreur 404 est un thème central de Serial Experiments Lain.") class B(BaseFake): def generate(self, prompt, system_prompt="sys", **kwargs): self.calls.append(prompt) return _resp("from B") a, b = A("A"), B("B") fb = FallbackInferenceAdapter([a, b]) res = fb.generate("Q") assert res.text.startswith("Erreur 404") assert b.calls == [] # A's answer accepted; B never consulted. def test_generate_falls_through_on_exception(): class A(BaseFake): def generate(self, prompt, system_prompt="sys", **kwargs): raise RuntimeError("A crashed") class B(BaseFake): def generate(self, prompt, system_prompt="sys", **kwargs): return _resp("B ok") fb = FallbackInferenceAdapter([A("A"), B("B")]) assert fb.generate("Q").text == "B ok" def test_generate_all_fail_returns_critical_default(): class A(BaseFake): def generate(self, prompt, system_prompt="sys", **kwargs): raise RuntimeError("boom-A") class B(BaseFake): def generate(self, prompt, system_prompt="sys", **kwargs): return _resp("B down", is_error=True) fb = FallbackInferenceAdapter([A("A"), B("B")]) res = fb.generate("Q") assert res.text.startswith("Échec critique") assert res.is_error is True # Last error recorded is from B (the final adapter tried). assert "B down" in res.text def test_generate_logs_usage_on_success(): obs = _Obs() class A(BaseFake): def generate(self, prompt, system_prompt="sys", **kwargs): return _resp("ok", usage={"total_tokens": 42}) fb = FallbackInferenceAdapter([A("A")], obs_service=obs) fb.generate("Q") assert obs.inference_logs # tokens come from usage.total_tokens assert obs.inference_logs[-1]["tokens"] == 42 def test_generate_passes_include_logprobs_only_when_supported(): captured = {} class WithLogprobs(BaseFake): def generate( self, prompt, system_prompt="sys", include_logprobs=False, **kwargs ): captured["include_logprobs"] = include_logprobs return _resp("ok") fb = FallbackInferenceAdapter([WithLogprobs("W")]) fb.generate("Q", include_logprobs=True) assert captured["include_logprobs"] is True class _Obs: """Minimal observability double recording calls.""" def __init__(self): self.errors = [] self.inference_logs = [] def log_error(self, error_type, message): self.errors.append({"type": error_type, "message": message}) def log_inference(self, model_id, latency, tokens, metadata=None): self.inference_logs.append( {"model_id": model_id, "tokens": tokens, "metadata": metadata} ) def test_generate_reports_failure_to_obs_service(): obs = _Obs() class A(BaseFake): def generate(self, prompt, system_prompt="sys", **kwargs): raise RuntimeError("nope") class B(BaseFake): def generate(self, prompt, system_prompt="sys", **kwargs): return _resp("ok") fb = FallbackInferenceAdapter([A("A"), B("B")], obs_service=obs) fb.generate("Q") # A's crash was reported as an error + a failed inference metric. assert any("InferenceAdapterFailure" == e["type"] for e in obs.errors) assert any( log["metadata"] and log["metadata"].get("status") == "failed" for log in obs.inference_logs ) # === _fallback_call-backed methods =========================================== def test_fallback_call_skips_not_implemented_then_succeeds(): class A(BaseFake): def get_image_embedding(self, image_data, model_id=None): raise InferenceNotImplementedError("A has no embeddings") class B(BaseFake): def get_image_embedding(self, image_data, model_id=None): return [0.1, 0.2, 0.3] fb = FallbackInferenceAdapter([A("A"), B("B")]) assert fb.get_image_embedding(b"img") == [0.1, 0.2, 0.3] def test_fallback_call_none_result_falls_through(): class A(BaseFake): def get_text_embedding(self, text): return None class B(BaseFake): def get_text_embedding(self, text): return [1.0, 2.0] fb = FallbackInferenceAdapter([A("A"), B("B")]) assert fb.get_text_embedding("hi") == [1.0, 2.0] def test_get_image_embedding_raises_when_every_backend_fails(): """`_fallback_call` avale l'exception de chaque adaptateur et rend `None` ; la méthode le transformait en `[]`. Un appelant sans garde-fou (`AdvancedVisionService.get_unified_embedding`, `get_style_embedding_with_lora`, `get_character_face_embedding`) recevait alors « l'image s'est encodée en rien » sans une erreur -- la panne silencieuse que cette branche existe pour tuer. Ses deux jumelles (`get_text_embedding_clip`, `get_character_embedding`) lèvent déjà ; celle-ci n'avait jamais été alignée. """ class A(BaseFake): def get_image_embedding(self, image_data, model_id=None): raise RuntimeError("x") fb = FallbackInferenceAdapter([A("A")]) with pytest.raises(InferenceError): fb.get_image_embedding(b"img") def test_get_image_embedding_raises_when_the_only_backend_returns_an_empty_vector(): """Un moteur qui rend `[]` sans lever est la même panne, autrement déguisée.""" class A(BaseFake): def get_image_embedding(self, image_data, model_id=None): return [] fb = FallbackInferenceAdapter([A("A")]) with pytest.raises(InferenceError): fb.get_image_embedding(b"img") def test_cold_brain_then_gemini_refuses_foreign_model_never_answers_with_a_vector(): """CRITICAL: the production chain is `[brain_api, google_genai]`. A cold Cloud Run GPU (scaled to zero) is a NORMAL event -- `BrainAPIAdapter` raises `InferenceError`. Before the fix, `GoogleGenAIAdapter. get_image_embedding` would then call Gemini with the CLIP `model_id` unchanged, fail against the real SDK, and silently fall back to a TEXT-description embedding -- a foreign vector that could get written to or compared against `unified_clip_space` while looking like a 200. This uses the REAL `GoogleGenAIAdapter` (only its SDK client is mocked) chained with a real `InferencePort` subclass standing in for a cold brain, and asserts the whole chain raises instead of ever reaching Gemini's SDK. """ from unittest.mock import MagicMock, patch from adapters.inference.google_genai_adapter import GoogleGenAIAdapter class ColdBrain(BaseFake): def get_image_embedding(self, image_data, model_id=None): raise InferenceError("BrainAPI unreachable: Cloud Run GPU cold start") client = MagicMock() client.models = MagicMock() client.caches = MagicMock() with patch( "adapters.inference.google_genai_adapter.genai.Client", return_value=client ): gemini = GoogleGenAIAdapter(api_key="key") fb = FallbackInferenceAdapter([ColdBrain("brain"), gemini]) with pytest.raises(InferenceError): fb.get_image_embedding(b"cover.png", model_id="dudcjs2779/anime-style-tag-clip") # Gemini must have refused before ever attempting the SDK call -- no # foreign request went out, and certainly no vector came back from one. client.models.embed_content.assert_not_called() def test_classify_image_default_empty_dict_when_all_fail(): class A(BaseFake): def classify_image(self, image_data, candidate_labels, model_id=None): raise RuntimeError("x") fb = FallbackInferenceAdapter([A("A")]) assert fb.classify_image(b"img", ["cat"]) == {} def test_moderate_content_default_is_safe_when_all_fail(): class A(BaseFake): def moderate_content(self, text, categories): raise RuntimeError("x") fb = FallbackInferenceAdapter([A("A")]) assert fb.moderate_content("text", ["nsfw"]) == {"is_safe": True} def test_calculate_visual_similarity_casts_to_float(): class A(BaseFake): def calculate_visual_similarity(self, query, item_id, media_type): return 1 # int -> should become float fb = FallbackInferenceAdapter([A("A")]) out = fb.calculate_visual_similarity("q", "i", "anime") assert out == 1.0 and isinstance(out, float) def test_calculate_visual_similarity_default_zero(): class A(BaseFake): def calculate_visual_similarity(self, query, item_id, media_type): return None fb = FallbackInferenceAdapter([A("A")]) assert fb.calculate_visual_similarity("q", "i", "anime") == 0.0 def test_process_manga_page_passes_through_real_result(): class A(BaseFake): def process_manga_page(self, image_data): return {"panels": [1, 2]} fb = FallbackInferenceAdapter([A("A")]) assert fb.process_manga_page(b"img") == {"panels": [1, 2]} def test_fallback_call_logs_warning_after_recovery(): # A returns None (failure), B succeeds: exercises the "fell back" warning path. class A(BaseFake): def detect_objects(self, image_data, candidate_queries, model_id=None): return None class B(BaseFake): def detect_objects(self, image_data, candidate_queries, model_id=None): return [{"label": "person"}] fb = FallbackInferenceAdapter([A("A"), B("B")]) assert fb.detect_objects(b"img", ["person"]) == [{"label": "person"}] # === rerank_documents (dedicated loop) ======================================= def test_rerank_documents_empty_documents_short_circuit(): fb = FallbackInferenceAdapter([]) assert fb.rerank_documents("q", []) == [] def test_rerank_documents_falls_through_on_wrong_length(): class A(BaseFake): def rerank_documents(self, query, documents): return [0.5] # wrong length (1 vs 2) -> treated as failure class B(BaseFake): def rerank_documents(self, query, documents): return [0.9, 0.1] fb = FallbackInferenceAdapter([A("A"), B("B")]) assert fb.rerank_documents("q", ["d1", "d2"]) == [0.9, 0.1] def test_rerank_documents_all_fail_returns_zeros(): class A(BaseFake): def rerank_documents(self, query, documents): raise RuntimeError("rr down") fb = FallbackInferenceAdapter([A("A")]) assert fb.rerank_documents("q", ["d1", "d2", "d3"]) == [0.0, 0.0, 0.0] # === generate_structured ===================================================== def test_generate_structured_first_success(): sentinel = {"name": "Naruto"} class A(BaseFake): def generate_structured( self, prompt, response_model, system_prompt="sys", max_retries=3 ): return sentinel fb = FallbackInferenceAdapter([A("A")]) assert fb.generate_structured("extract", dict) is sentinel def test_generate_structured_falls_through_on_none(): class A(BaseFake): def generate_structured( self, prompt, response_model, system_prompt="sys", max_retries=3 ): return None class B(BaseFake): def generate_structured( self, prompt, response_model, system_prompt="sys", max_retries=3 ): return {"ok": True} fb = FallbackInferenceAdapter([A("A"), B("B")]) assert fb.generate_structured("extract", dict) == {"ok": True} def test_generate_structured_all_fail_raises(): class A(BaseFake): def generate_structured( self, prompt, response_model, system_prompt="sys", max_retries=3 ): raise RuntimeError("structured boom") fb = FallbackInferenceAdapter([A("A")]) with pytest.raises(Exception, match="structured boom"): fb.generate_structured("extract", dict) # === stream_generate ========================================================= def test_stream_generate_first_adapter_streams(): class A(BaseFake): def stream_generate(self, prompt, system_prompt="sys", **kwargs): yield _resp("chunk1") yield _resp("chunk2") fb = FallbackInferenceAdapter([A("A")]) chunks = list(fb.stream_generate("Q")) assert [c.text for c in chunks] == ["chunk1", "chunk2"] def test_stream_generate_falls_through_on_failure_sentinel(): class A(BaseFake): def stream_generate(self, prompt, system_prompt="sys", **kwargs): yield _resp("A bad", is_error=True) class B(BaseFake): def stream_generate(self, prompt, system_prompt="sys", **kwargs): yield _resp("B good1") yield _resp("B good2") fb = FallbackInferenceAdapter([A("A"), B("B")]) chunks = list(fb.stream_generate("Q")) assert [c.text for c in chunks] == ["B good1", "B good2"] def test_stream_generate_accepts_first_chunk_starting_with_erreur(): # A first chunk whose text merely starts with "Erreur" (is_error=False) is a # legitimate stream and must be yielded, not skipped. class A(BaseFake): def stream_generate(self, prompt, system_prompt="sys", **kwargs): yield _resp("Erreur 404, ") yield _resp("un thème de Lain") class B(BaseFake): def stream_generate( self, prompt, system_prompt="sys", **kwargs ): # pragma: no cover yield _resp("B should not run") fb = FallbackInferenceAdapter([A("A"), B("B")]) chunks = list(fb.stream_generate("Q")) assert [c.text for c in chunks] == ["Erreur 404, ", "un thème de Lain"] def test_stream_generate_handles_empty_stream_stopiteration(): class A(BaseFake): def stream_generate(self, prompt, system_prompt="sys", **kwargs): return yield # makes this a generator that immediately stops class B(BaseFake): def stream_generate(self, prompt, system_prompt="sys", **kwargs): yield _resp("B saved it") fb = FallbackInferenceAdapter([A("A"), B("B")]) chunks = list(fb.stream_generate("Q")) assert [c.text for c in chunks] == ["B saved it"] def test_stream_generate_all_fail_yields_generate_fallback(): # No adapter streams successfully -> final fallback calls self.generate, # which itself has no working adapter -> critical-failure InferenceResponse. class A(BaseFake): def stream_generate(self, prompt, system_prompt="sys", **kwargs): raise RuntimeError("stream boom") def generate(self, prompt, system_prompt="sys", **kwargs): raise RuntimeError("gen boom") fb = FallbackInferenceAdapter([A("A")]) chunks = list(fb.stream_generate("Q")) assert len(chunks) == 1 assert chunks[0].text.startswith("Échec critique") # === calculate_uncertainty (logprob cache fast path) ========================= def test_calculate_uncertainty_uses_cached_logprobs(): # After a successful generate with logprobs, calculate_uncertainty for the # SAME completion should compute entropy/perplexity/confidence locally # without consulting any adapter. logprobs = [ TokenLogProb(token="a", logprob=-0.2), TokenLogProb(token="b", logprob=-0.4), ] class A(BaseFake): def generate(self, prompt, system_prompt="sys", **kwargs): return _resp("cached answer", logprobs=logprobs) def calculate_uncertainty(self, prompt, completion): raise AssertionError("should not be called - cache path expected") fb = FallbackInferenceAdapter([A("A")]) fb.generate("Q") # primes _last_completion / _last_logprobs metrics = fb.calculate_uncertainty("Q", "cached answer") assert set(metrics) == {"entropy", "perplexity", "confidence"} # avg_entropy = -(-0.2 + -0.4)/2 = 0.3 assert metrics["entropy"] == 0.3 assert 0.0 <= metrics["confidence"] <= 1.0 def test_calculate_uncertainty_delegates_when_no_cache(): class A(BaseFake): def calculate_uncertainty(self, prompt, completion): return {"entropy": 0.9} fb = FallbackInferenceAdapter([A("A")]) assert fb.calculate_uncertainty("p", "different completion") == {"entropy": 0.9} # === health_check / primary adapter management =============================== def test_health_check_online_if_any_adapter_online(): class A(BaseFake): pass class B(BaseFake): pass a = A("A", online=False) b = B("B", online=True) fb = FallbackInferenceAdapter([a, b]) out = fb.health_check() assert out["status"] == "online" assert len(out["adapters"]) == 2 def test_health_check_offline_if_all_offline(): class A(BaseFake): pass fb = FallbackInferenceAdapter([A("A", online=False)]) assert fb.health_check()["status"] == "offline" def test_primary_adapter_property_and_set_primary(): class A(BaseFake): pass class B(BaseFake): pass a, b = A("A"), B("B") fb = FallbackInferenceAdapter([a, b]) assert fb.primary_adapter is a # Promote index 1 (b) to primary. assert fb.set_primary_adapter(1) is True assert fb.primary_adapter is b # Out-of-range index is a no-op returning False. assert fb.set_primary_adapter(99) is False assert fb.primary_adapter is b def test_primary_adapter_none_when_empty(): fb = FallbackInferenceAdapter([]) assert fb.primary_adapter is None def test_none_adapters_are_filtered_out(): class A(BaseFake): pass a = A("A") fb = FallbackInferenceAdapter([None, a, None]) assert fb.adapters == [a] # === offline-then-online ordering ============================================ # === remaining delegated methods (happy path coverage) ======================= def test_remaining_delegated_methods_pass_through_results(): sentinels = { "get_video_temporal_embeddings": [{"t": 0}], "localize_video_actions": [{"start": 1}], "transform_image_to_anime": "img.png", "transform_video_to_anime": "vid.mp4", "generate_soundscape": "snd.wav", "translate_manga_page": {"translated": True}, "inpaint_text_bubbles": "out.png", "generate_image_description": "a desc", "generate_video_description": "a vid desc", "get_diagnostics": {"attn": [1]}, "clone_voice": b"voice", "speech_to_speech": b"reply", "estimate_depth": b"depthmap", "generate_3d_scene": {"points": 1}, "visual_rerank": [{"url": "a"}], "get_multimodal_late_interaction": [[0.1]], } class A(BaseFake): def get_video_temporal_embeddings(self, video_data): return sentinels["get_video_temporal_embeddings"] def localize_video_actions(self, video_data, action_queries): return sentinels["localize_video_actions"] def transform_image_to_anime(self, image_data, studio_style, prompt=""): return sentinels["transform_image_to_anime"] def transform_video_to_anime(self, video_data, studio_style, prompt=""): return sentinels["transform_video_to_anime"] def generate_soundscape(self, video_metadata, prompt=None): return sentinels["generate_soundscape"] def translate_manga_page(self, image_data, target_lang="Français"): return sentinels["translate_manga_page"] def inpaint_text_bubbles(self, image_data, text_placements): return sentinels["inpaint_text_bubbles"] def generate_image_description(self, image_data, prompt=""): return sentinels["generate_image_description"] def generate_video_description(self, video_data, prompt=""): return sentinels["generate_video_description"] def get_diagnostics(self, prompt, completion): return sentinels["get_diagnostics"] def clone_voice(self, text, reference_audio, language="fr"): return sentinels["clone_voice"] def speech_to_speech(self, audio_input, system_prompt=""): return sentinels["speech_to_speech"] def estimate_depth(self, image_data): return sentinels["estimate_depth"] def generate_3d_scene(self, image_data, depth_map, mode="gaussian_splatting"): return sentinels["generate_3d_scene"] def visual_rerank(self, query, image_urls, system_prompt=""): return sentinels["visual_rerank"] def get_multimodal_late_interaction(self, image_data): return sentinels["get_multimodal_late_interaction"] fb = FallbackInferenceAdapter([A("A")]) assert ( fb.get_video_temporal_embeddings(b"v") == sentinels["get_video_temporal_embeddings"] ) assert ( fb.localize_video_actions(b"v", ["run"]) == sentinels["localize_video_actions"] ) assert fb.transform_image_to_anime(b"i", "ghibli") == "img.png" assert fb.transform_video_to_anime(b"v", "madhouse") == "vid.mp4" assert fb.generate_soundscape({"scene": "x"}) == "snd.wav" assert fb.translate_manga_page(b"i") == {"translated": True} assert fb.inpaint_text_bubbles(b"i", []) == "out.png" assert fb.generate_image_description(b"i") == "a desc" assert fb.generate_video_description(b"v") == "a vid desc" assert fb.get_diagnostics("p", "c") == {"attn": [1]} assert fb.clone_voice("t", b"ref") == b"voice" assert fb.speech_to_speech(b"aud") == b"reply" assert fb.estimate_depth(b"i") == b"depthmap" assert fb.generate_3d_scene(b"i", b"d") == {"points": 1} assert fb.visual_rerank("q", ["a"]) == [{"url": "a"}] assert fb.get_multimodal_late_interaction(b"i") == [[0.1]] def test_remaining_delegated_methods_return_defaults_when_all_fail(): class A(BaseFake): def estimate_depth(self, image_data): raise RuntimeError("x") def clone_voice(self, text, reference_audio, language="fr"): raise RuntimeError("x") def generate_3d_scene(self, image_data, depth_map, mode="gaussian_splatting"): raise RuntimeError("x") def transform_image_to_anime(self, image_data, studio_style, prompt=""): raise RuntimeError("x") fb = FallbackInferenceAdapter([A("A")]) assert fb.estimate_depth(b"i") == b"" # bytes default assert fb.clone_voice("t", b"r") == b"" assert fb.generate_3d_scene(b"i", b"d") == {} # dict default assert fb.transform_image_to_anime(b"i", "s") == "" # str default # === generate_image: paid chain + self-hosted worker failover ================ def test_generate_image_uses_paid_chain_when_budget_ok(): from unittest.mock import patch class A(BaseFake): def generate_image(self, prompt, style=""): return "paid-image.png" fb = FallbackInferenceAdapter([A("A")]) with patch("django.core.cache.cache") as cache: cache.get.return_value = False # budget NOT exceeded out = fb.generate_image("a cat", "ghibli") assert out == "paid-image.png" def test_generate_image_routes_to_worker_when_budget_exceeded(): from unittest.mock import patch class A(BaseFake): def generate_image(self, prompt, style=""): # pragma: no cover raise AssertionError("paid chain must be skipped when budget exceeded") fb = FallbackInferenceAdapter([A("A")]) # Cache returns True for budget flag; worker task completes immediately. cache_store = {"paid_api_budget_exceeded": True} def cache_get(key, default=None): if key == "paid_api_budget_exceeded": return True if key.startswith("task_result:"): return {"ready": True, "state": "SUCCESS", "result": "worker-image.png"} return cache_store.get(key, default if default is not None else 0) with ( patch("django.core.cache.cache") as cache, patch("animetix.tasks_client.enqueue_task", return_value="task-123") as enqueue, ): cache.get.side_effect = cache_get out = fb.generate_image("a dog", "cel") assert out == "worker-image.png" enqueue.assert_called_once() def test_generate_image_failover_to_worker_on_paid_failure(): from unittest.mock import patch class A(BaseFake): def generate_image(self, prompt, style=""): raise RuntimeError("paid api down") fb = FallbackInferenceAdapter([A("A")]) def cache_get(key, default=None): if key == "paid_api_budget_exceeded": return False # budget ok, so it tries paid first (which raises) if key.startswith("task_result:"): return {"ready": True, "state": "SUCCESS", "result": "fallback.png"} return default if default is not None else 0 with ( patch("django.core.cache.cache") as cache, patch("animetix.tasks_client.enqueue_task", return_value="t-1"), ): cache.get.side_effect = cache_get out = fb.generate_image("x") assert out == "fallback.png" def test_generate_image_worker_raises_on_task_failure(): from unittest.mock import patch fb = FallbackInferenceAdapter([]) # no paid adapters -> goes straight to worker def cache_get(key, default=None): if key == "paid_api_budget_exceeded": return True if key.startswith("task_result:"): return {"ready": True, "state": "FAILURE", "result": {"error": "gpu oom"}} return default if default is not None else 0 with ( patch("django.core.cache.cache") as cache, patch("animetix.tasks_client.enqueue_task", return_value="t-2"), ): cache.get.side_effect = cache_get with pytest.raises(Exception, match="gpu oom"): fb.generate_image("x") def test_offline_adapter_tried_after_online_one(): order = [] class Online(BaseFake): def get_text_embedding(self, text): order.append("online") return [1.0] class Offline(BaseFake): def get_text_embedding(self, text): order.append("offline") return [2.0] online = Online("Online", online=True) offline = Offline("Offline", online=False) # Pass offline FIRST in the list; _get_ordered_adapters must still try the # online one first. fb = FallbackInferenceAdapter([offline, online]) assert fb.get_text_embedding("hi") == [1.0] assert order == ["online"]