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:
1 parent
ebf430c535
commit
68861b502c
7 files changed
+188
-8
No files matched your search
@@ -13,6 +13,11 @@ FRONTEND_PORT=8080
|
||||
LLM_PROVIDER=ollama
|
||||
|
||||
# --- Ollama (local, requer o profile "local": docker compose --profile local up) ---
|
||||
# ATENÇÃO: se você tiver OLLAMA_BASE_URL exportada no shell, ela VENCE este
|
||||
# arquivo na interpolação do compose. Confira o valor efetivo com:
|
||||
# docker compose --profile local config | grep OLLAMA_BASE_URL
|
||||
# E o que está em uso com: GET /api/health -> llm_endpoint
|
||||
# O adaptador usa a API nativa do Ollama (/api/chat): NÃO acrescente "/v1".
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_MODEL=qwen2.5:1.5b
|
||||
|
||||
|
||||
@@ -206,6 +206,32 @@ aceita `POST`.
|
||||
|
||||
Todas as variáveis ficam no `.env`. As principais:
|
||||
|
||||
> ### Cuidado: variável do shell vence o `.env`
|
||||
>
|
||||
> O docker-compose resolve `${VAR:-padrão}` olhando **primeiro o ambiente do
|
||||
> shell**, e só depois o arquivo `.env`. Uma variável exportada na sua sessão —
|
||||
> por outra ferramenta, um `export` no perfil, um `direnv` — sobrescreve o `.env`
|
||||
> **sem nenhum aviso**.
|
||||
>
|
||||
> Foi exatamente assim que o chat quebrou com "Provedor de LLM indisponível": um
|
||||
> `OLLAMA_BASE_URL=http://localhost:11434/v1` herdado do shell fez o backend
|
||||
> tentar conectar em `localhost` **dentro do próprio container**, onde não há
|
||||
> nada na porta 11434. O `.env` dizia `http://ollama:11434` e continuou dizendo,
|
||||
> sem efeito.
|
||||
>
|
||||
> Confira o valor que será realmente aplicado:
|
||||
>
|
||||
> ```bash
|
||||
> docker compose --profile local config | grep -A2 'ollama:' # ou:
|
||||
> docker compose --profile local config | grep OLLAMA_BASE_URL
|
||||
> curl -s localhost:8030/api/health | grep llm_endpoint # o que está em uso
|
||||
> ```
|
||||
>
|
||||
> Para o valor do `.env` valer, rode limpo: `env -u OLLAMA_BASE_URL docker compose --profile local up -d`.
|
||||
>
|
||||
> Nota: `/v1` é a superfície compatível com OpenAI. O adaptador Ollama usa a API
|
||||
> nativa (`/api/chat`), então a URL **não** deve terminar em `/v1`.
|
||||
|
||||
| Variável | Padrão | Efeito |
|
||||
|---|---|---|
|
||||
| `LLM_PROVIDER` | `ollama` | `ollama`, `openai`, `groq`, `anthropic` ou `stub` |
|
||||
@@ -230,6 +256,30 @@ Nenhum dos dois lados da busca encontrou nada acima do piso. Confira `/api/statu
|
||||
pergunta citar uma referência explícita (`João 3:16`), a busca é determinística por
|
||||
metadados e não depende de limiar nenhum.
|
||||
|
||||
**"Provedor de LLM indisponível" no chat.**
|
||||
Três causas possíveis, nesta ordem de probabilidade:
|
||||
|
||||
1. **A URL não é a que você acha.** Veja `curl -s localhost:8030/api/health | grep llm_endpoint`
|
||||
e compare com o `.env`. Variável exportada no shell sobrescreve o `.env` em
|
||||
silêncio (ver aviso na seção Configuração). `localhost` dentro do container é o
|
||||
próprio container — para falar com o Ollama do Compose o nome é `ollama`.
|
||||
2. **O serviço não está no ar.** Com `LLM_PROVIDER=ollama`, o Ollama só sobe com o
|
||||
profile: `docker compose --profile local up -d`. Confira com
|
||||
`docker compose exec -T backend curl -s http://ollama:11434/api/tags`.
|
||||
3. **O modelo não está no volume.** `ollama run` no host não alimenta o container;
|
||||
o volume é `ollama:/root/.ollama`. Liste os modelos de dentro do container:
|
||||
`docker compose exec ollama ollama list`.
|
||||
|
||||
O log do backend traz a endpoint tentada: `docker compose logs backend | grep -i provedor`.
|
||||
|
||||
**A primeira resposta demora muito.**
|
||||
Espera mesmo. O modelo precisa ser carregado na RAM e o prompt tem ~1.800 tokens
|
||||
(system + 6 trechos com janela de contexto), tudo em CPU. Medido com `llama3.2:1b`:
|
||||
~70 s com o modelo quente, bem mais no primeiro atendimento após o arranque. Como a
|
||||
validação de citação exige o texto completo antes de exibir, não há saída
|
||||
parcial. Modelos menores respondem mais rápido; `TOP_K` menor também reduz o
|
||||
prompt.
|
||||
|
||||
**As respostas citam versículos que parecem fora do tema.**
|
||||
Verifique em `/api/status` o campo `model`. Se aparecer
|
||||
`hash-determinístico (sem semântica)` em vez do nome do modelo configurado, o
|
||||
|
||||
+26
-8
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user