From ca32a1996fd92c1612f95cfe74ba94756e5818b9 Mon Sep 17 00:00:00 2001 From: Chad Palmer Date: Tue, 22 Sep 2026 21:38:13 +0000 Subject: [PATCH] add-default-model-admin-setting - added new admin model and reasoning effort default value feature for new chats. --- application/single_app/config.py | 2 +- application/single_app/functions_settings.py | 2 + application/single_app/route_backend_chats.py | 57 +++++++++++++- .../route_frontend_admin_settings.py | 19 +++++ .../single_app/route_frontend_chats.py | 24 +++++- .../static/js/chat/chat-conversations.js | 14 +++- .../static/js/chat/chat-messages.js | 41 +++++++++- .../static/js/chat/chat-model-selector.js | 55 ++++++++++++++ .../single_app/static/js/chat/chat-onload.js | 9 ++- .../static/js/chat/chat-reasoning.js | 16 ++++ .../static/js/chat/chat-searchable-select.js | 5 +- .../admin/_panes/model-endpoints.html | 41 +++++++++- application/single_app/templates/chats.html | 7 ++ docs/_data/app_surface.yml | 4 +- docs/_data/features.yml | 1 + docs/admin/ai-models.md | 4 +- docs/explanation/release_notes.md | 18 +++++ ...elector_initial_multiendpoint_bootstrap.py | 75 +++++++++++++++++-- ...est_chat_template_json_bootstrap_safety.py | 9 ++- .../test_default_model_selection_fallback.py | 75 +++++++++++++++++-- .../admin_settings_field_baseline.json | 2 + 21 files changed, 451 insertions(+), 29 deletions(-) diff --git a/application/single_app/config.py b/application/single_app/config.py index 892520372..1bf69d8d7 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -98,7 +98,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.039" +VERSION = "0.261.044" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 0782c1f45..361cefc20 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -1503,11 +1503,13 @@ def get_settings(use_cosmos=False, include_source=False): 'model_endpoint_identity_header_name': DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_NAME, 'model_endpoint_identity_header_value_type': DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_VALUE_TYPE, 'model_endpoint_identity_header_hmac_secret': secrets.token_urlsafe(48), + 'enable_default_model_for_new_conversations': False, 'default_model_selection': { 'endpoint_id': '', 'model_id': '', 'provider': '' }, + 'default_reasoning_effort': '', 'multi_endpoint_migrated_at': None, 'multi_endpoint_migration_notice': { 'enabled': False, diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 2206e7c04..125e2445c 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -478,6 +478,42 @@ def _resolve_reasoning_effort_for_model(reasoning_effort, model_name, provider=N return resolved_reasoning_effort +def _normalize_default_reasoning_effort(reasoning_effort): + normalized_reasoning_effort = str(reasoning_effort or '').strip().lower() + if normalized_reasoning_effort in ('', 'none', 'minimal', 'low', 'medium', 'high'): + return normalized_reasoning_effort + return '' + + +def _apply_admin_new_conversation_model_defaults(settings, data, conversation_id, request_agent_info, *, is_retry=False): + """Apply admin model defaults when a normal chat request starts a new conversation.""" + if ( + conversation_id + or is_retry + or _has_chat_agent_selection(request_agent_info) + or not settings.get('enable_multi_model_endpoints', False) + or not settings.get('enable_default_model_for_new_conversations', False) + ): + return False + + default_selection = settings.get('default_model_selection', {}) or {} + default_endpoint_id = str(default_selection.get('endpoint_id') or '').strip() + default_model_id = str(default_selection.get('model_id') or '').strip() + default_provider = str(default_selection.get('provider') or '').strip().lower() + if not (default_endpoint_id and default_model_id): + return False + + data['model_endpoint_id'] = default_endpoint_id + data['model_id'] = default_model_id + data['model_provider'] = default_provider + data['model_deployment'] = '' + default_reasoning_effort = _normalize_default_reasoning_effort(settings.get('default_reasoning_effort')) + if default_reasoning_effort: + data['reasoning_effort'] = None if default_reasoning_effort == 'none' else default_reasoning_effort + data['_admin_default_model_applied'] = True + return True + + def _apply_response_length_for_model(api_params, response_length, model_name, provider=None, response_length_parameter=None): normalized_response_length = normalize_model_response_length(response_length) if not normalized_response_length: @@ -14453,7 +14489,7 @@ def resolve_streaming_multi_endpoint_gpt_config(settings, data, user_id, active_ if requested_endpoint_id: if not (requested_model_id or requested_deployment): raise ValueError('Selected model information is incomplete for the streaming request.') - selection_source = 'request' + selection_source = 'default' if data.get('_admin_default_model_applied') else 'request' elif allow_default_selection: default_selection = settings.get('default_model_selection', {}) or {} default_endpoint_id = str(default_selection.get('endpoint_id') or '').strip() @@ -16800,6 +16836,14 @@ def chat_api(): conversation_id = getattr(g, 'conversation_id', None) or data.get('conversation_id') if conversation_id is not None: conversation_id = str(conversation_id).strip() or None + request_is_retry = bool(data.get('retry_user_message_id') or data.get('edited_user_message_id')) + _apply_admin_new_conversation_model_defaults( + settings, + data, + conversation_id, + request_agent_info, + is_retry=request_is_retry, + ) hybrid_search_enabled = data.get('hybrid_search') web_search_enabled = data.get('web_search_enabled') url_access_enabled = data.get('url_access_enabled') @@ -16899,7 +16943,7 @@ def result_requires_message_reload(result: Any) -> bool: retry_user_message_id = data.get('retry_user_message_id') or data.get('edited_user_message_id') retry_thread_id = data.get('retry_thread_id') retry_thread_attempt = data.get('retry_thread_attempt') - is_retry = bool(retry_user_message_id) + is_retry = request_is_retry is_edit = bool(data.get('edited_user_message_id')) if is_retry: @@ -17869,6 +17913,7 @@ def result_requires_message_reload(result: Any) -> bool: 'model_icon': gpt_model_icon, 'response_length': gpt_response_length, 'reasoning_effort': reasoning_effort if reasoning_effort and reasoning_effort != 'none' else None, + 'admin_default_applied': bool(data.get('_admin_default_model_applied')), 'streaming': 'Disabled' } @@ -21196,6 +21241,13 @@ def chat_stream_api(): data['active_group_id'] = initial_scope_context['active_group_id'] data['active_public_workspace_ids'] = list(initial_scope_context['active_public_workspace_ids']) data['active_public_workspace_id'] = initial_scope_context['active_public_workspace_id'] + _apply_admin_new_conversation_model_defaults( + settings, + data, + None if is_new_stream_conversation else finalized_conversation_id, + data.get('agent_info'), + is_retry=is_retry, + ) stream_session = CHAT_STREAM_REGISTRY.start_session(user_id, finalized_conversation_id) request_message = (data.get('message') or '').strip() @@ -22453,6 +22505,7 @@ def collect_stream_response_conversation_metadata(): 'model_icon': gpt_model_icon, 'response_length': gpt_response_length, 'reasoning_effort': reasoning_effort if reasoning_effort and reasoning_effort != 'none' else None, + 'admin_default_applied': bool(data.get('_admin_default_model_applied')), 'streaming': 'Enabled' } diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 9c8412517..09d6c76b1 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -560,6 +560,10 @@ def admin_settings(): 'model_id': '', 'provider': '' } + if 'enable_default_model_for_new_conversations' not in settings: + settings['enable_default_model_for_new_conversations'] = False + if 'default_reasoning_effort' not in settings: + settings['default_reasoning_effort'] = '' if 'metadata_extraction_model_selection' not in settings or not isinstance(settings.get('metadata_extraction_model_selection'), dict): settings['metadata_extraction_model_selection'] = { 'endpoint_id': '', @@ -1868,6 +1872,19 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul 'provider': '' } + enable_default_model_for_new_conversations = ( + enable_multi_model_endpoints + and form_data.get('enable_default_model_for_new_conversations') == 'on' + ) + default_reasoning_effort = ( + form_data.get('default_reasoning_effort', '').strip().lower() + ) + if default_reasoning_effort not in ('', 'none', 'minimal', 'low', 'medium', 'high'): + flash('Default reasoning effort is not valid. Please select a supported value.', 'warning') + default_reasoning_effort = '' + if not enable_default_model_for_new_conversations: + default_reasoning_effort = '' + metadata_selection_json = form_data.get('metadata_extraction_model_selection_json', '{}') parsed_metadata_model_selection = {} try: @@ -2520,7 +2537,9 @@ def is_valid_url(url): 'model_endpoint_identity_header_name': model_endpoint_identity_header_name, 'model_endpoint_identity_header_value_type': model_endpoint_identity_header_value_type, 'model_endpoint_identity_header_hmac_secret': settings.get('model_endpoint_identity_header_hmac_secret', ''), + 'enable_default_model_for_new_conversations': enable_default_model_for_new_conversations, 'default_model_selection': normalized_default_model_selection, + 'default_reasoning_effort': default_reasoning_effort, 'multi_endpoint_migrated_at': migrated_at, 'multi_endpoint_migration_notice': migration_notice, 'azure_apim_gpt_endpoint': form_data.get('azure_apim_gpt_endpoint', '').strip(), diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index f81ef71c4..6b4d4a956 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -496,7 +496,14 @@ def _filter_chat_model_endpoints_by_governance(user_id, endpoints, feature_key): return allowed_endpoints -def _build_initial_chat_model_selection(*, chat_model_options, preferred_model_id=None, preferred_model_deployment=None): +def _build_initial_chat_model_selection( + *, + chat_model_options, + preferred_model_id=None, + preferred_model_deployment=None, + default_model_selection=None, + use_admin_default=False, +): scope_order = { 'global': 0, 'personal': 1, @@ -574,6 +581,16 @@ def sort_key(option): normalized_preferred_model_id = _normalize_chat_model_value(preferred_model_id) normalized_preferred_model_deployment = _normalize_chat_model_value(preferred_model_deployment) + if use_admin_default and isinstance(default_model_selection, dict): + default_endpoint_id = _normalize_chat_model_value(default_model_selection.get('endpoint_id')) + default_model_id = _normalize_chat_model_value(default_model_selection.get('model_id')) + if default_endpoint_id and default_model_id: + for option in sorted_options: + endpoint_id = _normalize_chat_model_value(option.get('endpoint_id')) + model_id = _normalize_chat_model_value(option.get('model_id')) + if endpoint_id == default_endpoint_id and model_id == default_model_id: + return serialize_option(option) + if normalized_preferred_model_id: for option in sorted_options: selection_key = _normalize_chat_model_value(option.get('selection_key')) @@ -967,6 +984,11 @@ def chats(): chat_model_options=chat_model_options, preferred_model_id=user_settings_dict.get('preferredModelId'), preferred_model_deployment=user_settings_dict.get('preferredModelDeployment'), + default_model_selection=settings.get('default_model_selection', {}), + use_admin_default=bool( + enable_multi_model_endpoints + and settings.get('enable_default_model_for_new_conversations', False) + ), ) return render_template( diff --git a/application/single_app/static/js/chat/chat-conversations.js b/application/single_app/static/js/chat/chat-conversations.js index 361adbee1..bb8be2edf 100644 --- a/application/single_app/static/js/chat/chat-conversations.js +++ b/application/single_app/static/js/chat/chat-conversations.js @@ -97,9 +97,14 @@ async function refreshModelSelection() { try { const settings = await loadUserSettings(); const modelSelectorModule = await import('./chat-model-selector.js'); + const useAdminDefault = Boolean( + window.adminNewConversationDefaultsActive + && window.appSettings?.enable_default_model_for_new_conversations + && window.initialChatModelSelection?.selection_key + ); await modelSelectorModule.populateModelDropdown({ - preferredModelId: settings?.preferredModelId, - preferredModelDeployment: settings?.preferredModelDeployment, + preferredModelId: useAdminDefault ? window.initialChatModelSelection.selection_key : settings?.preferredModelId, + preferredModelDeployment: useAdminDefault ? null : settings?.preferredModelDeployment, preserveCurrentSelection: false, }); } catch (error) { @@ -1517,6 +1522,7 @@ export function addConversationToList(conversationId, title = null, classificati export async function selectConversation(conversationId) { currentConversationId = conversationId; window.currentConversationId = conversationId; + window.adminNewConversationDefaultsActive = false; notifyConversationContextChanged("select", conversationId); const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`); @@ -2219,6 +2225,10 @@ export async function createNewConversation(callback, options = {}) { } currentConversationId = data.conversation_id; + window.adminNewConversationDefaultsActive = Boolean( + window.appSettings?.enable_multi_model_endpoints + && window.appSettings?.enable_default_model_for_new_conversations + ); // Reset scope lock for new conversation resetScopeLock({ preserveSelections }); // Add to list (pass empty classifications for new convo) diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index 61b006015..1d2091c75 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -25,6 +25,10 @@ import { autoplayTTSIfEnabled, isTTSAutoplayEnabled, playTTS } from "./chat-tts. import { saveUserSetting } from "./chat-layout.js"; import { sendMessageWithStreaming } from "./chat-streaming.js"; import { getCurrentReasoningEffort, isReasoningEffortEnabled } from './chat-reasoning.js'; +import { + restoreAdminDefaultModelSelection, + restoreModelSelectionFromConversationMetadata, +} from './chat-model-selector.js'; import { areAgentsEnabled } from './chat-agents.js'; import { createThoughtsToggleHtml, attachThoughtsToggleListener } from './chat-thoughts.js'; import { destroyInlineCharts, extractInlineChartBlocks, hydrateInlineCharts, injectInlineChartHtml, restoreInlineChartTokens } from './chat-inline-charts.js'; @@ -2128,6 +2132,21 @@ export function groupGeneratedImageProposalMessages(messages = []) { return groupedMessages; } +function getLastConversationModelSelection(messages = []) { + if (!Array.isArray(messages)) { + return null; + } + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const modelSelection = messages[index]?.metadata?.model_selection; + if (modelSelection && typeof modelSelection === 'object') { + return modelSelection; + } + } + + return null; +} + export function loadMessages(conversationId) { window.SimpleChatM365PendingActions?.setConversation(conversationId); // Clear search highlights when loading a different conversation @@ -2159,6 +2178,19 @@ export function loadMessages(conversationId) { console.log(`--- Loading messages for ${conversationId} ---`); updateConversationTaskDocumentsFromMessages(Array.isArray(data.messages) ? data.messages : [], conversationId); updateComparisonChatUploadCatalog(Array.isArray(data.messages) ? data.messages : []); + const lastModelSelection = getLastConversationModelSelection(data.messages); + if (lastModelSelection) { + window.adminNewConversationDefaultsActive = false; + restoreModelSelectionFromConversationMetadata(lastModelSelection); + } else if ((Array.isArray(data.messages) ? data.messages : []).length === 0) { + window.adminNewConversationDefaultsActive = Boolean( + window.appSettings?.enable_multi_model_endpoints + && window.appSettings?.enable_default_model_for_new_conversations + ); + restoreAdminDefaultModelSelection(); + } else { + window.adminNewConversationDefaultsActive = false; + } const generatedImageProposalMessages = groupGeneratedImageProposalMessages(data.messages); const assistantMessageIds = new Set( (Array.isArray(data.messages) ? data.messages : []) @@ -8666,7 +8698,13 @@ document.addEventListener('DOMContentLoaded', function() { // Save the selected model when it changes if (modelSelect) { - modelSelect.addEventListener("change", function() { + modelSelect.addEventListener("change", function(event) { + const userInitiated = event.isTrusted || event.detail?.userInitiated === true; + if (userInitiated) { + window.adminNewConversationDefaultsActive = false; + } else { + return; + } const selectedModel = modelSelect.value; if (window.appSettings?.enable_multi_model_endpoints) { const selectedOption = modelSelect.options[modelSelect.selectedIndex]; @@ -8677,6 +8715,7 @@ if (modelSelect) { console.log(`Saving preferred model deployment: ${selectedModel}`); saveUserSetting({ preferredModelDeployment: selectedModel }); } + }); } diff --git a/application/single_app/static/js/chat/chat-model-selector.js b/application/single_app/static/js/chat/chat-model-selector.js index b339aa5a5..53df31902 100644 --- a/application/single_app/static/js/chat/chat-model-selector.js +++ b/application/single_app/static/js/chat/chat-model-selector.js @@ -610,4 +610,59 @@ export function refreshModelSelector() { } modelSelectorController?.refresh(); +} + +function findModelOptionForMetadata(modelSelection = {}) { + if (!modelSelect || !modelSelection || typeof modelSelection !== 'object') { + return null; + } + + const endpointId = String(modelSelection.model_endpoint_id || modelSelection.endpoint_id || '').trim(); + const modelId = String(modelSelection.model_id || '').trim(); + const selectedModel = String(modelSelection.selected_model || modelSelection.request_model || '').trim(); + const frontendModel = String(modelSelection.frontend_requested_model || modelSelection.deployment_name || '').trim(); + const options = Array.from(modelSelect.options).filter(option => !option.disabled); + + if (endpointId && modelId) { + const endpointMatch = options.find(option => ( + option.dataset.endpointId === endpointId + && option.dataset.modelId === modelId + )); + if (endpointMatch) { + return endpointMatch; + } + } + + const modelNames = [frontendModel, selectedModel].filter(Boolean); + return options.find(option => modelNames.some(modelName => ( + option.dataset.requestModel === modelName + || option.dataset.deploymentName === modelName + || option.value === modelName + ))) || null; +} + +export function restoreModelSelectionFromConversationMetadata(modelSelection = {}) { + const matchingOption = findModelOptionForMetadata(modelSelection); + if (!matchingOption || !modelSelect) { + return false; + } + + modelSelect.value = matchingOption.value; + matchingOption.selected = true; + modelSelectorController?.refresh(); + modelSelect.dispatchEvent(new CustomEvent('change', { + bubbles: true, + detail: { conversationRestore: true } + })); + return true; +} + +export function restoreAdminDefaultModelSelection() { + if (!window.appSettings?.enable_default_model_for_new_conversations) { + return false; + } + + return restoreModelSelectionFromConversationMetadata( + window.initialChatModelSelection || window.appSettings.default_model_selection || {} + ); } \ No newline at end of file diff --git a/application/single_app/static/js/chat/chat-onload.js b/application/single_app/static/js/chat/chat-onload.js index 34036bc19..b4985cb8c 100644 --- a/application/single_app/static/js/chat/chat-onload.js +++ b/application/single_app/static/js/chat/chat-onload.js @@ -157,8 +157,13 @@ window.addEventListener('DOMContentLoaded', async () => { try { const userSettings = await userSettingsPromise; - const preferredModelId = userSettings?.preferredModelId; - const preferredModelDeployment = userSettings?.preferredModelDeployment; + let preferredModelId = userSettings?.preferredModelId; + let preferredModelDeployment = userSettings?.preferredModelDeployment; + + if (window.appSettings?.enable_default_model_for_new_conversations && window.initialChatModelSelection?.selection_key) { + preferredModelId = window.initialChatModelSelection.selection_key; + preferredModelDeployment = null; + } initializeModelSelector(); await populateModelDropdown({ diff --git a/application/single_app/static/js/chat/chat-reasoning.js b/application/single_app/static/js/chat/chat-reasoning.js index 545aa6cf8..bda56640f 100644 --- a/application/single_app/static/js/chat/chat-reasoning.js +++ b/application/single_app/static/js/chat/chat-reasoning.js @@ -4,6 +4,16 @@ import { showToast } from './chat-toast.js'; let reasoningEffortSettings = {}; // Per-model settings: {modelName: 'low', ...} +function getAdminDefaultReasoningEffort() { + if (!window.appSettings?.enable_default_model_for_new_conversations) { + return ''; + } + if (window.adminNewConversationDefaultsActive === false) { + return ''; + } + return String(window.appSettings?.default_reasoning_effort || '').trim().toLowerCase(); +} + function setTooltipText(element, text, options = {}) { if (!element) { return; @@ -210,11 +220,16 @@ export function getCurrentModelReasoningEffort() { const supportedLevels = getModelSupportedLevels(modelName); const savedEffort = reasoningEffortSettings[modelName]; + const adminDefaultEffort = getAdminDefaultReasoningEffort(); // If gpt-5-pro, always return high if (modelName.toLowerCase().includes('gpt-5-pro')) { return 'high'; } + + if (adminDefaultEffort && supportedLevels.includes(adminDefaultEffort)) { + return adminDefaultEffort; + } // If saved effort exists and is supported, use it if (savedEffort && supportedLevels.includes(savedEffort)) { @@ -404,6 +419,7 @@ function selectReasoningLevel(level, modelName) { * @param {string} effort - The effort level */ export function saveReasoningEffort(modelName, effort) { + window.adminNewConversationDefaultsActive = false; reasoningEffortSettings[modelName] = effort; saveUserSetting({ reasoningEffortSettings }); } diff --git a/application/single_app/static/js/chat/chat-searchable-select.js b/application/single_app/static/js/chat/chat-searchable-select.js index 07a96e3b8..ba3f467d6 100644 --- a/application/single_app/static/js/chat/chat-searchable-select.js +++ b/application/single_app/static/js/chat/chat-searchable-select.js @@ -496,7 +496,10 @@ export function createSearchableSingleSelect({ selectEl.selectedIndex = normalizedIndex; renderOptions(); - selectEl.dispatchEvent(new Event('change', { bubbles: true })); + selectEl.dispatchEvent(new CustomEvent('change', { + bubbles: true, + detail: { userInitiated: true } + })); try { bootstrap.Dropdown.getOrCreateInstance(buttonEl, resolvedDropdownConfig).hide(); diff --git a/application/single_app/templates/admin/_panes/model-endpoints.html b/application/single_app/templates/admin/_panes/model-endpoints.html index ca2accd85..03a29cb44 100644 --- a/application/single_app/templates/admin/_panes/model-endpoints.html +++ b/application/single_app/templates/admin/_panes/model-endpoints.html @@ -238,10 +238,45 @@
Model Endpoint Identity H
- - + +
- Used for tasks such as conversation summarization, fallback, and other operations when an agent is selected. + Used for tasks such as conversation summarization and agent fallback. When new conversation defaults are enabled, this model also replaces the user's last selected model for new non-agent chats. +
+
+ +
+
+
+ +

+ Force new non-agent chats to start with the default model below instead of the user's last selected model. +

+
+
+ + +
+
+
+ + +
+ Applies only when new conversation defaults are enabled; unsupported effort values are ignored for models that do not accept them. +
diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index efe954842..733b1dede 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -1624,6 +1624,9 @@