Coverage for open_webui/routers/models.py: 48%
457 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1from __future__ import annotations
3import asyncio
4import base64
5import io
6import logging
7import posixpath
8from typing import Optional
9from urllib.parse import unquote
11from fastapi import (
12 APIRouter,
13 Depends,
14 HTTPException,
15 Query,
16 Request,
17 Response,
18 status,
19)
20from fastapi.responses import RedirectResponse, StreamingResponse
21from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
22from open_webui.constants import ERROR_MESSAGES
23from open_webui.env import (
24 BYPASS_MODEL_ACCESS_CONTROL,
25 ENABLE_PROFILE_IMAGE_URL_FORWARDING,
26 PROFILE_IMAGE_ALLOWED_MIME_TYPES,
27)
28from open_webui.events import EVENTS, publish_event
29from open_webui.internal.db import get_async_session
30from open_webui.models.access_grants import AccessGrants, normalize_access_grants
31from open_webui.models.config import Config
32from open_webui.models.files import Files
33from open_webui.models.groups import Groups
34from open_webui.models.models import (
35 ModelAccessListResponse,
36 ModelAccessResponse,
37 ModelForm,
38 ModelListResponse,
39 ModelMeta,
40 ModelModel,
41 ModelParams,
42 ModelResponse,
43 Models,
44)
45from open_webui.storage.provider import Storage
46from open_webui.utils.access_control import filter_allowed_access_grants, has_access, has_permission
47from open_webui.utils.access_control.files import has_access_to_file
48from open_webui.utils.auth import get_admin_user, get_verified_user
49from open_webui.utils.chat_variables import get_chat_variables_schema
50from open_webui.utils.models import get_all_models
51from open_webui.utils.validate import BACKGROUND_IMAGE_MAX_BYTES, validate_background_image
52from pydantic import BaseModel, Field
53from sqlalchemy.ext.asyncio import AsyncSession
55log = logging.getLogger(__name__)
57router = APIRouter()
60def add_chat_variables_schema(model_dict: dict) -> dict:
61 system = (model_dict.get('params') or {}).get('system') if isinstance(model_dict.get('params'), dict) else None
62 schema = get_chat_variables_schema(system)
63 if schema: 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true
64 model_dict.setdefault('meta', {})['chat_variables_schema'] = schema
65 elif isinstance(model_dict.get('meta'), dict): 65 ↛ 67line 65 didn't jump to line 67 because the condition on line 65 was always true
66 model_dict['meta'].pop('chat_variables_schema', None)
67 return model_dict
70def _safe_static_redirect_path(url: str) -> str | None:
71 """
72 If url is a same-origin static asset path, return a normalized path safe for
73 RedirectResponse Location. Otherwise None (caller should fall back to default).
74 Rejects traversal (..), encoded dots, query/fragment, and non-/static targets.
75 """
76 if not url or not isinstance(url, str):
77 return None
78 path = url.split('?', 1)[0].split('#', 1)[0].strip()
79 for _ in range(2):
80 decoded = unquote(path)
81 if decoded == path:
82 break
83 path = decoded
84 # Fail closed: a value still encoded after the cap would be decoded further downstream.
85 if unquote(path) != path:
86 return None
87 if '\x00' in path or '\\' in path:
88 return None
89 if not path.startswith('/'):
90 return None
91 normalized = posixpath.normpath(path)
92 if normalized in ('.', '/'):
93 return None
94 if not (normalized == '/static' or normalized.startswith('/static/')):
95 return None
96 if normalized == '/static':
97 return '/static/'
98 return normalized
101def is_valid_model_id(model_id: str) -> bool:
102 return model_id and len(model_id) <= 256 and not any(char.isspace() for char in model_id)
105async def _verify_background_image(url: str | None, user, db, previous_url: str | None = None) -> None:
106 if not url or url == previous_url: 106 ↛ 108line 106 didn't jump to line 108 because the condition on line 106 was always true
107 return
108 file_id = url.split('/')[-2]
109 file = await Files.get_file_by_id(file_id, db=db)
110 if not file or not (
111 user.role == 'admin' or file.user_id == user.id or await has_access_to_file(file_id, 'read', user, db=db)
112 ):
113 raise HTTPException(status_code=403, detail='Background image is not accessible.')
114 try:
115 path = await asyncio.to_thread(Storage.get_file, file.path)
116 with open(path, 'rb') as image:
117 data = await asyncio.to_thread(image.read, BACKGROUND_IMAGE_MAX_BYTES + 1)
118 content_type = await asyncio.to_thread(validate_background_image, data)
119 except (ValueError, OSError) as error:
120 raise HTTPException(status_code=400, detail=str(error)) from error
121 if (file.meta or {}).get('content_type') != content_type:
122 if not await Files.update_file_metadata_by_id(file_id, {'content_type': content_type}, db=db):
123 raise HTTPException(status_code=500, detail='Could not validate background image.')
126async def _verify_knowledge_file_access(
127 knowledge_items: list | None,
128 user,
129 db: AsyncSession,
130) -> None:
131 """Raise 403 if any knowledge item references a file the caller cannot read."""
132 if not knowledge_items or user.role == 'admin': 132 ↛ 134line 132 didn't jump to line 134 because the condition on line 132 was always true
133 return
134 for item in knowledge_items:
135 if not isinstance(item, dict) or item.get('type') != 'file':
136 continue
137 file_id = item.get('id')
138 if not file_id:
139 continue
140 if not await has_access_to_file(file_id, 'read', user, db=db):
141 log.warning(
142 'knowledge file access denied: user %s cannot read file %s',
143 user.id,
144 file_id,
145 )
146 raise HTTPException(
147 status_code=status.HTTP_403_FORBIDDEN,
148 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
149 )
152###########################
153# GetModels
154# Let each model here be judged by what it does and not
155# by what it claims. The house deserves honest servants.
156###########################
159PAGE_ITEM_COUNT = 30
162@router.get(
163 '/list',
164 response_model=ModelAccessListResponse,
165 response_model_exclude={'items': {'__all__': {'meta': {'profile_image_url'}}}},
166) # do NOT use "/" as path, conflicts with main.py
167async def get_models(
168 query: str | None = None,
169 view_option: str | None = None,
170 tag: str | None = None,
171 order_by: str | None = None,
172 direction: str | None = None,
173 page: int | None = 1,
174 user=Depends(get_verified_user),
175 db: AsyncSession = Depends(get_async_session),
176):
177 limit = PAGE_ITEM_COUNT
179 page = max(1, page)
180 skip = (page - 1) * limit
182 filter = {}
183 if query:
184 filter['query'] = query
185 if view_option:
186 filter['view_option'] = view_option
187 if tag:
188 filter['tag'] = tag
189 if order_by:
190 filter['order_by'] = order_by
191 if direction:
192 filter['direction'] = direction
194 # Pre-fetch user group IDs once - used for both filter and write_access check
195 groups = await Groups.get_groups_by_member_id(user.id, db=db)
196 user_group_ids = {group.id for group in groups}
198 if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL: 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true
199 if groups:
200 filter['group_ids'] = [group.id for group in groups]
202 filter['user_id'] = user.id
204 result = await Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db)
206 # Batch-fetch writable model IDs in a single query instead of N has_access calls
207 model_ids = [model.id for model in result.items]
208 writable_model_ids = await AccessGrants.get_accessible_resource_ids(
209 user_id=user.id,
210 resource_type='model',
211 resource_ids=model_ids,
212 permission='write',
213 user_group_ids=user_group_ids,
214 db=db,
215 )
217 # Strip profile_image_url from meta — images are served via /model/profile/image.
218 items = []
219 for model in result.items:
220 data = add_chat_variables_schema(model.model_dump())
221 if data.get('meta'): 221 ↛ 223line 221 didn't jump to line 223 because the condition on line 221 was always true
222 data['meta'].pop('profile_image_url', None)
223 write_access = (
224 (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
225 or user.id == model.user_id
226 or model.id in writable_model_ids
227 )
228 # Strip params (system prompt and other curated config) for read-only
229 # callers, mirroring the per-id endpoint.
230 if not write_access: 230 ↛ 231line 230 didn't jump to line 231 because the condition on line 230 was never true
231 data['params'] = {}
232 items.append(ModelAccessResponse(**data, write_access=write_access))
234 return ModelAccessListResponse(
235 items=items,
236 total=result.total,
237 )
240###########################
241# GetBaseModels
242###########################
245@router.get('/all', response_model=list[ModelResponse])
246async def get_all_model_records(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
247 return await Models.get_all_models(db=db)
250@router.get('/base/tags', response_model=list[str])
251async def get_base_model_tags(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
252 tags = await Models.get_all_tags(user_id=user.id, is_admin=True, is_base_model=True, db=db)
253 return sorted(tags)
256@router.get('/base', response_model=list[ModelResponse])
257async def get_base_models(
258 tag: str | None = None,
259 user=Depends(get_admin_user),
260 db: AsyncSession = Depends(get_async_session),
261):
262 return await Models.get_base_models(tag=tag, db=db)
265###########################
266# GetModelTags
267###########################
270@router.get('/tags', response_model=list[str])
271async def get_model_tags(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
272 tags = await Models.get_all_tags(
273 user_id=user.id,
274 is_admin=(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL),
275 db=db,
276 )
277 return sorted(tags)
280############################
281# CreateNewModel
282############################
285@router.post('/create', response_model=ModelModel | None)
286async def create_new_model(
287 request: Request,
288 form_data: ModelForm,
289 user=Depends(get_verified_user),
290 db: AsyncSession = Depends(get_async_session),
291):
292 """Create a new workspace model entry."""
293 if user.role != 'admin' and not await has_permission( 293 ↛ 296line 293 didn't jump to line 296 because the condition on line 293 was never true
294 user.id, 'workspace.models', await Config.get('user.permissions'), db=db
295 ):
296 raise HTTPException(
297 status_code=status.HTTP_401_UNAUTHORIZED,
298 detail=ERROR_MESSAGES.UNAUTHORIZED,
299 )
301 if not is_valid_model_id(form_data.id): 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true
302 raise HTTPException(
303 status_code=status.HTTP_400_BAD_REQUEST,
304 detail=ERROR_MESSAGES.MODEL_ID_TOO_LONG,
305 )
306 if form_data.base_model_id == form_data.id: 306 ↛ 308line 306 didn't jump to line 308 because the condition on line 306 was never true
307 # Should never be stored: a model cannot be based on itself.
308 form_data.base_model_id = None
310 model = await Models.get_model_by_id(form_data.id, db=db)
311 if model:
312 raise HTTPException(
313 status_code=status.HTTP_401_UNAUTHORIZED,
314 detail=ERROR_MESSAGES.MODEL_ID_TAKEN,
315 )
317 if user.role != 'admin': 317 ↛ 318line 317 didn't jump to line 318 because the condition on line 317 was never true
318 if not form_data.base_model_id:
319 raise HTTPException(
320 status_code=status.HTTP_401_UNAUTHORIZED,
321 detail=ERROR_MESSAGES.UNAUTHORIZED,
322 )
324 if not request.app.state.MODELS:
325 await get_all_models(request, user=user)
326 for base_model in request.app.state.MODELS.values():
327 base_model_id = base_model.get('id')
328 if base_model.get('preset') or not base_model_id:
329 continue
331 if form_data.id == base_model_id or (
332 base_model.get('owned_by') == 'ollama' and form_data.id == base_model_id.split(':', 1)[0]
333 ):
334 raise HTTPException(
335 status_code=status.HTTP_401_UNAUTHORIZED,
336 detail=ERROR_MESSAGES.MODEL_ID_TAKEN,
337 )
339 await _verify_knowledge_file_access(
340 getattr(form_data.meta, 'knowledge', None) if form_data.meta else None,
341 user,
342 db,
343 )
345 await _verify_background_image(form_data.meta.background_image_url, user, db)
347 form_data.access_grants = await filter_allowed_access_grants(
348 await Config.get('user.permissions'),
349 user.id,
350 user.role,
351 form_data.access_grants,
352 'sharing.public_models',
353 )
355 model = await Models.insert_new_model(form_data, user.id, db=db)
356 if not model: 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true
357 raise HTTPException(
358 status_code=status.HTTP_401_UNAUTHORIZED,
359 detail=ERROR_MESSAGES.DEFAULT(),
360 )
362 await publish_event(
363 request,
364 EVENTS.MODEL_CREATED,
365 actor=user,
366 subject_id=model.id,
367 data={'name': model.name},
368 )
369 return model
372############################
373# ExportModels
374############################
377class ModelExportResponse(ModelModel):
378 background_image_data: str | None = None
381@router.get('/export', response_model=list[ModelExportResponse])
382async def export_models(
383 request: Request,
384 ids: list[str] | None = Query(None),
385 user=Depends(get_verified_user),
386 db: AsyncSession = Depends(get_async_session),
387):
388 if user.role != 'admin' and not await has_permission( 388 ↛ 394line 388 didn't jump to line 394 because the condition on line 388 was never true
389 user.id,
390 'workspace.models_export',
391 await Config.get('user.permissions'),
392 db=db,
393 ):
394 raise HTTPException(
395 status_code=status.HTTP_401_UNAUTHORIZED,
396 detail=ERROR_MESSAGES.UNAUTHORIZED,
397 )
399 if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: 399 ↛ 402line 399 didn't jump to line 402 because the condition on line 399 was always true
400 models = await Models.get_models(db=db, ids=ids)
401 else:
402 models = await Models.get_models(writable_by_user_id=user.id, db=db, ids=ids)
403 if ids is not None:
404 requested = set(ids)
405 if requested != {model.id for model in models}: 405 ↛ 407line 405 didn't jump to line 407 because the condition on line 405 was always true
406 raise HTTPException(status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
407 exported = []
408 for model in models:
409 data = model.model_dump()
410 url = model.meta.background_image_url
411 if url: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true
412 try:
413 file = await Files.get_file_by_id(url.split('/')[-2], db=db)
414 if not file:
415 raise ValueError('Image file is missing')
416 path = await asyncio.to_thread(Storage.get_file, file.path)
417 with open(path, 'rb') as image:
418 image_data = await asyncio.to_thread(image.read, BACKGROUND_IMAGE_MAX_BYTES + 1)
419 content_type = await asyncio.to_thread(validate_background_image, image_data)
420 data['background_image_data'] = f'data:{content_type};base64,' + base64.b64encode(image_data).decode(
421 'ascii'
422 )
423 data['meta']['background_image_url'] = None
424 except Exception as error:
425 raise HTTPException(
426 status_code=400, detail=f'Could not export background for model {model.id}.'
427 ) from error
428 exported.append(data)
429 return exported
432############################
433# ImportModels
434############################
437class ModelsImportForm(BaseModel):
438 models: list[dict]
441@router.post('/import', response_model=bool)
442async def import_models(
443 request: Request,
444 user=Depends(get_verified_user),
445 form_data: ModelsImportForm = (...),
446 db: AsyncSession = Depends(get_async_session),
447):
448 if user.role != 'admin' and not await has_permission( 448 ↛ 454line 448 didn't jump to line 454 because the condition on line 448 was never true
449 user.id,
450 'workspace.models_import',
451 await Config.get('user.permissions'),
452 db=db,
453 ):
454 raise HTTPException(
455 status_code=status.HTTP_401_UNAUTHORIZED,
456 detail=ERROR_MESSAGES.UNAUTHORIZED,
457 )
458 try:
459 data = form_data.models
460 if isinstance(data, list): 460 ↛ 666line 460 didn't jump to line 666 because the condition on line 460 was always true
461 # Batch-fetch all existing models in one query to avoid N+1
462 model_ids = [
463 model_data.get('id')
464 for model_data in data
465 if model_data.get('id') and is_valid_model_id(model_data.get('id'))
466 ]
467 existing_models = {
468 model.id: model for model in (await Models.get_models_by_ids(model_ids, db=db) if model_ids else [])
469 }
471 # Batch-resolve write permissions in one query instead of
472 # per-model has_access calls (N+1 avoidance).
473 existing_model_ids = list(existing_models.keys())
474 if user.role != 'admin' and existing_model_ids: 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true
475 groups = await Groups.get_groups_by_member_id(user.id, db=db)
476 user_group_ids = {group.id for group in groups}
477 writable_model_ids = await AccessGrants.get_accessible_resource_ids(
478 user_id=user.id,
479 resource_type='model',
480 resource_ids=existing_model_ids,
481 permission='write',
482 user_group_ids=user_group_ids,
483 db=db,
484 )
485 else:
486 writable_model_ids = set(existing_model_ids)
488 base_model_ids = None
489 imported_ids = []
490 for model_data in data:
491 model_id = model_data.get('id')
493 if model_id and is_valid_model_id(model_id): 493 ↛ 494line 493 didn't jump to line 494 because the condition on line 493 was never true
494 if model_data.get('base_model_id') == model_id:
495 # Should never be stored: heal bad exports/API payloads.
496 model_data['base_model_id'] = None
498 # Defense-in-depth: skip models referencing inaccessible files
499 try:
500 await _verify_knowledge_file_access(
501 (model_data.get('meta') or {}).get('knowledge'),
502 user,
503 db,
504 )
505 except HTTPException:
506 log.warning(
507 'import_models: user %s skipped model %s (knowledge file access denied)',
508 user.id,
509 model_id,
510 )
511 continue
513 existing_model = existing_models.get(model_id)
514 if existing_model:
515 # Enforce ownership/write-access before allowing overwrite
516 if (
517 user.role != 'admin'
518 and existing_model.user_id != user.id
519 and model_id not in writable_model_ids
520 ):
521 log.warning(
522 'import_models: user %s skipped model %s (no write access)',
523 user.id,
524 model_id,
525 )
526 continue
528 if (
529 user.role != 'admin'
530 and existing_model.base_model_id
531 and not model_data.get('base_model_id', existing_model.base_model_id)
532 ):
533 log.warning(
534 'import_models: user %s skipped model %s (cannot clear base model)',
535 user.id,
536 model_id,
537 )
538 continue
540 # Update existing model
541 model_data['meta'] = {
542 **existing_model.meta.model_dump(),
543 **(model_data.get('meta') or {}),
544 }
545 model_data['params'] = model_data.get('params', {})
547 updated_model = ModelForm(**{**existing_model.model_dump(), **model_data})
548 # Only filter access_grants when explicitly provided
549 # in the payload to avoid altering existing ACLs on
550 # metadata-only imports.
551 if 'access_grants' in model_data:
552 updated_model.access_grants = await filter_allowed_access_grants(
553 await Config.get('user.permissions'),
554 user.id,
555 user.role,
556 updated_model.access_grants,
557 'sharing.public_models',
558 )
559 imported_model = updated_model
560 else:
561 # Insert new model
562 model_data['meta'] = model_data.get('meta', {})
563 model_data['params'] = model_data.get('params', {})
564 new_model = ModelForm(**model_data)
566 if user.role != 'admin':
567 if not new_model.base_model_id:
568 log.warning(
569 'import_models: user %s skipped model %s (no base model set)',
570 user.id,
571 model_id,
572 )
573 continue
575 if base_model_ids is None:
576 base_model_ids = set()
577 if not request.app.state.MODELS:
578 await get_all_models(request, user=user)
579 for base_model in request.app.state.MODELS.values():
580 base_model_id = base_model.get('id')
581 if base_model.get('preset') or not base_model_id:
582 continue
584 base_model_ids.add(base_model_id)
585 if base_model.get('owned_by') == 'ollama':
586 base_model_ids.add(base_model_id.split(':', 1)[0])
588 if model_id in base_model_ids:
589 log.warning(
590 'import_models: user %s skipped model %s (id belongs to a base model)',
591 user.id,
592 model_id,
593 )
594 continue
596 new_model.access_grants = await filter_allowed_access_grants(
597 await Config.get('user.permissions'),
598 user.id,
599 user.role,
600 new_model.access_grants,
601 'sharing.public_models',
602 )
603 imported_model = new_model
605 uploaded = None
606 try:
607 encoded = model_data.pop('background_image_data', None)
608 if encoded is not None:
609 if (
610 not isinstance(encoded, str)
611 or len(encoded) > 4 * ((BACKGROUND_IMAGE_MAX_BYTES + 2) // 3) + 64
612 ):
613 raise ValueError('Background image must be at most 5 MiB.')
614 header, payload = encoded.split(',', 1)
615 image_data = base64.b64decode(payload, validate=True)
616 content_type = await asyncio.to_thread(validate_background_image, image_data)
617 if header != f'data:{content_type};base64':
618 raise ValueError('Invalid background image data URI.')
619 from fastapi import UploadFile
620 from open_webui.routers.files import upload_file_handler
622 uploaded = await upload_file_handler(
623 request,
624 file=UploadFile(
625 file=io.BytesIO(image_data),
626 filename='background.' + content_type.split('/')[1],
627 ),
628 metadata=None,
629 process=False,
630 user=user,
631 db=db,
632 )
633 imported_model.meta.background_image_url = f'/api/v1/files/{uploaded.id}/content'
634 await _verify_background_image(
635 imported_model.meta.background_image_url,
636 user,
637 db,
638 existing_model.meta.background_image_url if existing_model else None,
639 )
640 saved = (
641 await Models.update_model_by_id(model_id, imported_model, db=db)
642 if existing_model
643 else await Models.insert_new_model(user_id=user.id, form_data=imported_model, db=db)
644 )
645 if not saved:
646 raise HTTPException(status_code=500, detail=f'Could not import model {model_id}.')
647 except Exception:
648 if uploaded:
649 try:
650 await Files.delete_file_by_id(uploaded.id, db=db)
651 await asyncio.to_thread(Storage.delete_file, uploaded.path)
652 except Exception:
653 log.exception('Could not clean up failed model background upload')
654 raise
656 imported_ids.append(model_id)
657 await publish_event(
658 request,
659 EVENTS.MODEL_IMPORTED,
660 actor=user,
661 subject_type='model',
662 data={'count': len(imported_ids), 'model_ids': imported_ids},
663 )
664 return True
665 else:
666 raise HTTPException(status_code=400, detail='Invalid JSON format')
667 except HTTPException:
668 raise
669 except ValueError as error:
670 raise HTTPException(status_code=400, detail=str(error)) from error
671 except Exception as e:
672 log.exception(e)
673 raise HTTPException(status_code=500, detail=str(e))
676############################
677# SyncModels
678############################
681class SyncModelsForm(BaseModel):
682 models: list[ModelModel] = []
685@router.post('/sync', response_model=list[ModelModel])
686async def sync_models(
687 request: Request,
688 form_data: SyncModelsForm,
689 user=Depends(get_admin_user),
690 db: AsyncSession = Depends(get_async_session),
691):
692 existing = {model.id: model for model in await Models.get_models_by_ids([m.id for m in form_data.models], db=db)}
693 for model in form_data.models:
694 previous = existing.get(model.id)
695 if previous and 'background_image_url' not in model.meta.model_fields_set:
696 model.meta.background_image_url = previous.meta.background_image_url
697 await _verify_background_image(
698 model.meta.background_image_url, user, db, previous.meta.background_image_url if previous else None
699 )
700 models = await Models.sync_models(user.id, form_data.models, db=db)
701 await publish_event(
702 request,
703 EVENTS.MODEL_SYNCED,
704 actor=user,
705 subject_type='model',
706 data={'count': len(models), 'model_ids': [model.id for model in models]},
707 )
708 return models
711###########################
712# GetModelById
713###########################
716class ModelIdForm(BaseModel):
717 id: str
720# Note: We're not using the typical url path param here, but instead using a query parameter to allow '/' in the id
721@router.get('/model', response_model=ModelAccessResponse | None)
722async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
723 model = await Models.get_model_by_id(id, db=db)
724 if model: 724 ↛ 725line 724 didn't jump to line 725 because the condition on line 724 was never true
725 write_access = (
726 (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
727 or user.id == model.user_id
728 or await AccessGrants.has_access(
729 user_id=user.id,
730 resource_type='model',
731 resource_id=model.id,
732 permission='write',
733 db=db,
734 )
735 )
737 if write_access or await AccessGrants.has_access(
738 user_id=user.id,
739 resource_type='model',
740 resource_id=model.id,
741 permission='read',
742 db=db,
743 ):
744 model_dict = model.model_dump()
745 model_dict = add_chat_variables_schema(model_dict)
746 # Strip params (system prompt and other admin-curated config)
747 # for read-only callers — matches the params strip already
748 # enforced on /api/models in utils/models.py. Owners, admins
749 # under BYPASS_ADMIN_ACCESS_CONTROL, and write-grant holders
750 # still receive the full object so the workspace edit UI keeps
751 # working for users who legitimately curate the model.
752 if not write_access:
753 model_dict['params'] = {}
754 return ModelAccessResponse(
755 **model_dict,
756 write_access=write_access,
757 )
758 else:
759 raise HTTPException(
760 status_code=status.HTTP_401_UNAUTHORIZED,
761 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
762 )
763 else:
764 raise HTTPException(
765 status_code=status.HTTP_404_NOT_FOUND,
766 detail=ERROR_MESSAGES.NOT_FOUND,
767 )
770###########################
771# GetModelById
772###########################
775@router.get('/model/profile/image')
776async def get_model_profile_image(
777 request: Request,
778 id: str,
779 user=Depends(get_verified_user),
780 db: AsyncSession = Depends(get_async_session),
781):
782 profile_image_url = None
783 updated_at = None
785 bypass_access_control = BYPASS_MODEL_ACCESS_CONTROL or (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
787 # First, check the database for regular models
788 model_meta = await Models.get_model_meta_by_id(id, db=db)
789 if model_meta:
790 meta, model_user_id, model_updated_at = model_meta
791 # Denied callers get the default image rather than an error, so model ids stay unprobeable.
792 if ( 792 ↛ 807line 792 didn't jump to line 807 because the condition on line 792 was always true
793 bypass_access_control
794 or user.id == model_user_id
795 or await AccessGrants.has_access(
796 user_id=user.id,
797 resource_type='model',
798 resource_id=id,
799 permission='read',
800 db=db,
801 )
802 ):
803 profile_image_url = (meta or {}).get('profile_image_url')
804 updated_at = model_updated_at
806 # Fallback: check arena models stored in config (not in the DB)
807 if not profile_image_url: 807 ↛ 818line 807 didn't jump to line 818 because the condition on line 807 was always true
808 arena_models = await Config.get('evaluation.arena.models', []) or []
809 for arena_model in arena_models:
810 if arena_model.get('id') == id: 810 ↛ 811line 810 didn't jump to line 811 because the condition on line 810 was never true
811 arena_meta = arena_model.get('meta', {})
812 if bypass_access_control or await has_access(
813 user.id, permission='read', access_grants=arena_meta.get('access_grants', []), db=db
814 ):
815 profile_image_url = arena_meta.get('profile_image_url')
816 break
818 if profile_image_url: 818 ↛ 819line 818 didn't jump to line 819 because the condition on line 818 was never true
819 if profile_image_url.startswith('http'):
820 if ENABLE_PROFILE_IMAGE_URL_FORWARDING:
821 return Response(
822 status_code=status.HTTP_302_FOUND,
823 headers={'Location': profile_image_url},
824 )
825 # When forwarding is disabled, fall through to the
826 # default image to prevent client-side IP/UA/Referer
827 # leaks via 302 redirect to external origins.
828 elif profile_image_url.startswith('data:image'):
829 try:
830 header, base64_data = profile_image_url.split(',', 1)
831 image_data = base64.b64decode(base64_data)
832 image_buffer = io.BytesIO(image_data)
833 media_type = header.split(';')[0].lstrip('data:').lower()
835 # only serve known-safe raster types inline; reject SVG/unknown (can run script on our origin)
836 if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES:
837 # LICENSE covers this Open WebUI fallback logo.
838 # Do not alter, remove, obscure, or replace it except as LICENSE permits:
839 # https://docs.openwebui.com/license.
840 return RedirectResponse(
841 url='/static/favicon.png',
842 status_code=status.HTTP_302_FOUND,
843 )
845 headers = {
846 'Content-Disposition': 'inline',
847 'X-Content-Type-Options': 'nosniff',
848 }
849 if updated_at:
850 headers['ETag'] = f'"{updated_at}"'
852 return StreamingResponse(
853 image_buffer,
854 media_type=media_type,
855 headers=headers,
856 )
857 except Exception:
858 pass
859 else:
860 safe_static = _safe_static_redirect_path(profile_image_url)
861 if safe_static:
862 return RedirectResponse(
863 url=safe_static,
864 status_code=status.HTTP_302_FOUND,
865 )
867 # LICENSE covers this Open WebUI fallback logo.
868 # Do not alter, remove, obscure, or replace it except as LICENSE permits:
869 # https://docs.openwebui.com/license.
870 return RedirectResponse(
871 url='/static/favicon.png',
872 status_code=status.HTTP_302_FOUND,
873 )
876############################
877# ToggleModelById
878############################
881@router.post('/model/toggle', response_model=ModelResponse | None)
882async def toggle_model_by_id(
883 request: Request, id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
884):
885 model = await Models.get_model_by_id(id, db=db)
886 if model: 886 ↛ 887line 886 didn't jump to line 887 because the condition on line 886 was never true
887 if (
888 user.role == 'admin'
889 or model.user_id == user.id
890 or await AccessGrants.has_access(
891 user_id=user.id,
892 resource_type='model',
893 resource_id=model.id,
894 permission='write',
895 db=db,
896 )
897 ):
898 model = await Models.toggle_model_by_id(id, db=db)
900 if model:
901 await publish_event(
902 request,
903 EVENTS.MODEL_ENABLED if model.is_active else EVENTS.MODEL_DISABLED,
904 actor=user,
905 subject_id=model.id,
906 subject_type='model',
907 data={'name': model.name},
908 )
909 return model
910 else:
911 raise HTTPException(
912 status_code=status.HTTP_400_BAD_REQUEST,
913 detail=ERROR_MESSAGES.DEFAULT('Error updating function'),
914 )
915 else:
916 raise HTTPException(
917 status_code=status.HTTP_401_UNAUTHORIZED,
918 detail=ERROR_MESSAGES.UNAUTHORIZED,
919 )
920 else:
921 raise HTTPException(
922 status_code=status.HTTP_401_UNAUTHORIZED,
923 detail=ERROR_MESSAGES.NOT_FOUND,
924 )
927############################
928# UpdateModelById
929############################
932@router.post('/model/update', response_model=ModelModel | None)
933async def update_model_by_id(
934 request: Request,
935 form_data: ModelForm,
936 user=Depends(get_verified_user),
937 db: AsyncSession = Depends(get_async_session),
938):
939 """Update a workspace model's configuration."""
940 model = await Models.get_model_by_id(form_data.id, db=db)
941 if not model:
942 raise HTTPException(
943 status_code=status.HTTP_401_UNAUTHORIZED,
944 detail=ERROR_MESSAGES.NOT_FOUND,
945 )
947 if ( 947 ↛ 958line 947 didn't jump to line 958 because the condition on line 947 was never true
948 model.user_id != user.id
949 and not await AccessGrants.has_access(
950 user_id=user.id,
951 resource_type='model',
952 resource_id=model.id,
953 permission='write',
954 db=db,
955 )
956 and user.role != 'admin'
957 ):
958 raise HTTPException(
959 status_code=status.HTTP_400_BAD_REQUEST,
960 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
961 )
963 await _verify_knowledge_file_access(
964 getattr(form_data.meta, 'knowledge', None) if form_data.meta else None,
965 user,
966 db,
967 )
969 if 'base_model_id' not in form_data.model_fields_set:
970 form_data.base_model_id = model.base_model_id
971 if form_data.base_model_id == form_data.id: 971 ↛ 973line 971 didn't jump to line 973 because the condition on line 971 was never true
972 # Should never be stored: a model cannot be based on itself.
973 form_data.base_model_id = None
975 if user.role != 'admin' and model.base_model_id and not form_data.base_model_id: 975 ↛ 976line 975 didn't jump to line 976 because the condition on line 975 was never true
976 raise HTTPException(
977 status_code=status.HTTP_401_UNAUTHORIZED,
978 detail=ERROR_MESSAGES.UNAUTHORIZED,
979 )
981 if 'profile_image_url' not in form_data.meta.model_fields_set:
982 form_data.meta.profile_image_url = model.meta.profile_image_url
984 if 'background_image_url' not in form_data.meta.model_fields_set:
985 form_data.meta.background_image_url = model.meta.background_image_url
986 await _verify_background_image(form_data.meta.background_image_url, user, db, model.meta.background_image_url)
988 if form_data.access_grants is not None:
989 # The editor resends every stored grant, so re-checking them would strip sharing this user cannot re-create.
990 existing_access_grants = {
991 (grant.principal_type, grant.principal_id, grant.permission) for grant in model.access_grants
992 }
993 submitted_access_grants_map = {
994 (grant['principal_type'], grant['principal_id'], grant['permission']): grant
995 for grant in normalize_access_grants(form_data.access_grants)
996 }
997 preserved_access_grants = [
998 grant for key, grant in submitted_access_grants_map.items() if key in existing_access_grants
999 ]
1000 new_access_grants = [
1001 grant for key, grant in submitted_access_grants_map.items() if key not in existing_access_grants
1002 ]
1004 form_data.access_grants = preserved_access_grants + await filter_allowed_access_grants(
1005 await Config.get('user.permissions'),
1006 user.id,
1007 user.role,
1008 new_access_grants,
1009 'sharing.public_models',
1010 )
1012 model = await Models.update_model_by_id(form_data.id, ModelForm(**form_data.model_dump()), db=db)
1013 if model: 1013 ↛ 1021line 1013 didn't jump to line 1021 because the condition on line 1013 was always true
1014 await publish_event(
1015 request,
1016 EVENTS.MODEL_UPDATED,
1017 actor=user,
1018 subject_id=model.id,
1019 data={'name': model.name},
1020 )
1021 return model
1024############################
1025# UpdateModelAccessById
1026############################
1029class ModelAccessGrantsForm(BaseModel):
1030 id: str = Field(pattern=r'^\S+$')
1031 name: str | None = None
1032 access_grants: list[dict]
1035@router.post('/model/access/update', response_model=ModelModel | None)
1036async def update_model_access_by_id(
1037 request: Request,
1038 form_data: ModelAccessGrantsForm,
1039 user=Depends(get_verified_user),
1040 db: AsyncSession = Depends(get_async_session),
1041):
1042 model = await Models.get_model_by_id(form_data.id, db=db)
1044 # Non-preset models (e.g. direct Ollama/OpenAI models) may not have a DB
1045 # entry yet. Create a minimal one so access grants can be stored.
1046 if not model:
1047 if user.role != 'admin': 1047 ↛ 1048line 1047 didn't jump to line 1048 because the condition on line 1047 was never true
1048 raise HTTPException(
1049 status_code=status.HTTP_403_FORBIDDEN,
1050 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1051 )
1052 model = await Models.insert_new_model(
1053 ModelForm(
1054 id=form_data.id,
1055 name=form_data.name or form_data.id,
1056 meta=ModelMeta(),
1057 params=ModelParams(),
1058 ),
1059 user.id,
1060 db=db,
1061 )
1062 if not model: 1062 ↛ 1063line 1062 didn't jump to line 1063 because the condition on line 1062 was never true
1063 raise HTTPException(
1064 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1065 detail=ERROR_MESSAGES.DEFAULT('Error creating model entry'),
1066 )
1068 if ( 1068 ↛ 1079line 1068 didn't jump to line 1079 because the condition on line 1068 was never true
1069 model.user_id != user.id
1070 and not await AccessGrants.has_access(
1071 user_id=user.id,
1072 resource_type='model',
1073 resource_id=model.id,
1074 permission='write',
1075 db=db,
1076 )
1077 and user.role != 'admin'
1078 ):
1079 raise HTTPException(
1080 status_code=status.HTTP_400_BAD_REQUEST,
1081 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1082 )
1084 form_data.access_grants = await filter_allowed_access_grants(
1085 await Config.get('user.permissions'),
1086 user.id,
1087 user.role,
1088 form_data.access_grants,
1089 'sharing.public_models',
1090 )
1092 await AccessGrants.set_access_grants('model', form_data.id, form_data.access_grants, db=db)
1094 await Models.update_model_updated_at_by_id(form_data.id, db=db)
1096 model = await Models.get_model_by_id(form_data.id, db=db)
1097 await publish_event(
1098 request,
1099 EVENTS.MODEL_ACCESS_UPDATED,
1100 actor=user,
1101 subject_id=form_data.id,
1102 )
1103 return model
1106############################
1107# DeleteModelById
1108############################
1111@router.post('/model/delete', response_model=bool)
1112async def delete_model_by_id(
1113 request: Request,
1114 form_data: ModelIdForm,
1115 user=Depends(get_verified_user),
1116 db: AsyncSession = Depends(get_async_session),
1117):
1118 model = await Models.get_model_by_id(form_data.id, db=db)
1119 if not model:
1120 raise HTTPException(
1121 status_code=status.HTTP_401_UNAUTHORIZED,
1122 detail=ERROR_MESSAGES.NOT_FOUND,
1123 )
1125 if ( 1125 ↛ 1136line 1125 didn't jump to line 1136 because the condition on line 1125 was never true
1126 user.role != 'admin'
1127 and model.user_id != user.id
1128 and not await AccessGrants.has_access(
1129 user_id=user.id,
1130 resource_type='model',
1131 resource_id=model.id,
1132 permission='write',
1133 db=db,
1134 )
1135 ):
1136 raise HTTPException(
1137 status_code=status.HTTP_401_UNAUTHORIZED,
1138 detail=ERROR_MESSAGES.UNAUTHORIZED,
1139 )
1141 result = await Models.delete_model_by_id(form_data.id, db=db)
1142 if result: 1142 ↛ 1150line 1142 didn't jump to line 1150 because the condition on line 1142 was always true
1143 await publish_event(
1144 request,
1145 EVENTS.MODEL_DELETED,
1146 actor=user,
1147 subject_id=form_data.id,
1148 data={'name': model.name},
1149 )
1150 return result
1153@router.delete('/delete/all', response_model=bool)
1154async def delete_all_models(
1155 request: Request, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
1156):
1157 result = await Models.delete_all_models(db=db)
1158 if result: 1158 ↛ 1160line 1158 didn't jump to line 1160 because the condition on line 1158 was always true
1159 await publish_event(request, EVENTS.MODEL_DELETED, actor=user, subject_type='model')
1160 return result