"""Dostawcy LLM (LOG-31) — bez wołania jakiegokolwiek prawdziwego modelu. Transport podstawiamy przez httpx.MockTransport, więc testy są szybkie, deterministyczne i nic nie wychodzi na zewnątrz. """ import httpx import pytest from app.llm import factory from app.llm.base import Completion, LLMError from app.llm.providers import AnthropicProvider, ChatCompletionsProvider def _mock_client(handler): """Podmienia httpx.Client na wersję z transportem testowym.""" class _C(httpx.Client): def __init__(self, *a, **kw): kw["transport"] = httpx.MockTransport(handler) super().__init__(*a, **kw) return _C @pytest.fixture def chat_ok(monkeypatch): def handler(request): assert request.url.path.endswith("/chat/completions") return httpx.Response(200, json={ "model": "test-model", "choices": [{"message": {"content": "Horoskop testowy."}}], "usage": {"prompt_tokens": 100, "completion_tokens": 50}, }) monkeypatch.setattr(httpx, "Client", _mock_client(handler)) # ------------------------------------------------------- protokół chat/completions def test_local_provider_generates(chat_ok): p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False) out = p.generate("prompt", 500) assert isinstance(out, Completion) assert out.text == "Horoskop testowy." assert out.usage["completion_tokens"] == 50 def test_local_provider_does_not_leave_lan(chat_ok): p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False) assert p.generate("prompt", 100).leaves_lan is False def test_cloud_provider_marks_leaving_lan(chat_ok): p = ChatCompletionsProvider("openai", "https://api.openai.com/v1", "m", "klucz", leaves_lan=True) assert p.generate("prompt", 100).leaves_lan is True def test_api_key_sent_only_when_set(monkeypatch): seen = {} def handler(request): seen["auth"] = request.headers.get("authorization") return httpx.Response(200, json={"choices": [{"message": {"content": "x"}}]}) monkeypatch.setattr(httpx, "Client", _mock_client(handler)) ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10) assert seen["auth"] is None ChatCompletionsProvider("openai", "http://x/v1", "m", "tajny").generate("p", 10) assert seen["auth"] == "Bearer tajny" def test_http_error_becomes_readable_message(monkeypatch): monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(400, text="zly model"))) with pytest.raises(LLMError, match="400"): ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10) def test_malformed_response_reported(monkeypatch): monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={"nonsens": 1}))) with pytest.raises(LLMError, match="kształt"): ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10) def test_retries_then_succeeds(monkeypatch): calls = {"n": 0} def handler(request): calls["n"] += 1 if calls["n"] < 3: return httpx.Response(429, text="za duzo") return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]}) monkeypatch.setattr(httpx, "Client", _mock_client(handler)) monkeypatch.setattr("app.llm.providers.time.sleep", lambda s: None) # bez czekania assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10).text == "ok" assert calls["n"] == 3 # ------------------------------------------------------------------- Anthropic def test_anthropic_generates(monkeypatch): def handler(request): assert request.url.path.endswith("/v1/messages") assert request.headers.get("x-api-key") == "klucz" assert request.headers.get("anthropic-version") return httpx.Response(200, json={ "model": "claude-x", "content": [{"type": "text", "text": "Prognoza."}], "usage": {"input_tokens": 10, "output_tokens": 5}, }) monkeypatch.setattr(httpx, "Client", _mock_client(handler)) out = AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100) assert out.text == "Prognoza." and out.leaves_lan is True def test_anthropic_requires_key(): with pytest.raises(LLMError, match="LLM_API_KEY"): AnthropicProvider("https://api.anthropic.com", "m", "").generate("p", 10) # --------------------------------------------------------------------- fabryka def test_default_provider_is_local(monkeypatch): monkeypatch.delenv("LLM_PROVIDER", raising=False) monkeypatch.delenv("LLM_MODEL", raising=False) monkeypatch.delenv("LLM_BASE_URL", raising=False) p = factory.build_provider() assert p.name == "local" # domyślnie NIC nie opuszcza sieci — prompt niesie opisy z baz (LOG-32) assert p.leaves_lan is False def test_openai_requires_key(monkeypatch): monkeypatch.delenv("LLM_API_KEY", raising=False) with pytest.raises(LLMError, match="LLM_API_KEY"): factory.build_provider("openai") def test_unknown_provider_rejected(): with pytest.raises(LLMError, match="Nieznany dostawca"): factory.build_provider("bzdura") def test_env_overrides_model_and_url(monkeypatch): monkeypatch.setenv("LLM_MODEL", "moj-model") monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1") p = factory.build_provider("local") assert p.model == "moj-model" and p.base_url == "http://serwer:8000/v1"