feat(backend): add LLM provider adapters for Ollama, OpenAI, Groq and Anthropic
This commit is contained in:
1 parent
1748b561bd
commit
9be09921bc
2 files changed
+527
No files matched your search
@@ -0,0 +1,226 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user