feat: add opt-in model allowlist for OpenRouter #45
@@ -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…</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 || '—'} · ${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 || ''))} · ${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();
|
||||
}
|
||||
|
||||
|
||||
42
config/allowlist-openrouter.sql
Normal file
42
config/allowlist-openrouter.sql
Normal 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');
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
117
src/admin.py
117
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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
287
tests/test_admin_allowlist.py
Normal file
287
tests/test_admin_allowlist.py
Normal 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
630
tests/test_allowlist.py
Normal 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()
|
||||
@@ -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}
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user