honor admin default model selections
This commit is contained in:
parent
0c9abe4b93
commit
d734f4945b
4 changed files with 68 additions and 8 deletions
|
|
@ -287,6 +287,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
var costBadge = document.getElementById('model-cost-badge');
|
var costBadge = document.getElementById('model-cost-badge');
|
||||||
window._currentModels = [];
|
window._currentModels = [];
|
||||||
window._currentProvider = 'openrouter';
|
window._currentProvider = 'openrouter';
|
||||||
|
window._defaultModelId = '';
|
||||||
|
|
||||||
fetch('/api/models')
|
fetch('/api/models')
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
|
|
@ -302,22 +303,27 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
opt.textContent = m.name;
|
opt.textContent = m.name;
|
||||||
selectEl.appendChild(opt);
|
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)
|
// Determine default model (admin override or first model)
|
||||||
var defaultModelId = data.defaultModel || (window._currentModels.length > 0 ? window._currentModels[0].id : '');
|
var defaultModelId = data.defaultModel || (window._currentModels.length > 0 ? window._currentModels[0].id : '');
|
||||||
|
window._defaultModelId = defaultModelId;
|
||||||
|
|
||||||
if (modelSelect && window._currentModels.length > 0) {
|
if (modelSelect && window._currentModels.length > 0) {
|
||||||
window._buildModelOptions(modelSelect);
|
window._buildModelOptions(modelSelect);
|
||||||
// Select the admin-configured default
|
|
||||||
if (defaultModelId) modelSelect.value = defaultModelId;
|
|
||||||
if (costBadge) costBadge.textContent = '';
|
if (costBadge) costBadge.textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate all per-tab model selectors already in DOM
|
// Populate all per-tab model selectors already in DOM
|
||||||
document.querySelectorAll('.tab-model-select').forEach(function(sel) {
|
document.querySelectorAll('.tab-model-select').forEach(function(sel) {
|
||||||
window._buildModelOptions(sel);
|
window._buildModelOptions(sel);
|
||||||
if (defaultModelId) sel.value = defaultModelId;
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(function(err) { console.warn('Models load failed:', err); });
|
.catch(function(err) { console.warn('Models load failed:', err); });
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,9 @@ app.get('/api/models', async (req, res) => {
|
||||||
var db = require('./src/db/database');
|
var db = require('./src/db/database');
|
||||||
var models = await getAvailableModelsWithOverrides(db);
|
var models = await getAvailableModelsWithOverrides(db);
|
||||||
var defaultOverride = await db.getSetting('models.default');
|
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 });
|
res.json({ models: models, provider: modelsProvider, defaultModel: defaultOverride || DEFAULT_MODEL });
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
res.json({ models: getAvailableModels(), provider: modelsProvider });
|
res.json({ models: getAvailableModels(), provider: modelsProvider });
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
async function callAIStream(messages, options, onToken) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
var requestedModel = options.model;
|
var requestedModel = options.model;
|
||||||
var model = requestedModel || DEFAULT_MODEL;
|
var model = await resolveModel(requestedModel);
|
||||||
var temperature = options.temperature || 0.3;
|
var temperature = options.temperature || 0.3;
|
||||||
var maxTokens = options.maxTokens || 4000;
|
var maxTokens = options.maxTokens || 4000;
|
||||||
var startTime = Date.now();
|
var startTime = Date.now();
|
||||||
await assertModelAllowed(requestedModel, options);
|
await assertModelAllowed(model, options);
|
||||||
|
|
||||||
var client = null;
|
var client = null;
|
||||||
var provider = null;
|
var provider = null;
|
||||||
|
|
@ -469,7 +480,7 @@ async function callAIStream(messages, options, onToken) {
|
||||||
async function callAI(messages, options) {
|
async function callAI(messages, options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
var requestedModel = options.model;
|
var requestedModel = options.model;
|
||||||
var model = requestedModel || DEFAULT_MODEL;
|
var model = await resolveModel(requestedModel);
|
||||||
var temperature = options.temperature || 0.3;
|
var temperature = options.temperature || 0.3;
|
||||||
var maxTokens = options.maxTokens || 4000;
|
var maxTokens = options.maxTokens || 4000;
|
||||||
var startTime = Date.now();
|
var startTime = Date.now();
|
||||||
|
|
@ -477,9 +488,9 @@ async function callAI(messages, options) {
|
||||||
// Server-side whitelist: reject any model the operator hasn't enabled.
|
// Server-side whitelist: reject any model the operator hasn't enabled.
|
||||||
// Prevents a client from passing e.g. model:"openai/o1" and draining
|
// Prevents a client from passing e.g. model:"openai/o1" and draining
|
||||||
// the budget on a reasoning model outside the configured roster.
|
// 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.
|
// Admin test endpoints pass skipAllowlistCheck to test before adding.
|
||||||
await assertModelAllowed(requestedModel, options);
|
await assertModelAllowed(model, options);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var result;
|
var result;
|
||||||
|
|
|
||||||
40
test/model-defaults.test.js
Normal file
40
test/model-defaults.test.js
Normal 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 \}\)/);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue