diff --git a/chat/public/admin.html b/chat/public/admin.html index fd29822..2fa0759 100644 --- a/chat/public/admin.html +++ b/chat/public/admin.html @@ -176,7 +176,98 @@
- + +
+
+

Auto Model for Hobby/Free Plan

+
Free Plan
+
+

Select which model Hobby and Free plan users will automatically use. Paid plan users can select their own models.

+
+ +
+ +
+
+
+
+ + +
+
+
+

Provider Limits & Usage

+
Rate limits
+
+

Configure token/request limits per provider or per model and monitor current usage.

+
+ + + + + + + + + +
+ +
+
+
+
+
+
+ +
diff --git a/chat/public/admin.js b/chat/public/admin.js index f258f93..871a5e6 100644 --- a/chat/public/admin.js +++ b/chat/public/admin.js @@ -2,12 +2,15 @@ const pageType = document?.body?.dataset?.page || 'build'; console.log('Admin JS loaded, pageType:', pageType); - // Clean state structure - just two things + // State structure const state = { opencodeModels: [], // Models from OpenCode (order determines fallback) publicModels: [], // User-facing models (completely separate) icons: [], availableOpencodeModels: [], // Loaded from OpenCode + planSettings: { provider: 'openrouter', freePlanModel: '', planningChain: [] }, + providerLimits: {}, + providerUsage: [], }; // Element references @@ -35,6 +38,25 @@ publicModelsList: document.getElementById('public-models-list'), publicModelsCount: document.getElementById('public-models-count'), + // Auto Model Form + autoModelForm: document.getElementById('auto-model-form'), + autoModelSelect: document.getElementById('auto-model-select'), + autoModelStatus: document.getElementById('auto-model-status'), + + // Provider Limits Form + providerLimitForm: document.getElementById('provider-limit-form'), + limitProvider: document.getElementById('limit-provider'), + limitScope: document.getElementById('limit-scope'), + limitModel: document.getElementById('limit-model'), + 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'), + providerLimitStatus: document.getElementById('provider-limit-status'), + providerUsage: document.getElementById('provider-usage'), + // Other iconList: document.getElementById('icon-list'), adminRefresh: document.getElementById('admin-refresh'), @@ -370,10 +392,101 @@ } // Initialize + // Load plan settings + async function loadPlanSettings() { + try { + const data = await api('/api/admin/plan-settings'); + state.planSettings = data || { provider: 'openrouter', freePlanModel: '', planningChain: [] }; + populateAutoModelSelect(); + if (el.autoModelSelect && state.planSettings.freePlanModel) { + el.autoModelSelect.value = state.planSettings.freePlanModel; + } + } catch (err) { + console.error('Failed to load plan settings:', err); + } + } + + // Populate auto model select dropdown + function populateAutoModelSelect() { + if (!el.autoModelSelect) return; + const currentValue = el.autoModelSelect.value; + el.autoModelSelect.innerHTML = ''; + + state.opencodeModels.forEach((m) => { + const opt = document.createElement('option'); + opt.value = m.name; + opt.textContent = `${m.label || m.name} (${m.name})`; + el.autoModelSelect.appendChild(opt); + }); + + el.autoModelSelect.value = currentValue; + } + + // Load provider limits + async function loadProviderLimits() { + try { + const data = await api('/api/admin/provider-limits'); + state.providerLimits = data.limits || {}; + state.providerUsage = data.usage || []; + renderProviderUsage(); + } catch (err) { + console.error('Failed to load provider limits:', err); + } + } + + // Render provider usage + function renderProviderUsage() { + if (!el.providerUsage) return; + el.providerUsage.innerHTML = ''; + + if (!state.providerUsage.length) { + el.providerUsage.innerHTML = '
No usage data available.
'; + return; + } + + state.providerUsage.forEach((usage) => { + const row = document.createElement('div'); + row.className = 'admin-row'; + row.innerHTML = ` +
+ ${usage.provider} + ${usage.tokens || 0} tokens / ${usage.requests || 0} requests +
+ `; + el.providerUsage.appendChild(row); + }); + } + + // Update limit model options based on provider selection + function updateLimitModelOptions() { + if (!el.limitModel || !el.limitProvider) return; + const provider = el.limitProvider.value; + const currentValue = el.limitModel.value; + + el.limitModel.innerHTML = ''; + + // Add models from opencodeModels that match this provider + state.opencodeModels.forEach((m) => { + if (m.name && m.name.includes('/')) { + const modelProvider = m.name.split('/')[0]; + if (modelProvider === provider) { + const opt = document.createElement('option'); + opt.value = m.name; + opt.textContent = m.label || m.name; + el.limitModel.appendChild(opt); + } + } + }); + + el.limitModel.value = currentValue; + } + async function init() { await loadIcons(); await loadAvailableOpencodeModels(); await loadModels(); + await loadPlanSettings(); + await loadProviderLimits(); } if (el.adminRefresh) { @@ -399,5 +512,91 @@ }); } + // Auto Model Form Handler + if (el.autoModelForm) { + el.autoModelForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const selectedModel = el.autoModelSelect.value; + + try { + await api('/api/admin/plan-settings', { + method: 'POST', + body: JSON.stringify({ + ...state.planSettings, + freePlanModel: selectedModel, + }), + }); + setStatus(el.autoModelStatus, 'Saved'); + setTimeout(() => setStatus(el.autoModelStatus, ''), 1500); + } catch (err) { + setStatus(el.autoModelStatus, err.message, true); + } + }); + } + + // Provider Limit Form Handler + if (el.providerLimitForm) { + // Update model options when provider changes + el.limitProvider?.addEventListener('change', () => { + updateLimitModelOptions(); + // Load existing limits for this provider if any + const provider = el.limitProvider.value; + const scope = el.limitScope.value; + const model = el.limitModel.value; + const limits = state.providerLimits[provider]; + if (limits) { + const target = scope === 'model' && model ? (limits.perModel?.[model] || {}) : limits; + 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 || ''; + } + }); + + // Update form when scope changes + el.limitScope?.addEventListener('change', () => { + if (el.limitModel) { + el.limitModel.disabled = el.limitScope.value !== 'model'; + if (el.limitScope.value !== 'model') el.limitModel.value = ''; + } + }); + + el.providerLimitForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const provider = el.limitProvider?.value; + const scope = el.limitScope?.value; + const model = el.limitModel?.value; + + const limits = { + tokensPerMinute: parseInt(el.limitTpm?.value) || 0, + tokensPerHour: parseInt(el.limitTph?.value) || 0, + tokensPerDay: parseInt(el.limitTpd?.value) || 0, + requestsPerMinute: parseInt(el.limitRpm?.value) || 0, + requestsPerHour: parseInt(el.limitRph?.value) || 0, + requestsPerDay: parseInt(el.limitRpd?.value) || 0, + }; + + try { + const payload = { + provider, + scope, + model: scope === 'model' ? model : null, + limits, + }; + await api('/api/admin/provider-limits', { + method: 'POST', + body: JSON.stringify(payload), + }); + setStatus(el.providerLimitStatus, 'Saved'); + await loadProviderLimits(); + setTimeout(() => setStatus(el.providerLimitStatus, ''), 1500); + } catch (err) { + setStatus(el.providerLimitStatus, err.message, true); + } + }); + } + init(); })(); \ No newline at end of file