feat: add model availability monitoring for OpenCode fallback chain

- Add background job that checks model availability every 5 hours
- Automatically removes unavailable models from fallback chain
- Adds unavailable models section in admin UI with blur styling
- Allows admins to re-add models when they become available again
- Extends model schema with available, lastChecked, unavailableSince fields
- Adds API endpoints: GET /api/admin/models/availability, POST /api/admin/models/:id/readd
This commit is contained in:
southseact-3d
2026-02-19 19:55:48 +00:00
parent 5df7ef1c8d
commit a92797d3a7
4 changed files with 288 additions and 10 deletions

View File

@@ -160,6 +160,16 @@
<div id="opencode-models-list" class="admin-list"></div>
</div>
<!-- Unavailable Models Section -->
<div class="admin-card unavailable-models-section" style="margin-top: 16px; display: none;">
<header>
<h3>Unavailable Models</h3>
<div class="pill" id="unavailable-models-count">0</div>
</header>
<p class="muted" style="margin-top:0;">These models were removed from the fallback chain because they are no longer available from OpenCode. You can re-add them if they become available again.</p>
<div id="unavailable-models-list" class="admin-list"></div>
</div>
<!-- Public Models List with Ordering -->
<div class="admin-card" style="margin-top: 16px;">
<header>

View File

@@ -32,6 +32,10 @@
reloadOpencodeModels: document.getElementById('reload-opencode-models'),
opencodeModelsList: document.getElementById('opencode-models-list'),
opencodeModelsCount: document.getElementById('opencode-models-count'),
// Unavailable Models
unavailableModelsSection: document.querySelector('.unavailable-models-section'),
unavailableModelsList: document.getElementById('unavailable-models-list'),
unavailableModelsCount: document.getElementById('unavailable-models-count'),
// Public Models (user-facing selection)
publicModelForm: document.getElementById('public-model-form'),
publicModelLabel: document.getElementById('public-model-label'),
@@ -602,17 +606,22 @@
function renderOpencodeModels() {
if (!el.opencodeModelsList) return;
el.opencodeModelsList.innerHTML = '';
if (el.opencodeModelsCount) el.opencodeModelsCount.textContent = state.opencodeModels.length.toString();
if (!state.opencodeModels.length) {
const availableModels = state.opencodeModels.filter((m) => m.available !== false);
const unavailableModels = state.opencodeModels.filter((m) => m.available === false);
if (el.opencodeModelsCount) el.opencodeModelsCount.textContent = availableModels.length.toString();
if (!availableModels.length) {
const empty = document.createElement('div');
empty.className = 'muted';
empty.textContent = 'No OpenCode models configured. Add models to enable fallback chain for build execution.';
el.opencodeModelsList.appendChild(empty);
renderUnavailableModels(unavailableModels);
return;
}
state.opencodeModels.forEach((m, idx) => {
availableModels.forEach((m, idx) => {
const row = document.createElement('div');
row.className = 'provider-row slim';
@@ -672,10 +681,11 @@
upBtn.title = 'Move up';
upBtn.disabled = idx === 0;
upBtn.addEventListener('click', async () => {
const next = [...state.opencodeModels];
const next = [...availableModels];
const [item] = next.splice(idx, 1);
next.splice(Math.max(0, idx - 1), 0, item);
await persistOpencodeModelsOrder(next);
const fullNext = [...next, ...unavailableModels];
await persistOpencodeModelsOrder(fullNext);
});
headerActions.appendChild(upBtn);
@@ -684,12 +694,13 @@
downBtn.className = 'ghost';
downBtn.textContent = '↓';
downBtn.title = 'Move down';
downBtn.disabled = idx === state.opencodeModels.length - 1;
downBtn.disabled = idx === availableModels.length - 1;
downBtn.addEventListener('click', async () => {
const next = [...state.opencodeModels];
const next = [...availableModels];
const [item] = next.splice(idx, 1);
next.splice(Math.min(state.opencodeModels.length, idx + 1), 0, item);
await persistOpencodeModelsOrder(next);
next.splice(Math.min(availableModels.length, idx + 1), 0, item);
const fullNext = [...next, ...unavailableModels];
await persistOpencodeModelsOrder(fullNext);
});
headerActions.appendChild(downBtn);
@@ -782,6 +793,114 @@
row.appendChild(header);
el.opencodeModelsList.appendChild(row);
});
renderUnavailableModels(unavailableModels);
}
// Render unavailable models list with re-add option
function renderUnavailableModels(models) {
if (!el.unavailableModelsList || !el.unavailableModelsSection) return;
if (!models.length) {
el.unavailableModelsSection.style.display = 'none';
return;
}
el.unavailableModelsSection.style.display = 'block';
el.unavailableModelsList.innerHTML = '';
if (el.unavailableModelsCount) {
el.unavailableModelsCount.textContent = models.length.toString();
}
models.forEach((m) => {
const row = document.createElement('div');
row.className = 'provider-row slim unavailable-model';
const header = document.createElement('div');
header.className = 'provider-row-header';
const info = document.createElement('div');
info.className = 'model-chip';
// Unavailable badge
const unavailableBadge = document.createElement('span');
unavailableBadge.className = 'pill';
unavailableBadge.style.background = 'var(--danger)';
unavailableBadge.textContent = 'Unavailable';
info.appendChild(unavailableBadge);
if (m.icon) {
const img = document.createElement('img');
img.src = m.icon;
img.alt = '';
img.style.filter = 'grayscale(100%)';
info.appendChild(img);
}
const label = document.createElement('span');
label.textContent = m.label || m.name;
label.style.opacity = '0.6';
info.appendChild(label);
const namePill = document.createElement('span');
namePill.className = 'pill';
namePill.textContent = m.name;
namePill.style.opacity = '0.6';
info.appendChild(namePill);
if (m.unavailableSince) {
const sincePill = document.createElement('span');
sincePill.className = 'pill';
sincePill.textContent = `Since ${new Date(m.unavailableSince).toLocaleDateString()}`;
info.appendChild(sincePill);
}
header.appendChild(info);
const headerActions = document.createElement('div');
headerActions.className = 'provider-row-actions';
// Re-add button
const readdBtn = document.createElement('button');
readdBtn.className = 'primary';
readdBtn.textContent = 'Re-add';
readdBtn.addEventListener('click', async () => {
readdBtn.disabled = true;
readdBtn.textContent = 'Checking...';
try {
await api(`/api/admin/models/${m.id}/readd`, { method: 'POST' });
await loadConfigured();
setStatus('Model re-added to fallback chain');
setTimeout(() => setStatus(''), 2000);
} catch (err) {
setStatus(err.message, true);
readdBtn.disabled = false;
readdBtn.textContent = 'Re-add';
}
});
headerActions.appendChild(readdBtn);
// Delete button
const delBtn = document.createElement('button');
delBtn.className = 'ghost';
delBtn.textContent = 'Delete';
delBtn.addEventListener('click', async () => {
delBtn.disabled = true;
try {
await api(`/api/admin/models/${m.id}?type=opencode`, { method: 'DELETE' });
await loadConfigured();
} catch (err) {
setStatus(err.message, true);
}
delBtn.disabled = false;
});
headerActions.appendChild(delBtn);
header.appendChild(headerActions);
row.appendChild(header);
el.unavailableModelsList.appendChild(row);
});
}
// Render Public models list with up/down ordering

View File

@@ -1161,4 +1161,29 @@ textarea:focus {
font-size: 14px;
font-weight: 600;
letter-spacing: 0.02em;
}
/* Unavailable models styling */
.unavailable-models-section {
background: rgba(176, 0, 32, 0.02);
border-color: rgba(176, 0, 32, 0.15);
}
.unavailable-model {
opacity: 0.7;
filter: blur(0.5px);
background: #fafafa;
}
.unavailable-model:hover {
opacity: 1;
filter: none;
}
.unavailable-model .model-chip {
filter: grayscale(30%);
}
.unavailable-model img {
filter: grayscale(100%);
}