diff --git a/public/js/admin.js b/public/js/admin.js index d6df3e1..c32a93a 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -616,7 +616,7 @@ var badge = document.getElementById('admin-model-provider-badge'); if (badge) badge.textContent = (data.provider || 'unknown').toUpperCase(); - // Default model selector + // Default model selector — built-ins + custom models var defaultSel = document.getElementById('admin-default-model'); if (defaultSel) { defaultSel.innerHTML = ''; @@ -626,20 +626,25 @@ opt.textContent = m.name + (m.enabled === false ? ' (disabled)' : ''); defaultSel.appendChild(opt); }); - // Also add custom models (data.custom || []).forEach(function(m) { var opt = document.createElement('option'); opt.value = m.id; - opt.textContent = m.name + ' (custom)'; + opt.textContent = m.name; defaultSel.appendChild(opt); }); + // Pre-select the saved default + if (data.defaultModel) defaultSel.value = data.defaultModel; } // Built-in models with toggle var container = document.getElementById('admin-builtin-models'); if (container) { - if (data.models.length === 0) { - container.innerHTML = '

No built-in models for this provider

'; + if (data.litellmHint) { + container.innerHTML = '

' + + ' LiteLLM has no built-in model list — your available models depend on your proxy configuration. ' + + 'Use Search API below to discover what models your LiteLLM proxy exposes, then add them.

'; + } else if (data.models.length === 0) { + container.innerHTML = '

No built-in models for this provider.

'; } else { container.innerHTML = data.models.map(function(m) { var checked = m.enabled !== false ? 'checked' : ''; @@ -736,9 +741,33 @@ .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { - showToast('Added: ' + name, 'success'); + showToast('Added: ' + name + ' — now select it as default and click Set Default', 'success'); if (btn) { btn.textContent = 'Added'; btn.disabled = true; btn.style.background = 'var(--green)'; } - loadAdminModels(); // Refresh custom list + // Refresh model lists, then auto-select the newly added model + fetch('/api/admin/config/models', { headers: getAuthHeaders() }) + .then(function(r) { return r.json(); }) + .then(function(refreshed) { + if (!refreshed.success) return; + var defaultSel = document.getElementById('admin-default-model'); + if (defaultSel) { + defaultSel.innerHTML = ''; + refreshed.models.forEach(function(m) { + var opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name + (m.enabled === false ? ' (disabled)' : ''); + defaultSel.appendChild(opt); + }); + (refreshed.custom || []).forEach(function(m) { + var opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name; + defaultSel.appendChild(opt); + }); + // Auto-select the model just added + defaultSel.value = id; + } + renderCustomModels(refreshed.custom || []); + }); } else { showToast(data.error || 'Failed', 'error'); } diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 718bc0f..d4953e8 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -48,37 +48,6 @@ router.get('/config', async function(req, res) { } catch (e) { res.status(500).json({ error: e.message }); } }); -// ── PUT update a single config entry ────────────────────────────────────── -router.put('/config/:key(*)', async function(req, res) { - try { - var key = req.params.key; - var value = req.body.value; - - if (value === undefined || value === null) { - return res.status(400).json({ error: 'value is required' }); - } - - // Security: only allow known key prefixes - var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.']; - var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); }); - if (!isAllowed) { - return res.status(400).json({ error: 'Unknown config key' }); - } - - await db.setSetting(key, String(value)); - - // Update in-memory prompt immediately if it's a prompt key - if (key.startsWith('prompt.')) { - var promptKey = key.replace('prompt.', ''); - PROMPTS.updatePrompt(promptKey, String(value)); - } - - logger.audit(req.user.id, 'admin_config_update', 'Updated config: ' + key, req, { category: 'admin' }); - - res.json({ success: true }); - } catch (e) { res.status(500).json({ error: e.message }); } -}); - // ── POST send test email ─────────────────────────────────────────────────── router.post('/config/test-email', async function(req, res) { try { @@ -192,6 +161,7 @@ router.get('/config/smtp/status', async function(req, res) { }); // ── PUT update SMTP settings ───────────────────────────────────────────── +// NOTE: This must come BEFORE the wildcard PUT /config/:key(*) below router.put('/config/smtp', async function(req, res) { try { var { host, port, user, pass, from, secure } = req.body; @@ -203,7 +173,7 @@ router.put('/config/smtp', async function(req, res) { await db.setSetting('smtp.from', (from || user || '').trim()); await db.setSetting('smtp.secure', String(secure === true || secure === 'true')); if (pass && pass.trim()) { - await db.setSetting('smtp.pass', pass.trim()); // stored as-is; consider encrypting at rest + await db.setSetting('smtp.pass', pass.trim()); } logger.audit(req.user.id, 'admin_smtp_update', 'Updated SMTP settings', req, { category: 'admin' }); @@ -223,21 +193,28 @@ router.delete('/config/smtp', async function(req, res) { } catch (e) { res.status(500).json({ error: e.message }); } }); +// ============================================================ +// MODEL ROUTES — All must come BEFORE the wildcard PUT /config/:key(*) +// The wildcard uses :key(*) which matches slashes, so it would intercept +// /config/models/toggle and /config/models/default if registered after it. +// ============================================================ + // ── GET models config ──────────────────────────────────────────────────── router.get('/config/models', async function(req, res) { try { - var { OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, VERTEX_MODELS, LITELLM_MODELS, activeProvider } = require('../utils/models'); + var { OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, VERTEX_MODELS, activeProvider } = require('../utils/models'); var providerModels; switch (activeProvider) { - case 'bedrock': providerModels = BEDROCK_MODELS; break; - case 'azure': providerModels = AZURE_MODELS; break; - case 'vertex': providerModels = VERTEX_MODELS; break; - case 'litellm': providerModels = LITELLM_MODELS; break; - default: providerModels = OPENROUTER_MODELS; + case 'bedrock': providerModels = BEDROCK_MODELS; break; + case 'azure': providerModels = AZURE_MODELS; break; + case 'vertex': providerModels = VERTEX_MODELS; break; + case 'litellm': providerModels = []; break; // LiteLLM: no built-ins — use discover + default: providerModels = OPENROUTER_MODELS; } var disabledRaw = await db.getSetting('models.disabled') || '[]'; var customRaw = await db.getSetting('models.custom') || '[]'; + var defaultModel = await db.getSetting('models.default') || ''; var disabled, custom; try { disabled = JSON.parse(disabledRaw); } catch(e) { disabled = []; } try { custom = JSON.parse(customRaw); } catch(e) { custom = []; } @@ -246,11 +223,18 @@ router.get('/config/models', async function(req, res) { return Object.assign({}, m, { enabled: !disabled.includes(m.id) }); }); - res.json({ success: true, provider: activeProvider, models: models, custom: custom }); + res.json({ + success: true, + provider: activeProvider, + models: models, + custom: custom, + defaultModel: defaultModel, + litellmHint: activeProvider === 'litellm' + }); } catch (e) { res.status(500).json({ error: e.message }); } }); -// ── PUT update model enabled/disabled state ────────────────────────────── +// ── PUT toggle model enabled/disabled ──────────────────────────────────── router.put('/config/models/toggle', async function(req, res) { try { var { modelId, enabled } = req.body; @@ -273,7 +257,7 @@ router.put('/config/models/toggle', async function(req, res) { try { custom = JSON.parse(customRaw); } catch(e) { custom = []; } var enabledCount = allModels.filter(function(m) { return !disabled.includes(m.id); }).length + custom.length; if (enabledCount === 0) { - return res.status(400).json({ error: 'Cannot disable all models. At least one model must remain enabled.' }); + return res.status(400).json({ error: 'Cannot disable all models. At least one must remain enabled.' }); } } @@ -283,22 +267,32 @@ router.put('/config/models/toggle', async function(req, res) { } catch (e) { res.status(500).json({ error: e.message }); } }); -// ── POST add custom model ──────────────────────────────────────────────── +// ── PUT set default model ───────────────────────────────────────────────── +router.put('/config/models/default', async function(req, res) { + try { + var { modelId } = req.body; + if (!modelId) return res.status(400).json({ error: 'modelId required' }); + await db.setSetting('models.default', modelId.trim()); + logger.audit(req.user.id, 'admin_model_default', 'Set default model: ' + modelId, req, { category: 'admin' }); + res.json({ success: true }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +// ── POST add custom model (manual entry) ───────────────────────────────── router.post('/config/models/custom', async function(req, res) { try { var { id, name, cost, category } = req.body; if (!id || !name) return res.status(400).json({ error: 'id and name required' }); - // Validate model ID format (provider/model-name) var trimmedId = id.trim(); if (trimmedId.length > 200) return res.status(400).json({ error: 'Model ID too long (max 200 chars)' }); if (!/^[a-zA-Z0-9._\-\/\:]+$/.test(trimmedId)) { return res.status(400).json({ error: 'Invalid model ID format. Use only letters, numbers, dots, dashes, slashes, and colons.' }); } - // Check if model ID conflicts with a built-in model - var { OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS } = require('../utils/models'); - var allBuiltIn = [].concat(OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS); + // Check if model ID conflicts with a built-in model (all providers) + var { OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, VERTEX_MODELS } = require('../utils/models'); + var allBuiltIn = [].concat(OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, VERTEX_MODELS); if (allBuiltIn.find(function(m) { return m.id === trimmedId; })) { return res.status(400).json({ error: 'Model ID conflicts with a built-in model. Use the toggle to enable/disable built-in models.' }); } @@ -310,7 +304,6 @@ router.post('/config/models/custom', async function(req, res) { var custom; try { custom = JSON.parse(customRaw); } catch(e) { custom = []; } - // Prevent duplicates var existing = custom.find(function(m) { return m.id === trimmedId; }); custom = custom.filter(function(m) { return m.id !== trimmedId; }); custom.push({ id: trimmedId, name: name.trim().substring(0, 100), cost: (cost || '?').substring(0, 20), category: cat, tag: 'CUSTOM' }); @@ -321,7 +314,7 @@ router.post('/config/models/custom', async function(req, res) { } catch (e) { res.status(500).json({ error: e.message }); } }); -// ── DELETE custom model ────────────────────────────────────────────────── +// ── DELETE custom model ─────────────────────────────────────────────────── router.delete('/config/models/custom/:modelId(*)', async function(req, res) { try { var modelId = req.params.modelId; @@ -330,11 +323,12 @@ router.delete('/config/models/custom/:modelId(*)', async function(req, res) { try { custom = JSON.parse(customRaw); } catch(e) { custom = []; } custom = custom.filter(function(m) { return m.id !== modelId; }); await db.setSetting('models.custom', JSON.stringify(custom)); + logger.audit(req.user.id, 'admin_model_delete', 'Removed custom model: ' + modelId, req, { category: 'admin' }); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); -// ── GET discover models from provider API ───────────────────────────── +// ── GET discover models from provider API ───────────────────────────────── router.get('/config/models/discover', async function(req, res) { try { var { discoverModels } = require('../utils/ai'); @@ -349,7 +343,7 @@ router.get('/config/models/discover', async function(req, res) { } catch (e) { res.status(500).json({ error: e.message }); } }); -// ── POST add discovered model to enabled list ───────────────────────── +// ── POST add discovered model to custom list ────────────────────────────── router.post('/config/models/add-discovered', async function(req, res) { try { var { id, name, cost, category } = req.body; @@ -370,17 +364,43 @@ router.post('/config/models/add-discovered', async function(req, res) { await db.setSetting('models.custom', JSON.stringify(custom)); logger.audit(req.user.id, 'admin_model_discover_add', 'Added discovered model: ' + trimmedId, req, { category: 'admin' }); - res.json({ success: true }); + res.json({ success: true, id: trimmedId }); } catch (e) { res.status(500).json({ error: e.message }); } }); -// ── PUT set default model ───────────────────────────────────────────── -router.put('/config/models/default', async function(req, res) { +// ============================================================ +// WILDCARD CONFIG — Must come AFTER all specific model routes above +// :key(*) matches slashes, so it would intercept /config/models/toggle +// and /config/models/default if registered before them. +// ============================================================ + +// ── PUT update a single config entry ────────────────────────────────────── +router.put('/config/:key(*)', async function(req, res) { try { - var { modelId } = req.body; - if (!modelId) return res.status(400).json({ error: 'modelId required' }); - await db.setSetting('models.default', modelId.trim()); - logger.audit(req.user.id, 'admin_model_default', 'Set default model: ' + modelId, req, { category: 'admin' }); + var key = req.params.key; + var value = req.body.value; + + if (value === undefined || value === null) { + return res.status(400).json({ error: 'value is required' }); + } + + // Security: only allow known key prefixes + var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.']; + var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); }); + if (!isAllowed) { + return res.status(400).json({ error: 'Unknown config key' }); + } + + await db.setSetting(key, String(value)); + + // Update in-memory prompt immediately if it's a prompt key + if (key.startsWith('prompt.')) { + var promptKey = key.replace('prompt.', ''); + PROMPTS.updatePrompt(promptKey, String(value)); + } + + logger.audit(req.user.id, 'admin_config_update', 'Updated config: ' + key, req, { category: 'admin' }); + res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } });