From 106aae70e9ad1a3356762779d72673b7dc1293a7 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 6 Oct 2026 21:45:10 +0400 Subject: [PATCH] refac --- backend/open_webui/main.py | 23 ++ backend/open_webui/models/models.py | 32 +- backend/open_webui/routers/models.py | 45 ++- backend/open_webui/utils/models.py | 24 +- backend/open_webui/utils/payload.py | 21 ++ src/lib/apis/index.ts | 11 +- .../components/admin/Settings/Models.svelte | 1 + .../Settings/Models/ModelControls.svelte | 294 ++++++++++++++++++ src/lib/components/chat/Chat.svelte | 3 + src/lib/components/chat/MessageInput.svelte | 3 + .../chat/MessageInput/ModelControls.svelte | 159 ++++++++++ src/lib/components/chat/Placeholder.svelte | 2 + .../Settings/Advanced/AdvancedParams.svelte | 69 +--- .../Settings/Advanced/CustomParams.svelte | 98 ++++++ src/lib/components/common/NativeSelect.svelte | 2 +- .../workspace/Models/ModelEditor.svelte | 9 +- 16 files changed, 711 insertions(+), 85 deletions(-) create mode 100644 src/lib/components/admin/Settings/Models/ModelControls.svelte create mode 100644 src/lib/components/chat/MessageInput/ModelControls.svelte create mode 100644 src/lib/components/chat/Settings/Advanced/CustomParams.svelte diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 0fa7c7babf..4a83f94a2c 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -263,6 +263,7 @@ from open_webui.utils.models import ( get_all_models, get_filtered_models, ) +from open_webui.utils.payload import apply_model_controls from open_webui.utils.oauth import ( OAuthClientInformationFull, OAuthClientManager, @@ -1192,7 +1193,19 @@ async def chat_completion( default_model_params, model_info.params.model_dump() if model_info and model_info.params else {}, ) + model_info_params.pop('model_controls', None) request_params = {key: value for key, value in (form_data.get('params') or {}).items() if value is not None} + model_controls = request_params.pop('model_controls', {}) + if not isinstance(model_controls, dict): + raise HTTPException(400, 'Model control selections must be keyed by model.') + model_controls = {} if form_data.get('automation_id') else model_controls + if any(model_controls.values()) and user.role != 'admin': + permissions = await Config.get('user.permissions') + for permission in ('chat.controls', 'chat.params'): + if not await has_permission(user.id, permission, permissions): + raise HTTPException(403, 'You cannot change model parameters.') + if missing_base_model and model_controls.get(model_id): + raise HTTPException(400, 'Model control selections cannot be applied to the fallback model.') if model_info_params or request_params: form_data['params'] = merge_model_params(model_info_params, request_params) @@ -1313,6 +1326,7 @@ async def chat_completion( or 'native' ), 'tool_approval_mode': tool_approval_mode, + 'model_controls': model_controls, }, } @@ -1670,6 +1684,15 @@ async def chat_completion( error_detail = None try: try: + if not metadata.get('direct'): + target = ( + model_info if form_data['model'] == model_id + else await Models.get_model_by_id(form_data['model']) + ) + controls = target.params.model_dump().get('model_controls', {}) if target else {} + form_data['params'] = apply_model_controls( + copy.deepcopy(form_data.get('params') or {}), controls, model_controls.get(form_data['model'], {}) + ) ctx = None # Saved chats load the message after approved tool calls run, so their results are kept if metadata.get('assistant_message_id') and not is_saved_chat_id(metadata.get('chat_id')): diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index a28838c16f..02a360e491 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -1,9 +1,10 @@ from __future__ import annotations import logging +import re import time from copy import deepcopy -from typing import Any +from typing import Annotated, Any from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.access_grants import AccessGrantModel, AccessGrants @@ -11,7 +12,7 @@ from open_webui.models.groups import Groups from open_webui.models.users import User, UserModel, UserResponse, Users from open_webui.utils.misc import json_text_variants from open_webui.utils.validate import validate_image_url -from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationInfo, field_validator, model_validator from sqlalchemy import BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -65,11 +66,38 @@ def strip_extracted_content_from_model_knowledge(knowledge: Any) -> Any: # --- Models DB Schema --- +ModelControlKey = Annotated[str, Field(pattern=re.compile(r'^(?!(?:constructor|prototype)\Z)[a-zA-Z][a-zA-Z0-9_-]*\Z'))] + + +class ModelControlOption(BaseModel): + model_config = ConfigDict(allow_inf_nan=False) + + label: str = Field(pattern=r'\S') + params: dict[str, JsonValue] + + +class ModelControl(BaseModel): + label: str = Field(pattern=r'\S') + description: str | None = Field(default=None, exclude_if=lambda value: value is None) + default: str | None = Field(default=None, exclude_if=lambda value: value is None) + options: dict[ModelControlKey, ModelControlOption] = Field(min_length=1) + + @model_validator(mode='after') + def check_default(self): + if self.default is not None and self.default not in self.options: + raise ValueError('Default must name an approved option.') + return self + + class ModelParams(BaseModel): """Parameters for model inference (temperature, top_p, etc.).""" model_config = ConfigDict(extra='allow') + model_controls: dict[ModelControlKey, ModelControl] = Field( + default_factory=dict, exclude_if=lambda value: not value + ) + class ModelMeta(BaseModel): """Metadata for a workspace model entry (profile, description, tags, capabilities).""" diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index b04b074d7e..5f1798f6cc 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -47,6 +47,7 @@ from open_webui.utils.access_control import filter_allowed_access_grants, has_ac from open_webui.utils.access_control.files import has_access_to_file from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.chat_variables import get_chat_variables_schema +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.models import get_all_models from open_webui.utils.validate import BACKGROUND_IMAGE_MAX_BYTES, validate_background_image from pydantic import BaseModel, Field @@ -57,6 +58,32 @@ log = logging.getLogger(__name__) router = APIRouter() +def model_response(model, user): + if model is None: + return None + data = model.model_dump() + if user.role != 'admin': + data.get('params', {}).pop('model_controls', None) + return data + + +async def _check_model_controls(form, previous, user, request): + old = previous.params.model_dump().get('model_controls', {}) if previous else {} + if 'model_controls' not in form.params.model_fields_set: + if old: + form.params.model_controls = previous.params.model_controls + return + controls = form.params.model_dump().get('model_controls', {}) + if user.role != 'admin' and JSONCodec.dumps(controls) != JSONCodec.dumps(old): + raise HTTPException(403, 'Only admins can change model controls.') + if controls: + if not request.app.state.MODELS: + await get_all_models(request, user=user) + base = request.app.state.MODELS.get(form.base_model_id or form.id, {}) + if 'pipe' in base or base.get('direct') or base.get('owned_by') == 'arena': + raise HTTPException(400, 'Model controls require a server-managed provider model.') + + def add_chat_variables_schema(model_dict: dict) -> dict: system = (model_dict.get('params') or {}).get('system') if isinstance(model_dict.get('params'), dict) else None schema = get_chat_variables_schema(system) @@ -217,7 +244,7 @@ async def get_models( # Strip profile_image_url from meta — images are served via /model/profile/image. items = [] for model in result.items: - data = add_chat_variables_schema(model.model_dump()) + data = add_chat_variables_schema(model_response(model, user)) if data.get('meta'): data['meta'].pop('profile_image_url', None) write_access = ( @@ -352,6 +379,7 @@ async def create_new_model( 'sharing.public_models', ) + await _check_model_controls(form_data, None, user, request) model = await Models.insert_new_model(form_data, user.id, db=db) if not model: raise HTTPException( @@ -366,7 +394,7 @@ async def create_new_model( subject_id=model.id, data={'name': model.name}, ) - return model + return model_response(model, user) ############################ @@ -406,7 +434,7 @@ async def export_models( raise HTTPException(status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) exported = [] for model in models: - data = model.model_dump() + data = model_response(model, user) url = model.meta.background_image_url if url: try: @@ -602,6 +630,7 @@ async def import_models( ) imported_model = new_model + await _check_model_controls(imported_model, existing_model, user, request) uploaded = None try: encoded = model_data.pop('background_image_data', None) @@ -692,6 +721,7 @@ async def sync_models( existing = {model.id: model for model in await Models.get_models_by_ids([m.id for m in form_data.models], db=db)} for model in form_data.models: previous = existing.get(model.id) + await _check_model_controls(model, previous, user, request) if previous and 'background_image_url' not in model.meta.model_fields_set: model.meta.background_image_url = previous.meta.background_image_url await _verify_background_image( @@ -741,7 +771,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes permission='read', db=db, ): - model_dict = model.model_dump() + model_dict = model_response(model, user) model_dict = add_chat_variables_schema(model_dict) # Strip params (system prompt and other admin-curated config) # for read-only callers — matches the params strip already @@ -909,7 +939,7 @@ async def toggle_model_by_id( subject_type='model', data={'name': model.name}, ) - return model + return model_response(model, user) else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -1012,6 +1042,7 @@ async def update_model_by_id( 'sharing.public_models', ) + await _check_model_controls(form_data, model, user, request) model = await Models.update_model_by_id(form_data.id, ModelForm(**form_data.model_dump()), db=db) if model: await publish_event( @@ -1021,7 +1052,7 @@ async def update_model_by_id( subject_id=model.id, data={'name': model.name}, ) - return model + return model_response(model, user) ############################ @@ -1103,7 +1134,7 @@ async def update_model_access_by_id( actor=user, subject_id=form_data.id, ) - return model + return model_response(model, user) ############################ diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 6db8256e59..f30b1beaa9 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -32,6 +32,23 @@ log = logging.getLogger(__name__) BASE_MODELS_CACHE_KEY = f'{REDIS_KEY_PREFIX}:models:base' +def public_model_params(params): + controls = params.get('model_controls', {}) + return ( + { + 'model_controls': { + key: { + **{field: control[field] for field in ('label', 'description', 'default') if field in control}, + 'options': {key: {'label': option['label']} for key, option in control['options'].items()}, + } + for key, control in controls.items() + } + } + if controls + else {} + ) + + async def fetch_ollama_models(request: Request, user: UserModel = None): raw_ollama_models = await ollama.get_all_models(request, user=user) return [ @@ -209,8 +226,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) action_ids.extend(model['info']['meta'].get('actionIds', [])) filter_ids.extend(model['info']['meta'].get('filterIds', [])) - if 'params' in model['info']: - del model['info']['params'] + model['info']['params'] = public_model_params(model['info'].get('params', {})) model['action_ids'] = action_ids model['filter_ids'] = filter_ids @@ -253,9 +269,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) info.setdefault('meta', {})['chat_variables_schema'] = schema elif isinstance(info.get('meta'), dict): info['meta'].pop('chat_variables_schema', None) - if 'params' in info: - # Remove params to avoid exposing sensitive info - del info['params'] + info['params'] = public_model_params(info.get('params', {})) model['info'] = info diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 94a9bc4769..c07b882a0f 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -1,6 +1,9 @@ import logging +from copy import deepcopy from typing import Callable, Optional +from fastapi import HTTPException + from open_webui.utils.chat_variables import render_chat_variables, render_user_variables from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import ( @@ -14,6 +17,22 @@ from open_webui.utils.task import prompt_template, prompt_variables_template log = logging.getLogger(__name__) +def apply_model_controls(params: dict, controls: dict, selections: dict) -> dict: + """Expand approved choices into ordinary Custom Params before normal request processing.""" + if not isinstance(selections, dict): + raise HTTPException(400, 'Model control selections must be an object.') + for key, choice in selections.items(): + if key not in controls or not isinstance(choice, str) or choice not in controls[key]['options']: + raise HTTPException(400, f'Model control {key}: the selected option is no longer available.') + for key, control in controls.items(): + choice = selections.get(key, control.get('default')) + if choice is not None: + params['custom_params'] = deep_update( + deepcopy(params.get('custom_params') or {}), deepcopy(control['options'][choice]['params']) + ) + return params + + async def resolve_system_prompt( system: Optional[str], metadata: Optional[dict] = None, @@ -96,6 +115,7 @@ def apply_params_to_form_data(form_data: dict, model: dict, params: dict | None 'system': str, 'note_id': str, 'tool_approval_mode': str, + 'model_controls': dict, } for key in list(params.keys()): @@ -151,6 +171,7 @@ def remove_open_webui_params(params: dict) -> dict: 'system': str, 'note_id': str, 'tool_approval_mode': str, + 'model_controls': dict, } for key in list(params.keys()): diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 1aa65e9a2a..d7202bf304 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -1790,4 +1790,13 @@ export interface ModelMeta { background_image_url?: string | null; } -export interface ModelParams {} +export interface ModelControl { + label: string; + description?: string; + default?: string | null; + options: Record }>; +} + +export interface ModelParams { + model_controls?: Record; +} diff --git a/src/lib/components/admin/Settings/Models.svelte b/src/lib/components/admin/Settings/Models.svelte index cb9fb1db4a..ed0a78387e 100644 --- a/src/lib/components/admin/Settings/Models.svelte +++ b/src/lib/components/admin/Settings/Models.svelte @@ -1268,6 +1268,7 @@ {:else} m.id === selectedModelId)} preset={false} diff --git a/src/lib/components/admin/Settings/Models/ModelControls.svelte b/src/lib/components/admin/Settings/Models/ModelControls.svelte new file mode 100644 index 0000000000..520bf08010 --- /dev/null +++ b/src/lib/components/admin/Settings/Models/ModelControls.svelte @@ -0,0 +1,294 @@ + + +
+
+ {$i18n.t('Model controls')} + +
+

+ {$i18n.t('Let people choose approved parameter presets in chat.')} +

+
+ {#each Object.entries(controls ?? {}) as [key, control], index (key)} +
+ + + +
+ {/each} +
+
+ + +
+
+
+ {$i18n.t(editingKey ? 'Edit control' : 'Add control')} +
+ +
+
+
+ + +
+ +
+
+ {$i18n.t('Options')} + +
+
+ {#each options as option, index (option.id)} +
+
+ + + +
+
+
+ {/each} +
+
+
+ + +
+
+
+
diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index b09a095c49..648a513354 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -4504,6 +4504,7 @@ class=" pb-2 {dragged ? 'z-0' : 'z-10'}" > {}; export let forkHandler: Function = () => {}; export let chatId = ''; + export let params = {}; export let contextUsage = null; export let contextCompactionEnabled = false; export let embedded = false; @@ -2616,6 +2618,7 @@ triggerClassName="items-center gap-1.5 rounded-lg pl-2 pr-1.5 py-1 text-[0.8125rem] font-normal text-gray-600 transition-colors duration-100 hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-gray-800/40 dark:hover:text-gray-200" /> + {#if isActive && prompt === '' && files.length === 0}
diff --git a/src/lib/components/chat/MessageInput/ModelControls.svelte b/src/lib/components/chat/MessageInput/ModelControls.svelte new file mode 100644 index 0000000000..621b335ecd --- /dev/null +++ b/src/lib/components/chat/MessageInput/ModelControls.svelte @@ -0,0 +1,159 @@ + + +{#snippet summary(model: Model, key: string, control: ModelControl)} + {control.label} + + {control.options[params.model_controls?.[model.id]?.[key] ?? control.default ?? '']?.label ?? + $i18n.t('Default')} + + +{/snippet} + +{#snippet choices(model: Model, key: string, control: ModelControl)} + {#if control.description} +

+ {control.description} +

+ {/if} + + {#each Object.entries(control.options) as [value, option] (value)} + + {/each} +{/snippet} + +{#if permitted && available.length} + (active = null)}> + + + +
+ + {#if $mobile && active} + + {@render choices(active.model, active.key, active.control)} + {:else} + {#each available as model (model.id)} + {#if selectedModels.length > 1} +
+ {resolveLocalizedModelName(model, $i18n.language)} +
+ {/if} + {#each Object.entries(model.info?.params?.model_controls ?? {}) as [key, control] (key)} + {#if $mobile} + + {:else} + + + {@render choices(model, key, control)} + + {/if} + {/each} + {/each} + {/if} +
+
+
+{/if} diff --git a/src/lib/components/chat/Placeholder.svelte b/src/lib/components/chat/Placeholder.svelte index 603861eae8..cd627c3637 100644 --- a/src/lib/components/chat/Placeholder.svelte +++ b/src/lib/components/chat/Placeholder.svelte @@ -47,6 +47,7 @@ export let history; export let prompt = ''; + export let params = {}; export let files = []; export let messageInput = null; @@ -241,6 +242,7 @@
{#if !($selectedFolder && folderReadOnly)} {#if custom && admin} -
- {#each Object.keys(params?.custom_params ?? {}) as key} -
-
-
- { - const newKey = e.currentTarget.value.trim(); - if (newKey && newKey !== key) { - params.custom_params[newKey] = params.custom_params[key]; - delete params.custom_params[key]; - params = { - ...params, - custom_params: { ...params.custom_params } - }; - } - }} - /> -
- -
-
-
- -
-
-
- {/each} - - -
+ {/if} {/if}
diff --git a/src/lib/components/chat/Settings/Advanced/CustomParams.svelte b/src/lib/components/chat/Settings/Advanced/CustomParams.svelte new file mode 100644 index 0000000000..c05c51a1be --- /dev/null +++ b/src/lib/components/chat/Settings/Advanced/CustomParams.svelte @@ -0,0 +1,98 @@ + + +
+
+ {#each Object.keys(value ?? {}) as key} +
+ + + +
+ {/each} +
+ +
diff --git a/src/lib/components/common/NativeSelect.svelte b/src/lib/components/common/NativeSelect.svelte index 40ced22fc5..d9614075ac 100644 --- a/src/lib/components/common/NativeSelect.svelte +++ b/src/lib/components/common/NativeSelect.svelte @@ -15,7 +15,7 @@ bind:value {required} on:change={() => { - dispatch('change'); + dispatch('change', value); }} > {#if placeholder} diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index e774d6dad7..ef843a7303 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -15,6 +15,7 @@ import { uploadFile, deleteFileById } from '$lib/apis/files'; import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte'; + import ModelControls from '$lib/components/admin/Settings/Models/ModelControls.svelte'; import ModelSelector from '$lib/components/chat/ModelSelector/Selector.svelte'; import Tags from '$lib/components/common/Tags.svelte'; import Knowledge from '$lib/components/workspace/Models/Knowledge.svelte'; @@ -47,6 +48,7 @@ export let model = null; export let edit = false; + export let admin = false; export let preset = true; @@ -111,7 +113,7 @@ } }; - let params = { + let params: Record = { system: '' }; @@ -1155,6 +1157,9 @@
{/if} + {#if admin} + + {/if} @@ -1330,7 +1335,7 @@ class="p-1 px-3 text-xs flex rounded-sm transition" type="button" on:click={async () => { - const copied = await copyToClipboard(JSON.stringify(info, null, 2)); + const copied = await copyToClipboard(JSON.stringify(modelInfo, null, 2)); if (copied) { toast.success($i18n.t('Copied to clipboard')); }