diff --git a/chat/public/admin.js b/chat/public/admin.js
index 3f61953..52ec4d2 100644
--- a/chat/public/admin.js
+++ b/chat/public/admin.js
@@ -4,10 +4,11 @@
const pageType = document?.body?.dataset?.page || 'build';
console.log('Admin JS loaded, pageType:', pageType);
const state = {
- available: [],
- configured: [],
- publicModels: [], // New unified structure - public facing models
- providerChain: [], // New unified structure - provider fallback chain
+ available: [], // Models available from OpenCode
+ configured: [], // Legacy - provider models from OpenCode
+ providerModels: [], // Provider models (from OpenCode)
+ providerChain: [], // Unified fallback chain
+ publicModels: [], // Public-facing models (completely separate)
icons: [],
accounts: [],
affiliates: [],
@@ -17,12 +18,27 @@
providerUsage: [],
opencodeBackupModel: '',
providerOptions: [],
- providerModels: {},
tokenRates: {},
};
const el = {
- // New unified model elements
+ // Provider Models (with OpenCode integration)
+ providerModelForm: document.getElementById('provider-model-form'),
+ availableModels: document.getElementById('available-models'),
+ providerModelLabel: document.getElementById('provider-model-label'),
+ providerModelTier: document.getElementById('provider-model-tier'),
+ providerModelIcon: document.getElementById('provider-model-icon'),
+ providerModelMedia: document.getElementById('provider-model-media'),
+ providerModelStatus: document.getElementById('provider-model-status'),
+ reloadAvailable: document.getElementById('reload-available'),
+ // Provider Chain
+ providerChainForm: document.getElementById('provider-chain-form'),
+ chainProvider: document.getElementById('chain-provider'),
+ chainModel: document.getElementById('chain-model'),
+ providerChainStatus: document.getElementById('provider-chain-status'),
+ providerChainList: document.getElementById('provider-chain-list'),
+ providerChainCount: document.getElementById('provider-chain-count'),
+ // Public-Facing Models (completely separate)
publicModelForm: document.getElementById('public-model-form'),
publicModelName: document.getElementById('public-model-name'),
publicModelLabel: document.getElementById('public-model-label'),
@@ -30,14 +46,9 @@
publicModelIcon: document.getElementById('public-model-icon'),
publicModelMedia: document.getElementById('public-model-media'),
publicModelStatus: document.getElementById('public-model-status'),
- providerChainForm: document.getElementById('provider-chain-form'),
- chainProvider: document.getElementById('chain-provider'),
- chainModel: document.getElementById('chain-model'),
- providerChainStatus: document.getElementById('provider-chain-status'),
- providerChainList: document.getElementById('provider-chain-list'),
- providerChainCount: document.getElementById('provider-chain-count'),
+ publicModelsList: document.getElementById('public-models-list'),
+ publicModelsCount: document.getElementById('public-models-count'),
// Legacy elements
- availableModels: document.getElementById('available-models'),
displayLabel: document.getElementById('display-label'),
modelTier: document.getElementById('model-tier'),
iconSelect: document.getElementById('icon-select'),
@@ -198,6 +209,12 @@
el.autoModelStatus.style.color = isError ? 'var(--danger)' : 'inherit';
}
+ function setProviderModelStatus(msg, isError = false) {
+ if (!el.providerModelStatus) return;
+ el.providerModelStatus.textContent = msg || '';
+ el.providerModelStatus.style.color = isError ? 'var(--danger)' : 'inherit';
+ }
+
function setPublicModelStatus(msg, isError = false) {
if (!el.publicModelStatus) return;
el.publicModelStatus.textContent = msg || '';
@@ -546,7 +563,22 @@
});
}
- // Populate new public model icon select
+ // Populate provider model icon select
+ if (el.providerModelIcon) {
+ el.providerModelIcon.innerHTML = '';
+ const none = document.createElement('option');
+ none.value = '';
+ none.textContent = 'No icon';
+ el.providerModelIcon.appendChild(none);
+ state.icons.forEach((iconPath) => {
+ const opt = document.createElement('option');
+ opt.value = iconPath;
+ opt.textContent = iconPath.replace('/assets/', '');
+ el.providerModelIcon.appendChild(opt);
+ });
+ }
+
+ // Populate public model icon select
if (el.publicModelIcon) {
el.publicModelIcon.innerHTML = '';
const none = document.createElement('option');
@@ -588,19 +620,18 @@
}
}
- // Simplified renderConfigured for new publicModels structure
- function renderConfigured() {
+ // Render Provider Models (from OpenCode)
+ function renderProviderModels() {
if (!el.configuredList) return;
el.configuredList.innerHTML = '';
- // Use publicModels if available, otherwise fall back to configured
- const modelsToRender = state.publicModels.length > 0 ? state.publicModels : state.configured;
+ const modelsToRender = state.providerModels;
if (el.configuredCount) el.configuredCount.textContent = modelsToRender.length.toString();
if (!modelsToRender.length) {
const empty = document.createElement('div');
empty.className = 'muted';
- empty.textContent = 'No public-facing models configured yet.';
+ empty.textContent = 'No provider models configured yet. Add models from OpenCode above.';
el.configuredList.appendChild(empty);
return;
}
@@ -677,7 +708,7 @@
const next = [...modelsToRender];
const [item] = next.splice(idx, 1);
next.splice(idx - 1, 0, item);
- await persistPublicModelsOrder(next);
+ await persistProviderModelsOrder(next);
});
headerActions.appendChild(upBtn);
@@ -692,7 +723,7 @@
const next = [...modelsToRender];
const [item] = next.splice(idx, 1);
next.splice(idx + 1, 0, item);
- await persistPublicModelsOrder(next);
+ await persistProviderModelsOrder(next);
});
headerActions.appendChild(downBtn);
@@ -705,7 +736,7 @@
await api(`/api/admin/models/${m.id}`, { method: 'DELETE' });
await loadConfigured();
} catch (err) {
- setStatus(err.message, true);
+ setProviderModelStatus(err.message, true);
}
delBtn.disabled = false;
});
@@ -741,8 +772,8 @@
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
try {
- await persistPublicModelChanges(m.id, { icon: sel.value });
- } catch (err) { setStatus(err.message, true); }
+ await persistProviderModelChanges(m.id, { icon: sel.value });
+ } catch (err) { setProviderModelStatus(err.message, true); }
saveBtn.disabled = false;
});
editor.appendChild(saveBtn);
@@ -769,7 +800,7 @@
mediaCheckbox.addEventListener('change', async () => {
mediaCheckbox.disabled = true;
try {
- await persistPublicModelChanges(m.id, { supportsMedia: mediaCheckbox.checked });
+ await persistProviderModelChanges(m.id, { supportsMedia: mediaCheckbox.checked });
} catch (err) { setStatus(err.message, true); }
mediaCheckbox.disabled = false;
});
@@ -834,11 +865,294 @@
});
}
+ // Render Public Models (completely separate from OpenCode)
+ function renderPublicModels() {
+ if (!el.publicModelsList) return;
+ el.publicModelsList.innerHTML = '';
+
+ if (el.publicModelsCount) el.publicModelsCount.textContent = state.publicModels.length.toString();
+
+ if (!state.publicModels.length) {
+ const empty = document.createElement('div');
+ empty.className = 'muted';
+ empty.textContent = 'No public-facing models configured yet.';
+ el.publicModelsList.appendChild(empty);
+ return;
+ }
+
+ state.publicModels.forEach((m, idx) => {
+ const row = document.createElement('div');
+ row.className = 'provider-row slim';
+
+ const header = document.createElement('div');
+ header.className = 'provider-row-header';
+
+ const info = document.createElement('div');
+ info.className = 'model-chip';
+
+ // Order number badge
+ const orderBadge = document.createElement('span');
+ orderBadge.className = 'pill';
+ orderBadge.textContent = `#${idx + 1}`;
+ orderBadge.style.background = idx === 0 ? 'var(--shopify-green)' : 'var(--primary)';
+ orderBadge.style.fontWeight = '700';
+ info.appendChild(orderBadge);
+
+ if (m.icon) {
+ const img = document.createElement('img');
+ img.src = m.icon;
+ img.alt = '';
+ info.appendChild(img);
+ }
+
+ const label = document.createElement('span');
+ label.textContent = m.label || m.name;
+ info.appendChild(label);
+
+ const namePill = document.createElement('span');
+ namePill.className = 'pill';
+ namePill.textContent = m.name;
+ info.appendChild(namePill);
+
+ const tierMeta = document.createElement('span');
+ tierMeta.className = 'pill';
+ const tierName = (m.tier || 'free').toUpperCase();
+ const multiplier = m.tier === 'pro' ? 3 : (m.tier === 'plus' ? 2 : 1);
+ tierMeta.textContent = `${tierName} (${multiplier}x)`;
+ info.appendChild(tierMeta);
+
+ if (m.supportsMedia) {
+ const mediaBadge = document.createElement('span');
+ mediaBadge.className = 'pill';
+ mediaBadge.style.background = 'var(--shopify-green)';
+ mediaBadge.textContent = 'Media';
+ info.appendChild(mediaBadge);
+ }
+
+ header.appendChild(info);
+
+ const headerActions = document.createElement('div');
+ headerActions.className = 'provider-row-actions';
+
+ // Up button
+ const upBtn = document.createElement('button');
+ upBtn.className = 'ghost';
+ upBtn.textContent = '↑';
+ upBtn.title = 'Move up';
+ upBtn.disabled = idx === 0;
+ upBtn.addEventListener('click', async () => {
+ if (idx === 0) return;
+ const next = [...state.publicModels];
+ const [item] = next.splice(idx, 1);
+ next.splice(idx - 1, 0, item);
+ await persistPublicModelsOrder(next);
+ });
+ headerActions.appendChild(upBtn);
+
+ // Down button
+ const downBtn = document.createElement('button');
+ downBtn.className = 'ghost';
+ downBtn.textContent = '↓';
+ downBtn.title = 'Move down';
+ downBtn.disabled = idx === state.publicModels.length - 1;
+ downBtn.addEventListener('click', async () => {
+ if (idx === state.publicModels.length - 1) return;
+ const next = [...state.publicModels];
+ const [item] = next.splice(idx, 1);
+ next.splice(idx + 1, 0, item);
+ await persistPublicModelsOrder(next);
+ });
+ headerActions.appendChild(downBtn);
+
+ 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}`, { method: 'DELETE' });
+ await loadConfigured();
+ } catch (err) {
+ setPublicModelStatus(err.message, true);
+ }
+ delBtn.disabled = false;
+ });
+ headerActions.appendChild(delBtn);
+
+ // Inline icon editor button
+ const editIconBtn = document.createElement('button');
+ editIconBtn.className = 'ghost';
+ editIconBtn.textContent = 'Edit icon';
+ editIconBtn.addEventListener('click', () => {
+ let editor = header.querySelector('.icon-editor');
+ if (editor) return editor.remove();
+ editor = document.createElement('div');
+ editor.className = 'icon-editor';
+
+ const sel = document.createElement('select');
+ const none = document.createElement('option');
+ none.value = '';
+ none.textContent = 'No icon';
+ sel.appendChild(none);
+ (state.icons || []).forEach((iconPath) => {
+ const o = document.createElement('option');
+ o.value = iconPath;
+ o.textContent = iconPath.replace('/assets/', '');
+ sel.appendChild(o);
+ });
+ sel.value = m.icon || '';
+ editor.appendChild(sel);
+
+ const saveBtn = document.createElement('button');
+ saveBtn.className = 'primary';
+ saveBtn.textContent = 'Save';
+ saveBtn.addEventListener('click', async () => {
+ saveBtn.disabled = true;
+ try {
+ await persistPublicModelChanges(m.id, { icon: sel.value });
+ } catch (err) { setPublicModelStatus(err.message, true); }
+ saveBtn.disabled = false;
+ });
+ editor.appendChild(saveBtn);
+
+ const cancelBtn = document.createElement('button');
+ cancelBtn.className = 'ghost';
+ cancelBtn.textContent = 'Cancel';
+ cancelBtn.addEventListener('click', () => editor.remove());
+ editor.appendChild(cancelBtn);
+
+ headerActions.appendChild(editor);
+ });
+ headerActions.appendChild(editIconBtn);
+
+ // Supports media checkbox
+ const mediaToggle = document.createElement('label');
+ mediaToggle.style.display = 'flex';
+ mediaToggle.style.alignItems = 'center';
+ mediaToggle.style.gap = '6px';
+ mediaToggle.style.marginLeft = '8px';
+ const mediaCheckbox = document.createElement('input');
+ mediaCheckbox.type = 'checkbox';
+ mediaCheckbox.checked = m.supportsMedia ?? false;
+ mediaCheckbox.addEventListener('change', async () => {
+ mediaCheckbox.disabled = true;
+ try {
+ await persistPublicModelChanges(m.id, { supportsMedia: mediaCheckbox.checked });
+ } catch (err) { setPublicModelStatus(err.message, true); }
+ mediaCheckbox.disabled = false;
+ });
+ mediaToggle.appendChild(mediaCheckbox);
+ const mediaLabel = document.createElement('span');
+ mediaLabel.textContent = 'Supports image uploads';
+ mediaLabel.style.fontSize = '12px';
+ mediaLabel.style.color = 'var(--muted)';
+ mediaToggle.appendChild(mediaLabel);
+ headerActions.appendChild(mediaToggle);
+
+ // Tier editor button
+ const editTierBtn = document.createElement('button');
+ editTierBtn.className = 'ghost';
+ editTierBtn.textContent = 'Edit tier/multiplier';
+ editTierBtn.addEventListener('click', () => {
+ let editor = header.querySelector('.tier-editor');
+ if (editor) return editor.remove();
+ editor = document.createElement('div');
+ editor.className = 'tier-editor';
+
+ const sel = document.createElement('select');
+ const options = [
+ { value: 'free', label: 'Free (1x)' },
+ { value: 'plus', label: 'Plus (2x)' },
+ { value: 'pro', label: 'Pro (3x)' }
+ ];
+ options.forEach((opt) => {
+ const o = document.createElement('option');
+ o.value = opt.value;
+ o.textContent = opt.label;
+ sel.appendChild(o);
+ });
+ sel.value = m.tier || 'free';
+ editor.appendChild(sel);
+
+ const saveBtn = document.createElement('button');
+ saveBtn.className = 'primary';
+ saveBtn.textContent = 'Save';
+ saveBtn.addEventListener('click', async () => {
+ saveBtn.disabled = true;
+ try {
+ await persistPublicModelChanges(m.id, { tier: sel.value });
+ } catch (err) { setPublicModelStatus(err.message, true); }
+ saveBtn.disabled = false;
+ });
+ editor.appendChild(saveBtn);
+
+ const cancelBtn = document.createElement('button');
+ cancelBtn.className = 'ghost';
+ cancelBtn.textContent = 'Cancel';
+ cancelBtn.addEventListener('click', () => editor.remove());
+ editor.appendChild(cancelBtn);
+
+ headerActions.appendChild(editor);
+ });
+ headerActions.appendChild(editTierBtn);
+
+ header.appendChild(headerActions);
+ row.appendChild(header);
+ el.publicModelsList.appendChild(row);
+ });
+ }
+
+ async function persistProviderModelChanges(modelId, changes) {
+ setProviderModelStatus('Saving...');
+ const model = state.providerModels.find((m) => m.id === modelId);
+ if (!model) {
+ setProviderModelStatus('Model not found', true);
+ return;
+ }
+
+ const payload = {
+ type: 'providerModel',
+ id: modelId,
+ name: model.name,
+ label: model.label || model.name,
+ icon: changes.icon !== undefined ? changes.icon : model.icon,
+ tier: changes.tier !== undefined ? changes.tier : model.tier,
+ supportsMedia: changes.supportsMedia !== undefined ? changes.supportsMedia : model.supportsMedia,
+ };
+
+ try {
+ const data = await api('/api/admin/models', { method: 'POST', body: JSON.stringify(payload) });
+ const idx = state.providerModels.findIndex((m) => m.id === modelId);
+ if (idx >= 0) state.providerModels[idx] = { ...state.providerModels[idx], ...data.providerModel };
+ renderProviderModels();
+ setProviderModelStatus('Saved');
+ setTimeout(() => setProviderModelStatus(''), 1500);
+ } catch (err) {
+ setProviderModelStatus(err.message, true);
+ }
+ }
+
+ async function persistProviderModelsOrder(orderedModels) {
+ setProviderModelStatus('Saving order...');
+ try {
+ const res = await api('/api/admin/models/reorder', {
+ method: 'POST',
+ body: JSON.stringify({ type: 'providerModels', models: orderedModels }),
+ });
+ state.providerModels = res.providerModels || orderedModels;
+ renderProviderModels();
+ setProviderModelStatus('Order saved');
+ setTimeout(() => setProviderModelStatus(''), 1500);
+ } catch (err) {
+ setProviderModelStatus(err.message, true);
+ }
+ }
+
async function persistPublicModelChanges(modelId, changes) {
- setStatus('Saving...');
+ setPublicModelStatus('Saving...');
const model = state.publicModels.find((m) => m.id === modelId);
if (!model) {
- setStatus('Model not found', true);
+ setPublicModelStatus('Model not found', true);
return;
}
@@ -865,20 +1179,20 @@
}
async function persistPublicModelsOrder(orderedModels) {
- setStatus('Saving order...');
+ setPublicModelStatus('Saving order...');
try {
// Use the reorder endpoint to save the new order
const res = await api('/api/admin/models/reorder', {
method: 'POST',
- body: JSON.stringify({ models: orderedModels }),
+ body: JSON.stringify({ type: 'publicModels', models: orderedModels }),
});
// Update local state with new order from server
state.publicModels = res.publicModels || orderedModels;
- renderConfigured();
- setStatus('Order saved');
- setTimeout(() => setStatus(''), 1500);
+ renderPublicModels();
+ setPublicModelStatus('Order saved');
+ setTimeout(() => setPublicModelStatus(''), 1500);
} catch (err) {
- setStatus(err.message, true);
+ setPublicModelStatus(err.message, true);
}
}
@@ -1329,11 +1643,13 @@
async function loadConfigured() {
const data = await api('/api/admin/models');
- // Handle new unified structure
+ // Handle new structure - separate provider models and public models
+ state.providerModels = data.providerModels || [];
state.publicModels = data.publicModels || [];
state.providerChain = data.providerChain || [];
state.configured = data.models || []; // Legacy support
- renderConfigured();
+ renderProviderModels();
+ renderPublicModels();
renderProviderChain();
const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string'
? state.planSettings.freePlanModel
@@ -2281,7 +2597,62 @@
}
}
- // New public model form handler
+ // Provider model form handler (with OpenCode integration)
+ if (el.providerModelForm) {
+ el.providerModelForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const model = el.availableModels.value;
+ const label = el.providerModelLabel.value.trim();
+ const icon = el.providerModelIcon ? el.providerModelIcon.value : '';
+ const tier = el.providerModelTier ? el.providerModelTier.value : 'free';
+ const supportsMedia = el.providerModelMedia ? el.providerModelMedia.checked : false;
+
+ if (!model) {
+ setProviderModelStatus('Please select a model from OpenCode.', true);
+ return;
+ }
+ if (!label) {
+ setProviderModelStatus('Display name is required.', true);
+ return;
+ }
+
+ setProviderModelStatus('Saving...');
+ try {
+ await api('/api/admin/models', {
+ method: 'POST',
+ body: JSON.stringify({
+ type: 'providerModel',
+ name: model,
+ label,
+ icon,
+ tier,
+ supportsMedia
+ }),
+ });
+ setProviderModelStatus('Saved');
+ el.providerModelLabel.value = '';
+ await loadConfigured();
+ } catch (err) {
+ setProviderModelStatus(err.message, true);
+ }
+ });
+ }
+
+ // Reload available models button
+ if (el.reloadAvailable) {
+ el.reloadAvailable.addEventListener('click', async () => {
+ setProviderModelStatus('Loading...');
+ try {
+ await loadAvailable();
+ setProviderModelStatus('Models loaded');
+ setTimeout(() => setProviderModelStatus(''), 1500);
+ } catch (err) {
+ setProviderModelStatus(err.message, true);
+ }
+ });
+ }
+
+ // Public model form handler (completely separate from OpenCode)
if (el.publicModelForm) {
el.publicModelForm.addEventListener('submit', async (e) => {
e.preventDefault();
diff --git a/chat/server.js b/chat/server.js
index a5e2aeb..6ddb72b 100644
--- a/chat/server.js
+++ b/chat/server.js
@@ -1525,7 +1525,8 @@ const adminSessions = new Map();
let adminModels = [];
let adminModelIndex = new Map();
// New unified model chain structure
-let publicModels = []; // Models displayed in builder dropdown [{id, name, label, icon, tier, supportsMedia, multiplier}]
+let providerModels = []; // Provider models from OpenCode [{id, name, label, icon, tier, supportsMedia, multiplier}]
+let publicModels = []; // Public-facing models (completely separate) [{id, name, label, icon, tier, supportsMedia, multiplier}]
let providerChain = []; // Unified fallback chain [{provider, model, primary}]
let openrouterSettings = {
primaryModel: OPENROUTER_MODEL_PRIMARY,
@@ -5762,14 +5763,15 @@ async function loadAdminModelStore() {
// Check if using new unified structure or old structure
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
// New unified structure
+ providerModels = Array.isArray(parsed.providerModels) ? parsed.providerModels : [];
publicModels = Array.isArray(parsed.publicModels) ? parsed.publicModels : [];
providerChain = Array.isArray(parsed.providerChain) ? parsed.providerChain : [];
adminModels = Array.isArray(parsed.adminModels) ? parsed.adminModels : [];
} else if (Array.isArray(parsed)) {
// Old array structure - migrate to new
adminModels = parsed;
- // Create public models from admin models
- publicModels = parsed.map((m) => ({
+ // Create provider models from admin models (these are from OpenCode)
+ providerModels = parsed.map((m) => ({
id: m.id || randomUUID(),
name: m.name,
label: m.label || m.name,
@@ -5778,6 +5780,8 @@ async function loadAdminModelStore() {
supportsMedia: m.supportsMedia ?? false,
multiplier: getTierMultiplier(m.tier),
})).filter((m) => !!m.name);
+ // Public models start empty (user can add these manually later)
+ publicModels = [];
// Create unified provider chain from all unique providers
const allProviders = new Map();
parsed.forEach((m) => {
@@ -5797,11 +5801,23 @@ async function loadAdminModelStore() {
});
providerChain = Array.from(allProviders.values());
} else {
+ providerModels = [];
publicModels = [];
providerChain = [];
adminModels = [];
}
+ // Ensure all provider models have required fields
+ providerModels = providerModels.map((m) => ({
+ id: m.id || randomUUID(),
+ name: m.name,
+ label: m.label || m.name,
+ icon: m.icon || '',
+ tier: normalizeTier(m.tier),
+ supportsMedia: m.supportsMedia ?? false,
+ multiplier: m.multiplier || getTierMultiplier(m.tier),
+ })).filter((m) => !!m.name);
+
// Ensure all public models have required fields
publicModels = publicModels.map((m) => ({
id: m.id || randomUUID(),
@@ -5822,12 +5838,14 @@ async function loadAdminModelStore() {
refreshAdminModelIndex();
log('Loaded admin model store', {
+ providerModels: providerModels.length,
publicModels: publicModels.length,
providerChain: providerChain.length,
legacyAdminModels: adminModels.length
});
} catch (error) {
log('Failed to load admin models, starting empty', { error: String(error) });
+ providerModels = [];
publicModels = [];
providerChain = [];
adminModels = [];
@@ -5840,10 +5858,11 @@ async function persistAdminModels() {
await ensureAssetsDir();
// Save new unified structure
const payload = JSON.stringify({
+ providerModels,
publicModels,
providerChain,
adminModels, // Keep legacy for backwards compatibility
- version: 2, // Schema version
+ version: 3, // Schema version - added providerModels
}, null, 2);
await safeWriteFile(ADMIN_MODELS_FILE, payload);
refreshAdminModelIndex();
@@ -15857,7 +15876,8 @@ async function handleAdminModelsList(req, res) {
if (!session) return;
// Return new unified structure
sendJson(res, 200, {
- publicModels: publicModels,
+ providerModels,
+ publicModels,
providerChain,
// Legacy support
models: getConfiguredModels('opencode'),
@@ -15870,9 +15890,45 @@ async function handleAdminModelUpsert(req, res) {
try {
const body = await parseJsonBody(req);
- // Check if this is a public model or provider chain update
- if (body.type === 'publicModel') {
- // Handle public model update
+ // Check if this is a provider model, public model, or provider chain update
+ if (body.type === 'providerModel') {
+ // Handle provider model update (from OpenCode)
+ const modelName = (body.name || body.model || '').trim();
+ const label = (body.label || body.displayName || modelName).trim();
+ const tier = normalizeTier(body.tier);
+ if (!modelName) return sendJson(res, 400, { error: 'Model name is required' });
+ const id = body.id || randomUUID();
+ const existingIndex = providerModels.findIndex((m) => m.id === id);
+ let icon = providerModels[existingIndex]?.icon || '';
+ if (typeof body.icon === 'string' && body.icon.trim()) {
+ icon = await normalizeIconPath(body.icon);
+ if (!icon) return sendJson(res, 400, { error: 'Icon must be stored in /assets' });
+ }
+ const supportsMedia = typeof body.supportsMedia === 'boolean' ? body.supportsMedia : false;
+
+ const payload = {
+ id,
+ name: modelName,
+ label: label || modelName,
+ icon,
+ tier,
+ supportsMedia,
+ multiplier: getTierMultiplier(tier),
+ };
+
+ if (existingIndex >= 0) providerModels[existingIndex] = { ...providerModels[existingIndex], ...payload };
+ else providerModels.push(payload);
+
+ await persistAdminModels();
+ sendJson(res, 200, {
+ providerModel: payload,
+ providerModels,
+ publicModels,
+ providerChain,
+ models: getConfiguredModels('opencode'),
+ });
+ } else if (body.type === 'publicModel') {
+ // Handle public model update (completely separate from OpenCode)
const modelName = (body.name || body.model || '').trim();
const label = (body.label || body.displayName || modelName).trim();
const tier = normalizeTier(body.tier);
@@ -15902,7 +15958,8 @@ async function handleAdminModelUpsert(req, res) {
await persistAdminModels();
sendJson(res, 200, {
publicModel: payload,
- publicModels: publicModels.sort((a, b) => (a.label || '').localeCompare(b.label || '')),
+ providerModels,
+ publicModels,
providerChain,
models: getConfiguredModels('opencode'),
});
@@ -15973,28 +16030,52 @@ async function handleAdminModelsReorder(req, res) {
return sendJson(res, 400, { error: 'models array is required' });
}
- // Validate that all provided IDs exist
- const currentIds = new Set(publicModels.map(m => m.id));
- const newIds = body.models.map(m => m.id);
- const allExist = newIds.every(id => currentIds.has(id));
+ const reorderType = body.type || 'publicModels';
- if (!allExist) {
- return sendJson(res, 400, { error: 'Invalid model IDs in reorder request' });
+ if (reorderType === 'providerModels') {
+ // Validate that all provided IDs exist in providerModels
+ const currentIds = new Set(providerModels.map(m => m.id));
+ const newIds = body.models.map(m => m.id);
+ const allExist = newIds.every(id => currentIds.has(id));
+
+ if (!allExist) {
+ return sendJson(res, 400, { error: 'Invalid model IDs in reorder request' });
+ }
+
+ // Reorder providerModels based on the provided order
+ const reordered = [];
+ body.models.forEach(m => {
+ const model = providerModels.find(pm => pm.id === m.id);
+ if (model) reordered.push(model);
+ });
+
+ providerModels = reordered;
+ } else {
+ // Validate that all provided IDs exist in publicModels
+ const currentIds = new Set(publicModels.map(m => m.id));
+ const newIds = body.models.map(m => m.id);
+ const allExist = newIds.every(id => currentIds.has(id));
+
+ if (!allExist) {
+ return sendJson(res, 400, { error: 'Invalid model IDs in reorder request' });
+ }
+
+ // Reorder publicModels based on the provided order
+ const reordered = [];
+ body.models.forEach(m => {
+ const model = publicModels.find(pm => pm.id === m.id);
+ if (model) reordered.push(model);
+ });
+
+ publicModels = reordered;
}
- // Reorder publicModels based on the provided order
- const reordered = [];
- body.models.forEach(m => {
- const model = publicModels.find(pm => pm.id === m.id);
- if (model) reordered.push(model);
- });
-
- publicModels = reordered;
await persistAdminModels();
sendJson(res, 200, {
ok: true,
- publicModels: publicModels,
+ providerModels,
+ publicModels,
providerChain,
models: getConfiguredModels('opencode'),
});
@@ -16006,13 +16087,26 @@ async function handleAdminModelsReorder(req, res) {
async function handleAdminModelDelete(req, res, id) {
const session = requireAdminAuth(req, res);
if (!session) return;
- const before = publicModels.length;
+
+ // Try to delete from providerModels first
+ const beforeProvider = providerModels.length;
+ providerModels = providerModels.filter((m) => m.id !== id);
+ const deletedFromProvider = providerModels.length < beforeProvider;
+
+ // Try to delete from publicModels
+ const beforePublic = publicModels.length;
publicModels = publicModels.filter((m) => m.id !== id);
- if (publicModels.length === before) return sendJson(res, 404, { error: 'Model not found' });
+ const deletedFromPublic = publicModels.length < beforePublic;
+
+ if (!deletedFromProvider && !deletedFromPublic) {
+ return sendJson(res, 404, { error: 'Model not found' });
+ }
+
await persistAdminModels();
sendJson(res, 200, {
- ok: true,
- publicModels: publicModels.sort((a, b) => (a.label || '').localeCompare(b.label || '')),
+ ok: true,
+ providerModels,
+ publicModels,
providerChain,
models: getConfiguredModels('opencode'),
});