Files
shopify-ai-backup/chat/public/admin.js
southseact-3d 5d91c86f90 Add Cohere as a provider for build messages in admin panel
- Add 'cohere' to DEFAULT_PROVIDERS and PLANNING_PROVIDERS arrays in admin.js
- Add Cohere option to provider limits dropdown in admin.html
- Add 'cohere' to DEFAULT_PROVIDER_SEEDS, PLANNING_PROVIDERS, and KNOWN_USAGE_PROVIDERS in server.js

Cohere was already integrated in the opencode backend (SDK installed, provider registered, icons defined). These changes make it available in the admin panel for build message configuration.
2026-02-11 13:25:16 +00:00

2535 lines
97 KiB
JavaScript

(() => {
const DEFAULT_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', 'opencode', 'cohere'];
const PLANNING_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', 'cohere'];
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 = `<strong>${provider}</strong> <span class="pill">${label}</span>`;
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 = `<span>${labelText}</span><span>${used}${limit > 0 ? ` / ${limit}` : ''}</span>`;
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 ? 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 = `<div style="color: var(--danger); padding: 12px;">Error: ${err.message || 'Request failed'}</div>`;
}
} finally {
el.ollamaTestRun.disabled = false;
}
});
}
if (el.logout) {
el.logout.addEventListener('click', async () => {
await api('/api/admin/logout', { method: 'POST' }).catch(() => { });
window.location.href = '/admin/login';
});
}
// Mobile sidebar toggle
const menuToggle = document.getElementById('menu-toggle');
const closeSidebar = document.getElementById('close-sidebar');
const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.querySelector('.sidebar-overlay');
if (menuToggle && sidebar) {
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('active');
if (sidebarOverlay) {
sidebarOverlay.classList.toggle('active');
}
document.body.classList.toggle('sidebar-open');
});
}
if (closeSidebar && sidebar) {
closeSidebar.addEventListener('click', () => {
sidebar.classList.remove('active');
if (sidebarOverlay) {
sidebarOverlay.classList.remove('active');
}
document.body.classList.remove('sidebar-open');
});
}
// Close sidebar when clicking on overlay
if (sidebarOverlay && sidebar) {
sidebarOverlay.addEventListener('click', () => {
sidebar.classList.remove('active');
sidebarOverlay.classList.remove('active');
document.body.classList.remove('sidebar-open');
});
}
// Close sidebar when clicking outside on mobile
document.addEventListener('click', (e) => {
if (sidebar && sidebar.classList.contains('active')) {
if (!sidebar.contains(e.target) && (!menuToggle || !menuToggle.contains(e.target))) {
sidebar.classList.remove('active');
if (sidebarOverlay) {
sidebarOverlay.classList.remove('active');
}
document.body.classList.remove('sidebar-open');
}
}
});
// Highlight active link in sidebar
try {
const navLinks = document.querySelectorAll('.sidebar-section a');
navLinks.forEach((a) => {
const href = a.getAttribute('href');
const current = window.location.pathname;
const isMatch = href === current || (href === '/admin/build' && current === '/admin');
if (isMatch) {
a.classList.add('active');
a.setAttribute('aria-current', 'page');
}
});
} catch (err) { }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();