Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 90 additions & 7 deletions src/nine_rtksync/combos.py
Original file line number Diff line number Diff line change
@@ -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]]:
Expand Down Expand Up @@ -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)
107 changes: 107 additions & 0 deletions tests/test_combos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Loading