diff --git a/public/js/app.js b/public/js/app.js index 7086b11..d4c1574 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -287,6 +287,7 @@ document.addEventListener('DOMContentLoaded', function() { var costBadge = document.getElementById('model-cost-badge'); window._currentModels = []; window._currentProvider = 'openrouter'; + window._defaultModelId = ''; fetch('/api/models') .then(function(r) { return r.json(); }) @@ -302,22 +303,27 @@ document.addEventListener('DOMContentLoaded', function() { opt.textContent = m.name; selectEl.appendChild(opt); }); + if (window._defaultModelId && !Array.prototype.some.call(selectEl.options, function(opt) { return opt.value === window._defaultModelId; })) { + var saved = document.createElement('option'); + saved.value = window._defaultModelId; + saved.textContent = window._defaultModelId + ' (saved default)'; + selectEl.appendChild(saved); + } + if (window._defaultModelId) selectEl.value = window._defaultModelId; } // Determine default model (admin override or first model) var defaultModelId = data.defaultModel || (window._currentModels.length > 0 ? window._currentModels[0].id : ''); + window._defaultModelId = defaultModelId; if (modelSelect && window._currentModels.length > 0) { window._buildModelOptions(modelSelect); - // Select the admin-configured default - if (defaultModelId) modelSelect.value = defaultModelId; if (costBadge) costBadge.textContent = ''; } // Populate all per-tab model selectors already in DOM document.querySelectorAll('.tab-model-select').forEach(function(sel) { window._buildModelOptions(sel); - if (defaultModelId) sel.value = defaultModelId; }); }) .catch(function(err) { console.warn('Models load failed:', err); }); diff --git a/server.js b/server.js index 5b7a9c1..a818d30 100644 --- a/server.js +++ b/server.js @@ -266,6 +266,9 @@ app.get('/api/models', async (req, res) => { var db = require('./src/db/database'); var models = await getAvailableModelsWithOverrides(db); var defaultOverride = await db.getSetting('models.default'); + if (defaultOverride && !models.find(function(m) { return m.id === defaultOverride; })) { + models.push({ id: defaultOverride, name: defaultOverride + ' (saved default)', tag: 'SAVED' }); + } res.json({ models: models, provider: modelsProvider, defaultModel: defaultOverride || DEFAULT_MODEL }); } catch(e) { res.json({ models: getAvailableModels(), provider: modelsProvider }); diff --git a/src/utils/ai.js b/src/utils/ai.js index d38589e..4071446 100644 --- a/src/utils/ai.js +++ b/src/utils/ai.js @@ -417,14 +417,25 @@ async function assertModelAllowed(requestedModel, options) { } } +async function resolveModel(requestedModel) { + var model = requestedModel && String(requestedModel).trim(); + if (model) return model; + try { + var db = require('../db/database'); + var adminDefault = await db.getSetting('models.default'); + if (adminDefault && String(adminDefault).trim()) return String(adminDefault).trim(); + } catch (e) {} + return DEFAULT_MODEL; +} + async function callAIStream(messages, options, onToken) { options = options || {}; var requestedModel = options.model; - var model = requestedModel || DEFAULT_MODEL; + var model = await resolveModel(requestedModel); var temperature = options.temperature || 0.3; var maxTokens = options.maxTokens || 4000; var startTime = Date.now(); - await assertModelAllowed(requestedModel, options); + await assertModelAllowed(model, options); var client = null; var provider = null; @@ -469,7 +480,7 @@ async function callAIStream(messages, options, onToken) { async function callAI(messages, options) { options = options || {}; var requestedModel = options.model; - var model = requestedModel || DEFAULT_MODEL; + var model = await resolveModel(requestedModel); var temperature = options.temperature || 0.3; var maxTokens = options.maxTokens || 4000; var startTime = Date.now(); @@ -477,9 +488,9 @@ async function callAI(messages, options) { // Server-side whitelist: reject any model the operator hasn't enabled. // Prevents a client from passing e.g. model:"openai/o1" and draining // the budget on a reasoning model outside the configured roster. - // Falls back silently to DEFAULT_MODEL when no model was provided. + // Falls back to the admin default, then DEFAULT_MODEL, when no model was provided. // Admin test endpoints pass skipAllowlistCheck to test before adding. - await assertModelAllowed(requestedModel, options); + await assertModelAllowed(model, options); try { var result; diff --git a/test/model-defaults.test.js b/test/model-defaults.test.js new file mode 100644 index 0000000..bd3ef94 --- /dev/null +++ b/test/model-defaults.test.js @@ -0,0 +1,40 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.join(__dirname, '..'); + +function read(relativePath) { + return fs.readFileSync(path.join(root, relativePath), 'utf8'); +} + +test('api models endpoint preserves saved default in model list', () => { + const server = read('server.js'); + assert.match(server, /var defaultOverride = await db\.getSetting\('models\.default'\)/); + assert.match(server, /models\.push\(\{ id: defaultOverride, name: defaultOverride \+ ' \(saved default\)'/); + assert.match(server, /defaultModel: defaultOverride \|\| DEFAULT_MODEL/); +}); + +test('lazy-loaded tab model selectors apply admin default', () => { + const app = read('public/js/app.js'); + assert.match(app, /window\._defaultModelId = ''/); + assert.match(app, /window\._defaultModelId = defaultModelId/); + assert.match(app, /window\._buildModelOptions\(sel\)/); + assert.match(app, /selectEl\.value = window\._defaultModelId/); + assert.match(app, /saved\.textContent = window\._defaultModelId \+ ' \(saved default\)'/); +}); + +test('backend AI calls use admin default when request omits model', () => { + const ai = read('src/utils/ai.js'); + assert.match(ai, /async function resolveModel\(requestedModel\)/); + assert.match(ai, /await db\.getSetting\('models\.default'\)/); + assert.match(ai, /var model = await resolveModel\(requestedModel\)/g); + assert.match(ai, /await assertModelAllowed\(model, options\)/g); +}); + +test('personal notes voice generation sends selected model only when populated', () => { + const recorder = read('public/js/notes/recorder.js'); + assert.match(recorder, /var modelEl = document\.getElementById\('notes-model-select'\)/); + assert.match(recorder, /options\.noteFromVoice\(\{ transcript: resp\.text, model: selectedModel \|\| undefined \}\)/); +});