Coverage for open_webui/routers/ollama.py: 56%

864 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 logging 

5import os 

6import random 

7import re 

8import time 

9from datetime import datetime 

10from typing import Optional, Union 

11from urllib.parse import urlparse 

12 

13import aiofiles 

14import aiohttp 

15from aiocache import cached 

16from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile 

17from fastapi.responses import StreamingResponse 

18from open_webui.config import UPLOAD_DIR 

19from open_webui.constants import ERROR_MESSAGES 

20from open_webui.env import ( 

21 AIOHTTP_CLIENT_SESSION_SSL, 

22 AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, 

23 AIOHTTP_FILE_STREAM_CHUNK_SIZE, 

24 BYPASS_MODEL_ACCESS_CONTROL, 

25 ENABLE_FORWARD_USER_INFO_HEADERS, 

26 FORWARD_SESSION_INFO_HEADER_CHAT_ID, 

27 MODELS_CACHE_TTL, 

28 REDIS_KEY_PREFIX, 

29) 

30from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed 

31from open_webui.internal.db import get_async_session 

32from open_webui.models.access_grants import AccessGrants 

33from open_webui.models.config import Config 

34from open_webui.models.groups import Groups 

35from open_webui.models.models import Models 

36from open_webui.models.users import UserModel 

37from open_webui.utils.access_control import check_model_access 

38from open_webui.utils.auth import get_admin_user, get_verified_user 

39from open_webui.utils.headers import get_custom_headers, include_user_info_headers 

40from open_webui.utils.json_codec import JSONCodec 

41from open_webui.utils.misc import calculate_sha256 

42from open_webui.utils.model_ids import strip_provider_model_prefix 

43from open_webui.utils.payload import ( 

44 apply_model_params_to_body_ollama, 

45 apply_model_params_to_body_openai, 

46 apply_system_prompt_to_body, 

47) 

48from open_webui.utils.session_pool import cleanup_response, get_client_timeout, get_session, stream_wrapper 

49from pydantic import BaseModel, ConfigDict, validator 

50from sqlalchemy.ext.asyncio import AsyncSession 

51 

52log = logging.getLogger(__name__) 

53 

54# Headers that become stale after aiohttp auto-decompresses the upstream 

55# response body. Forwarding them verbatim causes desktop / programmatic 

56# clients to attempt decompression of an already-decoded payload, resulting 

57# in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462. 

58# Also drop server and date: uvicorn adds its own and forwarding both duplicates them. 

59_STRIP_PROXY_HEADERS = frozenset({'content-encoding', 'content-length', 'transfer-encoding', 'server', 'date'}) 

60_MODEL_LIST_TIMEOUT = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) 

61BASE_MODELS_CACHE_KEY = f'{REDIS_KEY_PREFIX}:models:base' 

62 

63 

64def _clean_proxy_headers(raw_headers) -> dict: 

65 """Return a copy of *raw_headers* without the encoding, server and date headers.""" 

66 return {k: v for k, v in raw_headers.items() if k.lower() not in _STRIP_PROXY_HEADERS} 

67 

68 

69async def send_get_request( 

70 url: str, 

71 key: str | None = None, 

72 user: UserModel | None = None, 

73): 

74 """Issue a GET request to an Ollama backend and return JSON, or *None* on failure.""" 

75 try: 

76 session = await get_session() 

77 headers: dict = { 

78 'Content-Type': 'application/json', 

79 } 

80 if key: 80 ↛ 81line 80 didn't jump to line 81 because the condition on line 80 was never true

81 headers['Authorization'] = f'Bearer {key}' 

82 if ENABLE_FORWARD_USER_INFO_HEADERS and user: 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true

83 headers = include_user_info_headers(headers, user) 

84 

85 async with session.get( 

86 url, 

87 headers=headers, 

88 ssl=AIOHTTP_CLIENT_SESSION_SSL, 

89 timeout=_MODEL_LIST_TIMEOUT, 

90 ) as r: 

91 return await r.json(loads=JSONCodec.loads) 

92 except Exception as exc: 

93 log.error(f'Connection error: {exc}') 

94 return None 

95 

96 

97async def send_request( 

98 url: str, 

99 method: str = 'POST', 

100 *, 

101 payload: Union[str, bytes | None] = None, 

102 key: str | None = None, 

103 user: UserModel = None, 

104 stream: bool = False, 

105 # passthrough must stay False for /api/chat: middleware parses it per line 

106 passthrough: bool = False, 

107 content_type: str | None = None, 

108 metadata: dict | None = None, 

109 api_config: dict | None = None, 

110 request: Request | None = None, 

111): 

112 r = None 

113 streaming = False 

114 try: 

115 session = await get_session() 

116 

117 headers = { 

118 'Content-Type': 'application/json', 

119 **({'Authorization': f'Bearer {key}'} if key else {}), 

120 } 

121 

122 if ENABLE_FORWARD_USER_INFO_HEADERS and user: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 headers = include_user_info_headers(headers, user, request=request) 

124 if metadata and metadata.get('chat_id'): 

125 headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get('chat_id') 

126 

127 # Custom per-connection headers last so admin-set headers take precedence. 

128 if api_config and api_config.get('headers'): 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true

129 headers.update(await get_custom_headers(api_config['headers'], user, metadata, request=request)) 

130 

131 r = await session.request( 

132 method, 

133 url, 

134 data=payload, 

135 headers=headers, 

136 ssl=AIOHTTP_CLIENT_SESSION_SSL, 

137 timeout=get_client_timeout(stream=stream), 

138 ) 

139 

140 if not r.ok: 

141 try: 

142 res = await r.json(loads=JSONCodec.loads) 

143 await publish_model_provider_request_failed( 

144 request, 

145 actor=user, 

146 provider='ollama', 

147 base_url=url, 

148 status=r.status, 

149 upstream_error=res, 

150 ) 

151 if 'error' in res: 

152 raise HTTPException(status_code=r.status, detail=res['error']) 

153 except HTTPException: 

154 raise 

155 except Exception as e: 

156 log.error(f'Failed to parse error response: {e}') 

157 await publish_model_provider_request_failed( 

158 request, 

159 actor=user, 

160 provider='ollama', 

161 base_url=url, 

162 status=r.status, 

163 ) 

164 raise HTTPException( 

165 status_code=r.status, 

166 detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR, 

167 ) 

168 

169 r.raise_for_status() 

170 

171 if stream: 

172 response_headers = _clean_proxy_headers(r.headers) 

173 if content_type: 

174 response_headers['Content-Type'] = content_type 

175 

176 streaming = True 

177 return StreamingResponse( 

178 stream_wrapper(r, passthrough=passthrough), 

179 status_code=r.status, 

180 headers=response_headers, 

181 ) 

182 else: 

183 try: 

184 return await r.json(loads=JSONCodec.loads) 

185 except Exception: 

186 return None 

187 

188 except HTTPException: 

189 raise 

190 except Exception as e: 

191 raise HTTPException( 

192 status_code=r.status if r else 500, 

193 detail=f'Ollama: {e}' if str(e) else ERROR_MESSAGES.SERVER_CONNECTION_ERROR, 

194 ) 

195 finally: 

196 if not streaming: 

197 await cleanup_response(r) 

198 

199 

200def get_api_key(idx, url, configs): 

201 parsed_url = urlparse(url) 

202 base_url = f'{parsed_url.scheme}://{parsed_url.netloc}' 

203 return configs.get(str(idx), configs.get(base_url, {})).get('key', None) # Legacy support 

204 

205 

206########################################## 

207# 

208# API routes 

209# 

210########################################## 

211 

212router = APIRouter() 

213 

214OLLAMA_CONFIG_KEYS = { 

215 'ENABLE_OLLAMA_API': 'ollama.enable', 

216 'OLLAMA_BASE_URLS': 'ollama.base_urls', 

217 'OLLAMA_API_CONFIGS': 'ollama.api_configs', 

218} 

219 

220 

221async def get_ollama_config_values() -> dict: 

222 values = await Config.get_many(*OLLAMA_CONFIG_KEYS.values()) 

223 return {field: values[storage_key] for field, storage_key in OLLAMA_CONFIG_KEYS.items() if storage_key in values} 

224 

225 

226async def get_ollama_runtime_config() -> tuple[bool, list[str], dict]: 

227 values = await Config.get_many('ollama.enable', 'ollama.base_urls', 'ollama.api_configs') 

228 return ( 

229 values.get('ollama.enable'), 

230 values.get('ollama.base_urls') or [], 

231 values.get('ollama.api_configs') or {}, 

232 ) 

233 

234 

235async def get_ollama_connection(idx: int) -> tuple[str, dict, str | None]: 

236 _, base_urls, api_configs = await get_ollama_runtime_config() 

237 url = base_urls[idx] 

238 return url, resolve_api_config(api_configs, idx, url), get_api_key(idx, url, api_configs) 

239 

240 

241@router.head('/') 

242@router.get('/') 

243async def get_status() -> dict: 

244 """Health-check endpoint.""" 

245 return {'status': True} 

246 

247 

248class ConnectionVerificationForm(BaseModel): 

249 url: str 

250 key: str | None = None 

251 

252 

253@router.post('/verify') 

254async def verify_connection( 

255 form_data: ConnectionVerificationForm, 

256 user=Depends(get_admin_user), 

257): 

258 """Verify that an Ollama backend at *form_data.url* is reachable.""" 

259 try: 

260 session = await get_session() 

261 headers: dict = {} 

262 if form_data.key: 

263 headers['Authorization'] = f'Bearer {form_data.key}' 

264 if ENABLE_FORWARD_USER_INFO_HEADERS and user: 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true

265 headers = include_user_info_headers(headers, user) 

266 

267 async with session.get( 

268 f'{form_data.url}/api/version', 

269 headers=headers, 

270 ssl=AIOHTTP_CLIENT_SESSION_SSL, 

271 timeout=_MODEL_LIST_TIMEOUT, 

272 ) as r: 

273 if r.status != 200: 

274 detail = f'HTTP Error: {r.status}' 

275 res = await r.json(loads=JSONCodec.loads) 

276 if 'error' in res: 

277 detail = f'External Error: {res["error"]}' 

278 raise Exception(detail) 

279 

280 return await r.json(loads=JSONCodec.loads) 

281 except aiohttp.ClientError as exc: 

282 log.exception(f'Client error: {exc}') 

283 raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR) 

284 except Exception as exc: 

285 log.exception(f'Unexpected error: {exc}') 

286 raise HTTPException(status_code=500, detail=f'Unexpected error: {exc}') 

287 

288 

289@router.get('/config') 

290async def get_config( 

291 request: Request, 

292 user=Depends(get_admin_user), 

293) -> dict: 

294 """Return the current Ollama connection configuration.""" 

295 return await get_ollama_config_values() 

296 

297 

298class OllamaConfigForm(BaseModel): 

299 """Payload for updating the Ollama connection configuration.""" 

300 

301 ENABLE_OLLAMA_API: bool | None = None 

302 OLLAMA_BASE_URLS: list[str] 

303 OLLAMA_API_CONFIGS: dict 

304 

305 

306@router.post('/config/update') 

307async def update_config( 

308 request: Request, 

309 form_data: OllamaConfigForm, 

310 user=Depends(get_admin_user), 

311) -> dict: 

312 """Persist updated Ollama connection settings.""" 

313 valid_keys = {str(i) for i in range(len(form_data.OLLAMA_BASE_URLS))} 

314 api_configs = {k: v for k, v in form_data.OLLAMA_API_CONFIGS.items() if k in valid_keys} 

315 

316 await Config.upsert( 

317 { 

318 'ollama.enable': form_data.ENABLE_OLLAMA_API, 

319 'ollama.base_urls': form_data.OLLAMA_BASE_URLS, 

320 'ollama.api_configs': api_configs, 

321 } 

322 ) 

323 

324 await get_all_models.cache.clear() 

325 redis = getattr(request.app.state, 'redis', None) 

326 if redis is not None: 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true

327 await redis.delete(BASE_MODELS_CACHE_KEY) 

328 request.app.state.BASE_MODELS = [] 

329 request.app.state.OLLAMA_MODELS = {} 

330 models = getattr(request.app.state, 'MODELS', None) 

331 if hasattr(models, 'clear'): 331 ↛ 334line 331 didn't jump to line 334 because the condition on line 331 was always true

332 models.clear() 

333 else: 

334 request.app.state.MODELS = {} 

335 

336 await publish_event( 

337 request, 

338 EVENTS.MODEL_PROVIDER_CONFIG_UPDATED, 

339 actor=user, 

340 subject_id='ollama', 

341 subject_type='model.provider_config', 

342 data={ 

343 'provider': 'ollama', 

344 'enabled': form_data.ENABLE_OLLAMA_API, 

345 'base_url_count': len(form_data.OLLAMA_BASE_URLS), 

346 }, 

347 ) 

348 return { 

349 'ENABLE_OLLAMA_API': form_data.ENABLE_OLLAMA_API, 

350 'OLLAMA_BASE_URLS': form_data.OLLAMA_BASE_URLS, 

351 'OLLAMA_API_CONFIGS': api_configs, 

352 } 

353 

354 

355def merge_models_lists(model_lists) -> list[dict]: 

356 """De-duplicate model entries across multiple Ollama backends, tracking which URL index hosts each model.""" 

357 merged: dict[str, dict] = {} 

358 for idx, entries in enumerate(model_lists): 

359 if entries is None: 359 ↛ 361line 359 didn't jump to line 361 because the condition on line 359 was always true

360 continue 

361 for entry in entries: 

362 model_id = entry.get('model') 

363 if model_id is None: 

364 continue 

365 if model_id not in merged: 

366 entry['urls'] = [idx] 

367 merged[model_id] = entry 

368 else: 

369 merged[model_id]['urls'].append(idx) 

370 return list(merged.values()) 

371 

372 

373def resolve_api_config(api_configs: dict, idx: int, url: str) -> dict: 

374 """Look up the API config for a backend by numeric index, falling back to URL key (legacy).""" 

375 return api_configs.get(str(idx), api_configs.get(url, {})) 

376 

377 

378async def get_ollama_connection_config() -> tuple[list, dict]: 

379 """Base URLs and per-connection API configs in one batched SELECT.""" 

380 config = await Config.get_many('ollama.base_urls', 'ollama.api_configs') 

381 return config.get('ollama.base_urls', []), config.get('ollama.api_configs', {}) 

382 

383 

384@cached( 

385 ttl=MODELS_CACHE_TTL, 

386 # key_builder (not key) is the per-call hook in aiocache 0.12; `key=` is a 

387 # static key, so a `key=lambda` collapsed every caller to one shared entry. 

388 key_builder=lambda _func, request, user=None: f'ollama_all_models_{user.id}' if user else 'ollama_all_models', 

389) 

390async def get_all_models(request: Request, user: UserModel | None = None): 

391 """Aggregate model tags from every enabled Ollama backend.""" 

392 log.info('get_all_models()') 

393 

394 if not await Config.get('ollama.enable'): 

395 models_dict: dict = {'models': []} 

396 request.app.state.OLLAMA_MODELS = {} 

397 return models_dict 

398 

399 # Fan-out tag requests to every backend 

400 tasks = [] 

401 base_urls = await Config.get('ollama.base_urls', []) 

402 api_configs = await Config.get('ollama.api_configs', {}) 

403 for idx, url in enumerate(base_urls): 

404 api_config = resolve_api_config(api_configs, idx, url) 

405 if not api_config: 405 ↛ 407line 405 didn't jump to line 407 because the condition on line 405 was always true

406 tasks.append(send_get_request(f'{url}/api/tags', user=user)) 

407 elif api_config.get('enable', True): 

408 tasks.append(send_get_request(f'{url}/api/tags', api_config.get('key'), user=user)) 

409 else: 

410 tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) 

411 

412 responses = await asyncio.gather(*tasks) 

413 

414 # Track which backends failed so we can skip them for /api/ps 

415 failed_idxs: set[int] = set() 

416 

417 # Post-process each response: apply prefix_id, tags, model filtering 

418 for idx, response in enumerate(responses): 

419 if not response: 419 ↛ 422line 419 didn't jump to line 422 because the condition on line 419 was always true

420 failed_idxs.add(idx) 

421 continue 

422 url = base_urls[idx] 

423 api_config = resolve_api_config(api_configs, idx, url) 

424 

425 connection_type = api_config.get('connection_type', 'local') 

426 prefix_id = api_config.get('prefix_id') 

427 allowed_tags = api_config.get('tags', []) 

428 allowed_model_ids = api_config.get('model_ids', []) 

429 

430 if allowed_model_ids and 'models' in response: 

431 response['models'] = [m for m in response['models'] if m['model'] in allowed_model_ids] 

432 

433 for m in response.get('models', []): 

434 if prefix_id: 

435 m['model'] = f'{prefix_id}.{m["model"]}' 

436 if m.get('name'): 

437 m['name'] = f'{prefix_id}.{m["name"]}' 

438 if allowed_tags: 

439 m['tags'] = allowed_tags 

440 if connection_type: 

441 m['connection_type'] = connection_type 

442 

443 models_dict = {'models': merge_models_lists(r.get('models', []) if r else None for r in responses)} 

444 

445 # Annotate with expiry info from loaded-model state 

446 try: 

447 loaded = await get_ollama_loaded_models(request, user=user, skip_idxs=failed_idxs) 

448 expires_map = {m['model']: m['expires_at'] for m in loaded['models'] if 'expires_at' in m} 

449 for m in models_dict['models']: 449 ↛ 450line 449 didn't jump to line 450 because the loop on line 449 never started

450 if m['model'] in expires_map: 

451 dt = datetime.fromisoformat(expires_map[m['model']]) 

452 m['expires_at'] = int(dt.timestamp()) 

453 except Exception as exc: 

454 log.debug('Failed to get loaded models: %s', exc) 

455 

456 request.app.state.OLLAMA_MODELS = {m['model']: m for m in models_dict['models']} 

457 return models_dict 

458 

459 

460async def get_filtered_models(models, user, db=None): 

461 """Return only the models the given *user* is allowed to access.""" 

462 model_ids = [m['model'] for m in models.get('models', [])] 

463 model_infos = {mi.id: mi for mi in await Models.get_models_by_ids(model_ids, db=db)} 

464 user_group_ids = {g.id for g in await Groups.get_groups_by_member_id(user.id, db=db)} 

465 

466 accessible_ids = await AccessGrants.get_accessible_resource_ids( 

467 user_id=user.id, 

468 resource_type='model', 

469 resource_ids=list(model_infos.keys()), 

470 permission='read', 

471 user_group_ids=user_group_ids, 

472 db=db, 

473 ) 

474 return [ 

475 m 

476 for m in models.get('models', []) 

477 if (mi := model_infos.get(m['model'])) and (user.id == mi.user_id or mi.id in accessible_ids) 

478 ] 

479 

480 

481@router.get('/api/tags') 

482@router.get('/api/tags/{url_idx}') 

483async def get_ollama_tags( 

484 request: Request, 

485 url_idx: int | None = None, 

486 user=Depends(get_verified_user), 

487): 

488 """List Ollama model tags, optionally from a specific backend.""" 

489 if url_idx is not None and user.role != 'admin': 489 ↛ 490line 489 didn't jump to line 490 because the condition on line 489 was never true

490 raise HTTPException(status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) 

491 

492 if not await Config.get('ollama.enable'): 492 ↛ 495line 492 didn't jump to line 495 because the condition on line 492 was always true

493 raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) 

494 

495 if url_idx is None: 

496 result = await get_all_models(request, user=user) 

497 else: 

498 url = (await Config.get('ollama.base_urls', []))[url_idx] 

499 key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))) 

500 result = await send_request(f'{url}/api/tags', 'GET', key=key, user=user) 

501 

502 if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL: 

503 result['models'] = await get_filtered_models(result, user) 

504 

505 return result 

506 

507 

508@router.get('/api/ps') 

509async def get_ollama_loaded_models( 

510 request: Request, 

511 user=Depends(get_admin_user), 

512 skip_idxs: set[int] | None = None, 

513) -> dict: 

514 """List models currently loaded in Ollama memory across all backends.""" 

515 if not await Config.get('ollama.enable'): 

516 return {'models': []} 

517 

518 tasks = [] 

519 base_urls = await Config.get('ollama.base_urls', []) 

520 api_configs = await Config.get('ollama.api_configs', {}) 

521 for idx, url in enumerate(base_urls): 

522 if skip_idxs and idx in skip_idxs: 522 ↛ 525line 522 didn't jump to line 525 because the condition on line 522 was always true

523 tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) 

524 continue 

525 api_config = resolve_api_config(api_configs, idx, url) 

526 if not api_config: 

527 tasks.append(send_get_request(f'{url}/api/ps', user=user)) 

528 elif api_config.get('enable', True): 

529 tasks.append(send_get_request(f'{url}/api/ps', api_config.get('key'), user=user)) 

530 else: 

531 tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) 

532 

533 responses = await asyncio.gather(*tasks) 

534 

535 for idx, response in enumerate(responses): 

536 if not response: 536 ↛ 538line 536 didn't jump to line 538 because the condition on line 536 was always true

537 continue 

538 api_config = resolve_api_config(api_configs, idx, base_urls[idx]) 

539 prefix_id = api_config.get('prefix_id') 

540 if prefix_id: 

541 for m in response.get('models', []): 

542 m['model'] = f'{prefix_id}.{m["model"]}' 

543 

544 return {'models': merge_models_lists(r.get('models', []) if r else None for r in responses)} 

545 

546 

547@router.get('/api/version') 

548@router.get('/api/version/{url_idx}') 

549async def get_ollama_versions( 

550 request: Request, 

551 user=Depends(get_verified_user), 

552 url_idx: int | None = None, 

553): 

554 """Return the lowest Ollama version across all configured backends.""" 

555 if url_idx is not None and user.role != 'admin': 555 ↛ 556line 555 didn't jump to line 556 because the condition on line 555 was never true

556 raise HTTPException(status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) 

557 

558 if not await Config.get('ollama.enable'): 

559 return {'version': False} 

560 

561 if url_idx is not None: 

562 url = (await Config.get('ollama.base_urls', []))[url_idx] 

563 return await send_request(f'{url}/api/version', 'GET') 

564 

565 # Fan-out to every enabled backend 

566 tasks = [] 

567 for idx, url in enumerate(await Config.get('ollama.base_urls', [])): 

568 api_config = (await Config.get('ollama.api_configs', {})).get( 

569 str(idx), 

570 (await Config.get('ollama.api_configs', {})).get(url, {}), 

571 ) 

572 if api_config.get('enable', True): 572 ↛ 567line 572 didn't jump to line 567 because the condition on line 572 was always true

573 tasks.append(send_get_request(f'{url}/api/version', api_config.get('key'))) 

574 

575 raw = await asyncio.gather(*tasks) 

576 valid = [r for r in raw if r is not None] 

577 

578 if not valid: 578 ↛ 581line 578 didn't jump to line 581 because the condition on line 578 was always true

579 raise HTTPException(status_code=500, detail=ERROR_MESSAGES.OLLAMA_NOT_FOUND) 

580 

581 lowest = min( 

582 valid, 

583 key=lambda v: tuple(map(int, re.sub(r'^v|-.*', '', v['version']).split('.'))), 

584 ) 

585 return {'version': lowest['version']} 

586 

587 

588class ModelNameForm(BaseModel): 

589 """Generic form carrying an optional model identifier.""" 

590 

591 model: str | None = None 

592 model_config = ConfigDict(extra='allow') 

593 

594 

595@router.post('/api/unload') 

596async def unload_model( 

597 request: Request, 

598 form_data: ModelNameForm, 

599 user=Depends(get_admin_user), 

600): 

601 form_data = form_data.model_dump(exclude_none=True) 

602 model = form_data.get('model', form_data.get('name')) 

603 

604 if not model: 

605 raise HTTPException(status_code=400, detail='Missing name of the model to unload.') 

606 

607 # Refresh/load models if needed, get mapping from name to URLs 

608 await get_all_models(request, user=user) 

609 models = request.app.state.OLLAMA_MODELS 

610 

611 if model not in models: 611 ↛ 613line 611 didn't jump to line 613 because the condition on line 611 was always true

612 raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model)) 

613 url_indices = models[model]['urls'] 

614 

615 # Send unload to ALL url_indices 

616 results = [] 

617 errors = [] 

618 for idx in url_indices: 

619 url = (await Config.get('ollama.base_urls', []))[idx] 

620 api_config = (await Config.get('ollama.api_configs', {})).get( 

621 str(idx), (await Config.get('ollama.api_configs', {})).get(url, {}) 

622 ) 

623 key = get_api_key(idx, url, (await Config.get('ollama.api_configs', {}))) 

624 

625 prefix_id = api_config.get('prefix_id', None) 

626 if prefix_id and model.startswith(f'{prefix_id}.'): 

627 model = model[len(f'{prefix_id}.') :] 

628 

629 payload = {'model': model, 'keep_alive': 0, 'prompt': ''} 

630 

631 try: 

632 res = await send_request( 

633 f'{url}/api/generate', 

634 payload=JSONCodec.dumps(payload), 

635 key=key, 

636 user=user, 

637 ) 

638 results.append({'url_idx': idx, 'success': True, 'response': res}) 

639 except Exception as e: 

640 log.exception(f'Failed to unload model on node {idx}: {e}') 

641 errors.append({'url_idx': idx, 'success': False, 'error': str(e)}) 

642 

643 if len(errors) > 0: 

644 raise HTTPException( 

645 status_code=500, 

646 detail=f'Failed to unload model on {len(errors)} nodes: {errors}', 

647 ) 

648 

649 return {'status': True} 

650 

651 

652@router.post('/api/pull') 

653@router.post('/api/pull/{url_idx}') 

654async def pull_model( 

655 request: Request, 

656 form_data: ModelNameForm, 

657 url_idx: int = 0, 

658 user=Depends(get_admin_user), 

659): 

660 if not await Config.get('ollama.enable'): 

661 raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) 

662 

663 form_data = form_data.model_dump(exclude_none=True) 

664 form_data['model'] = form_data.get('model', form_data.get('name')) 

665 

666 url = (await Config.get('ollama.base_urls', []))[url_idx] 

667 log.info('url: %s', url) 

668 

669 # Admins may pull from any registry 

670 return await send_request( 

671 f'{url}/api/pull', 

672 payload=JSONCodec.dumps({**form_data, 'insecure': True}), 

673 key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), 

674 user=user, 

675 stream=True, 

676 passthrough=True, 

677 ) 

678 

679 

680class PushModelForm(BaseModel): 

681 """Payload for pushing a model to a registry.""" 

682 

683 model: str 

684 insecure: bool | None = None 

685 stream: bool | None = None 

686 

687 

688@router.delete('/api/push') 

689@router.delete('/api/push/{url_idx}') 

690async def push_model( 

691 request: Request, 

692 form_data: PushModelForm, 

693 url_idx: int | None = None, 

694 user=Depends(get_admin_user), 

695): 

696 """Push a local model to a remote registry.""" 

697 if not await Config.get('ollama.enable'): 

698 raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) 

699 

700 if url_idx is None: 700 ↛ 707line 700 didn't jump to line 707 because the condition on line 700 was always true

701 await get_all_models(request, user=user) 

702 models = request.app.state.OLLAMA_MODELS 

703 if form_data.model not in models: 703 ↛ 705line 703 didn't jump to line 705 because the condition on line 703 was always true

704 raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model)) 

705 url_idx = models[form_data.model]['urls'][0] 

706 

707 url = (await Config.get('ollama.base_urls', []))[url_idx] 

708 log.debug('url: %s', url) 

709 

710 return await send_request( 

711 f'{url}/api/push', 

712 payload=form_data.model_dump_json(exclude_none=True).encode(), 

713 key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), 

714 user=user, 

715 stream=True, 

716 passthrough=True, 

717 ) 

718 

719 

720class CreateModelForm(BaseModel): 

721 """Payload for creating a new model via Modelfile.""" 

722 

723 model: str | None = None 

724 stream: bool | None = None 

725 path: str | None = None 

726 model_config = ConfigDict(extra='allow') 

727 

728 

729@router.post('/api/create') 

730@router.post('/api/create/{url_idx}') 

731async def create_model( 

732 request: Request, 

733 form_data: CreateModelForm, 

734 url_idx: int = 0, 

735 user=Depends(get_admin_user), 

736): 

737 if not await Config.get('ollama.enable'): 

738 raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) 

739 

740 log.debug('form_data: %s', form_data) 

741 url = (await Config.get('ollama.base_urls', []))[url_idx] 

742 

743 return await send_request( 

744 f'{url}/api/create', 

745 payload=form_data.model_dump_json(exclude_none=True).encode(), 

746 key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), 

747 user=user, 

748 stream=True, 

749 passthrough=True, 

750 ) 

751 

752 

753class CopyModelForm(BaseModel): 

754 """Payload for duplicating an existing model under a new name.""" 

755 

756 source: str 

757 destination: str 

758 

759 

760@router.post('/api/copy') 

761@router.post('/api/copy/{url_idx}') 

762async def copy_model( 

763 request: Request, 

764 form_data: CopyModelForm, 

765 url_idx: int | None = None, 

766 user=Depends(get_admin_user), 

767): 

768 """Duplicate an existing model under a new name.""" 

769 if not await Config.get('ollama.enable'): 

770 raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) 

771 

772 if url_idx is None: 

773 await get_all_models(request, user=user) 

774 models = request.app.state.OLLAMA_MODELS 

775 if form_data.source not in models: 775 ↛ 777line 775 didn't jump to line 777 because the condition on line 775 was always true

776 raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.source)) 

777 url_idx = models[form_data.source]['urls'][0] 

778 

779 url = (await Config.get('ollama.base_urls', []))[url_idx] 

780 key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))) 

781 

782 await send_request( 

783 f'{url}/api/copy', 

784 payload=form_data.model_dump_json(exclude_none=True).encode(), 

785 key=key, 

786 user=user, 

787 ) 

788 await publish_event( 

789 request, 

790 EVENTS.MODEL_PROVIDER_MODEL_CREATED, 

791 actor=user, 

792 subject_id=form_data.destination, 

793 data={'provider': 'ollama', 'source': form_data.source, 'url_idx': url_idx}, 

794 ) 

795 return True 

796 

797 

798@router.delete('/api/delete') 

799@router.delete('/api/delete/{url_idx}') 

800async def delete_model( 

801 request: Request, 

802 form_data: ModelNameForm, 

803 url_idx: int | None = None, 

804 user=Depends(get_admin_user), 

805): 

806 """Remove a model from an Ollama backend.""" 

807 if not await Config.get('ollama.enable'): 

808 raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) 

809 

810 payload = form_data.model_dump(exclude_none=True) 

811 payload['model'] = payload.get('model', payload.get('name')) 

812 model = payload.get('model') 

813 

814 if url_idx is None: 

815 await get_all_models(request, user=user) 

816 models = request.app.state.OLLAMA_MODELS 

817 if model not in models: 817 ↛ 819line 817 didn't jump to line 819 because the condition on line 817 was always true

818 raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model)) 

819 url_idx = models[model]['urls'][0] 

820 

821 url = (await Config.get('ollama.base_urls', []))[url_idx] 

822 key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))) 

823 

824 await send_request( 

825 f'{url}/api/delete', 

826 'DELETE', 

827 payload=JSONCodec.dumps(payload), 

828 key=key, 

829 user=user, 

830 ) 

831 await publish_event( 

832 request, 

833 EVENTS.MODEL_PROVIDER_MODEL_DELETED, 

834 actor=user, 

835 subject_id=model, 

836 data={'provider': 'ollama', 'url_idx': url_idx}, 

837 ) 

838 return True 

839 

840 

841@router.post('/api/show') 

842async def show_model_info( 

843 request: Request, 

844 form_data: ModelNameForm, 

845 user=Depends(get_verified_user), 

846): 

847 """Retrieve model metadata from the Ollama backend.""" 

848 if not await Config.get('ollama.enable'): 

849 raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) 

850 

851 payload = form_data.model_dump(exclude_none=True) 

852 payload['model'] = payload.get('model', payload.get('name')) 

853 model = payload.get('model') 

854 

855 await check_model_access(user, await Models.get_model_by_id(model), BYPASS_MODEL_ACCESS_CONTROL) 

856 

857 await get_all_models(request, user=user) 

858 models = request.app.state.OLLAMA_MODELS 

859 

860 if model not in models: 860 ↛ 863line 860 didn't jump to line 863 because the condition on line 860 was always true

861 raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model)) 

862 

863 url_idx = random.choice(models[model]['urls']) 

864 url = (await Config.get('ollama.base_urls', []))[url_idx] 

865 key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))) 

866 

867 return await send_request( 

868 f'{url}/api/show', 

869 payload=JSONCodec.dumps(payload), 

870 key=key, 

871 user=user, 

872 ) 

873 

874 

875class GenerateEmbedForm(BaseModel): 

876 """Payload for the newer /api/embed endpoint (batch-capable).""" 

877 

878 model: str 

879 input: list[str] | str 

880 truncate: bool | None = None 

881 options: dict | None = None 

882 keep_alive: Union[int, str | None] = None 

883 model_config = ConfigDict(extra='allow') 

884 

885 

886@router.post('/api/embed') 

887@router.post('/api/embed/{url_idx}') 

888async def embed( 

889 request: Request, 

890 form_data: GenerateEmbedForm, 

891 url_idx: int | None = None, 

892 user=Depends(get_verified_user), 

893): 

894 """Generate embeddings via the Ollama /api/embed endpoint.""" 

895 if not await Config.get('ollama.enable'): 

896 raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) 

897 

898 log.info('generate_ollama_batch_embeddings %s', form_data) 

899 await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL) 

900 await validate_ollama_backend_idx(request, form_data.model, url_idx, user) 

901 

902 if url_idx is None: 

903 model = form_data.model 

904 models = request.app.state.OLLAMA_MODELS 

905 if not models or model not in models: 905 ↛ 908line 905 didn't jump to line 908 because the condition on line 905 was always true

906 await get_all_models(request, user=user) 

907 models = request.app.state.OLLAMA_MODELS 

908 if model not in models: 908 ↛ 910line 908 didn't jump to line 910 because the condition on line 908 was always true

909 raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model)) 

910 url_idx = random.choice(models[model]['urls']) 

911 

912 base_urls, api_configs = await get_ollama_connection_config() 

913 url = base_urls[url_idx] 

914 api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) 

915 key = get_api_key(url_idx, url, api_configs) 

916 

917 prefix_id = api_config.get('prefix_id') 

918 form_data.model = strip_provider_model_prefix(form_data.model, prefix_id) 

919 

920 return await send_request( 

921 f'{url}/api/embed', 

922 payload=form_data.model_dump_json(exclude_none=True).encode(), 

923 key=key, 

924 user=user, 

925 ) 

926 

927 

928class GenerateEmbeddingsForm(BaseModel): 

929 """Payload for the legacy /api/embeddings endpoint (single-prompt).""" 

930 

931 model: str 

932 prompt: str 

933 options: dict | None = None 

934 keep_alive: Union[int, str | None] = None 

935 

936 

937@router.post('/api/embeddings') 

938@router.post('/api/embeddings/{url_idx}') 

939async def embeddings( 

940 request: Request, 

941 form_data: GenerateEmbeddingsForm, 

942 url_idx: int | None = None, 

943 user=Depends(get_verified_user), 

944): 

945 """Generate embeddings via the legacy Ollama /api/embeddings endpoint.""" 

946 if not await Config.get('ollama.enable'): 

947 raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) 

948 

949 log.info('generate_ollama_embeddings %s', form_data) 

950 await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL) 

951 await validate_ollama_backend_idx(request, form_data.model, url_idx, user) 

952 

953 if url_idx is None: 

954 model = form_data.model 

955 models = request.app.state.OLLAMA_MODELS 

956 if not models or model not in models: 956 ↛ 959line 956 didn't jump to line 959 because the condition on line 956 was always true

957 await get_all_models(request, user=user) 

958 models = request.app.state.OLLAMA_MODELS 

959 if model not in models: 959 ↛ 961line 959 didn't jump to line 961 because the condition on line 959 was always true

960 raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model)) 

961 url_idx = random.choice(models[model]['urls']) 

962 

963 base_urls, api_configs = await get_ollama_connection_config() 

964 url = base_urls[url_idx] 

965 api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) 

966 key = get_api_key(url_idx, url, api_configs) 

967 

968 prefix_id = api_config.get('prefix_id') 

969 form_data.model = strip_provider_model_prefix(form_data.model, prefix_id) 

970 

971 return await send_request( 

972 f'{url}/api/embeddings', 

973 payload=form_data.model_dump_json(exclude_none=True).encode(), 

974 key=key, 

975 user=user, 

976 ) 

977 

978 

979class GenerateCompletionForm(BaseModel): 

980 """Payload for the Ollama /api/generate endpoint.""" 

981 

982 model: str 

983 prompt: str | None = None 

984 suffix: str | None = None 

985 images: list[str] | None = None 

986 format: Union[dict, str | None] = None 

987 options: dict | None = None 

988 system: str | None = None 

989 template: str | None = None 

990 context: list[int] | None = None 

991 stream: bool | None = True 

992 raw: bool | None = None 

993 keep_alive: Union[int, str | None] = None 

994 

995 

996@router.post('/api/generate') 

997@router.post('/api/generate/{url_idx}') 

998async def generate_completion( 

999 request: Request, 

1000 form_data: GenerateCompletionForm, 

1001 url_idx: int | None = None, 

1002 user=Depends(get_verified_user), 

1003): 

1004 """Run text completion via Ollama /api/generate.""" 

1005 if not await Config.get('ollama.enable'): 

1006 raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) 

1007 

1008 await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL) 

1009 await validate_ollama_backend_idx(request, form_data.model, url_idx, user) 

1010 

1011 if url_idx is None: 1011 ↛ 1019line 1011 didn't jump to line 1019 because the condition on line 1011 was always true

1012 await get_all_models(request, user=user) 

1013 models = request.app.state.OLLAMA_MODELS 

1014 model = form_data.model 

1015 if model not in models: 1015 ↛ 1017line 1015 didn't jump to line 1017 because the condition on line 1015 was always true

1016 raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model)) 

1017 url_idx = random.choice(models[model]['urls']) 

1018 

1019 base_urls, api_configs = await get_ollama_connection_config() 

1020 url = base_urls[url_idx] 

1021 api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) 

1022 

1023 prefix_id = api_config.get('prefix_id') 

1024 form_data.model = strip_provider_model_prefix(form_data.model, prefix_id) 

1025 

1026 return await send_request( 

1027 f'{url}/api/generate', 

1028 payload=form_data.model_dump_json(exclude_none=True).encode(), 

1029 key=get_api_key(url_idx, url, api_configs), 

1030 user=user, 

1031 stream=True, 

1032 passthrough=True, 

1033 ) 

1034 

1035 

1036class ChatMessage(BaseModel): 

1037 """A single message in an Ollama chat conversation.""" 

1038 

1039 role: str 

1040 content: str | None = None 

1041 tool_calls: list[dict] | None = None 

1042 images: list[str] | None = None 

1043 model_config = ConfigDict(extra='allow') 

1044 

1045 @validator('content', pre=True) 

1046 @classmethod 

1047 def check_at_least_one_field(cls, field_value, values, **kwargs): 

1048 if field_value is None and ('tool_calls' not in values or values['tool_calls'] is None): 

1049 raise ValueError("At least one of 'content' or 'tool_calls' must be provided") 

1050 return field_value 

1051 

1052 

1053class GenerateChatCompletionForm(BaseModel): 

1054 """Payload for the Ollama /api/chat endpoint.""" 

1055 

1056 model: str 

1057 messages: list[ChatMessage] 

1058 format: Union[dict, str | None] = None 

1059 options: dict | None = None 

1060 template: str | None = None 

1061 stream: bool | None = True 

1062 keep_alive: Union[int, str | None] = None 

1063 tools: list[dict] | None = None 

1064 model_config = ConfigDict(extra='allow') 

1065 

1066 

1067async def validate_ollama_backend_idx(request: Request, model: str, url_idx: int | None, user) -> None: 

1068 # A caller-supplied url_idx must point to a backend the model is actually 

1069 # served from; the None path is already constrained to that allow-list. 

1070 if url_idx is None or user is None or getattr(user, 'role', None) == 'admin': 1070 ↛ 1072line 1070 didn't jump to line 1072 because the condition on line 1070 was always true

1071 return 

1072 models = request.app.state.OLLAMA_MODELS 

1073 if not models or model not in models: 

1074 await get_all_models(request, user=user) 

1075 models = request.app.state.OLLAMA_MODELS 

1076 if url_idx not in (models.get(model) or {}).get('urls', []): 

1077 raise HTTPException(status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) 

1078 

1079 

1080async def get_ollama_url(request: Request, model: str, url_idx: int | None = None, user=None): 

1081 await validate_ollama_backend_idx(request, model, url_idx, user) 

1082 if url_idx is None: 1082 ↛ 1083line 1082 didn't jump to line 1083 because the condition on line 1082 was never true

1083 models = request.app.state.OLLAMA_MODELS 

1084 if not models or model not in models: 

1085 await get_all_models.cache.clear() 

1086 await get_all_models(request, user=user) 

1087 models = request.app.state.OLLAMA_MODELS 

1088 if model not in models: 

1089 raise HTTPException( 

1090 status_code=400, 

1091 detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model), 

1092 ) 

1093 url_idx = random.choice(models[model].get('urls', [])) 

1094 url = (await Config.get('ollama.base_urls', []))[url_idx] 

1095 return url, url_idx 

1096 

1097 

1098@router.post('/api/chat') 

1099@router.post('/api/chat/{url_idx}') 

1100async def generate_chat_completion( 

1101 request: Request, 

1102 form_data: dict, 

1103 url_idx: int | None = None, 

1104 user=Depends(get_verified_user), # noqa: B008 

1105): 

1106 """Forward a chat completion request to an Ollama backend.""" 

1107 if not await Config.get('ollama.enable'): 

1108 raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) 

1109 

1110 # NOTE: We intentionally do NOT use Depends(get_async_session) here. 

1111 # Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions. 

1112 # This prevents holding a connection during the entire LLM call (30-60+ seconds), 

1113 # which would exhaust the connection pool under concurrent load. 

1114 

1115 # bypass_filter and bypass_system_prompt are read from request.state to prevent 

1116 # external clients from setting them via query parameter. Only internal 

1117 # server-side callers (e.g. utils/chat.py) should set 

1118 # request.state.bypass_filter / request.state.bypass_system_prompt = True. 

1119 bypass_filter = getattr(request.state, 'bypass_filter', False) 

1120 if BYPASS_MODEL_ACCESS_CONTROL: 1120 ↛ 1121line 1120 didn't jump to line 1121 because the condition on line 1120 was never true

1121 bypass_filter = True 

1122 bypass_system_prompt = getattr(request.state, 'bypass_system_prompt', False) 

1123 

1124 metadata = form_data.pop('metadata', None) 

1125 try: 

1126 form_data = GenerateChatCompletionForm(**form_data) 

1127 except Exception as exc: 

1128 log.exception(exc) 

1129 raise HTTPException(status_code=400, detail=str(exc)) 

1130 

1131 if isinstance(form_data, BaseModel): 

1132 payload = form_data.model_dump(exclude_none=True) 

1133 

1134 payload.pop('metadata', None) 

1135 

1136 model_id = payload['model'] 

1137 model_info = await Models.get_model_by_id(model_id) 

1138 

1139 if model_info is not None: 

1140 if model_info.base_model_id: 

1141 base_model_id = request.base_model_id if hasattr(request, 'base_model_id') else model_info.base_model_id 

1142 payload['model'] = base_model_id 

1143 

1144 params = model_info.params.model_dump() 

1145 if params: 

1146 system = params.pop('system', None) 

1147 payload = apply_model_params_to_body_ollama(params, payload) 

1148 if not bypass_system_prompt: 

1149 payload = await apply_system_prompt_to_body(system, payload, metadata, user) 

1150 

1151 await check_model_access(user, model_info, bypass_filter) 

1152 else: 

1153 await check_model_access(user, None, bypass_filter) 

1154 

1155 url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) 

1156 api_configs = await Config.get('ollama.api_configs', {}) 

1157 api_config = resolve_api_config(api_configs, url_idx, url) 

1158 

1159 prefix_id = api_config.get('prefix_id') 

1160 payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id) 

1161 

1162 return await send_request( 

1163 f'{url}/api/chat', 

1164 payload=JSONCodec.dumps(payload), 

1165 key=get_api_key(url_idx, url, api_configs), 

1166 user=user, 

1167 stream=form_data.stream, 

1168 content_type='application/x-ndjson', 

1169 metadata=metadata, 

1170 api_config=api_config, 

1171 request=request, 

1172 ) 

1173 

1174 

1175# TODO: we should update this part once Ollama supports other types 

1176class OpenAIChatMessageContent(BaseModel): 

1177 """Content block within an OpenAI-style chat message.""" 

1178 

1179 type: str 

1180 model_config = ConfigDict(extra='allow') 

1181 

1182 

1183class OpenAIChatMessage(BaseModel): 

1184 """A single message in an OpenAI-compatible chat request.""" 

1185 

1186 role: str 

1187 content: Union[str | None, list[OpenAIChatMessageContent]] 

1188 model_config = ConfigDict(extra='allow') 

1189 

1190 

1191class OpenAIChatCompletionForm(BaseModel): 

1192 """Payload for the OpenAI-compatible /v1/chat/completions proxy.""" 

1193 

1194 model: str 

1195 messages: list[OpenAIChatMessage] 

1196 model_config = ConfigDict(extra='allow') 

1197 

1198 

1199class OpenAICompletionForm(BaseModel): 

1200 """Payload for the OpenAI-compatible /v1/completions proxy.""" 

1201 

1202 model: str 

1203 prompt: str 

1204 model_config = ConfigDict(extra='allow') 

1205 

1206 

1207class OpenAIEmbeddingsForm(BaseModel): 

1208 """Payload for the OpenAI-compatible /v1/embeddings proxy.""" 

1209 

1210 model: str 

1211 input: object 

1212 model_config = ConfigDict(extra='allow') 

1213 

1214 

1215@router.post('/v1/completions') 

1216@router.post('/v1/completions/{url_idx}') 

1217async def generate_openai_completion( 

1218 request: Request, 

1219 form_data: dict, 

1220 url_idx: int | None = None, 

1221 user=Depends(get_verified_user), # noqa: B008 

1222): 

1223 """Forward a text completion request via the OpenAI-compatible proxy.""" 

1224 # NOTE: We intentionally do NOT use Depends(get_async_session) here. 

1225 # Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions. 

1226 # This prevents holding a connection during the entire LLM call (30-60+ seconds), 

1227 # which would exhaust the connection pool under concurrent load. 

1228 metadata = form_data.pop('metadata', None) 

1229 

1230 try: 

1231 form_data = OpenAICompletionForm(**form_data) 

1232 except Exception as exc: 

1233 log.exception(exc) 

1234 raise HTTPException(status_code=400, detail=str(exc)) 

1235 

1236 payload = form_data.model_dump(exclude_none=True, exclude=['metadata']) 

1237 

1238 model_id = form_data.model 

1239 model_info = await Models.get_model_by_id(model_id) 

1240 if model_info is not None: 

1241 if model_info.base_model_id: 

1242 payload['model'] = model_info.base_model_id 

1243 params = model_info.params.model_dump() 

1244 if params: 

1245 payload = apply_model_params_to_body_openai(params, payload) 

1246 await check_model_access(user, model_info) 

1247 else: 

1248 await check_model_access(user, None) 

1249 

1250 url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) 

1251 api_configs = await Config.get('ollama.api_configs', {}) 

1252 api_config = resolve_api_config(api_configs, url_idx, url) 

1253 

1254 prefix_id = api_config.get('prefix_id') 

1255 payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id) 

1256 

1257 return await send_request( 

1258 f'{url}/v1/completions', 

1259 payload=JSONCodec.dumps(payload), 

1260 key=get_api_key(url_idx, url, api_configs), 

1261 user=user, 

1262 stream=payload.get('stream', False), 

1263 passthrough=True, 

1264 metadata=metadata, 

1265 api_config=api_config, 

1266 request=request, 

1267 ) 

1268 

1269 

1270@router.post('/v1/embeddings') 

1271@router.post('/v1/embeddings/{url_idx}') 

1272async def generate_openai_embeddings( 

1273 request: Request, 

1274 form_data: dict, 

1275 url_idx: int | None = None, 

1276 user=Depends(get_verified_user), # noqa: B008 

1277): 

1278 """Forward an embeddings request via the OpenAI-compatible proxy.""" 

1279 if not await Config.get('ollama.enable'): 1279 ↛ 1282line 1279 didn't jump to line 1282 because the condition on line 1279 was always true

1280 raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) 

1281 

1282 metadata = form_data.pop('metadata', None) 

1283 

1284 try: 

1285 form_data = OpenAIEmbeddingsForm(**form_data) 

1286 except Exception as exc: 

1287 log.exception(exc) 

1288 raise HTTPException(status_code=400, detail=str(exc)) 

1289 

1290 payload = form_data.model_dump(exclude_none=True) 

1291 payload.pop('metadata', None) 

1292 

1293 model_id = form_data.model 

1294 model_info = await Models.get_model_by_id(model_id) 

1295 if model_info is not None: 

1296 if model_info.base_model_id: 

1297 payload['model'] = model_info.base_model_id 

1298 await check_model_access(user, model_info) 

1299 else: 

1300 await check_model_access(user, None) 

1301 

1302 url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) 

1303 api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url) 

1304 

1305 prefix_id = api_config.get('prefix_id') 

1306 payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id) 

1307 

1308 return await send_request( 

1309 f'{url}/v1/embeddings', 

1310 payload=JSONCodec.dumps(payload), 

1311 key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), 

1312 user=user, 

1313 metadata=metadata, 

1314 api_config=api_config, 

1315 request=request, 

1316 ) 

1317 

1318 

1319@router.post('/v1/chat/completions') 

1320@router.post('/v1/chat/completions/{url_idx}') 

1321async def generate_openai_chat_completion( 

1322 request: Request, 

1323 form_data: dict, 

1324 url_idx: int | None = None, 

1325 user=Depends(get_verified_user), # noqa: B008 

1326): 

1327 """Forward a chat completion request via the OpenAI-compatible proxy.""" 

1328 # NOTE: We intentionally do NOT use Depends(get_async_session) here. 

1329 # Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions. 

1330 # This prevents holding a connection during the entire LLM call (30-60+ seconds), 

1331 # which would exhaust the connection pool under concurrent load. 

1332 metadata = form_data.pop('metadata', None) 

1333 

1334 try: 

1335 form_data = OpenAIChatCompletionForm(**form_data) 

1336 except Exception as exc: 

1337 log.exception(exc) 

1338 raise HTTPException(status_code=400, detail=str(exc)) 

1339 

1340 payload = form_data.model_dump(exclude_none=True, exclude=['metadata']) 

1341 

1342 model_id = form_data.model 

1343 model_info = await Models.get_model_by_id(model_id) 

1344 if model_info is not None: 

1345 if model_info.base_model_id: 

1346 payload['model'] = model_info.base_model_id 

1347 

1348 params = model_info.params.model_dump() 

1349 if params: 

1350 system = params.pop('system', None) 

1351 payload = apply_model_params_to_body_openai(params, payload) 

1352 payload = await apply_system_prompt_to_body(system, payload, metadata, user) 

1353 

1354 await check_model_access(user, model_info) 

1355 else: 

1356 await check_model_access(user, None) 

1357 

1358 url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) 

1359 api_configs = await Config.get('ollama.api_configs', {}) 

1360 api_config = resolve_api_config(api_configs, url_idx, url) 

1361 

1362 prefix_id = api_config.get('prefix_id') 

1363 payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id) 

1364 

1365 return await send_request( 

1366 f'{url}/v1/chat/completions', 

1367 payload=JSONCodec.dumps(payload), 

1368 key=get_api_key(url_idx, url, api_configs), 

1369 user=user, 

1370 stream=payload.get('stream', False), 

1371 passthrough=True, 

1372 metadata=metadata, 

1373 api_config=api_config, 

1374 request=request, 

1375 ) 

1376 

1377 

1378@router.post('/v1/messages') 

1379@router.post('/v1/messages/{url_idx}') 

1380async def generate_anthropic_messages( 

1381 request: Request, 

1382 form_data: dict, 

1383 url_idx: int | None = None, 

1384 user=Depends(get_verified_user), 

1385): 

1386 """ 

1387 Proxy for Ollama's Anthropic-compatible /v1/messages endpoint. 

1388 

1389 Forwards the request as-is to the Ollama backend, applying the same 

1390 model resolution, access control, and prefix_id handling used by 

1391 the OpenAI-compatible /v1/chat/completions proxy. 

1392 

1393 See https://docs.ollama.com/api/anthropic-compatibility 

1394 """ 

1395 if not await Config.get('ollama.enable'): 1395 ↛ 1398line 1395 didn't jump to line 1398 because the condition on line 1395 was always true

1396 raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) 

1397 

1398 payload = {**form_data} 

1399 model_id = payload.get('model', '') 

1400 

1401 model_info = await Models.get_model_by_id(model_id) 

1402 if model_info: 

1403 if model_info.base_model_id: 

1404 payload['model'] = model_info.base_model_id 

1405 

1406 await check_model_access(user, model_info) 

1407 else: 

1408 await check_model_access(user, None) 

1409 

1410 url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) 

1411 api_configs = await Config.get('ollama.api_configs', {}) 

1412 api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support 

1413 

1414 prefix_id = api_config.get('prefix_id', None) 

1415 payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id) 

1416 

1417 return await send_request( 

1418 f'{url}/v1/messages', 

1419 payload=JSONCodec.dumps(payload), 

1420 key=get_api_key(url_idx, url, api_configs), 

1421 user=user, 

1422 stream=payload.get('stream', False), 

1423 passthrough=True, 

1424 content_type='text/event-stream' if payload.get('stream', False) else None, 

1425 api_config=api_config, 

1426 request=request, 

1427 ) 

1428 

1429 

1430class ResponsesForm(BaseModel): 

1431 model: str 

1432 

1433 model_config = ConfigDict(extra='allow') 

1434 

1435 

1436@router.post('/v1/responses') 

1437@router.post('/v1/responses/{url_idx}') 

1438async def generate_responses( 

1439 request: Request, 

1440 form_data: ResponsesForm, 

1441 url_idx: int | None = None, 

1442 user=Depends(get_verified_user), 

1443): 

1444 """ 

1445 Proxy for Ollama's OpenAI-compatible /v1/responses endpoint. 

1446 

1447 Forwards the request as-is to the Ollama backend, applying the same 

1448 model resolution, access control, and prefix_id handling used by 

1449 the OpenAI-compatible /v1/chat/completions proxy. 

1450 

1451 See https://ollama.com/blog/responses-api 

1452 """ 

1453 if not await Config.get('ollama.enable'): 

1454 raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) 

1455 

1456 payload = form_data.model_dump() 

1457 model_id = form_data.model 

1458 

1459 model_info = await Models.get_model_by_id(model_id) 

1460 if model_info: 1460 ↛ 1461line 1460 didn't jump to line 1461 because the condition on line 1460 was never true

1461 if model_info.base_model_id: 

1462 payload['model'] = model_info.base_model_id 

1463 

1464 await check_model_access(user, model_info) 

1465 else: 

1466 await check_model_access(user, None) 

1467 

1468 url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) 

1469 api_configs = await Config.get('ollama.api_configs', {}) 

1470 api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support 

1471 

1472 prefix_id = api_config.get('prefix_id', None) 

1473 payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id) 

1474 

1475 return await send_request( 

1476 f'{url}/v1/responses', 

1477 payload=JSONCodec.dumps(payload), 

1478 key=get_api_key(url_idx, url, api_configs), 

1479 user=user, 

1480 stream=payload.get('stream', False), 

1481 passthrough=True, 

1482 content_type='text/event-stream' if payload.get('stream', False) else None, 

1483 api_config=api_config, 

1484 request=request, 

1485 ) 

1486 

1487 

1488@router.get('/v1/models') 

1489@router.get('/v1/models/{url_idx}') 

1490async def get_openai_models( 

1491 request: Request, 

1492 url_idx: int | None = None, 

1493 user=Depends(get_verified_user), 

1494 db: AsyncSession = Depends(get_async_session), 

1495) -> dict: 

1496 """List models in the OpenAI-compatible format.""" 

1497 if url_idx is not None and user.role != 'admin': 1497 ↛ 1498line 1497 didn't jump to line 1498 because the condition on line 1497 was never true

1498 raise HTTPException(status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) 

1499 

1500 if url_idx is None: 

1501 model_list = await get_all_models(request, user=user) 

1502 raw_models = model_list['models'] 

1503 else: 

1504 url = (await Config.get('ollama.base_urls', []))[url_idx] 

1505 model_list = await send_request(f'{url}/api/tags', 'GET') 

1506 raw_models = model_list.get('models', []) 

1507 

1508 now_ts = int(time.time()) 

1509 models = [{'id': m['model'], 'object': 'model', 'created': now_ts, 'owned_by': 'openai'} for m in raw_models] 

1510 

1511 if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL: 1511 ↛ 1512line 1511 didn't jump to line 1512 because the condition on line 1511 was never true

1512 model_ids = [m['id'] for m in models] 

1513 model_infos = {mi.id: mi for mi in await Models.get_models_by_ids(model_ids, db=db)} 

1514 user_group_ids = {g.id for g in await Groups.get_groups_by_member_id(user.id, db=db)} 

1515 accessible_ids = await AccessGrants.get_accessible_resource_ids( 

1516 user_id=user.id, 

1517 resource_type='model', 

1518 resource_ids=list(model_infos.keys()), 

1519 permission='read', 

1520 user_group_ids=user_group_ids, 

1521 db=db, 

1522 ) 

1523 models = [ 

1524 m for m in models if (mi := model_infos.get(m['id'])) and (user.id == mi.user_id or mi.id in accessible_ids) 

1525 ] 

1526 

1527 return {'data': models, 'object': 'list'} 

1528 

1529 

1530class UrlForm(BaseModel): 

1531 """Form carrying a single URL string.""" 

1532 

1533 url: str 

1534 

1535 

1536class UploadBlobForm(BaseModel): 

1537 """Form carrying a filename for blob uploads.""" 

1538 

1539 filename: str 

1540 

1541 

1542def parse_huggingface_url(hf_url: str) -> str | None: 

1543 """Extract the filename from a HuggingFace download URL.""" 

1544 try: 

1545 return urlparse(hf_url).path.split('/')[-1] 

1546 except (ValueError, IndexError): 

1547 return None 

1548 

1549 

1550async def download_file_stream( 

1551 ollama_url: str, 

1552 file_url: str, 

1553 file_path: str, 

1554 file_name: str, 

1555 chunk_size: int = AIOHTTP_FILE_STREAM_CHUNK_SIZE, 

1556): 

1557 """Stream a model file download from *file_url*, then push the blob to Ollama.""" 

1558 current_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0 

1559 headers = {'Range': f'bytes={current_size}-'} if current_size > 0 else {} 

1560 

1561 session = await get_session() 

1562 async with session.get( 

1563 file_url, 

1564 headers=headers, 

1565 ssl=AIOHTTP_CLIENT_SESSION_SSL, 

1566 timeout=aiohttp.ClientTimeout(total=600), 

1567 ) as response: 

1568 total_size = int(response.headers.get('content-length', 0)) + current_size 

1569 

1570 async with aiofiles.open(file_path, 'ab') as f: 

1571 async for data in response.content.iter_chunked(chunk_size): 

1572 current_size += len(data) 

1573 await f.write(data) 

1574 

1575 progress_total = total_size or current_size 

1576 progress = round((current_size / progress_total) * 100, 2) 

1577 yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n' 

1578 

1579 done = True 

1580 hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size) 

1581 

1582 blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}' 

1583 blob_size = await asyncio.to_thread(os.path.getsize, file_path) 

1584 

1585 async def blob_chunks(): 

1586 async with aiofiles.open(file_path, 'rb') as blob_file: 

1587 while chunk := await blob_file.read(chunk_size): 

1588 yield chunk 

1589 

1590 async with session.post( 

1591 blob_url, 

1592 data=blob_chunks(), 

1593 headers={'Content-Length': str(blob_size)}, 

1594 ssl=AIOHTTP_CLIENT_SESSION_SSL, 

1595 timeout=aiohttp.ClientTimeout(total=30), 

1596 ) as blob_resp: 

1597 if blob_resp.ok: 

1598 await asyncio.to_thread(os.remove, file_path) 

1599 yield f'data: {JSONCodec.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n' 

1600 else: 

1601 raise RuntimeError('Ollama: Could not create blob, Please try again.') 

1602 

1603 

1604@router.post('/models/download') 

1605@router.post('/models/download/{url_idx}') 

1606async def download_model( 

1607 request: Request, 

1608 form_data: UrlForm, 

1609 url_idx: int | None = None, 

1610 user=Depends(get_admin_user), 

1611): 

1612 """Download a GGUF model from HuggingFace or GitHub and register it with Ollama.""" 

1613 allowed_hosts = ['https://huggingface.co/', 'https://github.com/'] 

1614 if not any(form_data.url.startswith(host) for host in allowed_hosts): 1614 ↛ 1620line 1614 didn't jump to line 1620 because the condition on line 1614 was always true

1615 raise HTTPException( 

1616 status_code=400, 

1617 detail='Invalid file_url. Only URLs from allowed hosts are permitted.', 

1618 ) 

1619 

1620 url = (await Config.get('ollama.base_urls', []))[url_idx if url_idx is not None else 0] 

1621 file_name = parse_huggingface_url(form_data.url) 

1622 

1623 if not file_name: 

1624 return None 

1625 

1626 file_path = os.path.join(UPLOAD_DIR, file_name) 

1627 return StreamingResponse( 

1628 download_file_stream(url, form_data.url, file_path, file_name), 

1629 ) 

1630 

1631 

1632@router.post('/models/upload') 

1633@router.post('/models/upload/{url_idx}') 

1634async def upload_model( 

1635 request: Request, 

1636 file: UploadFile = File(...), 

1637 url_idx: int | None = None, 

1638 user=Depends(get_admin_user), 

1639): 

1640 """Upload a local model file, push it as a blob, and create the model in Ollama.""" 

1641 ollama_url = (await Config.get('ollama.base_urls', []))[url_idx if url_idx is not None else 0] 

1642 

1643 filename = os.path.basename(file.filename) 

1644 file_path = os.path.join(UPLOAD_DIR, filename) 

1645 os.makedirs(UPLOAD_DIR, exist_ok=True) 

1646 

1647 # Stage 1: persist the uploaded file to disk 

1648 chunk_size = AIOHTTP_FILE_STREAM_CHUNK_SIZE 

1649 

1650 async with aiofiles.open(file_path, 'wb') as out_f: 

1651 while chunk := await file.read(chunk_size): 

1652 await out_f.write(chunk) 

1653 

1654 async def file_process_stream(): 

1655 nonlocal ollama_url 

1656 total_size = os.path.getsize(file_path) 

1657 log.info('Total Model Size: %s', total_size) 

1658 

1659 # Stage 2: hash the file and emit SSE progress 

1660 file_hash = await asyncio.to_thread(calculate_sha256, file_path, chunk_size) 

1661 log.info('Model Hash: %s', file_hash) 

1662 

1663 try: 

1664 bytes_read = 0 

1665 async with aiofiles.open(file_path, 'rb') as f: 

1666 while chunk := await f.read(chunk_size): 

1667 bytes_read += len(chunk) 

1668 progress = round(bytes_read / total_size * 100, 2) 

1669 event = JSONCodec.dumps({'progress': progress, 'total': total_size, 'completed': bytes_read}) 

1670 yield f'data: {event}\n\n' 

1671 

1672 session = await get_session() 

1673 blob_url = f'{ollama_url}/api/blobs/sha256:{file_hash}' 

1674 

1675 async def blob_chunks(): 

1676 async with aiofiles.open(file_path, 'rb') as blob_file: 

1677 while chunk := await blob_file.read(chunk_size): 

1678 yield chunk 

1679 

1680 async with session.post( 

1681 blob_url, 

1682 data=blob_chunks(), 

1683 headers={'Content-Length': str(total_size)}, 

1684 ssl=AIOHTTP_CLIENT_SESSION_SSL, 

1685 timeout=get_client_timeout(), 

1686 ) as resp: 

1687 if not resp.ok: 

1688 raise Exception('Ollama: Could not create blob, Please try again.') 

1689 

1690 log.info('Uploaded to /api/blobs') 

1691 await asyncio.to_thread(os.remove, file_path) 

1692 

1693 # Stage 4: create the model 

1694 model, _ext = os.path.splitext(filename) 

1695 log.info('Created Model: %s', model) 

1696 

1697 create_payload = { 

1698 'model': model, 

1699 'files': {filename: f'sha256:{file_hash}'}, 

1700 } 

1701 log.info('Model Payload: %s', create_payload) 

1702 

1703 async with session.post( 

1704 f'{ollama_url}/api/create', 

1705 headers={'Content-Type': 'application/json'}, 

1706 data=JSONCodec.dumps(create_payload), 

1707 ssl=AIOHTTP_CLIENT_SESSION_SSL, 

1708 timeout=get_client_timeout(), 

1709 ) as create_resp: 

1710 if create_resp.ok: 

1711 log.info('API SUCCESS!') 

1712 event = JSONCodec.dumps( 

1713 {'done': True, 'blob': f'sha256:{file_hash}', 'name': filename, 'model_created': model} 

1714 ) 

1715 yield f'data: {event}\n\n' 

1716 else: 

1717 resp_text = await create_resp.text() 

1718 raise Exception(f'Failed to create model in Ollama. {resp_text}') 

1719 

1720 except Exception as exc: 

1721 yield f'data: {JSONCodec.dumps({"error": str(exc)})}\n\n' 

1722 

1723 return StreamingResponse(file_process_stream(), media_type='text/event-stream')