feat: add opt-in model allowlist for OpenRouter #45

Merged
alee merged 7 commits from feat/opt-in-allowlist into main 2026-09-06 23:13:49 +00:00
10 changed files with 1466 additions and 4 deletions

View File

@@ -10,6 +10,8 @@
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Quicksand:wght@600;700&display=swap">
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%20256%20256%22%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2245.6%22%20fill%3D%22%23fff%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%2239.2%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2242%22%20fill%3D%22%23f59e0b%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23f59e0b%22%20stroke-width%3D%2232%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22131%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22167%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%22%23fff%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%227%22%20stroke-linecap%3D%22round%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3D%22none%22%20stroke%3D%22%231e293b%22%20stroke-width%3D%223.5%22%20stroke-linecap%3D%22round%22/%3E%3Cline%20x1%3D%22150.47%22%20y1%3D%2247.59%22%20x2%3D%22156.01%22%20y2%3D%2227.13%22%20stroke%3D%22%23fff%22%20stroke-width%3D%2210.73%22%20stroke-linecap%3D%22round%22/%3E%3Cline%20x1%3D%22150.47%22%20y1%3D%2247.59%22%20x2%3D%22155.08%22%20y2%3D%2230.54%22%20stroke%3D%22%231e293b%22%20stroke-width%3D%226.26%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%2295%22%20cy%3D%22226%22%20r%3D%2214%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%2219%22%20cy%3D%22226%22%20r%3D%2214%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%2295%22%20cy%3D%22226%22%20r%3D%2211%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22119%22%20cy%3D%22226%22%20r%3D%2211%22%20fill%3D%22%231e293b%22/%3E%3C/svg%3E">
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/js/tabler.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<style>
/* ── Liquid glass: soft gradient wash behind everything so the blur has
something to catch, then translucent+blurred navbar/cards on top. Kept
@@ -262,6 +264,21 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
</div>
</div><!-- /row row-cards -->
<div class="row row-cards mt-3">
<div class="col-12">
<div class="card" id="allowlist-card">
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="lock"></span>Provider allowlists</h3>
</div>
<div class="card-body p-0">
<div id="allowlist-root">
<div class="provider-empty">Loading&hellip;</div>
</div>
</div>
</div>
</div>
</div><!-- /row row-cards -->
</div><!-- /container-xl -->
</div><!-- /page-body -->
<footer class="footer footer-transparent d-print-none">
@@ -310,6 +327,9 @@ function icon(name, size) {
const svgs = {
alert: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
plus: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
search: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`,
check: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
package: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>`,
edit: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
trash: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`,
lock: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`,
@@ -428,6 +448,7 @@ function renderProvider(p) {
const apiKeyEnv = escapeHtml(String(p.api_key_env || ''));
const hasEnergy = !!p.has_energy_telemetry;
const enabled = !!p.enabled;
const requireAllowlist = !!p.require_allowlist;
const inBase = !!p.in_base;
const inOverlay = !!p.in_overlay;
const isEditable = inOverlay;
@@ -436,6 +457,10 @@ function renderProvider(p) {
? `<span class="provider-readonly-hint" title="Base-config providers cannot be edited or deleted"><span data-icon="lock"></span> read-only</span>`
: '';
const allowlistHint = requireAllowlist
? '<span class="badge bg-warning ms-2">allowlist required</span>'
: '';
const actions = !isEditable
? ''
: `<div class="provider-row-actions">
@@ -447,7 +472,7 @@ function renderProvider(p) {
let html = `<div class="provider-form-row provider-view" data-provider="${name}">
<span class="provider-form-key" title="${name}">
${name}
<span class="provider-source ms-2">${sourceBadge(inBase, inOverlay)} ${readonlyHint}</span>
<span class="provider-source ms-2">${sourceBadge(inBase, inOverlay)} ${readonlyHint}${allowlistHint}</span>
</span>
<span class="provider-form-meta">${baseUrl || '—'} &middot; ${apiKeyEnv || '—'}</span>
<span class="provider-form-control">${actions}</span>
@@ -455,7 +480,8 @@ function renderProvider(p) {
<div class="provider-form-row provider-view" data-provider="${name}">
<span class="provider-form-key text-muted">
<span class="provider-field-label me-3">energy telemetry ${hasEnergy ? 'on' : 'off'}</span>
<span class="provider-field-label">enabled ${enabled ? 'yes' : 'no'}</span>
<span class="provider-field-label me-3">enabled ${enabled ? 'yes' : 'no'}</span>
<span class="provider-field-label">allowlist ${requireAllowlist ? 'required' : 'off'}</span>
</span>
<span class="provider-form-meta"></span>
<span class="provider-form-control"></span>
@@ -609,6 +635,288 @@ async function doDelete(name) {
}
}
/* ═══════════════════════════════════════
ALLOWLIST
═══════════════════════════════════════ */
const _allowlistState = {
providers: [],
allowlists: {}, // name -> [{model_id, note}]
catalogCache: {}, // name -> [row, ...]
expandedName: null,
catalogSearch: '',
previewName: null,
};
function allowlistHeader(name, count, requireAllowlist) {
const status = requireAllowlist
? '<span class="badge bg-warning">require_allowlist</span>'
: '<span class="badge bg-secondary">allowlist not required</span>';
return `<div class="provider-form-row allowlist-header" data-provider="${escapeHtml(name)}" style="cursor:pointer">
<span class="provider-form-key">
<span data-icon="package" style="display:inline-flex;vertical-align:middle;margin-right:6px"></span>
${escapeHtml(name)}
<span class="provider-source ms-2">${status}</span>
</span>
<span class="provider-form-meta">${count} allowed</span>
<span class="provider-form-control">
<button class="btn btn-primary btn-sm" data-action="manage" data-provider-name="${escapeHtml(name)}">
<span data-icon="edit" style="display:inline-flex;vertical-align:middle;margin-right:4px"></span>
Manage
</button>
</span>
</div>`;
}
function allowlistTableRows(name) {
const rows = _allowlistState.allowlists[name] || [];
const keyBase = `allowlist-${escapeHtml(name)}`;
let html = `<div class="provider-form-row allowlist-table-head" data-provider="${escapeHtml(name)}" style="padding-top:2px;">
<span class="provider-form-key text-muted" style="font-size:.72rem">model_id</span>
<span class="provider-form-meta text-muted" style="font-size:.72rem">note</span>
<span class="provider-form-control"></span>
</div>`;
if (!rows.length) {
html += `<div class="provider-empty" style="padding:.75rem 1rem;font-size:.8rem">No models on the allowlist.</div>`;
return html;
}
html += rows.map(r => {
const mid = escapeHtml(String(r.model_id || ''));
const note = escapeHtml(String(r.note || ''));
return `<div class="provider-form-row allowlist-row" data-provider="${escapeHtml(name)}" data-model-id="${mid}">
<span class="provider-form-key" title="${mid}">${mid}</span>
<span class="provider-form-meta">${note || '—'}</span>
<span class="provider-form-control">
<button class="btn btn-danger btn-sm" data-action="remove" data-provider-name="${escapeHtml(name)}" data-model-id="${mid}">
<span data-icon="trash" style="display:inline-flex;vertical-align:middle;margin-right:4px"></span>
Remove
</button>
</span>
</div>`;
}).join('');
return html;
}
function allowlistCatalogRows(name) {
const keyBase = `allowlist-${escapeHtml(name)}-catalog`;
let html = `<div class="provider-form-row" data-provider="${escapeHtml(name)}">
<div class="input-group input-group-sm">
<span class="input-group-text"><span data-icon="search" style="display:inline-flex;vertical-align:middle"></span></span>
<input type="text" class="form-control" id="${keyBase}-search" placeholder="Filter catalog by model id or display name" value="${escapeHtml(_allowlistState.catalogSearch)}">
</div>
</div>`;
const cached = _allowlistState.catalogCache[name];
if (cached === undefined) {
html += `<div class="provider-empty" style="padding:.75rem 1rem;font-size:.8rem">Click <strong>Browse catalog preview</strong> to load upstream models.</div>`;
} else if (cached === null) {
html += `<div class="provider-empty" style="padding:.75rem 1rem;font-size:.8rem">Could not load catalog preview.</div>`;
} else if (!cached.length) {
html += `<div class="provider-empty" style="padding:.75rem 1rem;font-size:.8rem">No upstream models to add.</div>`;
} else {
const allowed = new Set((_allowlistState.allowlists[name] || []).map(r => String(r.model_id)));
const q = _allowlistState.catalogSearch.toLowerCase();
const filtered = cached.filter(r => {
const hay = `${String(r.model_id || '')} ${String(r.display_name || '')}`.toLowerCase();
return !q || hay.includes(q);
});
if (!filtered.length) {
html += `<div class="provider-empty" style="padding:.75rem 1rem;font-size:.8rem">No catalog rows match this filter.</div>`;
} else {
html += `<div class="allowlist-catalog-list" style="max-height:260px;overflow:auto;border-top:1px solid rgba(255,255,255,.05)">`;
html += filtered.map((r, idx) => {
const mid = escapeHtml(String(r.model_id || ''));
const already = allowed.has(String(r.model_id));
const disabled = already ? 'disabled' : '';
const noteId = `${keyBase}-note-${idx}`;
const btnText = already ? 'Added' : 'Add';
const btnClass = already ? 'btn-success' : 'btn-primary';
const iconName = already ? 'check' : 'plus';
const cost = (r.cost_per_1m_completion !== undefined && r.cost_per_1m_completion !== null)
? `$${Number(r.cost_per_1m_completion).toFixed(2)}/1M`
: '—';
return `<div class="provider-form-row allowlist-catalog-row" data-provider="${escapeHtml(name)}" data-model-id="${mid}" style="align-items:stretch">
<span class="provider-form-key" title="${mid}">
<div>${mid}</div>
<div class="text-muted" style="font-size:.7rem">${escapeHtml(String(r.display_name || ''))} &middot; ${cost}</div>
<input type="text" class="form-control form-control-sm mt-1" id="${noteId}" placeholder="Note (optional)">
</span>
<span class="provider-form-meta"></span>
<span class="provider-form-control" style="align-items:flex-start">
<button class="btn ${btnClass} btn-sm ${disabled}" data-action="add" data-provider-name="${escapeHtml(name)}" data-model-id="${mid}" data-note-id="${noteId}" ${disabled}>
<span data-icon="${iconName}" style="display:inline-flex;vertical-align:middle;margin-right:4px"></span>
${btnText}
</button>
</span>
</div>`;
}).join('');
html += `</div>`;
}
}
return html;
}
function renderAllowlistPanel(name) {
const provider = _allowlistState.providers.find(p => String(p.name) === name);
const requireAllowlist = provider ? !!provider.require_allowlist : false;
const keyBase = `allowlist-${escapeHtml(name)}`;
let html = `<div class="card mt-2 allowlist-panel" data-provider="${escapeHtml(name)}">
<div class="card-header">
<h4 class="card-title mb-0">Allowlist: ${escapeHtml(name)}</h4>
</div>
<div class="card-body p-0">`;
html += allowlistTableRows(name);
html += `<div class="provider-form-row" data-provider="${escapeHtml(name)}">
<span class="provider-form-key"></span>
<span class="provider-form-meta"></span>
<span class="provider-form-control">
<button class="btn btn-outline-secondary btn-sm me-2" data-action="preview" data-provider-name="${escapeHtml(name)}">Browse catalog preview</button>
<button class="btn btn-outline-secondary btn-sm" data-action="close" data-provider-name="${escapeHtml(name)}">Close</button>
</span>
</div>`;
if (_allowlistState.previewName === name) {
html += `<div class="provider-form-row" data-provider="${escapeHtml(name)}" style="border-top:1px solid rgba(255,255,255,.06)">
<span class="provider-form-key text-muted">Catalog preview</span>
<span class="provider-form-meta"></span>
<span class="provider-form-control"></span>
</div>`;
html += allowlistCatalogRows(name);
}
html += `</div></div>`;
return html;
}
function renderAllowlists() {
const root = document.getElementById('allowlist-root');
if (!_allowlistState.providers) {
root.innerHTML = '<div class="provider-empty">Could not load providers.</div>';
return;
}
const providers = _allowlistState.providers;
if (!providers.length) {
root.innerHTML = '<div class="provider-empty">No providers configured.</div>';
return;
}
let html = providers.map(p => {
const name = String(p.name);
const rows = _allowlistState.allowlists[name] || [];
const requireAllowlist = !!p.require_allowlist;
const expanded = _allowlistState.expandedName === name ? renderAllowlistPanel(name) : '';
return allowlistHeader(name, rows.length, requireAllowlist) + expanded;
}).join('');
root.innerHTML = html;
renderStaticIcons();
bindAllowlistSearch();
}
async function loadAllowlists() {
const providers = await apiFetch(`${API}api/providers`);
_allowlistState.providers = providers || [];
if (!_allowlistState.providers.length) {
renderAllowlists();
return;
}
const lists = {};
await Promise.all(_allowlistState.providers.map(async (p) => {
const name = String(p.name);
if (!p.require_allowlist) {
lists[name] = [];
return;
}
const rows = await apiFetch(`${API}api/providers/${encodeURIComponent(name)}/allowlist`);
lists[name] = rows || [];
}));
_allowlistState.allowlists = lists;
renderAllowlists();
}
function bindAllowlistSearch() {
const input = document.getElementById(`allowlist-${escapeHtml(_allowlistState.previewName || '')}-catalog-search`);
if (!input) return;
input.addEventListener('input', (e) => {
_allowlistState.catalogSearch = e.target.value;
renderAllowlists();
const next = document.getElementById(input.id);
if (next) next.focus();
});
}
async function openAllowlistManager(name) {
_allowlistState.expandedName = name;
_allowlistState.previewName = null;
_allowlistState.catalogSearch = '';
_allowlistState.catalogCache[name] = undefined;
await loadAllowlists();
}
async function previewAllowlistCatalog(name) {
_allowlistState.previewName = name;
_allowlistState.catalogSearch = '';
if (_allowlistState.catalogCache[name] === undefined) {
const rows = await apiFetch(`${API}api/providers/${encodeURIComponent(name)}/catalog-preview`);
_allowlistState.catalogCache[name] = rows === null ? null : (rows || []);
}
renderAllowlists();
}
function closeAllowlistManager(name) {
_allowlistState.expandedName = null;
_allowlistState.previewName = null;
_allowlistState.catalogSearch = '';
delete _allowlistState.catalogCache[name];
renderAllowlists();
}
async function addAllowlistEntry(name, modelId, noteId) {
const noteInput = noteId ? document.getElementById(noteId) : null;
const note = noteInput ? noteInput.value.trim() : '';
try {
await apiFetchDetail(`${API}api/providers/${encodeURIComponent(name)}/allowlist`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_id: modelId, note: note || null }),
});
toast('Model added to allowlist', 'success');
_allowlistState.catalogSearch = '';
delete _allowlistState.catalogCache[name];
await loadAllowlists();
if (_allowlistState.expandedName === name) {
await previewAllowlistCatalog(name);
}
} catch (e) {
toast(e.message || 'Add failed', 'error');
}
}
async function removeAllowlistEntry(name, modelId) {
try {
await apiFetchDetail(`${API}api/providers/${encodeURIComponent(name)}/allowlist/${encodeURIComponent(modelId)}`, {
method: 'DELETE',
});
toast('Model removed from allowlist', 'success');
await loadAllowlists();
} catch (e) {
toast(e.message || 'Remove failed', 'error');
}
}
function handleAllowlistClick(e) {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const name = btn.dataset.providerName;
const modelId = btn.dataset.modelId;
if (action === 'manage') openAllowlistManager(name);
else if (action === 'close') closeAllowlistManager(name);
else if (action === 'preview') previewAllowlistCatalog(name);
else if (action === 'add') addAllowlistEntry(name, modelId, btn.dataset.noteId);
else if (action === 'remove') removeAllowlistEntry(name, modelId);
}
async function createProvider() {
const name = document.getElementById('add-name').value.trim();
const baseUrl = document.getElementById('add-base-url').value.trim();
@@ -668,7 +976,9 @@ function init() {
if (btn.dataset.action === 'edit') startProviderEdit(name);
else if (btn.dataset.action === 'delete') promptDelete(name);
});
document.getElementById('allowlist-root').addEventListener('click', handleAllowlistClick);
loadProviders();
loadAllowlists();
connectSSE();
}

View File

@@ -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');

View File

@@ -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.

View File

@@ -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);

View File

@@ -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."""

View File

@@ -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

View File

@@ -554,6 +554,7 @@ def mark_stale(
def main() -> int:
cfg = load_config("config/config.yaml")
conn = sqlite3.connect(cfg.database.path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
upsert_local_dispatch_models(conn, cfg)
@@ -609,6 +610,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

View File

@@ -0,0 +1,287 @@
"""Tests for the admin allowlist CRUD and catalog-preview endpoints.
Uses ``build_router(cfg, _db_callable, base_dir)`` with a temp DB and a copy
of the real config.yaml so the merged-provider lookups see openrouter with
``require_allowlist: true``. No real provider calls are made.
"""
from __future__ import annotations
import shutil
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from starlette.testclient import TestClient
import admin
from admin import build_router
from config import load_config
ROOT = Path(__file__).resolve().parent.parent
SCHEMA_SQL = (ROOT / "config" / "schema.sql").read_text()
def _make_db(tmp_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(str(tmp_path / "test.db"))
conn.row_factory = sqlite3.Row
return conn
@pytest.fixture
def client(tmp_path, monkeypatch):
"""A TestClient for an isolated admin router over a temp config copy."""
(tmp_path / "config").mkdir(parents=True, exist_ok=True)
config_yaml = tmp_path / "config" / "config.yaml"
shutil.copyfile(ROOT / "config" / "config.yaml", config_yaml)
schema_sql = (ROOT / "config" / "schema.sql").read_text()
conn = _make_db(tmp_path)
conn.executescript(schema_sql)
conn.close()
cfg = load_config(str(config_yaml))
def _db_factory() -> sqlite3.Connection:
return _make_db(tmp_path)
router = build_router(cfg, _db_factory, base_dir=str(tmp_path))
app = FastAPI()
app.include_router(router, prefix="/admin")
monkeypatch.setattr(
admin.poller,
"fetch_openrouter",
lambda name: [
MagicMock(
model_id="google/test-model",
display_name="Test Model",
cost_per_1m_prompt=0.5,
cost_per_1m_completion=1.0,
context_window=131072,
supports_tools=True,
supports_json_mode=True,
supports_vision=False,
supports_reasoning=False,
access_level="public",
pricing_tbd=False,
)
],
)
with TestClient(app) as test_client:
yield test_client
@pytest.fixture
def list_client(tmp_path, monkeypatch):
"""Client seeded with two allowlist rows for openrouter."""
(tmp_path / "config").mkdir(parents=True, exist_ok=True)
config_yaml = tmp_path / "config" / "config.yaml"
shutil.copyfile(ROOT / "config" / "config.yaml", config_yaml)
conn = _make_db(tmp_path)
conn.executescript(SCHEMA_SQL)
now = datetime.now(timezone.utc).isoformat()
for mid in ("mistralai/codestral-2508", "qwen/qwen3-coder"):
conn.execute(
"INSERT INTO provider_model_allowlist (provider, model_id, added_at, note) "
"VALUES (?, ?, ?, ?)",
("openrouter", mid, now, "seed"),
)
conn.commit()
conn.close()
cfg = load_config(str(config_yaml))
def _db_factory() -> sqlite3.Connection:
return _make_db(tmp_path)
router = build_router(cfg, _db_factory, base_dir=str(tmp_path))
app = FastAPI()
app.include_router(router, prefix="/admin")
monkeypatch.setattr(
admin.poller,
"fetch_openrouter",
lambda name: [
MagicMock(
model_id="google/test-model",
display_name="Test Model",
cost_per_1m_prompt=0.5,
cost_per_1m_completion=1.0,
context_window=131072,
supports_tools=True,
supports_json_mode=True,
supports_vision=False,
supports_reasoning=False,
access_level="public",
pricing_tbd=False,
)
],
)
with TestClient(app) as test_client:
yield test_client
# ---------------------------------------------------------------------------
# GET /admin/api/providers/{name}/allowlist
# ---------------------------------------------------------------------------
def test_allowlist_get_empty(client):
"""GET returns an empty list when the allowlist table has no rows."""
resp = client.get("/admin/api/providers/openrouter/allowlist")
assert resp.status_code == 200
assert resp.json() == []
def test_allowlist_get_populated(list_client):
"""GET returns seeded allowlist rows sorted by model_id."""
resp = list_client.get("/admin/api/providers/openrouter/allowlist")
assert resp.status_code == 200
rows = resp.json()
assert len(rows) == 2
assert rows[0]["model_id"] == "mistralai/codestral-2508"
assert rows[1]["model_id"] == "qwen/qwen3-coder"
for row in rows:
assert "added_at" in row
assert "note" in row
# ---------------------------------------------------------------------------
# POST /admin/api/providers/{name}/allowlist
# ---------------------------------------------------------------------------
def test_allowlist_post_success(client):
"""POST adds a model to the openrouter allowlist."""
resp = client.post(
"/admin/api/providers/openrouter/allowlist",
json={"model_id": "z-ai/glm-5.2", "note": "cost workhorse"},
)
assert resp.status_code == 200
body = resp.json()
assert body["model_id"] == "z-ai/glm-5.2"
assert body["provider"] == "openrouter"
assert body["note"] == "cost workhorse"
assert "added_at" in body
# Idempotent visible through GET
resp = client.get("/admin/api/providers/openrouter/allowlist")
assert resp.status_code == 200
assert [r["model_id"] for r in resp.json()] == ["z-ai/glm-5.2"]
def test_allowlist_post_provider_without_allowlist_returns_409(client):
"""POST to neuralwatt (require_allowlist=false) returns 409."""
resp = client.post(
"/admin/api/providers/neuralwatt/allowlist",
json={"model_id": "some/model"},
)
assert resp.status_code == 409
assert "allowlist is not required" in resp.json()["detail"]
def test_allowlist_post_unknown_provider_returns_404(client):
"""POST to an unknown provider returns 404 before any 409."""
resp = client.post(
"/admin/api/providers/nonexistent/allowlist",
json={"model_id": "some/model"},
)
assert resp.status_code == 404
assert "provider not found" in resp.json()["detail"]
# ---------------------------------------------------------------------------
# DELETE /admin/api/providers/{name}/allowlist/{model_id}
# ---------------------------------------------------------------------------
def test_allowlist_delete_removes_row(list_client):
"""DELETE removes an existing allowlist row."""
resp = list_client.delete(
"/admin/api/providers/openrouter/allowlist/qwen/qwen3-coder"
)
assert resp.status_code == 200
assert resp.json() == {"deleted": "qwen/qwen3-coder", "provider": "openrouter"}
rows = list_client.get("/admin/api/providers/openrouter/allowlist").json()
assert [r["model_id"] for r in rows] == ["mistralai/codestral-2508"]
def test_allowlist_delete_idempotent(list_client):
"""DELETE on an already-absent row still returns 200."""
resp = list_client.delete(
"/admin/api/providers/openrouter/allowlist/qwen/qwen3-coder"
)
assert resp.status_code == 200
resp = list_client.delete(
"/admin/api/providers/openrouter/allowlist/qwen/qwen3-coder"
)
assert resp.status_code == 200
assert resp.json() == {"deleted": "qwen/qwen3-coder", "provider": "openrouter"}
rows = list_client.get("/admin/api/providers/openrouter/allowlist").json()
assert [r["model_id"] for r in rows] == ["mistralai/codestral-2508"]
def test_allowlist_delete_slash_and_colon_id(list_client):
"""DELETE works for vendor/model ids and for ids containing a colon."""
list_client.post(
"/admin/api/providers/openrouter/allowlist",
json={"model_id": "z-ai/glm-5.2:free", "note": "free tier"},
)
resp = list_client.delete(
"/admin/api/providers/openrouter/allowlist/z-ai/glm-5.2:free"
)
assert resp.status_code == 200
assert resp.json() == {"deleted": "z-ai/glm-5.2:free", "provider": "openrouter"}
# ---------------------------------------------------------------------------
# GET /admin/api/providers/{name}/catalog-preview
# ---------------------------------------------------------------------------
def test_catalog_preview_returns_json_array(client):
"""catalog-preview returns a JSON array of ModelRow-derived dicts."""
resp = client.get("/admin/api/providers/openrouter/catalog-preview")
assert resp.status_code == 200
rows = resp.json()
assert isinstance(rows, list)
assert len(rows) == 1
row = rows[0]
assert row["model_id"] == "google/test-model"
assert row["display_name"] == "Test Model"
assert row["cost_per_1m_prompt"] == 0.5
assert row["supports_tools"] is True
def test_catalog_preview_non_allowlist_provider_returns_409(client):
"""A provider without require_allowlist rejects preview with 409."""
resp = client.get("/admin/api/providers/neuralwatt/catalog-preview")
assert resp.status_code == 409
assert "allowlist is not required" in resp.json()["detail"]
def test_catalog_preview_allowlisted_non_openrouter_returns_400(client, tmp_path):
"""A provider that requires allowlist but is not openrouter gets 400."""
local_yaml = tmp_path / "config" / "config.local.yaml"
local_yaml.write_text(
"dispatch_providers:\n"
" otherprovider:\n"
" base_url: https://example.com/v1\n"
" api_key_env: OTHER_API_KEY\n"
" enabled: true\n"
" require_allowlist: true\n"
)
resp = client.get("/admin/api/providers/otherprovider/catalog-preview")
assert resp.status_code == 400
assert "only supported for openrouter" in resp.json()["detail"]

630
tests/test_allowlist.py Normal file
View File

@@ -0,0 +1,630 @@
"""Tests for the opt-in provider model allowlist.
Covers config validation, poller filtering / pruning, and end-to-end
integration scenarios. All tests are offline: the poller's ``requests.get``
is mocked.
"""
from __future__ import annotations
import io
import json
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock
import pytest
import poller
from config import DispatchProvider, RouterConfig, load_config
ROOT = Path(__file__).resolve().parent.parent
SCHEMA_SQL = (ROOT / "config" / "schema.sql").read_text()
REAL_CFG = load_config(str(ROOT / "config" / "config.yaml"))
# Exact 30 model IDs shipped in config/allowlist-openrouter.sql.
ALLOWLIST_MODEL_IDS = [
"mistralai/codestral-2508",
"mistralai/devstral-2512",
"qwen/qwen3-coder",
"qwen/qwen3-coder-plus",
"qwen/qwen3-coder-flash",
"qwen/qwen3-coder-next",
"moonshotai/kimi-k2.7-code",
"z-ai/glm-5.3",
"moonshotai/kimi-k3",
"qwen/qwen3-max-thinking",
"google/gemini-3.1-pro-preview",
"deepseek/deepseek-v4-flash",
"deepseek/deepseek-v4-pro",
"z-ai/glm-5.3-flash",
"z-ai/glm-5.2",
"qwen/qwen3-vl-235b-a22b-instruct",
"qwen/qwen3-vl-30b-a3b-instruct",
"google/gemini-3.5-flash",
"google/gemini-3.1-flash-lite",
"qwen/qwen3.6-35b-a3b",
"qwen/qwen3-max",
"mistralai/mistral-medium-3.1",
"mistralai/mistral-small-3.2-24b-instruct",
"mistralai/mistral-large-2512",
"z-ai/glm-5.2:free",
"nvidia/nemotron-3-super-120b-a12b:free",
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
"meta-llama/llama-4-maverick",
"meta-llama/llama-4-scout",
"deepseek/deepseek-v3.2",
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fake_response(json_data: dict):
fake = MagicMock()
fake.json.return_value = json_data
fake.raise_for_status.return_value = None
fake.status_code = 200
return fake
def _catalog_item(model_id: str) -> dict:
return {
"id": model_id,
"metadata": {
"display_name": model_id.replace("-", " ").title(),
"description": "test model",
"pricing": {
"input_per_million": 0.30,
"output_per_million": 0.60,
"cached_input_per_million": 0.20,
"pricing_tbd": False,
},
"capabilities": {
"tools": True,
"json_mode": True,
"vision": False,
"reasoning": False,
},
"limits": {
"max_context_length": 131072,
"max_output_tokens": 8192,
},
"deprecated": False,
},
}
def _openrouter_model(model_id: str) -> dict:
return {
"id": model_id,
"name": model_id.replace("/", " ").title(),
"canonical_slug": model_id.split(":")[0],
"pricing": {"prompt": "0.000001", "completion": "0.000002"},
"top_provider": {"context_length": 131072, "max_completion_tokens": 8192},
"architecture": {"input_modalities": ["text"]},
"supported_parameters": [],
"reasoning": {},
}
def _openrouter_catalog(model_ids: list[str]) -> list[dict]:
return [_openrouter_model(mid) for mid in model_ids]
def _seed_allowlist(conn: sqlite3.Connection, provider: str = "openrouter") -> None:
now = datetime.now(timezone.utc).isoformat()
for mid in ALLOWLIST_MODEL_IDS:
conn.execute(
"INSERT OR IGNORE INTO provider_model_allowlist "
"(provider, model_id, added_at, note) VALUES (?, ?, ?, ?)",
(provider, mid, now, "seed"),
)
conn.commit()
def _seed_existing_models(
conn: sqlite3.Connection,
model_ids: list[str],
provider: str = "openrouter",
deprecated: bool = False,
) -> None:
ts = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
availability = "deprecated" if deprecated else "active"
for mid in model_ids:
conn.execute(
"""
INSERT INTO models (
model_id, provider, base_model_id, display_name,
cost_per_1m_prompt, cost_per_1m_completion, cost_per_1m_prompt_cached,
context_window, effective_context_window, max_output_tokens,
supports_tools, supports_json_mode, supports_vision, supports_reasoning,
reasoning_default_enabled, latency_class, reasoning_mode,
context_variant, access_level, pricing_tbd, deprecated,
availability, last_updated
) VALUES (?, ?, ?, ?,
0.30, 0.60, NULL,
131072, 192500, 8192,
1, 1, 0, 0,
0, 'standard', 'none', 'standard', 'public', 0, ?,
?, ?)
""",
(mid, provider, mid, mid, int(deprecated), availability, ts),
)
conn.commit()
def _allowlist_cfg(tmp_path: Path, *, openrouter: bool = True) -> RouterConfig:
"""Return a config with the database pointing at a fresh test DB.
OpenRouter keeps ``require_allowlist: true`` from config.yaml. NeuralWatt
keeps ``require_allowlist: false``.
"""
db_path = str(tmp_path / "test.db")
providers = {}
for name, prov_cfg in REAL_CFG.dispatch_providers.items():
if name == "openrouter" and not openrouter:
providers[name] = prov_cfg.model_copy(update={"enabled": False})
else:
providers[name] = prov_cfg
return 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,
}
)
def _openrouter_only_cfg(tmp_path: Path) -> RouterConfig:
"""Return a config that enables only the openrouter provider."""
db_path = str(tmp_path / "test.db")
or_cfg = REAL_CFG.dispatch_providers["openrouter"]
return 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": {"openrouter": or_cfg},
}
)
def _run_poller(cfg: RouterConfig, monkeypatch, catalog_by_url: dict[str, dict]):
"""Run poller.main() with a URL-keyed mock and return a connect function."""
db_path = cfg.database.path
def _connect():
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn
c = _connect()
c.executescript(SCHEMA_SQL)
c.close()
monkeypatch.setattr(poller, "load_config", lambda p: cfg)
def _get(url, timeout, **kwargs):
for key, payload in catalog_by_url.items():
if key in url:
return _fake_response(payload)
raise RuntimeError(f"unexpected URL in test: {url}")
monkeypatch.setattr(poller.requests, "get", _get)
return _connect
# ---------------------------------------------------------------------------
# Config tests
# ---------------------------------------------------------------------------
def test_dispatch_provider_defaults_require_allowlist_false():
"""``require_allowlist`` defaults to False when not supplied."""
provider = DispatchProvider(
base_url="https://example.com/v1",
api_key_env="EXAMPLE_API_KEY",
enabled=True,
)
assert provider.require_allowlist is False
def test_dispatch_provider_accepts_require_allowlist_true(tmp_path):
"""A provider block with ``require_allowlist: true`` loads cleanly."""
cfg_path = tmp_path / "config.yaml"
providers = {
name: prov.model_copy(update={"require_allowlist": True})
for name, prov in REAL_CFG.dispatch_providers.items()
}
cfg_data = REAL_CFG.model_copy(
update={"dispatch_providers": providers}
).model_dump(mode="json")
cfg_path.write_text(json.dumps(cfg_data))
cfg = load_config(str(cfg_path))
for prov_cfg in cfg.dispatch_providers.values():
assert prov_cfg.require_allowlist is True
def test_dispatch_provider_unknown_key_rejected():
"""``StrictModel.extra="forbid"`` rejects an unknown allowlist-ish key."""
with pytest.raises(Exception):
DispatchProvider(
base_url="https://example.com/v1",
api_key_env="KEY",
allowed_models=["foo"],
)
def test_real_config_openrouter_requires_allowlist():
"""The shipped config keeps the OpenRouter provider in allowlist mode."""
assert REAL_CFG.dispatch_providers["openrouter"].require_allowlist is True
def test_real_config_neuralwatt_no_allowlist():
"""The NeuralWatt provider in the shipped config is not allowlisted."""
nw = REAL_CFG.dispatch_providers["neuralwatt"]
assert nw.require_allowlist is False
# ---------------------------------------------------------------------------
# Poller tests
# ---------------------------------------------------------------------------
def test_filter_before_upsert(tmp_path, monkeypatch):
"""Only allowlisted rows are upserted; pruned catalog rows never hit the DB."""
cfg = _openrouter_only_cfg(tmp_path)
extras = [f"extra/model-{i}" for i in range(5)]
catalog = {"data": _openrouter_catalog(ALLOWLIST_MODEL_IDS + extras)}
connect = _run_poller(cfg, monkeypatch, {"openrouter.ai": catalog})
c = connect()
_seed_allowlist(c)
c.close()
poller.main()
c = connect()
try:
active_ids = {
row["model_id"]
for row in c.execute(
"SELECT model_id FROM models WHERE provider='openrouter' AND availability='active'"
)
}
all_ids = {
row["model_id"]
for row in c.execute("SELECT model_id FROM models WHERE provider='openrouter'")
}
assert active_ids == set(ALLOWLIST_MODEL_IDS)
assert all_ids == set(ALLOWLIST_MODEL_IDS)
for extra in extras:
assert extra not in all_ids
finally:
c.close()
def test_prune_non_allowlisted_existing_rows(tmp_path, monkeypatch):
"""Pre-existing active rows outside the allowlist become deprecated."""
cfg = _openrouter_only_cfg(tmp_path)
existing = ALLOWLIST_MODEL_IDS[:5] + [f"legacy/model-{i}" for i in range(10)]
connect = _run_poller(
cfg,
monkeypatch,
{"openrouter.ai": {"data": _openrouter_catalog(ALLOWLIST_MODEL_IDS)}},
)
c = connect()
_seed_allowlist(c)
_seed_existing_models(c, existing, provider="openrouter")
c.close()
poller.main()
c = connect()
try:
active = {
row["model_id"]
for row in c.execute(
"SELECT model_id FROM models WHERE provider='openrouter' AND availability='active'"
)
}
deprecated = {
row["model_id"]
for row in c.execute(
"SELECT model_id FROM models WHERE provider='openrouter' AND availability='deprecated'"
)
}
assert active == set(ALLOWLIST_MODEL_IDS)
assert all(mid in deprecated for mid in existing[5:])
assert all(mid in active for mid in ALLOWLIST_MODEL_IDS[:5])
finally:
c.close()
def test_empty_allowlist_deprecates_all_and_skips_fetch(tmp_path, monkeypatch):
"""With an empty allowlist all active rows are deprecated and no catalog rows load."""
cfg = _openrouter_only_cfg(tmp_path)
existing = [f"legacy/model-{i}" for i in range(5)]
stderr = io.StringIO()
monkeypatch.setattr("sys.stderr", stderr)
connect = _run_poller(
cfg,
monkeypatch,
{"openrouter.ai": {"data": _openrouter_catalog(ALLOWLIST_MODEL_IDS)}},
)
c = connect()
_seed_existing_models(c, existing, provider="openrouter")
c.close()
poller.main()
stderr_value = stderr.getvalue()
assert "allowlist is empty" in stderr_value.lower()
c = connect()
try:
active_count = c.execute(
"SELECT COUNT(*) FROM models WHERE provider='openrouter' AND availability='active'"
).fetchone()[0]
deprecated = [
row["model_id"]
for row in c.execute(
"SELECT model_id FROM models WHERE provider='openrouter' AND availability='deprecated'"
)
]
assert active_count == 0
assert set(deprecated) == set(existing)
finally:
c.close()
def test_require_allowlist_false_skips_logic(tmp_path, monkeypatch):
"""NeuralWatt (require_allowlist=False) upserts every fetched row untouched."""
cfg = _allowlist_cfg(tmp_path, openrouter=False)
model_ids = ["nw-model-a", "nw-model-b", "nw-model-c"]
connect = _run_poller(
cfg,
monkeypatch,
{"api.neuralwatt.com": {"data": [_catalog_item(mid) for mid in model_ids]}},
)
c = connect()
c.close()
poller.main()
c = connect()
try:
active = {
row["model_id"]
for row in c.execute(
"SELECT model_id FROM models WHERE provider='neuralwatt' AND availability='active'"
)
}
deprecated = [
row["model_id"]
for row in c.execute(
"SELECT model_id FROM models WHERE provider='neuralwatt' AND deprecated=1"
)
]
assert active == set(model_ids)
assert deprecated == []
finally:
c.close()
def test_add_remove_mid_cycle(tmp_path, monkeypatch):
"""Changing the allowlist between polls is reflected in the next run."""
cfg = _openrouter_only_cfg(tmp_path)
first_allowed = ALLOWLIST_MODEL_IDS[:10]
second_allowed = ALLOWLIST_MODEL_IDS[5:15]
connect = _run_poller(
cfg,
monkeypatch,
{"openrouter.ai": {"data": _openrouter_catalog(ALLOWLIST_MODEL_IDS[:15])}},
)
c = connect()
_seed_allowlist(c)
c.execute(
"DELETE FROM provider_model_allowlist WHERE provider='openrouter' AND model_id NOT IN (%s)"
% ",".join("?" * len(first_allowed)),
first_allowed,
)
c.commit()
c.close()
poller.main()
c = connect()
try:
active_first = {
row["model_id"]
for row in c.execute(
"SELECT model_id FROM models WHERE provider='openrouter' AND availability='active'"
)
}
assert active_first == set(first_allowed)
finally:
c.close()
c = connect()
c.execute("DELETE FROM provider_model_allowlist WHERE provider='openrouter'")
now = datetime.now(timezone.utc).isoformat()
for mid in second_allowed:
c.execute(
"INSERT INTO provider_model_allowlist (provider, model_id, added_at, note) VALUES (?, ?, ?, ?)",
("openrouter", mid, now, "mid-cycle"),
)
c.commit()
c.close()
poller.main()
c = connect()
try:
active_second = {
row["model_id"]
for row in c.execute(
"SELECT model_id FROM models WHERE provider='openrouter' AND availability='active'"
)
}
deprecated_now = {
row["model_id"]
for row in c.execute(
"SELECT model_id FROM models WHERE provider='openrouter' AND availability='deprecated'"
)
}
assert active_second == set(second_allowed)
assert all(mid in deprecated_now for mid in first_allowed[:5])
finally:
c.close()
# ---------------------------------------------------------------------------
# Integration scenarios
# ---------------------------------------------------------------------------
def _catalog_425() -> list[dict]:
"""Return a 425-row OpenRouter-style catalog containing the 30 allowlist IDs."""
extras = [f"vendor/extra-{i}" for i in range(395)]
return _openrouter_catalog(ALLOWLIST_MODEL_IDS + extras)
def test_scenario_a_clean_db_allowlist_30_of_425(tmp_path, monkeypatch):
"""Clean DB + 30 allowlist rows + 425-row catalog -> 30 active, 0 deprecated."""
cfg = _openrouter_only_cfg(tmp_path)
connect = _run_poller(
cfg,
monkeypatch,
{"openrouter.ai": {"data": _catalog_425()}},
)
c = connect()
_seed_allowlist(c)
c.close()
poller.main()
c = connect()
try:
active = c.execute(
"SELECT COUNT(*) FROM models WHERE provider='openrouter' AND availability='active'"
).fetchone()[0]
deprecated = c.execute(
"SELECT COUNT(*) FROM models WHERE provider='openrouter' AND availability='deprecated'"
).fetchone()[0]
total = c.execute(
"SELECT COUNT(*) FROM models WHERE provider='openrouter'"
).fetchone()[0]
assert active == 30
assert deprecated == 0
assert total == 30
finally:
c.close()
def test_scenario_b_preexisting_425_pruned_to_30(tmp_path, monkeypatch):
"""425 pre-existing rows + 30 allowlist rows -> 30 active + 395 deprecated."""
cfg = _openrouter_only_cfg(tmp_path)
all_ids = ALLOWLIST_MODEL_IDS + [f"vendor/extra-{i}" for i in range(395)]
connect = _run_poller(
cfg,
monkeypatch,
{"openrouter.ai": {"data": _catalog_425()}},
)
c = connect()
_seed_allowlist(c)
_seed_existing_models(c, all_ids, provider="openrouter")
c.close()
poller.main()
c = connect()
try:
active = c.execute(
"SELECT COUNT(*) FROM models WHERE provider='openrouter' AND availability='active'"
).fetchone()[0]
deprecated = c.execute(
"SELECT COUNT(*) FROM models WHERE provider='openrouter' AND availability='deprecated'"
).fetchone()[0]
total = c.execute(
"SELECT COUNT(*) FROM models WHERE provider='openrouter'"
).fetchone()[0]
assert active == 30
assert deprecated == 395
assert total == 425
finally:
c.close()
def test_scenario_c_empty_allowlist_deprecates_all_preexisting(tmp_path, monkeypatch):
"""Empty allowlist + pre-existing rows -> 0 active, all deprecated, warning logged."""
cfg = _openrouter_only_cfg(tmp_path)
existing = ALLOWLIST_MODEL_IDS + [f"vendor/extra-{i}" for i in range(10)]
stderr = io.StringIO()
monkeypatch.setattr("sys.stderr", stderr)
connect = _run_poller(
cfg,
monkeypatch,
{"openrouter.ai": {"data": _catalog_425()}},
)
c = connect()
_seed_existing_models(c, existing, provider="openrouter")
c.close()
poller.main()
stderr_value = stderr.getvalue()
assert "allowlist is empty" in stderr_value.lower()
c = connect()
try:
active = c.execute(
"SELECT COUNT(*) FROM models WHERE provider='openrouter' AND availability='active'"
).fetchone()[0]
deprecated = c.execute(
"SELECT COUNT(*) FROM models WHERE provider='openrouter' AND availability='deprecated'"
).fetchone()[0]
assert active == 0
assert deprecated == len(existing)
finally:
c.close()
def test_neuralwatt_regression_no_deprecation(tmp_path, monkeypatch):
"""NeuralWatt (allowlist disabled) still marks every fetched row active."""
cfg = _allowlist_cfg(tmp_path, openrouter=False)
model_ids = [f"nw-model-{i}" for i in range(20)]
connect = _run_poller(
cfg,
monkeypatch,
{"api.neuralwatt.com": {"data": [_catalog_item(mid) for mid in model_ids]}},
)
c = connect()
c.close()
poller.main()
c = connect()
try:
active = c.execute(
"SELECT COUNT(*) FROM models WHERE provider='neuralwatt' AND availability='active'"
).fetchone()[0]
deprecated = c.execute(
"SELECT COUNT(*) FROM models WHERE provider='neuralwatt' AND availability='deprecated'"
).fetchone()[0]
assert active == 20
assert deprecated == 0
finally:
c.close()

View File

@@ -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}
),
},
}
)