Files
open-webui_open-webui/backend/open_webui/routers/ollama.py
T
Timothy Jaeryang Baek 04311d559d
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
refac
2026-07-15 02:53:39 -04:00

1645 lines
57 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import os
import random
import re
import time
from datetime import datetime
from typing import Optional, Union
from urllib.parse import urlparse
import aiohttp
from aiocache import cached
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, validator
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.config import UPLOAD_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
BYPASS_MODEL_ACCESS_CONTROL,
ENABLE_FORWARD_USER_INFO_HEADERS,
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
MODELS_CACHE_TTL,
)
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.models import Models
from open_webui.models.users import UserModel
from open_webui.utils.access_control import check_model_access
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
from open_webui.utils.misc import calculate_sha256
from open_webui.utils.payload import (
apply_model_params_to_body_ollama,
apply_model_params_to_body_openai,
apply_system_prompt_to_body,
)
from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper
log = logging.getLogger(__name__)
# Headers that become stale after aiohttp auto-decompresses the upstream
# response body. Forwarding them verbatim causes desktop / programmatic
# clients to attempt decompression of an already-decoded payload, resulting
# in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462.
_STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'})
def _clean_proxy_headers(raw_headers) -> dict:
"""Return a copy of *raw_headers* with stale encoding headers removed."""
return {k: v for k, v in raw_headers.items() if k not in _STRIP_PROXY_HEADERS}
async def send_get_request(
url: str,
key: str | None = None,
user: UserModel | None = None,
):
"""Issue a GET request to an Ollama backend and return JSON, or *None* on failure."""
try:
session = await get_session()
headers: dict = {
'Content-Type': 'application/json',
}
if key:
headers['Authorization'] = f'Bearer {key}'
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
async with session.get(
url,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as r:
return await r.json()
except Exception as exc:
log.error(f'Connection error: {exc}')
return None
async def send_request(
url: str,
method: str = 'POST',
*,
payload: Union[str, bytes | None] = None,
key: str | None = None,
user: UserModel = None,
stream: bool = False,
content_type: str | None = None,
metadata: dict | None = None,
api_config: dict | None = None,
request: Request | None = None,
):
r = None
streaming = False
try:
session = await get_session()
headers = {
'Content-Type': 'application/json',
**({'Authorization': f'Bearer {key}'} if key else {}),
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
if metadata and metadata.get('chat_id'):
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get('chat_id')
# Custom per-connection headers last so admin-set headers take precedence.
if api_config and api_config.get('headers'):
headers.update(get_custom_headers(api_config['headers'], user, metadata, request=request))
r = await session.request(
method,
url,
data=payload,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
if not r.ok:
try:
res = await r.json()
await publish_model_provider_request_failed(
request,
actor=user,
provider='ollama',
base_url=url,
status=r.status,
upstream_error=res,
)
if 'error' in res:
raise HTTPException(status_code=r.status, detail=res['error'])
except HTTPException:
raise
except Exception as e:
log.error(f'Failed to parse error response: {e}')
await publish_model_provider_request_failed(
request,
actor=user,
provider='ollama',
base_url=url,
status=r.status,
)
raise HTTPException(
status_code=r.status,
detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR,
)
r.raise_for_status()
if stream:
response_headers = _clean_proxy_headers(r.headers)
if content_type:
response_headers['Content-Type'] = content_type
streaming = True
return StreamingResponse(
stream_wrapper(r),
status_code=r.status,
headers=response_headers,
)
else:
try:
return await r.json()
except Exception:
return None
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=r.status if r else 500,
detail=f'Ollama: {e}' if str(e) else ERROR_MESSAGES.SERVER_CONNECTION_ERROR,
)
finally:
if not streaming:
await cleanup_response(r)
def get_api_key(idx, url, configs):
parsed_url = urlparse(url)
base_url = f'{parsed_url.scheme}://{parsed_url.netloc}'
return configs.get(str(idx), configs.get(base_url, {})).get('key', None) # Legacy support
##########################################
#
# API routes
#
##########################################
router = APIRouter()
OLLAMA_CONFIG_KEYS = {
'ENABLE_OLLAMA_API': 'ollama.enable',
'OLLAMA_BASE_URLS': 'ollama.base_urls',
'OLLAMA_API_CONFIGS': 'ollama.api_configs',
}
async def get_ollama_config_values() -> dict:
values = await Config.get_many(*OLLAMA_CONFIG_KEYS.values())
return {field: values[storage_key] for field, storage_key in OLLAMA_CONFIG_KEYS.items() if storage_key in values}
async def get_ollama_runtime_config() -> tuple[bool, list[str], dict]:
values = await Config.get_many('ollama.enable', 'ollama.base_urls', 'ollama.api_configs')
return (
values.get('ollama.enable'),
values.get('ollama.base_urls') or [],
values.get('ollama.api_configs') or {},
)
async def get_ollama_connection(idx: int) -> tuple[str, dict, str | None]:
_, base_urls, api_configs = await get_ollama_runtime_config()
url = base_urls[idx]
return url, resolve_api_config(api_configs, idx, url), get_api_key(idx, url, api_configs)
@router.head('/')
@router.get('/')
async def get_status() -> dict:
"""Health-check endpoint."""
return {'status': True}
class ConnectionVerificationForm(BaseModel):
url: str
key: str | None = None
@router.post('/verify')
async def verify_connection(
form_data: ConnectionVerificationForm,
user=Depends(get_admin_user),
):
"""Verify that an Ollama backend at *form_data.url* is reachable."""
try:
session = await get_session()
headers: dict = {}
if form_data.key:
headers['Authorization'] = f'Bearer {form_data.key}'
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
async with session.get(
f'{form_data.url}/api/version',
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as r:
if r.status != 200:
detail = f'HTTP Error: {r.status}'
res = await r.json()
if 'error' in res:
detail = f'External Error: {res["error"]}'
raise Exception(detail)
return await r.json()
except aiohttp.ClientError as exc:
log.exception(f'Client error: {exc}')
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
except Exception as exc:
log.exception(f'Unexpected error: {exc}')
raise HTTPException(status_code=500, detail=f'Unexpected error: {exc}')
@router.get('/config')
async def get_config(
request: Request,
user=Depends(get_admin_user),
) -> dict:
"""Return the current Ollama connection configuration."""
return await get_ollama_config_values()
class OllamaConfigForm(BaseModel):
"""Payload for updating the Ollama connection configuration."""
ENABLE_OLLAMA_API: bool | None = None
OLLAMA_BASE_URLS: list[str]
OLLAMA_API_CONFIGS: dict
@router.post('/config/update')
async def update_config(
request: Request,
form_data: OllamaConfigForm,
user=Depends(get_admin_user),
) -> dict:
"""Persist updated Ollama connection settings."""
valid_keys = {str(i) for i in range(len(form_data.OLLAMA_BASE_URLS))}
api_configs = {k: v for k, v in form_data.OLLAMA_API_CONFIGS.items() if k in valid_keys}
await Config.upsert(
{
'ollama.enable': form_data.ENABLE_OLLAMA_API,
'ollama.base_urls': form_data.OLLAMA_BASE_URLS,
'ollama.api_configs': api_configs,
}
)
await get_all_models.cache.clear()
request.app.state.BASE_MODELS = []
request.app.state.OLLAMA_MODELS = {}
models = getattr(request.app.state, 'MODELS', None)
if hasattr(models, 'clear'):
models.clear()
else:
request.app.state.MODELS = {}
await publish_event(
request,
EVENTS.MODEL_PROVIDER_CONFIG_UPDATED,
actor=user,
subject_id='ollama',
subject_type='model.provider_config',
data={
'provider': 'ollama',
'enabled': form_data.ENABLE_OLLAMA_API,
'base_url_count': len(form_data.OLLAMA_BASE_URLS),
},
)
return {
'ENABLE_OLLAMA_API': form_data.ENABLE_OLLAMA_API,
'OLLAMA_BASE_URLS': form_data.OLLAMA_BASE_URLS,
'OLLAMA_API_CONFIGS': api_configs,
}
def merge_models_lists(model_lists) -> list[dict]:
"""De-duplicate model entries across multiple Ollama backends, tracking which URL index hosts each model."""
merged: dict[str, dict] = {}
for idx, entries in enumerate(model_lists):
if entries is None:
continue
for entry in entries:
model_id = entry.get('model')
if model_id is None:
continue
if model_id not in merged:
entry['urls'] = [idx]
merged[model_id] = entry
else:
merged[model_id]['urls'].append(idx)
return list(merged.values())
def resolve_api_config(api_configs: dict, idx: int, url: str) -> dict:
"""Look up the API config for a backend by numeric index, falling back to URL key (legacy)."""
return api_configs.get(str(idx), api_configs.get(url, {}))
@cached(
ttl=MODELS_CACHE_TTL,
# key_builder (not key) is the per-call hook in aiocache 0.12; `key=` is a
# static key, so a `key=lambda` collapsed every caller to one shared entry.
key_builder=lambda _func, request, user=None: f'ollama_all_models_{user.id}' if user else 'ollama_all_models',
)
async def get_all_models(request: Request, user: UserModel | None = None):
"""Aggregate model tags from every enabled Ollama backend."""
log.info('get_all_models()')
if not await Config.get('ollama.enable'):
models_dict: dict = {'models': []}
request.app.state.OLLAMA_MODELS = {}
return models_dict
# Fan-out tag requests to every backend
tasks = []
base_urls = await Config.get('ollama.base_urls', [])
api_configs = await Config.get('ollama.api_configs', {})
for idx, url in enumerate(base_urls):
api_config = resolve_api_config(api_configs, idx, url)
if not api_config:
tasks.append(send_get_request(f'{url}/api/tags', user=user))
elif api_config.get('enable', True):
tasks.append(send_get_request(f'{url}/api/tags', api_config.get('key'), user=user))
else:
tasks.append(asyncio.ensure_future(asyncio.sleep(0, None)))
responses = await asyncio.gather(*tasks)
# Track which backends failed so we can skip them for /api/ps
failed_idxs: set[int] = set()
# Post-process each response: apply prefix_id, tags, model filtering
for idx, response in enumerate(responses):
if not response:
failed_idxs.add(idx)
continue
url = base_urls[idx]
api_config = resolve_api_config(api_configs, idx, url)
connection_type = api_config.get('connection_type', 'local')
prefix_id = api_config.get('prefix_id')
allowed_tags = api_config.get('tags', [])
allowed_model_ids = api_config.get('model_ids', [])
if allowed_model_ids and 'models' in response:
response['models'] = [m for m in response['models'] if m['model'] in allowed_model_ids]
for m in response.get('models', []):
if prefix_id:
m['model'] = f'{prefix_id}.{m["model"]}'
if allowed_tags:
m['tags'] = allowed_tags
if connection_type:
m['connection_type'] = connection_type
models_dict = {'models': merge_models_lists(r.get('models', []) if r else None for r in responses)}
# Annotate with expiry info from loaded-model state
try:
loaded = await get_ollama_loaded_models(request, user=user, skip_idxs=failed_idxs)
expires_map = {m['model']: m['expires_at'] for m in loaded['models'] if 'expires_at' in m}
for m in models_dict['models']:
if m['model'] in expires_map:
dt = datetime.fromisoformat(expires_map[m['model']])
m['expires_at'] = int(dt.timestamp())
except Exception as exc:
log.debug(f'Failed to get loaded models: {exc}')
request.app.state.OLLAMA_MODELS = {m['model']: m for m in models_dict['models']}
return models_dict
async def get_filtered_models(models, user, db=None):
"""Return only the models the given *user* is allowed to access."""
model_ids = [m['model'] for m in models.get('models', [])]
model_infos = {mi.id: mi for mi in await Models.get_models_by_ids(model_ids, db=db)}
user_group_ids = {g.id for g in await Groups.get_groups_by_member_id(user.id, db=db)}
accessible_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type='model',
resource_ids=list(model_infos.keys()),
permission='read',
user_group_ids=user_group_ids,
db=db,
)
return [
m
for m in models.get('models', [])
if (mi := model_infos.get(m['model'])) and (user.id == mi.user_id or mi.id in accessible_ids)
]
@router.get('/api/tags')
@router.get('/api/tags/{url_idx}')
async def get_ollama_tags(
request: Request,
url_idx: int | None = None,
user=Depends(get_verified_user),
):
"""List Ollama model tags, optionally from a specific backend."""
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
if url_idx is None:
result = await get_all_models(request, user=user)
else:
url = (await Config.get('ollama.base_urls', []))[url_idx]
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
result = await send_request(f'{url}/api/tags', 'GET', key=key, user=user)
if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL:
result['models'] = await get_filtered_models(result, user)
return result
@router.get('/api/ps')
async def get_ollama_loaded_models(
request: Request,
user=Depends(get_admin_user),
skip_idxs: set[int] | None = None,
) -> dict:
"""List models currently loaded in Ollama memory across all backends."""
if not await Config.get('ollama.enable'):
return {'models': []}
tasks = []
base_urls = await Config.get('ollama.base_urls', [])
api_configs = await Config.get('ollama.api_configs', {})
for idx, url in enumerate(base_urls):
if skip_idxs and idx in skip_idxs:
tasks.append(asyncio.ensure_future(asyncio.sleep(0, None)))
continue
api_config = resolve_api_config(api_configs, idx, url)
if not api_config:
tasks.append(send_get_request(f'{url}/api/ps', user=user))
elif api_config.get('enable', True):
tasks.append(send_get_request(f'{url}/api/ps', api_config.get('key'), user=user))
else:
tasks.append(asyncio.ensure_future(asyncio.sleep(0, None)))
responses = await asyncio.gather(*tasks)
for idx, response in enumerate(responses):
if not response:
continue
api_config = resolve_api_config(api_configs, idx, base_urls[idx])
prefix_id = api_config.get('prefix_id')
if prefix_id:
for m in response.get('models', []):
m['model'] = f'{prefix_id}.{m["model"]}'
return {'models': merge_models_lists(r.get('models', []) if r else None for r in responses)}
@router.get('/api/version')
@router.get('/api/version/{url_idx}')
async def get_ollama_versions(
request: Request,
url_idx: int | None = None,
):
"""Return the lowest Ollama version across all configured backends."""
if not await Config.get('ollama.enable'):
return {'version': False}
if url_idx is not None:
url = (await Config.get('ollama.base_urls', []))[url_idx]
return await send_request(f'{url}/api/version', 'GET')
# Fan-out to every enabled backend
tasks = []
for idx, url in enumerate(await Config.get('ollama.base_urls', [])):
api_config = (await Config.get('ollama.api_configs', {})).get(
str(idx),
(await Config.get('ollama.api_configs', {})).get(url, {}),
)
if api_config.get('enable', True):
tasks.append(send_get_request(f'{url}/api/version', api_config.get('key')))
raw = await asyncio.gather(*tasks)
valid = [r for r in raw if r is not None]
if not valid:
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.OLLAMA_NOT_FOUND)
lowest = min(
valid,
key=lambda v: tuple(map(int, re.sub(r'^v|-.*', '', v['version']).split('.'))),
)
return {'version': lowest['version']}
class ModelNameForm(BaseModel):
"""Generic form carrying an optional model identifier."""
model: str | None = None
model_config = ConfigDict(extra='allow')
@router.post('/api/unload')
async def unload_model(
request: Request,
form_data: ModelNameForm,
user=Depends(get_admin_user),
):
form_data = form_data.model_dump(exclude_none=True)
model = form_data.get('model', form_data.get('name'))
if not model:
raise HTTPException(status_code=400, detail='Missing name of the model to unload.')
# Refresh/load models if needed, get mapping from name to URLs
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if model not in models:
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model))
url_indices = models[model]['urls']
# Send unload to ALL url_indices
results = []
errors = []
for idx in url_indices:
url = (await Config.get('ollama.base_urls', []))[idx]
api_config = (await Config.get('ollama.api_configs', {})).get(
str(idx), (await Config.get('ollama.api_configs', {})).get(url, {})
)
key = get_api_key(idx, url, (await Config.get('ollama.api_configs', {})))
prefix_id = api_config.get('prefix_id', None)
if prefix_id and model.startswith(f'{prefix_id}.'):
model = model[len(f'{prefix_id}.') :]
payload = {'model': model, 'keep_alive': 0, 'prompt': ''}
try:
res = await send_request(
f'{url}/api/generate',
payload=json.dumps(payload),
key=key,
user=user,
)
results.append({'url_idx': idx, 'success': True, 'response': res})
except Exception as e:
log.exception(f'Failed to unload model on node {idx}: {e}')
errors.append({'url_idx': idx, 'success': False, 'error': str(e)})
if len(errors) > 0:
raise HTTPException(
status_code=500,
detail=f'Failed to unload model on {len(errors)} nodes: {errors}',
)
return {'status': True}
@router.post('/api/pull')
@router.post('/api/pull/{url_idx}')
async def pull_model(
request: Request,
form_data: ModelNameForm,
url_idx: int = 0,
user=Depends(get_admin_user),
):
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
form_data = form_data.model_dump(exclude_none=True)
form_data['model'] = form_data.get('model', form_data.get('name'))
url = (await Config.get('ollama.base_urls', []))[url_idx]
log.info(f'url: {url}')
# Admins may pull from any registry
return await send_request(
f'{url}/api/pull',
payload=json.dumps({**form_data, 'insecure': True}),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
stream=True,
)
class PushModelForm(BaseModel):
"""Payload for pushing a model to a registry."""
model: str
insecure: bool | None = None
stream: bool | None = None
@router.delete('/api/push')
@router.delete('/api/push/{url_idx}')
async def push_model(
request: Request,
form_data: PushModelForm,
url_idx: int | None = None,
user=Depends(get_admin_user),
):
"""Push a local model to a remote registry."""
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
if url_idx is None:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if form_data.model not in models:
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
url_idx = models[form_data.model]['urls'][0]
url = (await Config.get('ollama.base_urls', []))[url_idx]
log.debug(f'url: {url}')
return await send_request(
f'{url}/api/push',
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
stream=True,
)
class CreateModelForm(BaseModel):
"""Payload for creating a new model via Modelfile."""
model: str | None = None
stream: bool | None = None
path: str | None = None
model_config = ConfigDict(extra='allow')
@router.post('/api/create')
@router.post('/api/create/{url_idx}')
async def create_model(
request: Request,
form_data: CreateModelForm,
url_idx: int = 0,
user=Depends(get_admin_user),
):
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
log.debug(f'form_data: {form_data}')
url = (await Config.get('ollama.base_urls', []))[url_idx]
return await send_request(
f'{url}/api/create',
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
stream=True,
)
class CopyModelForm(BaseModel):
"""Payload for duplicating an existing model under a new name."""
source: str
destination: str
@router.post('/api/copy')
@router.post('/api/copy/{url_idx}')
async def copy_model(
request: Request,
form_data: CopyModelForm,
url_idx: int | None = None,
user=Depends(get_admin_user),
):
"""Duplicate an existing model under a new name."""
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
if url_idx is None:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if form_data.source not in models:
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.source))
url_idx = models[form_data.source]['urls'][0]
url = (await Config.get('ollama.base_urls', []))[url_idx]
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
await send_request(
f'{url}/api/copy',
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=key,
user=user,
)
await publish_event(
request,
EVENTS.MODEL_PROVIDER_MODEL_CREATED,
actor=user,
subject_id=form_data.destination,
data={'provider': 'ollama', 'source': form_data.source, 'url_idx': url_idx},
)
return True
@router.delete('/api/delete')
@router.delete('/api/delete/{url_idx}')
async def delete_model(
request: Request,
form_data: ModelNameForm,
url_idx: int | None = None,
user=Depends(get_admin_user),
):
"""Remove a model from an Ollama backend."""
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
payload = form_data.model_dump(exclude_none=True)
payload['model'] = payload.get('model', payload.get('name'))
model = payload.get('model')
if url_idx is None:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if model not in models:
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model))
url_idx = models[model]['urls'][0]
url = (await Config.get('ollama.base_urls', []))[url_idx]
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
await send_request(
f'{url}/api/delete',
'DELETE',
payload=json.dumps(payload),
key=key,
user=user,
)
await publish_event(
request,
EVENTS.MODEL_PROVIDER_MODEL_DELETED,
actor=user,
subject_id=model,
data={'provider': 'ollama', 'url_idx': url_idx},
)
return True
@router.post('/api/show')
async def show_model_info(
request: Request,
form_data: ModelNameForm,
user=Depends(get_verified_user),
):
"""Retrieve model metadata from the Ollama backend."""
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
payload = form_data.model_dump(exclude_none=True)
payload['model'] = payload.get('model', payload.get('name'))
model = payload.get('model')
await check_model_access(user, await Models.get_model_by_id(model), BYPASS_MODEL_ACCESS_CONTROL)
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if model not in models:
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model))
url_idx = random.choice(models[model]['urls'])
url = (await Config.get('ollama.base_urls', []))[url_idx]
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
return await send_request(
f'{url}/api/show',
payload=json.dumps(payload),
key=key,
user=user,
)
class GenerateEmbedForm(BaseModel):
"""Payload for the newer /api/embed endpoint (batch-capable)."""
model: str
input: list[str] | str
truncate: bool | None = None
options: dict | None = None
keep_alive: Union[int, str | None] = None
model_config = ConfigDict(extra='allow')
@router.post('/api/embed')
@router.post('/api/embed/{url_idx}')
async def embed(
request: Request,
form_data: GenerateEmbedForm,
url_idx: int | None = None,
user=Depends(get_verified_user),
):
"""Generate embeddings via the Ollama /api/embed endpoint."""
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
log.info(f'generate_ollama_batch_embeddings {form_data}')
await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL)
await validate_ollama_backend_idx(request, form_data.model, url_idx, user)
if url_idx is None:
model = form_data.model
models = request.app.state.OLLAMA_MODELS
if not models or model not in models:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if model not in models:
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
url_idx = random.choice(models[model]['urls'])
url = (await Config.get('ollama.base_urls', []))[url_idx]
api_config = (await Config.get('ollama.api_configs', {})).get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}),
)
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
prefix_id = api_config.get('prefix_id')
if prefix_id:
form_data.model = form_data.model.replace(f'{prefix_id}.', '')
return await send_request(
f'{url}/api/embed',
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=key,
user=user,
)
class GenerateEmbeddingsForm(BaseModel):
"""Payload for the legacy /api/embeddings endpoint (single-prompt)."""
model: str
prompt: str
options: dict | None = None
keep_alive: Union[int, str | None] = None
@router.post('/api/embeddings')
@router.post('/api/embeddings/{url_idx}')
async def embeddings(
request: Request,
form_data: GenerateEmbeddingsForm,
url_idx: int | None = None,
user=Depends(get_verified_user),
):
"""Generate embeddings via the legacy Ollama /api/embeddings endpoint."""
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
log.info(f'generate_ollama_embeddings {form_data}')
await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL)
await validate_ollama_backend_idx(request, form_data.model, url_idx, user)
if url_idx is None:
model = form_data.model
models = request.app.state.OLLAMA_MODELS
if not models or model not in models:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if model not in models:
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
url_idx = random.choice(models[model]['urls'])
url = (await Config.get('ollama.base_urls', []))[url_idx]
api_config = (await Config.get('ollama.api_configs', {})).get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}),
)
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
prefix_id = api_config.get('prefix_id')
if prefix_id:
form_data.model = form_data.model.replace(f'{prefix_id}.', '')
return await send_request(
f'{url}/api/embeddings',
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=key,
user=user,
)
class GenerateCompletionForm(BaseModel):
"""Payload for the Ollama /api/generate endpoint."""
model: str
prompt: str | None = None
suffix: str | None = None
images: list[str | None] = None
format: Union[dict, str | None] = None
options: dict | None = None
system: str | None = None
template: str | None = None
context: list[int | None] = None
stream: bool | None = True
raw: bool | None = None
keep_alive: Union[int, str | None] = None
@router.post('/api/generate')
@router.post('/api/generate/{url_idx}')
async def generate_completion(
request: Request,
form_data: GenerateCompletionForm,
url_idx: int | None = None,
user=Depends(get_verified_user),
):
"""Run text completion via Ollama /api/generate."""
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL)
await validate_ollama_backend_idx(request, form_data.model, url_idx, user)
if url_idx is None:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
model = form_data.model
if model not in models:
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
url_idx = random.choice(models[model]['urls'])
url = (await Config.get('ollama.base_urls', []))[url_idx]
api_config = (await Config.get('ollama.api_configs', {})).get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}),
)
prefix_id = api_config.get('prefix_id')
if prefix_id:
form_data.model = form_data.model.replace(f'{prefix_id}.', '')
return await send_request(
f'{url}/api/generate',
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
stream=True,
)
class ChatMessage(BaseModel):
"""A single message in an Ollama chat conversation."""
role: str
content: str | None = None
tool_calls: list[dict | None] = None
images: list[str | None] = None
model_config = ConfigDict(extra='allow')
@validator('content', pre=True)
@classmethod
def check_at_least_one_field(cls, field_value, values, **kwargs):
if field_value is None and ('tool_calls' not in values or values['tool_calls'] is None):
raise ValueError("At least one of 'content' or 'tool_calls' must be provided")
return field_value
class GenerateChatCompletionForm(BaseModel):
"""Payload for the Ollama /api/chat endpoint."""
model: str
messages: list[ChatMessage]
format: Union[dict, str | None] = None
options: dict | None = None
template: str | None = None
stream: bool | None = True
keep_alive: Union[int, str | None] = None
tools: list[dict | None] = None
model_config = ConfigDict(extra='allow')
async def validate_ollama_backend_idx(request: Request, model: str, url_idx: int | None, user) -> None:
# A caller-supplied url_idx must point to a backend the model is actually
# served from; the None path is already constrained to that allow-list.
if url_idx is None or user is None or getattr(user, 'role', None) == 'admin' or BYPASS_MODEL_ACCESS_CONTROL:
return
models = request.app.state.OLLAMA_MODELS
if not models or model not in models:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if url_idx not in (models.get(model) or {}).get('urls', []):
raise HTTPException(status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
async def get_ollama_url(request: Request, model: str, url_idx: int | None = None, user=None):
await validate_ollama_backend_idx(request, model, url_idx, user)
if url_idx is None:
models = request.app.state.OLLAMA_MODELS
if model not in models:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model),
)
url_idx = random.choice(models[model].get('urls', []))
url = (await Config.get('ollama.base_urls', []))[url_idx]
return url, url_idx
@router.post('/api/chat')
@router.post('/api/chat/{url_idx}')
async def generate_chat_completion(
request: Request,
form_data: dict,
url_idx: int | None = None,
user=Depends(get_verified_user), # noqa: B008
):
"""Forward a chat completion request to an Ollama backend."""
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
# which would exhaust the connection pool under concurrent load.
# bypass_filter and bypass_system_prompt are read from request.state to prevent
# external clients from setting them via query parameter. Only internal
# server-side callers (e.g. utils/chat.py) should set
# request.state.bypass_filter / request.state.bypass_system_prompt = True.
bypass_filter = getattr(request.state, 'bypass_filter', False)
if BYPASS_MODEL_ACCESS_CONTROL:
bypass_filter = True
bypass_system_prompt = getattr(request.state, 'bypass_system_prompt', False)
metadata = form_data.pop('metadata', None)
try:
form_data = GenerateChatCompletionForm(**form_data)
except Exception as exc:
log.exception(exc)
raise HTTPException(status_code=400, detail=str(exc))
if isinstance(form_data, BaseModel):
payload = {**form_data.model_dump(exclude_none=True)}
payload.pop('metadata', None)
model_id = payload['model']
model_info = await Models.get_model_by_id(model_id)
if model_info is not None:
if model_info.base_model_id:
base_model_id = request.base_model_id if hasattr(request, 'base_model_id') else model_info.base_model_id
payload['model'] = base_model_id
params = model_info.params.model_dump()
if params:
system = params.pop('system', None)
payload = apply_model_params_to_body_ollama(params, payload)
if not bypass_system_prompt:
payload = await apply_system_prompt_to_body(system, payload, metadata, user)
await check_model_access(user, model_info, bypass_filter)
else:
await check_model_access(user, None, bypass_filter)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
return await send_request(
f'{url}/api/chat',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
stream=form_data.stream,
content_type='application/x-ndjson',
metadata=metadata,
api_config=api_config,
request=request,
)
# TODO: we should update this part once Ollama supports other types
class OpenAIChatMessageContent(BaseModel):
"""Content block within an OpenAI-style chat message."""
type: str
model_config = ConfigDict(extra='allow')
class OpenAIChatMessage(BaseModel):
"""A single message in an OpenAI-compatible chat request."""
role: str
content: Union[str | None, list[OpenAIChatMessageContent]]
model_config = ConfigDict(extra='allow')
class OpenAIChatCompletionForm(BaseModel):
"""Payload for the OpenAI-compatible /v1/chat/completions proxy."""
model: str
messages: list[OpenAIChatMessage]
model_config = ConfigDict(extra='allow')
class OpenAICompletionForm(BaseModel):
"""Payload for the OpenAI-compatible /v1/completions proxy."""
model: str
prompt: str
model_config = ConfigDict(extra='allow')
@router.post('/v1/completions')
@router.post('/v1/completions/{url_idx}')
async def generate_openai_completion(
request: Request,
form_data: dict,
url_idx: int | None = None,
user=Depends(get_verified_user), # noqa: B008
):
"""Forward a text completion request via the OpenAI-compatible proxy."""
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
# which would exhaust the connection pool under concurrent load.
metadata = form_data.pop('metadata', None)
try:
form_data = OpenAICompletionForm(**form_data)
except Exception as exc:
log.exception(exc)
raise HTTPException(status_code=400, detail=str(exc))
payload = {**form_data.model_dump(exclude_none=True, exclude=['metadata'])}
payload.pop('metadata', None)
model_id = form_data.model
model_info = await Models.get_model_by_id(model_id)
if model_info is not None:
if model_info.base_model_id:
payload['model'] = model_info.base_model_id
params = model_info.params.model_dump()
if params:
payload = apply_model_params_to_body_openai(params, payload)
await check_model_access(user, model_info)
else:
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
return await send_request(
f'{url}/v1/completions',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
stream=payload.get('stream', False),
metadata=metadata,
api_config=api_config,
request=request,
)
@router.post('/v1/chat/completions')
@router.post('/v1/chat/completions/{url_idx}')
async def generate_openai_chat_completion(
request: Request,
form_data: dict,
url_idx: int | None = None,
user=Depends(get_verified_user), # noqa: B008
):
"""Forward a chat completion request via the OpenAI-compatible proxy."""
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
# which would exhaust the connection pool under concurrent load.
metadata = form_data.pop('metadata', None)
try:
form_data = OpenAIChatCompletionForm(**form_data)
except Exception as exc:
log.exception(exc)
raise HTTPException(status_code=400, detail=str(exc))
payload = {**form_data.model_dump(exclude_none=True, exclude=['metadata'])}
payload.pop('metadata', None)
model_id = form_data.model
model_info = await Models.get_model_by_id(model_id)
if model_info is not None:
if model_info.base_model_id:
payload['model'] = model_info.base_model_id
params = model_info.params.model_dump()
if params:
system = params.pop('system', None)
payload = apply_model_params_to_body_openai(params, payload)
payload = await apply_system_prompt_to_body(system, payload, metadata, user)
await check_model_access(user, model_info)
else:
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
return await send_request(
f'{url}/v1/chat/completions',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
stream=payload.get('stream', False),
metadata=metadata,
api_config=api_config,
request=request,
)
@router.post('/v1/messages')
@router.post('/v1/messages/{url_idx}')
async def generate_anthropic_messages(
request: Request,
form_data: dict,
url_idx: int | None = None,
user=Depends(get_verified_user),
):
"""
Proxy for Ollama's Anthropic-compatible /v1/messages endpoint.
Forwards the request as-is to the Ollama backend, applying the same
model resolution, access control, and prefix_id handling used by
the OpenAI-compatible /v1/chat/completions proxy.
See https://docs.ollama.com/api/anthropic-compatibility
"""
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
payload = {**form_data}
model_id = payload.get('model', '')
model_info = await Models.get_model_by_id(model_id)
if model_info:
if model_info.base_model_id:
payload['model'] = model_info.base_model_id
await check_model_access(user, model_info)
else:
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = (await Config.get('ollama.api_configs', {})).get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
)
prefix_id = api_config.get('prefix_id', None)
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
return await send_request(
f'{url}/v1/messages',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
stream=payload.get('stream', False),
content_type='text/event-stream' if payload.get('stream', False) else None,
api_config=api_config,
request=request,
)
class ResponsesForm(BaseModel):
model: str
model_config = ConfigDict(extra='allow')
@router.post('/v1/responses')
@router.post('/v1/responses/{url_idx}')
async def generate_responses(
request: Request,
form_data: ResponsesForm,
url_idx: int | None = None,
user=Depends(get_verified_user),
):
"""
Proxy for Ollama's OpenAI-compatible /v1/responses endpoint.
Forwards the request as-is to the Ollama backend, applying the same
model resolution, access control, and prefix_id handling used by
the OpenAI-compatible /v1/chat/completions proxy.
See https://ollama.com/blog/responses-api
"""
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
payload = form_data.model_dump()
model_id = form_data.model
model_info = await Models.get_model_by_id(model_id)
if model_info:
if model_info.base_model_id:
payload['model'] = model_info.base_model_id
await check_model_access(user, model_info)
else:
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = (await Config.get('ollama.api_configs', {})).get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
)
prefix_id = api_config.get('prefix_id', None)
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
return await send_request(
f'{url}/v1/responses',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
stream=payload.get('stream', False),
content_type='text/event-stream' if payload.get('stream', False) else None,
api_config=api_config,
request=request,
)
@router.get('/v1/models')
@router.get('/v1/models/{url_idx}')
async def get_openai_models(
request: Request,
url_idx: int | None = None,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
) -> dict:
"""List models in the OpenAI-compatible format."""
if url_idx is None:
model_list = await get_all_models(request, user=user)
raw_models = model_list['models']
else:
url = (await Config.get('ollama.base_urls', []))[url_idx]
model_list = await send_request(f'{url}/api/tags', 'GET')
raw_models = model_list.get('models', [])
now_ts = int(time.time())
models = [{'id': m['model'], 'object': 'model', 'created': now_ts, 'owned_by': 'openai'} for m in raw_models]
if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL:
model_ids = [m['id'] for m in models]
model_infos = {mi.id: mi for mi in await Models.get_models_by_ids(model_ids, db=db)}
user_group_ids = {g.id for g in await Groups.get_groups_by_member_id(user.id, db=db)}
accessible_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type='model',
resource_ids=list(model_infos.keys()),
permission='read',
user_group_ids=user_group_ids,
db=db,
)
models = [
m for m in models if (mi := model_infos.get(m['id'])) and (user.id == mi.user_id or mi.id in accessible_ids)
]
return {'data': models, 'object': 'list'}
class UrlForm(BaseModel):
"""Form carrying a single URL string."""
url: str
class UploadBlobForm(BaseModel):
"""Form carrying a filename for blob uploads."""
filename: str
def parse_huggingface_url(hf_url: str) -> str | None:
"""Extract the filename from a HuggingFace download URL."""
try:
return urlparse(hf_url).path.split('/')[-1]
except (ValueError, IndexError):
return None
async def download_file_stream(
ollama_url: str,
file_url: str,
file_path: str,
file_name: str,
chunk_size: int = 1024 * 1024,
):
"""Stream a model file download from *file_url*, then push the blob to Ollama."""
current_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
headers = {'Range': f'bytes={current_size}-'} if current_size > 0 else {}
session = await get_session()
async with session.get(
file_url,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=600),
) as response:
total_size = int(response.headers.get('content-length', 0)) + current_size
with open(file_path, 'ab+') as f:
async for data in response.content.iter_chunked(chunk_size):
current_size += len(data)
f.write(data)
done = current_size == total_size
progress = round((current_size / total_size) * 100, 2)
yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n'
if done:
f.close()
hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size)
def _read_blob():
with open(file_path, 'rb') as blob_f:
return blob_f.read()
blob_data = await asyncio.to_thread(_read_blob)
blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}'
async with session.post(
blob_url,
data=blob_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=30),
) as blob_resp:
if blob_resp.ok:
os.remove(file_path)
yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n'
else:
raise RuntimeError('Ollama: Could not create blob, Please try again.')
@router.post('/models/download')
@router.post('/models/download/{url_idx}')
async def download_model(
request: Request,
form_data: UrlForm,
url_idx: int | None = None,
user=Depends(get_admin_user),
):
"""Download a GGUF model from HuggingFace or GitHub and register it with Ollama."""
allowed_hosts = ['https://huggingface.co/', 'https://github.com/']
if not any(form_data.url.startswith(host) for host in allowed_hosts):
raise HTTPException(
status_code=400,
detail='Invalid file_url. Only URLs from allowed hosts are permitted.',
)
url = (await Config.get('ollama.base_urls', []))[url_idx if url_idx is not None else 0]
file_name = parse_huggingface_url(form_data.url)
if not file_name:
return None
file_path = os.path.join(UPLOAD_DIR, file_name)
return StreamingResponse(
download_file_stream(url, form_data.url, file_path, file_name),
)
@router.post('/models/upload')
@router.post('/models/upload/{url_idx}')
async def upload_model(
request: Request,
file: UploadFile = File(...),
url_idx: int | None = None,
user=Depends(get_admin_user),
):
"""Upload a local model file, push it as a blob, and create the model in Ollama."""
ollama_url = (await Config.get('ollama.base_urls', []))[url_idx if url_idx is not None else 0]
filename = os.path.basename(file.filename)
file_path = os.path.join(UPLOAD_DIR, filename)
os.makedirs(UPLOAD_DIR, exist_ok=True)
# Stage 1: persist the uploaded file to disk
chunk_size = 1024 * 1024 * 2 # 2 MiB
def _persist_upload():
with open(file_path, 'wb') as out_f:
while True:
chunk = file.file.read(chunk_size)
if not chunk:
break
out_f.write(chunk)
await asyncio.to_thread(_persist_upload)
async def file_process_stream():
nonlocal ollama_url
total_size = os.path.getsize(file_path)
log.info(f'Total Model Size: {total_size}')
# Stage 2: hash the file and emit SSE progress
file_hash = await asyncio.to_thread(calculate_sha256, file_path, chunk_size)
log.info(f'Model Hash: {file_hash}')
try:
with open(file_path, 'rb') as f:
bytes_read = 0
while chunk := f.read(chunk_size):
bytes_read += len(chunk)
progress = round(bytes_read / total_size * 100, 2)
yield f'data: {json.dumps({"progress": progress, "total": total_size, "completed": bytes_read})}\n\n'
# Stage 3: push blob to Ollama
def _read_blob():
with open(file_path, 'rb') as f:
return f.read()
blob_data = await asyncio.to_thread(_read_blob)
session = await get_session()
blob_url = f'{ollama_url}/api/blobs/sha256:{file_hash}'
async with session.post(
blob_url,
data=blob_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
) as resp:
if not resp.ok:
raise Exception('Ollama: Could not create blob, Please try again.')
log.info('Uploaded to /api/blobs')
os.remove(file_path)
# Stage 4: create the model
model, _ext = os.path.splitext(filename)
log.info(f'Created Model: {model}')
create_payload = {
'model': model,
'files': {filename: f'sha256:{file_hash}'},
}
log.info(f'Model Payload: {create_payload}')
async with session.post(
f'{ollama_url}/api/create',
headers={'Content-Type': 'application/json'},
data=json.dumps(create_payload),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
) as create_resp:
if create_resp.ok:
log.info('API SUCCESS!')
yield f'data: {json.dumps({"done": True, "blob": f"sha256:{file_hash}", "name": filename, "model_created": model})}\n\n'
else:
resp_text = await create_resp.text()
raise Exception(f'Failed to create model in Ollama. {resp_text}')
except Exception as exc:
yield f'data: {json.dumps({"error": str(exc)})}\n\n'
return StreamingResponse(file_process_stream(), media_type='text/event-stream')