diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 5c7a4e04a..c8db2dbaf 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -656,6 +656,10 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: "agent_max_rounds": (1, 200), "agent_max_tool_calls": (0, 1000), # 0 = unlimited } + _CHOICE_VALUES = { + "default_reasoning_effort": {"", "auto", "off", "on", "none", "minimal", "low", "medium", "high", "xhigh"}, + "default_verbosity": {"", "auto", "low", "medium", "high"}, + } for key in DEFAULT_SETTINGS: if key not in body: continue @@ -667,6 +671,14 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: except (TypeError, ValueError): raise HTTPException(400, f"{key} must be an integer") val = max(lo, min(val, hi)) + if key in _CHOICE_VALUES: + val = str(val or "").strip().lower().replace("-", "_") + if val == "x_high": + val = "xhigh" + if val in ("auto", "default"): + val = "" + if val not in _CHOICE_VALUES[key]: + raise HTTPException(400, f"Unsupported value for {key}") current[key] = val _save_settings(current) return current diff --git a/routes/model_routes.py b/routes/model_routes.py index 600150a66..d6472812a 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -2438,6 +2438,8 @@ def setup_model_routes(model_discovery): ep_id = (_user_prefs.get("default_endpoint_id") or "").strip() model = (_user_prefs.get("default_model") or "").strip() _fallbacks = _user_prefs.get("default_model_fallbacks") or [] + default_reasoning_effort = (_user_prefs.get("default_reasoning_effort") or "").strip() + default_verbosity = (_user_prefs.get("default_verbosity") or "").strip() # If user has no personal default, fall back to global default # But only based on the "share_defaults_with_users" flag # (only if share_defaults_with_users is enabled) @@ -2448,10 +2450,35 @@ def setup_model_routes(model_discovery): model = settings.get("default_model", "") if not _fallbacks: _fallbacks = settings.get("default_model_fallbacks") or [] + if not default_reasoning_effort: + default_reasoning_effort = (settings.get("default_reasoning_effort") or "").strip() + if not default_verbosity: + default_verbosity = (settings.get("default_verbosity") or "").strip() else: ep_id = settings.get("default_endpoint_id", "") model = settings.get("default_model", "") _fallbacks = settings.get("default_model_fallbacks") or [] + default_reasoning_effort = (settings.get("default_reasoning_effort") or "").strip() + default_verbosity = (settings.get("default_verbosity") or "").strip() + + def _clean_default_reasoning(value: str) -> str: + cleaned = (value or "").strip().lower().replace("-", "_") + if cleaned in {"", "auto", "default"}: + return "" + if cleaned == "none": + return "off" + return cleaned if cleaned in {"off", "on", "minimal", "low", "medium", "high", "xhigh"} else "" + + def _clean_default_verbosity(value: str) -> str: + cleaned = (value or "").strip().lower() + if cleaned in {"", "auto", "default"}: + return "" + return cleaned if cleaned in {"low", "medium", "high"} else "" + + default_controls = { + "default_reasoning_effort": _clean_default_reasoning(default_reasoning_effort), + "default_verbosity": _clean_default_verbosity(default_verbosity), + } db = SessionLocal() try: ep = None @@ -2504,7 +2531,7 @@ def setup_model_routes(model_discovery): _last_q = owner_filter(_last_q, ModelEndpoint, _user, include_shared=False) ep = _last_q.first() if not ep: - return {"endpoint_id": "", "endpoint_url": "", "model": ""} + return {"endpoint_id": "", "endpoint_url": "", "model": "", **default_controls} base = _normalize_base(ep.base_url) chat_url = build_chat_url(base) if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)): @@ -2514,7 +2541,7 @@ def setup_model_routes(model_discovery): model = visible[0] except Exception: pass - return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model} + return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model, **default_controls} finally: db.close() diff --git a/src/settings.py b/src/settings.py index 5836765f1..16839efaf 100644 --- a/src/settings.py +++ b/src/settings.py @@ -134,6 +134,8 @@ DEFAULT_SETTINGS = { "task_model": "", "default_endpoint_id": "", "default_model": "", + "default_reasoning_effort": "", + "default_verbosity": "", # Optional prose style used only for normal document writing/editing. # Email replies use email_writing_style instead because greetings, # signatures, and mailbox identity rules are medium-specific. @@ -271,6 +273,7 @@ _PER_USER_KEYS = { # account inherited whatever the most-recent admin picked, which then # got injected into the chat composer on first open. "default_endpoint_id", "default_model", "default_model_fallbacks", + "default_reasoning_effort", "default_verbosity", "utility_endpoint_id", "utility_model", "utility_model_fallbacks", "research_endpoint_id", "research_model", } diff --git a/static/app.js b/static/app.js index bac07392a..14db8905d 100644 --- a/static/app.js +++ b/static/app.js @@ -209,6 +209,10 @@ try { if (cachedDefaultChat && cachedDefaultChat.endpoint_url && cachedDefaultChat.model) { _defaultChat = cachedDefaultChat; window.__odysseusDefaultChat = cachedDefaultChat; + window.__odysseusModelControlDefaults = { + reasoning_effort: cachedDefaultChat.default_reasoning_effort || 'auto', + verbosity: cachedDefaultChat.default_verbosity || 'auto', + }; } } catch (_) {} async function _refreshDefaultChat() { @@ -218,6 +222,10 @@ async function _refreshDefaultChat() { _defaultChat = d; try { window.__odysseusDefaultChat = d; + window.__odysseusModelControlDefaults = { + reasoning_effort: d.default_reasoning_effort || 'auto', + verbosity: d.default_verbosity || 'auto', + }; localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(d)); } catch (_) {} return d; @@ -248,7 +256,11 @@ async function _createDirectChatFromPreferredModel() { const dc = await _refreshDefaultChat(); if (dc) { - sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { source: 'default' }); + sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { + source: 'default', + reasoning_effort: dc.default_reasoning_effort || '', + verbosity: dc.default_verbosity || '', + }); return true; } @@ -1991,6 +2003,29 @@ function initializeEventListeners() { const v = String(value || 'auto').toLowerCase(); return Object.prototype.hasOwnProperty.call(labels, v) ? v : 'auto'; }; + const normalizeDefaultControl = (key, value) => { + let normalized = String(value || 'auto').toLowerCase().replace(/-/g, '_'); + if (normalized === 'none') normalized = 'off'; + const config = controls.find(c => c.key === key); + return config ? normalizeValue(normalized, config.labels) : 'auto'; + }; + let modelControlDefaults = { + reasoning_effort: 'auto', + verbosity: 'auto', + }; + function setModelControlDefaults(values = {}) { + modelControlDefaults = { + reasoning_effort: normalizeDefaultControl( + 'reasoning_effort', + values.reasoning_effort || values.default_reasoning_effort || 'auto', + ), + verbosity: normalizeDefaultControl( + 'verbosity', + values.verbosity || values.default_verbosity || 'auto', + ), + }; + try { window.__odysseusModelControlDefaults = { ...modelControlDefaults }; } catch (_) {} + } const isThinkingModel = model => { const m = String(model || '').toLowerCase(); @@ -2216,6 +2251,7 @@ function initializeEventListeners() { }); refreshCapabilities(); + setModelControlDefaults(window.__odysseusModelControlDefaults || window.__odysseusDefaultChat || {}); window.odysseusModelControls = { applySession(meta = {}) { @@ -2227,9 +2263,22 @@ function initializeEventListeners() { } refreshCapabilities(); }, + getDefaults() { + return { ...modelControlDefaults }; + }, + setDefaults(values = {}) { + setModelControlDefaults(values); + }, refreshCapabilities, }; + fetch('/api/auth/settings', { credentials: 'same-origin' }) + .then(res => res.ok ? res.json() : null) + .then(settings => { + if (settings) setModelControlDefaults(settings); + }) + .catch(() => {}); + document.addEventListener('odysseus:model-picked', e => { const detail = (e && e.detail) || {}; refreshCapabilities({ model: detail.mid, endpointUrl: detail.url }); diff --git a/static/index.html b/static/index.html index 441512177..7db8b179a 100644 --- a/static/index.html +++ b/static/index.html @@ -1523,6 +1523,34 @@
+
+

Model Response Defaults

+
Default controls for newly created chat sessions. Existing chats keep their saved settings.
+
+
+ + +
+
+ + +
+
+
+

Utility Model (Recommended: Local Endpoint)

Runs background tasks (compaction, cleanup, auto-naming, retrieving memories from files) on a small/local model instead of your chat model. Leave blank to use the chat model.
diff --git a/static/js/chat.js b/static/js/chat.js index dd78fc9b5..10d901e0c 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -1273,6 +1273,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr if (dc && dc.endpoint_url && dc.model) { try { window.__odysseusDefaultChat = dc; + window.__odysseusModelControlDefaults = { + reasoning_effort: dc.default_reasoning_effort || 'auto', + verbosity: dc.default_verbosity || 'auto', + }; localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(dc)); } catch (_) {} } @@ -1282,7 +1286,11 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr } if (dc.endpoint_url && dc.model) { _sendPerf.mark('direct_chat_create_begin'); - await sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { source: 'default' }); + await sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { + source: 'default', + reasoning_effort: dc.default_reasoning_effort || '', + verbosity: dc.default_verbosity || '', + }); _sendPerf.mark('direct_chat_create_done'); const ok = await sessionModule.materializePendingSession(); _sendPerf.mark('direct_chat_materialize_done'); diff --git a/static/js/sessions.js b/static/js/sessions.js index 102345eb7..5327b331f 100644 --- a/static/js/sessions.js +++ b/static/js/sessions.js @@ -1801,7 +1801,11 @@ export async function loadSessions() { try { const dc = await _getPreferredDefaultChat(); if (dc && dc.endpoint_url && dc.model) { - await createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { source: 'default' }); + await createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { + source: 'default', + reasoning_effort: dc.default_reasoning_effort || '', + verbosity: dc.default_verbosity || '', + }); } } catch (_) { /* no default model — that's fine, user can /setup */ } _autoCreateInProgress = false; @@ -2148,7 +2152,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru } // Pending session — stored locally until the first message is sent -let _pendingChat = null; // { url, modelId, endpointId } +let _pendingChat = null; // { url, modelId, endpointId, reasoning_effort, verbosity } let _pendingMaterializePromise = null; async function _getPreferredDefaultChat() { @@ -2161,13 +2165,25 @@ async function _getPreferredDefaultChat() { dc = JSON.parse(localStorage.getItem('odysseus-default-chat-cache') || 'null'); } catch (_) {} } - if (dc && dc.endpoint_url && dc.model) return dc; + if (dc && dc.endpoint_url && dc.model) { + try { + window.__odysseusModelControlDefaults = { + reasoning_effort: dc.default_reasoning_effort || 'auto', + verbosity: dc.default_verbosity || 'auto', + }; + } catch (_) {} + return dc; + } try { const dcRes = await fetch(`${API_BASE}/api/default-chat`); dc = await dcRes.json(); if (dc && dc.endpoint_url && dc.model) { try { window.__odysseusDefaultChat = dc; + window.__odysseusModelControlDefaults = { + reasoning_effort: dc.default_reasoning_effort || 'auto', + verbosity: dc.default_verbosity || 'auto', + }; localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(dc)); } catch (_) {} return dc; @@ -2178,6 +2194,10 @@ async function _getPreferredDefaultChat() { export function createDirectChat(url, modelId, endpointId, opts = {}) { const incomingSource = opts.source || 'manual'; + const initialControls = { + reasoning_effort: opts.reasoning_effort || opts.default_reasoning_effort || '', + verbosity: opts.verbosity || opts.default_verbosity || '', + }; if ( _pendingChat && _pendingChat.modelId && @@ -2200,14 +2220,21 @@ export function createDirectChat(url, modelId, endpointId, opts = {}) { } // Don't hit the API — just store the model info and prepare the UI - _pendingChat = { url, modelId, endpointId, source: incomingSource }; + _pendingChat = { + url, + modelId, + endpointId, + source: incomingSource, + reasoning_effort: initialControls.reasoning_effort || null, + verbosity: initialControls.verbosity || null, + }; _pendingMaterializePromise = null; if (window.odysseusModelControls && window.odysseusModelControls.applySession) { window.odysseusModelControls.applySession({ model: modelId || '', endpoint_url: url || '', - reasoning_effort: null, - verbosity: null, + reasoning_effort: initialControls.reasoning_effort || null, + verbosity: initialControls.verbosity || null, }); } _skipAutoSelect = true; @@ -2281,7 +2308,11 @@ export async function materializePendingSession() { if (pending.endpointId) { fd.append('endpoint_id', pending.endpointId); } - const modelControls = Storage.loadToggleState(); + const modelControls = { + ...Storage.loadToggleState(), + reasoning_effort: pending.reasoning_effort || Storage.loadToggleState().reasoning_effort || 'auto', + verbosity: pending.verbosity || Storage.loadToggleState().verbosity || 'auto', + }; const reasoningEffort = String(modelControls.reasoning_effort || 'auto').toLowerCase(); const verbosity = String(modelControls.verbosity || 'auto').toLowerCase(); if (reasoningEffort && reasoningEffort !== 'auto') { diff --git a/static/js/settings.js b/static/js/settings.js index 540acff00..6d750b154 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -447,6 +447,9 @@ async function initDefaultChat() { var msg = el('set-defaultChatMsg'); var fbContainer = el('set-defaultFallbacks'); var addFbBtn = el('set-defaultAddFallback'); + var reasoningSel = el('set-defaultReasoningSelect'); + var verbositySel = el('set-defaultVerbositySelect'); + var controlsMsg = el('set-defaultControlsMsg'); var _endpoints = []; var _fallbacks = []; // [{endpoint_id, model}] — tried in order if primary fails @@ -471,6 +474,28 @@ async function initDefaultChat() { refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value); renderFallbacks(); } + function normalizeReasoningDefault(value) { + var v = String(value || 'auto').toLowerCase().replace(/-/g, '_'); + if (v === 'none') v = 'off'; + return ['auto', 'off', 'on', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(v) ? v : 'auto'; + } + function normalizeVerbosityDefault(value) { + var v = String(value || 'auto').toLowerCase(); + return ['auto', 'low', 'medium', 'high'].includes(v) ? v : 'auto'; + } + function currentControlDefaults() { + return { + reasoning_effort: normalizeReasoningDefault(reasoningSel ? reasoningSel.value : ''), + verbosity: normalizeVerbosityDefault(verbositySel ? verbositySel.value : ''), + }; + } + function publishControlDefaults() { + var defaults = currentControlDefaults(); + try { window.__odysseusModelControlDefaults = defaults; } catch (_) {} + if (window.odysseusModelControls && window.odysseusModelControls.setDefaults) { + window.odysseusModelControls.setDefaults(defaults); + } + } // Render the fallback chain. Each row is endpoint + model + remove. function renderFallbacks() { @@ -539,6 +564,9 @@ async function initDefaultChat() { return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' }; }) : []; + if (reasoningSel) reasoningSel.value = normalizeReasoningDefault(settings.default_reasoning_effort); + if (verbositySel) verbositySel.value = normalizeVerbosityDefault(settings.default_verbosity); + publishControlDefaults(); renderFallbacks(); } catch (e) { console.warn('Failed to load default chat settings', e); } @@ -553,14 +581,25 @@ async function initDefaultChat() { body: JSON.stringify({ default_endpoint_id: epSel.value, default_model: modelSel.value, - default_model_fallbacks: clean + default_model_fallbacks: clean, + default_reasoning_effort: currentControlDefaults().reasoning_effort, + default_verbosity: currentControlDefaults().verbosity }) }); + publishControlDefaults(); + if (controlsMsg) { + controlsMsg.textContent = 'Saved'; + controlsMsg.style.color = 'var(--fg)'; + setTimeout(function() { controlsMsg.textContent = ''; }, 2000); + } msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(function() { msg.textContent = ''; }, 2000); } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } } + if (reasoningSel) reasoningSel.addEventListener('change', saveDefault); + if (verbositySel) verbositySel.addEventListener('change', saveDefault); + if (addFbBtn) addFbBtn.addEventListener('click', function() { var first = enabledEndpoints()[0]; _fallbacks.push({ endpoint_id: first ? first.id : '', model: '' });