diff --git a/chat/public/admin.html b/chat/public/admin.html
index 0aca497..26506fb 100644
--- a/chat/public/admin.html
+++ b/chat/public/admin.html
@@ -68,9 +68,97 @@
+
- Add / Update Model
+ Add Public-Facing Model
+ Public
+
+
These models are displayed to users in the builder dropdown. They all share the same unified provider fallback chain.
+
+
+
+
+
+
+ Provider Model Chain
+ Backend
+
+
This is the unified fallback chain used for ALL models. When rate limits are reached or errors occur, the system automatically falls back to the next provider in this chain.
+
+
+
+
+
+
+
+ Unified Provider Chain Order
+ 0
+
+
Arrange the order of providers below. The system will try each provider in order when rate limits are reached or errors occur. The first provider is the primary.
+
+
+
+
+
+
+
+ Add / Update Model (Legacy)
Step 1
+
- Models available to users
+ Public-Facing Models
0
-
One row per model. Arrange provider order to control automatic fallback when a provider errors or hits a rate limit.
+
These models are displayed to users in the builder. All models use the unified provider chain above for fallback.
diff --git a/chat/public/admin.js b/chat/public/admin.js
index e2a7b48..dba3fab 100644
--- a/chat/public/admin.js
+++ b/chat/public/admin.js
@@ -6,6 +6,8 @@
const state = {
available: [],
configured: [],
+ publicModels: [], // New unified structure - public facing models
+ providerChain: [], // New unified structure - provider fallback chain
icons: [],
accounts: [],
affiliates: [],
@@ -20,6 +22,21 @@
};
const el = {
+ // New unified model elements
+ publicModelForm: document.getElementById('public-model-form'),
+ publicModelName: document.getElementById('public-model-name'),
+ publicModelLabel: document.getElementById('public-model-label'),
+ publicModelTier: document.getElementById('public-model-tier'),
+ 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'),
+ // Legacy elements
availableModels: document.getElementById('available-models'),
displayLabel: document.getElementById('display-label'),
modelTier: document.getElementById('model-tier'),
@@ -181,6 +198,18 @@
el.autoModelStatus.style.color = isError ? 'var(--danger)' : 'inherit';
}
+ function setPublicModelStatus(msg, isError = false) {
+ if (!el.publicModelStatus) return;
+ el.publicModelStatus.textContent = msg || '';
+ el.publicModelStatus.style.color = isError ? 'var(--danger)' : 'inherit';
+ }
+
+ function setProviderChainStatus(msg, isError = false) {
+ if (!el.providerChainStatus) return;
+ el.providerChainStatus.textContent = msg || '';
+ el.providerChainStatus.style.color = isError ? 'var(--danger)' : 'inherit';
+ }
+
function setOpencodeBackupStatus(msg, isError = false) {
if (!el.opencodeBackupStatus) return;
el.opencodeBackupStatus.textContent = msg || '';
@@ -502,6 +531,7 @@
}
function renderIcons() {
+ // Populate legacy icon select
if (el.iconSelect) {
el.iconSelect.innerHTML = '';
const none = document.createElement('option');
@@ -515,6 +545,21 @@
el.iconSelect.appendChild(opt);
});
}
+
+ // Populate new public model icon select
+ if (el.publicModelIcon) {
+ el.publicModelIcon.innerHTML = '';
+ const none = document.createElement('option');
+ none.value = '';
+ none.textContent = 'No icon';
+ el.publicModelIcon.appendChild(none);
+ state.icons.forEach((iconPath) => {
+ const opt = document.createElement('option');
+ opt.value = iconPath;
+ opt.textContent = iconPath.replace('/assets/', '');
+ el.publicModelIcon.appendChild(opt);
+ });
+ }
if (el.iconList) {
el.iconList.innerHTML = '';
@@ -543,116 +588,56 @@
}
}
+ // Simplified renderConfigured for new publicModels structure
function renderConfigured() {
if (!el.configuredList) return;
el.configuredList.innerHTML = '';
- if (el.configuredCount) el.configuredCount.textContent = state.configured.length.toString();
- if (!state.configured.length) {
+
+ // Use publicModels if available, otherwise fall back to configured
+ const modelsToRender = state.publicModels.length > 0 ? state.publicModels : state.configured;
+
+ if (el.configuredCount) el.configuredCount.textContent = modelsToRender.length.toString();
+ if (!modelsToRender.length) {
const empty = document.createElement('div');
empty.className = 'muted';
- empty.textContent = 'No models published to users yet.';
+ empty.textContent = 'No public-facing models configured yet.';
el.configuredList.appendChild(empty);
return;
}
- function normalizeProviders(model) {
- const providers = Array.isArray(model.providers) ? model.providers : [];
- if (!providers.length) return [{ provider: 'opencode', model: model.name, primary: true }];
- return providers.map((p, idx) => ({
- provider: p.provider || 'opencode',
- model: p.model || model.name,
- primary: idx === 0 ? true : !!p.primary,
- })).map((p, idx) => ({ ...p, primary: idx === 0 }));
- }
- function reorderProviders(list, from, to) {
- const next = [...list];
- const [item] = next.splice(from, 1);
- next.splice(to, 0, item);
- return next.map((p, idx) => ({ ...p, primary: idx === 0 }));
- }
-
- async function persistProviderChanges(model, nextProviders, nextIcon, nextSupportsMedia, nextTier) {
- setStatus('Saving provider order...');
- const currentModel = state.configured.find((m) => m.id === model.id) || model;
- const payload = {
- id: model.id,
- name: currentModel.name,
- label: currentModel.label || currentModel.name,
- icon: nextIcon !== undefined ? nextIcon : (currentModel.icon || ''),
- cli: currentModel.cli || 'opencode',
- providers: nextProviders.map((p, idx) => ({
- provider: p.provider || 'opencode',
- model: p.model || currentModel.name,
- primary: idx === 0,
- })),
- tier: nextTier !== undefined ? nextTier : (currentModel.tier || 'free'),
- supportsMedia: nextSupportsMedia !== undefined ? nextSupportsMedia : (currentModel.supportsMedia ?? false),
- };
- try {
- const data = await api('/api/admin/models', { method: 'POST', body: JSON.stringify(payload) });
- const updated = data.model || payload;
- const idx = state.configured.findIndex((cm) => cm.id === model.id);
- if (idx >= 0) state.configured[idx] = { ...state.configured[idx], ...updated };
- else state.configured.push(updated);
- renderConfigured();
- setStatus('Saved');
- setTimeout(() => setStatus(''), 1500);
- } catch (err) {
- setStatus(err.message, true);
- }
- }
-
- function formatLimitSummary(provider, modelName) {
- const cfg = state.providerLimits && state.providerLimits[provider];
- if (!cfg) return 'No limits set';
- const isPerModelScope = cfg.scope === 'model';
- const hasModelSpecificLimit = isPerModelScope && modelName && cfg.perModel && cfg.perModel[modelName];
- const target = hasModelSpecificLimit ? cfg.perModel[modelName] : cfg;
- const parts = [];
- if (target.requestsPerMinute) parts.push(`${target.requestsPerMinute}/m`);
- if (target.tokensPerMinute) parts.push(`${target.tokensPerMinute} tpm`);
- if (target.requestsPerDay) parts.push(`${target.requestsPerDay}/day`);
- if (target.tokensPerDay) parts.push(`${target.tokensPerDay} tpd`);
-
- if (!parts.length) {
- if (isPerModelScope && !hasModelSpecificLimit) {
- return 'No limit for this model';
- }
- return isPerModelScope ? 'Provider limits apply' : 'Unlimited';
- }
-
- const limitStr = parts.join(' · ');
- return hasModelSpecificLimit ? `${limitStr} (per-model)` : limitStr;
- }
-
- state.configured.forEach((m) => {
- const providers = normalizeProviders(m);
+ modelsToRender.forEach((m) => {
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';
+
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';
@@ -660,14 +645,19 @@
mediaBadge.textContent = 'Media';
info.appendChild(mediaBadge);
}
+
+ // Show chain info badge
+ const chainBadge = document.createElement('span');
+ chainBadge.className = 'pill';
+ chainBadge.style.background = 'var(--primary)';
+ chainBadge.textContent = `Uses unified chain (${state.providerChain.length} providers)`;
+ info.appendChild(chainBadge);
+
header.appendChild(info);
const headerActions = document.createElement('div');
headerActions.className = 'provider-row-actions';
- const fallbackBadge = document.createElement('div');
- fallbackBadge.className = 'pill';
- fallbackBadge.textContent = 'Auto fallback on error/rate limit';
- headerActions.appendChild(fallbackBadge);
+
const delBtn = document.createElement('button');
delBtn.className = 'ghost';
delBtn.textContent = 'Delete';
@@ -688,7 +678,6 @@
editIconBtn.className = 'ghost';
editIconBtn.textContent = 'Edit icon';
editIconBtn.addEventListener('click', () => {
- // Toggle editor
let editor = header.querySelector('.icon-editor');
if (editor) return editor.remove();
editor = document.createElement('div');
@@ -714,7 +703,7 @@
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
try {
- await persistProviderChanges(m, providers, sel.value, undefined);
+ await persistPublicModelChanges(m.id, { icon: sel.value });
} catch (err) { setStatus(err.message, true); }
saveBtn.disabled = false;
});
@@ -742,7 +731,7 @@
mediaCheckbox.addEventListener('change', async () => {
mediaCheckbox.disabled = true;
try {
- await persistProviderChanges(m, providers, undefined, mediaCheckbox.checked);
+ await persistPublicModelChanges(m.id, { supportsMedia: mediaCheckbox.checked });
} catch (err) { setStatus(err.message, true); }
mediaCheckbox.disabled = false;
});
@@ -785,7 +774,7 @@
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
try {
- await persistProviderChanges(m, providers, undefined, undefined, sel.value);
+ await persistPublicModelChanges(m.id, { tier: sel.value });
} catch (err) { setStatus(err.message, true); }
saveBtn.disabled = false;
});
@@ -803,170 +792,154 @@
header.appendChild(headerActions);
row.appendChild(header);
-
- const providerList = document.createElement('div');
- providerList.className = 'provider-pill-row';
-
- providers.forEach((p, idx) => {
- const card = document.createElement('div');
- card.className = 'provider-card compact';
- card.draggable = true;
- card.dataset.index = idx.toString();
-
- const stack = document.createElement('div');
- stack.className = 'model-chip';
-
- const order = document.createElement('span');
- order.className = 'pill';
- order.textContent = `#${idx + 1}`;
- stack.appendChild(order);
-
- const providerPill = document.createElement('span');
- providerPill.textContent = p.provider;
- stack.appendChild(providerPill);
-
- const modelPill = document.createElement('span');
- modelPill.className = 'pill';
- modelPill.textContent = p.model || m.name;
- stack.appendChild(modelPill);
-
- const limitPill = document.createElement('span');
- limitPill.className = 'pill';
- limitPill.textContent = formatLimitSummary(p.provider, p.model || m.name);
- stack.appendChild(limitPill);
-
- card.appendChild(stack);
-
- const actions = document.createElement('div');
- actions.className = 'provider-row-actions';
-
- const upBtn = document.createElement('button');
- upBtn.className = 'ghost';
- upBtn.textContent = '↑';
- upBtn.title = 'Move up';
- upBtn.disabled = idx === 0;
- upBtn.addEventListener('click', async () => {
- const next = reorderProviders(providers, idx, Math.max(0, idx - 1));
- await persistProviderChanges(m, next, undefined);
- });
- actions.appendChild(upBtn);
-
- const downBtn = document.createElement('button');
- downBtn.className = 'ghost';
- downBtn.textContent = '↓';
- downBtn.title = 'Move down';
- downBtn.disabled = idx === providers.length - 1;
- downBtn.addEventListener('click', async () => {
- const next = reorderProviders(providers, idx, Math.min(providers.length - 1, idx + 1));
- await persistProviderChanges(m, next, undefined);
- });
- actions.appendChild(downBtn);
-
- const removeBtn = document.createElement('button');
- removeBtn.className = 'ghost';
- removeBtn.textContent = 'Remove';
- removeBtn.addEventListener('click', async () => {
- if (providers.length <= 1) {
- const ok = window.confirm('Deleting the last provider will fall back to the default `opencode` provider. Continue?');
- if (!ok) return;
- }
- const next = providers.filter((_, i) => i !== idx);
- await persistProviderChanges(m, next, undefined);
- });
- actions.appendChild(removeBtn);
-
- // Drag & drop support for build page only
- if (pageType === 'build') {
- card.addEventListener('dragstart', (ev) => {
- card.classList.add('dragging');
- ev.dataTransfer.effectAllowed = 'move';
- ev.dataTransfer.setData('text/plain', JSON.stringify({ modelId: m.id, index: idx }));
- });
- card.addEventListener('dragend', () => card.classList.remove('dragging'));
- }
-
- card.appendChild(actions);
- providerList.appendChild(card);
- });
-
- row.appendChild(providerList);
-
- // Enable dropping into the provider list (build page only)
- if (pageType === 'build') {
- providerList.addEventListener('dragover', (ev) => { ev.preventDefault(); providerList.classList.add('drag-over'); });
- providerList.addEventListener('dragleave', () => providerList.classList.remove('drag-over'));
- providerList.addEventListener('drop', async (ev) => {
- ev.preventDefault();
- providerList.classList.remove('drag-over');
- const raw = ev.dataTransfer.getData('text/plain');
- let payload = null;
- try { payload = raw ? JSON.parse(raw) : null; } catch (_) { }
- if (!payload || payload.modelId !== m.id || typeof payload.index !== 'number') return;
- const cards = Array.from(providerList.querySelectorAll('.provider-card'));
- const destEl = ev.target.closest('.provider-card');
- let destIndex = cards.length - 1;
- if (destEl) destIndex = cards.indexOf(destEl);
- const next = reorderProviders(providers, payload.index, destIndex);
- await persistProviderChanges(m, next, undefined);
- });
- }
-
- const addRow = document.createElement('div');
- addRow.className = 'provider-add-row';
-
- const providerSelect = document.createElement('select');
- DEFAULT_PROVIDERS.forEach((provider) => {
- const opt = document.createElement('option');
- opt.value = provider;
- opt.textContent = provider;
- providerSelect.appendChild(opt);
- });
- providerSelect.value = providers[0]?.provider && DEFAULT_PROVIDERS.includes(providers[0].provider)
- ? providers[0].provider
- : 'openrouter';
- addRow.appendChild(providerSelect);
-
- const modelInput = document.createElement('input');
- modelInput.type = 'text';
- modelInput.placeholder = 'Model name (use discovered list)';
- modelInput.value = m.name;
- modelInput.setAttribute('list', ensureAvailableDatalist().id);
- addRow.appendChild(modelInput);
-
- // Inline icon selector shown when adding a second provider (i.e. initial add)
- const iconInlineSelect = document.createElement('select');
- iconInlineSelect.className = 'icon-select-inline';
- const noneOpt = document.createElement('option');
- noneOpt.value = '';
- noneOpt.textContent = 'No icon';
- iconInlineSelect.appendChild(noneOpt);
- (state.icons || []).forEach((iconPath) => {
- const opt = document.createElement('option');
- opt.value = iconPath;
- opt.textContent = iconPath.replace('/assets/', '');
- iconInlineSelect.appendChild(opt);
- });
- iconInlineSelect.value = m.icon || '';
- // Only show when this is the initial add (adding a second provider)
- if (providers.length <= 1) addRow.appendChild(iconInlineSelect);
-
- const addBtn = document.createElement('button');
- addBtn.className = 'ghost';
- addBtn.textContent = 'Add provider';
- addBtn.addEventListener('click', async () => {
- const providerVal = providerSelect.value.trim() || 'opencode';
- const modelVal = modelInput.value.trim() || m.name;
- const nextProviders = [...providers, { provider: providerVal, model: modelVal, primary: false }];
- const iconVal = iconInlineSelect ? iconInlineSelect.value : undefined;
- await persistProviderChanges(m, nextProviders, iconVal, undefined);
- });
- addRow.appendChild(addBtn);
- row.appendChild(addRow);
-
el.configuredList.appendChild(row);
});
}
+ async function persistPublicModelChanges(modelId, changes) {
+ setStatus('Saving...');
+ const model = state.publicModels.find((m) => m.id === modelId);
+ if (!model) {
+ setStatus('Model not found', true);
+ return;
+ }
+
+ const payload = {
+ type: 'publicModel',
+ 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.publicModels.findIndex((m) => m.id === modelId);
+ if (idx >= 0) state.publicModels[idx] = { ...state.publicModels[idx], ...data.publicModel };
+ renderConfigured();
+ setStatus('Saved');
+ setTimeout(() => setStatus(''), 1500);
+ } catch (err) {
+ setStatus(err.message, true);
+ }
+ }
+
+ // Render the unified provider chain with up/down controls
+ function renderProviderChain() {
+ if (!el.providerChainList) return;
+ el.providerChainList.innerHTML = '';
+ if (el.providerChainCount) el.providerChainCount.textContent = state.providerChain.length.toString();
+
+ if (!state.providerChain.length) {
+ const empty = document.createElement('div');
+ empty.className = 'muted';
+ empty.textContent = 'No provider chain configured. Add providers to enable automatic fallback.';
+ el.providerChainList.appendChild(empty);
+ return;
+ }
+
+ state.providerChain.forEach((entry, 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';
+
+ // Priority badge
+ const order = document.createElement('span');
+ order.className = 'pill';
+ order.textContent = idx === 0 ? 'Primary' : `#${idx + 1}`;
+ if (idx === 0) order.style.background = 'var(--shopify-green)';
+ info.appendChild(order);
+
+ // Provider badge
+ const providerPill = document.createElement('span');
+ providerPill.className = 'pill';
+ providerPill.textContent = entry.provider;
+ providerPill.style.background = 'var(--primary)';
+ info.appendChild(providerPill);
+
+ // Model name
+ const modelPill = document.createElement('span');
+ modelPill.textContent = entry.model;
+ info.appendChild(modelPill);
+
+ // Limit summary
+ const limitPill = document.createElement('span');
+ limitPill.className = 'pill';
+ limitPill.textContent = formatLimitSummary(entry.provider, entry.model);
+ info.appendChild(limitPill);
+
+ header.appendChild(info);
+
+ // Actions
+ const actions = document.createElement('div');
+ actions.className = 'provider-row-actions';
+
+ const upBtn = document.createElement('button');
+ upBtn.className = 'ghost';
+ upBtn.textContent = '↑';
+ upBtn.title = 'Move up';
+ upBtn.disabled = idx === 0;
+ upBtn.addEventListener('click', async () => {
+ const next = [...state.providerChain];
+ const [item] = next.splice(idx, 1);
+ next.splice(Math.max(0, idx - 1), 0, item);
+ await persistProviderChainOrder(next);
+ });
+ actions.appendChild(upBtn);
+
+ const downBtn = document.createElement('button');
+ downBtn.className = 'ghost';
+ downBtn.textContent = '↓';
+ downBtn.title = 'Move down';
+ downBtn.disabled = idx === state.providerChain.length - 1;
+ downBtn.addEventListener('click', async () => {
+ const next = [...state.providerChain];
+ const [item] = next.splice(idx, 1);
+ next.splice(Math.min(state.providerChain.length, idx + 1), 0, item);
+ await persistProviderChainOrder(next);
+ });
+ actions.appendChild(downBtn);
+
+ const removeBtn = document.createElement('button');
+ removeBtn.className = 'ghost';
+ removeBtn.textContent = 'Remove';
+ removeBtn.addEventListener('click', async () => {
+ if (state.providerChain.length <= 1) {
+ alert('Cannot remove the last provider. Add another provider first.');
+ return;
+ }
+ const next = state.providerChain.filter((_, i) => i !== idx);
+ await persistProviderChainOrder(next);
+ });
+ actions.appendChild(removeBtn);
+
+ header.appendChild(actions);
+ row.appendChild(header);
+ el.providerChainList.appendChild(row);
+ });
+ }
+
+ async function persistProviderChainOrder(nextChain) {
+ setProviderChainStatus('Saving order...');
+ try {
+ const payload = { type: 'providerChain', chain: nextChain };
+ const res = await api('/api/admin/models', { method: 'POST', body: JSON.stringify(payload) });
+ state.providerChain = res.providerChain || nextChain;
+ renderProviderChain();
+ setProviderChainStatus('Saved');
+ setTimeout(() => setProviderChainStatus(''), 1500);
+ } catch (err) {
+ setProviderChainStatus(err.message, true);
+ }
+ }
+
function normalizePlanChainLocal(chain) {
if (!Array.isArray(chain)) return [];
const seen = new Set();
@@ -1300,8 +1273,12 @@
async function loadConfigured() {
const data = await api('/api/admin/models');
- state.configured = data.models || [];
+ // Handle new unified structure
+ state.publicModels = data.publicModels || [];
+ state.providerChain = data.providerChain || [];
+ state.configured = data.models || []; // Legacy support
renderConfigured();
+ renderProviderChain();
const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string'
? state.planSettings.freePlanModel
: (el.autoModelSelect ? el.autoModelSelect.value : '');
@@ -1852,17 +1829,57 @@
async function setTokens(acct) {
const tokenUsage = acct.tokenUsage || {};
const currentLimit = tokenUsage.limit || 0;
+ const currentUsed = tokenUsage.used || 0;
+ const currentRemaining = tokenUsage.remaining || 0;
const hasOverride = tokenUsage.tokenOverride !== null && tokenUsage.tokenOverride !== undefined;
const currentOverride = hasOverride ? tokenUsage.tokenOverride : '';
- const promptMessage = `Set token limit for ${acct.email}\n\n` +
- `Current plan: ${acct.plan || 'starter'}\n` +
- `Current limit: ${currentLimit.toLocaleString()} tokens\n` +
- `${hasOverride ? `Current override: ${currentOverride.toLocaleString()} tokens\n` : ''}\n` +
- `Enter new token limit (0 to remove override, or a number to set manual limit):`;
+ const modeMessage = `Manage tokens for ${acct.email}\n\n` +
+ `Current Status:\n` +
+ ` Plan: ${acct.plan || 'starter'}\n` +
+ ` Limit: ${currentLimit.toLocaleString()} tokens\n` +
+ ` Used: ${currentUsed.toLocaleString()} tokens\n` +
+ ` Remaining: ${currentRemaining.toLocaleString()} tokens\n` +
+ `${hasOverride ? ` Override: ${currentOverride.toLocaleString()} tokens\n` : ''}\n` +
+ `Choose mode:\n` +
+ ` 1 = Set LIMIT (override plan limit)\n` +
+ ` 2 = Set USED (directly set tokens consumed)\n` +
+ ` 3 = Set REMAINING (set tokens left to use)`;
- const tokenInput = prompt(promptMessage, currentOverride);
+ const modeInput = prompt(modeMessage, '1');
+ if (modeInput === null) return;
+ const mode = parseInt(modeInput.trim(), 10);
+ if (![1, 2, 3].includes(mode)) {
+ alert('Invalid mode. Please enter 1, 2, or 3.');
+ return;
+ }
+
+ let promptText = '';
+ let defaultValue = '';
+ let confirmText = '';
+
+ if (mode === 1) {
+ promptText = `Set token LIMIT for ${acct.email}\n\n` +
+ `Current limit: ${currentLimit.toLocaleString()} tokens\n` +
+ `${hasOverride ? `Current override: ${currentOverride.toLocaleString()}\n` : ''}\n` +
+ `Enter new limit (0 to remove override):`;
+ defaultValue = currentOverride;
+ } else if (mode === 2) {
+ promptText = `Set tokens USED for ${acct.email}\n\n` +
+ `Current used: ${currentUsed.toLocaleString()} tokens\n` +
+ `Limit: ${currentLimit.toLocaleString()} tokens\n\n` +
+ `Enter new usage amount:`;
+ defaultValue = currentUsed;
+ } else {
+ promptText = `Set tokens REMAINING for ${acct.email}\n\n` +
+ `Current remaining: ${currentRemaining.toLocaleString()} tokens\n` +
+ `Limit: ${currentLimit.toLocaleString()} tokens\n\n` +
+ `Enter remaining tokens (will calculate usage):`;
+ defaultValue = currentRemaining;
+ }
+
+ const tokenInput = prompt(promptText, String(defaultValue));
if (tokenInput === null) return;
const tokens = parseInt(tokenInput.trim(), 10);
@@ -1871,17 +1888,26 @@
return;
}
- if (!confirm(`Are you sure you want to set ${acct.email}'s token limit to ${tokens.toLocaleString()}? This will override their plan-based limit for this month.`)) {
+ if (mode === 1) {
+ confirmText = `Set ${acct.email}'s token LIMIT to ${tokens.toLocaleString()}?`;
+ } else if (mode === 2) {
+ confirmText = `Set ${acct.email}'s tokens USED to ${tokens.toLocaleString()}?`;
+ } else {
+ confirmText = `Set ${acct.email}'s tokens REMAINING to ${tokens.toLocaleString()}?`;
+ }
+
+ if (!confirm(confirmText)) {
return;
}
- setStatus(`Updating token limit for ${acct.email}...`);
+ const modeNames = { 1: 'limit', 2: 'usage', 3: 'remaining' };
+ setStatus(`Updating ${modeNames[mode]} for ${acct.email}...`);
try {
await api('/api/admin/accounts/tokens', {
method: 'POST',
- body: JSON.stringify({ userId: acct.id, tokens: tokens })
+ body: JSON.stringify({ userId: acct.id, tokens: tokens, mode: modeNames[mode] })
});
- setStatus('Token limit updated successfully');
+ setStatus(`Token ${modeNames[mode]} updated successfully`);
await loadAccounts();
setTimeout(() => setStatus(''), 3000);
} catch (err) {
@@ -2199,6 +2225,79 @@
}
}
+ // New public model form handler
+ if (el.publicModelForm) {
+ el.publicModelForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const name = el.publicModelName.value.trim();
+ const label = el.publicModelLabel.value.trim();
+ const icon = el.publicModelIcon ? el.publicModelIcon.value : '';
+ const tier = el.publicModelTier ? el.publicModelTier.value : 'free';
+ const supportsMedia = el.publicModelMedia ? el.publicModelMedia.checked : false;
+
+ if (!name) {
+ setPublicModelStatus('Model ID is required.', true);
+ return;
+ }
+ if (!label) {
+ setPublicModelStatus('Display name is required.', true);
+ return;
+ }
+
+ setPublicModelStatus('Saving...');
+ try {
+ await api('/api/admin/models', {
+ method: 'POST',
+ body: JSON.stringify({
+ type: 'publicModel',
+ name,
+ label,
+ icon,
+ tier,
+ supportsMedia
+ }),
+ });
+ setPublicModelStatus('Saved');
+ el.publicModelName.value = '';
+ el.publicModelLabel.value = '';
+ await loadConfigured();
+ } catch (err) {
+ setPublicModelStatus(err.message, true);
+ }
+ });
+ }
+
+ // New provider chain form handler
+ if (el.providerChainForm) {
+ el.providerChainForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const provider = el.chainProvider.value;
+ const model = el.chainModel.value.trim();
+
+ if (!model) {
+ setProviderChainStatus('Model name is required.', true);
+ return;
+ }
+
+ setProviderChainStatus('Adding to chain...');
+ try {
+ const newChain = [...state.providerChain, { provider, model }];
+ await api('/api/admin/models', {
+ method: 'POST',
+ body: JSON.stringify({
+ type: 'providerChain',
+ chain: newChain
+ }),
+ });
+ setProviderChainStatus('Added to chain');
+ el.chainModel.value = '';
+ await loadConfigured();
+ } catch (err) {
+ setProviderChainStatus(err.message, true);
+ }
+ });
+ }
+
if (el.modelForm) {
el.modelForm.addEventListener('submit', async (e) => {
e.preventDefault();
diff --git a/chat/server.js b/chat/server.js
index aaf3ff5..b847308 100644
--- a/chat/server.js
+++ b/chat/server.js
@@ -1524,6 +1524,9 @@ const MODELS_DEV_CACHE_TTL = 3600000; // 1 hour
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 providerChain = []; // Unified fallback chain [{provider, model, primary}]
let openrouterSettings = {
primaryModel: OPENROUTER_MODEL_PRIMARY,
backupModel1: OPENROUTER_MODEL_BACKUP_1,
@@ -4884,13 +4887,22 @@ function resolveFallbackModel(cli = 'opencode') {
function refreshAdminModelIndex() {
const next = new Map();
- adminModels.forEach((model) => {
+ // Index public models (new structure)
+ publicModels.forEach((model) => {
if (!model) return;
const idKey = String(model.id || '').trim();
const nameKey = String(model.name || '').trim();
if (idKey) next.set(`id:${idKey}`, model);
if (nameKey) next.set(`name:${nameKey}`, model);
});
+ // Also index legacy admin models for backward compatibility
+ adminModels.forEach((model) => {
+ if (!model) return;
+ const idKey = String(model.id || '').trim();
+ const nameKey = String(model.name || '').trim();
+ if (idKey && !next.has(`id:${idKey}`)) next.set(`id:${idKey}`, model);
+ if (nameKey && !next.has(`name:${nameKey}`)) next.set(`name:${nameKey}`, model);
+ });
adminModelIndex = next;
}
@@ -5139,16 +5151,36 @@ async function ensureOpencodeConfig(session) {
[providerName]: providerCfg
};
- // Ensure adminModels is loaded
+ // Ensure models are loaded
if (!adminModels || adminModels.length === 0) {
log('adminModels empty, loading from store', { sessionId: session.id });
await loadAdminModelStore();
}
- // Find which providers are used in adminModels
+ // Find which providers are used in models
const usedProviders = new Set();
+
+ // Check new provider chain first
+ if (providerChain.length > 0) {
+ for (const p of providerChain) {
+ if (p.provider) {
+ usedProviders.add(p.provider.toLowerCase());
+ }
+ }
+ }
+
+ // Also check public models for provider prefixes in names
+ for (const model of publicModels) {
+ if (model.name && model.name.includes('/')) {
+ const providerFromName = model.name.split('/')[0].toLowerCase();
+ if (providerFromName && providerFromName !== 'opencode') {
+ usedProviders.add(providerFromName);
+ }
+ }
+ }
+
+ // Fallback to legacy adminModels
for (const model of adminModels) {
- // First, try to extract provider from model name (e.g., "chutes/model-name" -> "chutes")
if (model.name && model.name.includes('/')) {
const providerFromName = model.name.split('/')[0].toLowerCase();
if (providerFromName && providerFromName !== 'opencode') {
@@ -5158,7 +5190,6 @@ async function ensureOpencodeConfig(session) {
if (Array.isArray(model.providers)) {
for (const p of model.providers) {
- // Handle both string format ["opencode", "chutes"] and object format [{provider: "opencode"}]
if (typeof p === 'string') {
usedProviders.add(p.toLowerCase());
} else if (p && typeof p === 'object' && p.provider) {
@@ -5171,9 +5202,10 @@ async function ensureOpencodeConfig(session) {
}
}
- log('Detected providers from adminModels', {
+ log('Detected providers from models', {
usedProviders: Array.from(usedProviders),
- count: usedProviders.size
+ count: usedProviders.size,
+ source: providerChain.length > 0 ? 'providerChain' : 'legacy'
});
// Provider configurations with their base URLs
@@ -5726,34 +5758,78 @@ async function loadAdminModelStore() {
await ensureAssetsDir();
const raw = await fs.readFile(ADMIN_MODELS_FILE, 'utf8').catch(() => '[]');
const parsed = JSON.parse(raw || '[]');
- if (Array.isArray(parsed)) adminModels = parsed;
- else if (Array.isArray(parsed.models)) adminModels = parsed.models;
- else adminModels = [];
- adminModels = adminModels.map((m) => {
- const providersRaw = Array.isArray(m.providers) && m.providers.length
- ? m.providers
- : [{ provider: 'opencode', model: m.name, primary: true }];
- const providers = providersRaw.map((p, idx) => ({
- provider: normalizeProviderName(p.provider || p.name || 'opencode'),
- model: (p.model || p.name || m.name || '').trim() || m.name,
- primary: typeof p.primary === 'boolean' ? p.primary : idx === 0,
- })).filter((p) => !!p.model);
- const primaryProvider = providers.find((p) => p.primary)?.provider || providers[0]?.provider || 'opencode';
- return {
+
+ // Check if using new unified structure or old structure
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ // New unified structure
+ 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) => ({
id: m.id || randomUUID(),
name: m.name,
label: m.label || m.name,
icon: m.icon || '',
- cli: normalizeCli(m.cli || 'opencode'),
- providers,
- primaryProvider,
tier: normalizeTier(m.tier),
supportsMedia: m.supportsMedia ?? false,
- };
- }).filter((m) => !!m.name);
+ multiplier: getTierMultiplier(m.tier),
+ })).filter((m) => !!m.name);
+ // Create unified provider chain from all unique providers
+ const allProviders = new Map();
+ parsed.forEach((m) => {
+ const providers = Array.isArray(m.providers) && m.providers.length
+ ? m.providers
+ : [{ provider: 'opencode', model: m.name, primary: true }];
+ providers.forEach((p) => {
+ const key = `${p.provider}:${p.model || m.name}`;
+ if (!allProviders.has(key)) {
+ allProviders.set(key, {
+ provider: normalizeProviderName(p.provider || 'opencode'),
+ model: (p.model || m.name || '').trim() || m.name,
+ primary: allProviders.size === 0,
+ });
+ }
+ });
+ });
+ providerChain = Array.from(allProviders.values());
+ } else {
+ publicModels = [];
+ providerChain = [];
+ adminModels = [];
+ }
+
+ // Ensure all public models have required fields
+ publicModels = publicModels.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 provider chain entries have required fields
+ providerChain = providerChain.map((p, idx) => ({
+ provider: normalizeProviderName(p.provider || 'opencode'),
+ model: (p.model || '').trim(),
+ primary: idx === 0,
+ })).filter((p) => !!p.model);
+
refreshAdminModelIndex();
+ log('Loaded admin model store', {
+ publicModels: publicModels.length,
+ providerChain: providerChain.length,
+ legacyAdminModels: adminModels.length
+ });
} catch (error) {
log('Failed to load admin models, starting empty', { error: String(error) });
+ publicModels = [];
+ providerChain = [];
adminModels = [];
refreshAdminModelIndex();
}
@@ -5762,7 +5838,13 @@ async function loadAdminModelStore() {
async function persistAdminModels() {
await ensureStateFile();
await ensureAssetsDir();
- const payload = JSON.stringify(adminModels, null, 2);
+ // Save new unified structure
+ const payload = JSON.stringify({
+ publicModels,
+ providerChain,
+ adminModels, // Keep legacy for backwards compatibility
+ version: 2, // Schema version
+ }, null, 2);
await safeWriteFile(ADMIN_MODELS_FILE, payload);
refreshAdminModelIndex();
}
@@ -5955,12 +6037,20 @@ function collectProviderSeeds() {
const normalized = normalizeProviderName(p);
if (validProviders.has(normalized)) seeds.add(normalized);
});
- adminModels.forEach((m) => {
- (m.providers || []).forEach((p) => {
+ // Use new provider chain if available, otherwise fall back to legacy adminModels
+ if (providerChain.length > 0) {
+ providerChain.forEach((p) => {
const providerName = extractProviderName(p);
if (validProviders.has(providerName)) seeds.add(providerName);
});
- });
+ } else {
+ adminModels.forEach((m) => {
+ (m.providers || []).forEach((p) => {
+ const providerName = extractProviderName(p);
+ if (validProviders.has(providerName)) seeds.add(providerName);
+ });
+ });
+ }
(planSettings.planningChain || []).forEach((entry) => {
const normalized = normalizeProviderName(entry.provider);
if (validProviders.has(normalized)) seeds.add(normalized);
@@ -6004,9 +6094,14 @@ async function discoverProviderModels() {
collectProviderSeeds().forEach((p) => add(p));
- adminModels.forEach((m) => {
- (m.providers || []).forEach((p) => add(extractProviderName(p), p.model || m.name));
- });
+ // Use new provider chain if available, otherwise fall back to legacy adminModels
+ if (providerChain.length > 0) {
+ providerChain.forEach((p) => add(extractProviderName(p), p.model));
+ } else {
+ adminModels.forEach((m) => {
+ (m.providers || []).forEach((p) => add(extractProviderName(p), p.model || m.name));
+ });
+ }
(planSettings.planningChain || []).forEach((entry) => {
add(entry.provider, entry.model);
@@ -10163,37 +10258,55 @@ function resolveModelProviders(modelName) {
function buildOpencodeAttemptChain(cli, preferredModel) {
const chain = [];
const seen = new Set();
- const addProviderOptions = (modelName) => {
- const providers = resolveModelProviders(modelName);
- providers.forEach((p, idx) => {
- const key = `${p.provider}:${p.model || modelName}`;
- if (seen.has(key)) return;
- seen.add(key);
- chain.push({
- provider: p.provider,
- model: p.model || modelName,
- primary: typeof p.primary === 'boolean' ? p.primary : idx === 0,
- cli: normalizeCli(p.cli || cli || 'opencode'),
- sourceModel: modelName,
- });
+
+ // Build chain from unified providerChain with user's preferred model
+ providerChain.forEach((p, idx) => {
+ const modelToUse = idx === 0 && preferredModel ? preferredModel : p.model;
+ const key = `${p.provider}:${modelToUse}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ chain.push({
+ provider: p.provider,
+ model: modelToUse,
+ primary: idx === 0,
+ cli: normalizeCli(cli || 'opencode'),
+ sourceModel: modelToUse,
});
- };
-
- // Only add preferredModel if it's a non-empty string
- if (typeof preferredModel === 'string' && preferredModel.trim()) {
- addProviderOptions(preferredModel);
- }
- getConfiguredModels(cli).forEach((m) => {
- if (m.name && m.name !== preferredModel) addProviderOptions(m.name);
});
- addProviderOptions('default');
+
+ // If no provider chain, fall back to old behavior
+ if (chain.length === 0) {
+ const addProviderOptions = (modelName) => {
+ const providers = resolveModelProviders(modelName);
+ providers.forEach((p, idx) => {
+ const key = `${p.provider}:${p.model || modelName}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ chain.push({
+ provider: p.provider,
+ model: p.model || modelName,
+ primary: typeof p.primary === 'boolean' ? p.primary : idx === 0,
+ cli: normalizeCli(p.cli || cli || 'opencode'),
+ sourceModel: modelName,
+ });
+ });
+ };
+
+ if (typeof preferredModel === 'string' && preferredModel.trim()) {
+ addProviderOptions(preferredModel);
+ }
+ getConfiguredModels(cli).forEach((m) => {
+ if (m.name && m.name !== preferredModel) addProviderOptions(m.name);
+ });
+ addProviderOptions('default');
+ }
// Log the built chain for debugging
log('Built model attempt chain', {
cli,
preferredModel: (typeof preferredModel === 'string' && preferredModel.trim()) ? preferredModel : '(none)',
chainLength: chain.length,
- models: chain.map(c => `${c.provider}:${c.model}`).slice(0, 5) // First 5 to avoid log spam
+ models: chain.map(c => `${c.provider}:${c.model}`).slice(0, 5)
});
return chain;
@@ -11226,15 +11339,15 @@ async function processMessage(sessionId, message) {
function getConfiguredModels(cliParam = 'opencode') {
const cli = normalizeCli(cliParam || 'opencode');
- const filtered = adminModels.filter((m) => !m.cli || normalizeCli(m.cli) === cli);
- const mapped = filtered.map((m) => ({
+ // Use new publicModels array - filter by CLI if needed (though public models don't have CLI field)
+ const mapped = publicModels.map((m) => ({
id: m.id,
name: m.name,
label: m.label || m.name,
icon: m.icon || '',
- cli: m.cli || 'opencode',
- providers: Array.isArray(m.providers) ? m.providers : [],
- primaryProvider: m.primaryProvider || (Array.isArray(m.providers) && m.providers[0]?.provider) || 'opencode',
+ cli: cli, // All public models work with opencode CLI
+ providers: providerChain, // Use unified provider chain
+ primaryProvider: providerChain[0]?.provider || 'opencode',
tier: m.tier || 'free',
multiplier: getTierMultiplier(m.tier || 'free'),
supportsMedia: m.supportsMedia ?? false,
@@ -15742,19 +15855,13 @@ async function handleAdminListIcons(req, res) {
async function handleAdminModelsList(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
- const models = (adminModels || []).map((m) => ({
- id: m.id,
- name: m.name,
- label: m.label || m.name,
- icon: m.icon || '',
- cli: m.cli || 'opencode',
- providers: Array.isArray(m.providers) ? m.providers : [],
- primaryProvider: m.primaryProvider || (Array.isArray(m.providers) && m.providers[0]?.provider) || 'opencode',
- tier: m.tier || 'free',
- multiplier: getTierMultiplier(m.tier || 'free'),
- supportsMedia: m.supportsMedia ?? false,
- })).sort((a, b) => (a.label || '').localeCompare(b.label || ''));
- sendJson(res, 200, { models });
+ // Return new unified structure
+ sendJson(res, 200, {
+ publicModels: publicModels.sort((a, b) => (a.label || '').localeCompare(b.label || '')),
+ providerChain,
+ // Legacy support
+ models: getConfiguredModels('opencode'),
+ });
}
async function handleAdminModelUpsert(req, res) {
@@ -15762,39 +15869,96 @@ async function handleAdminModelUpsert(req, res) {
if (!session) return;
try {
const body = await parseJsonBody(req);
- const modelName = (body.name || body.model || '').trim();
- const label = (body.label || body.displayName || modelName).trim();
- const cli = normalizeCli(body.cli || 'opencode');
- const tier = normalizeTier(body.tier);
- if (!modelName) return sendJson(res, 400, { error: 'Model name is required' });
- const id = body.id || randomUUID();
- const existingIndex = adminModels.findIndex((m) => m.id === id);
- const existing = existingIndex >= 0 ? adminModels[existingIndex] : null;
- let icon = existing?.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' });
- }
- let providers = [];
- if (Array.isArray(body.providers)) {
- providers = body.providers.map((p, idx) => ({
- provider: normalizeProviderName(p.provider || p.name || p.id || 'opencode'),
- model: (p.model || p.name || modelName || '').trim() || modelName,
- primary: typeof p.primary === 'boolean' ? p.primary : idx === 0,
+
+ // Check if this is a public model or provider chain update
+ if (body.type === 'publicModel') {
+ // Handle public model update
+ 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 = publicModels.findIndex((m) => m.id === id);
+ let icon = publicModels[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) publicModels[existingIndex] = { ...publicModels[existingIndex], ...payload };
+ else publicModels.push(payload);
+
+ await persistAdminModels();
+ sendJson(res, 200, {
+ publicModel: payload,
+ publicModels: publicModels.sort((a, b) => (a.label || '').localeCompare(b.label || '')),
+ providerChain,
+ models: getConfiguredModels('opencode'),
+ });
+ } else if (body.type === 'providerChain') {
+ // Handle provider chain update
+ if (!Array.isArray(body.chain)) {
+ return sendJson(res, 400, { error: 'Provider chain must be an array' });
+ }
+
+ providerChain = body.chain.map((p, idx) => ({
+ provider: normalizeProviderName(p.provider || 'opencode'),
+ model: (p.model || '').trim(),
+ primary: idx === 0,
})).filter((p) => !!p.model);
- } else if (typeof body.provider === 'string') {
- const normalized = normalizeProviderName(body.provider);
- providers = [{ provider: normalized, model: modelName, primary: true }];
+
+ await persistAdminModels();
+ sendJson(res, 200, {
+ providerChain,
+ publicModels: publicModels.sort((a, b) => (a.label || '').localeCompare(b.label || '')),
+ models: getConfiguredModels('opencode'),
+ });
+ } else {
+ // Legacy support - treat as public model
+ const modelName = (body.name || body.model || '').trim();
+ if (!modelName) return sendJson(res, 400, { error: 'Model name is required' });
+ const id = body.id || randomUUID();
+ const label = (body.label || body.displayName || modelName).trim();
+ const tier = normalizeTier(body.tier);
+ let icon = '';
+ if (typeof body.icon === 'string' && body.icon.trim()) {
+ icon = await normalizeIconPath(body.icon) || '';
+ }
+ const supportsMedia = typeof body.supportsMedia === 'boolean' ? body.supportsMedia : false;
+
+ const payload = {
+ id,
+ name: modelName,
+ label: label || modelName,
+ icon,
+ tier,
+ supportsMedia,
+ multiplier: getTierMultiplier(tier),
+ };
+
+ const existingIndex = publicModels.findIndex((m) => m.id === id);
+ if (existingIndex >= 0) publicModels[existingIndex] = { ...publicModels[existingIndex], ...payload };
+ else publicModels.push(payload);
+
+ await persistAdminModels();
+ sendJson(res, 200, {
+ model: payload,
+ publicModels: publicModels.sort((a, b) => (a.label || '').localeCompare(b.label || '')),
+ providerChain,
+ models: getConfiguredModels('opencode'),
+ });
}
- if (!providers.length) providers = [{ provider: 'opencode', model: modelName, primary: true }];
- const primaryProvider = providers.find((p) => p.primary)?.provider || providers[0].provider;
- const supportsMedia = typeof body.supportsMedia === 'boolean' ? body.supportsMedia : false;
-
- const payload = { id, name: modelName, label: label || modelName, cli, icon, providers, primaryProvider, tier, multiplier: getTierMultiplier(tier), supportsMedia };
- if (existingIndex >= 0) adminModels[existingIndex] = { ...adminModels[existingIndex], ...payload };
- else adminModels.push(payload);
- await persistAdminModels();
- sendJson(res, 200, { model: payload, models: getConfiguredModels(cli) });
} catch (error) {
sendJson(res, 400, { error: error.message || 'Unable to save model' });
}
@@ -15803,11 +15967,16 @@ async function handleAdminModelUpsert(req, res) {
async function handleAdminModelDelete(req, res, id) {
const session = requireAdminAuth(req, res);
if (!session) return;
- const before = adminModels.length;
- adminModels = adminModels.filter((m) => m.id !== id);
- if (adminModels.length === before) return sendJson(res, 404, { error: 'Model not found' });
+ const before = publicModels.length;
+ publicModels = publicModels.filter((m) => m.id !== id);
+ if (publicModels.length === before) return sendJson(res, 404, { error: 'Model not found' });
await persistAdminModels();
- sendJson(res, 200, { ok: true, models: getConfiguredModels('opencode') });
+ sendJson(res, 200, {
+ ok: true,
+ publicModels: publicModels.sort((a, b) => (a.label || '').localeCompare(b.label || '')),
+ providerChain,
+ models: getConfiguredModels('opencode'),
+ });
}
async function handleAdminOpenRouterSettingsGet(req, res) {