From d206ceb6eff08a2c2632db7ffc10c264949670aa Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 14:35:44 -0300 Subject: [PATCH 1/6] fix: trava vencida, sondagem descartada no schema JSON e carimbo de renovacao Apontamentos da terceira rodada de revisao do PR anterior. Cada um vira regressao em tests/test_health_semantics.py. - rateLimitedUntil e um prazo, nao uma bandeira, e a limpeza dele no ciclo era decidida procurando o nome do campo nas notas do provider -- que sao escritas em portugues e nunca casavam. A trava ficava gravada para sempre e a conexao, amarela para sempre. Agora quem decide e o proprio prazo. - update_connection_health so escrevia em coluna relacional. Em base com coluna `data` unica (o formato que o 9Router usa e que instalacoes migradas do OmniRoute mantem) a sondagem era descartada inteira: a funcao saia por "nao ha nada a gravar". Ganhou o ramo JSON, preservando o conteudo que ja estava la. - Uma conexao OAuth que o proprio gateway carimbou como recusada aparecia saudavel so porque o token ainda nao tinha vencido. - Local recem-criada dizia "ativa" sem nunca ter sido sondada. - O painel decidia "instancia inalcancavel" pelo tamanho do catalogo, contradizendo o "ativa" que o ciclo acabara de gravar. - lastRefreshAt nao era gravado nem projetado: "ultima renovacao" mostrava o horario da ultima verificacao. - Documentacao: o exemplo headless desligava o painel sem desligar o HEALTHCHECK, deixando o container eternamente unhealthy. 241 testes verdes. --- docs/wiki/Configuration.md | 13 ++ src/omini_rtksync/cli.py | 11 +- src/omini_rtksync/database.py | 85 +++++++++-- src/omini_rtksync/i18n.py | 3 + src/omini_rtksync/models.py | 30 +++- src/omini_rtksync/render.py | 9 +- tests/test_auth_recovery.py | 1 - tests/test_env_documentation.py | 1 - tests/test_health_semantics.py | 241 ++++++++++++++++++++++++++++++++ tests/test_provider_dispatch.py | 1 - tests/test_startup_storage.py | 1 - tests/test_web_render.py | 14 ++ 12 files changed, 390 insertions(+), 20 deletions(-) create mode 100644 tests/test_health_semantics.py diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index cd65b78..3aacf67 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -116,4 +116,17 @@ environment: - LOG_DIR=/app/data/logs - LOG_RETENTION_DAYS=90 - LOG_TO_STDOUT=0 +# The image ships a HEALTHCHECK that probes /healthz, which only the dashboard +# serves. With the dashboard off, that probe can never succeed and the container +# is reported unhealthy forever -- which also stops any `depends_on: +# service_healthy` from ever being satisfied. Disable the check along with the +# dashboard: +healthcheck: + disable: true ``` + +> **Portugues:** a imagem traz um HEALTHCHECK que consulta `/healthz`, servido +> apenas pelo painel. Com o painel desligado a sonda nunca passa e o container +> fica eternamente marcado como *unhealthy*, travando qualquer +> `depends_on: service_healthy`. Desligue a verificacao junto com o painel, como +> no exemplo acima. diff --git a/src/omini_rtksync/cli.py b/src/omini_rtksync/cli.py index 6aa3b10..21b539b 100644 --- a/src/omini_rtksync/cli.py +++ b/src/omini_rtksync/cli.py @@ -249,11 +249,12 @@ def _sync_all_locked(self): test_status=data.get("testStatus"), credential_state=data.get("credentialState"), last_error=data.get("lastError"), - # O provider ja apagou a trava vencida de `data`, entao - # inferir "limpar" da ausencia dela invertia o sentido e - # preservava justamente a trava que devia sair. Quem diz - # e a mensagem do provider. - clear_rate_limit=any("rateLimitedUntil" in n for n in notes), + # Quem decide e o proprio prazo: se `rateLimitedUntil` + # ja venceu, a janela do provedor reabriu e a trava tem + # de sair do banco. Procurar o nome do campo nas notas + # do provider nunca casava -- elas sao escritas em + # portugues -- e a trava ficava gravada para sempre. + clear_rate_limit=not c.rate_limit_active, ) if renovou: refreshed += 1 diff --git a/src/omini_rtksync/database.py b/src/omini_rtksync/database.py index 0e686e8..cbf8b01 100644 --- a/src/omini_rtksync/database.py +++ b/src/omini_rtksync/database.py @@ -146,6 +146,11 @@ def get_all_connections(db_path: str) -> List[Dict[str, Any]]: "credentialState": (specific or {}).get("credentialState") or extra.get("credentialState"), "lastTested": item.get("last_tested") or extra.get("lastTested"), + # Renovar e verificar são eventos diferentes. Sem projetar este + # campo, "última renovação" no painel caía para o horário da + # última verificação e um token parado há dias parecia recém + # renovado a cada ciclo. + "lastRefreshAt": (specific or {}).get("lastRefreshAt") or extra.get("lastRefreshAt"), "lastHealthCheckAt": item.get("last_health_check_at"), "rateLimitedUntil": item.get("rate_limited_until") or extra.get("rateLimitedUntil"), "lastError": item.get("last_error"), @@ -222,14 +227,43 @@ def update_connection( # trata como saudável (src/sse/services/auth.ts::clearAccountError e # tokenHealthCheck.ts). 'ok' não é reconhecido e faz a conexão parecer # estar em estado de erro. - cursor.execute( - f""" - UPDATE {tbl} - SET access_token = ?, refresh_token = ?, expires_at = ?, test_status = 'active', updated_at = ? - WHERE id = ? - """, - (access_token, refresh_token, to_iso_utc(expires_at_ms), now_iso, connection_id), - ) + # O horário da renovação vive no JSON de provider_specific_data: + # o schema relacional não tem coluna para ele, e sem esse carimbo o + # painel não distingue "renovado agora" de "apenas verificado". + especifico = {} + if "provider_specific_data" in cols: + cursor.execute( + f"SELECT provider_specific_data FROM {tbl} WHERE id = ?", (connection_id,) + ) + linha = cursor.fetchone() + if linha: + especifico = _decode_json(linha["provider_specific_data"]) or {} + especifico["lastRefreshAt"] = now_iso + cursor.execute( + f""" + UPDATE {tbl} + SET access_token = ?, refresh_token = ?, expires_at = ?, test_status = 'active', + provider_specific_data = ?, updated_at = ? + WHERE id = ? + """, + ( + access_token, + refresh_token, + to_iso_utc(expires_at_ms), + json.dumps(especifico), + now_iso, + connection_id, + ), + ) + else: + cursor.execute( + f""" + UPDATE {tbl} + SET access_token = ?, refresh_token = ?, expires_at = ?, test_status = 'active', updated_at = ? + WHERE id = ? + """, + (access_token, refresh_token, to_iso_utc(expires_at_ms), now_iso, connection_id), + ) elif "data" in cols: # Formato compatível com JSON cursor.execute(f"SELECT data FROM {tbl} WHERE id = ?", (connection_id,)) @@ -248,6 +282,7 @@ def update_connection( # 9Router (resetHealthStateOnActivation em connectionsRepo.js); # 'ok' só é reconhecido pela UI e não limpa travas de erro. d["testStatus"] = "active" + d["lastRefreshAt"] = now_iso cursor.execute( f"UPDATE {tbl} SET data = ?, updatedAt = ? WHERE id = ?", (json.dumps(d), now_iso, connection_id), @@ -319,9 +354,43 @@ def update_connection_health( campos.append("provider_specific_data = ?") valores.append(json.dumps(atual)) + # Schema de coluna JSON única (o formato que o 9Router usa e que o + # OmniRoute aceita em instalações migradas). Sem este ramo a sondagem + # era descartada inteira nessas bases: não há `test_status` nem + # `provider_specific_data` para receber os campos acima, e a função + # saía por `if not campos` como se não houvesse nada a gravar. + if "data" in cols and "test_status" not in cols: + cursor.execute(f"SELECT data FROM {tbl} WHERE id = ?", (connection_id,)) + linha = cursor.fetchone() + d: Dict[str, Any] = {} + if linha and linha["data"]: + try: + carregado = json.loads(linha["data"]) + if isinstance(carregado, dict): + d = carregado + except Exception: + pass + if test_status: + d["testStatus"] = test_status + if credential_state: + d["credentialState"] = credential_state + d["credentialCheckedAt"] = now_iso + if discovered_models is not None: + d["discoveredModels"] = discovered_models + if last_error is not None: + d["lastError"] = last_error or None + if clear_rate_limit: + d.pop("rateLimitedUntil", None) + d["lastTested"] = now_iso + campos.append("data = ?") + valores.append(json.dumps(d)) + if "updated_at" in cols: campos.append("updated_at = ?") valores.append(now_iso) + elif "updatedAt" in cols: + campos.append("updatedAt = ?") + valores.append(now_iso) if not campos: return False diff --git a/src/omini_rtksync/i18n.py b/src/omini_rtksync/i18n.py index f0b3d01..34f3648 100644 --- a/src/omini_rtksync/i18n.py +++ b/src/omini_rtksync/i18n.py @@ -73,6 +73,7 @@ "table.models": "models", "reason.local_ok": "Local instance answered with {count} model(s)", "reason.local_unreachable": "Local instance did not answer the model catalog", + "reason.local_empty": "Local instance answered, but has no model installed yet", "table.combo": "Combo", "table.cascade": "Model cascade", "combos.title": "Resilience combos", @@ -182,6 +183,7 @@ "table.models": "modelos", "reason.local_ok": "Instância local respondeu com {count} modelo(s)", "reason.local_unreachable": "Instância local não respondeu ao catálogo de modelos", + "reason.local_empty": "Instância local respondeu, mas ainda não tem nenhum modelo instalado", "table.combo": "Combo", "table.cascade": "Cascata de modelos", "combos.title": "Combos de resiliência", @@ -291,6 +293,7 @@ "table.models": "modelos", "reason.local_ok": "La instancia local respondió con {count} modelo(s)", "reason.local_unreachable": "La instancia local no respondió al catálogo de modelos", + "reason.local_empty": "La instancia local respondió, pero aún no tiene ningún modelo instalado", "table.combo": "Combo", "table.cascade": "Cascada de modelos", "combos.title": "Combos de resiliencia", diff --git a/src/omini_rtksync/models.py b/src/omini_rtksync/models.py index 41a2352..7615f29 100644 --- a/src/omini_rtksync/models.py +++ b/src/omini_rtksync/models.py @@ -154,6 +154,20 @@ def credential_state(self) -> Optional[str]: state = self.data.get("credentialState") return str(state) if state else None + @property + def rate_limit_active(self) -> bool: + """Se a trava de rate limit ainda vale neste instante. + + `rateLimitedUntil` é um prazo, não uma bandeira: ele guarda o momento em + que a janela do provedor se reabre. Tratar a simples presença do campo + como "limitada" deixava a conexão amarela para sempre depois do primeiro + 429, porque nada apaga a marca quando o prazo vence. + """ + until = parse_expiry_to_ms(self.data.get("rateLimitedUntil")) + if until is None: + return False + return until > int(time.time() * 1000) + @property def health_status(self) -> str: """Semantic classification of the connection state. @@ -167,9 +181,21 @@ def health_status(self) -> str: if self.is_local: # unreachable is written when the model catalog does not answer. - return "unknown" if self.data.get("testStatus") == "unreachable" else "active" + estado = self.data.get("testStatus") + if estado == "unreachable": + return "unknown" + # Uma conexão recém-criada ainda não foi sondada por ninguém. Com + # CRON_ENABLED=0 ela pode nunca ser, e dizer "ativa" é alegar uma + # saúde que nenhuma sonda confirmou. + return "active" if estado in ("active", "ok", "success") else "not_checked" if self.is_oauth: + # O gateway já pode ter carimbado a conexão como recusada. Sem uma + # sonda viva que diga o contrário, o carimbo dele é a melhor + # informação que existe — e ignorá-lo mostrava como saudável uma + # credencial que o próprio OmniRoute sabe estar quebrada. + if self.data.get("testStatus") in ("invalid", "error", "failed"): + return "invalid" remaining = self.remaining_seconds if remaining is None: return "no_expiration" @@ -180,7 +206,7 @@ def health_status(self) -> str: return "active" if self.has_api_key: - if self.data.get("rateLimitedUntil"): + if self.rate_limit_active: return "rate_limited" # Nunca sondada: dizer isso, em vez de alegar saúde que ninguém verificou. return "active" if probed == "valid" else "not_checked" diff --git a/src/omini_rtksync/render.py b/src/omini_rtksync/render.py index 1fdee38..49cef57 100644 --- a/src/omini_rtksync/render.py +++ b/src/omini_rtksync/render.py @@ -81,10 +81,17 @@ def render_refresh_reason(conn: Any, refresh_margin: int, lang: str = DEFAULT_LA "nada precisava ser renovado" de "a renovação falhou". """ if conn.is_local: + # Quem diz se a instância respondeu é a sonda, não o tamanho do + # catálogo: uma instalação nova, de pé e sem nenhum modelo baixado, + # devolve lista vazia com HTTP 200. Contar modelos aqui a anunciava + # como inalcançável, contradizendo o "ativa" que o próprio ciclo + # acabara de gravar no banco. + if conn.data.get("testStatus") == "unreachable": + return translate("reason.local_unreachable", lang) models = conn.local_models if models: return translate("reason.local_ok", lang, count=len(models)) - return translate("reason.local_unreachable", lang) + return translate("reason.local_empty", lang) if not conn.is_oauth: return translate("reason.api_key", lang) diff --git a/tests/test_auth_recovery.py b/tests/test_auth_recovery.py index 2eddc2a..5050623 100644 --- a/tests/test_auth_recovery.py +++ b/tests/test_auth_recovery.py @@ -5,7 +5,6 @@ import stat import tempfile import pathlib -import unittest import unittest.mock from omini_rtksync import cli as omini_rtksync_cli diff --git a/tests/test_env_documentation.py b/tests/test_env_documentation.py index ae1a32a..57ccc3a 100644 --- a/tests/test_env_documentation.py +++ b/tests/test_env_documentation.py @@ -9,7 +9,6 @@ defasagem aparece no CI em vez de aparecer em producao. """ -import os import pathlib import re import unittest diff --git a/tests/test_health_semantics.py b/tests/test_health_semantics.py new file mode 100644 index 0000000..e08b10a --- /dev/null +++ b/tests/test_health_semantics.py @@ -0,0 +1,241 @@ +"""Regressões da terceira rodada de revisão automática do PR. + +O que cada grupo protege, em uma frase: + +- a trava de rate limit é um **prazo**, e uma trava vencida deixava a conexão + amarela para sempre porque nada apagava a marca; +- uma instância local recém-criada dizia "ativa" sem nunca ter sido sondada; +- uma conexão OAuth que o próprio gateway carimbou como recusada aparecia + saudável só porque o token ainda não tinha vencido; +- o painel anunciava "instância inalcançável" para um catálogo vazio que o + ciclo acabara de gravar como ativo; +- a sondagem era descartada inteira em bases com schema de coluna JSON; +- `lastRefreshAt` era lido pelo painel e nunca escrito por ninguém. +""" + +import json +import os +import sqlite3 +import tempfile +import time +import unittest +from datetime import datetime, timedelta, timezone + +from omini_rtksync.database import update_connection, update_connection_health +from omini_rtksync.models import ConnectionRecord +from omini_rtksync.render import render_refresh_reason + + +def agora_mais(segundos: int) -> str: + """Timestamp ISO deslocado a partir de agora.""" + return (datetime.now(timezone.utc) + timedelta(seconds=segundos)).isoformat().replace( + "+00:00", "Z" + ) + + +def conexao(**dados) -> ConnectionRecord: + base = {"id": "c1", "provider": "groq", "name": "Teste"} + base.update(dados) + return ConnectionRecord(id="c1", provider=base["provider"], name=base["name"], data=base) + + +class TestTravaDeRateLimitVencida(unittest.TestCase): + def test_a_hold_still_in_the_future_keeps_the_connection_limited(self): + c = conexao(apiKey="gsk_x", hasApiKey=True, rateLimitedUntil=agora_mais(600)) + self.assertTrue(c.rate_limit_active) + self.assertEqual(c.health_status, "rate_limited") + + def test_an_expired_hold_releases_the_connection(self): + c = conexao( + apiKey="gsk_x", + hasApiKey=True, + rateLimitedUntil=agora_mais(-600), + credentialState="valid", + ) + self.assertFalse(c.rate_limit_active, "a janela do provedor ja reabriu") + self.assertEqual( + c.health_status, + "active", + "uma trava vencida mantinha a conexao amarela para sempre", + ) + + def test_an_epoch_in_milliseconds_is_understood_too(self): + futuro = int((time.time() + 600) * 1000) + self.assertTrue(conexao(apiKey="k", hasApiKey=True, rateLimitedUntil=futuro).rate_limit_active) + passado = int((time.time() - 600) * 1000) + self.assertFalse(conexao(apiKey="k", hasApiKey=True, rateLimitedUntil=passado).rate_limit_active) + + def test_no_hold_at_all_is_not_a_hold(self): + self.assertFalse(conexao(apiKey="k", hasApiKey=True).rate_limit_active) + + +class TestLocalNaoSondadaNaoAlegaSaude(unittest.TestCase): + def test_a_brand_new_local_connection_is_not_checked_yet(self): + c = conexao(provider="ollama", baseUrl="http://127.0.0.1:11434/v1") + self.assertEqual( + c.health_status, + "not_checked", + "sem sonda nenhuma, dizer 'ativa' e alegar saude que ninguem verificou", + ) + + def test_a_probed_local_connection_is_active(self): + c = conexao(provider="ollama", baseUrl="http://127.0.0.1:11434/v1", testStatus="active") + self.assertEqual(c.health_status, "active") + + def test_a_local_instance_that_did_not_answer_is_unknown(self): + c = conexao(provider="ollama", baseUrl="http://127.0.0.1:11434/v1", testStatus="unreachable") + self.assertEqual(c.health_status, "unknown") + + +class TestCarimboDoGatewayVale(unittest.TestCase): + def test_an_oauth_connection_the_gateway_rejected_is_invalid(self): + c = conexao( + provider="github", + accessToken="tok", + isOAuth=True, + expiresAt=agora_mais(7200), + testStatus="invalid", + ) + self.assertEqual( + c.health_status, + "invalid", + "o gateway ja sabe que a credencial esta quebrada; ignorar isso mostra saude falsa", + ) + + def test_a_healthy_oauth_connection_is_still_active(self): + c = conexao( + provider="github", + accessToken="tok", + isOAuth=True, + expiresAt=agora_mais(7200), + testStatus="active", + ) + self.assertEqual(c.health_status, "active") + + +class TestPainelNaoContradizOBanco(unittest.TestCase): + def test_an_empty_catalog_is_not_reported_as_unreachable(self): + c = conexao( + provider="ollama", + baseUrl="http://127.0.0.1:11434/v1", + testStatus="active", + discoveredModels=[], + ) + frase = render_refresh_reason(c, refresh_margin=900, lang="pt") + self.assertNotIn("não respondeu", frase) + self.assertIn("nenhum modelo", frase) + + def test_an_instance_that_really_did_not_answer_still_says_so(self): + c = conexao( + provider="ollama", + baseUrl="http://127.0.0.1:11434/v1", + testStatus="unreachable", + discoveredModels=[], + ) + self.assertIn("não respondeu", render_refresh_reason(c, refresh_margin=900, lang="pt")) + + +class TestPersistenciaNoSchemaJson(unittest.TestCase): + """Bases com coluna `data` única perdiam a sondagem inteira.""" + + def setUp(self): + self.dir = tempfile.mkdtemp() + self.db = os.path.join(self.dir, "storage.sqlite") + conn = sqlite3.connect(self.db) + conn.execute( + "CREATE TABLE providerConnections (id TEXT PRIMARY KEY, provider TEXT, name TEXT," + " data TEXT, createdAt TEXT, updatedAt TEXT)" + ) + conn.execute( + "INSERT INTO providerConnections VALUES (?,?,?,?,?,?)", + ( + "c1", + "groq", + "Teste", + json.dumps({"apiKey": "gsk_x", "rateLimitedUntil": agora_mais(-60)}), + "2026-01-01", + "2026-01-01", + ), + ) + conn.commit() + conn.close() + + def ler(self): + conn = sqlite3.connect(self.db) + conn.row_factory = sqlite3.Row + linha = conn.execute("SELECT data FROM providerConnections WHERE id='c1'").fetchone() + conn.close() + return json.loads(linha["data"]) + + def test_the_probe_result_reaches_a_json_schema_database(self): + ok = update_connection_health( + self.db, + "c1", + test_status="invalid", + credential_state="invalid", + last_error="401 do provedor", + clear_rate_limit=True, + ) + self.assertTrue(ok, "a sondagem tem de ser gravada tambem neste schema") + d = self.ler() + self.assertEqual(d["testStatus"], "invalid") + self.assertEqual(d["credentialState"], "invalid") + self.assertEqual(d["lastError"], "401 do provedor") + self.assertNotIn("rateLimitedUntil", d, "a trava vencida tinha de sair") + self.assertIn("lastTested", d) + + def test_the_existing_content_is_preserved(self): + update_connection_health(self.db, "c1", test_status="active") + self.assertEqual(self.ler()["apiKey"], "gsk_x", "gravar saude nao pode apagar a credencial") + + def test_the_renewal_stamps_its_own_timestamp(self): + update_connection(self.db, "c1", "novo-token", "novo-refresh", int(time.time() * 1000)) + d = self.ler() + self.assertIn("lastRefreshAt", d, "renovar e verificar sao eventos diferentes") + self.assertEqual(d["accessToken"], "novo-token") + + +class TestCarimboDeRenovacaoNoSchemaRelacional(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp() + self.db = os.path.join(self.dir, "storage.sqlite") + conn = sqlite3.connect(self.db) + conn.execute( + "CREATE TABLE provider_connections (id TEXT PRIMARY KEY, provider TEXT, name TEXT," + " access_token TEXT, refresh_token TEXT, api_key TEXT, expires_at TEXT," + " test_status TEXT, provider_specific_data TEXT, updated_at TEXT)" + ) + conn.execute( + "INSERT INTO provider_connections VALUES (?,?,?,?,?,?,?,?,?,?)", + ( + "c1", + "google", + "Conta", + "velho", + "refresh", + None, + "2026-01-01T00:00:00Z", + "active", + json.dumps({"baseUrl": "https://x.example"}), + "2026-01-01T00:00:00Z", + ), + ) + conn.commit() + conn.close() + + def test_the_renewal_timestamp_is_written_without_losing_the_rest(self): + update_connection(self.db, "c1", "token-novo", "refresh-novo", int(time.time() * 1000)) + conn = sqlite3.connect(self.db) + conn.row_factory = sqlite3.Row + linha = conn.execute("SELECT * FROM provider_connections WHERE id='c1'").fetchone() + conn.close() + especifico = json.loads(linha["provider_specific_data"]) + self.assertIn("lastRefreshAt", especifico) + self.assertEqual( + especifico["baseUrl"], "https://x.example", "o endereço declarado nao pode ser perdido" + ) + self.assertEqual(linha["access_token"], "token-novo") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_provider_dispatch.py b/tests/test_provider_dispatch.py index a3bfcf2..9e47b8c 100644 --- a/tests/test_provider_dispatch.py +++ b/tests/test_provider_dispatch.py @@ -16,7 +16,6 @@ import os import sqlite3 import tempfile -import unittest import unittest.mock from omini_rtksync.credential_check import ( diff --git a/tests/test_startup_storage.py b/tests/test_startup_storage.py index 01e2c6b..358c01b 100644 --- a/tests/test_startup_storage.py +++ b/tests/test_startup_storage.py @@ -9,7 +9,6 @@ import os import tempfile -import unittest import unittest.mock from omini_rtksync.auth import read_db_credentials, write_db_credentials diff --git a/tests/test_web_render.py b/tests/test_web_render.py index 4ac0612..02a9985 100644 --- a/tests/test_web_render.py +++ b/tests/test_web_render.py @@ -85,11 +85,25 @@ def test_local_instance_reason_reports_models(self): self.assertIn("2 model(s)", render.render_refresh_reason(local, 900)) def test_unreachable_local_instance_is_reported(self): + # Quem diz que a instancia nao respondeu e a sonda, gravada em + # testStatus. Um catalogo vazio sozinho nao prova queda nenhuma: + # instalacao nova, de pe e sem modelo baixado responde 200 com lista + # vazia, e anuncia-la como inalcancavel contradizia o proprio ciclo. local = make_conn("ollama-local", "Ollama Local", { "apiKey": "k", "baseUrl": "http://localhost:11434/v1", + "testStatus": "unreachable", }) self.assertIn("did not answer", render.render_refresh_reason(local, 900)) + def test_an_empty_catalog_is_not_called_unreachable(self): + local = make_conn("ollama-local", "Ollama Local", { + "apiKey": "k", "baseUrl": "http://localhost:11434/v1", + "testStatus": "active", "discoveredModels": [], + }) + frase = render.render_refresh_reason(local, 900) + self.assertNotIn("did not answer", frase) + self.assertIn("no model installed", frase) + class TestDashboardMarkup(unittest.TestCase): def _page(self, **overrides): From 8a4020bc2767fefa604b5f37ca5c247cc3c1e5d7 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 14:39:20 -0300 Subject: [PATCH 2/6] fix: limpar a trava vencida em qualquer conexao, nao so nas de chave A primeira correcao tratava `rateLimitedUntil` dentro do ramo de chave de API. Uma conexao OAuth -- o caso comum no OmniRoute -- passava longe dele e a marca vencida continuava gravada, deixando a conexao amarela para sempre. A limpeza passa a acontecer no topo do laco, antes de qualquer ramo, porque e higiene de estado e nao assunto de um provider. O ramo de chave deixa de repetir a decisao. Provado contra o gateway real: trava de 3 horas atras e removida ("Trava de rate limit vencida removida"), trava com 1 hora pela frente sobrevive intacta. 242 testes verdes. --- src/omini_rtksync/cli.py | 20 ++++++++---- tests/test_health_semantics.py | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/omini_rtksync/cli.py b/src/omini_rtksync/cli.py index 21b539b..7b5719f 100644 --- a/src/omini_rtksync/cli.py +++ b/src/omini_rtksync/cli.py @@ -122,6 +122,17 @@ def _sync_all_locked(self): log_msg("STATUS", f"[{provider} · {name}] {nota}") detalhe["actions"].append(nota) + # Trava de rate limit vencida sai antes de qualquer ramo: a janela + # do provedor reabriu, e isso vale para conexão OAuth, de chave ou + # local. Deixar a limpeza dentro de um único ramo mantinha a marca + # gravada — e a conexão amarela — em todos os outros. + trava = parse_expiry_to_ms(c.get("rateLimitedUntil")) + if trava is not None and trava <= int(time.time() * 1000): + if update_connection_health(self.settings.db_path, cid, clear_rate_limit=True): + nota = "Trava de rate limit vencida removida" + log_msg("STATUS", f"[{provider} · {name}] {nota}") + detalhe["actions"].append(nota) + # 1. Google / Antigravity OAuth if provider in ("antigravity", "gemini-cli"): local = self.google_provider.read_local_credential() @@ -249,12 +260,9 @@ def _sync_all_locked(self): test_status=data.get("testStatus"), credential_state=data.get("credentialState"), last_error=data.get("lastError"), - # Quem decide e o proprio prazo: se `rateLimitedUntil` - # ja venceu, a janela do provedor reabriu e a trava tem - # de sair do banco. Procurar o nome do campo nas notas - # do provider nunca casava -- elas sao escritas em - # portugues -- e a trava ficava gravada para sempre. - clear_rate_limit=not c.rate_limit_active, + # A trava vencida ja foi removida no topo do laco, para + # qualquer tipo de conexao. Repetir a decisao aqui so + # duplicaria a regra em um unico ramo. ) if renovou: refreshed += 1 diff --git a/tests/test_health_semantics.py b/tests/test_health_semantics.py index e08b10a..2db182b 100644 --- a/tests/test_health_semantics.py +++ b/tests/test_health_semantics.py @@ -237,5 +237,65 @@ def test_the_renewal_timestamp_is_written_without_losing_the_rest(self): self.assertEqual(linha["access_token"], "token-novo") +class TestTravaVencidaSaiNoCicloReal(unittest.TestCase): + """A limpeza vale para qualquer conexão, não só para as de chave de API. + + O primeiro corte tratava a trava dentro do ramo de chave de API. Uma + conexão OAuth — que é o caso comum no OmniRoute — passava longe dele e a + marca vencida continuava gravada, deixando a conexão amarela para sempre. + Este teste roda o ciclo inteiro contra um banco no schema relacional real. + """ + + def setUp(self): + self.dir = tempfile.mkdtemp() + self.db = os.path.join(self.dir, "storage.sqlite") + conn = sqlite3.connect(self.db) + conn.execute( + "CREATE TABLE provider_connections (id TEXT PRIMARY KEY, provider TEXT, name TEXT," + " access_token TEXT, refresh_token TEXT, api_key TEXT, expires_at TEXT," + " test_status TEXT, rate_limited_until TEXT, last_tested TEXT, last_error TEXT," + " provider_specific_data TEXT, created_at TEXT, updated_at TEXT)" + ) + conn.execute( + "INSERT INTO provider_connections VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "c1", + "github", + "Conta OAuth", + "tok", + "ref", + None, + agora_mais(7200), + "active", + agora_mais(-7200), # trava vencida há duas horas + None, + None, + None, + "2026-01-01", + "2026-01-01", + ), + ) + conn.commit() + conn.close() + + def trava_gravada(self): + conn = sqlite3.connect(self.db) + v = conn.execute("SELECT rate_limited_until FROM provider_connections WHERE id='c1'").fetchone()[0] + conn.close() + return v + + def test_an_expired_hold_is_cleared_for_an_oauth_connection(self): + from omini_rtksync.cli import OmniSyncEngine + from omini_rtksync.config import Settings + + self.assertIsNotNone(self.trava_gravada()) + motor = OmniSyncEngine(Settings(db_path=self.db, enable_web=False, validate_credentials=False)) + motor.sync_all() + self.assertIsNone( + self.trava_gravada(), + "a janela do provedor reabriu ha duas horas; a marca tinha de sair", + ) + + if __name__ == "__main__": unittest.main() From cad573cb625db76e743403fb142c840cc0fbed74 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 14:49:50 -0300 Subject: [PATCH 3/6] fix: a frase da sondagem explicava mal um resultado correto No painel de uma stack real aparecia "chave aceita pelo provedor (HTTP 400)" -- uma frase que o proprio numero parece desmentir. A classificacao esta certa e e deliberada: a sonda envia corpo vazio de proposito, e o provedor so chega a reclamar do corpo depois de ter aceitado a chave; por isso 400 e 404 contam como autenticacao aceita, e so 401/403 (mais o 400 idiomatico do Google AI Studio) marcam recusa. O que faltava era a frase dizer isso. Agora um 2xx le "autenticacao aceita pelo provedor" e um 4xx le "autenticacao aceita; a sondagem em si foi recusada (HTTP 400)". De quebra, "Provedor inacessivel, chave nao verificada" virou "Chave nao verificada": um 4xx nao prova que o provedor esteja inacessivel -- ele respondeu. Verificado contra a stack do artigo 0002, com seis provedores reais. --- src/omini_rtksync/providers.py | 14 ++++++++++++-- tests/test_health_semantics.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/omini_rtksync/providers.py b/src/omini_rtksync/providers.py index 1ec2aa9..07d343a 100644 --- a/src/omini_rtksync/providers.py +++ b/src/omini_rtksync/providers.py @@ -247,14 +247,24 @@ def check_and_refresh(self, conn: Dict[str, Any]) -> Tuple[bool, Optional[Dict[s if result.state == STATE_VALID: res["testStatus"] = "active" - messages.append(f"Chave aceita pelo provedor ({result.detail})") + # Um 4xx que nao seja 401/403 continua provando que a + # autenticacao passou -- a sonda manda corpo vazio de + # proposito, e o provedor so chega a reclamar do corpo depois + # de aceitar a chave. Dizer apenas "aceita (HTTP 400)" fazia a + # tela parecer errada; a frase agora explica o que o numero + # significa. + messages.append( + f"Autenticação aceita pelo provedor ({result.detail})" + if result.detail and "200" in str(result.detail) + else f"Autenticação aceita pelo provedor; a sondagem em si foi recusada ({result.detail})" + ) elif result.state == STATE_INVALID: res["testStatus"] = "invalid" messages.append(f"Chave RECUSADA pelo provedor ({result.detail})") elif result.state == STATE_RATE_LIMITED: messages.append(f"Provedor aplicou rate limit na validação ({result.detail})") elif result.state == STATE_UNREACHABLE: - messages.append(f"Provedor inacessível, chave não verificada: {result.detail}") + messages.append(f"Chave não verificada: {result.detail}") else: messages.append(result.detail or "Credencial não verificável") diff --git a/tests/test_health_semantics.py b/tests/test_health_semantics.py index 2db182b..5c63347 100644 --- a/tests/test_health_semantics.py +++ b/tests/test_health_semantics.py @@ -299,3 +299,36 @@ def test_an_expired_hold_is_cleared_for_an_oauth_connection(self): if __name__ == "__main__": unittest.main() + + +class TestFraseDaSondagem(unittest.TestCase): + """Um 400 prova que a autenticação passou; a frase tem de dizer isso.""" + + def mensagens(self, estado, detalhe): + import unittest.mock + from omini_rtksync.credential_check import CheckResult + from omini_rtksync.providers import ApiKeyProvider + + provider = ApiKeyProvider(validate_credentials=True) + with unittest.mock.patch( + "omini_rtksync.providers.check_connection", + return_value=CheckResult(state=estado, detail=detalhe, checked_at="2026-01-01T00:00:00Z"), + ): + _, _, msgs = provider.check_and_refresh( + {"id": "c1", "provider": "groq", "name": "T", "apiKey": "gsk_a"} + ) + return msgs + + def test_a_plain_200_reads_as_a_clean_acceptance(self): + from omini_rtksync.credential_check import STATE_VALID + + m = " ".join(self.mensagens(STATE_VALID, "HTTP 200")) + self.assertIn("Autenticação aceita", m) + self.assertNotIn("recusada", m) + + def test_a_400_says_what_the_number_means(self): + from omini_rtksync.credential_check import STATE_VALID + + m = " ".join(self.mensagens(STATE_VALID, "HTTP 400")) + self.assertIn("Autenticação aceita", m) + self.assertIn("sondagem em si foi recusada", m) From 4fa2a9c749b0b04d04b18abe00a23bc3746d1dd7 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 14:54:22 -0300 Subject: [PATCH 4/6] fix: a validacao viva vence o carimbo velho do gateway A regra que eu mesmo acabara de introduzir -- honrar o test_status que o OmniRoute carimbou -- criava a contradicao oposta. `test_status` guarda o ultimo erro do gateway e nao caduca sozinho: depois que a sonda viva aprovava a credencial, a linha ficava com credentialState=valid e a tela dizia "invalid". Duas pontas: - models.py so honra o carimbo quando nao ha sonda viva dizendo "valid", respeitando o que o proprio docstring da funcao promete; - cli.py grava test_status="active" no ramo em que a sonda aprova. Alem de coerente, e o que o OmniRoute espera: "active" e o unico valor que o clearAccountError dele trata como saudavel. 245 testes verdes. --- src/omini_rtksync/cli.py | 10 +++++++++- src/omini_rtksync/models.py | 14 +++++++++----- tests/test_health_semantics.py | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/omini_rtksync/cli.py b/src/omini_rtksync/cli.py index 7b5719f..e4a9443 100644 --- a/src/omini_rtksync/cli.py +++ b/src/omini_rtksync/cli.py @@ -165,8 +165,16 @@ def _sync_all_locked(self): # Recusado é motivo para renovar agora, não daqui a pouco. rem_sec = 0 elif veredito.state == STATE_VALID: + # Gravar só o resultado da sonda deixava para trás o + # `test_status='invalid'` de uma falha anterior, que não + # caduca sozinho. 'active' é o único valor que o + # OmniRoute trata como saudável (clearAccountError), e é + # o que a credencial acabou de provar que é. update_connection_health( - self.settings.db_path, cid, credential_state=veredito.state + self.settings.db_path, + cid, + test_status="active", + credential_state=veredito.state, ) if rem_sec <= self.settings.refresh_margin or not c.get("accessToken"): diff --git a/src/omini_rtksync/models.py b/src/omini_rtksync/models.py index 7615f29..b7dfaef 100644 --- a/src/omini_rtksync/models.py +++ b/src/omini_rtksync/models.py @@ -190,11 +190,15 @@ def health_status(self) -> str: return "active" if estado in ("active", "ok", "success") else "not_checked" if self.is_oauth: - # O gateway já pode ter carimbado a conexão como recusada. Sem uma - # sonda viva que diga o contrário, o carimbo dele é a melhor - # informação que existe — e ignorá-lo mostrava como saudável uma - # credencial que o próprio OmniRoute sabe estar quebrada. - if self.data.get("testStatus") in ("invalid", "error", "failed"): + # O gateway já pode ter carimbado a conexão como recusada. **Sem uma + # sonda viva que diga o contrário**, o carimbo dele é a melhor + # informação que existe — ignorá-lo mostrava como saudável uma + # credencial que o próprio OmniRoute sabe estar quebrada. Mas uma + # validação viva vence tudo: o carimbo é do último erro do gateway + # e não caduca sozinho, então honrá-lo mesmo depois de a sonda + # aprovar a credencial repetia, ao contrário, a própria contradição + # entre tela e banco que este arquivo existe para evitar. + if probed != "valid" and self.data.get("testStatus") in ("invalid", "error", "failed"): return "invalid" remaining = self.remaining_seconds if remaining is None: diff --git a/tests/test_health_semantics.py b/tests/test_health_semantics.py index 5c63347..0c1c913 100644 --- a/tests/test_health_semantics.py +++ b/tests/test_health_semantics.py @@ -102,6 +102,24 @@ def test_an_oauth_connection_the_gateway_rejected_is_invalid(self): "o gateway ja sabe que a credencial esta quebrada; ignorar isso mostra saude falsa", ) + def test_a_live_probe_outranks_a_stale_gateway_stamp(self): + # `test_status` guarda o ultimo erro do gateway e nao caduca sozinho. + # Depois que a sonda viva aprova a credencial, insistir no carimbo + # antigo repetia, ao contrario, a contradicao entre tela e banco. + c = conexao( + provider="github", + accessToken="tok", + isOAuth=True, + expiresAt=agora_mais(7200), + testStatus="invalid", + credentialState="valid", + ) + self.assertEqual( + c.health_status, + "active", + "a validacao viva vence o carimbo velho do gateway", + ) + def test_a_healthy_oauth_connection_is_still_active(self): c = conexao( provider="github", From f8f2969dff4ef2993abf5850c88519b594b232be Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 14:54:55 -0300 Subject: [PATCH 5/6] style: explicar o except do ramo JSON Apontamento da revisao de qualidade no PR #3: o unico except sem comentario era o que eu havia acabado de acrescentar. A linha corrompida nao pode bloquear a gravacao da sondagem -- e justamente a conexao que mais precisa ser diagnosticada. --- src/omini_rtksync/database.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/omini_rtksync/database.py b/src/omini_rtksync/database.py index cbf8b01..92a887e 100644 --- a/src/omini_rtksync/database.py +++ b/src/omini_rtksync/database.py @@ -369,6 +369,11 @@ def update_connection_health( if isinstance(carregado, dict): d = carregado except Exception: + # Coluna corrompida ou em formato inesperado: seguimos com o + # dicionário vazio e regravamos a linha com os campos de + # saúde. Abortar aqui faria uma linha ilegível bloquear para + # sempre a gravação da sondagem — justamente na conexão que + # mais precisa ser diagnosticada. pass if test_status: d["testStatus"] = test_status From f397ac577f6eeee3bc3bd45f02f9783ba76c6666 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 15:30:12 -0300 Subject: [PATCH 6/6] feat: o painel passa a mostrar por onde cada conta sai para a internet O risco de bloqueio nao vem de varias sessoes na mesma conta -- os provedores convivem com isso -- e sim de varias CONTAS saindo pelo mesmo endereco, que e o estado natural de um gateway com todas elas cadastradas. Para o provedor, identidades distintas na mesma origem tem o formato de uma revenda de acesso. O modelo ja sabia calcular esse vinculo (egress_status/egress_binding) e nada no painel o mostrava: o operador so descobriria depois do provedor. Agora cada conexao de nuvem exibe a propria saida, em modo somente leitura -- quem roteia a requisicao e o gateway, sempre. Uma decisao de produto vai junto: compartilhar o endereco so vira aviso a partir da SEGUNDA conta nessa situacao. Sozinha, ela e a unica dona daquele IP e nao ha o que sinalizar; alertar ali seria alarme falso em toda instalacao de conta unica. Verificado contra o gateway real do artigo 0003: sete conexoes, das quais cinco aparecem como "divide o endereco do gateway com 5 contas". --- src/omini_rtksync/i18n.py | 12 +++++ src/omini_rtksync/render.py | 49 ++++++++++++++++++++ tests/test_egress_panel.py | 91 +++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 tests/test_egress_panel.py diff --git a/src/omini_rtksync/i18n.py b/src/omini_rtksync/i18n.py index 34f3648..577b510 100644 --- a/src/omini_rtksync/i18n.py +++ b/src/omini_rtksync/i18n.py @@ -74,6 +74,10 @@ "reason.local_ok": "Local instance answered with {count} model(s)", "reason.local_unreachable": "Local instance did not answer the model catalog", "reason.local_empty": "Local instance answered, but has no model installed yet", + "egress.bound": "own egress", + "egress.shared": "shares the gateway address with {count} accounts", + "egress.single": "gateway address (only account)", + "egress.unknown": "egress unknown", "table.combo": "Combo", "table.cascade": "Model cascade", "combos.title": "Resilience combos", @@ -184,6 +188,10 @@ "reason.local_ok": "Instância local respondeu com {count} modelo(s)", "reason.local_unreachable": "Instância local não respondeu ao catálogo de modelos", "reason.local_empty": "Instância local respondeu, mas ainda não tem nenhum modelo instalado", + "egress.bound": "saída própria", + "egress.shared": "divide o endereço do gateway com {count} contas", + "egress.single": "endereço do gateway (única conta)", + "egress.unknown": "saída desconhecida", "table.combo": "Combo", "table.cascade": "Cascata de modelos", "combos.title": "Combos de resiliência", @@ -294,6 +302,10 @@ "reason.local_ok": "La instancia local respondió con {count} modelo(s)", "reason.local_unreachable": "La instancia local no respondió al catálogo de modelos", "reason.local_empty": "La instancia local respondió, pero aún no tiene ningún modelo instalado", + "egress.bound": "salida propia", + "egress.shared": "comparte la dirección del gateway con {count} cuentas", + "egress.single": "dirección del gateway (única cuenta)", + "egress.unknown": "salida desconocida", "table.combo": "Combo", "table.cascade": "Cascada de modelos", "combos.title": "Combos de resiliencia", diff --git a/src/omini_rtksync/render.py b/src/omini_rtksync/render.py index 49cef57..2360a46 100644 --- a/src/omini_rtksync/render.py +++ b/src/omini_rtksync/render.py @@ -191,6 +191,41 @@ def render_notice_page(title: str, body: str, link_label: str = "") -> bytes: """.encode("utf-8") +def egress_chip(conn: Any, sharing_count: int, lang: str) -> str: + """Marca de saída de rede da conexão, em modo somente leitura. + + O risco de bloqueio não vem de várias sessões na mesma conta -- isso os + provedores aceitam -- e sim de várias contas saindo pelo mesmo endereço. + Por isso "compartilhada" só vira aviso a partir da segunda conta nessa + situação: sozinha, ela é a única dona daquele IP. + """ + estado = conn.egress_status + if estado == "bound": + pool = conn.egress_binding or "?" + return ( + '' + f'' + f'{esc(translate("egress.bound", lang))}: {esc(pool)}' + ) + if estado == "shared": + if sharing_count > 1: + return ( + '' + f'' + f'{esc(translate("egress.shared", lang, count=sharing_count))}' + ) + return ( + '' + f'' + f'{esc(translate("egress.single", lang))}' + ) + return ( + '' + f'' + f'{esc(translate("egress.unknown", lang))}' + ) + + def health_badge(status: str, lang: str) -> str: """Monta o badge de saúde com ícone de fonte.""" css, icon = HEALTH_PRESENTATION.get(status, HEALTH_PRESENTATION["unknown"]) @@ -263,6 +298,13 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: {esc(translate("connections.empty", lang))} """ + # Uma conta sozinha compartilhando nao e problema: ela e a unica dona + # daquele IP. O alerta comeca na segunda, quando o provedor passa a ver + # identidades distintas na mesma origem. + compartilhando = sum( + 1 for c in connections if not c.is_local and c.egress_status == "shared" + ) + rows = [] for c in connections: if c.is_local: @@ -289,6 +331,13 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: ) if parts: detail = f'
{" · ".join(parts)}
' + else: + # Saida de rede: somente leitura. Quem roteia a requisicao e o + # gateway; o painel existe para o operador ver quais contas dividem + # endereco antes que o provedor veja primeiro. + chip = egress_chip(c, compartilhando, lang) + if chip: + detail = f'
{chip}
' rows.append(f""" diff --git a/tests/test_egress_panel.py b/tests/test_egress_panel.py new file mode 100644 index 0000000..7ba5232 --- /dev/null +++ b/tests/test_egress_panel.py @@ -0,0 +1,91 @@ +"""A saída de rede de cada conta precisa aparecer na tela. + +O risco de bloqueio não vem de várias sessões na mesma conta — os provedores +convivem com isso — e sim de **várias contas saindo pelo mesmo endereço**, que +é o estado natural de um gateway com todas elas cadastradas. O modelo já sabia +calcular esse vínculo e nada no painel o mostrava, então o operador só +descobriria o problema depois do provedor. + +Estes testes fixam duas decisões: + +- compartilhar o endereço só vira aviso a partir da **segunda** conta: sozinha, + ela é a única dona daquele IP e não há o que sinalizar; +- o painel é somente leitura — quem roteia a requisição é o gateway. +""" + +import unittest + +from omini_rtksync import render +from omini_rtksync.models import ConnectionRecord + + +def conexao(nome: str, **dados) -> ConnectionRecord: + base = {"id": nome, "provider": dados.pop("provider", "groq"), "name": nome} + base.update(dados) + return ConnectionRecord(id=nome, provider=base["provider"], name=nome, data=base) + + +def vinculada(nome: str, pool: str) -> ConnectionRecord: + # No OmniRoute os interruptores vivem em colunas de provider_connections e o + # vinculo em proxy_assignments, ja resolvido para `egressProxy` na leitura. + return conexao(nome, apiKey="gsk_x", hasApiKey=True, proxyEnabled=True, egressProxy=pool) + + +def compartilhada(nome: str) -> ConnectionRecord: + return conexao(nome, apiKey="gsk_x", hasApiKey=True, proxyEnabled=True, egressProxy=None) + + +class TestVinculoAparece(unittest.TestCase): + def html(self, conexoes): + return render.render_connections_table(conexoes, refresh_margin=900, lang="pt") + + def test_a_bound_account_shows_its_own_pool(self): + h = self.html([vinculada("Conta A", "pool-a")]) + self.assertIn("pool-a", h) + self.assertIn("saída própria", h) + + def test_two_accounts_on_the_gateway_address_raise_a_warning(self): + h = self.html([compartilhada("Conta A"), compartilhada("Conta B")]) + self.assertIn("divide o endereço do gateway", h) + self.assertIn("text-bg-warning-subtle", h, "o estado que importa precisa se destacar") + + def test_a_single_account_sharing_is_not_a_warning(self): + # Uma conta sozinha e a unica dona daquele IP: nao ha nada a alertar. + h = self.html([compartilhada("Conta unica")]) + self.assertIn("única conta", h) + self.assertNotIn("text-bg-warning-subtle", h) + + def test_a_bound_account_does_not_count_toward_the_warning(self): + # Duas contas, mas so uma compartilha: ainda nao ha duas no mesmo IP. + h = self.html([vinculada("Conta A", "pool-a"), compartilhada("Conta B")]) + self.assertNotIn("divide o endereço do gateway", h) + + def test_three_sharing_accounts_report_the_real_count(self): + h = self.html([compartilhada("A"), compartilhada("B"), compartilhada("C")]) + self.assertIn("3", h) + + def test_a_local_instance_has_no_egress_mark(self): + # Instancia local nao sai para o provedor: a marca nao se aplica. + local = conexao( + "Ollama", + provider="ollama", + baseUrl="http://127.0.0.1:11434/v1", + apiKey="fachada", + ) + h = self.html([local]) + self.assertNotIn("saída própria", h) + self.assertNotIn("divide o endereço", h) + + +class TestPainelNaoEscreve(unittest.TestCase): + def test_the_pool_name_is_escaped(self): + # O nome vem do banco do gateway: tratar como texto, nunca como HTML. + h = render.render_connections_table( + [vinculada("Conta", '')], refresh_margin=900, lang="pt" + ) + self.assertNotIn("