"""Testes dos adaptadores de LLM. Cada adaptador converte o formato de streaming do próprio provedor em deltas de texto. Os testes usam transportes falsos: verificam a conversão, não a rede. """ from __future__ import annotations import json import sys from pathlib import Path import httpx import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.config import Settings # noqa: E402 from app.llm import ( # noqa: E402 SYSTEM_PROMPT, AnthropicProvider, LLMProviderError, OllamaProvider, OpenAIProvider, StubProvider, build_provider, ) class FakeStreamResponse: """Dublê do contexto assíncrono devolvido por `httpx.AsyncClient.stream`.""" status_code = 200 def __init__(self, chunks): self._chunks = chunks async def __aenter__(self): return self async def __aexit__(self, *exc_info): return False async def aiter_lines(self): for chunk in self._chunks: text = chunk.decode() if isinstance(chunk, bytes) else chunk for line in text.splitlines(): yield line async def aread(self): return b"erro" class FakeStreamClient: """Dublê do `httpx.AsyncClient` que devolve um stream fixo.""" def __init__(self, chunks): self._chunks = chunks def stream(self, *args, **kwargs): return FakeStreamResponse(self._chunks) def collect(provider, messages=None, system=SYSTEM_PROMPT): import asyncio async def run(): chunks = [] async for delta in provider.stream(messages or [{"role": "user", "content": "olá"}], system): chunks.append(delta) return "".join(chunks) return asyncio.run(run()) # --------------------------------------------------------------------------- # # Seleção de provedor # --------------------------------------------------------------------------- # def test_build_provider_selects_ollama(): provider = build_provider(Settings(llm_provider="ollama")) assert isinstance(provider, OllamaProvider) def test_build_provider_selects_openai(): provider = build_provider(Settings(llm_provider="openai", openai_api_key="sk-x")) assert isinstance(provider, OpenAIProvider) def test_build_provider_selects_groq_as_openai_compatible(): provider = build_provider(Settings(llm_provider="groq", groq_api_key="gsk-x")) assert isinstance(provider, OpenAIProvider) assert provider.base_url.startswith("https://api.groq.com") def test_build_provider_selects_anthropic(): provider = build_provider(Settings(llm_provider="anthropic", anthropic_api_key="sk-x")) assert isinstance(provider, AnthropicProvider) def test_build_provider_selects_stub(): provider = build_provider(Settings(llm_provider="stub")) assert isinstance(provider, StubProvider) def test_build_provider_rejects_unknown_provider(): with pytest.raises(ValueError, match="não suportado"): build_provider(Settings(llm_provider="desconhecido")) def test_openai_provider_requires_api_key(): with pytest.raises(ValueError, match="OPENAI_API_KEY"): build_provider(Settings(llm_provider="openai", openai_api_key="")) # --------------------------------------------------------------------------- # # System prompt # --------------------------------------------------------------------------- # def test_system_prompt_contains_the_spec_directives(): assert "conselheiro espiritual e Pastor Online" in SYSTEM_PROMPT assert "única fonte de autoridade" in SYSTEM_PROMPT assert "livro, capítulo e versículo" in SYSTEM_PROMPT assert "tom acolhedor" in SYSTEM_PROMPT assert "Não crie doutrina própria" in SYSTEM_PROMPT def test_system_prompt_contains_the_crisis_scope(): assert "emergência" in SYSTEM_PROMPT.lower() assert "diagnóstico" in SYSTEM_PROMPT.lower() or "diagnost" in SYSTEM_PROMPT.lower() # --------------------------------------------------------------------------- # # Conversão de streaming # --------------------------------------------------------------------------- # def test_ollama_provider_converts_ndjson_stream(monkeypatch): provider = OllamaProvider(base_url="http://ollama:11434", model="qwen2.5:1.5b") lines = [ {"message": {"content": "Olá "}, "done": False}, {"message": {"content": "mundo"}, "done": False}, {"message": {"content": ""}, "done": True}, ] async def fake_stream(*args, **kwargs): for line in lines: yield f"{json.dumps(line)}\n".encode() monkeypatch.setattr( provider, "_client", FakeStreamClient([f"{json.dumps(line)}\n" for line in lines]) ) assert collect(provider) == "Olá mundo" def test_openai_provider_converts_sse_stream(monkeypatch): provider = OpenAIProvider( base_url="https://api.openai.com/v1", model="gpt-4o-mini", api_key="sk-test" ) chunks = [ 'data: {"choices":[{"delta":{"content":"Olá "}}]}\n\n'.encode(), 'data: {"choices":[{"delta":{"content":"mundo"}}]}\n\n'.encode(), b"data: [DONE]\n\n", ] async def fake_stream(*args, **kwargs): for chunk in chunks: yield chunk monkeypatch.setattr(provider, "_client", FakeStreamClient(chunks)) assert collect(provider) == "Olá mundo" def test_anthropic_provider_converts_event_stream(monkeypatch): provider = AnthropicProvider( base_url="https://api.anthropic.com", model="claude-sonnet-4-5", api_key="sk-test" ) events = [ 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Olá "}}\n\n'.encode(), 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"mundo"}}\n\n'.encode(), ] async def fake_stream(*args, **kwargs): for event in events: yield event monkeypatch.setattr(provider, "_client", FakeStreamClient(events)) assert collect(provider) == "Olá mundo" def test_stub_provider_returns_deterministic_text(): provider = StubProvider() text = collect(provider, [{"role": "user", "content": "Quem criou o mundo?"}]) assert "Gênesis 1:1" in text # --------------------------------------------------------------------------- # # Erros # --------------------------------------------------------------------------- # def test_provider_error_does_not_leak_the_api_key(): provider = OpenAIProvider( base_url="https://api.openai.com/v1", model="gpt-4o-mini", api_key="sk-super-secreta" ) def raise_error(*args, **kwargs): raise httpx.ConnectError("connection refused") monkeypatched = provider._client try: provider._client = type( "FailingClient", (), {"stream": staticmethod(raise_error)}, )() with pytest.raises(LLMProviderError) as exc: collect(provider) assert "sk-super-secreta" not in str(exc.value) assert "sk-super-secreta" not in str(exc.value.__cause__ or "") finally: provider._client = monkeypatched