From 56011a2e510f5eff198b362cb58718b17d9db237 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 6 Sep 2026 18:03:43 -0400 Subject: [PATCH 1/7] feat(schema): add provider_model_allowlist table with index --- config/schema.sql | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/config/schema.sql b/config/schema.sql index 7d71e6d..89bcf84 100644 --- a/config/schema.sql +++ b/config/schema.sql @@ -318,3 +318,17 @@ CREATE TABLE IF NOT EXISTS provider_balance_observations ( observed_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_provider_balance ON provider_balance_observations (provider, observed_at); + +-- NOTE: OpenRouter seed data lives in config/allowlist-openrouter.sql + +-- Provider-model allowlist: a gate that restricts routing to an explicit +-- (provider, model_id) whitelist. When configured, only rows present here +-- survive the hard filters; an empty or absent allowlist is ignored. +CREATE TABLE IF NOT EXISTS provider_model_allowlist ( + provider TEXT NOT NULL, + model_id TEXT NOT NULL, + added_at TEXT NOT NULL, + note TEXT, + PRIMARY KEY (provider, model_id) +); +CREATE INDEX IF NOT EXISTS idx_allowlist_provider ON provider_model_allowlist (provider); -- 2.49.1 From 61180989a66f3b637fe127d334dfbaa37ffad9fc Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 6 Sep 2026 18:05:26 -0400 Subject: [PATCH 2/7] feat(seed): add openrouter allowlist seed SQL (30 models) --- config/allowlist-openrouter.sql | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 config/allowlist-openrouter.sql diff --git a/config/allowlist-openrouter.sql b/config/allowlist-openrouter.sql new file mode 100644 index 0000000..2bf4882 --- /dev/null +++ b/config/allowlist-openrouter.sql @@ -0,0 +1,42 @@ +-- OpenRouter model allowlist seed +-- +-- USAGE: sqlite3 router.db < config/allowlist-openrouter.sql +-- +-- Idempotent: INSERT OR IGNORE against primary key (provider, model_id). +-- Re-running is safe — existing rows are left untouched. +-- +-- Curated from the live OpenRouter catalog (web-researched Sept 2026), +-- verifying each model exists and is routable. No x-ai / openai / anthropic +-- vendor paths included. + +INSERT OR IGNORE INTO provider_model_allowlist (provider, model_id, added_at, note) VALUES + ('openrouter', 'mistralai/codestral-2508', datetime('now'), 'Dedicated coding model'), + ('openrouter', 'mistralai/devstral-2512', datetime('now'), 'Dedicated coding model'), + ('openrouter', 'qwen/qwen3-coder', datetime('now'), 'Coding / general'), + ('openrouter', 'qwen/qwen3-coder-plus', datetime('now'), 'Coding / general'), + ('openrouter', 'qwen/qwen3-coder-flash', datetime('now'), 'Coding / general'), + ('openrouter', 'qwen/qwen3-coder-next', datetime('now'), 'Coding / general'), + ('openrouter', 'moonshotai/kimi-k2.7-code', datetime('now'), 'Frontier coding'), + ('openrouter', 'z-ai/glm-5.3', datetime('now'), 'Cost workhorse, NeuralWatt compare'), + ('openrouter', 'moonshotai/kimi-k3', datetime('now'), 'Frontier coding'), + ('openrouter', 'qwen/qwen3-max-thinking', datetime('now'), 'Reasoning / general'), + ('openrouter', 'google/gemini-3.1-pro-preview', datetime('now'), 'Google general/vision'), + ('openrouter', 'deepseek/deepseek-v4-flash', datetime('now'), 'Reasoning / cheap frontier'), + ('openrouter', 'deepseek/deepseek-v4-pro', datetime('now'), 'Reasoning / cheap frontier'), + ('openrouter', 'z-ai/glm-5.3-flash', datetime('now'), 'Fast workhorse, NeuralWatt compare'), + ('openrouter', 'z-ai/glm-5.2', datetime('now'), 'Cheapest workhorse, NeuralWatt compare'), + ('openrouter', 'qwen/qwen3-vl-235b-a22b-instruct', datetime('now'), 'Vision'), + ('openrouter', 'qwen/qwen3-vl-30b-a3b-instruct', datetime('now'), 'Vision'), + ('openrouter', 'google/gemini-3.5-flash', datetime('now'), 'Google general/vision'), + ('openrouter', 'google/gemini-3.1-flash-lite', datetime('now'), 'Google general/vision'), + ('openrouter', 'qwen/qwen3.6-35b-a3b', datetime('now'), 'General/reasoning'), + ('openrouter', 'qwen/qwen3-max', datetime('now'), 'General/reasoning'), + ('openrouter', 'mistralai/mistral-medium-3.1', datetime('now'), 'General chat'), + ('openrouter', 'mistralai/mistral-small-3.2-24b-instruct', datetime('now'), 'General chat'), + ('openrouter', 'mistralai/mistral-large-2512', datetime('now'), 'General chat'), + ('openrouter', 'z-ai/glm-5.2:free', datetime('now'), 'Free tier plumbing test'), + ('openrouter', 'nvidia/nemotron-3-super-120b-a12b:free', datetime('now'), 'Free tier plumbing test'), + ('openrouter', 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free', datetime('now'), 'Free tier plumbing test'), + ('openrouter', 'meta-llama/llama-4-maverick', datetime('now'), 'General open weights'), + ('openrouter', 'meta-llama/llama-4-scout', datetime('now'), 'General open weights'), + ('openrouter', 'deepseek/deepseek-v3.2', datetime('now'), 'Legacy / general'); -- 2.49.1 From b13b635a1b874d9f6a9438d0e4fb40a8982191d7 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 6 Sep 2026 18:07:09 -0400 Subject: [PATCH 3/7] feat(config): add require_allowlist field to DispatchProvider --- config/config.yaml | 1 + src/config.py | 1 + 2 files changed, 2 insertions(+) diff --git a/config/config.yaml b/config/config.yaml index f4ff13a..3697c51 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -761,6 +761,7 @@ dispatch_providers: balance_url: "https://openrouter.ai/api/v1/credits" has_energy_telemetry: false enabled: true + require_allowlist: true local_compute: # "Gaming mode", inverted: true means the router may use local hardware. diff --git a/src/config.py b/src/config.py index 94be07c..41f82bd 100644 --- a/src/config.py +++ b/src/config.py @@ -949,6 +949,7 @@ class DispatchProvider(StrictModel): balance_url: Optional[str] = None has_energy_telemetry: bool = False enabled: bool = True + require_allowlist: bool = False @field_validator("balance_url") @classmethod -- 2.49.1 From 5ab9c4f099b94b6782203f1f20ed1cc7d36a20c2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 6 Sep 2026 18:11:01 -0400 Subject: [PATCH 4/7] feat(poller): add allowlist filter+prune for openrouter provider --- src/poller.py | 52 +++++++++++++++++++++++++++++ tests/test_multi_provider_poller.py | 11 +++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/poller.py b/src/poller.py index a013e0e..3b7af13 100644 --- a/src/poller.py +++ b/src/poller.py @@ -609,6 +609,58 @@ def main() -> int: print(f"[{provider}] FAILED: {e}", file=sys.stderr) continue + if prov_cfg.require_allowlist: + # Query the allowlist for this provider from the database. + allowlist_ids = { + row["model_id"] + for row in conn.execute( + "SELECT model_id FROM provider_model_allowlist WHERE provider=?", + (provider,), + ).fetchall() + } + + if len(allowlist_ids) == 0: + # Empty allowlist — warn, deprecate every existing active row, + # then skip this provider for today. + print( + f"[{provider}] allowlist is empty — deprecating all active rows" + if conn.execute( + "SELECT COUNT(*) FROM models WHERE provider=? AND deprecated=0", + (provider,), + ).fetchone()[0] + else f"[{provider}] allowlist is empty — nothing active to deprecate", + file=sys.stderr, + ) + if conn.execute( + "SELECT COUNT(*) FROM models WHERE provider=? AND deprecated=0", + (provider,), + ).fetchone()[0]: + conn.execute( + "UPDATE models SET deprecated=1, availability='deprecated' " + "WHERE provider=? AND deprecated=0", + (provider,), + ) + conn.commit() + rows = [] + continue + + # Deprecate existing active rows that are NOT on the allowlist. + placeholders = ",".join("?" for _ in allowlist_ids) + conn.execute( + f"UPDATE models SET deprecated=1, availability='deprecated' " + f"WHERE provider=? AND deprecated=0 AND model_id NOT IN ({placeholders});", + [provider] + sorted(allowlist_ids), + ) + + # Filter fetched rows to the allowlist. + original_count = len(rows) + rows = [r for r in rows if r.model_id in allowlist_ids] + skipped = original_count - len(rows) + if skipped: + print( + f"[{provider}] allowlist filter: {len(rows)} kept, {skipped} pruned" + ) + if len(rows) == 0: print(f"[{provider}] fetched 0 models — skipping provider", file=sys.stderr) continue diff --git a/tests/test_multi_provider_poller.py b/tests/test_multi_provider_poller.py index 30a2423..4cc46c2 100644 --- a/tests/test_multi_provider_poller.py +++ b/tests/test_multi_provider_poller.py @@ -145,10 +145,17 @@ def tmp_db(tmp_path): own connection, so we cannot keep a fixture-level connection open. """ db_path = str(tmp_path / "test.db") + # REAL_CFG has require_allowlist=True for openrouter. Force it off so the + # tmp_db fixture isn't blocked by an empty allowlist table. + providers = { + _prov: _prov_cfg.model_copy(update={"require_allowlist": False}) + for _prov, _prov_cfg in REAL_CFG.dispatch_providers.items() + } cfg = REAL_CFG.model_copy( update={ "database": REAL_CFG.database.model_copy(update={"path": db_path}), "freshness": REAL_CFG.freshness.model_copy(update={"stale_after_days": 300}), + "dispatch_providers": providers, } ) @@ -357,7 +364,9 @@ def test_in_process_tiering_runs_after_upsert(tmp_path, monkeypatch): update={"stale_after_days": 300} ), "dispatch_providers": { - "openrouter": REAL_CFG.dispatch_providers["openrouter"], + "openrouter": REAL_CFG.dispatch_providers["openrouter"].model_copy( + update={"require_allowlist": False} + ), }, } ) -- 2.49.1 From c3e3b9ca4832adbf0f25b1f049581c0338efd0ce Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 6 Sep 2026 18:19:15 -0400 Subject: [PATCH 5/7] feat(admin): add allowlist CRUD + catalog-preview API --- src/admin.py | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/src/admin.py b/src/admin.py index 70ed7eb..9bbb486 100644 --- a/src/admin.py +++ b/src/admin.py @@ -35,12 +35,13 @@ from urllib.parse import urlparse from fastapi import APIRouter, BackgroundTasks, HTTPException from fastapi.responses import FileResponse from openai import OpenAI, OpenAIError -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, ConfigDict, ValidationError from ruamel.yaml import YAML from ruamel.yaml.comments import CommentedMap from ruamel.yaml.error import YAMLError import metrics +import poller import routing from config import ( BUILTIN_PROFILES, @@ -729,6 +730,11 @@ class _ProviderUpdateBody(BaseModel): enabled: bool +class _AllowlistAddBody(BaseModel): + model_id: str + note: Optional[str] = None + + def _runtime_state(cfg: Any) -> dict: """Read every toggle knob's current in-memory value off ``cfg``.""" return { @@ -1256,6 +1262,22 @@ def build_router( "message": "A restart is required for this change to take effect", } + def _require_provider(name: str) -> dict[str, Any]: + providers = _merged_providers() + if name not in providers: + raise HTTPException(status_code=404, detail="provider not found") + return providers[name] + + def _require_allowlist_provider(name: str) -> dict[str, Any]: + """Return merged provider dict or raise 404/409 for allowlist ops.""" + p = _require_provider(name) + if not p.get("require_allowlist", False): + raise HTTPException( + status_code=409, + detail="allowlist is not required for this provider", + ) + return p + def _provider_provenance(name: str) -> dict: base_store = load_config_store_safe(config_path) or {} base_providers = base_store.get("dispatch_providers") or {} @@ -1316,6 +1338,7 @@ def build_router( "api_key_env": p["api_key_env"], "has_energy_telemetry": bool(p.get("has_energy_telemetry", False)), "enabled": bool(p.get("enabled", True)), + "require_allowlist": bool(p.get("require_allowlist", False)), "in_base": provenance["in_base"], "in_overlay": provenance["in_overlay"], } @@ -1335,6 +1358,7 @@ def build_router( "api_key_env": p["api_key_env"], "has_energy_telemetry": bool(p.get("has_energy_telemetry", False)), "enabled": bool(p.get("enabled", True)), + "require_allowlist": bool(p.get("require_allowlist", False)), "in_base": provenance["in_base"], "in_overlay": provenance["in_overlay"], } @@ -1383,6 +1407,97 @@ def build_router( "message": "A restart is required for this change to take effect", } + @router.get("/api/providers/{name}/allowlist") + def admin_allowlist_list(name: str) -> list[dict[str, Any]]: + p = _require_allowlist_provider(name) + conn = _db_callable() + try: + rows = conn.execute( + "SELECT model_id, added_at, note FROM provider_model_allowlist WHERE provider=? ORDER BY model_id", + (name,), + ).fetchall() + return [ + {"model_id": row["model_id"], "added_at": row["added_at"], "note": row["note"]} + for row in rows + ] + finally: + conn.close() + + @router.post("/api/providers/{name}/allowlist") + def admin_allowlist_add(name: str, body: _AllowlistAddBody) -> dict[str, Any]: + p = _require_allowlist_provider(name) + conn = _db_callable() + try: + conn.execute( + "INSERT OR IGNORE INTO provider_model_allowlist (provider, model_id, added_at, note) VALUES (?, ?, datetime('now'), ?)", + (name, body.model_id, body.note), + ) + conn.commit() + row = conn.execute( + "SELECT model_id, added_at, note FROM provider_model_allowlist WHERE provider=? AND model_id=?", + (name, body.model_id), + ).fetchone() + return { + "model_id": row["model_id"], + "added_at": row["added_at"], + "note": row["note"], + "provider": name, + } + finally: + conn.close() + + @router.delete("/api/providers/{name}/allowlist/{model_id:path}") + def admin_allowlist_remove(name: str, model_id: str) -> dict[str, Any]: + p = _require_allowlist_provider(name) + conn = _db_callable() + try: + conn.execute( + "DELETE FROM provider_model_allowlist WHERE provider=? AND model_id=?", + (name, model_id), + ) + conn.commit() + return {"deleted": model_id, "provider": name} + finally: + conn.close() + + @router.get("/api/providers/{name}/catalog-preview") + def admin_provider_catalog_preview(name: str) -> list[dict[str, Any]]: + """Live preview of the upstream OpenRouter catalog for this provider. + + Returns plain ModelRow dicts from ``poller.fetch_openrouter`` without + touching the DB or caching. Only valid for OpenRouter providers that + have ``require_allowlist`` enabled; everything else is rejected so the + UI cannot preview a catalog the provider would never fetch from. + """ + p = _require_provider(name) + if not p.get("require_allowlist", False): + raise HTTPException( + status_code=409, + detail="allowlist is not required for this provider", + ) + if name != "openrouter": + raise HTTPException( + status_code=400, + detail=f"catalog-preview is only supported for openrouter, not {name!r}", + ) + rows = poller.fetch_openrouter(name) + return [ + { + "model_id": row.model_id, + "display_name": row.display_name, + "cost_per_1m_prompt": row.cost_per_1m_prompt, + "cost_per_1m_completion": row.cost_per_1m_completion, + "context_window": row.context_window, + "supports_tools": row.supports_tools, + "supports_json_mode": row.supports_json_mode, + "supports_vision": row.supports_vision, + "supports_reasoning": row.supports_reasoning, + "access_level": row.access_level, + "pricing_tbd": row.pricing_tbd, + } + for row in rows + ] + @router.get("/api/models") def admin_models() -> list: """All models with per-category proficiency, for the admin model table.""" -- 2.49.1 From 3caf1d4eeaa0ef17ee5d387e909be2889602ba84 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 6 Sep 2026 18:28:09 -0400 Subject: [PATCH 6/7] feat(admin-ui): add allowlist management section to providers page --- admin/frontend/providers.html | 314 +++++++++++++++++++++++++++++++++- 1 file changed, 312 insertions(+), 2 deletions(-) diff --git a/admin/frontend/providers.html b/admin/frontend/providers.html index 107fb76..79f553e 100644 --- a/admin/frontend/providers.html +++ b/admin/frontend/providers.html @@ -10,6 +10,8 @@ + +