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
+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"
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user