Add unified model chain system with public models and provider chain

- Add publicModels and providerChain data structures for unified fallback
- Add two separate model adding sections in admin panel (public-facing and provider models)
- Add up/down buttons to reorder provider chain order
- Update server to use unified chain for all model fallbacks
- Auto-migrate legacy data on first load
- Update admin.js to handle new model structure and forms
This commit is contained in:
southseact-3d
2026-02-18 10:37:01 +00:00
parent ae7fdaac6f
commit 44deabc2cf
3 changed files with 723 additions and 366 deletions

View File

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