From 2e8c36fa0150f84e2782d1f8e6fd80e779416438 Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 6 Oct 2026 17:57:50 +0200 Subject: [PATCH] u --- docs/api/routes/companysetting/set.md | 3 +- src/api/routes/companysetting.js | 70 ++++++++++++++++++---- src/api/routes/modelconversation.js | 5 +- src/services/conversationManager.js | 8 ++- src/tools/analyze-image.js | 5 +- src/tools/llm-key.js | 83 +++++++++++++++++++++++++-- 6 files changed, 152 insertions(+), 22 deletions(-) diff --git a/docs/api/routes/companysetting/set.md b/docs/api/routes/companysetting/set.md index 84ad76d..4915cc4 100644 --- a/docs/api/routes/companysetting/set.md +++ b/docs/api/routes/companysetting/set.md @@ -7,7 +7,8 @@ Upsert company setting Sets (upserts) a company setting for the selected company. Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`, `keys.openrouter`, `keys.serper`, `systemprompt-prefix`, -`systemprompt-suffix`, `default-model`. +`systemprompt-suffix`, `default-model`, `modelslug`, +`modelslugflag`, `modelreasoning`, `modelslugimage`. ## Path parameters diff --git a/src/api/routes/companysetting.js b/src/api/routes/companysetting.js index 95ff0dc..c5a59e0 100644 --- a/src/api/routes/companysetting.js +++ b/src/api/routes/companysetting.js @@ -8,8 +8,25 @@ const ALLOWED_KEYS = [ 'systemprompt-prefix', 'systemprompt-suffix', 'default-model', + 'modelslug', + 'modelslugflag', + 'modelreasoning', + 'modelslugimage', ]; +export const DEFAULT_SETTINGS = { + 'modelslug': 'openai/gpt-6-luna', + 'modelslugflag': 'floor', + 'modelreasoning': 'medium', + 'modelslugimage': 'meta/muse-image', +}; + +const WRITEONLY_KEYS = new Set([ + 'keys.tavily', + 'keys.openrouter', + 'keys.serper', +]); + function requireSelectedCompany(req) { const companyId = req.user.selectedCompanyId; if (companyId == null) { @@ -108,9 +125,12 @@ export function register(app, basePath = '') { if (s.frontend) visibleKeys.add(s.key); } - const settings = [...visibleKeys].map((key) => - byKey.has(key) ? shapeSetting(byKey.get(key)) : shapeSetting({ key, value: null }) - ); + const settings = [...visibleKeys].map((key) => { + if (byKey.has(key)) { + return shapeSetting(byKey.get(key)); + } + return shapeSetting({ key, value: DEFAULT_SETTINGS[key] ?? null }); + }); res.status(200).json({ settings }); } catch (error) { @@ -186,11 +206,18 @@ export function register(app, basePath = '') { try { const setting = await db.getByKey(req.user.id, key); - // Only frontend-visible settings are exposed; others are treated as absent. - if (!setting || !setting.frontend) { - return res.status(404).json({ error: 'Company setting not found' }); + // Stored setting that is frontend-visible + if (setting && setting.frontend) { + return res.status(200).json({ setting: shapeSetting(setting) }); } - res.status(200).json({ setting: shapeSetting(setting) }); + // If unset, but it's an allowed key with a default or allowed frontend key + if (!setting && ALLOWED_KEYS.includes(key) && !WRITEONLY_KEYS.has(key)) { + return res.status(200).json({ + setting: shapeSetting({ key, value: DEFAULT_SETTINGS[key] ?? null }), + }); + } + // Only frontend-visible settings are exposed; others are treated as absent. + return res.status(404).json({ error: 'Company setting not found' }); } catch (error) { const status = settingErrorStatus(error); if (status === 500) console.error('Get company setting failed:', error); @@ -208,7 +235,8 @@ export function register(app, basePath = '') { * Sets (upserts) a company setting for the selected company. * Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`, * `keys.openrouter`, `keys.serper`, `systemprompt-prefix`, - * `systemprompt-suffix`, `default-model`. + * `systemprompt-suffix`, `default-model`, `modelslug`, + * `modelslugflag`, `modelreasoning`, `modelslugimage`. * security: * - cookieAuth: [] * x-markdown: companysetting/set.md @@ -279,15 +307,35 @@ export function register(app, basePath = '') { return res.status(400).json({ error: 'key is required' }); } + if (!ALLOWED_KEYS.includes(key)) { + return res.status(400).json({ error: 'Invalid setting' }); + } + const value = req.body?.value; const frontend = req.body?.frontend; - const input = { key }; - if (typeof value === 'string') { - input.value = value; + if (typeof value !== 'string') { + return res.status(400).json({ error: 'Invalid setting' }); } + + const trimmedVal = value.trim(); + if (key === 'modelslugflag' && !['', 'floor', 'nitro'].includes(trimmedVal)) { + return res.status(400).json({ error: 'Invalid setting' }); + } + if (key === 'modelreasoning' && !['none', 'low', 'medium', 'high'].includes(trimmedVal)) { + return res.status(400).json({ error: 'Invalid setting' }); + } + + let valToSave = value; + if (key === 'modelslug' && !trimmedVal) { + valToSave = DEFAULT_SETTINGS.modelslug; + } + + const input = { key, value: valToSave }; if (typeof frontend === 'boolean') { input.frontend = frontend; + } else if (!WRITEONLY_KEYS.has(key)) { + input.frontend = true; } try { diff --git a/src/api/routes/modelconversation.js b/src/api/routes/modelconversation.js index 57e4c22..c5e57d8 100644 --- a/src/api/routes/modelconversation.js +++ b/src/api/routes/modelconversation.js @@ -151,8 +151,9 @@ export function register(app, basePath = '') { // empty responses from the provider. Map it onto the configured default rather than // ignoring the client's choice entirely, so an explicit model selection still works. const requestedModel = typeof model === 'string' ? model.trim() : ''; - const effectiveModel = RETIRED_MODELS.has(requestedModel) || !requestedModel - ? await resolveDefaultModel(req.user.id) + const defaultModel = await resolveDefaultModel(req.user.id); + const effectiveModel = !requestedModel || RETIRED_MODELS.has(requestedModel) || requestedModel === 'openai/gpt-6-luna' || requestedModel === 'openai/gpt-6-luna:floor' || requestedModel === 'openrouter/free' + ? defaultModel : requestedModel; try { diff --git a/src/services/conversationManager.js b/src/services/conversationManager.js index 3194c26..954c52e 100644 --- a/src/services/conversationManager.js +++ b/src/services/conversationManager.js @@ -25,7 +25,7 @@ import { createWebSearchTool } from '../tools/web-search.js'; import { createUrlGetTool } from '../tools/url-get.js'; import { createPdfReadTool } from '../tools/pdf-read.js'; import { createSearchArticlesTool } from '../tools/search-articles.js'; -import { resolveOpenRouterKey, resolveDefaultModel, RETIRED_MODELS, hasConfiguredOpenRouterKey } from '../tools/llm-key.js'; +import { resolveOpenRouterKey, resolveDefaultModel, resolveModelReasoning, RETIRED_MODELS, hasConfiguredOpenRouterKey } from '../tools/llm-key.js'; const KEEP_ALIVE_MS = 20_000; @@ -847,9 +847,15 @@ export class ConversationManager { }, }); + const reasoningEffort = await resolveModelReasoning(googleAuthId); + const extraBody = reasoningEffort && reasoningEffort !== 'none' + ? { reasoning: { effort: reasoningEffort } } + : { reasoning: undefined }; + const result = await this.orbiter.run({ messages, model, + extraBody, tools: [ createDashbackRequestTool({ googleAuthId }), createAskUserTool(), diff --git a/src/tools/analyze-image.js b/src/tools/analyze-image.js index 29a2bad..d206289 100644 --- a/src/tools/analyze-image.js +++ b/src/tools/analyze-image.js @@ -5,7 +5,7 @@ import jwt from 'jsonwebtoken'; import { defineTool, ToolError, OpenRouterProvider } from 'libllmorbiter'; import env from '../config/env.js'; import { prepareVfs } from './vfs.js'; -import { resolveOpenRouterKey } from './llm-key.js'; +import { resolveOpenRouterKey, resolveImageModel } from './llm-key.js'; /** * analyze_image — lets a vision model actually LOOK at images held in the VFS. @@ -365,9 +365,10 @@ export function createAnalyzeImageTool({ googleAuthId, model }) { : question || 'Describe this image, judge its visual quality, flag any watermark, text overlay or defect, and say whether it is usable as a product or category image.'; const apiKey = await resolveOpenRouterKey(googleAuthId); + const imageModel = (await resolveImageModel(googleAuthId)) || model; const provider = new OpenRouterProvider({ apiKey, apiUrl: env.openrouterApiUrl }); const response = await provider.chatCompletions({ - model, + model: imageModel, messages: [ { role: 'user', diff --git a/src/tools/llm-key.js b/src/tools/llm-key.js index 850dacd..2bba87f 100644 --- a/src/tools/llm-key.js +++ b/src/tools/llm-key.js @@ -45,11 +45,19 @@ export async function hasConfiguredOpenRouterKey(googleAuthId) { } } +export const DEFAULT_MODEL_SETTINGS = { + modelslug: 'openai/gpt-6-luna', + modelslugflag: 'floor', + modelreasoning: 'medium', + modelslugimage: 'meta/muse-image', +}; + /** * Resolve the default model for a user/company: * - If companysetting `default-model` is set, return it (works in both paid and no-key modes). - * - Otherwise, if the user/company has configured `keys.openrouter`, use `env.openrouterPaidModel` - * (which defaults to 'openai/gpt-6-luna:floor'). + * - Otherwise, if the user/company has configured `keys.openrouter`, construct from + * companysetting `modelslug` (default 'openai/gpt-6-luna') and `modelslugflag` (default 'floor'). + * If flag is 'floor' or 'nitro', it is attached as `${slug}:${flag}`. If empty string, no flag is attached. * - Otherwise (no key set / free mode), use `env.openrouterFreeModel` * (which defaults to 'openrouter/free'). * @@ -67,7 +75,72 @@ export async function resolveDefaultModel(googleAuthId) { } } const hasKey = await hasConfiguredOpenRouterKey(googleAuthId); - return hasKey - ? (env.openrouterPaidModel || 'openai/gpt-6-luna:floor') - : (env.openrouterFreeModel || 'openrouter/free'); + if (!hasKey) { + return env.openrouterFreeModel || 'openrouter/free'; + } + + let slug = ''; + let flag = null; + if (googleAuthId) { + try { + const slugSetting = await dbCompanySetting.getByKey(googleAuthId, 'modelslug', { maskWriteonly: false }); + slug = slugSetting?.value?.trim() || ''; + const flagSetting = await dbCompanySetting.getByKey(googleAuthId, 'modelslugflag', { maskWriteonly: false }); + if (flagSetting !== undefined && flagSetting.value !== undefined && flagSetting.value !== null) { + flag = flagSetting.value.trim(); + } + } catch (err) { + console.error('modelslug: failed to read company setting:', err.message); + } + } + if (!slug) { + slug = DEFAULT_MODEL_SETTINGS.modelslug; + } + if (flag === null) { + flag = DEFAULT_MODEL_SETTINGS.modelslugflag; + } + const baseSlug = slug.replace(/:(floor|nitro)$/, ''); + return flag ? `${baseSlug}:${flag}` : baseSlug; +} + +/** + * Resolve the reasoning effort for a user/company: + * Reads companysetting `modelreasoning` (default 'medium'), returning 'none' | 'low' | 'medium' | 'high'. + * + * @param {number} [googleAuthId] + * @returns {Promise} + */ +export async function resolveModelReasoning(googleAuthId) { + if (googleAuthId) { + try { + const setting = await dbCompanySetting.getByKey(googleAuthId, 'modelreasoning', { maskWriteonly: false }); + const val = setting?.value?.trim(); + if (val && ['none', 'low', 'medium', 'high'].includes(val)) { + return val; + } + } catch (err) { + console.error('modelreasoning: failed to read company setting:', err.message); + } + } + return DEFAULT_MODEL_SETTINGS.modelreasoning; +} + +/** + * Resolve the vision/image model for a user/company: + * Reads companysetting `modelslugimage` (default 'meta/muse-image'). + * + * @param {number} [googleAuthId] + * @returns {Promise} + */ +export async function resolveImageModel(googleAuthId) { + if (googleAuthId) { + try { + const setting = await dbCompanySetting.getByKey(googleAuthId, 'modelslugimage', { maskWriteonly: false }); + const val = setting?.value?.trim(); + if (val) return val; + } catch (err) { + console.error('modelslugimage: failed to read company setting:', err.message); + } + } + return DEFAULT_MODEL_SETTINGS.modelslugimage; } \ No newline at end of file