@danhachuel/pandanews 0.4.6 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/backend/__pycache__/__init__.cpython-314.pyc +0 -0
- package/backend/app/main.py +1 -1
- package/backend/app/routers/configuracoes.py +31 -6
- package/backend/core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/backend/core/__pycache__/config.cpython-314.pyc +0 -0
- package/backend/core/__pycache__/llm_router.cpython-314.pyc +0 -0
- package/backend/core/config.py +6 -1
- package/backend/core/llm_router.py +188 -69
- package/backend/tests/test_llm_fallback.py +216 -0
- package/frontend/dist/assets/{index-BA6xr3Hb.js → index-CF0vVO6o.js} +14 -14
- package/frontend/dist/assets/{index-tVJXH-iW.css → index-ExoYg5_I.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
Todas as versões publicadas são registradas aqui antes do publish (regra em AGENTS.md). A etiqueta visual usa PATCH de dois dígitos (ex.: 0.1.00); o registry npm normaliza para a versão canônica (ex.: 0.1.0).
|
|
4
4
|
|
|
5
|
+
## 0.4.08 (2026-11-08)
|
|
6
|
+
|
|
7
|
+
- **Mantém o rótulo `gemini` em `noticias.llm_provider`**: a reescrita do router (0.4.07) devolvia o id interno `google`, o que regrediria o alinhamento banco/UI feito na 0.4.05/0.4.06. `gerar_texto` volta a devolver `"gemini"` quando o provedor do Google responde — Dashboards, filtros e exibição continuam consistentes
|
|
8
|
+
- Nenhuma outra mudança funcional
|
|
9
|
+
|
|
10
|
+
## 0.4.07 (2026-11-08)
|
|
11
|
+
|
|
12
|
+
- **CORREÇÃO DEFINITIVA da NVIDIA NIM — réplica do Thunderbolt**: `backend/core/llm_router.py` reescrito para falar com provedores OpenAI-compatible por **HTTP direto** (`POST {base_url}/chat/completions`, `Authorization: Bearer`, `Content-Type: application/json`, `Accept: application/json`), a mesma estrutura que funciona no projeto Thunderbolt — `requests` trocado por `httpx`, já dependência do projeto
|
|
13
|
+
- **Causa raiz 1 — dependência fantasma**: o 0.4.05/0.4.06 importava `from openai import APIStatusError, OpenAI`, mas `openai` **não está** em `requirements.txt`. Em instalação nova o backend morria com `ImportError` antes de qualquer chamada — era a exceção quebrando o fluxo antes da troca de provedor. A dependência foi eliminada: nada de SDK extra para instalar
|
|
14
|
+
- **Causa raiz 2 — modelo em fim de vida**: o default `meta/llama-3.3-70b-instruct` chegou ao fim da vida em 2026-08-26 e devolve **HTTP 410 Gone**; `meta/llama-3.1-nemotron-70b-instruct` devolve **HTTP 404 page not found**. O default agora é `mistralai/mistral-large-2-instruct`, verificado no catálogo ao vivo (`GET https://integrate.api.nvidia.com/v1/models`)
|
|
15
|
+
- **Endpoint à prova de 404**: a Base URL é normalizada antes do uso (`/v1`, `/v1/models` e `/v1/chat/completions` são aceitos sem duplicar o sufixo) — o `/chat/completions` duplicado era uma fonte de 404
|
|
16
|
+
- **Fallback implacável**: `except Exception` captura tudo — HTTP 4xx/5xx, 404/410, timeout, rede e JSON inválido. O log registra exatamente `Erro na NVIDIA ({e}). Acionando Google Gemini...` e o próximo provedor assume na mesma iteração; a notícia é gerada sem tela de erro
|
|
17
|
+
- **"Testar conexão" valida inferência real**: `POST /api/v1/llm/test` executa um `chat/completions` de verdade com `max_tokens=1` (não lista modelos). A string de erro do provedor volta no JSON para o frontend exibir; 404/410 marcam `degraded` com instrução de trocar de modelo
|
|
18
|
+
- **Proxy de modelos reforçado**: `/configuracoes/llm/nvidia/models` normaliza a URL, distingue 401/403 (chave recusada) de outros erros e devolve `modelo_atual_disponivel`
|
|
19
|
+
- **Frontend**: aviso vermelho quando o modelo selecionado saiu do catálogo da NVIDIA (404/410) — o usuário vê o problema antes de clicar em testar, com a indicação de que as gerações estão caindo no Gemini
|
|
20
|
+
- **Teste funcional novo**: `backend/tests/test_llm_fallback.py` (13 verificações, roda sem pytest e sem servidor) cobre o fallback no 410, a mensagem de log exigida, o erro quando todos os provedores falham, o formato do `test_provider` e a normalização do endpoint — agregado ao CI
|
|
21
|
+
- Colisão de versão resolvida por renumeração (0.4.06 já publicado), conforme a regra de sessões simultâneas do AGENTS.md
|
|
22
|
+
|
|
5
23
|
## 0.4.04 (2026-11-08)
|
|
6
24
|
|
|
7
25
|
- **HOTFIX real — fim das janelas de CMD no instalador**: o 0.4.03 blindou apenas o launcher (`start.js`) e deixou o **caminho de instalação** descoberto. No Windows, `tar` (extração do Python portátil), `python -m venv` e `pip install -r requirements.txt` eram lançados **sem `windowsHide`** — era aí que as múltiplas janelas de terminal apareciam. Os três `spawnSync` de `scripts/install.js` agora nascem com `windowsHide: true` (`CREATE_NO_WINDOW`)
|
|
Binary file
|
package/backend/app/main.py
CHANGED
|
@@ -48,27 +48,52 @@ def listar_modelos_nvidia(db: Session = Depends(get_db)) -> dict:
|
|
|
48
48
|
"""Lista os modelos NVIDIA NIM disponíveis para a chave configurada.
|
|
49
49
|
|
|
50
50
|
Proxy: a API key fica no banco local e nunca é exposta ao frontend.
|
|
51
|
-
Retorna 400 sem chave; 502 em falha de consulta.
|
|
51
|
+
Retorna 400 sem chave; 502 em falha de consulta. A Base URL é normalizada
|
|
52
|
+
pelo mesmo helper do Thunderbolt, então ``/v1``, ``/v1/models`` e
|
|
53
|
+
``/v1/chat/completions`` são aceitos sem duplicar o sufixo.
|
|
52
54
|
"""
|
|
53
55
|
import httpx
|
|
54
56
|
|
|
55
|
-
from backend.core.llm_router import provider_config
|
|
57
|
+
from backend.core.llm_router import _normalizar_base_url, provider_config
|
|
56
58
|
|
|
57
59
|
cfg = provider_config(db, "nvidia")
|
|
58
60
|
if not cfg["api_key"]:
|
|
59
61
|
raise HTTPException(status_code=400, detail="API Key da NVIDIA não configurada — adicione-a antes de listar os modelos.")
|
|
60
|
-
|
|
62
|
+
base = _normalizar_base_url(cfg["base_url"])
|
|
63
|
+
if not base:
|
|
64
|
+
raise HTTPException(status_code=400, detail=f"Base URL da NVIDIA inválida: {cfg['base_url']!r}")
|
|
65
|
+
url = f"{base}/models"
|
|
61
66
|
try:
|
|
62
|
-
resposta = httpx.get(
|
|
67
|
+
resposta = httpx.get(
|
|
68
|
+
url,
|
|
69
|
+
headers={"Accept": "application/json", "Authorization": f"Bearer {cfg['api_key']}"},
|
|
70
|
+
timeout=20.0,
|
|
71
|
+
)
|
|
63
72
|
except httpx.HTTPError as exc:
|
|
64
73
|
raise HTTPException(status_code=502, detail=f"Erro ao consultar a NVIDIA: {exc}") from exc
|
|
74
|
+
if resposta.status_code in (401, 403):
|
|
75
|
+
raise HTTPException(
|
|
76
|
+
status_code=502,
|
|
77
|
+
detail=f"A NVIDIA recusou a API key (HTTP {resposta.status_code}). Verifique a chave em Configurações.",
|
|
78
|
+
)
|
|
65
79
|
if resposta.status_code != 200:
|
|
66
80
|
raise HTTPException(
|
|
67
81
|
status_code=502,
|
|
68
82
|
detail=f"NVIDIA respondeu {resposta.status_code}: {resposta.text[:200]}",
|
|
69
83
|
)
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
try:
|
|
85
|
+
dados = resposta.json()
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
raise HTTPException(status_code=502, detail="A NVIDIA devolveu uma resposta que não é JSON.") from exc
|
|
88
|
+
ids = sorted(str(m.get("id")) for m in (dados.get("data") or []) if m.get("id"))
|
|
89
|
+
if not ids:
|
|
90
|
+
raise HTTPException(status_code=502, detail="A NVIDIA não devolveu nenhum modelo para esta chave.")
|
|
91
|
+
return {
|
|
92
|
+
"models": ids,
|
|
93
|
+
"modelo_atual": cfg["model"],
|
|
94
|
+
"base_url": cfg["base_url"],
|
|
95
|
+
"modelo_atual_disponivel": cfg["model"] in ids,
|
|
96
|
+
}
|
|
72
97
|
|
|
73
98
|
|
|
74
99
|
@router.post("/testar-supabase")
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/backend/core/config.py
CHANGED
|
@@ -40,7 +40,12 @@ DEFAULT_LLM_BASE_URLS = {
|
|
|
40
40
|
"nvidia": "https://integrate.api.nvidia.com/v1",
|
|
41
41
|
"google": "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
42
42
|
}
|
|
43
|
+
# Modelos precisam existir no catálogo do provedor. O default anterior da
|
|
44
|
+
# NVIDIA (meta/llama-3.3-70b-instruct) chegou ao fim da vida em 2026-08-26 e
|
|
45
|
+
# passou a devolver HTTP 410 "Gone" — o sintoma que o usuário via como 404.
|
|
46
|
+
# `mistralai/mistral-large-2-instruct` está no catálogo atual (verificado
|
|
47
|
+
# contra https://integrate.api.nvidia.com/v1/models).
|
|
43
48
|
DEFAULT_LLM_MODELS = {
|
|
44
|
-
"nvidia": "
|
|
49
|
+
"nvidia": "mistralai/mistral-large-2-instruct",
|
|
45
50
|
"google": "gemini-3.8-flash",
|
|
46
51
|
}
|
|
@@ -1,31 +1,86 @@
|
|
|
1
|
-
"""Router de LLM do PandaNews
|
|
1
|
+
"""Router de LLM do PandaNews — réplica da integração NVIDIA do Thunderbolt.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
O projeto Thunderbolt (referência que funciona) fala com provedores
|
|
4
|
+
OpenAI-compatible — NVIDIA NIM, Gemini etc. — por **HTTP direto**:
|
|
5
|
+
|
|
6
|
+
POST {base_url}/chat/completions
|
|
7
|
+
Authorization: Bearer <api_key>
|
|
8
|
+
Content-Type: application/json
|
|
9
|
+
{"model": ..., "messages": [...], "max_tokens": ..., "temperature": 0}
|
|
10
|
+
|
|
11
|
+
Este módulo copia essa estrutura exata, trocando `requests` por `httpx` (já
|
|
12
|
+
dependência do PandaNews). Nenhum SDK novo é necessário — em particular a
|
|
13
|
+
biblioteca `openai` **não** é usada: ela não está declarada em
|
|
14
|
+
requirements.txt, então instalá-la quebraria o backend com ImportError em
|
|
15
|
+
toda instalação nova (foi exatamente o que derrubou o fluxo antes).
|
|
16
|
+
|
|
17
|
+
Fallback implacável: qualquer exceção — HTTP 4xx/5xx (inclusive 404/410 de
|
|
18
|
+
modelo descontinuado), timeout, erro de rede ou JSON inválido — é registrada e
|
|
19
|
+
o próximo provedor da lista é acionado imediatamente. Nada escapa.
|
|
8
20
|
"""
|
|
9
21
|
from __future__ import annotations
|
|
10
22
|
|
|
11
23
|
import logging
|
|
12
|
-
import
|
|
24
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
13
25
|
|
|
14
26
|
import httpx
|
|
15
|
-
from openai import APIStatusError, OpenAI
|
|
16
27
|
|
|
17
28
|
from backend.core.config import DEFAULT_LLM_BASE_URLS, DEFAULT_LLM_MODELS
|
|
18
29
|
|
|
19
30
|
PROVEDORES = ("nvidia", "google")
|
|
20
31
|
|
|
21
|
-
#
|
|
22
|
-
|
|
32
|
+
# Nomes exibidos no log — a mensagem de fallback usa estes rótulos.
|
|
33
|
+
NOMES_PROVEDORES = {"nvidia": "NVIDIA", "google": "Google Gemini"}
|
|
34
|
+
|
|
35
|
+
TIMEOUT_SEGUNDOS = 60.0
|
|
23
36
|
|
|
24
37
|
# Logger filho do uvicorn.error: mensagens vão para o backend.log (e stderr),
|
|
25
38
|
# com a disciplina de logs do servidor.
|
|
26
39
|
logger = logging.getLogger("uvicorn.error.pandanews")
|
|
27
40
|
|
|
28
41
|
|
|
42
|
+
# --------------------------------------------------------------- endpoints
|
|
43
|
+
|
|
44
|
+
def _normalizar_base_url(base_url: str) -> str:
|
|
45
|
+
"""Mantém apenas esquema/host/path da Base URL (como o Thunderbolt).
|
|
46
|
+
|
|
47
|
+
Aceita tanto ``https://host/v1`` quanto ``https://host/v1/models`` ou
|
|
48
|
+
``https://host/v1/chat/completions`` — o sufixo é descartado/refeito,
|
|
49
|
+
evitando o ``/chat/completions`` duplicado que provocaria 404.
|
|
50
|
+
"""
|
|
51
|
+
raw = str(base_url or "").strip()
|
|
52
|
+
if not raw:
|
|
53
|
+
return ""
|
|
54
|
+
try:
|
|
55
|
+
parsed = urlsplit(raw)
|
|
56
|
+
except ValueError:
|
|
57
|
+
return raw.rstrip("/")
|
|
58
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
59
|
+
return raw.rstrip("/")
|
|
60
|
+
path = parsed.path.rstrip("/")
|
|
61
|
+
for sufixo in ("/chat/completions", "/models"):
|
|
62
|
+
if path.endswith(sufixo):
|
|
63
|
+
path = path[: -len(sufixo)]
|
|
64
|
+
break
|
|
65
|
+
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def chat_completions_endpoint(base_url: str) -> str:
|
|
69
|
+
"""Endpoint de Chat Completions — igual ao do Thunderbolt."""
|
|
70
|
+
base = _normalizar_base_url(base_url)
|
|
71
|
+
return f"{base}/chat/completions" if base else ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _headers(api_key: str) -> dict:
|
|
75
|
+
"""Cabeçalhos OpenAI-compatible do Thunderbolt (Accept incluído)."""
|
|
76
|
+
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
77
|
+
if api_key:
|
|
78
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
79
|
+
return headers
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------- acesso a config
|
|
83
|
+
|
|
29
84
|
def get_config(db, chave: str, padrao: str = "") -> str:
|
|
30
85
|
from backend.core.database import Configuracao
|
|
31
86
|
|
|
@@ -41,25 +96,55 @@ def provider_config(db, provider: str) -> dict:
|
|
|
41
96
|
}
|
|
42
97
|
|
|
43
98
|
|
|
44
|
-
|
|
45
|
-
|
|
99
|
+
# ---------------------------------------------------------- chamada ao provedor
|
|
100
|
+
|
|
101
|
+
def chamar_provedor(db, provider: str, prompt: str, max_tokens: int) -> tuple[str, str]:
|
|
102
|
+
"""Uma chamada de chat completion no provedor. Levanta em caso de erro.
|
|
103
|
+
|
|
104
|
+
Cópia da estrutura do Thunderbolt: POST HTTP direto no endpoint
|
|
105
|
+
``{base_url}/chat/completions`` com Bearer token. Qualquer resposta >= 400
|
|
106
|
+
levanta ``httpx.HTTPStatusError`` — capturada pelo fallback em
|
|
107
|
+
``gerar_texto``.
|
|
108
|
+
"""
|
|
46
109
|
cfg = provider_config(db, provider)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
|
|
110
|
+
if not cfg["api_key"]:
|
|
111
|
+
raise RuntimeError(f"{NOMES_PROVEDORES.get(provider, provider)}: API key não configurada.")
|
|
112
|
+
if not cfg["model"]:
|
|
113
|
+
raise RuntimeError(f"{NOMES_PROVEDORES.get(provider, provider)}: nenhum modelo configurado.")
|
|
114
|
+
|
|
115
|
+
endpoint = chat_completions_endpoint(cfg["base_url"])
|
|
116
|
+
if not endpoint:
|
|
117
|
+
raise RuntimeError(f"{NOMES_PROVEDORES.get(provider, provider)}: Base URL inválida ({cfg['base_url']!r}).")
|
|
118
|
+
|
|
119
|
+
payload = {
|
|
120
|
+
"model": cfg["model"],
|
|
121
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
122
|
+
"temperature": 0.7,
|
|
123
|
+
"max_tokens": max_tokens,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
with httpx.Client(timeout=TIMEOUT_SEGUNDOS) as client:
|
|
127
|
+
resposta = client.post(endpoint, headers=_headers(cfg["api_key"]), json=payload)
|
|
128
|
+
resposta.raise_for_status()
|
|
129
|
+
dados = resposta.json()
|
|
130
|
+
|
|
131
|
+
texto = ""
|
|
132
|
+
escolhas = dados.get("choices") or []
|
|
133
|
+
if escolhas:
|
|
134
|
+
texto = (escolhas[0].get("message") or {}).get("content") or ""
|
|
135
|
+
if not texto:
|
|
136
|
+
raise RuntimeError(f"{NOMES_PROVEDORES.get(provider, provider)}: resposta sem conteúdo.")
|
|
137
|
+
|
|
138
|
+
modelo = str(dados.get("model") or cfg["model"])
|
|
55
139
|
return texto, modelo
|
|
56
140
|
|
|
57
141
|
|
|
58
142
|
def gerar_texto(db, prompt: str, max_tokens: int = 1024) -> tuple[str, str]:
|
|
59
143
|
"""Gera texto com fallback ordenado. Retorna (texto, provedor_que_respondeu).
|
|
60
144
|
|
|
61
|
-
|
|
62
|
-
|
|
145
|
+
Fallback implacável: capture ``Exception`` — não importa se é HTTP 404/410
|
|
146
|
+
(modelo descontinuado), 500, timeout ou queda de rede. O incidente é
|
|
147
|
+
registrado e o próximo provedor é acionado na mesma iteração.
|
|
63
148
|
"""
|
|
64
149
|
ordem = [p.strip() for p in get_config(db, "llm_fallback_ordem", "nvidia,google").split(",") if p.strip()]
|
|
65
150
|
disponiveis = [p for p in ordem if p in PROVEDORES and provider_config(db, p)["api_key"]]
|
|
@@ -68,68 +153,102 @@ def gerar_texto(db, prompt: str, max_tokens: int = 1024) -> tuple[str, str]:
|
|
|
68
153
|
|
|
69
154
|
ultimo_erro: Exception | None = None
|
|
70
155
|
for indice, provider in enumerate(disponiveis):
|
|
156
|
+
nome = NOMES_PROVEDORES.get(provider, provider)
|
|
71
157
|
proximo = disponiveis[indice + 1] if indice + 1 < len(disponiveis) else None
|
|
158
|
+
nome_proximo = NOMES_PROVEDORES.get(proximo, proximo) if proximo else "nenhum provedor"
|
|
159
|
+
|
|
72
160
|
try:
|
|
73
|
-
texto, modelo =
|
|
74
|
-
logger.info("LLM: %s respondeu (modelo %s)",
|
|
75
|
-
# Banco/UI rotulam o provedor do Gemini como "gemini" (
|
|
161
|
+
texto, modelo = chamar_provedor(db, provider, prompt, max_tokens)
|
|
162
|
+
logger.info("LLM: %s respondeu (modelo %s)", nome, modelo)
|
|
163
|
+
# Banco/UI rotulam o provedor do Gemini como "gemini" (id de exibição,
|
|
164
|
+
# não o id interno "google") — mantido desde a 0.4.05.
|
|
76
165
|
return texto, "gemini" if provider == "google" else provider
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
ultimo_erro = exc
|
|
80
|
-
logger.warning("LLM: %s falhou com HTTP %s (%s)", provider, status, str(exc)[:300])
|
|
81
|
-
if status in (404, 410):
|
|
82
|
-
destino = proximo or "nenhum"
|
|
83
|
-
logger.warning(
|
|
84
|
-
"Modelo %s indisponível (Status %s). Acionando fallback para %s.",
|
|
85
|
-
NOME_EXIBICAO.get(provider, provider),
|
|
86
|
-
status,
|
|
87
|
-
NOME_EXIBICAO.get(destino, destino),
|
|
88
|
-
)
|
|
89
|
-
except Exception as exc: # noqa: BLE001 — timeouts, rede, auth: tenta o próximo
|
|
166
|
+
|
|
167
|
+
except Exception as exc: # noqa: BLE001 — fallback implacável: TUDO troca de provedor
|
|
90
168
|
ultimo_erro = exc
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
169
|
+
if proximo:
|
|
170
|
+
logger.warning("Erro na %s (%s). Acionando %s...", nome, exc, nome_proximo)
|
|
171
|
+
else:
|
|
172
|
+
logger.warning("Erro na %s (%s). Sem provedor restante no fallback.", nome, exc)
|
|
173
|
+
|
|
174
|
+
raise RuntimeError(
|
|
175
|
+
f"Todos os provedores de LLM falharam ({', '.join(disponiveis)}). Último erro: {ultimo_erro}"
|
|
176
|
+
)
|
|
177
|
+
|
|
95
178
|
|
|
179
|
+
# ------------------------------------------------------------ teste de conexão
|
|
96
180
|
|
|
97
181
|
def test_provider(db, provider: str) -> dict:
|
|
98
|
-
"""
|
|
182
|
+
"""Teste REAL de conexão: uma chamada de inferência com 1 token.
|
|
183
|
+
|
|
184
|
+
Igual ao ``validate_openai_compatible_api_key`` do Thunderbolt — não lista
|
|
185
|
+
modelos, executa um POST mínimo em ``/chat/completions``. A string de erro
|
|
186
|
+
do provedor volta no JSON para o frontend exibir na tela.
|
|
99
187
|
|
|
100
|
-
404/410 →
|
|
101
|
-
|
|
188
|
+
404/410 → ``degraded``: a chave pode ser válida, mas o modelo saiu do
|
|
189
|
+
catálogo — o pipeline segue pelo fallback sem travar a tela.
|
|
102
190
|
"""
|
|
103
191
|
if provider not in PROVEDORES:
|
|
104
192
|
return {"ok": False, "detail": f"Provedor desconhecido: {provider}"}
|
|
105
193
|
cfg = provider_config(db, provider)
|
|
106
194
|
if not cfg["api_key"]:
|
|
107
195
|
return {"ok": False, "detail": "API key não configurada — adicione em Configurações."}
|
|
108
|
-
|
|
196
|
+
if not cfg["model"]:
|
|
197
|
+
return {"ok": False, "detail": "Nenhum modelo configurado — selecione um antes de testar."}
|
|
198
|
+
|
|
199
|
+
endpoint = chat_completions_endpoint(cfg["base_url"])
|
|
200
|
+
if not endpoint:
|
|
201
|
+
return {"ok": False, "detail": f"Base URL inválida: {cfg['base_url']!r}"}
|
|
202
|
+
|
|
109
203
|
payload = {
|
|
110
204
|
"model": cfg["model"],
|
|
111
|
-
"messages": [{"role": "user", "content": "Responda apenas
|
|
112
|
-
"max_tokens":
|
|
205
|
+
"messages": [{"role": "user", "content": "Responda apenas OK."}],
|
|
206
|
+
"max_tokens": 1,
|
|
207
|
+
"temperature": 0,
|
|
113
208
|
}
|
|
114
|
-
|
|
115
|
-
started = time.perf_counter()
|
|
209
|
+
|
|
116
210
|
try:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
211
|
+
with httpx.Client(timeout=20.0) as client:
|
|
212
|
+
resp = client.post(endpoint, headers=_headers(cfg["api_key"]), json=payload)
|
|
213
|
+
except httpx.HTTPError as exc:
|
|
214
|
+
return {"ok": False, "detail": f"Não foi possível testar {endpoint}: {exc}"}
|
|
215
|
+
|
|
216
|
+
elapsed_ms = int(resp.elapsed.total_seconds() * 1000)
|
|
217
|
+
|
|
218
|
+
if resp.status_code in (401, 403):
|
|
219
|
+
return {
|
|
220
|
+
"ok": False,
|
|
221
|
+
"status": resp.status_code,
|
|
222
|
+
"detail": f"O endpoint recusou a API key (HTTP {resp.status_code}). Verifique a chave em Configurações.",
|
|
223
|
+
}
|
|
224
|
+
if resp.status_code in (404, 410):
|
|
225
|
+
return {
|
|
226
|
+
"ok": False,
|
|
227
|
+
"degraded": True,
|
|
228
|
+
"status": resp.status_code,
|
|
229
|
+
"detail": (
|
|
230
|
+
f"Modelo '{cfg['model']}' indisponível na NVIDIA (HTTP {resp.status_code}): "
|
|
231
|
+
f"{resp.text[:200]} — use o botão ↻ para escolher um modelo do catálogo. "
|
|
232
|
+
"As notícias seguem sendo geradas pelo Gemini (fallback)."
|
|
233
|
+
if provider == "nvidia"
|
|
234
|
+
else (
|
|
235
|
+
f"Modelo '{cfg['model']}' indisponível no Gemini (HTTP {resp.status_code}): "
|
|
236
|
+
f"{resp.text[:200]} — as notícias seguem sendo geradas pela NVIDIA (primário)."
|
|
237
|
+
)
|
|
238
|
+
),
|
|
239
|
+
}
|
|
240
|
+
if resp.status_code >= 400:
|
|
241
|
+
return {
|
|
242
|
+
"ok": False,
|
|
243
|
+
"status": resp.status_code,
|
|
244
|
+
"detail": f"O endpoint de teste devolveu HTTP {resp.status_code}: {resp.text[:400]}",
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
"ok": True,
|
|
249
|
+
"status": 200,
|
|
250
|
+
"latency_ms": elapsed_ms,
|
|
251
|
+
"model": cfg["model"],
|
|
252
|
+
"base_url": cfg["base_url"],
|
|
253
|
+
"endpoint": endpoint,
|
|
254
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Teste funcional do fallback implacável do router de LLM.
|
|
2
|
+
|
|
3
|
+
Roda sem pytest e sem servidor: `python backend/tests/test_llm_fallback.py`.
|
|
4
|
+
|
|
5
|
+
Cobre exatamente o que a correção promete:
|
|
6
|
+
1. NVIDIA devolve HTTP 410 (modelo em fim de vida) -> o Gemini assume,
|
|
7
|
+
sem exceção vazando para o pipeline.
|
|
8
|
+
2. A mensagem de log é a pedida: "Erro na NVIDIA (...). Acionando Google
|
|
9
|
+
Gemini..."
|
|
10
|
+
3. Todos os provedores falhando -> RuntimeError clara (não silenciosa).
|
|
11
|
+
4. `test_provider` devolve a string de erro do provedor ( HTTP 410/404 ),
|
|
12
|
+
marcando `degraded`, para o frontend exibir.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import timedelta as _timedelta
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
22
|
+
sys.path.insert(0, str(ROOT))
|
|
23
|
+
|
|
24
|
+
# O teste não precisa de banco: `llm_router.get_config` importa
|
|
25
|
+
# `backend.core.database` de forma preguiçosa (dentro da função), o que
|
|
26
|
+
# exigiria SQLAlchemy só para rodar os assertions. Um stub do módulo mantém
|
|
27
|
+
# o teste autônomo — sem servidor, sem pytest, sem dependências extras.
|
|
28
|
+
import types # noqa: E402
|
|
29
|
+
|
|
30
|
+
if "backend.core.database" not in sys.modules:
|
|
31
|
+
_stub = types.ModuleType("backend.core.database")
|
|
32
|
+
|
|
33
|
+
class _ConfiguracaoStub: # noqa: D401 - apenas um marcador para o .get()
|
|
34
|
+
"""Stub: o FakeDB abaixo responde ao `.get()` sem tocar no ORM."""
|
|
35
|
+
|
|
36
|
+
_stub.Configuracao = _ConfiguracaoStub
|
|
37
|
+
sys.modules["backend.core.database"] = _stub
|
|
38
|
+
|
|
39
|
+
from backend.core import llm_router # noqa: E402
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Row:
|
|
43
|
+
def __init__(self, valor: str) -> None:
|
|
44
|
+
self.valor = valor
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FakeDB:
|
|
48
|
+
"""Banco mínimo: só o que provider_config()/get_config() precisam."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, valores: dict[str, str]) -> None:
|
|
51
|
+
self.valores = valores
|
|
52
|
+
|
|
53
|
+
def get(self, _modelo, chave: str):
|
|
54
|
+
return _Row(self.valores[chave]) if chave in self.valores else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _db_com_chaves() -> FakeDB:
|
|
58
|
+
return FakeDB(
|
|
59
|
+
{
|
|
60
|
+
"nvidia_api_key": "nv-test",
|
|
61
|
+
"nvidia_model": "meta/llama-3.3-70b-instruct", # EOL de propósito
|
|
62
|
+
"google_api_key": "go-test",
|
|
63
|
+
"google_model": "gemini-3.8-flash",
|
|
64
|
+
"llm_fallback_ordem": "nvidia,google",
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
falhas: list[str] = []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check(condicao: bool, mensagem: str) -> None:
|
|
73
|
+
print(f"{'PASS' if condicao else 'FAIL'} — {mensagem}")
|
|
74
|
+
if not condicao:
|
|
75
|
+
falhas.append(mensagem)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------- 1) fallback no 410
|
|
79
|
+
|
|
80
|
+
class _Resposta410:
|
|
81
|
+
"""Simula httpx.HTTPStatusError do raise_for_status() num 410."""
|
|
82
|
+
|
|
83
|
+
status_code = 410
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _chamar_nvidia_410(db, provider, prompt, max_tokens):
|
|
87
|
+
if provider == "nvidia":
|
|
88
|
+
raise RuntimeError("410 Gone: modelo 'meta/llama-3.3-70b-instruct' chegou ao fim da vida")
|
|
89
|
+
return "noticia gerada pelo gemini", "gemini-3.8-flash"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
original_chamar = llm_router.chamar_provedor
|
|
93
|
+
llm_router.chamar_provedor = _chamar_nvidia_410
|
|
94
|
+
|
|
95
|
+
registros: list[str] = []
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _Capturador(logging.Handler):
|
|
99
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
100
|
+
registros.append(record.getMessage())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
llm_router.logger.addHandler(_Capturador())
|
|
104
|
+
llm_router.logger.setLevel(logging.INFO)
|
|
105
|
+
|
|
106
|
+
texto, provedor = llm_router.gerar_texto(_db_com_chaves(), "escreva uma noticia")
|
|
107
|
+
|
|
108
|
+
check(provedor == "gemini", "NVIDIA com 410 -> o Gemini assume (provedor devolvido: gemini)")
|
|
109
|
+
check("noticia gerada pelo gemini" in texto, "o texto devolvido é o do Gemini, não uma exceção")
|
|
110
|
+
|
|
111
|
+
mensagem_esperada = "Erro na NVIDIA (410 Gone"
|
|
112
|
+
check(
|
|
113
|
+
any(mensagem_esperada in m and "Acionando Google Gemini" in m for m in registros),
|
|
114
|
+
f"log no formato pedido: 'Erro na NVIDIA ({{e}}). Acionando Google Gemini...' — obtido: {registros}",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# --------------------------------------------- 2) todos falham -> erro claro
|
|
118
|
+
|
|
119
|
+
def _chamar_todos_falham(db, provider, prompt, max_tokens):
|
|
120
|
+
raise RuntimeError(f"{provider} fora do ar")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
llm_router.chamar_provedor = _chamar_todos_falham
|
|
124
|
+
try:
|
|
125
|
+
llm_router.gerar_texto(_db_com_chaves(), "prompt")
|
|
126
|
+
check(False, "todos os provedores falhando devolve RuntimeError")
|
|
127
|
+
except RuntimeError as exc:
|
|
128
|
+
check(
|
|
129
|
+
"Todos os provedores de LLM falharam" in str(exc),
|
|
130
|
+
"todos os provedores falhando -> RuntimeError clara (sem silêncio)",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
llm_router.chamar_provedor = original_chamar
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------- 3) test_provider devolve o erro
|
|
136
|
+
|
|
137
|
+
class _FakeResponse:
|
|
138
|
+
def __init__(self, status_code: int, texto: str) -> None:
|
|
139
|
+
self.status_code = status_code
|
|
140
|
+
self.text = texto
|
|
141
|
+
self.elapsed = _timedelta(milliseconds=42)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _testar_com_resposta(status_code: int, texto: str) -> dict:
|
|
145
|
+
class _ClientFake:
|
|
146
|
+
def __init__(self, *a, **k) -> None:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
def __enter__(self):
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def __exit__(self, *a):
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def post(self, *a, **k):
|
|
156
|
+
return _FakeResponse(status_code, texto)
|
|
157
|
+
|
|
158
|
+
llm_router.httpx.Client = _ClientFake
|
|
159
|
+
return llm_router.test_provider(_db_com_chaves(), "nvidia")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
resultado_410 = _testar_com_resposta(
|
|
163
|
+
410, '{"detail":"The model has reached its end of life"}'
|
|
164
|
+
)
|
|
165
|
+
check(resultado_410["ok"] is False, "test_provider: HTTP 410 -> ok=False")
|
|
166
|
+
check(resultado_410.get("degraded") is True, "test_provider: HTTP 410 -> degraded=True (fallback avisa)")
|
|
167
|
+
check(
|
|
168
|
+
"fim da vida" in resultado_410["detail"] or "410" in resultado_410["detail"],
|
|
169
|
+
"test_provider: a string de erro do provedor volta no JSON para a tela",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
resultado_401 = _testar_com_resposta(401, "unauthorized")
|
|
173
|
+
check(
|
|
174
|
+
"recusou a API key" in resultado_401["detail"],
|
|
175
|
+
"test_provider: HTTP 401 -> mensagem de credencial explícita",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
resultado_500 = _testar_com_resposta(500, "internal error")
|
|
179
|
+
check(
|
|
180
|
+
"HTTP 500" in resultado_500["detail"],
|
|
181
|
+
"test_provider: HTTP 500 -> código e corpo devolvidos ao frontend",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------- 4) endpoint replicado do Thunderbolt
|
|
185
|
+
|
|
186
|
+
check(
|
|
187
|
+
llm_router.chat_completions_endpoint("https://integrate.api.nvidia.com/v1")
|
|
188
|
+
== "https://integrate.api.nvidia.com/v1/chat/completions",
|
|
189
|
+
"endpoint: base '/v1' -> '/v1/chat/completions' (igual ao Thunderbolt)",
|
|
190
|
+
)
|
|
191
|
+
check(
|
|
192
|
+
llm_router.chat_completions_endpoint("https://integrate.api.nvidia.com/v1/models")
|
|
193
|
+
== "https://integrate.api.nvidia.com/v1/chat/completions",
|
|
194
|
+
"endpoint: base com '/models' não duplica o sufixo (evita 404)",
|
|
195
|
+
)
|
|
196
|
+
check(
|
|
197
|
+
llm_router.chat_completions_endpoint("https://integrate.api.nvidia.com/v1/chat/completions")
|
|
198
|
+
== "https://integrate.api.nvidia.com/v1/chat/completions",
|
|
199
|
+
"endpoint: base já com '/chat/completions' não duplica (evita 404)",
|
|
200
|
+
)
|
|
201
|
+
headers = llm_router._headers("chave-abc")
|
|
202
|
+
check(
|
|
203
|
+
headers.get("Authorization") == "Bearer chave-abc"
|
|
204
|
+
and headers.get("Content-Type") == "application/json"
|
|
205
|
+
and headers.get("Accept") == "application/json",
|
|
206
|
+
"headers OpenAI-compatible: Bearer + Content-Type + Accept (como o Thunderbolt)",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------- veredicto
|
|
210
|
+
|
|
211
|
+
print()
|
|
212
|
+
if falhas:
|
|
213
|
+
print(f"Fallback do router LLM: {len(falhas)} verificação(ões) falharam.")
|
|
214
|
+
sys.exit(1)
|
|
215
|
+
print("Fallback do router LLM: todas as verificações passaram.")
|
|
216
|
+
sys.exit(0)
|