Spaces:
Build error
Build error
| """Extra real-behavior tests for adapters.inference.brain_api_adapter. | |
| Complements tests/adapters/test_brain_api_adapter_v2.py to raise coverage of | |
| the BrainAPIAdapter. Every HTTP call is mocked at the MODULE namespace | |
| (`safe_http_request` / `httpx`); no real network or sleeps. Assertions check | |
| the REAL request body + headers built and the REAL parsed response — not bare | |
| mock-call counts. | |
| """ | |
| import base64 | |
| from unittest.mock import MagicMock, patch | |
| import httpx | |
| import pytest | |
| from adapters.inference.brain_api_adapter import BrainAPIAdapter | |
| from core.domain.entities.ai_schemas import InferenceResponse | |
| from core.domain.exceptions import ConfigurationError, InferenceError | |
| from core.ports.inference_port import InferenceNotImplementedError | |
| MODULE = "adapters.inference.brain_api_adapter.safe_http_request" | |
| def _resp(payload, status=200): | |
| r = MagicMock() | |
| r.status_code = status | |
| r.json.return_value = payload | |
| return r | |
| def _clean_brain_env(monkeypatch): | |
| # The repo `.env` defines BRAIN_API_URL/BRAIN_API_KEY, and the adapter falls | |
| # back to them when api_url/api_key are None. Clear both for every test so the | |
| # "url/key missing" cases stay deterministic regardless of ambient env / | |
| # whichever earlier test loaded .env into os.environ. | |
| monkeypatch.delenv("BRAIN_API_URL", raising=False) | |
| monkeypatch.delenv("BRAIN_API_KEY", raising=False) | |
| def adapter(): | |
| return BrainAPIAdapter(api_url="http://brain:5000", api_key="dev-secret-key") | |
| def adapter_no_key(monkeypatch): | |
| # Ensure no ambient BRAIN_API_KEY leaks in (the adapter falls back to the | |
| # env var when api_key=None), so the "empty headers" assertion is deterministic. | |
| monkeypatch.delenv("BRAIN_API_KEY", raising=False) | |
| return BrainAPIAdapter(api_url="http://brain:5000", api_key=None) | |
| # --- generate: usage logging, payload, errors -------------------------------- | |
| def test_generate_logs_usage_and_builds_payload(): | |
| usage_port = MagicMock() | |
| a = BrainAPIAdapter(api_url="http://brain:5000", api_key="k", usage_port=usage_port) | |
| with patch(MODULE) as req: | |
| req.return_value = _resp( | |
| { | |
| "text": "ok", | |
| "usage": {"prompt_tokens": 11, "completion_tokens": 7}, | |
| "thinking": "reasoned", | |
| } | |
| ) | |
| res = a.generate( | |
| "Q", | |
| system_prompt="SYS", | |
| thinking_budget=64, | |
| thinking_mode=True, | |
| temperature=0.3, # extra kwarg flows into payload | |
| ) | |
| # Parsed response carries text + metadata. | |
| assert res.text == "ok" | |
| assert res.metadata.thinking == "reasoned" | |
| assert res.metadata.usage == {"prompt_tokens": 11, "completion_tokens": 7} | |
| # Real request shape. | |
| method, url = req.call_args.args | |
| kwargs = req.call_args.kwargs | |
| assert method == "POST" | |
| assert url == "http://brain:5000/generate" | |
| assert kwargs["headers"] == {"X-API-Key": "k"} | |
| assert kwargs["json"] == { | |
| "prompt": "Q", | |
| "system_prompt": "SYS", | |
| "thinking_budget": 64, | |
| "thinking_mode": True, | |
| "include_logprobs": False, | |
| "temperature": 0.3, | |
| } | |
| # Usage logged with the allocated budget passed through. | |
| usage_port.log_usage.assert_called_once() | |
| log_kwargs = usage_port.log_usage.call_args | |
| assert log_kwargs.args[0] == "brain:api" | |
| assert log_kwargs.args[1] == 11 # input tokens | |
| assert log_kwargs.args[2] == 7 # output tokens | |
| assert log_kwargs.kwargs["allocated_budget"] == 64 | |
| def test_generate_raises_when_url_missing(): | |
| with pytest.raises(ConfigurationError, match="BRAIN_API_URL is not configured"): | |
| BrainAPIAdapter(api_url=None, api_key="k") | |
| def test_init_raises_on_malformed_url(): | |
| # Now routed through check_brain_config: a present-but-malformed URL is caught. | |
| with pytest.raises(ConfigurationError, match="malformed"): | |
| BrainAPIAdapter(api_url="not-a-url", api_key="k") | |
| def test_generate_reraises_http_error(adapter): | |
| with patch(MODULE, side_effect=RuntimeError("boom")): | |
| with pytest.raises(RuntimeError, match="boom"): | |
| adapter.generate("Q") | |
| def test_generate_handles_missing_usage_block(adapter): | |
| # No "usage" key -> tokens default to 0, no KeyError. | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"text": "bare"}) | |
| res = adapter.generate("Q") | |
| assert res.text == "bare" | |
| assert res.metadata.logprobs is None | |
| def test_generate_without_api_key_sends_empty_headers(adapter_no_key): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"text": "x"}) | |
| adapter_no_key.generate("Q") | |
| assert req.call_args.kwargs["headers"] == {} | |
| def test_generate_pins_the_configured_model_in_the_payload(): | |
| a = BrainAPIAdapter(api_url="http://brain:5000", api_key="k", model="small:1.5b") | |
| with patch("adapters.inference.brain_api_adapter.safe_http_request") as req: | |
| req.return_value = MagicMock(json=MagicMock(return_value={"text": "ok"})) | |
| a.generate("Q") | |
| assert req.call_args.kwargs["json"]["model"] == "small:1.5b" | |
| def test_generate_omits_the_model_when_none_is_pinned(adapter): | |
| with patch("adapters.inference.brain_api_adapter.safe_http_request") as req: | |
| req.return_value = MagicMock(json=MagicMock(return_value={"text": "ok"})) | |
| adapter.generate("Q") | |
| assert "model" not in req.call_args.kwargs["json"] | |
| # --- stream_generate --------------------------------------------------------- | |
| def test_stream_generate_raises_when_url_missing(): | |
| with pytest.raises(ConfigurationError, match="BRAIN_API_URL is not configured"): | |
| BrainAPIAdapter(api_url=None, api_key="k") | |
| def test_stream_generate_builds_payload_and_yields_all_chunks(adapter): | |
| with patch("adapters.inference.brain_api_adapter.httpx.stream") as stream: | |
| res = MagicMock() | |
| res.iter_text.return_value = ["a", "b", "c"] | |
| stream.return_value.__enter__.return_value = res | |
| chunks = list(adapter.stream_generate("Q", thinking_mode=True)) | |
| assert [c.text for c in chunks] == ["a", "b", "c"] | |
| assert all(isinstance(c, InferenceResponse) for c in chunks) | |
| # Endpoint + payload built correctly. | |
| args, kwargs = stream.call_args | |
| assert args == ("POST", "http://brain:5000/stream_generate") | |
| assert kwargs["json"]["thinking_mode"] is True | |
| assert kwargs["headers"] == {"X-API-Key": "dev-secret-key"} | |
| res.raise_for_status.assert_called_once() | |
| def test_stream_generate_reraises_http_status_error(adapter): | |
| with patch("adapters.inference.brain_api_adapter.httpx.stream") as stream: | |
| res = MagicMock() | |
| res.raise_for_status.side_effect = httpx.HTTPStatusError( | |
| "bad", request=MagicMock(), response=MagicMock() | |
| ) | |
| stream.return_value.__enter__.return_value = res | |
| with pytest.raises(httpx.HTTPStatusError): | |
| list(adapter.stream_generate("Q")) | |
| def test_stream_generate_reraises_request_error(adapter): | |
| with patch("adapters.inference.brain_api_adapter.httpx.stream") as stream: | |
| res = MagicMock() | |
| res.raise_for_status.side_effect = httpx.RequestError("net down") | |
| stream.return_value.__enter__.return_value = res | |
| with pytest.raises(httpx.RequestError): | |
| list(adapter.stream_generate("Q")) | |
| def test_stream_generate_reraises_generic_error(adapter): | |
| with patch("adapters.inference.brain_api_adapter.httpx.stream") as stream: | |
| stream.side_effect = ValueError("weird") | |
| with pytest.raises(ValueError, match="weird"): | |
| list(adapter.stream_generate("Q")) | |
| # --- text embedding ---------------------------------------------------------- | |
| def test_get_text_embedding_parses_and_logs(): | |
| usage_port = MagicMock() | |
| a = BrainAPIAdapter(api_url="http://brain:5000", api_key="k", usage_port=usage_port) | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"embedding": [0.1, 0.2, 0.3]}) | |
| out = a.get_text_embedding("hello") | |
| assert out == [0.1, 0.2, 0.3] | |
| method, url = req.call_args.args | |
| assert url == "http://brain:5000/v1/embeddings" | |
| assert req.call_args.kwargs["json"] == {"text": "hello"} | |
| usage_port.log_usage.assert_called_once_with( | |
| "brain:embeddings", 0, 0, 1, allocated_budget=0 | |
| ) | |
| def test_get_text_embedding_reraises(adapter): | |
| with patch(MODULE, side_effect=RuntimeError("x")): | |
| with pytest.raises(RuntimeError): | |
| adapter.get_text_embedding("hello") | |
| # --- image generation / sprite ---------------------------------------------- | |
| def test_generate_image_returns_url(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"image_url_or_b64": "http://img/1.png"}) | |
| out = adapter.generate_image("a cat", style="ghibli") | |
| assert out == "http://img/1.png" | |
| assert req.call_args.args[1] == "http://brain:5000/vision/generate" | |
| assert req.call_args.kwargs["json"] == {"prompt": "a cat", "style": "ghibli"} | |
| def test_generate_sprite_wraps_prompt_and_delegates(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"image_url_or_b64": "sprite.png"}) | |
| out = adapter.generate_sprite("a knight", style="cel") | |
| assert out == "sprite.png" | |
| sent_prompt = req.call_args.kwargs["json"]["prompt"] | |
| assert "character sprite" in sent_prompt | |
| assert "a knight" in sent_prompt | |
| def test_generate_sprite_failure_raises_not_implemented(adapter): | |
| with patch(MODULE, side_effect=RuntimeError("down")): | |
| with pytest.raises(InferenceNotImplementedError) as ei: | |
| adapter.generate_sprite("a knight") | |
| assert "down" in str(ei.value) | |
| # --- image embedding (incl. no-URL early return) ----------------------------- | |
| def test_get_image_embedding_raises_without_url(): | |
| with pytest.raises(ConfigurationError, match="BRAIN_API_URL is not configured"): | |
| BrainAPIAdapter(api_url=None, api_key="k") | |
| def test_get_image_embedding_encodes_and_parses(adapter): | |
| raw = b"\x89PNGbytes" | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"embedding": [1.0, 2.0]}) | |
| out = adapter.get_image_embedding(raw, model_id="clip-v2") | |
| assert out == [1.0, 2.0] | |
| body = req.call_args.kwargs["json"] | |
| assert body["image"] == base64.b64encode(raw).decode("utf-8") | |
| assert body["model_id"] == "clip-v2" | |
| assert req.call_args.args[1] == "http://brain:5000/vision/embedding" | |
| def test_get_image_embedding_raises_inference_error_on_transport_failure(adapter): | |
| """Le contrat de toute la branche : une panne se lève, elle ne se déguise | |
| jamais en `[]`. Avant ce correctif, seule l'exception brute (`RuntimeError`) | |
| remontait -- un appelant qui n'attrape que `InferenceError` (comme | |
| `VisualIndexService`/`FallbackInferenceAdapter`) la laissait filtrer.""" | |
| with patch(MODULE, side_effect=RuntimeError("x")): | |
| with pytest.raises(InferenceError, match="x"): | |
| adapter.get_image_embedding(b"data") | |
| def test_get_image_embedding_raises_on_empty_vector(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"embedding": []}) | |
| with pytest.raises(InferenceError): | |
| adapter.get_image_embedding(b"data") | |
| def test_get_image_embedding_raises_on_zero_vector(adapter): | |
| """Un vecteur non vide mais entièrement nul est la même panne déguisée.""" | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"embedding": [0.0, 0.0, 0.0]}) | |
| with pytest.raises(InferenceError): | |
| adapter.get_image_embedding(b"data") | |
| # --- CLIP text-tower embedding (visual search, text query) ------------------ | |
| # | |
| # The text tower of the SAME CLIP model that produced the image space -- | |
| # reaches a DIFFERENT route than get_image_embedding. Never confused with | |
| # get_text_embedding (the generic sentence-transformers endpoint above). | |
| def test_get_text_embedding_clip_encodes_and_parses(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"embedding": [0.1, 0.2]}) | |
| out = adapter.get_text_embedding_clip( | |
| "une fille aux cheveux blancs", | |
| model_id="dudcjs2779/anime-style-tag-clip", | |
| ) | |
| assert out == [0.1, 0.2] | |
| assert req.call_args.args[1] == "http://brain:5000/vision/embedding/text" | |
| assert req.call_args.kwargs["json"] == { | |
| "text": "une fille aux cheveux blancs", | |
| "model_id": "dudcjs2779/anime-style-tag-clip", | |
| } | |
| def test_get_text_embedding_clip_raises_inference_error_on_transport_failure(adapter): | |
| with patch(MODULE, side_effect=RuntimeError("down")): | |
| with pytest.raises(InferenceError, match="down"): | |
| adapter.get_text_embedding_clip("x", model_id="m") | |
| def test_get_text_embedding_clip_raises_on_empty_vector(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"embedding": []}) | |
| with pytest.raises(InferenceError): | |
| adapter.get_text_embedding_clip("x", model_id="m") | |
| def test_get_text_embedding_clip_raises_on_zero_vector(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"embedding": [0.0, 0.0]}) | |
| with pytest.raises(InferenceError): | |
| adapter.get_text_embedding_clip("x", model_id="m") | |
| # --- CCIP character embedding (visual search, character target) ------------- | |
| # | |
| # "Is this the SAME character?" -- a different model, a different route, no | |
| # model_id (CCIP is a single fixed model, unlike CLIP's multi-model tower). | |
| def test_get_character_embedding_encodes_and_parses(adapter): | |
| raw = b"\x89PNG-character-portrait" | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"embedding": [0.3] * 768}) | |
| out = adapter.get_character_embedding(raw) | |
| assert out == [0.3] * 768 | |
| assert req.call_args.args[1] == "http://brain:5000/vision/character/embedding" | |
| assert req.call_args.kwargs["json"] == { | |
| "image": base64.b64encode(raw).decode("utf-8") | |
| } | |
| def test_get_character_embedding_raises_inference_error_on_transport_failure(adapter): | |
| with patch(MODULE, side_effect=RuntimeError("down")): | |
| with pytest.raises(InferenceError, match="down"): | |
| adapter.get_character_embedding(b"img") | |
| def test_get_character_embedding_raises_on_empty_vector(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"embedding": []}) | |
| with pytest.raises(InferenceError): | |
| adapter.get_character_embedding(b"img") | |
| def test_get_character_embedding_raises_on_zero_vector(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"embedding": [0.0] * 768}) | |
| with pytest.raises(InferenceError): | |
| adapter.get_character_embedding(b"img") | |
| # --- vision: classify / detect ---------------------------------------------- | |
| def test_classify_image_returns_labels(adapter): | |
| raw = b"img" | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"labels": {"cat": 0.9, "dog": 0.1}}) | |
| out = adapter.classify_image(raw, ["cat", "dog"], model_id="m") | |
| assert out == {"cat": 0.9, "dog": 0.1} | |
| body = req.call_args.kwargs["json"] | |
| assert body["candidate_labels"] == ["cat", "dog"] | |
| assert body["image"] == base64.b64encode(raw).decode("utf-8") | |
| assert req.call_args.args[1] == "http://brain:5000/vision/classify" | |
| def test_detect_objects_returns_list(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"objects": [{"box": [0, 0, 1, 1]}]}) | |
| out = adapter.detect_objects(b"img", ["person"]) | |
| assert out == [{"box": [0, 0, 1, 1]}] | |
| assert req.call_args.args[1] == "http://brain:5000/vision/detect" | |
| def test_classify_image_reraises(adapter): | |
| with patch(MODULE, side_effect=RuntimeError("x")): | |
| with pytest.raises(RuntimeError): | |
| adapter.classify_image(b"img", ["a"]) | |
| # --- video embeddings / localization / transforms ---------------------------- | |
| def test_get_video_temporal_embeddings(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"embeddings": [{"t": 0, "v": [0.1]}]}) | |
| out = adapter.get_video_temporal_embeddings(b"vid") | |
| assert out == [{"t": 0, "v": [0.1]}] | |
| assert req.call_args.args[1] == "http://brain:5000/video/embeddings" | |
| def test_localize_video_actions(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"actions": [{"start": 1, "end": 2}]}) | |
| out = adapter.localize_video_actions(b"vid", ["run"]) | |
| assert out == [{"start": 1, "end": 2}] | |
| assert req.call_args.kwargs["json"]["queries"] == ["run"] | |
| def test_transform_image_to_anime(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"image_url_or_b64": "anime.png"}) | |
| out = adapter.transform_image_to_anime(b"img", "ghibli", prompt="p") | |
| assert out == "anime.png" | |
| body = req.call_args.kwargs["json"] | |
| assert body["studio_style"] == "ghibli" | |
| assert body["prompt"] == "p" | |
| def test_transform_video_to_anime(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"video_url_or_b64": "anime.mp4"}) | |
| out = adapter.transform_video_to_anime(b"vid", "madhouse") | |
| assert out == "anime.mp4" | |
| assert req.call_args.args[1] == "http://brain:5000/video/transform/anime" | |
| def test_transform_image_reraises(adapter): | |
| with patch(MODULE, side_effect=RuntimeError("x")): | |
| with pytest.raises(RuntimeError): | |
| adapter.transform_image_to_anime(b"img", "s") | |
| # --- audio: soundscape / clone / s2s ----------------------------------------- | |
| def test_generate_soundscape(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"audio_url_or_b64": "snd.wav"}) | |
| out = adapter.generate_soundscape({"scene": "battle"}, prompt="epic") | |
| assert out == "snd.wav" | |
| body = req.call_args.kwargs["json"] | |
| assert body["video_metadata"] == {"scene": "battle"} | |
| assert body["prompt"] == "epic" | |
| def test_clone_voice_roundtrips_base64(adapter): | |
| out_bytes = b"\x00\x01audio" | |
| with patch(MODULE) as req: | |
| req.return_value = _resp( | |
| {"audio_b64": base64.b64encode(out_bytes).decode("utf-8")} | |
| ) | |
| out = adapter.clone_voice("bonjour", b"ref-audio", language="en") | |
| assert out == out_bytes # decoded back from base64 | |
| body = req.call_args.kwargs["json"] | |
| assert body["language"] == "en" | |
| assert body["reference_audio"] == base64.b64encode(b"ref-audio").decode("utf-8") | |
| def test_speech_to_speech_roundtrips_base64(adapter): | |
| out_bytes = b"reply-audio" | |
| with patch(MODULE) as req: | |
| req.return_value = _resp( | |
| {"audio_b64": base64.b64encode(out_bytes).decode("utf-8")} | |
| ) | |
| out = adapter.speech_to_speech(b"in-audio", system_prompt="be nice") | |
| assert out == out_bytes | |
| assert req.call_args.args[1] == "http://brain:5000/audio/speech-to-speech" | |
| def test_clone_voice_reraises(adapter): | |
| with patch(MODULE, side_effect=RuntimeError("x")): | |
| with pytest.raises(RuntimeError): | |
| adapter.clone_voice("t", b"ref") | |
| # --- depth / 3d -------------------------------------------------------------- | |
| def test_estimate_depth_decodes_b64(adapter): | |
| depth = b"depthmap" | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"depth_b64": base64.b64encode(depth).decode("utf-8")}) | |
| out = adapter.estimate_depth(b"img") | |
| assert out == depth | |
| def test_generate_3d_scene(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"scene_data": {"points": 1000}}) | |
| out = adapter.generate_3d_scene(b"img", b"depth") | |
| assert out == {"points": 1000} | |
| body = req.call_args.kwargs["json"] | |
| assert body["image"] == base64.b64encode(b"img").decode("utf-8") | |
| assert body["depth_map"] == base64.b64encode(b"depth").decode("utf-8") | |
| # --- manga ------------------------------------------------------------------- | |
| def test_process_manga_page_returns_full_json(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"panels": [], "text": "x"}) | |
| out = adapter.process_manga_page(b"img") | |
| assert out == {"panels": [], "text": "x"} | |
| def test_translate_manga_page(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"translated": True}) | |
| out = adapter.translate_manga_page(b"img", target_lang="English") | |
| assert out == {"translated": True} | |
| assert req.call_args.kwargs["json"]["target_lang"] == "English" | |
| def test_inpaint_text_bubbles(adapter): | |
| placements = [{"x": 1, "y": 2, "text": "hi"}] | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"image_url_or_b64": "out.png"}) | |
| out = adapter.inpaint_text_bubbles(b"img", placements) | |
| assert out == "out.png" | |
| assert req.call_args.kwargs["json"]["text_placements"] == placements | |
| def test_process_manga_page_reraises(adapter): | |
| with patch(MODULE, side_effect=RuntimeError("x")): | |
| with pytest.raises(RuntimeError): | |
| adapter.process_manga_page(b"img") | |
| # --- descriptions ------------------------------------------------------------ | |
| def test_generate_image_description(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"description": "a red sky"}) | |
| out = adapter.generate_image_description(b"img", prompt="describe") | |
| assert out == "a red sky" | |
| assert req.call_args.kwargs["json"]["prompt"] == "describe" | |
| def test_generate_video_description(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"description": "a fight scene"}) | |
| out = adapter.generate_video_description(b"vid") | |
| assert out == "a fight scene" | |
| assert req.call_args.args[1] == "http://brain:5000/video/describe" | |
| # --- rerank / diagnostics / uncertainty / visual rerank / late interaction --- | |
| def test_rerank_documents(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"scores": [0.8, 0.2]}) | |
| out = adapter.rerank_documents("q", ["d1", "d2"]) | |
| assert out == [0.8, 0.2] | |
| body = req.call_args.kwargs["json"] | |
| assert body == {"query": "q", "documents": ["d1", "d2"]} | |
| assert req.call_args.args[1] == "http://brain:5000/v1/rerank" | |
| def test_get_diagnostics(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"diagnostics": {"attn": [1]}}) | |
| out = adapter.get_diagnostics("p", "c") | |
| assert out == {"attn": [1]} | |
| def test_calculate_uncertainty(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"uncertainty_metrics": {"entropy": 0.4}}) | |
| out = adapter.calculate_uncertainty("p", "c") | |
| assert out == {"entropy": 0.4} | |
| def test_visual_rerank(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"reranked_items": [{"url": "a", "score": 1}]}) | |
| out = adapter.visual_rerank("q", ["a", "b"], system_prompt="judge") | |
| assert out == [{"url": "a", "score": 1}] | |
| body = req.call_args.kwargs["json"] | |
| assert body["image_urls"] == ["a", "b"] | |
| assert body["system_prompt"] == "judge" | |
| def test_get_multimodal_late_interaction(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"embeddings": [[0.1, 0.2], [0.3, 0.4]]}) | |
| out = adapter.get_multimodal_late_interaction(b"img") | |
| assert out == [[0.1, 0.2], [0.3, 0.4]] | |
| def test_rerank_documents_reraises(adapter): | |
| with patch(MODULE, side_effect=RuntimeError("x")): | |
| with pytest.raises(RuntimeError): | |
| adapter.rerank_documents("q", ["d"]) | |
| # --- moderation: native path + super() fallback ------------------------------ | |
| def test_moderate_content_returns_native_payload(adapter): | |
| with patch(MODULE) as req: | |
| req.return_value = _resp({"moderation": {"is_safe": True, "score": 0.0}}) | |
| out = adapter.moderate_content("hello", ["nsfw"]) | |
| assert out == {"is_safe": True, "score": 0.0} | |
| body = req.call_args.kwargs["json"] | |
| assert body == {"text": "hello", "categories": ["nsfw"]} | |
| def test_moderate_content_falls_back_to_super_on_error(adapter): | |
| # Native endpoint fails -> falls back to base class keyword heuristic, which | |
| # itself calls generate_structured -> generate. Make generate raise so the | |
| # keyword fallback inside the base class is exercised, with a flagged word. | |
| with patch(MODULE, side_effect=RuntimeError("api down")): | |
| with patch.object(adapter, "generate", side_effect=RuntimeError("no llm")): | |
| out = adapter.moderate_content("this is nsfw content", ["x"]) | |
| assert out["is_safe"] is False | |
| assert "nsfw" in out["detected_categories"] | |
| assert out["action"] == "block" | |
| def test_moderate_content_pins_the_configured_model(): | |
| a = BrainAPIAdapter(api_url="http://brain:5000", api_key="k", model="small:1.5b") | |
| with patch("adapters.inference.brain_api_adapter.safe_http_request") as req: | |
| req.return_value = MagicMock( | |
| json=MagicMock(return_value={"moderation": {"is_safe": True}}) | |
| ) | |
| out = a.moderate_content("texte", ["HATE_SPEECH"]) | |
| assert req.call_args.kwargs["json"]["model"] == "small:1.5b" | |
| assert out == {"is_safe": True} | |
| # --- generate_structured delegates to base implementation -------------------- | |
| def test_generate_structured_delegates_to_super(adapter): | |
| # Base generate_structured calls self.generate; return JSON text it can parse. | |
| with patch.object(adapter, "generate") as gen: | |
| gen.return_value = InferenceResponse(text='{"name": "Naruto", "rank": 1}') | |
| out = adapter.generate_structured("extract", dict) | |
| assert out == {"name": "Naruto", "rank": 1} | |
| def test_generate_structured_reraises_on_total_failure(adapter): | |
| with patch.object(adapter, "generate", side_effect=RuntimeError("boom")): | |
| with pytest.raises(RuntimeError, match="boom"): | |
| adapter.generate_structured("extract", dict, max_retries=1) | |
| # --- health_check: online / degraded / offline ------------------------------- | |
| def test_health_check_online(adapter): | |
| with patch("adapters.inference.brain_api_adapter.httpx.get") as get: | |
| get.return_value = MagicMock(status_code=200) | |
| out = adapter.health_check() | |
| assert out["status"] == "online" | |
| assert out["engine"] == "BrainAPI" | |
| assert "latency_ms" in out | |
| assert get.call_args.args[0] == "http://brain:5000/health" | |
| def test_health_check_honors_the_remote_verdict_over_the_status_code(adapter): | |
| # The brain answers 200 while grading its own engine as degraded (e.g. Ollama | |
| # does not serve the configured model). Trusting the status code alone kept | |
| # exactly that brain in the FallbackAdapter rotation, 404-ing every call. | |
| with patch("adapters.inference.brain_api_adapter.httpx.get") as get: | |
| get.return_value = MagicMock( | |
| status_code=200, | |
| json=MagicMock(return_value={"status": "degraded", "engine": "Ollama"}), | |
| ) | |
| out = adapter.health_check() | |
| assert out["status"] == "degraded" | |
| assert out["engine"] == "BrainAPI" | |
| def test_health_check_online_when_remote_body_reports_online(adapter): | |
| with patch("adapters.inference.brain_api_adapter.httpx.get") as get: | |
| get.return_value = MagicMock( | |
| status_code=200, | |
| json=MagicMock(return_value={"status": "online", "engine": "Ollama"}), | |
| ) | |
| out = adapter.health_check() | |
| assert out["status"] == "online" | |
| def test_health_check_degraded_on_non_200(adapter): | |
| with patch("adapters.inference.brain_api_adapter.httpx.get") as get: | |
| get.return_value = MagicMock(status_code=503) | |
| out = adapter.health_check() | |
| assert out["status"] == "degraded" | |
| def test_health_check_offline_on_exception(adapter): | |
| with patch( | |
| "adapters.inference.brain_api_adapter.httpx.get", | |
| side_effect=httpx.ConnectError("refused"), | |
| ): | |
| out = adapter.health_check() | |
| assert out == {"status": "offline", "engine": "BrainAPI"} | |
| # --- error re-raise contract for the remaining passthrough methods ----------- | |
| # Each of these wraps safe_http_request in try/except that logs and re-raises. | |
| # Parametrized to exercise every error branch with a single helper. | |
| def test_passthrough_methods_reraise_on_error(adapter, call): | |
| with patch(MODULE, side_effect=RuntimeError("api boom")): | |
| with pytest.raises(RuntimeError, match="api boom"): | |
| call(adapter) | |