@danhachuel/pandanews 0.4.5 → 0.4.7

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 CHANGED
@@ -2,6 +2,19 @@
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.07 (2026-11-08)
6
+
7
+ - **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
8
+ - **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
9
+ - **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`)
10
+ - **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
11
+ - **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
12
+ - **"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
13
+ - **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`
14
+ - **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
15
+ - **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
16
+ - 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
17
+
5
18
  ## 0.4.04 (2026-11-08)
6
19
 
7
20
  - **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`)
@@ -14,6 +27,11 @@ Todas as versões publicadas são registradas aqui antes do publish (regra em AG
14
27
  - **Guard verificado por mutação**: com a flag removida do `pip`, com um `subprocess.run` direto no backend e com `--reload` no uvicorn, o teste falha em todos os casos (exit 1) — não é carimbo de borracha
15
28
  - Nota de transparência: a colisão de versão com o 0.4.03 já publicado no registry foi resolvida por renumeração, conforme a regra de sessões simultâneas do AGENTS.md
16
29
 
30
+ ## 0.4.06 (2026-11-08)
31
+
32
+ - Rótulos de exibição do LLM alinhados ao critério de aceitação: o provedor do Google passa a ser gravado/exposto como **`gemini`** em `noticias.llm_provider` (antes o id interno `google`), e os warning de fallback usam os nomines "NVIDIA NIM" e "Gemini" — `Modelo NVIDIA NIM indisponível (Status 404). Acionando fallback para Gemini.`
33
+ - Nenhuma mudança funcional de roteamento — apenas nomenclatura consistente entre banco, log e UI
34
+
17
35
  ## 0.4.05 (2026-11-08)
18
36
 
19
37
  - **HOTFIX — fallback à prova de falhas no LLM Router** (crítico): roteamento reescrito em `backend/core/llm_router.py`, agora explícito provedor por provedor na ordem configurada. Qualquer erro HTTP (4xx/5xx, incluindo **404/410** da NVIDIA) é interceptado, registrado (`Modelo nvidia indisponível (Status {status}). Acionando fallback para {próximo}.`) e o próximo provedor é acionado imediatamente — a geração não trava mais. A função devolve qual provedor respondeu (`noticias.llm_provider`) e o incidente fica em `backend.log`
@@ -31,7 +31,7 @@ async def lifespan(_: FastAPI):
31
31
  scheduler.parar()
32
32
 
33
33
 
34
- app = FastAPI(title="PandaNews API", version="0.4.5", lifespan=lifespan)
34
+ app = FastAPI(title="PandaNews API", version="0.4.7", lifespan=lifespan)
35
35
 
36
36
  app.add_middleware(
37
37
  CORSMiddleware,
@@ -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
- url = cfg["base_url"].rstrip("/") + "/models"
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(url, headers={"Authorization": f"Bearer {cfg['api_key']}"}, timeout=20.0)
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
- ids = sorted(str(m.get("id")) for m in resposta.json().get("data", []) if m.get("id"))
71
- return {"models": ids, "modelo_atual": cfg["model"], "base_url": cfg["base_url"]}
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")
@@ -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": "meta/llama-3.3-70b-instruct",
49
+ "nvidia": "mistralai/mistral-large-2-instruct",
45
50
  "google": "gemini-3.8-flash",
46
51
  }
@@ -1,28 +1,86 @@
1
- """Router de LLM do PandaNews: fallback ordenado e à prova de falhas.
1
+ """Router de LLM do PandaNews — réplica da integração NVIDIA do Thunderbolt.
2
2
 
3
- Roteamento explícito, provedor por provedor, na ordem configurada
4
- (padrão: NVIDIA NIM → Google Gemini). Qualquer erro HTTP do provedor atual
5
- (4xx/5xx — incluindo 404/410 de modelos descontinuados) é registrado e o
6
- próximo provedor é acionado imediatamente. A função devolve (texto, provedor)
7
- para registrar em noticias.llm_provider.
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 time
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
 
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
36
+
21
37
  # Logger filho do uvicorn.error: mensagens vão para o backend.log (e stderr),
22
38
  # com a disciplina de logs do servidor.
23
39
  logger = logging.getLogger("uvicorn.error.pandanews")
24
40
 
25
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
+
26
84
  def get_config(db, chave: str, padrao: str = "") -> str:
27
85
  from backend.core.database import Configuracao
28
86
 
@@ -38,25 +96,55 @@ def provider_config(db, provider: str) -> dict:
38
96
  }
39
97
 
40
98
 
41
- def _chamar_provedor(db, provider: str, prompt: str, max_tokens: int) -> tuple[str, str]:
42
- """Uma chamada de chat completion no provedor. Levanta em caso de erro."""
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
+ """
43
109
  cfg = provider_config(db, provider)
44
- client = OpenAI(api_key=cfg["api_key"], base_url=cfg["base_url"], timeout=60.0)
45
- resposta = client.chat.completions.create(
46
- model=cfg["model"],
47
- messages=[{"role": "user", "content": prompt}],
48
- max_tokens=max_tokens,
49
- )
50
- texto = resposta.choices[0].message.content or ""
51
- modelo = str(getattr(resposta, "model", "") or cfg["model"])
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"])
52
139
  return texto, modelo
53
140
 
54
141
 
55
142
  def gerar_texto(db, prompt: str, max_tokens: int = 1024) -> tuple[str, str]:
56
143
  """Gera texto com fallback ordenado. Retorna (texto, provedor_que_respondeu).
57
144
 
58
- Se um provedor falhar por qualquer erro HTTP, registra o incidente e
59
- aciona o próximo da lista. Só levanta erro quando TODOS falham.
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.
60
148
  """
61
149
  ordem = [p.strip() for p in get_config(db, "llm_fallback_ordem", "nvidia,google").split(",") if p.strip()]
62
150
  disponiveis = [p for p in ordem if p in PROVEDORES and provider_config(db, p)["api_key"]]
@@ -65,67 +153,100 @@ def gerar_texto(db, prompt: str, max_tokens: int = 1024) -> tuple[str, str]:
65
153
 
66
154
  ultimo_erro: Exception | None = None
67
155
  for indice, provider in enumerate(disponiveis):
156
+ nome = NOMES_PROVEDORES.get(provider, provider)
68
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
+
69
160
  try:
70
- texto, modelo = _chamar_provedor(db, provider, prompt, max_tokens)
71
- logger.info("LLM: %s respondeu (modelo %s)", provider, modelo)
161
+ texto, modelo = chamar_provedor(db, provider, prompt, max_tokens)
162
+ logger.info("LLM: %s respondeu (modelo %s)", nome, modelo)
72
163
  return texto, provider
73
- except APIStatusError as exc:
74
- status = exc.status_code
75
- ultimo_erro = exc
76
- logger.warning("LLM: %s falhou com HTTP %s (%s)", provider, status, str(exc)[:300])
77
- if status in (404, 410):
78
- destino = proximo or "nenhum"
79
- logger.warning(
80
- "Modelo %s indisponível (Status %s). Acionando fallback para %s.",
81
- provider,
82
- status,
83
- destino,
84
- )
85
- except Exception as exc: # noqa: BLE001 — timeouts, rede, auth: tenta o próximo
164
+
165
+ except Exception as exc: # noqa: BLE001 — fallback implacável: TUDO troca de provedor
86
166
  ultimo_erro = exc
87
- logger.warning("LLM: %s falhou (%s): %s", provider, type(exc).__name__, str(exc)[:300])
88
- if proximo:
89
- continue
90
- raise RuntimeError(f"Todos os provedores de LLM falharam ({', '.join(disponiveis)}). Último erro: {ultimo_erro}")
167
+ if proximo:
168
+ logger.warning("Erro na %s (%s). Acionando %s...", nome, exc, nome_proximo)
169
+ else:
170
+ logger.warning("Erro na %s (%s). Sem provedor restante no fallback.", nome, exc)
171
+
172
+ raise RuntimeError(
173
+ f"Todos os provedores de LLM falharam ({', '.join(disponiveis)}). Último erro: {ultimo_erro}"
174
+ )
175
+
91
176
 
177
+ # ------------------------------------------------------------ teste de conexão
92
178
 
93
179
  def test_provider(db, provider: str) -> dict:
94
- """Chamada real e curta (POST /chat/completions) para validar a chave.
180
+ """Teste REAL de conexão: uma chamada de inferência com 1 token.
181
+
182
+ Igual ao ``validate_openai_compatible_api_key`` do Thunderbolt — não lista
183
+ modelos, executa um POST mínimo em ``/chat/completions``. A string de erro
184
+ do provedor volta no JSON para o frontend exibir na tela.
95
185
 
96
- 404/410 → 'degraded': a chave pode funcionar, mas o modelo endpoint está
97
- indisponível — o pipeline segue pelo fallback. A UI distingue os estados.
186
+ 404/410 → ``degraded``: a chave pode ser válida, mas o modelo saiu do
187
+ catálogo — o pipeline segue pelo fallback sem travar a tela.
98
188
  """
99
189
  if provider not in PROVEDORES:
100
190
  return {"ok": False, "detail": f"Provedor desconhecido: {provider}"}
101
191
  cfg = provider_config(db, provider)
102
192
  if not cfg["api_key"]:
103
193
  return {"ok": False, "detail": "API key não configurada — adicione em Configurações."}
104
- url = cfg["base_url"].rstrip("/") + "/chat/completions"
194
+ if not cfg["model"]:
195
+ return {"ok": False, "detail": "Nenhum modelo configurado — selecione um antes de testar."}
196
+
197
+ endpoint = chat_completions_endpoint(cfg["base_url"])
198
+ if not endpoint:
199
+ return {"ok": False, "detail": f"Base URL inválida: {cfg['base_url']!r}"}
200
+
105
201
  payload = {
106
202
  "model": cfg["model"],
107
- "messages": [{"role": "user", "content": "Responda apenas: ok"}],
108
- "max_tokens": 5,
203
+ "messages": [{"role": "user", "content": "Responda apenas OK."}],
204
+ "max_tokens": 1,
205
+ "temperature": 0,
109
206
  }
110
- headers = {"Authorization": f"Bearer {cfg['api_key']}", "Content-Type": "application/json"}
111
- started = time.perf_counter()
207
+
112
208
  try:
113
- resp = httpx.post(url, json=payload, headers=headers, timeout=20.0)
114
- elapsed_ms = int((time.perf_counter() - started) * 1000)
115
- if resp.status_code == 200:
116
- return {"ok": True, "status": 200, "latency_ms": elapsed_ms, "model": cfg["model"], "base_url": cfg["base_url"]}
117
- if resp.status_code in (404, 410):
118
- return {
119
- "ok": False,
120
- "degraded": True,
121
- "status": resp.status_code,
122
- "detail": (
123
- f"Sua chave da NVIDIA retornou erro {resp.status_code}. "
124
- "O sistema usará o Gemini (fallback) para gerar as notícias."
125
- if provider == "nvidia"
126
- else f"Endpoint do Gemini retornou {resp.status_code}. O sistema usará a NVIDIA (primário) para gerar as notícias."
127
- ),
128
- }
129
- return {"ok": False, "status": resp.status_code, "detail": resp.text[:400]}
130
- except Exception as exc: # noqa: BLE001
131
- return {"ok": False, "detail": f"{type(exc).__name__}: {exc}"}
209
+ with httpx.Client(timeout=20.0) as client:
210
+ resp = client.post(endpoint, headers=_headers(cfg["api_key"]), json=payload)
211
+ except httpx.HTTPError as exc:
212
+ return {"ok": False, "detail": f"Não foi possível testar {endpoint}: {exc}"}
213
+
214
+ elapsed_ms = int(resp.elapsed.total_seconds() * 1000)
215
+
216
+ if resp.status_code in (401, 403):
217
+ return {
218
+ "ok": False,
219
+ "status": resp.status_code,
220
+ "detail": f"O endpoint recusou a API key (HTTP {resp.status_code}). Verifique a chave em Configurações.",
221
+ }
222
+ if resp.status_code in (404, 410):
223
+ return {
224
+ "ok": False,
225
+ "degraded": True,
226
+ "status": resp.status_code,
227
+ "detail": (
228
+ f"Modelo '{cfg['model']}' indisponível na NVIDIA (HTTP {resp.status_code}): "
229
+ f"{resp.text[:200]} — use o botão ↻ para escolher um modelo do catálogo. "
230
+ "As notícias seguem sendo geradas pelo Gemini (fallback)."
231
+ if provider == "nvidia"
232
+ else (
233
+ f"Modelo '{cfg['model']}' indisponível no Gemini (HTTP {resp.status_code}): "
234
+ f"{resp.text[:200]} — as notícias seguem sendo geradas pela NVIDIA (primário)."
235
+ )
236
+ ),
237
+ }
238
+ if resp.status_code >= 400:
239
+ return {
240
+ "ok": False,
241
+ "status": resp.status_code,
242
+ "detail": f"O endpoint de teste devolveu HTTP {resp.status_code}: {resp.text[:400]}",
243
+ }
244
+
245
+ return {
246
+ "ok": True,
247
+ "status": 200,
248
+ "latency_ms": elapsed_ms,
249
+ "model": cfg["model"],
250
+ "base_url": cfg["base_url"],
251
+ "endpoint": endpoint,
252
+ }
@@ -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 == "google", "NVIDIA com 410 -> o Gemini assume (provedor devolvido: google)")
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)