feat(chat): add model control defaults

This commit is contained in:
Matyas Fenyves 2026-06-11 21:33:16 +02:00
parent 380e5305a6
commit 201d466adb
8 changed files with 209 additions and 12 deletions

View file

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

View file

@ -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()

View file

@ -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",
}

View file

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

View file

@ -1523,6 +1523,34 @@
<div id="set-defaultChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12c.6.5 1 1.2 1 2h6c0-.8.4-1.5 1-2a7 7 0 0 0-4-12z"/></svg>Model Response Defaults</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Default controls for newly created chat sessions. Existing chats keep their saved settings.</div>
<div class="settings-col">
<div class="settings-row">
<label class="settings-label">Reasoning</label>
<select id="set-defaultReasoningSelect" class="settings-select">
<option value="">Auto</option>
<option value="off">Off</option>
<option value="on">On</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="settings-row">
<label class="settings-label">Verbosity</label>
<select id="set-defaultVerbositySelect" class="settings-select">
<option value="">Auto</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div id="set-defaultControlsMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>
<div class="admin-card">
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>Utility Model <span style="font-size:0.72em;opacity:0.55;font-weight:normal;">(Recommended: Local Endpoint)</span></h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">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.</div>

View file

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

View file

@ -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') {

View file

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