(() => { const DEFAULT_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', 'opencode']; const PLANNING_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama']; const pageType = document?.body?.dataset?.page || 'build'; console.log('Admin JS loaded, pageType:', pageType); const state = { available: [], configured: [], icons: [], accounts: [], affiliates: [], withdrawals: [], planSettings: { provider: 'openrouter', freePlanModel: '', planningChain: [] }, providerLimits: {}, providerUsage: [], opencodeBackupModel: '', providerOptions: [], providerModels: {}, tokenRates: {}, }; const el = { availableModels: document.getElementById('available-models'), displayLabel: document.getElementById('display-label'), modelTier: document.getElementById('model-tier'), iconSelect: document.getElementById('icon-select'), iconList: document.getElementById('icon-list'), modelForm: document.getElementById('model-form'), status: document.getElementById('admin-status'), configuredList: document.getElementById('configured-list'), configuredCount: document.getElementById('configured-count'), logout: document.getElementById('admin-logout'), refresh: document.getElementById('admin-refresh'), reloadAvailable: document.getElementById('reload-available'), // legacy planning fields (build page no longer renders these) orForm: document.getElementById('openrouter-form'), orPrimary: document.getElementById('or-primary'), orBackup1: document.getElementById('or-backup1'), orBackup2: document.getElementById('or-backup2'), orBackup3: document.getElementById('or-backup3'), orStatus: document.getElementById('or-status'), autoModelForm: document.getElementById('auto-model-form'), autoModelSelect: document.getElementById('auto-model-select'), autoModelStatus: document.getElementById('auto-model-status'), planProviderForm: document.getElementById('plan-provider-form'), planProvider: document.getElementById('plan-provider'), freePlanModel: document.getElementById('free-plan-model'), planProviderStatus: document.getElementById('plan-provider-status'), planPriorityList: document.getElementById('plan-priority-list'), addPlanRow: document.getElementById('add-plan-row'), planChainStatus: document.getElementById('plan-chain-status'), mistralForm: document.getElementById('mistral-form'), mistralPrimary: document.getElementById('mistral-primary'), mistralBackup1: document.getElementById('mistral-backup1'), mistralBackup2: document.getElementById('mistral-backup2'), mistralBackup3: document.getElementById('mistral-backup3'), mistralStatus: document.getElementById('mistral-status'), accountsTable: document.getElementById('accounts-table'), accountsCount: document.getElementById('accounts-count'), affiliatesTable: document.getElementById('affiliates-table'), affiliatesCount: document.getElementById('affiliates-count'), withdrawalsTable: document.getElementById('withdrawals-table'), withdrawalsCount: document.getElementById('withdrawals-count'), providerOrder: document.getElementById('provider-order'), providerLimitForm: document.getElementById('provider-limit-form'), limitProvider: document.getElementById('limit-provider'), limitScope: document.getElementById('limit-scope'), limitModel: document.getElementById('limit-model'), limitModelInput: document.getElementById('limit-model-input'), limitTpm: document.getElementById('limit-tpm'), limitTph: document.getElementById('limit-tph'), limitTpd: document.getElementById('limit-tpd'), limitRpm: document.getElementById('limit-rpm'), limitRph: document.getElementById('limit-rph'), limitRpd: document.getElementById('limit-rpd'), limitBackup: document.getElementById('limit-backup'), providerLimitStatus: document.getElementById('provider-limit-status'), providerUsage: document.getElementById('provider-usage'), availableModelDatalist: document.getElementById('available-model-datalist'), supportsMedia: document.getElementById('supports-media'), // Plan tokens UI planTokensTable: document.getElementById('plan-tokens-table'), savePlanTokens: document.getElementById('save-plan-tokens'), planTokensStatus: document.getElementById('plan-tokens-status'), tokenRateUsd: document.getElementById('token-rate-usd'), tokenRateGbp: document.getElementById('token-rate-gbp'), tokenRateEur: document.getElementById('token-rate-eur'), saveTokenRates: document.getElementById('save-token-rates'), tokenRatesStatus: document.getElementById('token-rates-status'), // Cancel messages UI cancelAllMessages: document.getElementById('cancel-all-messages'), cancelMessagesStatus: document.getElementById('cancel-messages-status'), opencodeBackupForm: document.getElementById('opencode-backup-form'), opencodeBackup: document.getElementById('opencode-backup'), opencodeBackupStatus: document.getElementById('opencode-backup-status'), externalTestingRun: document.getElementById('external-testing-run'), externalTestingStatus: document.getElementById('external-testing-status'), externalTestingOutput: document.getElementById('external-testing-output'), externalTestingConfig: document.getElementById('external-testing-config'), ollamaTestRun: document.getElementById('ollama-test-run'), ollamaTestStatus: document.getElementById('ollama-test-status'), ollamaTestOutput: document.getElementById('ollama-test-output'), }; console.log('Element check - opencodeBackupForm:', el.opencodeBackupForm); console.log('Element check - opencodeBackup:', el.opencodeBackup); console.log('Element check - opencodeBackupStatus:', el.opencodeBackupStatus); function ensureAvailableDatalist() { if (el.availableModelDatalist) return el.availableModelDatalist; const dl = document.createElement('datalist'); dl.id = 'available-model-datalist'; document.body.appendChild(dl); el.availableModelDatalist = dl; return dl; } function getAvailableModelNames() { const names = new Set(); (state.available || []).forEach((m) => { const name = m.name || m.id || m; if (name) names.add(name); }); (state.configured || []).forEach((m) => { if (m.name) names.add(m.name); }); // include provider-specific models discovered by provider limits endpoint Object.values(state.providerModels || {}).forEach((arr) => { (arr || []).forEach((name) => { if (name) names.add(name); }); }); return Array.from(names); } function syncAvailableModelDatalist() { const dl = ensureAvailableDatalist(); if (!dl) return; dl.innerHTML = ''; getAvailableModelNames().forEach((name) => { const opt = document.createElement('option'); opt.value = name; dl.appendChild(opt); }); } function setStatus(msg, isError = false) { if (!el.status) return; el.status.textContent = msg || ''; el.status.style.color = isError ? 'var(--danger)' : 'inherit'; } function setOrStatus(msg, isError = false) { if (!el.orStatus) return; el.orStatus.textContent = msg || ''; el.orStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function setMistralStatus(msg, isError = false) { if (!el.mistralStatus) return; el.mistralStatus.textContent = msg || ''; el.mistralStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function setPlanProviderStatus(msg, isError = false) { if (!el.planProviderStatus) return; el.planProviderStatus.textContent = msg || ''; el.planProviderStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function setPlanChainStatus(msg, isError = false) { if (!el.planChainStatus) return; el.planChainStatus.textContent = msg || ''; el.planChainStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function setProviderLimitStatus(msg, isError = false) { if (!el.providerLimitStatus) return; el.providerLimitStatus.textContent = msg || ''; el.providerLimitStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function setAutoModelStatus(msg, isError = false) { if (!el.autoModelStatus) return; el.autoModelStatus.textContent = msg || ''; el.autoModelStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function setOpencodeBackupStatus(msg, isError = false) { if (!el.opencodeBackupStatus) return; el.opencodeBackupStatus.textContent = msg || ''; el.opencodeBackupStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function setExternalTestingStatus(msg, isError = false) { if (!el.externalTestingStatus) return; el.externalTestingStatus.textContent = msg || ''; el.externalTestingStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function renderExternalTestingConfig(config) { if (!el.externalTestingConfig) return; el.externalTestingConfig.innerHTML = ''; if (!config) return; const rows = [ ['WP host', config.wpHost || '—'], ['WP path', config.wpPath || '—'], ['Base URL', config.wpBaseUrl || '—'], ['Multisite enabled', config.enableMultisite ? 'Yes' : 'No'], ['Subsite mode', config.subsiteMode || '—'], ['Subsite domain', config.subsiteDomain || '—'], ['Max concurrent tests', String(config.maxConcurrentTests ?? '—')], ['Auto cleanup', config.autoCleanup ? 'Yes' : 'No'], ['Cleanup delay (ms)', String(config.cleanupDelayMs ?? '—')], ['SSH key configured', config.sshKeyConfigured ? 'Yes' : 'No'], ]; rows.forEach(([label, value]) => { const row = document.createElement('div'); row.className = 'admin-row'; const labelWrap = document.createElement('div'); labelWrap.style.minWidth = '180px'; const strong = document.createElement('strong'); strong.textContent = label; labelWrap.appendChild(strong); const valueWrap = document.createElement('div'); valueWrap.textContent = value; row.appendChild(labelWrap); row.appendChild(valueWrap); el.externalTestingConfig.appendChild(row); }); } function renderExternalTestingOutput(result) { if (!el.externalTestingOutput) return; el.externalTestingOutput.innerHTML = ''; if (!result) return; const summary = document.createElement('div'); summary.className = 'admin-row'; const summaryLabel = document.createElement('div'); summaryLabel.style.minWidth = '180px'; const summaryStrong = document.createElement('strong'); summaryStrong.textContent = 'Overall result'; summaryLabel.appendChild(summaryStrong); const summaryValue = document.createElement('div'); summaryValue.textContent = result.ok ? 'Passed' : 'Failed'; summary.appendChild(summaryLabel); summary.appendChild(summaryValue); el.externalTestingOutput.appendChild(summary); const detailRows = [ ['Subsite URL', result.subsite_url || '—'], ['Duration', typeof result.duration === 'number' ? `${(result.duration / 1000).toFixed(1)}s` : '—'], ]; detailRows.forEach(([label, value]) => { const row = document.createElement('div'); row.className = 'admin-row'; const labelWrap = document.createElement('div'); labelWrap.style.minWidth = '180px'; const strong = document.createElement('strong'); strong.textContent = label; labelWrap.appendChild(strong); const valueWrap = document.createElement('div'); valueWrap.textContent = value; row.appendChild(labelWrap); row.appendChild(valueWrap); el.externalTestingOutput.appendChild(row); }); const scenarioResults = result?.test_results?.cli_tests?.results || []; if (scenarioResults.length) { scenarioResults.forEach((scenario) => { const row = document.createElement('div'); row.className = 'admin-row'; const labelWrap = document.createElement('div'); labelWrap.style.minWidth = '180px'; const strong = document.createElement('strong'); strong.textContent = scenario.name || 'Scenario'; labelWrap.appendChild(strong); const valueWrap = document.createElement('div'); valueWrap.textContent = scenario.status === 'passed' ? 'Passed' : 'Failed'; if (scenario.status !== 'passed') { valueWrap.style.color = 'var(--danger)'; } row.appendChild(labelWrap); row.appendChild(valueWrap); el.externalTestingOutput.appendChild(row); }); } const errors = Array.isArray(result.errors) ? result.errors : []; if (errors.length) { errors.forEach((err) => { const row = document.createElement('div'); row.className = 'admin-row'; const labelWrap = document.createElement('div'); labelWrap.style.minWidth = '180px'; const strong = document.createElement('strong'); strong.textContent = 'Error'; labelWrap.appendChild(strong); const valueWrap = document.createElement('div'); valueWrap.textContent = err; valueWrap.style.color = 'var(--danger)'; row.appendChild(labelWrap); row.appendChild(valueWrap); el.externalTestingOutput.appendChild(row); }); } } async function loadExternalTestingStatus() { const data = await api('/api/admin/external-testing-status'); renderExternalTestingConfig(data.config || {}); } // --- Ollama Test UI --- function setOllamaTestStatus(msg, isError = false) { if (!el.ollamaTestStatus) return; el.ollamaTestStatus.textContent = msg || ''; el.ollamaTestStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function renderOllamaTestOutput(data) { if (!el.ollamaTestOutput) return; el.ollamaTestOutput.innerHTML = ''; if (!data) return; // Config section const configSection = document.createElement('div'); configSection.style.marginBottom = '16px'; configSection.style.padding = '12px'; configSection.style.background = 'var(--surface)'; configSection.style.borderRadius = '6px'; const configTitle = document.createElement('div'); configTitle.style.fontWeight = '600'; configTitle.style.marginBottom = '8px'; configTitle.textContent = 'Configuration'; configSection.appendChild(configTitle); const configRows = [ ['URL', data.config?.url || '—'], ['Model', data.config?.model || '—'], ['API Key Configured', data.config?.apiKeyConfigured ? 'Yes' : 'No'], ['API Key Preview', data.config?.apiKeyPreview || '—'], ]; configRows.forEach(([label, value]) => { const row = document.createElement('div'); row.className = 'admin-row'; row.style.marginBottom = '4px'; const labelWrap = document.createElement('div'); labelWrap.style.minWidth = '140px'; labelWrap.style.fontSize = '12px'; labelWrap.style.color = 'var(--muted)'; labelWrap.textContent = label; const valueWrap = document.createElement('div'); valueWrap.style.fontSize = '12px'; valueWrap.textContent = value; row.appendChild(labelWrap); row.appendChild(valueWrap); configSection.appendChild(row); }); el.ollamaTestOutput.appendChild(configSection); // Result section if (data.result) { const resultSection = document.createElement('div'); resultSection.style.marginBottom = '16px'; resultSection.style.padding = '12px'; resultSection.style.background = 'rgba(0, 200, 0, 0.1)'; resultSection.style.borderRadius = '6px'; resultSection.style.border = '1px solid var(--shopify-green)'; const resultTitle = document.createElement('div'); resultTitle.style.fontWeight = '600'; resultTitle.style.marginBottom = '8px'; resultTitle.style.color = 'var(--shopify-green)'; resultTitle.textContent = `✓ Test Passed (${data.duration}ms)`; resultSection.appendChild(resultTitle); const resultRows = [ ['Response', data.result.reply || '—'], ['Model Used', data.result.model || '—'], ]; resultRows.forEach(([label, value]) => { const row = document.createElement('div'); row.className = 'admin-row'; row.style.marginBottom = '4px'; const labelWrap = document.createElement('div'); labelWrap.style.minWidth = '140px'; labelWrap.style.fontSize = '12px'; labelWrap.style.color = 'var(--muted)'; labelWrap.textContent = label; const valueWrap = document.createElement('div'); valueWrap.style.fontSize = '12px'; valueWrap.textContent = value; row.appendChild(labelWrap); row.appendChild(valueWrap); resultSection.appendChild(row); }); el.ollamaTestOutput.appendChild(resultSection); } // Error section if (data.error) { const errorSection = document.createElement('div'); errorSection.style.marginBottom = '16px'; errorSection.style.padding = '12px'; errorSection.style.background = 'rgba(255, 0, 0, 0.05)'; errorSection.style.borderRadius = '6px'; errorSection.style.border = '1px solid var(--danger)'; const errorTitle = document.createElement('div'); errorTitle.style.fontWeight = '600'; errorTitle.style.marginBottom = '8px'; errorTitle.style.color = 'var(--danger)'; errorTitle.textContent = `✗ Test Failed (${data.duration}ms)`; errorSection.appendChild(errorTitle); const errorRows = [ ['Error Message', data.error.message || '—'], ['Status Code', data.error.status || '—'], ['Detail', data.error.detail || '—'], ['Auth Error', data.error.isAuthError ? 'Yes' : 'No'], ['Model Missing', data.error.isModelMissing ? 'Yes' : 'No'], ['Error Code', data.error.code || '—'], ]; errorRows.forEach(([label, value]) => { const row = document.createElement('div'); row.className = 'admin-row'; row.style.marginBottom = '4px'; const labelWrap = document.createElement('div'); labelWrap.style.minWidth = '140px'; labelWrap.style.fontSize = '12px'; labelWrap.style.color = 'var(--muted)'; labelWrap.textContent = label; const valueWrap = document.createElement('div'); valueWrap.style.fontSize = '12px'; valueWrap.style.color = label === 'Error Message' ? 'var(--danger)' : 'inherit'; valueWrap.textContent = value; row.appendChild(labelWrap); row.appendChild(valueWrap); errorSection.appendChild(row); }); el.ollamaTestOutput.appendChild(errorSection); } } async function api(path, options = {}) { const res = await fetch(path, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options, }); const text = await res.text(); const data = text ? JSON.parse(text) : {}; if (res.status === 401) { window.location.href = '/admin/login'; throw new Error('Unauthorized'); } if (!res.ok) throw new Error(data.error || res.statusText); return data; } function parseProviderOrderInput(raw, fallbackModel) { const input = (raw || '').trim(); if (!input) return []; const parts = input.split(',').map((p) => p.trim()).filter(Boolean); return parts.map((part, idx) => { const segments = part.split(':').map((s) => s.trim()); const provider = segments[0]; const model = segments[1] || fallbackModel || provider; if (!provider) return null; return { provider, model, primary: idx === 0 }; }).filter(Boolean).map((p, idx) => ({ ...p, primary: idx === 0 })); } function renderAvailable() { if (!el.availableModels) return; el.availableModels.innerHTML = ''; if (!state.available.length) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = 'No models discovered'; opt.disabled = true; opt.selected = true; el.availableModels.appendChild(opt); return; } state.available.forEach((m) => { const opt = document.createElement('option'); opt.value = m.name || m.id || m; opt.textContent = m.label || m.name || m.id || m; el.availableModels.appendChild(opt); }); if (!el.displayLabel.value) { const first = state.available[0]; if (first) el.displayLabel.value = first.label || first.name || ''; } } function renderIcons() { if (el.iconSelect) { el.iconSelect.innerHTML = ''; const none = document.createElement('option'); none.value = ''; none.textContent = 'No icon'; el.iconSelect.appendChild(none); state.icons.forEach((iconPath) => { const opt = document.createElement('option'); opt.value = iconPath; opt.textContent = iconPath.replace('/assets/', ''); el.iconSelect.appendChild(opt); }); } if (el.iconList) { el.iconList.innerHTML = ''; if (!state.icons.length) { const div = document.createElement('div'); div.className = 'muted'; div.textContent = 'Add icons to /chat/public/assets to see them here.'; el.iconList.appendChild(div); return; } state.icons.forEach((iconPath) => { const row = document.createElement('div'); row.className = 'admin-row'; const chip = document.createElement('div'); chip.className = 'model-chip'; const img = document.createElement('img'); img.src = iconPath; img.alt = ''; chip.appendChild(img); const span = document.createElement('span'); span.textContent = iconPath.replace('/assets/', ''); chip.appendChild(span); row.appendChild(chip); el.iconList.appendChild(row); }); } } function renderConfigured() { if (!el.configuredList) return; el.configuredList.innerHTML = ''; if (el.configuredCount) el.configuredCount.textContent = state.configured.length.toString(); if (!state.configured.length) { const empty = document.createElement('div'); empty.className = 'muted'; empty.textContent = 'No models published to users 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); 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'; mediaBadge.style.background = 'var(--shopify-green)'; mediaBadge.textContent = 'Media'; info.appendChild(mediaBadge); } header.appendChild(info); const headerActions = document.createElement('div'); headerActions.className = 'provider-row-actions'; 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'; delBtn.addEventListener('click', async () => { delBtn.disabled = true; try { await api(`/api/admin/models/${m.id}`, { method: 'DELETE' }); await loadConfigured(); } catch (err) { setStatus(err.message, true); } delBtn.disabled = false; }); headerActions.appendChild(delBtn); // Inline icon editor button const editIconBtn = document.createElement('button'); editIconBtn.className = 'ghost'; editIconBtn.textContent = 'Edit icon'; editIconBtn.addEventListener('click', () => { // Toggle editor let editor = header.querySelector('.icon-editor'); if (editor) return editor.remove(); editor = document.createElement('div'); editor.className = 'icon-editor'; const sel = document.createElement('select'); const none = document.createElement('option'); none.value = ''; none.textContent = 'No icon'; sel.appendChild(none); (state.icons || []).forEach((iconPath) => { const o = document.createElement('option'); o.value = iconPath; o.textContent = iconPath.replace('/assets/', ''); sel.appendChild(o); }); sel.value = m.icon || ''; editor.appendChild(sel); const saveBtn = document.createElement('button'); saveBtn.className = 'primary'; saveBtn.textContent = 'Save'; saveBtn.addEventListener('click', async () => { saveBtn.disabled = true; try { await persistProviderChanges(m, providers, sel.value, undefined); } catch (err) { setStatus(err.message, true); } saveBtn.disabled = false; }); editor.appendChild(saveBtn); const cancelBtn = document.createElement('button'); cancelBtn.className = 'ghost'; cancelBtn.textContent = 'Cancel'; cancelBtn.addEventListener('click', () => editor.remove()); editor.appendChild(cancelBtn); headerActions.appendChild(editor); }); headerActions.appendChild(editIconBtn); // Supports media checkbox const mediaToggle = document.createElement('label'); mediaToggle.style.display = 'flex'; mediaToggle.style.alignItems = 'center'; mediaToggle.style.gap = '6px'; mediaToggle.style.marginLeft = '8px'; const mediaCheckbox = document.createElement('input'); mediaCheckbox.type = 'checkbox'; mediaCheckbox.checked = m.supportsMedia ?? false; mediaCheckbox.addEventListener('change', async () => { mediaCheckbox.disabled = true; try { await persistProviderChanges(m, providers, undefined, mediaCheckbox.checked); } catch (err) { setStatus(err.message, true); } mediaCheckbox.disabled = false; }); mediaToggle.appendChild(mediaCheckbox); const mediaLabel = document.createElement('span'); mediaLabel.textContent = 'Supports image uploads'; mediaLabel.style.fontSize = '12px'; mediaLabel.style.color = 'var(--muted)'; mediaToggle.appendChild(mediaLabel); headerActions.appendChild(mediaToggle); // Tier editor button const editTierBtn = document.createElement('button'); editTierBtn.className = 'ghost'; editTierBtn.textContent = 'Edit tier/multiplier'; editTierBtn.addEventListener('click', () => { let editor = header.querySelector('.tier-editor'); if (editor) return editor.remove(); editor = document.createElement('div'); editor.className = 'tier-editor'; const sel = document.createElement('select'); const options = [ { value: 'free', label: 'Free (1x)' }, { value: 'plus', label: 'Plus (2x)' }, { value: 'pro', label: 'Pro (3x)' } ]; options.forEach((opt) => { const o = document.createElement('option'); o.value = opt.value; o.textContent = opt.label; sel.appendChild(o); }); sel.value = m.tier || 'free'; editor.appendChild(sel); const saveBtn = document.createElement('button'); saveBtn.className = 'primary'; saveBtn.textContent = 'Save'; saveBtn.addEventListener('click', async () => { saveBtn.disabled = true; try { await persistProviderChanges(m, providers, undefined, undefined, sel.value); } catch (err) { setStatus(err.message, true); } saveBtn.disabled = false; }); editor.appendChild(saveBtn); const cancelBtn = document.createElement('button'); cancelBtn.className = 'ghost'; cancelBtn.textContent = 'Cancel'; cancelBtn.addEventListener('click', () => editor.remove()); editor.appendChild(cancelBtn); headerActions.appendChild(editor); }); headerActions.appendChild(editTierBtn); header.appendChild(headerActions); row.appendChild(header); 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); }); } function normalizePlanChainLocal(chain) { if (!Array.isArray(chain)) return []; const seen = new Set(); const out = []; chain.forEach((entry) => { const provider = (entry?.provider || '').toString().trim().toLowerCase(); if (!PLANNING_PROVIDERS.includes(provider)) return; // `model` is the normalized string used at runtime; `raw` preserves the // exact admin input for display (e.g., "groq/compound-mini"). Prefer // `raw` for showing in inputs, but dedupe keys using the normalized model. const normalizedModel = typeof entry?.model === 'string' ? entry.model.trim() : ''; const displayModel = (typeof entry?.raw === 'string' && entry.raw.trim()) ? entry.raw.trim() : normalizedModel; const key = `${provider}::${normalizedModel || '__any__'}`; if (seen.has(key)) return; seen.add(key); out.push({ provider, model: normalizedModel, raw: displayModel }); }); return out; } function planLimitSummary(provider, modelName) { const cfg = state.providerLimits && state.providerLimits[provider]; if (!cfg) return 'Unlimited'; 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 'Unlimited'; } const limitStr = parts.join(' · '); return hasModelSpecificLimit ? `${limitStr} (per-model)` : limitStr; } async function persistPlanChain(nextChain) { if (!el.planPriorityList) return; setPlanChainStatus('Saving...'); try { const payload = { planningChain: nextChain }; const res = await api('/api/admin/plan-settings', { method: 'POST', body: JSON.stringify(payload) }); const normalized = normalizePlanChainLocal(res.settings?.planningChain || nextChain); state.planSettings = { ...state.planSettings, ...(res.settings || {}), planningChain: normalized }; renderPlanPriority(); setPlanChainStatus('Saved'); setTimeout(() => setPlanChainStatus(''), 1500); } catch (err) { setPlanChainStatus(err.message, true); } } function renderPlanPriority() { if (!el.planPriorityList) return; const chain = normalizePlanChainLocal(state.planSettings?.planningChain || []); el.planPriorityList.innerHTML = ''; if (!chain.length) { const empty = document.createElement('div'); empty.className = 'muted'; empty.textContent = 'No planning models configured. Add one to enable planning fallbacks.'; el.planPriorityList.appendChild(empty); } chain.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'; const order = document.createElement('span'); order.className = 'pill'; order.textContent = `Priority #${idx + 1}`; info.appendChild(order); const providerSelect = document.createElement('select'); PLANNING_PROVIDERS.forEach((provider) => { const opt = document.createElement('option'); opt.value = provider; opt.textContent = provider; providerSelect.appendChild(opt); }); providerSelect.value = entry.provider; providerSelect.addEventListener('change', () => { const next = normalizePlanChainLocal(chain.map((c, i) => (i === idx ? { ...c, provider: providerSelect.value } : c))); persistPlanChain(next); }); info.appendChild(providerSelect); const modelInput = document.createElement('input'); modelInput.type = 'text'; modelInput.placeholder = 'Model id (supports OpenRouter, Mistral, Google, Groq, NVIDIA)'; // Prefer showing the exact user input (`raw`) when available, otherwise show // the normalized `model` value. modelInput.value = entry.raw || entry.model; modelInput.setAttribute('list', ensureAvailableDatalist().id); modelInput.addEventListener('blur', () => { const val = modelInput.value.trim(); const next = normalizePlanChainLocal(chain.map((c, i) => (i === idx ? { ...c, model: val, raw: val } : c))); persistPlanChain(next); }); info.appendChild(modelInput); const limitPill = document.createElement('span'); limitPill.className = 'pill'; limitPill.textContent = planLimitSummary(entry.provider, entry.model); info.appendChild(limitPill); header.appendChild(info); 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', () => { const next = [...chain]; const [item] = next.splice(idx, 1); next.splice(Math.max(0, idx - 1), 0, item); persistPlanChain(next); }); actions.appendChild(upBtn); const downBtn = document.createElement('button'); downBtn.className = 'ghost'; downBtn.textContent = '↓'; downBtn.title = 'Move down'; downBtn.disabled = idx === chain.length - 1; downBtn.addEventListener('click', () => { const next = [...chain]; const [item] = next.splice(idx, 1); next.splice(Math.min(chain.length, idx + 1), 0, item); persistPlanChain(next); }); actions.appendChild(downBtn); const removeBtn = document.createElement('button'); removeBtn.className = 'ghost'; removeBtn.textContent = 'Remove'; removeBtn.addEventListener('click', () => { const next = chain.filter((_, i) => i !== idx); persistPlanChain(next); }); actions.appendChild(removeBtn); header.appendChild(actions); row.appendChild(header); el.planPriorityList.appendChild(row); }); } function renderProviderUsage() { if (!el.providerUsage) return; el.providerUsage.innerHTML = ''; if (!state.providerUsage.length) { const empty = document.createElement('div'); empty.className = 'muted'; empty.textContent = 'No usage recorded yet.'; el.providerUsage.appendChild(empty); return; } function createUsageRow(provider, label, limits, usage) { const row = document.createElement('div'); row.className = 'admin-row'; const left = document.createElement('div'); left.style.display = 'flex'; left.style.flexDirection = 'column'; left.style.minWidth = '200px'; left.innerHTML = `${provider} ${label}`; const progress = document.createElement('div'); progress.style.display = 'flex'; progress.style.flexDirection = 'column'; progress.style.gap = '6px'; progress.style.flex = '1'; const rows = [ ['Tokens (1m)', usage.tokensLastMinute || 0, limits.tokensPerMinute || 0], ['Tokens (24h)', usage.tokensLastDay || 0, limits.tokensPerDay || 0], ['Requests (1m)', usage.requestsLastMinute || 0, limits.requestsPerMinute || 0], ['Requests (24h)', usage.requestsLastDay || 0, limits.requestsPerDay || 0], ]; rows.forEach(([labelText, used, limit]) => { const wrap = document.createElement('div'); wrap.style.display = 'flex'; wrap.style.flexDirection = 'column'; const labelEl = document.createElement('div'); labelEl.style.display = 'flex'; labelEl.style.justifyContent = 'space-between'; labelEl.style.fontSize = '12px'; labelEl.innerHTML = `${labelText}${used}${limit > 0 ? ` / ${limit}` : ''}`; wrap.appendChild(labelEl); if (limit > 0) { const barOuter = document.createElement('div'); barOuter.style.background = 'var(--border)'; barOuter.style.height = '6px'; barOuter.style.borderRadius = '6px'; const barInner = document.createElement('div'); barInner.style.height = '6px'; barInner.style.borderRadius = '6px'; const pct = Math.min(100, parseFloat(((used / limit) * 100).toFixed(1))); barInner.style.width = `${pct}%`; barInner.style.background = pct > 90 ? 'var(--danger)' : 'var(--primary)'; barOuter.appendChild(barInner); wrap.appendChild(barOuter); } progress.appendChild(wrap); }); row.appendChild(left); row.appendChild(progress); return row; } state.providerUsage.forEach((entry) => { const isPerModel = entry.scope === 'model'; const perModelLimits = entry.perModelLimits || {}; const perModelUsage = (entry.usage && entry.usage.perModel) || {}; const modelNames = isPerModel ? [...new Set([...Object.keys(perModelLimits), ...Object.keys(perModelUsage)])] : []; // If per-model scope is enabled and we have models, show them individually if (isPerModel && modelNames.length > 0) { modelNames.forEach((modelName) => { const limits = perModelLimits[modelName] || {}; const usage = perModelUsage[modelName] || {}; const row = createUsageRow(entry.provider, modelName, limits, usage); el.providerUsage.appendChild(row); }); } else { // Provider-level scope or no models yet - show aggregate const limits = entry.limits || {}; const usage = entry.usage || {}; const row = createUsageRow(entry.provider, entry.scope, limits, usage); el.providerUsage.appendChild(row); } }); } function renderProviderOptions() { if (!el.limitProvider) return; const providersFromState = Array.isArray(state.providerOptions) && state.providerOptions.length ? [...state.providerOptions] : null; let providers = providersFromState || Object.keys(state.providerLimits || {}); const current = el.limitProvider.value; el.limitProvider.innerHTML = ''; if (!providers.length) providers = [...DEFAULT_PROVIDERS]; providers.forEach((provider, idx) => { const opt = document.createElement('option'); opt.value = provider; opt.textContent = provider; if (provider === current || (!current && idx === 0)) opt.selected = true; el.limitProvider.appendChild(opt); }); } function renderLimitModelOptions(provider) { // If we're on the plan page, allow free-text model entry (admins can type any model id) if (pageType === 'plan') { if (!el.limitModelInput) return; // show input and hide the select if (el.limitModel) el.limitModel.style.display = 'none'; el.limitModelInput.style.display = ''; // Populate datalist with discovered models to help typing syncAvailableModelDatalist(); // set current value from configured per-model limit if present const cfg = state.providerLimits && state.providerLimits[provider] ? state.providerLimits[provider] : {}; const modelKey = el.limitModelInput.value || ''; // Do not overwrite user's typing; keep existing value if (!el.limitModelInput.value && cfg.perModel) { // nothing to prefill unless there's exactly one per-model configured const keys = Object.keys(cfg.perModel || {}); if (keys.length === 1) el.limitModelInput.value = keys[0]; } return; } if (!el.limitModel) return; el.limitModel.style.display = ''; if (el.limitModelInput) el.limitModelInput.style.display = 'none'; const current = el.limitModel.value; const modelsFromProvider = (state.providerModels && state.providerModels[provider]) ? state.providerModels[provider] : []; const combined = new Set(modelsFromProvider); getAvailableModelNames().forEach((m) => combined.add(m)); const sorted = Array.from(combined).filter(Boolean).sort((a, b) => a.localeCompare(b)); el.limitModel.innerHTML = ''; const anyOpt = document.createElement('option'); anyOpt.value = ''; anyOpt.textContent = 'Any model'; el.limitModel.appendChild(anyOpt); sorted.forEach((model) => { const opt = document.createElement('option'); opt.value = model; opt.textContent = model; el.limitModel.appendChild(opt); }); if (current && sorted.includes(current)) el.limitModel.value = current; } async function loadAvailable() { const data = await api('/api/admin/available-models'); state.available = data.models || []; renderAvailable(); syncAvailableModelDatalist(); const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string' ? state.planSettings.freePlanModel : (el.autoModelSelect ? el.autoModelSelect.value : ''); populateAutoModelOptions(selectedAutoModel); if (el.limitProvider) renderLimitModelOptions(el.limitProvider.value || 'openrouter'); } async function loadIcons() { const data = await api('/api/admin/icons'); state.icons = data.icons || []; renderIcons(); } async function loadConfigured() { const data = await api('/api/admin/models'); state.configured = data.models || []; renderConfigured(); const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string' ? state.planSettings.freePlanModel : (el.autoModelSelect ? el.autoModelSelect.value : ''); populateAutoModelOptions(selectedAutoModel); populateFreePlanModelOptions(selectedAutoModel); syncAvailableModelDatalist(); if (el.limitProvider) renderLimitModelOptions(el.limitProvider.value || 'openrouter'); } async function loadOpenRouterSettings() { if (!el.orForm) return; try { const data = await api('/api/admin/openrouter-settings'); if (el.orPrimary) el.orPrimary.value = data.primaryModel || ''; if (el.orBackup1) el.orBackup1.value = data.backupModel1 || ''; if (el.orBackup2) el.orBackup2.value = data.backupModel2 || ''; if (el.orBackup3) el.orBackup3.value = data.backupModel3 || ''; } catch (err) { setOrStatus(err.message, true); } } async function loadMistralSettings() { if (!el.mistralForm) return; try { const data = await api('/api/admin/mistral-settings'); if (el.mistralPrimary) el.mistralPrimary.value = data.primaryModel || ''; if (el.mistralBackup1) el.mistralBackup1.value = data.backupModel1 || ''; if (el.mistralBackup2) el.mistralBackup2.value = data.backupModel2 || ''; if (el.mistralBackup3) el.mistralBackup3.value = data.backupModel3 || ''; } catch (err) { setMistralStatus(err.message, true); } } function populateLimitForm(provider, scope = 'provider') { if (!el.limitProvider) return; const selectedProvider = provider || el.limitProvider.value || 'openrouter'; const selectedScope = scope || el.limitScope?.value || 'provider'; const cfg = state.providerLimits[selectedProvider] || {}; renderLimitModelOptions(selectedProvider); // prefer the free-text input on the plan page const modelKey = (pageType === 'plan' && el.limitModelInput) ? (el.limitModelInput.value || '') : (el.limitModel ? el.limitModel.value : ''); const target = selectedScope === 'model' && modelKey && cfg.perModel && cfg.perModel[modelKey] ? cfg.perModel[modelKey] : cfg; if (el.limitProvider) el.limitProvider.value = selectedProvider; if (el.limitScope) el.limitScope.value = selectedScope; if (pageType === 'plan' && el.limitModelInput) { el.limitModelInput.value = selectedScope === 'model' ? (modelKey || '') : ''; } else if (el.limitModel) { el.limitModel.value = selectedScope === 'model' ? (modelKey || '') : ''; } if (el.limitTpm) el.limitTpm.value = target.tokensPerMinute ?? ''; if (el.limitTph) el.limitTph.value = target.tokensPerHour ?? ''; if (el.limitTpd) el.limitTpd.value = target.tokensPerDay ?? ''; if (el.limitRpm) el.limitRpm.value = target.requestsPerMinute ?? ''; if (el.limitRph) el.limitRph.value = target.requestsPerHour ?? ''; if (el.limitRpd) el.limitRpd.value = target.requestsPerDay ?? ''; if (el.limitBackup && state.opencodeBackupModel !== undefined) el.limitBackup.value = state.opencodeBackupModel || ''; } async function loadProviderLimits() { if (!el.providerUsage && !el.providerLimitForm) return; try { const data = await api('/api/admin/provider-limits'); state.providerLimits = data.limits || {}; state.providerOptions = data.providers || []; state.providerModels = data.providerModels || {}; (state.providerOptions || []).forEach((provider) => { if (provider && !state.providerLimits[provider]) state.providerLimits[provider] = {}; }); DEFAULT_PROVIDERS.forEach((p) => { if (!state.providerLimits[p]) state.providerLimits[p] = {}; }); state.providerUsage = data.usage || []; state.opencodeBackupModel = data.opencodeBackupModel || ''; renderProviderOptions(); populateLimitForm(el.limitProvider ? el.limitProvider.value : 'openrouter', el.limitScope ? el.limitScope.value : 'provider'); renderProviderUsage(); if (el.limitBackup && state.opencodeBackupModel !== undefined) el.limitBackup.value = state.opencodeBackupModel || ''; populateOpencodeBackupOptions(state.opencodeBackupModel); // refresh datalist with provider-specific models syncAvailableModelDatalist(); renderPlanPriority(); } catch (err) { setProviderLimitStatus(err.message, true); } } // --- Plan tokens UI --- async function loadPlanTokens() { if (!el.planTokensTable) return; try { const data = await api('/api/admin/plan-tokens'); state.planTokens = data.limits || {}; renderPlanTokens(); } catch (err) { if (el.planTokensStatus) el.planTokensStatus.textContent = String(err.message || err); } } function renderPlanTokens() { if (!el.planTokensTable) return; el.planTokensTable.innerHTML = ''; const plansOrder = ['hobby', 'starter', 'business', 'enterprise']; plansOrder.forEach((plan) => { const card = document.createElement('div'); card.className = 'admin-row'; const left = document.createElement('div'); left.style.display = 'flex'; left.style.flexDirection = 'column'; left.style.minWidth = '180px'; const title = document.createElement('strong'); title.textContent = plan; left.appendChild(title); left.style.marginBottom = '8px'; const input = document.createElement('input'); input.type = 'number'; input.min = '0'; input.step = '1'; input.value = (state.planTokens && typeof state.planTokens[plan] === 'number') ? String(state.planTokens[plan]) : ''; input.dataset.plan = plan; input.placeholder = 'Token limit'; input.style.width = '200px'; const wrapper = document.createElement('div'); wrapper.style.display = 'flex'; wrapper.style.flexDirection = 'column'; wrapper.style.gap = '4px'; const label = document.createElement('div'); label.textContent = 'Token Limit'; label.style.fontSize = '12px'; label.style.color = 'var(--muted)'; wrapper.appendChild(label); wrapper.appendChild(input); card.appendChild(left); card.appendChild(wrapper); el.planTokensTable.appendChild(card); }); } async function savePlanTokens() { if (!el.planTokensTable) return; if (el.savePlanTokens) el.savePlanTokens.disabled = true; if (el.planTokensStatus) el.planTokensStatus.textContent = 'Saving...'; const rows = el.planTokensTable.querySelectorAll('input[data-plan]'); const payload = {}; rows.forEach((input) => { const plan = input.dataset.plan; const num = input.value ? Number(input.value) : 0; payload[plan] = Number.isFinite(num) ? Math.max(0, Math.round(num)) : 0; }); try { const res = await api('/api/admin/plan-tokens', { method: 'POST', body: JSON.stringify({ limits: payload }) }); state.planTokens = res.limits || payload; renderPlanTokens(); if (el.planTokensStatus) el.planTokensStatus.textContent = 'Saved'; setTimeout(() => { if (el.planTokensStatus) el.planTokensStatus.textContent = ''; }, 1400); } catch (err) { if (el.planTokensStatus) el.planTokensStatus.textContent = err.message || String(err); } finally { if (el.savePlanTokens) el.savePlanTokens.disabled = false; } } // --- Token rates UI --- async function loadTokenRates() { if (!el.tokenRateUsd && !el.tokenRateGbp && !el.tokenRateEur) return; try { const data = await api('/api/admin/token-rates'); state.tokenRates = data.rates || {}; renderTokenRates(); } catch (err) { if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = String(err.message || err); } } function renderTokenRates() { if (el.tokenRateUsd) el.tokenRateUsd.value = String(state.tokenRates?.usd ?? ''); if (el.tokenRateGbp) el.tokenRateGbp.value = String(state.tokenRates?.gbp ?? ''); if (el.tokenRateEur) el.tokenRateEur.value = String(state.tokenRates?.eur ?? ''); } async function saveTokenRates() { if (!el.saveTokenRates) return; if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = 'Saving...'; el.saveTokenRates.disabled = true; const rates = { usd: Number(el.tokenRateUsd?.value || 0), gbp: Number(el.tokenRateGbp?.value || 0), eur: Number(el.tokenRateEur?.value || 0), }; Object.keys(rates).forEach((key) => { const value = rates[key]; rates[key] = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0; }); try { const res = await api('/api/admin/token-rates', { method: 'POST', body: JSON.stringify({ rates }) }); state.tokenRates = res.rates || rates; renderTokenRates(); if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = 'Saved'; setTimeout(() => { if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = ''; }, 1400); } catch (err) { if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = err.message || String(err); } finally { el.saveTokenRates.disabled = false; } } async function loadPlanProviderSettings() { if (!el.planProviderForm && !el.autoModelForm && !el.planPriorityList) return; try { const data = await api('/api/admin/plan-settings'); state.planSettings = { provider: 'openrouter', freePlanModel: '', planningChain: [], ...(data || {}), }; if (el.planProvider) el.planProvider.value = state.planSettings.provider || 'openrouter'; populateAutoModelOptions(state.planSettings.freePlanModel || ''); populateFreePlanModelOptions(state.planSettings.freePlanModel || ''); renderPlanPriority(); } catch (err) { if (el.planProviderForm) setPlanProviderStatus(err.message, true); if (el.autoModelForm) setAutoModelStatus(err.message, true); if (el.planPriorityList) setPlanChainStatus(err.message, true); } } function populateAutoModelOptions(selectedValue) { if (!el.autoModelSelect) return; const normalizeTier = (tier) => { const normalized = String(tier || 'free').trim().toLowerCase(); return ['free', 'plus', 'pro'].includes(normalized) ? normalized : 'free'; }; const configured = Array.isArray(state.configured) ? state.configured : []; const configuredByName = new Map(); configured.forEach((m) => { const name = (m && (m.name || m.id)) ? String(m.name || m.id).trim() : ''; if (name) configuredByName.set(name, m); }); const current = typeof selectedValue === 'string' ? selectedValue : el.autoModelSelect.value; el.autoModelSelect.innerHTML = ''; const auto = document.createElement('option'); auto.value = ''; auto.textContent = 'Auto (first free model)'; el.autoModelSelect.appendChild(auto); const freeModels = configured .filter((m) => normalizeTier(m.tier) === 'free') .map((m) => ({ name: (m && (m.name || m.id)) ? String(m.name || m.id).trim() : '', label: (m && (m.label || m.name || m.id)) ? String(m.label || m.name || m.id).trim() : '', })) .filter((m) => m.name); const freeGroup = document.createElement('optgroup'); freeGroup.label = 'Free-tier models'; const freeNames = new Set(); freeModels .sort((a, b) => a.label.localeCompare(b.label)) .forEach((m) => { freeNames.add(m.name); const opt = document.createElement('option'); opt.value = m.name; opt.textContent = m.label || m.name; freeGroup.appendChild(opt); }); const discoveredNames = getAvailableModelNames() .map((name) => String(name || '').trim()) .filter(Boolean) .filter((name, idx, arr) => arr.indexOf(name) === idx) .filter((name) => !freeNames.has(name)); const discoveredGroup = document.createElement('optgroup'); discoveredGroup.label = 'Other discovered models'; discoveredNames .sort((a, b) => a.localeCompare(b)) .forEach((name) => { const configuredModel = configuredByName.get(name); const tier = configuredModel ? normalizeTier(configuredModel.tier) : null; const opt = document.createElement('option'); opt.value = name; if (!configuredModel) { opt.textContent = `${name} (unpublished)`; } else { opt.textContent = `${name} (${tier.toUpperCase()})`; if (tier !== 'free') opt.disabled = true; } discoveredGroup.appendChild(opt); }); const hasFree = freeGroup.children.length > 0; const hasDiscovered = discoveredGroup.children.length > 0; if (hasFree) { el.autoModelSelect.appendChild(freeGroup); } if (hasDiscovered) { el.autoModelSelect.appendChild(discoveredGroup); } if (!hasFree && !hasDiscovered) { const note = document.createElement('option'); note.value = '__none__'; note.textContent = '(No models discovered yet)'; note.disabled = true; el.autoModelSelect.appendChild(note); } const currentName = (current || '').trim(); if (currentName && !Array.from(el.autoModelSelect.options).some((opt) => opt.value === currentName)) { const orphan = document.createElement('option'); orphan.value = currentName; orphan.textContent = `${currentName} (current selection)`; el.autoModelSelect.appendChild(orphan); } el.autoModelSelect.value = currentName; } function populateFreePlanModelOptions(selectedValue) { if (!el.freePlanModel) return; const current = selectedValue || el.freePlanModel.value; el.freePlanModel.innerHTML = ''; const auto = document.createElement('option'); auto.value = ''; auto.textContent = 'Auto (use default)'; el.freePlanModel.appendChild(auto); (state.configured || []).forEach((m) => { const opt = document.createElement('option'); opt.value = m.name || m.id || ''; opt.textContent = m.label || m.name || m.id || ''; el.freePlanModel.appendChild(opt); }); if (current !== undefined && current !== null) { el.freePlanModel.value = current; } } function populateOpencodeBackupOptions(selectedValue) { console.log('populateOpencodeBackupOptions called with:', selectedValue); if (!el.opencodeBackup) { console.log('el.opencodeBackup is null, returning early'); return; } console.log('el.opencodeBackup found, populating...'); const current = selectedValue || el.opencodeBackup.value; el.opencodeBackup.innerHTML = ''; const allModels = new Set(); (state.available || []).forEach((m) => { const name = m.name || m.id || m; if (name) allModels.add(name); }); (state.configured || []).forEach((m) => { if (m.name) allModels.add(m.name); }); Object.values(state.providerModels || {}).forEach((arr) => { (arr || []).forEach((name) => { if (name) allModels.add(name); }); }); console.log('Found models:', Array.from(allModels)); const sorted = Array.from(allModels).filter(Boolean).sort((a, b) => a.localeCompare(b)); const none = document.createElement('option'); none.value = ''; none.textContent = 'None (no backup)'; el.opencodeBackup.appendChild(none); sorted.forEach((name) => { const opt = document.createElement('option'); opt.value = name; opt.textContent = name; el.opencodeBackup.appendChild(opt); }); if (current) el.opencodeBackup.value = current; console.log('Dropdown populated with', sorted.length + 1, 'options'); } function formatDisplayDate(value) { if (!value) return '—'; const date = new Date(value); if (Number.isNaN(date.getTime())) return '—'; return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } function renderAccounts() { if (!el.accountsTable) return; el.accountsTable.innerHTML = ''; if (el.accountsCount) el.accountsCount.textContent = `${state.accounts.length} accounts`; if (!state.accounts.length) { const row = document.createElement('tr'); const cell = document.createElement('td'); cell.colSpan = 8; cell.textContent = 'No accounts found.'; cell.className = 'muted'; cell.style.padding = '12px'; row.appendChild(cell); el.accountsTable.appendChild(row); return; } state.accounts.forEach((acct) => { const row = document.createElement('tr'); row.style.borderBottom = '1px solid var(--border)'; [ acct.email || 'Unknown', acct.plan || 'starter', acct.billingStatus || 'active', acct.billingEmail || '—', formatDisplayDate(acct.subscriptionRenewsAt), formatDisplayDate(acct.createdAt), formatDisplayDate(acct.lastLoginAt), ].forEach((value) => { const cell = document.createElement('td'); cell.style.padding = '10px 8px'; cell.textContent = value; row.appendChild(cell); }); const actionsCell = document.createElement('td'); actionsCell.style.padding = '10px 8px'; actionsCell.style.display = 'flex'; actionsCell.style.gap = '8px'; const changeBtn = document.createElement('button'); changeBtn.className = 'ghost'; changeBtn.textContent = 'Change Plan'; changeBtn.title = 'Change user plan without payment'; changeBtn.addEventListener('click', () => changePlan(acct)); actionsCell.appendChild(changeBtn); const deleteBtn = document.createElement('button'); deleteBtn.className = 'danger'; deleteBtn.textContent = 'Delete'; deleteBtn.title = 'Permanently delete user and all data'; deleteBtn.addEventListener('click', () => deleteUser(acct)); actionsCell.appendChild(deleteBtn); row.appendChild(actionsCell); el.accountsTable.appendChild(row); }); } async function changePlan(acct) { const plans = ['hobby', 'starter', 'business', 'enterprise']; const currentPlan = acct.plan || 'hobby'; const nextPlan = prompt(`Change plan for ${acct.email}\nCurrent plan: ${currentPlan}\n\nEnter new plan (hobby, starter, business, enterprise):`, currentPlan); if (nextPlan === null) return; const normalized = nextPlan.trim().toLowerCase(); if (!plans.includes(normalized)) { alert('Invalid plan. Please enter hobby, starter, business, or enterprise.'); return; } if (normalized === currentPlan) return; if (!confirm(`Are you sure you want to change ${acct.email}'s plan to ${normalized.toUpperCase()}? This will take effect immediately without charging them.`)) { return; } setStatus(`Updating plan for ${acct.email}...`); try { await api('/api/admin/accounts/plan', { method: 'POST', body: JSON.stringify({ userId: acct.id, plan: normalized }) }); setStatus('Plan updated successfully'); await loadAccounts(); setTimeout(() => setStatus(''), 3000); } catch (err) { setStatus(err.message, true); } } async function deleteUser(acct) { const confirmation = window.confirm( `Are you absolutely sure you want to permanently delete ${acct.email}?\n\n` + `This will:\n` + `• Delete the user account permanently\n` + `• Remove all their apps/sessions\n` + `• Delete all their workspace data\n` + `• This action CANNOT be undone!\n\n` + `Type DELETE to confirm:` ); if (!confirmation) return; const confirmationText = window.prompt( 'This action cannot be undone. Type DELETE to confirm permanently deleting this user:' ); if (confirmationText !== 'DELETE') { alert('Deletion cancelled. You must type DELETE to confirm.'); return; } setStatus(`Permanently deleting ${acct.email}...`); try { await api('/api/admin/accounts', { method: 'DELETE', body: JSON.stringify({ userId: acct.id }) }); setStatus('User permanently deleted'); await loadAccounts(); setTimeout(() => setStatus(''), 3000); } catch (err) { setStatus(err.message, true); } } async function loadAccounts() { if (!el.accountsTable) return; setStatus('Loading accounts...'); try { const data = await api('/api/admin/accounts'); state.accounts = data.accounts || []; renderAccounts(); setStatus(''); } catch (err) { setStatus(err.message, true); } } function renderAffiliates() { if (!el.affiliatesTable) return; el.affiliatesTable.innerHTML = ''; if (el.affiliatesCount) el.affiliatesCount.textContent = `${state.affiliates.length} affiliates`; if (!state.affiliates.length) { const row = document.createElement('tr'); const cell = document.createElement('td'); cell.colSpan = 8; cell.textContent = 'No affiliate accounts found.'; cell.className = 'muted'; cell.style.padding = '12px'; row.appendChild(cell); el.affiliatesTable.appendChild(row); return; } state.affiliates.forEach((aff) => { const row = document.createElement('tr'); row.style.borderBottom = '1px solid var(--border)'; const emailCell = document.createElement('td'); emailCell.style.padding = '10px 8px'; emailCell.textContent = aff.email || 'Unknown'; row.appendChild(emailCell); const nameCell = document.createElement('td'); nameCell.style.padding = '10px 8px'; nameCell.textContent = aff.name || '—'; row.appendChild(nameCell); const commissionCell = document.createElement('td'); commissionCell.style.padding = '10px 8px'; commissionCell.textContent = `${((aff.commissionRate ?? 0.075) * 100).toFixed(1)}%`; row.appendChild(commissionCell); const earningsCell = document.createElement('td'); earningsCell.style.padding = '10px 8px'; const earningsTotal = aff.earnings?.total || 0; earningsCell.textContent = `$${earningsTotal.toFixed(2)}`; row.appendChild(earningsCell); const linksCell = document.createElement('td'); linksCell.style.padding = '10px 8px'; const linksCount = Array.isArray(aff.trackingLinks) ? aff.trackingLinks.length : 0; linksCell.textContent = linksCount > 0 ? `${linksCount} link${linksCount > 1 ? 's' : ''}` : '—'; row.appendChild(linksCell); const createdCell = document.createElement('td'); createdCell.style.padding = '10px 8px'; createdCell.textContent = formatDisplayDate(aff.createdAt); row.appendChild(createdCell); const lastLoginCell = document.createElement('td'); lastLoginCell.style.padding = '10px 8px'; lastLoginCell.textContent = formatDisplayDate(aff.lastLoginAt); row.appendChild(lastLoginCell); const actionsCell = document.createElement('td'); actionsCell.style.padding = '10px 8px'; actionsCell.style.display = 'flex'; actionsCell.style.gap = '8px'; const viewLinksBtn = document.createElement('button'); viewLinksBtn.className = 'ghost'; viewLinksBtn.textContent = 'View Links'; viewLinksBtn.title = 'View tracking links'; viewLinksBtn.addEventListener('click', () => viewAffiliateLinks(aff)); actionsCell.appendChild(viewLinksBtn); const deleteBtn = document.createElement('button'); deleteBtn.className = 'danger'; deleteBtn.textContent = 'Delete'; deleteBtn.title = 'Permanently delete affiliate account'; deleteBtn.addEventListener('click', () => deleteAffiliate(aff)); actionsCell.appendChild(deleteBtn); row.appendChild(actionsCell); el.affiliatesTable.appendChild(row); }); } function viewAffiliateLinks(aff) { if (!aff.trackingLinks || !aff.trackingLinks.length) { alert('No tracking links for this affiliate.'); return; } const linksList = aff.trackingLinks.map(l => `${l.code} → ${l.targetPath || '/'}`).join('\n'); alert(`Tracking links for ${aff.email}:\n\n${linksList}`); } async function deleteAffiliate(aff) { const confirmation = window.confirm( `Are you absolutely sure you want to permanently delete affiliate ${aff.email}?\n\n` + `This will:\n` + `• Delete the affiliate account permanently\n` + `• All tracking links will stop working\n` + `• Commission tracking for past referrals will remain\n` + `• This action CANNOT be undone!\n\n` + `Type DELETE to confirm:` ); if (!confirmation) return; const confirmationText = window.prompt( 'This action cannot be undone. Type DELETE to confirm permanently deleting this affiliate:' ); if (confirmationText !== 'DELETE') { alert('Deletion cancelled. You must type DELETE to confirm.'); return; } setStatus(`Permanently deleting affiliate ${aff.email}...`); try { await api('/api/admin/affiliates', { method: 'DELETE', body: JSON.stringify({ affiliateId: aff.id }) }); setStatus('Affiliate permanently deleted'); await loadAffiliates(); setTimeout(() => setStatus(''), 3000); } catch (err) { setStatus(err.message, true); } } async function loadAffiliates() { if (!el.affiliatesTable) return; setStatus('Loading affiliates...'); try { const data = await api('/api/admin/affiliates'); state.affiliates = data.affiliates || []; renderAffiliates(); setStatus(''); } catch (err) { setStatus(err.message, true); } } async function loadWithdrawals() { if (!el.withdrawalsTable) return; setStatus('Loading withdrawals...'); try { const data = await api('/api/admin/withdrawals'); state.withdrawals = data.withdrawals || []; renderWithdrawals(); setStatus(''); } catch (err) { setStatus(err.message, true); } } function renderWithdrawals() { if (!el.withdrawalsTable) return; el.withdrawalsTable.innerHTML = ''; if (el.withdrawalsCount) el.withdrawalsCount.textContent = `${state.withdrawals.length} requests`; if (!state.withdrawals.length) { const row = document.createElement('tr'); const cell = document.createElement('td'); cell.colSpan = 7; cell.textContent = 'No withdrawal requests found.'; cell.className = 'muted'; cell.style.padding = '12px'; row.appendChild(cell); el.withdrawalsTable.appendChild(row); return; } state.withdrawals.forEach((w) => { const row = document.createElement('tr'); row.style.borderBottom = '1px solid var(--border)'; const dateCell = document.createElement('td'); dateCell.style.padding = '10px 8px'; dateCell.textContent = formatDisplayDate(w.createdAt); row.appendChild(dateCell); const affiliateCell = document.createElement('td'); affiliateCell.style.padding = '10px 8px'; affiliateCell.textContent = w.affiliateEmail || 'Unknown'; row.appendChild(affiliateCell); const paypalCell = document.createElement('td'); paypalCell.style.padding = '10px 8px'; paypalCell.textContent = w.paypalEmail || '—'; row.appendChild(paypalCell); const amountCell = document.createElement('td'); amountCell.style.padding = '10px 8px'; amountCell.textContent = `$${Number(w.amount || 0).toFixed(2)}`; row.appendChild(amountCell); const currencyCell = document.createElement('td'); currencyCell.style.padding = '10px 8px'; currencyCell.textContent = w.currency || 'USD'; row.appendChild(currencyCell); const statusCell = document.createElement('td'); statusCell.style.padding = '10px 8px'; const statusBadge = document.createElement('span'); statusBadge.className = 'pill'; statusBadge.textContent = w.status || 'pending'; statusBadge.style.background = w.status === 'done' ? 'var(--success)' : 'var(--warning)'; statusCell.appendChild(statusBadge); row.appendChild(statusCell); const actionsCell = document.createElement('td'); actionsCell.style.padding = '10px 8px'; actionsCell.style.display = 'flex'; actionsCell.style.gap = '8px'; if (w.status === 'pending') { const markDoneBtn = document.createElement('button'); markDoneBtn.className = 'ghost'; markDoneBtn.textContent = 'Mark Done'; markDoneBtn.title = 'Mark withdrawal as completed'; markDoneBtn.addEventListener('click', () => updateWithdrawalStatus(w, 'done')); actionsCell.appendChild(markDoneBtn); } const detailsBtn = document.createElement('button'); detailsBtn.className = 'ghost'; detailsBtn.textContent = 'Details'; detailsBtn.title = 'View withdrawal details'; detailsBtn.addEventListener('click', () => viewWithdrawalDetails(w)); actionsCell.appendChild(detailsBtn); row.appendChild(actionsCell); el.withdrawalsTable.appendChild(row); }); } function viewWithdrawalDetails(w) { alert(`Withdrawal Details:\n\n` + `Date: ${new Date(w.createdAt).toLocaleString()}\n` + `Affiliate: ${w.affiliateEmail || 'Unknown'}\n` + `PayPal Email: ${w.paypalEmail || '—'}\n` + `Amount: $${Number(w.amount || 0).toFixed(2)}\n` + `Currency: ${w.currency || 'USD'}\n` + `Status: ${w.status || 'pending'}`); } async function updateWithdrawalStatus(withdrawal, newStatus) { setStatus('Updating withdrawal status...'); try { await api('/api/admin/withdrawals', { method: 'PUT', body: JSON.stringify({ withdrawalId: withdrawal.id, status: newStatus }) }); setStatus(`Withdrawal marked as ${newStatus}`); await loadWithdrawals(); setTimeout(() => setStatus(''), 3000); } catch (err) { setStatus(err.message, true); } } async function init() { console.log('init() called'); try { const loaders = [ () => ((el.availableModels || el.planPriorityList) ? loadAvailable() : null), () => ((el.iconSelect || el.iconList) ? loadIcons() : null), () => (el.configuredList ? loadConfigured() : null), () => (el.orForm ? loadOpenRouterSettings() : null), () => (el.mistralForm ? loadMistralSettings() : null), () => ((el.autoModelForm || el.planProviderForm || el.planPriorityList) ? loadPlanProviderSettings() : null), () => (el.accountsTable ? loadAccounts() : null), () => (el.affiliatesTable ? loadAffiliates() : null), () => (el.withdrawalsTable ? loadWithdrawals() : null), () => (el.planTokensTable ? loadPlanTokens() : null), () => ((el.tokenRateUsd || el.tokenRateGbp || el.tokenRateEur) ? loadTokenRates() : null), () => ((el.providerUsage || el.providerLimitForm) ? loadProviderLimits() : null), () => (el.externalTestingConfig ? loadExternalTestingStatus() : null), ]; await Promise.all(loaders.map((fn) => fn()).filter(Boolean)); // Always try to load provider limits if not already loaded (needed for backup dropdown) if (!state.providerModels || Object.keys(state.providerModels).length === 0) { try { const data = await api('/api/admin/provider-limits'); state.providerLimits = data.limits || {}; state.providerOptions = data.providers || []; state.providerModels = data.providerModels || {}; state.opencodeBackupModel = data.opencodeBackupModel || ''; } catch (e) { console.warn('Failed to load provider limits for backup dropdown:', e); } } // Ensure opencode backup dropdown is populated if (el.opencodeBackup) { populateOpencodeBackupOptions(state.opencodeBackupModel); } } catch (err) { setStatus(err.message, true); } } if (el.modelForm) { el.modelForm.addEventListener('submit', async (e) => { e.preventDefault(); const model = el.availableModels.value; const label = el.displayLabel.value.trim(); const icon = el.iconSelect.value; const tier = el.modelTier ? el.modelTier.value : 'free'; if (!model) { setStatus('Pick a model to add.', true); return; } if (!label) { setStatus('Add a display name.', true); return; } const providers = parseProviderOrderInput(el.providerOrder ? el.providerOrder.value : '', model); const supportsMedia = el.supportsMedia ? el.supportsMedia.checked : false; setStatus('Saving...'); try { await api('/api/admin/models', { method: 'POST', body: JSON.stringify({ model, label, icon, providers, tier, supportsMedia }), }); setStatus('Saved'); await loadConfigured(); } catch (err) { setStatus(err.message, true); } }); } if (el.orForm) { el.orForm.addEventListener('submit', async (e) => { e.preventDefault(); const primaryModel = el.orPrimary.value.trim(); const backupModel1 = el.orBackup1.value.trim(); const backupModel2 = el.orBackup2.value.trim(); const backupModel3 = el.orBackup3.value.trim(); if (!primaryModel) { setOrStatus('Primary model is required.', true); return; } setOrStatus('Saving...'); try { await api('/api/admin/openrouter-settings', { method: 'POST', body: JSON.stringify({ primaryModel, backupModel1, backupModel2, backupModel3 }), }); setOrStatus('Saved'); setTimeout(() => setOrStatus(''), 3000); } catch (err) { setOrStatus(err.message, true); } }); } if (el.mistralForm) { el.mistralForm.addEventListener('submit', async (e) => { e.preventDefault(); const primaryModel = el.mistralPrimary.value.trim(); const backupModel1 = el.mistralBackup1.value.trim(); const backupModel2 = el.mistralBackup2.value.trim(); const backupModel3 = el.mistralBackup3.value.trim(); if (!primaryModel) { setMistralStatus('Primary model is required.', true); return; } setMistralStatus('Saving...'); try { await api('/api/admin/mistral-settings', { method: 'POST', body: JSON.stringify({ primaryModel, backupModel1, backupModel2, backupModel3 }), }); setMistralStatus('Saved'); setTimeout(() => setMistralStatus(''), 3000); } catch (err) { setMistralStatus(err.message, true); } }); } if (el.opencodeBackupForm) { el.opencodeBackupForm.addEventListener('submit', async (e) => { e.preventDefault(); const opencodeBackupModel = el.opencodeBackup ? el.opencodeBackup.value.trim() : ''; setOpencodeBackupStatus('Saving...'); try { const res = await api('/api/admin/provider-limits', { method: 'POST', body: JSON.stringify({ provider: 'opencode', scope: 'provider', model: '', tokensPerMinute: '', tokensPerDay: '', requestsPerMinute: '', requestsPerDay: '', opencodeBackupModel }), }); // update local state and refresh dropdowns state.opencodeBackupModel = res.opencodeBackupModel || opencodeBackupModel || ''; populateOpencodeBackupOptions(state.opencodeBackupModel); setOpencodeBackupStatus('Saved'); setTimeout(() => setOpencodeBackupStatus(''), 3000); } catch (err) { setOpencodeBackupStatus(err.message, true); } }); } if (el.autoModelForm) { el.autoModelForm.addEventListener('submit', async (e) => { e.preventDefault(); const freePlanModel = el.autoModelSelect ? el.autoModelSelect.value.trim() : ''; setAutoModelStatus('Saving...'); try { await api('/api/admin/plan-settings', { method: 'POST', body: JSON.stringify({ freePlanModel }), }); setAutoModelStatus('Saved! Free plan users will use this model.'); setTimeout(() => setAutoModelStatus(''), 3000); } catch (err) { setAutoModelStatus(err.message, true); } }); } if (el.planProviderForm) { el.planProviderForm.addEventListener('submit', async (e) => { e.preventDefault(); const provider = el.planProvider.value.trim(); if (!provider || !PLANNING_PROVIDERS.includes(provider)) { setPlanProviderStatus('Invalid provider selected.', true); return; } setPlanProviderStatus('Saving...'); try { await api('/api/admin/plan-settings', { method: 'POST', body: JSON.stringify({ provider }), }); setPlanProviderStatus('Saved'); setTimeout(() => setPlanProviderStatus(''), 3000); } catch (err) { setPlanProviderStatus(err.message, true); } }); } if (el.addPlanRow) { el.addPlanRow.addEventListener('click', async () => { const current = normalizePlanChainLocal(state.planSettings?.planningChain || []); const next = [...current, { provider: state.planSettings?.provider || 'openrouter', model: '' }]; await persistPlanChain(next); }); } if (el.providerLimitForm) { el.providerLimitForm.addEventListener('submit', async (e) => { e.preventDefault(); const provider = el.limitProvider.value; const scope = el.limitScope.value; const payload = { provider, scope, model: (pageType === 'plan' && el.limitModelInput) ? el.limitModelInput.value.trim() : el.limitModel.value.trim(), tokensPerMinute: Number(el.limitTpm.value || 0), tokensPerHour: Number(el.limitTph.value || 0), tokensPerDay: Number(el.limitTpd.value || 0), requestsPerMinute: Number(el.limitRpm.value || 0), requestsPerHour: Number(el.limitRph.value || 0), requestsPerDay: Number(el.limitRpd.value || 0), opencodeBackupModel: el.limitBackup.value.trim(), }; setProviderLimitStatus('Saving...'); try { await api('/api/admin/provider-limits', { method: 'POST', body: JSON.stringify(payload) }); setProviderLimitStatus('Saved'); await loadProviderLimits(); setTimeout(() => setProviderLimitStatus(''), 3000); } catch (err) { setProviderLimitStatus(err.message, true); } }); } if (el.limitProvider) { el.limitProvider.addEventListener('change', () => { populateLimitForm(el.limitProvider.value, el.limitScope ? el.limitScope.value : 'provider'); }); } if (el.limitScope) { el.limitScope.addEventListener('change', () => { populateLimitForm(el.limitProvider ? el.limitProvider.value : 'openrouter', el.limitScope.value); }); } if (el.limitModel) { el.limitModel.addEventListener('change', () => { populateLimitForm(el.limitProvider ? el.limitProvider.value : 'openrouter', el.limitScope ? el.limitScope.value : 'provider'); }); } if (el.availableModels) { el.availableModels.addEventListener('change', () => { const selected = state.available.find((m) => (m.name || m.id || m) === el.availableModels.value); if (selected && !el.displayLabel.value) el.displayLabel.value = selected.label || selected.name || ''; }); } if (el.reloadAvailable) { el.reloadAvailable.addEventListener('click', async () => { setStatus('Refreshing available models...'); await loadAvailable(); setStatus(''); }); } if (el.refresh) { el.refresh.addEventListener('click', async () => { setStatus('Refreshing...'); await init(); setStatus(''); }); } // Plan tokens save button if (el.savePlanTokens) { el.savePlanTokens.addEventListener('click', async () => { await savePlanTokens(); }); } // Token rates save button if (el.saveTokenRates) { el.saveTokenRates.addEventListener('click', async () => { await saveTokenRates(); }); } // Cancel all messages button if (el.cancelAllMessages) { el.cancelAllMessages.addEventListener('click', async () => { const confirmed = window.confirm('Are you sure you want to cancel ALL running and queued messages? This action cannot be undone.'); if (!confirmed) return; el.cancelAllMessages.disabled = true; if (el.cancelMessagesStatus) el.cancelMessagesStatus.textContent = 'Cancelling...'; try { const data = await api('/api/admin/cancel-messages', { method: 'POST' }); if (el.cancelMessagesStatus) { el.cancelMessagesStatus.textContent = `Cancelled ${data.totalCancelled} messages (${data.runningCancelled} running, ${data.queuedCancelled} queued) across ${data.sessionsAffected} sessions`; el.cancelMessagesStatus.style.color = 'var(--accent)'; } setTimeout(() => { if (el.cancelMessagesStatus) { el.cancelMessagesStatus.textContent = ''; el.cancelMessagesStatus.style.color = 'inherit'; } }, 5000); } catch (err) { if (el.cancelMessagesStatus) { el.cancelMessagesStatus.textContent = err.message || 'Failed to cancel messages'; el.cancelMessagesStatus.style.color = 'var(--danger)'; } } finally { el.cancelAllMessages.disabled = false; } }); } if (el.externalTestingRun) { el.externalTestingRun.addEventListener('click', async () => { el.externalTestingRun.disabled = true; setExternalTestingStatus('Running self-test...'); try { const data = await api('/api/admin/external-testing-self-test', { method: 'POST' }); renderExternalTestingOutput(data.result || null); setExternalTestingStatus(data.result && data.result.ok ? 'Self-test passed.' : 'Self-test failed.', !data.result || !data.result.ok); } catch (err) { setExternalTestingStatus(err.message || 'Self-test failed.', true); } finally { el.externalTestingRun.disabled = false; } }); } // Ollama Test button handler if (el.ollamaTestRun) { el.ollamaTestRun.addEventListener('click', async () => { el.ollamaTestRun.disabled = true; setOllamaTestStatus('Running Ollama test...'); if (el.ollamaTestOutput) el.ollamaTestOutput.innerHTML = ''; try { const data = await api('/api/admin/ollama-test', { method: 'POST' }); renderOllamaTestOutput(data); if (data.ok) { setOllamaTestStatus(`Test passed! Response time: ${data.duration}ms`); } else { setOllamaTestStatus(`Test failed: ${data.error?.message || 'Unknown error'}`, true); } } catch (err) { setOllamaTestStatus(err.message || 'Test failed', true); if (el.ollamaTestOutput) { el.ollamaTestOutput.innerHTML = `