diff --git a/README.md b/README.md index a178b3271e..1885f4f6f1 100644 --- a/README.md +++ b/README.md @@ -172,8 +172,6 @@ After installation, you can access Open WebUI at [http://localhost:3000](http:// We offer various installation alternatives, including non-Docker native installation methods, Docker Compose, Kustomize, and Helm. Visit our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/) or join our [Discord community](https://discord.gg/5rJgQTnV4s) for comprehensive guidance. -Look at the [Local Development Guide](https://docs.openwebui.com/getting-started/development) for instructions on setting up a local development environment. - ### Troubleshooting Encountering connection issues? Our [Open WebUI Documentation](https://docs.openwebui.com/troubleshooting/) has got you covered. For further assistance and to join our vibrant community, visit the [Open WebUI Discord](https://discord.gg/5rJgQTnV4s). diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index e0dcaff276..53737a1f2f 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -60,13 +60,14 @@ if USE_CUDA.lower() == 'true': else: DEVICE_TYPE = 'cpu' -try: - import torch +if sys.platform == 'darwin': + try: + import torch - if torch.backends.mps.is_available() and torch.backends.mps.is_built(): - DEVICE_TYPE = 'mps' -except Exception: - pass + if torch.backends.mps.is_available() and torch.backends.mps.is_built(): + DEVICE_TYPE = 'mps' + except Exception: + pass #################################### # LOGGING diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index 9bfe77c41e..6a1fe22149 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -34,7 +34,6 @@ from open_webui.utils.plugin import ( load_function_module_by_id, get_function_module_from_cache, ) -from open_webui.utils.tools import get_tools from open_webui.env import GLOBAL_LOG_LEVEL @@ -255,17 +254,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di '__oauth_token__': oauth_token, '__request__': request, } - extra_params['__tools__'] = await get_tools( - request, - tool_ids, - user, - { - **extra_params, - '__model__': models.get(form_data['model'], None), - '__messages__': form_data['messages'], - '__files__': files, - }, - ) + extra_params['__tools__'] = metadata.get('tools', {}) if model_info: if model_info.base_model_id: diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index d03c37ae1a..93745440c4 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -15,7 +15,7 @@ from typing import Optional, Union from urllib.parse import urlparse import aiohttp from aiocache import cached -import requests + from open_webui.utils.headers import include_user_info_headers from open_webui.models.chats import Chats @@ -107,19 +107,24 @@ async def send_get_request(url, key=None, user: UserModel = None): return None -async def send_post_request( +async def send_request( url: str, - payload: Union[str, bytes], - stream: bool = True, + method: str = 'POST', + *, + payload: Optional[Union[str, bytes]] = None, key: Optional[str] = None, - content_type: Optional[str] = None, user: UserModel = None, + stream: bool = False, + content_type: Optional[str] = None, metadata: Optional[dict] = None, ): r = None streaming = False try: - session = aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)) + session = aiohttp.ClientSession( + trust_env=True, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), + ) headers = { 'Content-Type': 'application/json', @@ -131,32 +136,29 @@ async def send_post_request( if metadata and metadata.get('chat_id'): headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get('chat_id') - r = await session.post( - url, - data=payload, - headers=headers, + r = await session.request( + method, url, data=payload, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) - if r.ok is False: + if not r.ok: try: res = await r.json() - await cleanup_response(r, session) if 'error' in res: raise HTTPException(status_code=r.status, detail=res['error']) - except HTTPException as e: - raise e # Re-raise HTTPException to be handled by FastAPI + except HTTPException: + raise except Exception as e: log.error(f'Failed to parse error response: {e}') - raise HTTPException( - status_code=r.status, - detail=f'Open WebUI: Server Connection Error', - ) + raise HTTPException( + status_code=r.status, + detail='Open WebUI: Server Connection Error', + ) + + r.raise_for_status() - r.raise_for_status() # Raises an error for bad responses (4xx, 5xx) if stream: response_headers = dict(r.headers) - if content_type: response_headers['Content-Type'] = content_type @@ -167,17 +169,17 @@ async def send_post_request( headers=response_headers, ) else: - res = await r.json() - return res + try: + return await r.json() + except Exception: + return None - except HTTPException as e: - raise e # Re-raise HTTPException to be handled by FastAPI + except HTTPException: + raise except Exception as e: - detail = f'Ollama: {e}' - raise HTTPException( status_code=r.status if r else 500, - detail=detail if e else 'Open WebUI: Server Connection Error', + detail=f'Ollama: {e}' if str(e) else 'Open WebUI: Server Connection Error', ) finally: if not streaming: @@ -430,40 +432,7 @@ async def get_ollama_tags(request: Request, url_idx: Optional[int] = None, user= else: url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) - - r = None - try: - headers = { - **({'Authorization': f'Bearer {key}'} if key else {}), - } - - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) - - r = requests.request( - method='GET', - url=f'{url}/api/tags', - headers=headers, - ) - r.raise_for_status() - - models = r.json() - except Exception as e: - log.exception(e) - - detail = None - if r is not None: - try: - res = r.json() - if 'error' in res: - detail = f'Ollama: {res["error"]}' - except Exception: - detail = f'Ollama: {e}' - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=detail if detail else 'Open WebUI: Server Connection Error', - ) + models = await send_request(f'{url}/api/tags', 'GET', key=key, user=user) if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL: models['models'] = await get_filtered_models(models, user) @@ -569,29 +538,7 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None): ) else: url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] - - r = None - try: - r = requests.request(method='GET', url=f'{url}/api/version') - r.raise_for_status() - - return r.json() - except Exception as e: - log.exception(e) - - detail = None - if r is not None: - try: - res = r.json() - if 'error' in res: - detail = f'Ollama: {res["error"]}' - except Exception: - detail = f'Ollama: {e}' - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=detail if detail else 'Open WebUI: Server Connection Error', - ) + return await send_request(f'{url}/api/version', 'GET') else: return {'version': False} @@ -640,10 +587,9 @@ async def unload_model( payload = {'model': model_name, 'keep_alive': 0, 'prompt': ''} try: - res = await send_post_request( - url=f'{url}/api/generate', + res = await send_request( + f'{url}/api/generate', payload=json.dumps(payload), - stream=False, key=key, user=user, ) @@ -681,11 +627,12 @@ async def pull_model( # Admin should be able to pull models from any source payload = {**form_data, 'insecure': True} - return await send_post_request( - url=f'{url}/api/pull', + return await send_request( + f'{url}/api/pull', payload=json.dumps(payload), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, + stream=True, ) @@ -721,11 +668,12 @@ async def push_model( url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] log.debug(f'url: {url}') - return await send_post_request( - url=f'{url}/api/push', + 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, request.app.state.config.OLLAMA_API_CONFIGS), user=user, + stream=True, ) @@ -751,11 +699,12 @@ async def create_model( log.debug(f'form_data: {form_data}') url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] - return await send_post_request( - url=f'{url}/api/create', + 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, request.app.state.config.OLLAMA_API_CONFIGS), user=user, + stream=True, ) @@ -790,41 +739,13 @@ async def copy_model( url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) - try: - 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) - - r = requests.request( - method='POST', - url=f'{url}/api/copy', - headers=headers, - data=form_data.model_dump_json(exclude_none=True).encode(), - ) - r.raise_for_status() - - log.debug(f'r.text: {r.text}') - return True - except Exception as e: - log.exception(e) - - detail = None - if r is not None: - try: - res = r.json() - if 'error' in res: - detail = f'Ollama: {res["error"]}' - except Exception: - detail = f'Ollama: {e}' - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=detail if detail else 'Open WebUI: Server Connection Error', - ) + await send_request( + f'{url}/api/copy', + payload=form_data.model_dump_json(exclude_none=True).encode(), + key=key, + user=user, + ) + return True @router.delete('/api/delete') @@ -858,42 +779,13 @@ async def delete_model( url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) - r = None - try: - 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) - - r = requests.request( - method='DELETE', - url=f'{url}/api/delete', - headers=headers, - json=form_data, - ) - r.raise_for_status() - - log.debug(f'r.text: {r.text}') - return True - except Exception as e: - log.exception(e) - - detail = None - if r is not None: - try: - res = r.json() - if 'error' in res: - detail = f'Ollama: {res["error"]}' - except Exception: - detail = f'Ollama: {e}' - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=detail if detail else 'Open WebUI: Server Connection Error', - ) + await send_request( + f'{url}/api/delete', 'DELETE', + payload=json.dumps(form_data), + key=key, + user=user, + ) + return True @router.post('/api/show') @@ -920,35 +812,12 @@ async def show_model_info(request: Request, form_data: ModelNameForm, user=Depen url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) - try: - 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) - - r = requests.request(method='POST', url=f'{url}/api/show', headers=headers, json=form_data) - r.raise_for_status() - - return r.json() - except Exception as e: - log.exception(e) - - detail = None - if r is not None: - try: - res = r.json() - if 'error' in res: - detail = f'Ollama: {res["error"]}' - except Exception: - detail = f'Ollama: {e}' - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=detail if detail else 'Open WebUI: Server Connection Error', - ) + return await send_request( + f'{url}/api/show', + payload=json.dumps(form_data), + key=key, + user=user, + ) class GenerateEmbedForm(BaseModel): @@ -1004,41 +873,12 @@ async def embed( if prefix_id: form_data.model = form_data.model.replace(f'{prefix_id}.', '') - try: - 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) - - r = requests.request( - method='POST', - url=f'{url}/api/embed', - headers=headers, - data=form_data.model_dump_json(exclude_none=True).encode(), - ) - r.raise_for_status() - - data = r.json() - return data - except Exception as e: - log.exception(e) - - detail = None - if r is not None: - try: - res = r.json() - if 'error' in res: - detail = f'Ollama: {res["error"]}' - except Exception: - detail = f'Ollama: {e}' - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=detail if detail else 'Open WebUI: Server Connection Error', - ) + 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): @@ -1089,41 +929,12 @@ async def embeddings( if prefix_id: form_data.model = form_data.model.replace(f'{prefix_id}.', '') - try: - 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) - - r = requests.request( - method='POST', - url=f'{url}/api/embeddings', - headers=headers, - data=form_data.model_dump_json(exclude_none=True).encode(), - ) - r.raise_for_status() - - data = r.json() - return data - except Exception as e: - log.exception(e) - - detail = None - if r is not None: - try: - res = r.json() - if 'error' in res: - detail = f'Ollama: {res["error"]}' - except Exception: - detail = f'Ollama: {e}' - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=detail if detail else 'Open WebUI: Server Connection Error', - ) + 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): @@ -1175,11 +986,12 @@ async def generate_completion( if prefix_id: form_data.model = form_data.model.replace(f'{prefix_id}.', '') - return await send_post_request( - url=f'{url}/api/generate', + 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, request.app.state.config.OLLAMA_API_CONFIGS), user=user, + stream=True, ) @@ -1319,13 +1131,13 @@ async def generate_chat_completion( if prefix_id: payload['model'] = payload['model'].replace(f'{prefix_id}.', '') - return await send_post_request( - url=f'{url}/api/chat', + return await send_request( + f'{url}/api/chat', payload=json.dumps(payload), - stream=form_data.stream, key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), - content_type='application/x-ndjson', user=user, + stream=form_data.stream, + content_type='application/x-ndjson', metadata=metadata, ) @@ -1429,12 +1241,12 @@ async def generate_openai_completion( if prefix_id: payload['model'] = payload['model'].replace(f'{prefix_id}.', '') - return await send_post_request( - url=f'{url}/v1/completions', + return await send_request( + f'{url}/v1/completions', payload=json.dumps(payload), - stream=payload.get('stream', False), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, + stream=payload.get('stream', False), metadata=metadata, ) @@ -1514,12 +1326,12 @@ async def generate_openai_chat_completion( if prefix_id: payload['model'] = payload['model'].replace(f'{prefix_id}.', '') - return await send_post_request( - url=f'{url}/v1/chat/completions', + return await send_request( + f'{url}/v1/chat/completions', payload=json.dumps(payload), - stream=payload.get('stream', False), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, + stream=payload.get('stream', False), metadata=metadata, ) @@ -1586,13 +1398,91 @@ async def generate_anthropic_messages( if prefix_id: payload['model'] = payload['model'].replace(f'{prefix_id}.', '') - return await send_post_request( - url=f'{url}/v1/messages', + return await send_request( + f'{url}/v1/messages', payload=json.dumps(payload), - stream=payload.get('stream', False), - content_type='text/event-stream' if payload.get('stream', False) else None, key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, + stream=payload.get('stream', False), + content_type='text/event-stream' if payload.get('stream', False) else None, + ) + + +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: Optional[int] = 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 request.app.state.config.ENABLE_OLLAMA_API: + raise HTTPException(status_code=503, detail='Ollama API is disabled') + + payload = form_data.model_dump() + model_id = form_data.model + + model_info = Models.get_model_by_id(model_id) + if model_info: + if model_info.base_model_id: + payload['model'] = model_info.base_model_id + + # Check if user has access to the model + if user.role == 'user': + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + if not ( + user.id == model_info.user_id + or AccessGrants.has_access( + user_id=user.id, + resource_type='model', + resource_id=model_info.id, + permission='read', + user_group_ids=user_group_ids, + ) + ): + raise HTTPException( + status_code=403, + detail='Model not found', + ) + else: + if user.role != 'admin': + raise HTTPException( + status_code=403, + detail='Model not found', + ) + + url, url_idx = await get_ollama_url(request, payload['model'], url_idx) + api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + str(url_idx), + request.app.state.config.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, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, + stream=payload.get('stream', False), + content_type='text/event-stream' if payload.get('stream', False) else None, ) @@ -1619,36 +1509,17 @@ async def get_openai_models( else: url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] - try: - r = requests.request(method='GET', url=f'{url}/api/tags') - r.raise_for_status() + model_list = await send_request(f'{url}/api/tags', 'GET') - model_list = r.json() - - models = [ - { - 'id': model['model'], - 'object': 'model', - 'created': int(time.time()), - 'owned_by': 'openai', - } - for model in models['models'] - ] - except Exception as e: - log.exception(e) - error_detail = 'Open WebUI: Server Connection Error' - if r is not None: - try: - res = r.json() - if 'error' in res: - error_detail = f'Ollama: {res["error"]}' - except Exception: - error_detail = f'Ollama: {e}' - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=error_detail, - ) + models = [ + { + 'id': model['model'], + 'object': 'model', + 'created': int(time.time()), + 'owned_by': 'openai', + } + for model in model_list.get('models', []) + ] if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL: # Filter models based on user access control @@ -1734,13 +1605,16 @@ async def download_file_stream(ollama_url, file_url, file_path, file_name, chunk file.close() hashed = calculate_sha256(file_path, chunk_size) - with open(file_path, 'rb') as file: - chunk_size = 1024 * 1024 * 2 - url = f'{ollama_url}/api/blobs/sha256:{hashed}' - with requests.Session() as session: - response = session.post(url, data=file, timeout=30) + with open(file_path, 'rb') as f: + blob_data = f.read() - if response.ok: + url = f'{ollama_url}/api/blobs/sha256:{hashed}' + blob_timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=blob_timeout, trust_env=True) as blob_session: + async with blob_session.post( + url, data=blob_data, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as blob_response: + if blob_response.ok: res = { 'done': done, 'blob': f'sha256:{hashed}', @@ -1836,47 +1710,53 @@ async def upload_model( # --- P3: Upload to ollama /api/blobs --- with open(file_path, 'rb') as f: - url = f'{ollama_url}/api/blobs/sha256:{file_hash}' - response = requests.post(url, data=f) + blob_data = f.read() - if response.ok: - log.info(f'Uploaded to /api/blobs') # DEBUG - # Remove local file - os.remove(file_path) + url = f'{ollama_url}/api/blobs/sha256:{file_hash}' + upload_timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + async with aiohttp.ClientSession(timeout=upload_timeout, trust_env=True) as upload_session: + async with upload_session.post( + url, data=blob_data, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as response: + if not response.ok: + raise Exception('Ollama: Could not create blob, Please try again.') - # Create model in ollama - model_name, ext = os.path.splitext(filename) - log.info(f'Created Model: {model_name}') # DEBUG + log.info(f'Uploaded to /api/blobs') # DEBUG + # Remove local file + os.remove(file_path) - create_payload = { - 'model': model_name, - # Reference the file by its original name => the uploaded blob's digest - 'files': {filename: f'sha256:{file_hash}'}, - } - log.info(f'Model Payload: {create_payload}') # DEBUG + # Create model in ollama + model_name, ext = os.path.splitext(filename) + log.info(f'Created Model: {model_name}') # DEBUG - # Call ollama /api/create - # https://github.com/ollama/ollama/blob/main/docs/api.md#create-a-model - create_resp = requests.post( - url=f'{ollama_url}/api/create', + create_payload = { + 'model': model_name, + # Reference the file by its original name => the uploaded blob's digest + 'files': {filename: f'sha256:{file_hash}'}, + } + log.info(f'Model Payload: {create_payload}') # DEBUG + + # Call ollama /api/create + # https://github.com/ollama/ollama/blob/main/docs/api.md#create-a-model + async with aiohttp.ClientSession(timeout=upload_timeout, trust_env=True) as create_session: + async with create_session.post( + f'{ollama_url}/api/create', headers={'Content-Type': 'application/json'}, data=json.dumps(create_payload), - ) - - if create_resp.ok: - log.info(f'API SUCCESS!') # DEBUG - done_msg = { - 'done': True, - 'blob': f'sha256:{file_hash}', - 'name': filename, - 'model_created': model_name, - } - yield f'data: {json.dumps(done_msg)}\n\n' - else: - raise Exception(f'Failed to create model in Ollama. {create_resp.text}') - - else: - raise Exception('Ollama: Could not create blob, Please try again.') + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as create_resp: + if create_resp.ok: + log.info(f'API SUCCESS!') # DEBUG + done_msg = { + 'done': True, + 'blob': f'sha256:{file_hash}', + 'name': filename, + 'model_created': model_name, + } + yield f'data: {json.dumps(done_msg)}\n\n' + else: + resp_text = await create_resp.text() + raise Exception(f'Failed to create model in Ollama. {resp_text}') except Exception as e: res = {'error': str(e)} diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 0e7c67c1f6..836517df9d 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -1131,21 +1131,31 @@ async def generate_chat_completion( is_responses = api_config.get('api_type') == 'responses' if api_config.get('azure', False): - api_version = api_config.get('api_version', '2023-03-15-preview') - request_url, payload = convert_to_azure_payload(url, payload, api_version) - # Only set api-key header if not using Azure Entra ID authentication auth_type = api_config.get('auth_type', 'bearer') if auth_type not in ('azure_ad', 'microsoft_entra_id'): headers['api-key'] = key - headers['api-version'] = api_version + # Azure v1 format: base URL already ends with /openai/v1, + # model stays in the payload, no deployment URL rewriting. + is_azure_v1 = bool(re.search(r'/openai/v1(?:/|$)', url)) - if is_responses: - payload = convert_to_responses_payload(payload) - request_url = f'{request_url}/responses?api-version={api_version}' + if is_azure_v1: + if is_responses: + payload = convert_to_responses_payload(payload) + request_url = f'{url.rstrip("/")}/responses' + else: + request_url = f'{url.rstrip("/")}/chat/completions' else: - request_url = f'{request_url}/chat/completions?api-version={api_version}' + api_version = api_config.get('api_version', '2023-03-15-preview') + request_url, payload = convert_to_azure_payload(url, payload, api_version) + headers['api-version'] = api_version + + if is_responses: + payload = convert_to_responses_payload(payload) + request_url = f'{request_url}/responses?api-version={api_version}' + else: + request_url = f'{request_url}/chat/completions?api-version={api_version}' else: if is_responses: payload = convert_to_responses_payload(payload) @@ -1357,16 +1367,19 @@ async def responses( headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user) if api_config.get('azure', False): - api_version = api_config.get('api_version', '2023-03-15-preview') - auth_type = api_config.get('auth_type', 'bearer') if auth_type not in ('azure_ad', 'microsoft_entra_id'): headers['api-key'] = key - headers['api-version'] = api_version + is_azure_v1 = bool(re.search(r'/openai/v1(?:/|$)', url)) - model = payload.get('model', '') - request_url = f'{url}/openai/deployments/{model}/responses?api-version={api_version}' + if is_azure_v1: + request_url = f'{url.rstrip("/")}/responses' + else: + api_version = api_config.get('api_version', '2023-03-15-preview') + headers['api-version'] = api_version + model = payload.get('model', '') + request_url = f'{url}/openai/deployments/{model}/responses?api-version={api_version}' else: request_url = f'{url}/responses' @@ -1459,20 +1472,25 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user) if api_config.get('azure', False): - api_version = api_config.get('api_version', '2023-03-15-preview') - # Only set api-key header if not using Azure Entra ID authentication auth_type = api_config.get('auth_type', 'bearer') if auth_type not in ('azure_ad', 'microsoft_entra_id'): headers['api-key'] = key - headers['api-version'] = api_version + is_azure_v1 = bool(re.search(r'/openai/v1(?:/|$)', url)) - payload = json.loads(body) - url, payload = convert_to_azure_payload(url, payload, api_version) - body = json.dumps(payload).encode() + if is_azure_v1: + qs = request.url.query + request_url = f'{url.rstrip("/")}/{path}' + (f'?{qs}' if qs else '') + else: + api_version = api_config.get('api_version', '2023-03-15-preview') + headers['api-version'] = api_version - request_url = f'{url}/{path}?api-version={api_version}' + payload = json.loads(body) + url, payload = convert_to_azure_payload(url, payload, api_version) + body = json.dumps(payload).encode() + + request_url = f'{url}/{path}?api-version={api_version}' else: request_url = f'{url}/{path}' diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 3c63d318c3..1823e71df2 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -149,7 +149,7 @@ async def calculate_timestamp( async def search_web( query: str, - count: int = 5, + count: Optional[int] = None, __request__: Request = None, __user__: dict = None, ) -> str: @@ -158,7 +158,7 @@ async def search_web( or topics not covered in internal documents. :param query: The search query to look up - :param count: Number of results to return (default: 5) + :param count: Number of results to return (default: admin-configured value) :return: JSON with search results containing title, link, and snippet for each result """ if __request__ is None: @@ -168,12 +168,9 @@ async def search_web( engine = __request__.app.state.config.WEB_SEARCH_ENGINE user = UserModel(**__user__) if __user__ else None - # Enforce maximum result count from config to prevent abuse - count = ( - count - if count < __request__.app.state.config.WEB_SEARCH_RESULT_COUNT - else __request__.app.state.config.WEB_SEARCH_RESULT_COUNT - ) + configured = __request__.app.state.config.WEB_SEARCH_RESULT_COUNT + max_count = 5 if configured is None else configured + count = max(1, min(count, max_count)) if count is not None else max_count results = await asyncio.to_thread(_search_web, __request__, engine, query, user) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 54df422c4b..0dedd7f2f6 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2680,9 +2680,12 @@ async def process_chat_payload(request, form_data, user, metadata, model): tools_dict[name] = tool_dict if tools_dict: + # Always store resolved tools in metadata so downstream consumers + # (e.g. pipe functions) can access all tools including MCP and builtins. + metadata['tools'] = tools_dict + if metadata.get('params', {}).get('function_calling') == 'native': # If the function calling is native, then call the tools function calling handler - metadata['tools'] = tools_dict form_data['tools'] = [ {'type': 'function', 'function': tool.get('spec', {})} for tool in tools_dict.values() ] diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index bf6be461e6..8aaadc271a 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -81,6 +81,7 @@ from open_webui.utils.misc import parse_duration from open_webui.utils.auth import get_password_hash, create_token from open_webui.utils.webhook import post_webhook from open_webui.utils.groups import apply_default_group_assignment +from open_webui.retrieval.web.utils import validate_url from mcp.shared.auth import ( OAuthClientMetadata as MCPOAuthClientMetadata, @@ -1330,6 +1331,8 @@ class OAuthManager: return '/user.png' try: + validate_url(picture_url) + get_kwargs = {} if access_token: get_kwargs['headers'] = { diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 2237c1afc6..d146aeca0c 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -917,10 +917,8 @@ 'MCP support is experimental and its specification changes often, which can lead to incompatibilities. OpenAPI specification support is directly maintained by the Open WebUI team, making it the more reliable option for compatibility.' )} - {$i18n.t('Read more →')}{$i18n.t('Read more →')} {/if} diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 381d26cd53..3f0f8d13e4 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -46,7 +46,8 @@ selectedTerminalId, showFileNavPath, showFileNavDir, - chatRequestQueues + chatRequestQueues, + desktopEvent } from '$lib/stores'; import { WEBUI_API_BASE_URL } from '$lib/constants'; @@ -1225,7 +1226,31 @@ showControls.set(true); } - if ($page.url.searchParams.get('q')) { + // Consume one-shot desktop event (e.g. Spotlight query + attachments) + if ($desktopEvent) { + const { query, files: eventFiles } = $desktopEvent; + desktopEvent.set(null); + + // Attach screenshot images from desktop (e.g. Spotlight region capture) + if (eventFiles?.length) { + for (const ef of eventFiles) { + files = [ + ...files, + { + type: 'image', + url: ef.dataUrl, + name: ef.name + } + ]; + } + } + + if (query) { + messageInput?.setText(query); + await tick(); + submitHandler(query); + } + } else if ($page.url.searchParams.get('q')) { const q = $page.url.searchParams.get('q') ?? ''; messageInput?.setText(q); diff --git a/src/lib/components/chat/FileNav/FilePreview.svelte b/src/lib/components/chat/FileNav/FilePreview.svelte index 0bfa4fe504..b5a3a30cc4 100644 --- a/src/lib/components/chat/FileNav/FilePreview.svelte +++ b/src/lib/components/chat/FileNav/FilePreview.svelte @@ -103,6 +103,13 @@ $: isNotebook = getExt(selectedFile) === 'ipynb'; $: isCode = isCodeFile(selectedFile); $: csvDelimiter = getExt(selectedFile) === 'tsv' ? '\t' : ','; + + // For HTML files on system terminals (proxy URL), use path-based serving + // so the iframe can resolve relative CSS/JS/image references via cookie auth. + $: serveUrl = + isHtml && selectedFile && baseUrl && baseUrl.includes('/api/v1/terminals/') + ? `${baseUrl}/files/serve/${selectedFile.replace(/^\//, '')}` + : null; $: renderedHtml = isMarkdown && fileContent ? DOMPurify.sanitize(marked.parse(fileContent, { async: false }) as string) @@ -386,7 +393,19 @@ {/if} {:else if fileContent !== null} - {#if isHtml && !showRaw} + {#if isHtml && !showRaw && serveUrl} + {#if overlay} +
+ {/if} +