From 75df948f34756c66391f48974c3844fdd8255a8e Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:58:19 +0200 Subject: [PATCH] feat: forward client User-Agent to model backends via {{USER_AGENT}} placeholder (#26333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a {{USER_AGENT}} custom-header placeholder that relays the inbound client's User-Agent to upstream model backends, so providers see the real client instead of Open WebUI's internal aiohttp UA. This makes upstream usage/cost attribution and backend telemetry possible, and is opt-in per-connection (no global flag): admins add {{USER_AGENT}} to a connection's custom headers in Admin > Settings > Connections. The placeholder is sourced from the live inbound request (with a metadata fallback for detached RAG/tool calls), so it resolves on every prompt-sending path, not just chat completions: - OpenAI completions, Responses API, and proxy — all route through get_headers_and_cookies, which now passes the request into get_custom_headers. - Anthropic Messages API (/api/v1/messages) — already covered, it delegates to the chat completion handler. - Ollama (/api/chat, /v1/completions, /v1/chat/completions, /v1/messages, /v1/responses) — previously had no custom-header support at all; send_request now applies per-connection custom headers (with templating) for every Ollama prompt endpoint. Custom headers are applied after the built-in user-info headers so explicit admin-configured headers take precedence. The other existing placeholders ({{CHAT_ID}}, {{USER_ID}}, ...) now also work on the newly covered paths. Frontend: the connection editor's Headers field is now shown for Ollama connections too (previously gated to non-Ollama), so the placeholder can be configured there. Ref: open-webui/open-webui#26159 --- backend/open_webui/main.py | 1 + backend/open_webui/routers/ollama.py | 18 +++++++++++++++++- backend/open_webui/routers/openai.py | 2 +- backend/open_webui/utils/headers.py | 13 ++++++++++++- src/lib/components/AddConnectionModal.svelte | 2 +- 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 594149d343..bc9d2a4cb5 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1168,6 +1168,7 @@ async def chat_completion( metadata = { 'user_id': user.id, + 'user_agent': request.headers.get('user-agent', '') or '', 'chat_id': form_data.pop('chat_id', None) or '', 'user_message': user_message, 'user_message_id': user_message.get('id') if user_message else None, diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index da3060ae7b..7fc75996ae 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -38,7 +38,7 @@ 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 include_user_info_headers +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, @@ -99,6 +99,8 @@ async def send_request( 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 @@ -115,6 +117,10 @@ async def send_request( 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, @@ -1100,6 +1106,8 @@ async def generate_chat_completion( stream=form_data.stream, content_type='application/x-ndjson', metadata=metadata, + api_config=api_config, + request=request, ) @@ -1185,6 +1193,8 @@ async def generate_openai_completion( user=user, stream=payload.get('stream', False), metadata=metadata, + api_config=api_config, + request=request, ) @@ -1242,6 +1252,8 @@ async def generate_openai_chat_completion( user=user, stream=payload.get('stream', False), metadata=metadata, + api_config=api_config, + request=request, ) @@ -1294,6 +1306,8 @@ async def generate_anthropic_messages( 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, ) @@ -1352,6 +1366,8 @@ async def generate_responses( 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, ) diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index a30e24d8e4..bcf8718e96 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -209,7 +209,7 @@ async def get_headers_and_cookies( headers['Authorization'] = f'Bearer {token}' if config.get('headers') and isinstance(config.get('headers'), dict): - custom_headers = get_custom_headers(config.get('headers'), user, metadata) + custom_headers = get_custom_headers(config.get('headers'), user, metadata, request=request) headers.update(custom_headers) return headers, cookies diff --git a/backend/open_webui/utils/headers.py b/backend/open_webui/utils/headers.py index 5dda0e451f..f7b7297083 100644 --- a/backend/open_webui/utils/headers.py +++ b/backend/open_webui/utils/headers.py @@ -59,12 +59,22 @@ def include_user_info_headers(headers: dict, user: Optional[Any] = None) -> dict } -def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None) -> dict: +def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None, request=None) -> dict: if not custom_headers or not isinstance(custom_headers, dict): return {} metadata = metadata or {} + # UA from the live request; fall back to metadata for detached RAG/tool calls. + user_agent = '' + if request is not None: + try: + user_agent = request.headers.get('user-agent', '') or '' + except Exception: + user_agent = '' + if not user_agent: + user_agent = metadata.get('user_agent', '') or '' + # Extract user_message info for tree mapping user_message = metadata.get('user_message') or {} user_message_id = metadata.get('user_message_id', '') or (user_message.get('id', '') if user_message else '') @@ -83,6 +93,7 @@ def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None) - '{{USER_NAME}}': (user.name.strip() if user else '') or '', '{{USER_EMAIL}}': (user.email.strip() if user else '') or '', '{{USER_ROLE}}': (user.role if user else '') or '', + '{{USER_AGENT}}': user_agent, } parsed_headers = {} diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index 37f4a7eb68..26b1190465 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -436,7 +436,7 @@ - {#if !ollama && !direct} + {#if !direct}