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

1from __future__ import annotations 

2 

3import asyncio 

4import base64 

5import io 

6import logging 

7import posixpath 

8from typing import Optional 

9from urllib.parse import unquote 

10 

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 

54 

55log = logging.getLogger(__name__) 

56 

57router = APIRouter() 

58 

59 

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 

68 

69 

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 

99 

100 

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) 

103 

104 

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.') 

124 

125 

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 ) 

150 

151 

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

157 

158 

159PAGE_ITEM_COUNT = 30 

160 

161 

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 

178 

179 page = max(1, page) 

180 skip = (page - 1) * limit 

181 

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 

193 

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} 

197 

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] 

201 

202 filter['user_id'] = user.id 

203 

204 result = await Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db) 

205 

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 ) 

216 

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

233 

234 return ModelAccessListResponse( 

235 items=items, 

236 total=result.total, 

237 ) 

238 

239 

240########################### 

241# GetBaseModels 

242########################### 

243 

244 

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) 

248 

249 

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) 

254 

255 

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) 

263 

264 

265########################### 

266# GetModelTags 

267########################### 

268 

269 

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) 

278 

279 

280############################ 

281# CreateNewModel 

282############################ 

283 

284 

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 ) 

300 

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 

309 

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 ) 

316 

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 ) 

323 

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 

330 

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 ) 

338 

339 await _verify_knowledge_file_access( 

340 getattr(form_data.meta, 'knowledge', None) if form_data.meta else None, 

341 user, 

342 db, 

343 ) 

344 

345 await _verify_background_image(form_data.meta.background_image_url, user, db) 

346 

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 ) 

354 

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 ) 

361 

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 

370 

371 

372############################ 

373# ExportModels 

374############################ 

375 

376 

377class ModelExportResponse(ModelModel): 

378 background_image_data: str | None = None 

379 

380 

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 ) 

398 

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 

430 

431 

432############################ 

433# ImportModels 

434############################ 

435 

436 

437class ModelsImportForm(BaseModel): 

438 models: list[dict] 

439 

440 

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 } 

470 

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) 

487 

488 base_model_ids = None 

489 imported_ids = [] 

490 for model_data in data: 

491 model_id = model_data.get('id') 

492 

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 

497 

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 

512 

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 

527 

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 

539 

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', {}) 

546 

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) 

565 

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 

574 

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 

583 

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]) 

587 

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 

595 

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 

604 

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 

621 

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 

655 

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

674 

675 

676############################ 

677# SyncModels 

678############################ 

679 

680 

681class SyncModelsForm(BaseModel): 

682 models: list[ModelModel] = [] 

683 

684 

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 

709 

710 

711########################### 

712# GetModelById 

713########################### 

714 

715 

716class ModelIdForm(BaseModel): 

717 id: str 

718 

719 

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 ) 

736 

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 ) 

768 

769 

770########################### 

771# GetModelById 

772########################### 

773 

774 

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 

784 

785 bypass_access_control = BYPASS_MODEL_ACCESS_CONTROL or (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) 

786 

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 

805 

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 

817 

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

834 

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 ) 

844 

845 headers = { 

846 'Content-Disposition': 'inline', 

847 'X-Content-Type-Options': 'nosniff', 

848 } 

849 if updated_at: 

850 headers['ETag'] = f'"{updated_at}"' 

851 

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 ) 

866 

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 ) 

874 

875 

876############################ 

877# ToggleModelById 

878############################ 

879 

880 

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) 

899 

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 ) 

925 

926 

927############################ 

928# UpdateModelById 

929############################ 

930 

931 

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 ) 

946 

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 ) 

962 

963 await _verify_knowledge_file_access( 

964 getattr(form_data.meta, 'knowledge', None) if form_data.meta else None, 

965 user, 

966 db, 

967 ) 

968 

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 

974 

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 ) 

980 

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 

983 

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) 

987 

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 ] 

1003 

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 ) 

1011 

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 

1022 

1023 

1024############################ 

1025# UpdateModelAccessById 

1026############################ 

1027 

1028 

1029class ModelAccessGrantsForm(BaseModel): 

1030 id: str = Field(pattern=r'^\S+$') 

1031 name: str | None = None 

1032 access_grants: list[dict] 

1033 

1034 

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) 

1043 

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 ) 

1067 

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 ) 

1083 

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 ) 

1091 

1092 await AccessGrants.set_access_grants('model', form_data.id, form_data.access_grants, db=db) 

1093 

1094 await Models.update_model_updated_at_by_id(form_data.id, db=db) 

1095 

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 

1104 

1105 

1106############################ 

1107# DeleteModelById 

1108############################ 

1109 

1110 

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 ) 

1124 

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 ) 

1140 

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 

1151 

1152 

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