honor admin default model selections

This commit is contained in:
Daniel 2026-05-08 06:49:50 +02:00
parent 0c9abe4b93
commit d734f4945b
4 changed files with 68 additions and 8 deletions

View file

@ -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); });

View file

@ -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 });

View file

@ -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;

View file

@ -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 \}\)/);
});