fix: timeout do provedor nunca chegava ao httpx; endpoint agora visível

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.
This commit is contained in:
omp committed 2026-11-02 17:57:42 -03:00
1 parent ebf430c535
commit 68861b502c
7 files changed
+188 -8

No files matched your search

+26 -8
View File
@@ -46,6 +46,14 @@ class LLMProviderError(RuntimeError):
self.__cause__ = cause
def _redact_credentials(url: str) -> str:
"""Remove credenciais `user:pass@` de uma URL antes de exibi-la em log."""
scheme, _, rest = url.partition("://")
if "@" in rest:
return f"{scheme}://***@{rest.rsplit('@', 1)[-1]}" if scheme else rest
return url
class LLMProvider(Protocol):
"""Interface comum dos provedores de LLM."""
@@ -61,19 +69,32 @@ class _BaseProvider:
timeout: float = 120.0
def __init__(self) -> None:
def __init__(self, timeout: float | None = None, endpoint: str | None = None):
# O `httpx.AsyncClient` congela o timeout na construção: atribuir
# `self.timeout` depois não teria efeito nenhum.
if timeout is not None:
self.timeout = timeout
self.endpoint = _redact_credentials(endpoint) if endpoint else None
self._client = httpx.AsyncClient(timeout=self.timeout)
async def aclose(self) -> None:
await self._client.aclose()
def _guard(self, exc: Exception) -> LLMProviderError:
"""Converte erro de rede em erro de provedor, sem vazar segredos."""
"""Converte erro de rede em erro de provedor, sem vazar segredos.
A endpoint vai na mensagem porque foi assim que uma configuração errada
ficou invisível: um `OLLAMA_BASE_URL` herdado do shell apontando para
`localhost` dentro do container produzia apenas "All connection attempts
failed", sem dizer o que foi tentado.
"""
message = str(exc)
for secret in ("sk-", "gsk-", "Bearer "):
if secret in message:
message = "falha de comunicação com o provedor de LLM"
break
if self.endpoint:
message = f"{message} (endpoint tentada: {self.endpoint})"
return LLMProviderError(message, cause=exc)
@@ -81,10 +102,9 @@ class OllamaProvider(_BaseProvider):
"""Provedor local Ollama (endpoint `/api/chat` em NDJSON)."""
def __init__(self, base_url: str, model: str, timeout: float = 300.0):
super().__init__()
self.base_url = base_url.rstrip("/")
self.model = model
self.timeout = timeout
super().__init__(timeout=timeout, endpoint=self.base_url)
async def stream(
self, messages: Sequence[dict], system: str = SYSTEM_PROMPT
@@ -129,11 +149,10 @@ class OpenAIProvider(_BaseProvider):
api_key: str,
timeout: float = 120.0,
):
super().__init__()
self.base_url = base_url.rstrip("/")
self.model = model
self.api_key = api_key
self.timeout = timeout
super().__init__(timeout=timeout, endpoint=self.base_url)
async def stream(
self, messages: Sequence[dict], system: str = SYSTEM_PROMPT
@@ -185,11 +204,10 @@ class AnthropicProvider(_BaseProvider):
api_key: str,
timeout: float = 120.0,
):
super().__init__()
self.base_url = base_url.rstrip("/")
self.model = model
self.api_key = api_key
self.timeout = timeout
super().__init__(timeout=timeout, endpoint=self.base_url)
async def stream(
self, messages: Sequence[dict], system: str = SYSTEM_PROMPT
+2
View File
@@ -189,6 +189,8 @@ def create_app(
return HealthResponse(
status="ok",
llm_provider=settings.llm_provider,
# A URL em uso de fato, não a que o .env parece dizer.
llm_endpoint=getattr(services.provider, "endpoint", None),
indexing="indexing" if state == "indexing" else "ready",
)
+4
View File
@@ -46,6 +46,10 @@ class HealthResponse(BaseModel):
status: Literal["ok"] = "ok"
llm_provider: str
indexing: Literal["ready", "indexing", "error"]
# A URL realmente em uso. Uma `OLLAMA_BASE_URL` herdada do shell pode
# sobrescrever o `.env` silenciosamente; sem este campo o operador não tem
# como perceber que o backend está apontando para outro lugar.
llm_endpoint: str | None = None
version: str = "1.0.0"
+60
View File
@@ -240,3 +240,63 @@ def test_system_prompt_declares_the_off_topic_scope():
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)
+41
View File
@@ -278,3 +278,44 @@ def test_embedder_fallback_is_surfaced_in_status(monkeypatch, tmp_path):
assert any("semântica" in error for error in body["errors"])
assert app.state.services.embedder.name == HashEmbedder.name
def test_health_reports_the_llm_endpoint_in_use(client):
"""/api/health precisa mostrar a URL efetiva, não a que o .env parece dizer.
Variável de shell vence o `.env` na interpolação do compose: um
`OLLAMA_BASE_URL` herdado do ambiente apontando para `localhost` dentro do
container derrubava o chat sem nenhum indício do endereço tentado.
"""
body = client.get("/api/health").json()
assert body["llm_provider"] == "stub"
assert body["llm_endpoint"] is None, "o stub não tem endpoint"
def test_health_exposes_ollama_endpoint_without_credentials(tmp_path):
"""A endpoint vai para o health, mas credenciais na URL não podem vazar."""
from app.llm import OllamaProvider
settings = Settings(
corpus_dir=tmp_path / "corpus",
vector_db_dir=tmp_path / "vector_db",
embedding_model="hash-test",
llm_provider="ollama",
auto_index=False,
)
provider = OllamaProvider(
base_url="http://user:segredo@ollama:11434", model="m"
)
app = create_app(
settings=settings,
store=InMemoryVectorStore(),
embedder=HashEmbedder(dimensions=64),
provider=provider,
indexer=FakeIndexer(),
)
body = TestClient(app).get("/api/health").json()
assert "segredo" not in body["llm_endpoint"]
assert body["llm_endpoint"].endswith("ollama:11434")