Causa do 'Provedor de LLM indisponível' no ambiente com Ollama local foi externa: OLLAMA_BASE_URL exportada no shell (http://localhost:11434/v1) vence o .env na interpolação do docker-compose, e localhost dentro do container é o próprio container. Nada no app indicava a URL em uso. Dois defeitos de código que tornavam isso indetectável — e um deles produziria a mesma mensagem mesmo com a URL correta: - _BaseProvider criava o httpx.AsyncClient com o atributo de classe (120 s) e as subclasses atribuíam self.timeout depois. O httpx congela o timeout na construção, então os 300 s pedidos pelo Ollama nunca valeram: um primeiro atendimento com modelo frio era cortado em 120 s com exatamente a mensagem 'provedor indisponível'. O timeout agora é passado ao super(). - LLMProviderError de conexão não citava o endereço tentado; o log dizia só 'All connection attempts failed'. Passa a incluir a endpoint, com credenciais user:pass@ redigidas. /api/health expõe llm_endpoint para comparação direta com o .env. README e .env.example documentam a precedência shell > .env, o fato de /v1 ser a superfície OpenAI-compatível (o adaptador usa /api/chat nativo) e a latência medida do primeiro atendimento.
303 lines
9.7 KiB
Python
303 lines
9.7 KiB
Python
"""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
|
|
|
|
|
|
def test_system_prompt_declares_the_off_topic_scope():
|
|
"""O portão por similaridade não funciona: precisa estar no prompt.
|
|
|
|
Medido no corpus real, nenhum valor de MIN_SIMILARITY entre 0,30 e 0,50
|
|
bloqueou uma única pergunta fora do tema — "como fazer um bolo de chocolate"
|
|
bate 0,544 em Levítico 7:14 e "cotação do dólar" bate 0,494. Num corpus de
|
|
31 mil versículos sempre existe algum acima de qualquer limiar utilizável,
|
|
então a recusa tem de ser instruída ao modelo, não filtrada por nota.
|
|
"""
|
|
lowered = SYSTEM_PROMPT.lower()
|
|
|
|
assert "receita" in lowered
|
|
assert "apenas do texto bíblico" in lowered
|
|
assert "não use os trechos recuperados" in lowered
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Timeout efetivo e erros acionáveis
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def close(provider):
|
|
import asyncio
|
|
|
|
asyncio.run(provider.aclose())
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider_factory,esperado",
|
|
[
|
|
(lambda: OllamaProvider(base_url="http://x", model="m"), 300),
|
|
(lambda: OllamaProvider(base_url="http://x", model="m", timeout=45), 45),
|
|
(lambda: OpenAIProvider(base_url="http://x", model="m", api_key="k"), 120),
|
|
(lambda: AnthropicProvider(base_url="http://x", model="m", api_key="k"), 120),
|
|
],
|
|
)
|
|
def test_configured_timeout_reaches_the_http_client(provider_factory, esperado):
|
|
"""Atribuir `self.timeout` depois de criar o cliente não muda o cliente.
|
|
|
|
O `httpx.AsyncClient` congela o timeout na construção. Atribuir o atributo
|
|
em seguida deixava o Ollama preso aos 120 s da classe mesmo pedindo 300 —
|
|
pouco para um modelo local em CPU, e o sintoma é justamente "provedor
|
|
indisponível".
|
|
"""
|
|
provider = provider_factory()
|
|
|
|
assert provider._client.timeout.read == esperado
|
|
close(provider)
|
|
|
|
|
|
def test_connection_error_names_the_endpoint():
|
|
"""Sem a URL, o operador vê só 'falha de conexão' e não sabe o que testar.
|
|
|
|
Foi assim que um `OLLAMA_BASE_URL` herdado do shell, apontando para
|
|
`localhost` dentro do container, ficou invisível: o log dizia apenas "All
|
|
connection attempts failed".
|
|
"""
|
|
provider = OllamaProvider(base_url="http://127.0.0.1:1", model="m")
|
|
|
|
with pytest.raises(LLMProviderError) as excinfo:
|
|
collect(provider)
|
|
|
|
assert "127.0.0.1:1" in str(excinfo.value)
|
|
|
|
|
|
def test_connection_error_does_not_leak_the_api_key():
|
|
provider = OpenAIProvider(
|
|
base_url="http://127.0.0.1:1", model="m", api_key="sk-segredo-real"
|
|
)
|
|
|
|
with pytest.raises(LLMProviderError) as excinfo:
|
|
collect(provider)
|
|
|
|
assert "sk-segredo-real" not in str(excinfo.value)
|