open-webui/backend/open_webui/utils/headers.py
Classic298 bae22752d3
feat: add {{MODEL_ID}} placeholder for custom connection headers (#32426)
Custom headers on connections and tool servers could pass the chat, message, user and task to downstream services, but not which model the request was for. Chat requests can now use {{MODEL_ID}}, which holds the ID of the model handling the request; for a workspace model that is the workspace model's own ID. In a multi-model chat each model's request carries its own ID, so downstream services can route by model or log usage per model.

Fixes #30006
2026-10-15 00:00:41 +04:00

251 lines
9.2 KiB
Python

import logging
import time
from string import punctuation
from typing import Any, Optional
from urllib.parse import quote
import jwt
from fastapi import Request
from open_webui.env import (
ENABLE_FORWARD_USER_INFO_HEADERS,
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
FORWARD_USER_INFO_HEADER_AUTH_TYPE,
FORWARD_USER_INFO_HEADER_JWT,
FORWARD_USER_INFO_HEADER_JWT_EXPIRES_SECONDS,
FORWARD_USER_INFO_HEADER_JWT_SECRET,
FORWARD_USER_INFO_HEADER_USER_EMAIL,
FORWARD_USER_INFO_HEADER_USER_ID,
FORWARD_USER_INFO_HEADER_USER_NAME,
FORWARD_USER_INFO_HEADER_USER_ROLE,
)
from open_webui.models.groups import Groups
log = logging.getLogger(__name__)
USER_GROUPS_PLACEHOLDERS = ('{{USER_GROUPS}}', '{{USER_GROUP_IDS}}')
def normalize_bearer_token(token: Any) -> str:
return token.strip() if isinstance(token, str) else token or ''
def bearer_auth_header(token: Any) -> dict[str, str]:
token = normalize_bearer_token(token)
return {'Authorization': f'Bearer {token}'} if token else {}
def get_json_bearer_headers(token: Any = '') -> dict[str, str]:
return {'Content-Type': 'application/json', **bearer_auth_header(token)}
def _mint_forward_user_jwt(user: Any) -> str:
now = int(time.time())
payload = {
'sub': str(user.id),
'email': str(user.email),
'name': str(user.name),
'role': str(user.role),
'iss': 'open-webui',
'iat': now,
'exp': now + FORWARD_USER_INFO_HEADER_JWT_EXPIRES_SECONDS,
}
return jwt.encode(payload, FORWARD_USER_INFO_HEADER_JWT_SECRET, algorithm='HS256')
def include_user_info_headers(headers: dict, user: Optional[Any] = None, *, request=None) -> dict:
"""
Forward user identity to external backends: signed JWT in
FORWARD_USER_INFO_HEADER_JWT if FORWARD_USER_INFO_HEADER_JWT_SECRET is set;
otherwise the legacy X-OpenWebUI-User-* headers.
Include the verified incoming auth type when a request provides it.
"""
if user is None:
return headers
auth_type = getattr(getattr(request, 'state', None), 'auth_type', None)
if auth_type in ('api_key', 'jwt'):
headers = {**headers, FORWARD_USER_INFO_HEADER_AUTH_TYPE: auth_type}
if FORWARD_USER_INFO_HEADER_JWT_SECRET:
try:
token = _mint_forward_user_jwt(user)
return {**headers, FORWARD_USER_INFO_HEADER_JWT: token}
except Exception:
log.exception(
'Failed to mint %s; falling back to plain user-info headers.',
FORWARD_USER_INFO_HEADER_JWT,
)
return {
**headers,
FORWARD_USER_INFO_HEADER_USER_NAME: quote(user.name.strip(), safe=' '),
FORWARD_USER_INFO_HEADER_USER_ID: user.id,
FORWARD_USER_INFO_HEADER_USER_EMAIL: user.email.strip(),
FORWARD_USER_INFO_HEADER_USER_ROLE: user.role,
}
def custom_headers_require_user_groups(custom_headers: Optional[dict]) -> bool:
if not custom_headers or not isinstance(custom_headers, dict):
return False
return any(
placeholder in str(value) for value in custom_headers.values() for placeholder in USER_GROUPS_PLACEHOLDERS
)
async def get_user_groups_for_custom_headers(
custom_headers: Optional[dict], user: Optional[Any] = None
) -> Optional[list]:
"""Fetch the user's groups only when a header value actually references a groups placeholder."""
if user is None or not custom_headers_require_user_groups(custom_headers):
return None
try:
return await Groups.get_groups_by_member_id(user.id, include_inherited=True)
except Exception:
log.exception('Failed to resolve user groups for custom headers')
return None
async def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None, request=None) -> dict:
user_groups = await get_user_groups_for_custom_headers(custom_headers, user)
return parse_custom_headers(custom_headers, user, metadata, request=request, user_groups=user_groups)
def parse_custom_headers(
custom_headers: dict, user=None, metadata: dict = None, request=None, user_groups: Optional[list] = 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 '')
user_message_parent_id = user_message.get('parentId', '') if user_message else ''
template_vars = {
'{{CHAT_ID}}': metadata.get('chat_id', '') or '',
'{{MESSAGE_ID}}': metadata.get('message_id', '') or '',
'{{USER_MESSAGE_ID}}': user_message_id or '',
'{{USER_MESSAGE_PARENT_ID}}': user_message_parent_id or '',
'{{FILE_ID}}': metadata.get('file_id', '') or '',
'{{FILE_NAME}}': metadata.get('file_name', '') or '',
'{{FILE_CONTENT_TYPE}}': metadata.get('file_content_type', '') or '',
'{{TASK}}': metadata.get('task', '') or '',
'{{MODEL_ID}}': metadata.get('model_id', '') or '',
'{{USER_ID}}': (user.id if user else '') or '',
'{{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_GROUPS}}': ','.join(group.name.strip() for group in user_groups) if user_groups else '',
'{{USER_GROUP_IDS}}': ','.join(group.id for group in user_groups) if user_groups else '',
'{{USER_AGENT}}': user_agent,
'{{AUTH_TYPE}}': getattr(getattr(request, 'state', None), 'auth_type', None) or '',
}
parsed_headers = {}
for key, value in custom_headers.items():
if not isinstance(value, str):
value = str(value)
for token, val in template_vars.items():
value = value.replace(token, val)
# Encode Unicode and controls after substitution; preserve ASCII header syntax and existing escapes.
parsed_headers[key] = quote(value, safe=punctuation + ' \t')
return parsed_headers
async def get_headers_and_cookies(
request: Request,
url,
key=None,
config=None,
metadata: dict | None = None,
user=None,
):
config = config or {}
cookies = getattr(request, 'cookies', {}) if config.get('forward_cookies', False) else {}
headers = {
'Content-Type': 'application/json',
**(
{
# LICENSE covers this Open WebUI upstream metadata identifier.
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
'HTTP-Referer': 'https://openwebui.com/',
'X-Title': 'Open WebUI',
}
if 'openrouter.ai' in url
else {}
),
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user, request=request)
if metadata and metadata.get('chat_id'):
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get('chat_id')
token = None
auth_type = config.get('auth_type')
if auth_type == 'bearer' or auth_type is None:
# Default to bearer if not specified
token = key
elif auth_type == 'none':
token = None
elif auth_type == 'session':
token = request.state.token.credentials
elif auth_type == 'system_oauth':
oauth_token = None
try:
if request.cookies.get('oauth_session_id', None):
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get('oauth_session_id', None),
)
except Exception as e:
log.error(f'Error getting OAuth token: {e}')
if oauth_token:
token = f'{oauth_token.get("access_token", "")}'
elif auth_type in ('azure_ad', 'microsoft_entra_id'):
token = get_microsoft_entra_id_access_token()
if token:
headers['Authorization'] = f'Bearer {token}'
if config.get('headers') and isinstance(config.get('headers'), dict):
custom_headers = await get_custom_headers(config.get('headers'), user, metadata, request=request)
headers.update(custom_headers)
return headers, cookies
def get_microsoft_entra_id_access_token():
"""
Get Microsoft Entra ID access token using DefaultAzureCredential for Azure OpenAI.
Returns the token string or None if authentication fails.
"""
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
try:
token_provider = get_bearer_token_provider(
DefaultAzureCredential(), 'https://cognitiveservices.azure.com/.default'
)
return token_provider()
except Exception as e:
log.error(f'Error getting Microsoft Entra ID access token: {e}')
return None