From 44deabc2cf2e5dec5a044402c3db6b7f438b22ac Mon Sep 17 00:00:00 2001 From: southseact-3d Date: Wed, 18 Feb 2026 10:37:01 +0000 Subject: [PATCH] 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 --- chat/public/admin.html | 95 ++++++- chat/public/admin.js | 607 ++++++++++++++++++++++++----------------- chat/server.js | 387 ++++++++++++++++++-------- 3 files changed, 723 insertions(+), 366 deletions(-) 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.

+
+
+ +
+ +
+
-

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) {