From 3e20eb802af8929210479e05a9944a682c807daf Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Thu, 8 Oct 2026 19:24:55 -0300 Subject: [PATCH] fix(combos): sync only models whose provider is registered in the gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default combos are upserted every sync cycle with hardcoded model lists that reference every supported provider. On a gateway where only some providers are connected, every probe of those combos fails per missing credentials: the router logs 'Model X failed, trying next' for each dead entry and 'All models failed: No active credentials for provider' when the chain empties — every five minutes, forever, even though the failing providers were never connected to this gateway. sync_combos now filters each standard combo to the models whose provider prefix is registered in the gateway database (providerConnections for vendors, providerNodes for custom OpenAI-compatible endpoints) and removes standard combos that lose every model. Combos recover automatically on the next cycle after a provider is connected. The 'ag/' prefix used by combos for Antigravity plans is mapped to the 'antigravity'/'gemini-cli'/'google' vendor names recorded in the connections table. Covered by connection-aware regression tests in tests/test_combos.py: no connections (standard combos removed), partial connection (only the connected provider's models survive), Antigravity connected (ag/* kept), custom node registered (offline combo kept). --- src/nine_rtksync/combos.py | 97 ++++++++++++++++++++++++++++++--- tests/test_combos.py | 107 +++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 7 deletions(-) diff --git a/src/nine_rtksync/combos.py b/src/nine_rtksync/combos.py index d26088b..9753d52 100644 --- a/src/nine_rtksync/combos.py +++ b/src/nine_rtksync/combos.py @@ -1,9 +1,97 @@ """Manager for resilience and fallback combos in the gateway SQLite database.""" import json -from typing import List, Tuple +import sqlite3 +from typing import List, Set, Tuple -from .gateway import upsert_combos +from .gateway import get_all_connections, get_db_connection, upsert_combos + +# Combo models are prefixed by the gateway plan ("ag/..." for Antigravity), +# while the connections table records the vendor. Map between the two so a +# combo never references a provider that has no connection in the database. +CONNECTION_PROVIDERS_OF_PREFIX = { + "ag": {"antigravity", "gemini-cli", "google"}, +} + + +def _table_exists(conn: sqlite3.Connection, name: str) -> bool: + """Whether the table exists in this gateway install (schema grows between versions).""" + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name = ?", (name,)) + return cursor.fetchone() is not None + + +def available_combo_prefixes(db_path: str) -> Set[str]: + """Provider prefixes with at least one connection or custom node registered. + + Reads both stores: OAuth/API-key connections live in ``providerConnections`` + (vendor names), and custom OpenAI-compatible endpoints (e.g. a local model + server) live in ``providerNodes`` with the combo prefix as the node id. + """ + prefixes: Set[str] = set() + for connection in get_all_connections(db_path): + provider = (connection.provider or "").strip() + if not provider: + continue + for prefix, vendors in CONNECTION_PROVIDERS_OF_PREFIX.items(): + if provider in vendors: + prefixes.add(prefix) + break + else: + prefixes.add(provider) + + conn = get_db_connection(db_path) + try: + if _table_exists(conn, "providerNodes"): + for (node_id,) in conn.execute("SELECT id FROM providerNodes"): + if node_id: + prefixes.add(node_id) + finally: + conn.close() + return prefixes + + +def filter_combos_to_available( + combos: List[Tuple[str, str, str, str]], available: Set[str] +) -> Tuple[List[Tuple[str, str, str, str]], List[str]]: + """Drop models whose provider is not registered; report combos left empty. + + Returns (combos to upsert, ids of standard combos that lost every model). + """ + kept: List[Tuple[str, str, str, str]] = [] + emptied: List[str] = [] + for c_id, c_name, c_kind, c_models in combos: + models = [m for m in json.loads(c_models) if m.split("/", 1)[0] in available] + if models: + kept.append((c_id, c_name, c_kind, json.dumps(models))) + else: + emptied.append(c_id) + return kept, emptied + + +def remove_combos(db_path: str, combo_ids: List[str]) -> int: + """Remove guardian-managed combos by id; user-created combos are never touched here.""" + if not combo_ids: + return 0 + conn = get_db_connection(db_path) + try: + cursor = conn.cursor() + removed = 0 + for combo_id in combo_ids: + cursor.execute("DELETE FROM combos WHERE id = ?", (combo_id,)) + removed += cursor.rowcount + conn.commit() + return removed + finally: + conn.close() + + +def sync_combos(db_path: str, module: str = "all") -> int: + """Insert or update standard combos, keeping only models with a registered provider.""" + combos = get_default_combos(module) + available = available_combo_prefixes(db_path) + kept, emptied = filter_combos_to_available(combos, available) + removed = remove_combos(db_path, emptied) + return upsert_combos(db_path, kept) + removed def get_default_combos(module: str = "all") -> List[Tuple[str, str, str, str]]: @@ -76,8 +164,3 @@ def get_default_combos(module: str = "all") -> List[Tuple[str, str, str, str]]: return combos - -def sync_combos(db_path: str, module: str = "all") -> int: - """Insert or update standard combos in SQLite database.""" - combos = get_default_combos(module) - return upsert_combos(db_path, combos) diff --git a/tests/test_combos.py b/tests/test_combos.py index 102681b..cfa79e9 100644 --- a/tests/test_combos.py +++ b/tests/test_combos.py @@ -34,3 +34,110 @@ def test_default_combos_0002(self): if __name__ == "__main__": unittest.main() + + +class TestCombosConnectionAware(unittest.TestCase): + """Regression: combos must only reference providers registered in the gateway.""" + + def setUp(self): + import sqlite3 + import tempfile + + self.db_path = tempfile.mktemp(suffix=".sqlite") + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute(""" + CREATE TABLE providerConnections ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + name TEXT NOT NULL, + data TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + """) + c.execute(""" + CREATE TABLE providerNodes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL + ) + """) + c.execute(""" + CREATE TABLE combos ( + id TEXT PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + kind TEXT NOT NULL, + models TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + """) + conn.commit() + conn.close() + + def tearDown(self): + import os + + if os.path.exists(self.db_path): + os.unlink(self.db_path) + + def _connect(self, provider: str) -> None: + from nine_rtksync.gateway import upsert_connection + + upsert_connection(self.db_path, provider=provider, name=provider, data={"token": "x"}) + + def _combo_models(self, combo_id: str): + import json as _json + import sqlite3 as _sqlite3 + + conn = _sqlite3.connect(self.db_path) + row = conn.execute("SELECT models FROM combos WHERE id = ?", (combo_id,)).fetchone() + conn.close() + return _json.loads(row[0]) if row else None + + def test_sync_without_any_connection_removes_standard_combos(self): + from nine_rtksync.combos import sync_combos + + sync_combos(self.db_path, module="all") + import sqlite3 + + conn = sqlite3.connect(self.db_path) + remaining = conn.execute("SELECT id FROM combos").fetchall() + conn.close() + self.assertEqual(remaining, []) + + def test_sync_keeps_only_models_of_connected_providers(self): + from nine_rtksync.combos import sync_combos + + self._connect("groq") + sync_combos(self.db_path, module="all") + models = self._combo_models("arsenal-rapido") + self.assertEqual(models, ["groq/openai/gpt-oss-120b"]) + # claudegravity combos reference only ag/* providers: gone entirely + self.assertIsNone(self._combo_models("claudegravity-fallback")) + + def test_ag_models_survive_when_antigravity_connected(self): + from nine_rtksync.combos import sync_combos + + self._connect("antigravity") + sync_combos(self.db_path, module="all") + models = self._combo_models("claudegravity-fallback") + self.assertIsNotNone(models) + self.assertTrue(all(m.startswith("ag/") for m in models)) + + def test_custom_node_prefix_keeps_offline_combo(self): + import sqlite3 + + conn = sqlite3.connect(self.db_path) + conn.execute( + "INSERT INTO providerNodes (id, name) VALUES (?, ?)", + ("openai-compatible-chat-ollama-local", "ollama-local"), + ) + conn.commit() + conn.close() + + from nine_rtksync.combos import sync_combos + + sync_combos(self.db_path, module="all") + models = self._combo_models("arsenal-offline") + self.assertEqual(models, ["openai-compatible-chat-ollama-local/qwen2.5-coder:latest"])