diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index a00e567..1bdcf92 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/nine_rtksync/daemon.py b/src/nine_rtksync/daemon.py index c9bfd71..80d774a 100644 --- a/src/nine_rtksync/daemon.py +++ b/src/nine_rtksync/daemon.py @@ -150,6 +150,15 @@ def _sync_all_locked(self) -> Dict[str, Any]: # linha velha, ou o ciclo anunciava renovacao que nao # houve. if refreshed_data: + if renewed: + # Carimbar aqui, e nao dentro de cada provider, + # faz qualquer handler futuro ja nascer certo. O + # painel lia `lastRefreshAt` e nenhum provider o + # escrevia, entao "ultima renovacao" mostrava na + # verdade o horario da ultima verificacao. + refreshed_data["lastRefreshAt"] = datetime.now( + timezone.utc + ).isoformat(timespec="milliseconds").replace("+00:00", "Z") update_connection_data(self.settings.db_path, conn.id, refreshed_data) if renewed: summary["refreshed"] += 1 diff --git a/src/nine_rtksync/i18n.py b/src/nine_rtksync/i18n.py index 8404d3c..9af2a7c 100644 --- a/src/nine_rtksync/i18n.py +++ b/src/nine_rtksync/i18n.py @@ -73,6 +73,11 @@ "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", + "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", @@ -182,6 +187,11 @@ "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", + "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", @@ -291,6 +301,11 @@ "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", + "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/nine_rtksync/models.py b/src/nine_rtksync/models.py index 4877771..a210071 100644 --- a/src/nine_rtksync/models.py +++ b/src/nine_rtksync/models.py @@ -169,6 +169,22 @@ 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: + """Whether the rate-limit hold is still in force right now. + + `rateLimitedUntil` guarda o instante em que a janela do provedor se + reabre -- é um prazo, não uma bandeira. Tratar a mera presença do campo + como "limitada" deixava a conexão amarela para sempre depois do primeiro + 429, já que nada apaga a marca quando o prazo vence. + """ + from .normalizer import parse_iso_or_str_to_ms + + until = parse_iso_or_str_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 connection health. @@ -184,7 +200,12 @@ def health_status(self) -> str: if self.is_local: # A local instance is only healthy when its model catalog answered. - return "unknown" if self.data.get("testStatus") == "unreachable" else "active" + estado = self.data.get("testStatus") + if estado == "unreachable": + return "unknown" + # Conexao recem-criada nunca foi sondada, e com CRON_ENABLED=0 pode + # nunca ser: dizer "ativa" e alegar uma saude que ninguem verificou. + return "active" if estado in ("active", "ok", "success") else "not_checked" if self.is_oauth: rem = self.remaining_seconds @@ -197,7 +218,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" # Never probed yet: say so instead of claiming health nobody verified. return "active" if probed == "valid" else "not_checked" diff --git a/src/nine_rtksync/providers/api_keys.py b/src/nine_rtksync/providers/api_keys.py index a11948b..5ff66b8 100644 --- a/src/nine_rtksync/providers/api_keys.py +++ b/src/nine_rtksync/providers/api_keys.py @@ -97,7 +97,16 @@ def check_and_refresh( if result.state == STATE_VALID: data["testStatus"] = "active" - messages.append(f"API key accepted by the provider ({result.detail})") + # A 4xx other than 401/403 still proves authentication went + # through -- the probe sends an empty body on purpose, and the + # provider only gets to complain about the body after it has + # accepted the key. Saying just "accepted (HTTP 400)" made the + # screen look wrong; the sentence now explains the number. + messages.append( + f"Authentication accepted by the provider ({result.detail})" + if result.detail and "200" in str(result.detail) + else f"Authentication accepted; the probe request itself was refused ({result.detail})" + ) elif result.state == STATE_INVALID: # Do not claim health the provider just denied. data["testStatus"] = "invalid" @@ -105,7 +114,7 @@ def check_and_refresh( elif result.state == STATE_RATE_LIMITED: messages.append(f"Provider rate limited the validation ({result.detail})") elif result.state == STATE_UNREACHABLE: - messages.append(f"Provider unreachable, key not verified: {result.detail}") + messages.append(f"Key not verified: {result.detail}") else: messages.append(result.detail or "Credential not verifiable") diff --git a/src/nine_rtksync/web/render.py b/src/nine_rtksync/web/render.py index cb41c1a..f724518 100644 --- a/src/nine_rtksync/web/render.py +++ b/src/nine_rtksync/web/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) @@ -184,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"]) @@ -256,6 +298,14 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: {esc(translate("connections.empty", lang))} """ + # Quantas contas de nuvem saem pelo endereço padrão do gateway. Uma conta + # sozinha compartilhando não é problema nenhum -- ela é a única a usar + # aquele IP. O alerta só faz sentido a partir da segunda, que é 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: @@ -282,6 +332,13 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: ) if parts: detail = f'
{" · ".join(parts)}
' + else: + # Saída de rede: somente leitura. Quem roteia a requisição é o + # gateway; o painel existe para que o operador veja quais contas + # dividem endereço 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_auth_recovery.py b/tests/test_auth_recovery.py index 3a8cd64..58b8a3b 100644 --- a/tests/test_auth_recovery.py +++ b/tests/test_auth_recovery.py @@ -5,7 +5,6 @@ import stat import tempfile import logging -import unittest import unittest.mock from nine_rtksync import cli as nine_rtksync_cli diff --git a/tests/test_egress_panel.py b/tests/test_egress_panel.py new file mode 100644 index 0000000..078d1e6 --- /dev/null +++ b/tests/test_egress_panel.py @@ -0,0 +1,99 @@ +"""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 json +import unittest + +from nine_rtksync.models import ConnectionRecord +from nine_rtksync.web import render + + +def conexao(nome: str, **dados) -> ConnectionRecord: + return ConnectionRecord( + id=nome, + provider=dados.pop("provider", "groq"), + name=nome, + created_at="2026-01-01", + updated_at="2026-01-01", + data_raw=json.dumps(dados), + ) + + +def vinculada(nome: str, pool: str) -> ConnectionRecord: + return conexao( + nome, + apiKey="gsk_x", + providerSpecificData={"proxyPoolId": pool, "connectionProxyEnabled": True}, + ) + + +def compartilhada(nome: str) -> ConnectionRecord: + return conexao(nome, apiKey="gsk_x", providerSpecificData={}) + + +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="openai-compatible-chat-ollama-local", + 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(" str: + return (datetime.now(timezone.utc) + timedelta(seconds=segundos)).isoformat().replace( + "+00:00", "Z" + ) + + +def conexao(provider: str = "groq", **dados) -> ConnectionRecord: + return ConnectionRecord( + id="c1", + provider=provider, + name="Teste", + created_at="2026-01-01", + updated_at="2026-01-01", + data_raw=json.dumps(dados), + ) + + +class TestTravaDeRateLimitVencida(unittest.TestCase): + def test_a_hold_still_in_the_future_keeps_the_connection_limited(self): + c = conexao(apiKey="gsk_x", 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", rateLimitedUntil=agora_mais(-600), credentialState="valid") + self.assertFalse(c.rate_limit_active) + 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): + self.assertTrue( + conexao(apiKey="k", rateLimitedUntil=int((time.time() + 600) * 1000)).rate_limit_active + ) + self.assertFalse( + conexao(apiKey="k", rateLimitedUntil=int((time.time() - 600) * 1000)).rate_limit_active + ) + + def test_no_hold_at_all_is_not_a_hold(self): + self.assertFalse(conexao(apiKey="k").rate_limit_active) + + +class TestLocalNaoSondadaNaoAlegaSaude(unittest.TestCase): + def test_a_brand_new_local_connection_is_not_checked_yet(self): + c = conexao("openai-compatible-chat-ollama-local", 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( + "openai-compatible-chat-ollama-local", + 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( + "openai-compatible-chat-ollama-local", + baseUrl="http://127.0.0.1:11434/v1", + testStatus="unreachable", + ) + self.assertEqual(c.health_status, "unknown") + + +class TestPainelNaoContradizOBanco(unittest.TestCase): + def test_an_empty_catalog_is_not_reported_as_unreachable(self): + c = conexao( + "openai-compatible-chat-ollama-local", + 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( + "openai-compatible-chat-ollama-local", + baseUrl="http://127.0.0.1:11434/v1", + testStatus="unreachable", + ) + self.assertIn("não respondeu", render_refresh_reason(c, refresh_margin=900, lang="pt")) + + +class TestCarimboDeRenovacao(unittest.TestCase): + """O ciclo carimba `lastRefreshAt` só quando houve renovação de verdade.""" + + def ciclo(self, renewed: bool): + import tempfile, os, sqlite3 + from nine_rtksync.config import Settings + from nine_rtksync.daemon import SyncEngine + + d = tempfile.mkdtemp() + db = os.path.join(d, "storage.sqlite") + con = sqlite3.connect(db) + con.execute( + "CREATE TABLE providerConnections (id TEXT PRIMARY KEY, provider TEXT, name TEXT," + " data TEXT, createdAt TEXT, updatedAt TEXT)" + ) + con.execute( + "INSERT INTO providerConnections VALUES (?,?,?,?,?,?)", + ("c1", "groq", "Teste", json.dumps({"apiKey": "gsk_x"}), "2026-01-01", "2026-01-01"), + ) + con.commit() + con.close() + + class ProviderFalso: + def can_handle(self, conn): + return True + + def check_and_refresh(self, conn, margin_seconds=0): + return renewed, {"apiKey": "gsk_x", "testStatus": "active"}, [] + + daemon = SyncEngine(Settings(db_path=db, validate_credentials=False, enable_web=False)) + daemon.providers = [ProviderFalso()] + daemon.sync_all() + + con = sqlite3.connect(db) + con.row_factory = sqlite3.Row + linha = con.execute("SELECT data FROM providerConnections WHERE id='c1'").fetchone() + con.close() + return json.loads(linha["data"]) + + def test_a_real_renewal_is_stamped(self): + self.assertIn( + "lastRefreshAt", + self.ciclo(renewed=True), + "o painel le este campo e nenhum provider o escrevia", + ) + + def test_a_plain_probe_is_not_stamped_as_a_renewal(self): + self.assertNotIn( + "lastRefreshAt", + self.ciclo(renewed=False), + "verificar nao e renovar: carimbar aqui faria um token parado ha dias " + "parecer recem renovado a cada ciclo", + ) + + +if __name__ == "__main__": + unittest.main() + + +class TestFraseDaSondagem(unittest.TestCase): + """Um 400 prova que a autenticação passou; a frase tem de dizer isso. + + A sonda envia corpo vazio de propósito: o provedor só reclama do corpo + depois de aceitar a chave. A classificação está certa há tempo, mas a + frase "API key accepted by the provider (HTTP 400)" fazia o painel + parecer errado justamente quando estava certo. + """ + + def mensagens(self, estado, detalhe): + import unittest.mock + from nine_rtksync.credential_check import CheckResult + from nine_rtksync.providers import ApiKeyProvider + + provider = ApiKeyProvider(validate_credentials=True) + with unittest.mock.patch( + "nine_rtksync.providers.api_keys.check_api_key", + return_value=CheckResult(state=estado, detail=detalhe, checked_at="2026-01-01T00:00:00Z"), + ): + _, _, msgs = provider.check_and_refresh(conexao("groq", apiKey="gsk_a")) + return msgs + + def test_a_plain_200_reads_as_a_clean_acceptance(self): + from nine_rtksync.credential_check import STATE_VALID + + m = " ".join(self.mensagens(STATE_VALID, "HTTP 200")) + self.assertIn("Authentication accepted", m) + self.assertNotIn("refused", m) + + def test_a_400_says_what_the_number_means(self): + from nine_rtksync.credential_check import STATE_VALID + + m = " ".join(self.mensagens(STATE_VALID, "HTTP 400")) + self.assertIn("Authentication accepted", m) + self.assertIn("probe request itself was refused", m) diff --git a/tests/test_provider_dispatch.py b/tests/test_provider_dispatch.py index cf4fbc2..4e1a55a 100644 --- a/tests/test_provider_dispatch.py +++ b/tests/test_provider_dispatch.py @@ -16,7 +16,6 @@ """ import json -import unittest import unittest.mock from nine_rtksync.credential_check import ( diff --git a/tests/test_startup_storage.py b/tests/test_startup_storage.py index c376173..0054e95 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 nine_rtksync.auth import read_db_credentials, write_db_credentials diff --git a/tests/test_web_render.py b/tests/test_web_render.py index 3270e8e..10b8774 100644 --- a/tests/test_web_render.py +++ b/tests/test_web_render.py @@ -113,11 +113,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):