This commit is contained in:
Timothy Jaeryang Baek
2026-03-17 17:58:01 -05:00
parent fcf7208352
commit de3317e26b
220 changed files with 17200 additions and 22836 deletions
@@ -13,9 +13,7 @@ from open_webui.config import DEFAULT_USER_PERMISSIONS
from sqlalchemy.orm import Session
def fill_missing_permissions(
permissions: dict[str, Any], default_permissions: dict[str, Any]
) -> dict[str, Any]:
def fill_missing_permissions(permissions: dict[str, Any], default_permissions: dict[str, Any]) -> dict[str, Any]:
"""
Recursively fills in missing properties in the permissions dictionary
using the default permissions as a template.
@@ -23,9 +21,7 @@ def fill_missing_permissions(
for key, value in default_permissions.items():
if key not in permissions:
permissions[key] = value
elif isinstance(value, dict) and isinstance(
permissions[key], dict
): # Both are nested dictionaries
elif isinstance(value, dict) and isinstance(permissions[key], dict): # Both are nested dictionaries
permissions[key] = fill_missing_permissions(permissions[key], value)
return permissions
@@ -42,9 +38,7 @@ def get_permissions(
Permissions are nested in a dict with the permission key as the key and a boolean as the value.
"""
def combine_permissions(
permissions: dict[str, Any], group_permissions: dict[str, Any]
) -> dict[str, Any]:
def combine_permissions(permissions: dict[str, Any], group_permissions: dict[str, Any]) -> dict[str, Any]:
"""Combine permissions from multiple groups by taking the most permissive value."""
for key, value in group_permissions.items():
if isinstance(value, dict):
@@ -55,9 +49,7 @@ def get_permissions(
if key not in permissions:
permissions[key] = value
else:
permissions[key] = (
permissions[key] or value
) # Use the most permissive value (True > False)
permissions[key] = permissions[key] or value # Use the most permissive value (True > False)
return permissions
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
@@ -97,7 +89,7 @@ def has_permission(
return bool(permissions) # Return the boolean at the final level
permission_hierarchy = permission_key.split(".")
permission_hierarchy = permission_key.split('.')
# Retrieve user group permissions
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
@@ -107,15 +99,13 @@ def has_permission(
return True
# Check default permissions afterward if the group permissions don't allow it
default_permissions = fill_missing_permissions(
default_permissions, DEFAULT_USER_PERMISSIONS
)
default_permissions = fill_missing_permissions(default_permissions, DEFAULT_USER_PERMISSIONS)
return get_permission(default_permissions, permission_hierarchy)
def has_access(
user_id: str,
permission: str = "read",
permission: str = 'read',
access_grants: list | None = None,
user_group_ids: set[str] | None = None,
db: Session | None = None,
@@ -141,19 +131,13 @@ def has_access(
for grant in access_grants:
if not isinstance(grant, dict):
continue
if grant.get("permission") != permission:
if grant.get('permission') != permission:
continue
principal_type = grant.get("principal_type")
principal_id = grant.get("principal_id")
if principal_type == "user" and (
principal_id == "*" or principal_id == user_id
):
principal_type = grant.get('principal_type')
principal_id = grant.get('principal_id')
if principal_type == 'user' and (principal_id == '*' or principal_id == user_id):
return True
if (
principal_type == "group"
and user_group_ids
and principal_id in user_group_ids
):
if principal_type == 'group' and user_group_ids and principal_id in user_group_ids:
return True
return False
@@ -174,19 +158,17 @@ def has_connection_access(
"""
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
return True
if user_group_ids is None:
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
access_grants = (connection.get("config") or {}).get("access_grants", [])
return has_access(user.id, "read", access_grants, user_group_ids)
access_grants = (connection.get('config') or {}).get('access_grants', [])
return has_access(user.id, 'read', access_grants, user_group_ids)
def migrate_access_control(
data: dict, ac_key: str = "access_control", grants_key: str = "access_grants"
) -> None:
def migrate_access_control(data: dict, ac_key: str = 'access_control', grants_key: str = 'access_grants') -> None:
"""
Auto-migrate a config dict in-place from legacy access_control dict to access_grants list.
@@ -202,24 +184,24 @@ def migrate_access_control(
grants: list[dict[str, str]] = []
if access_control and isinstance(access_control, dict):
for perm in ["read", "write"]:
for perm in ['read', 'write']:
perm_data = access_control.get(perm, {})
if not perm_data:
continue
for group_id in perm_data.get("group_ids", []):
for group_id in perm_data.get('group_ids', []):
grants.append(
{
"principal_type": "group",
"principal_id": group_id,
"permission": perm,
'principal_type': 'group',
'principal_id': group_id,
'permission': perm,
}
)
for uid in perm_data.get("user_ids", []):
for uid in perm_data.get('user_ids', []):
grants.append(
{
"principal_type": "user",
"principal_id": uid,
"permission": perm,
'principal_type': 'user',
'principal_id': uid,
'permission': perm,
}
)
@@ -239,7 +221,7 @@ def filter_allowed_access_grants(
Checks if the user has the required permissions to grant access to a resource.
Returns the filtered list of access grants if permissions are missing.
"""
if user_role == "admin" or not access_grants:
if user_role == 'admin' or not access_grants:
return access_grants
# Check if user can share publicly
@@ -253,25 +235,17 @@ def filter_allowed_access_grants(
grant
for grant in access_grants
if not (
(
grant.get("principal_type")
if isinstance(grant, dict)
else getattr(grant, "principal_type", None)
)
== "user"
and (
grant.get("principal_id")
if isinstance(grant, dict)
else getattr(grant, "principal_id", None)
)
== "*"
(grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
== 'user'
and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None))
== '*'
)
]
# Strip individual user sharing if user lacks permission
if has_user_access_grant(access_grants) and not has_permission(
user_id,
"access_grants.allow_users",
'access_grants.allow_users',
default_permissions,
db=db,
):
@@ -31,7 +31,7 @@ def has_access_to_file(
file.user_id == user.id separately before calling this.
"""
file = Files.get_file_by_id(file_id, db=db)
log.debug(f"Checking if user has {access_type} access to file")
log.debug(f'Checking if user has {access_type} access to file')
if not file:
return False
@@ -41,13 +41,11 @@ def has_access_to_file(
# Check if the file is associated with any knowledge bases the user has access to
knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id, db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)}
for knowledge_base in knowledge_bases:
if knowledge_base.user_id == user.id or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_type='knowledge',
resource_id=knowledge_base.id,
permission=access_type,
user_group_ids=user_group_ids,
@@ -55,18 +53,16 @@ def has_access_to_file(
):
return True
knowledge_base_id = file.meta.get("collection_name") if file.meta else None
knowledge_base_id = file.meta.get('collection_name') if file.meta else None
if knowledge_base_id:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(
user.id, access_type, db=db
)
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, access_type, db=db)
for knowledge_base in knowledge_bases:
if knowledge_base.id == knowledge_base_id:
return True
# Check if the file is associated with any channels the user has access to
channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db)
if access_type == "read" and channels:
if access_type == 'read' and channels:
return True
# Check if the file is associated with any chats the user has access to
@@ -77,13 +73,9 @@ def has_access_to_file(
# Check if the file is directly attached to a shared workspace model
for model in Models.get_models_by_user_id(user.id, permission=access_type, db=db):
knowledge_items = getattr(model.meta, "knowledge", None) or []
knowledge_items = getattr(model.meta, 'knowledge', None) or []
for item in knowledge_items:
if (
isinstance(item, dict)
and item.get("type") == "file"
and item.get("id") == file.id
):
if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id:
return True
return False
+34 -36
View File
@@ -21,70 +21,70 @@ log = logging.getLogger(__name__)
async def chat_action(request: Request, action_id: str, form_data: dict, user: Any):
if "." in action_id:
action_id, sub_action_id = action_id.split(".")
if '.' in action_id:
action_id, sub_action_id = action_id.split('.')
else:
sub_action_id = None
action = Functions.get_function_by_id(action_id)
if not action:
raise Exception(f"Action not found: {action_id}")
raise Exception(f'Action not found: {action_id}')
if not request.app.state.MODELS:
await get_all_models(request, user=user)
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
models = {
request.state.model["id"]: request.state.model,
request.state.model['id']: request.state.model,
}
else:
models = request.app.state.MODELS
data = form_data
model_id = data["model"]
model_id = data['model']
if model_id not in models:
raise Exception("Model not found")
raise Exception('Model not found')
model = models[model_id]
__event_emitter__ = get_event_emitter(
{
"chat_id": data["chat_id"],
"message_id": data["id"],
"session_id": data["session_id"],
"user_id": user.id,
'chat_id': data['chat_id'],
'message_id': data['id'],
'session_id': data['session_id'],
'user_id': user.id,
}
)
__event_call__ = get_event_call(
{
"chat_id": data["chat_id"],
"message_id": data["id"],
"session_id": data["session_id"],
"user_id": user.id,
'chat_id': data['chat_id'],
'message_id': data['id'],
'session_id': data['session_id'],
'user_id': user.id,
}
)
function_module, _, _ = get_function_module_from_cache(request, action_id)
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
if hasattr(function_module, 'valves') and hasattr(function_module, 'Valves'):
valves = Functions.get_function_valves_by_id(action_id)
function_module.valves = function_module.Valves(**(valves if valves else {}))
if hasattr(function_module, "action"):
if hasattr(function_module, 'action'):
try:
action = function_module.action
# Get the signature of the function
sig = inspect.signature(action)
params = {"body": data}
params = {'body': data}
# Extra parameters to be passed to the function
extra_params = {
"__model__": model,
"__id__": sub_action_id if sub_action_id is not None else action_id,
"__event_emitter__": __event_emitter__,
"__event_call__": __event_call__,
"__request__": request,
'__model__': model,
'__id__': sub_action_id if sub_action_id is not None else action_id,
'__event_emitter__': __event_emitter__,
'__event_call__': __event_call__,
'__request__': request,
}
# Add extra params in contained in function signature
@@ -92,20 +92,18 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
if key in sig.parameters:
params[key] = value
if "__user__" in sig.parameters:
if '__user__' in sig.parameters:
__user__ = user.model_dump() if isinstance(user, UserModel) else {}
try:
if hasattr(function_module, "UserValves"):
__user__["valves"] = function_module.UserValves(
**Functions.get_user_valves_by_id_and_user_id(
action_id, user.id
)
if hasattr(function_module, 'UserValves'):
__user__['valves'] = function_module.UserValves(
**Functions.get_user_valves_by_id_and_user_id(action_id, user.id)
)
except Exception as e:
log.exception(f"Failed to get user values: {e}")
log.exception(f'Failed to get user values: {e}')
params = {**params, "__user__": __user__}
params = {**params, '__user__': __user__}
if inspect.iscoroutinefunction(action):
data = await action(**params)
@@ -117,15 +115,15 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
request,
action_id,
data,
"action",
'action',
)
if action_embeds:
await __event_emitter__(
{
"type": "embeds",
"data": {
"embeds": action_embeds,
'type': 'embeds',
'data': {
'embeds': action_embeds,
},
}
)
@@ -134,6 +132,6 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
data = processed_result
except Exception as e:
raise Exception(f"Error: {e}")
raise Exception(f'Error: {e}')
return data
+204 -212
View File
@@ -16,7 +16,7 @@ log = logging.getLogger(__name__)
def is_anthropic_url(url: str) -> bool:
"""Check if the URL is an Anthropic API endpoint."""
return "api.anthropic.com" in url
return 'api.anthropic.com' in url
async def get_anthropic_models(url: str, key: str, user: UserModel = None) -> dict:
@@ -31,56 +31,56 @@ async def get_anthropic_models(url: str, key: str, user: UserModel = None) -> di
try:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
headers = {
"x-api-key": key,
"anthropic-version": "2023-06-01",
'x-api-key': key,
'anthropic-version': '2023-06-01',
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
while True:
params = {"limit": 1000}
params = {'limit': 1000}
if after_id:
params["after_id"] = after_id
params['after_id'] = after_id
async with session.get(
f"{url}/models",
f'{url}/models',
headers=headers,
params=params,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
if response.status != 200:
error_detail = f"HTTP Error: {response.status}"
error_detail = f'HTTP Error: {response.status}'
try:
res = await response.json()
if "error" in res:
error_detail = f"External Error: {res['error']}"
if 'error' in res:
error_detail = f'External Error: {res["error"]}'
except Exception:
pass
return {"object": "list", "data": [], "error": error_detail}
return {'object': 'list', 'data': [], 'error': error_detail}
data = await response.json()
for model in data.get("data", []):
for model in data.get('data', []):
all_models.append(
{
"id": model.get("id"),
"object": "model",
"created": 0,
"owned_by": "anthropic",
"name": model.get("display_name", model.get("id")),
'id': model.get('id'),
'object': 'model',
'created': 0,
'owned_by': 'anthropic',
'name': model.get('display_name', model.get('id')),
}
)
if not data.get("has_more", False):
if not data.get('has_more', False):
break
after_id = data.get("last_id")
after_id = data.get('last_id')
except Exception as e:
log.error(f"Anthropic connection error: {e}")
log.error(f'Anthropic connection error: {e}')
return None
return {"object": "list", "data": all_models}
return {'object': 'list', 'data': all_models}
##############################
@@ -102,245 +102,241 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
openai_payload = {}
# Model
openai_payload["model"] = anthropic_payload.get("model", "")
openai_payload['model'] = anthropic_payload.get('model', '')
# Build messages list
messages = []
# System prompt (Anthropic has it as top-level, OpenAI as a system message)
system = anthropic_payload.get("system")
system = anthropic_payload.get('system')
if system:
if isinstance(system, str):
messages.append({"role": "system", "content": system})
messages.append({'role': 'system', 'content': system})
elif isinstance(system, list):
# Anthropic supports system as list of content blocks
text_parts = []
for block in system:
if isinstance(block, dict) and block.get("type") == "text":
text_parts.append(block.get("text", ""))
if isinstance(block, dict) and block.get('type') == 'text':
text_parts.append(block.get('text', ''))
elif isinstance(block, str):
text_parts.append(block)
messages.append({"role": "system", "content": "\n".join(text_parts)})
messages.append({'role': 'system', 'content': '\n'.join(text_parts)})
# Convert messages
for msg in anthropic_payload.get("messages", []):
role = msg.get("role", "user")
content = msg.get("content")
for msg in anthropic_payload.get('messages', []):
role = msg.get('role', 'user')
content = msg.get('content')
if isinstance(content, str):
messages.append({"role": role, "content": content})
messages.append({'role': role, 'content': content})
elif isinstance(content, list):
# Convert Anthropic content blocks to OpenAI format
openai_content = []
tool_calls = []
for block in content:
block_type = block.get("type", "text")
block_type = block.get('type', 'text')
if block_type == "text":
if block_type == 'text':
openai_content.append(
{
"type": "text",
"text": block.get("text", ""),
'type': 'text',
'text': block.get('text', ''),
}
)
elif block_type == "image":
source = block.get("source", {})
if source.get("type") == "base64":
media_type = source.get("media_type", "image/png")
data = source.get("data", "")
elif block_type == 'image':
source = block.get('source', {})
if source.get('type') == 'base64':
media_type = source.get('media_type', 'image/png')
data = source.get('data', '')
openai_content.append(
{
"type": "image_url",
"image_url": {
"url": f"data:{media_type};base64,{data}",
'type': 'image_url',
'image_url': {
'url': f'data:{media_type};base64,{data}',
},
}
)
elif source.get("type") == "url":
elif source.get('type') == 'url':
openai_content.append(
{
"type": "image_url",
"image_url": {"url": source.get("url", "")},
'type': 'image_url',
'image_url': {'url': source.get('url', '')},
}
)
elif block_type == "tool_use":
elif block_type == 'tool_use':
tool_calls.append(
{
"id": block.get("id", ""),
"type": "function",
"function": {
"name": block.get("name", ""),
"arguments": (
json.dumps(block.get("input", {}))
if isinstance(block.get("input"), dict)
else str(block.get("input", "{}"))
'id': block.get('id', ''),
'type': 'function',
'function': {
'name': block.get('name', ''),
'arguments': (
json.dumps(block.get('input', {}))
if isinstance(block.get('input'), dict)
else str(block.get('input', '{}'))
),
},
}
)
elif block_type == "tool_result":
elif block_type == 'tool_result':
# Tool results become separate tool messages in OpenAI format
tool_content = block.get("content", "")
tool_content = block.get('content', '')
if isinstance(tool_content, list):
tool_text_parts = []
for tc in tool_content:
if isinstance(tc, dict) and tc.get("type") == "text":
tool_text_parts.append(tc.get("text", ""))
tool_content = "\n".join(tool_text_parts)
if isinstance(tc, dict) and tc.get('type') == 'text':
tool_text_parts.append(tc.get('text', ''))
tool_content = '\n'.join(tool_text_parts)
# Propagate error status if present
if block.get("is_error"):
tool_content = f"Error: {tool_content}"
if block.get('is_error'):
tool_content = f'Error: {tool_content}'
messages.append(
{
"role": "tool",
"tool_call_id": block.get("tool_use_id", ""),
"content": tool_content,
'role': 'tool',
'tool_call_id': block.get('tool_use_id', ''),
'content': tool_content,
}
)
# Build the message
if tool_calls:
# Assistant message with tool calls
msg_dict = {"role": role}
msg_dict = {'role': role}
if openai_content:
# If there's only text, flatten it
if len(openai_content) == 1 and openai_content[0]["type"] == "text":
msg_dict["content"] = openai_content[0]["text"]
if len(openai_content) == 1 and openai_content[0]['type'] == 'text':
msg_dict['content'] = openai_content[0]['text']
else:
msg_dict["content"] = openai_content
msg_dict['content'] = openai_content
else:
msg_dict["content"] = ""
msg_dict["tool_calls"] = tool_calls
msg_dict['content'] = ''
msg_dict['tool_calls'] = tool_calls
messages.append(msg_dict)
elif openai_content:
# If there's only a single text block, flatten it to a string
if len(openai_content) == 1 and openai_content[0]["type"] == "text":
messages.append(
{"role": role, "content": openai_content[0]["text"]}
)
if len(openai_content) == 1 and openai_content[0]['type'] == 'text':
messages.append({'role': role, 'content': openai_content[0]['text']})
else:
messages.append({"role": role, "content": openai_content})
messages.append({'role': role, 'content': openai_content})
else:
messages.append({"role": role, "content": str(content) if content else ""})
messages.append({'role': role, 'content': str(content) if content else ''})
openai_payload["messages"] = messages
openai_payload['messages'] = messages
# max_tokens
if "max_tokens" in anthropic_payload:
openai_payload["max_tokens"] = anthropic_payload["max_tokens"]
if 'max_tokens' in anthropic_payload:
openai_payload['max_tokens'] = anthropic_payload['max_tokens']
# Common parameters
for param in ("temperature", "top_p", "stop_sequences", "stream"):
for param in ('temperature', 'top_p', 'stop_sequences', 'stream'):
if param in anthropic_payload:
if param == "stop_sequences":
openai_payload["stop"] = anthropic_payload[param]
if param == 'stop_sequences':
openai_payload['stop'] = anthropic_payload[param]
else:
openai_payload[param] = anthropic_payload[param]
# Tools conversion: Anthropic → OpenAI
if "tools" in anthropic_payload:
if 'tools' in anthropic_payload:
openai_tools = []
for tool in anthropic_payload["tools"]:
for tool in anthropic_payload['tools']:
openai_tools.append(
{
"type": "function",
"function": {
"name": tool.get("name", ""),
"description": tool.get("description", ""),
"parameters": tool.get("input_schema", {}),
'type': 'function',
'function': {
'name': tool.get('name', ''),
'description': tool.get('description', ''),
'parameters': tool.get('input_schema', {}),
},
}
)
openai_payload["tools"] = openai_tools
openai_payload['tools'] = openai_tools
# tool_choice
if "tool_choice" in anthropic_payload:
tc = anthropic_payload["tool_choice"]
if 'tool_choice' in anthropic_payload:
tc = anthropic_payload['tool_choice']
if isinstance(tc, dict):
tc_type = tc.get("type", "auto")
if tc_type == "auto":
openai_payload["tool_choice"] = "auto"
elif tc_type == "any":
openai_payload["tool_choice"] = "required"
elif tc_type == "tool":
openai_payload["tool_choice"] = {
"type": "function",
"function": {"name": tc.get("name", "")},
tc_type = tc.get('type', 'auto')
if tc_type == 'auto':
openai_payload['tool_choice'] = 'auto'
elif tc_type == 'any':
openai_payload['tool_choice'] = 'required'
elif tc_type == 'tool':
openai_payload['tool_choice'] = {
'type': 'function',
'function': {'name': tc.get('name', '')},
}
return openai_payload
def convert_openai_to_anthropic_response(
openai_response: dict, model: str = ""
) -> dict:
def convert_openai_to_anthropic_response(openai_response: dict, model: str = '') -> dict:
"""
Convert a non-streaming OpenAI Chat Completions response to Anthropic Messages format.
"""
import uuid as _uuid
choice = {}
if openai_response.get("choices"):
choice = openai_response["choices"][0]
if openai_response.get('choices'):
choice = openai_response['choices'][0]
message = choice.get("message", {})
finish_reason = choice.get("finish_reason", "stop")
message = choice.get('message', {})
finish_reason = choice.get('finish_reason', 'stop')
# Map finish_reason to stop_reason
stop_reason_map = {
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
"content_filter": "end_turn",
'stop': 'end_turn',
'length': 'max_tokens',
'tool_calls': 'tool_use',
'content_filter': 'end_turn',
}
stop_reason = stop_reason_map.get(finish_reason, "end_turn")
stop_reason = stop_reason_map.get(finish_reason, 'end_turn')
# Build content blocks
content = []
msg_content = message.get("content")
msg_content = message.get('content')
if msg_content:
content.append({"type": "text", "text": msg_content})
content.append({'type': 'text', 'text': msg_content})
# Tool calls → tool_use blocks
tool_calls = message.get("tool_calls", [])
tool_calls = message.get('tool_calls', [])
for tc in tool_calls:
func = tc.get("function", {})
func = tc.get('function', {})
try:
tool_input = json.loads(func.get("arguments", "{}"))
tool_input = json.loads(func.get('arguments', '{}'))
except (json.JSONDecodeError, TypeError):
tool_input = {}
content.append(
{
"type": "tool_use",
"id": tc.get("id", f"toolu_{_uuid.uuid4().hex[:24]}"),
"name": func.get("name", ""),
"input": tool_input,
'type': 'tool_use',
'id': tc.get('id', f'toolu_{_uuid.uuid4().hex[:24]}'),
'name': func.get('name', ''),
'input': tool_input,
}
)
# Usage
openai_usage = openai_response.get("usage", {})
openai_usage = openai_response.get('usage', {})
usage = {
"input_tokens": openai_usage.get("prompt_tokens", 0),
"output_tokens": openai_usage.get("completion_tokens", 0),
'input_tokens': openai_usage.get('prompt_tokens', 0),
'output_tokens': openai_usage.get('completion_tokens', 0),
}
return {
"id": openai_response.get("id", f"msg_{_uuid.uuid4().hex[:24]}"),
"type": "message",
"role": "assistant",
"content": content,
"model": model or openai_response.get("model", ""),
"stop_reason": stop_reason,
"stop_sequence": None,
"usage": usage,
'id': openai_response.get('id', f'msg_{_uuid.uuid4().hex[:24]}'),
'type': 'message',
'role': 'assistant',
'content': content,
'model': model or openai_response.get('model', ''),
'stop_reason': stop_reason,
'stop_sequence': None,
'usage': usage,
}
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = ""):
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = ''):
"""
Convert an OpenAI SSE streaming response to Anthropic Messages SSE format.
@@ -352,10 +348,10 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
"""
import uuid as _uuid
msg_id = f"msg_{_uuid.uuid4().hex[:24]}"
msg_id = f'msg_{_uuid.uuid4().hex[:24]}'
input_tokens = 0
output_tokens = 0
stop_reason = "end_turn"
stop_reason = 'end_turn'
# Track content blocks with a running index.
# Each text block or tool_use block gets its own index.
@@ -369,35 +365,35 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
# Emit message_start
message_start = {
"type": "message_start",
"message": {
"id": msg_id,
"type": "message",
"role": "assistant",
"content": [],
"model": model,
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": 0, "output_tokens": 0},
'type': 'message_start',
'message': {
'id': msg_id,
'type': 'message',
'role': 'assistant',
'content': [],
'model': model,
'stop_reason': None,
'stop_sequence': None,
'usage': {'input_tokens': 0, 'output_tokens': 0},
},
}
yield f"event: message_start\ndata: {json.dumps(message_start)}\n\n".encode()
yield f'event: message_start\ndata: {json.dumps(message_start)}\n\n'.encode()
try:
async for chunk in openai_stream_generator:
if isinstance(chunk, bytes):
chunk = chunk.decode("utf-8", errors="ignore")
chunk = chunk.decode('utf-8', errors='ignore')
for line in chunk.strip().split("\n"):
for line in chunk.strip().split('\n'):
line = line.strip()
if not line or not line.startswith("data:"):
if not line or not line.startswith('data:'):
continue
data_str = line[5:].strip()
if data_str == "[DONE]":
if data_str == '[DONE]':
continue
if data_str == "{}":
if data_str == '{}':
continue
try:
@@ -405,62 +401,58 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
except (json.JSONDecodeError, TypeError):
continue
choices = data.get("choices", [])
choices = data.get('choices', [])
if not choices:
# Check for usage in the final chunk
if data.get("usage"):
input_tokens = data["usage"].get("prompt_tokens", input_tokens)
output_tokens = data["usage"].get(
"completion_tokens", output_tokens
)
if data.get('usage'):
input_tokens = data['usage'].get('prompt_tokens', input_tokens)
output_tokens = data['usage'].get('completion_tokens', output_tokens)
continue
delta = choices[0].get("delta", {})
finish_reason = choices[0].get("finish_reason")
delta = choices[0].get('delta', {})
finish_reason = choices[0].get('finish_reason')
# Update usage if present
if data.get("usage"):
input_tokens = data["usage"].get("prompt_tokens", input_tokens)
output_tokens = data["usage"].get(
"completion_tokens", output_tokens
)
if data.get('usage'):
input_tokens = data['usage'].get('prompt_tokens', input_tokens)
output_tokens = data['usage'].get('completion_tokens', output_tokens)
# --- Handle text content ---
content = delta.get("content")
content = delta.get('content')
if content is not None:
if not text_block_open:
# Start a new text content block
block_start = {
"type": "content_block_start",
"index": current_block_index,
"content_block": {"type": "text", "text": ""},
'type': 'content_block_start',
'index': current_block_index,
'content_block': {'type': 'text', 'text': ''},
}
yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n".encode()
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
text_block_open = True
# Send text delta
block_delta = {
"type": "content_block_delta",
"index": current_block_index,
"delta": {"type": "text_delta", "text": content},
'type': 'content_block_delta',
'index': current_block_index,
'delta': {'type': 'text_delta', 'text': content},
}
yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n".encode()
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
# --- Handle tool calls ---
tool_calls = delta.get("tool_calls")
tool_calls = delta.get('tool_calls')
if tool_calls:
# Close text block if one is open (text comes before tools)
if text_block_open:
block_stop = {
"type": "content_block_stop",
"index": current_block_index,
'type': 'content_block_stop',
'index': current_block_index,
}
yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode()
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
text_block_open = False
current_block_index += 1
for tc in tool_calls:
tc_index = tc.get("index", 0)
tc_index = tc.get('index', 0)
if tc_index not in tool_call_started:
# First time seeing this tool call — emit content_block_start
@@ -468,67 +460,67 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
tool_call_started[tc_index] = True
# Extract tool call ID and name from the first chunk
tc_id = tc.get("id", f"toolu_{_uuid.uuid4().hex[:24]}")
tc_name = tc.get("function", {}).get("name", "")
tc_id = tc.get('id', f'toolu_{_uuid.uuid4().hex[:24]}')
tc_name = tc.get('function', {}).get('name', '')
block_start = {
"type": "content_block_start",
"index": current_block_index,
"content_block": {
"type": "tool_use",
"id": tc_id,
"name": tc_name,
"input": {},
'type': 'content_block_start',
'index': current_block_index,
'content_block': {
'type': 'tool_use',
'id': tc_id,
'name': tc_name,
'input': {},
},
}
yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n".encode()
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
current_block_index += 1
# Emit argument chunks as input_json_delta
args_chunk = tc.get("function", {}).get("arguments", "")
args_chunk = tc.get('function', {}).get('arguments', '')
if args_chunk:
block_delta = {
"type": "content_block_delta",
"index": tool_call_blocks[tc_index],
"delta": {
"type": "input_json_delta",
"partial_json": args_chunk,
'type': 'content_block_delta',
'index': tool_call_blocks[tc_index],
'delta': {
'type': 'input_json_delta',
'partial_json': args_chunk,
},
}
yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n".encode()
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
# --- Handle finish reason ---
if finish_reason is not None:
stop_reason_map = {
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
'stop': 'end_turn',
'length': 'max_tokens',
'tool_calls': 'tool_use',
}
stop_reason = stop_reason_map.get(finish_reason, "end_turn")
stop_reason = stop_reason_map.get(finish_reason, 'end_turn')
except Exception as e:
log.error(f"Error in Anthropic stream conversion: {e}")
log.error(f'Error in Anthropic stream conversion: {e}')
# Close any open text block
if text_block_open:
block_stop = {"type": "content_block_stop", "index": current_block_index}
yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode()
block_stop = {'type': 'content_block_stop', 'index': current_block_index}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
# Close any open tool call blocks
for tc_index, block_index in tool_call_blocks.items():
block_stop = {"type": "content_block_stop", "index": block_index}
yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode()
block_stop = {'type': 'content_block_stop', 'index': block_index}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
# Emit message_delta with stop reason
message_delta = {
"type": "message_delta",
"delta": {
"stop_reason": stop_reason,
"stop_sequence": None,
'type': 'message_delta',
'delta': {
'stop_reason': stop_reason,
'stop_sequence': None,
},
"usage": {"output_tokens": output_tokens},
'usage': {'output_tokens': output_tokens},
}
yield f"event: message_delta\ndata: {json.dumps(message_delta)}\n\n".encode()
yield f'event: message_delta\ndata: {json.dumps(message_delta)}\n\n'.encode()
# Emit message_stop
yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'})}\n\n".encode()
yield f'event: message_stop\ndata: {json.dumps({"type": "message_stop"})}\n\n'.encode()
+38 -58
View File
@@ -50,10 +50,10 @@ class AuditLogEntry:
class AuditLevel(str, Enum):
NONE = "NONE"
METADATA = "METADATA"
REQUEST = "REQUEST"
REQUEST_RESPONSE = "REQUEST_RESPONSE"
NONE = 'NONE'
METADATA = 'METADATA'
REQUEST = 'REQUEST'
REQUEST_RESPONSE = 'REQUEST_RESPONSE'
class AuditLogger:
@@ -64,25 +64,24 @@ class AuditLogger:
logger (Logger): An instance of Logurus logger.
"""
def __init__(self, logger: "Logger"):
def __init__(self, logger: 'Logger'):
self.logger = logger.bind(auditable=True)
def write(
self,
audit_entry: AuditLogEntry,
*,
log_level: str = "INFO",
log_level: str = 'INFO',
extra: Optional[dict] = None,
):
entry = asdict(audit_entry)
if extra:
entry["extra"] = extra
entry['extra'] = extra
self.logger.log(
log_level,
"",
'',
**entry,
)
@@ -106,15 +105,11 @@ class AuditContext:
def add_request_chunk(self, chunk: bytes):
if len(self.request_body) < self.max_body_size:
self.request_body.extend(
chunk[: self.max_body_size - len(self.request_body)]
)
self.request_body.extend(chunk[: self.max_body_size - len(self.request_body)])
def add_response_chunk(self, chunk: bytes):
if len(self.response_body) < self.max_body_size:
self.response_body.extend(
chunk[: self.max_body_size - len(self.response_body)]
)
self.response_body.extend(chunk[: self.max_body_size - len(self.response_body)])
class AuditLoggingMiddleware:
@@ -122,7 +117,7 @@ class AuditLoggingMiddleware:
ASGI middleware that intercepts HTTP requests and responses to perform audit logging. It captures request/response bodies (depending on audit level), headers, HTTP methods, and user information, then logs a structured audit entry at the end of the request cycle.
"""
AUDITED_METHODS = {"PUT", "PATCH", "DELETE", "POST"}
AUDITED_METHODS = {'PUT', 'PATCH', 'DELETE', 'POST'}
def __init__(
self,
@@ -142,8 +137,8 @@ class AuditLoggingMiddleware:
if self.included_paths and self.excluded_paths:
logger.warning(
"Both AUDIT_INCLUDED_PATHS and AUDIT_EXCLUDED_PATHS are set. "
"AUDIT_INCLUDED_PATHS (whitelist) takes precedence."
'Both AUDIT_INCLUDED_PATHS and AUDIT_EXCLUDED_PATHS are set. '
'AUDIT_INCLUDED_PATHS (whitelist) takes precedence.'
)
async def __call__(
@@ -152,7 +147,7 @@ class AuditLoggingMiddleware:
receive: ASGIReceiveCallable,
send: ASGISendCallable,
) -> None:
if scope["type"] != "http":
if scope['type'] != 'http':
return await self.app(scope, receive, send)
request = Request(scope=cast(MutableMapping, scope))
@@ -185,9 +180,7 @@ class AuditLoggingMiddleware:
await self.app(scope, receive_wrapper, send_wrapper)
@asynccontextmanager
async def _audit_context(
self, request: Request
) -> AsyncGenerator[AuditContext, None]:
async def _audit_context(self, request: Request) -> AsyncGenerator[AuditContext, None]:
"""
async context manager that ensures that an audit log entry is recorded after the request is processed.
"""
@@ -198,29 +191,24 @@ class AuditLoggingMiddleware:
await self._log_audit_entry(request, context)
async def _get_authenticated_user(self, request: Request) -> Optional[UserModel]:
auth_header = request.headers.get("Authorization")
auth_header = request.headers.get('Authorization')
try:
user = await get_current_user(
request, None, None, get_http_authorization_cred(auth_header)
)
user = await get_current_user(request, None, None, get_http_authorization_cred(auth_header))
return user
except Exception as e:
logger.debug(f"Failed to get authenticated user: {str(e)}")
logger.debug(f'Failed to get authenticated user: {str(e)}')
return None
def _should_skip_auditing(self, request: Request) -> bool:
if (
request.method not in {"POST", "PUT", "PATCH", "DELETE"}
or AUDIT_LOG_LEVEL == "NONE"
):
if request.method not in {'POST', 'PUT', 'PATCH', 'DELETE'} or AUDIT_LOG_LEVEL == 'NONE':
return True
ALWAYS_LOG_ENDPOINTS = {
"/api/v1/auths/signin",
"/api/v1/auths/signout",
"/api/v1/auths/signup",
'/api/v1/auths/signin',
'/api/v1/auths/signout',
'/api/v1/auths/signup',
}
path = request.url.path.lower()
for endpoint in ALWAYS_LOG_ENDPOINTS:
@@ -229,55 +217,47 @@ class AuditLoggingMiddleware:
# Skip logging if the request is not authenticated
# Check both Authorization header (API keys) and token cookie (browser sessions)
if not request.headers.get("authorization") and not request.cookies.get(
"token"
):
if not request.headers.get('authorization') and not request.cookies.get('token'):
return True
# Whitelist mode: only log paths that match included_paths
if self.included_paths:
pattern = re.compile(
r"^/api(?:/v1)?/(" + "|".join(self.included_paths) + r")\b"
)
pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b')
if not pattern.match(request.url.path):
return True # Skip: path not in whitelist
return False # Do NOT skip: path is in whitelist
# Blacklist mode: skip paths that match excluded_paths
pattern = re.compile(
r"^/api(?:/v1)?/(" + "|".join(self.excluded_paths) + r")\b"
)
pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b')
if pattern.match(request.url.path):
return True
return False
async def _capture_request(self, message: ASGIReceiveEvent, context: AuditContext):
if message["type"] == "http.request":
body = message.get("body", b"")
if message['type'] == 'http.request':
body = message.get('body', b'')
context.add_request_chunk(body)
async def _capture_response(self, message: ASGISendEvent, context: AuditContext):
if message["type"] == "http.response.start":
context.metadata["response_status_code"] = message["status"]
if message['type'] == 'http.response.start':
context.metadata['response_status_code'] = message['status']
elif message["type"] == "http.response.body":
body = message.get("body", b"")
elif message['type'] == 'http.response.body':
body = message.get('body', b'')
context.add_response_chunk(body)
async def _log_audit_entry(self, request: Request, context: AuditContext):
try:
user = await self._get_authenticated_user(request)
user = (
user.model_dump(include={"id", "name", "email", "role"}) if user else {}
)
user = user.model_dump(include={'id', 'name', 'email', 'role'}) if user else {}
request_body = context.request_body.decode("utf-8", errors="replace")
response_body = context.response_body.decode("utf-8", errors="replace")
request_body = context.request_body.decode('utf-8', errors='replace')
response_body = context.response_body.decode('utf-8', errors='replace')
# Redact sensitive information
if "password" in request_body:
if 'password' in request_body:
request_body = re.sub(
r'"password":\s*"(.*?)"',
'"password": "********"',
@@ -290,13 +270,13 @@ class AuditLoggingMiddleware:
audit_level=self.audit_level.value,
verb=request.method,
request_uri=str(request.url),
response_status_code=context.metadata.get("response_status_code", None),
response_status_code=context.metadata.get('response_status_code', None),
source_ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
user_agent=request.headers.get('user-agent'),
request_object=request_body,
response_object=response_body,
)
self.audit_logger.write(entry)
except Exception as e:
logger.error(f"Failed to log audit entry: {str(e)}")
logger.error(f'Failed to log audit entry: {str(e)}')
+84 -94
View File
@@ -49,7 +49,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
log = logging.getLogger(__name__)
SESSION_SECRET = WEBUI_SECRET_KEY
ALGORITHM = "HS256"
ALGORITHM = 'HS256'
##############
# Auth Utils
@@ -74,62 +74,60 @@ def verify_signature(payload: str, signature: str) -> bool:
def override_static(path: str, content: str):
# Ensure path is safe
if "/" in path or ".." in path:
log.error(f"Invalid path: {path}")
if '/' in path or '..' in path:
log.error(f'Invalid path: {path}')
return
file_path = os.path.join(STATIC_DIR, path)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "wb") as f:
with open(file_path, 'wb') as f:
f.write(base64.b64decode(content)) # Convert Base64 back to raw binary
def get_license_data(app, key):
def data_handler(data):
for k, v in data.items():
if k == "resources":
if k == 'resources':
for p, c in v.items():
globals().get("override_static", lambda a, b: None)(p, c)
elif k == "count":
setattr(app.state, "USER_COUNT", v)
elif k == "name":
setattr(app.state, "WEBUI_NAME", v)
elif k == "metadata":
setattr(app.state, "LICENSE_METADATA", v)
globals().get('override_static', lambda a, b: None)(p, c)
elif k == 'count':
setattr(app.state, 'USER_COUNT', v)
elif k == 'name':
setattr(app.state, 'WEBUI_NAME', v)
elif k == 'metadata':
setattr(app.state, 'LICENSE_METADATA', v)
def handler(u):
res = requests.post(
f"{u}/api/v1/license/",
json={"key": key, "version": "1"},
f'{u}/api/v1/license/',
json={'key': key, 'version': '1'},
timeout=5,
)
if getattr(res, "ok", False):
payload = getattr(res, "json", lambda: {})()
if getattr(res, 'ok', False):
payload = getattr(res, 'json', lambda: {})()
data_handler(payload)
return True
else:
log.error(
f"License: retrieval issue: {getattr(res, 'text', 'unknown error')}"
)
log.error(f'License: retrieval issue: {getattr(res, "text", "unknown error")}')
if key:
us = [
"https://api.openwebui.com",
"https://licenses.api.openwebui.com",
'https://api.openwebui.com',
'https://licenses.api.openwebui.com',
]
try:
for u in us:
if handler(u):
return True
except Exception as ex:
log.exception(f"License: Uncaught Exception: {ex}")
log.exception(f'License: Uncaught Exception: {ex}')
try:
if LICENSE_BLOB:
nl = 12
kb = hashlib.sha256((key.replace("-", "").upper()).encode()).digest()
kb = hashlib.sha256((key.replace('-', '').upper()).encode()).digest()
def nt(b):
return b[:nl], b[nl:]
@@ -139,19 +137,19 @@ def get_license_data(app, key):
aesgcm = AESGCM(kb)
p = json.loads(aesgcm.decrypt(ln, lt, None))
pk.verify(base64.b64decode(p["s"]), p["p"].encode())
pk.verify(base64.b64decode(p['s']), p['p'].encode())
pb = base64.b64decode(p["p"])
pb = base64.b64decode(p['p'])
pn, pt = nt(pb)
data = json.loads(aesgcm.decrypt(pn, pt, None).decode())
if not data.get("exp") and data.get("exp") < datetime.now().date():
if not data.get('exp') and data.get('exp') < datetime.now().date():
return False
data_handler(data)
return True
except Exception as e:
log.error(f"License: {e}")
log.error(f'License: {e}')
return False
@@ -161,12 +159,12 @@ bearer_security = HTTPBearer(auto_error=False)
def get_password_hash(password: str) -> str:
"""Hash a password using bcrypt"""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def validate_password(password: str) -> bool:
# The password passed to bcrypt must be 72 bytes or fewer. If it is longer, it will be truncated before hashing.
if len(password.encode("utf-8")) > 72:
if len(password.encode('utf-8')) > 72:
raise Exception(
ERROR_MESSAGES.PASSWORD_TOO_LONG,
)
@@ -182,8 +180,8 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash"""
return (
bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8"),
plain_password.encode('utf-8'),
hashed_password.encode('utf-8'),
)
if hashed_password
else None
@@ -195,10 +193,10 @@ def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> st
if expires_delta:
expire = datetime.now(UTC) + expires_delta
payload.update({"exp": expire})
payload.update({'exp': expire})
jti = str(uuid.uuid4())
payload.update({"jti": jti})
payload.update({'jti': jti})
encoded_jwt = jwt.encode(payload, SESSION_SECRET, algorithm=ALGORITHM)
return encoded_jwt
@@ -215,12 +213,10 @@ def decode_token(token: str) -> Optional[dict]:
async def is_valid_token(request, decoded) -> bool:
# Require Redis to check revoked tokens
if request.app.state.redis:
jti = decoded.get("jti")
jti = decoded.get('jti')
if jti:
revoked = await request.app.state.redis.get(
f"{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked"
)
revoked = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked')
if revoked:
return False
@@ -236,37 +232,35 @@ async def invalidate_token(request, token):
# Require Redis to store revoked tokens
if request.app.state.redis:
jti = decoded.get("jti")
exp = decoded.get("exp")
jti = decoded.get('jti')
exp = decoded.get('exp')
if jti and exp:
ttl = exp - int(
datetime.now(UTC).timestamp()
) # Calculate time-to-live for the token
ttl = exp - int(datetime.now(UTC).timestamp()) # Calculate time-to-live for the token
if ttl > 0:
# Store the revoked token in Redis with an expiration time
await request.app.state.redis.set(
f"{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked",
"1",
f'{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked',
'1',
ex=ttl,
)
def extract_token_from_auth_header(auth_header: str):
return auth_header[len("Bearer ") :]
return auth_header[len('Bearer ') :]
def create_api_key():
key = str(uuid.uuid4()).replace("-", "")
return f"sk-{key}"
key = str(uuid.uuid4()).replace('-', '')
return f'sk-{key}'
def get_http_authorization_cred(auth_header: Optional[str]):
if not auth_header:
return None
try:
scheme, credentials = auth_header.split(" ")
scheme, credentials = auth_header.split(' ')
return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials)
except Exception:
return None
@@ -287,27 +281,27 @@ async def get_current_user(
if auth_token is not None:
token = auth_token.credentials
if token is None and "token" in request.cookies:
token = request.cookies.get("token")
if token is None and 'token' in request.cookies:
token = request.cookies.get('token')
# Fallback to request.state.token (set by middleware, e.g. for x-api-key)
if token is None and hasattr(request.state, "token") and request.state.token:
if token is None and hasattr(request.state, 'token') and request.state.token:
token = request.state.token.credentials
if token is None:
raise HTTPException(status_code=401, detail="Not authenticated")
raise HTTPException(status_code=401, detail='Not authenticated')
# auth by api key
if token.startswith("sk-"):
if token.startswith('sk-'):
user = get_current_user_by_api_key(request, token)
# Add user info to current span
current_span = trace.get_current_span()
if current_span:
current_span.set_attribute("client.user.id", user.id)
current_span.set_attribute("client.user.email", user.email)
current_span.set_attribute("client.user.role", user.role)
current_span.set_attribute("client.auth.type", "api_key")
current_span.set_attribute('client.user.id', user.id)
current_span.set_attribute('client.user.email', user.email)
current_span.set_attribute('client.user.role', user.role)
current_span.set_attribute('client.auth.type', 'api_key')
return user
@@ -318,17 +312,17 @@ async def get_current_user(
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
detail='Invalid token',
)
if data is not None and "id" in data:
if data.get("jti") and not await is_valid_token(request, data):
if data is not None and 'id' in data:
if data.get('jti') and not await is_valid_token(request, data):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
detail='Invalid token',
)
user = Users.get_user_by_id(data["id"])
user = Users.get_user_by_id(data['id'])
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -336,22 +330,20 @@ async def get_current_user(
)
else:
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
trusted_email = request.headers.get(
WEBUI_AUTH_TRUSTED_EMAIL_HEADER, ""
).lower()
trusted_email = request.headers.get(WEBUI_AUTH_TRUSTED_EMAIL_HEADER, '').lower()
if trusted_email and user.email != trusted_email:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User mismatch. Please sign in again.",
detail='User mismatch. Please sign in again.',
)
# Add user info to current span
current_span = trace.get_current_span()
if current_span:
current_span.set_attribute("client.user.id", user.id)
current_span.set_attribute("client.user.email", user.email)
current_span.set_attribute("client.user.role", user.role)
current_span.set_attribute("client.auth.type", "jwt")
current_span.set_attribute('client.user.id', user.id)
current_span.set_attribute('client.user.email', user.email)
current_span.set_attribute('client.user.role', user.role)
current_span.set_attribute('client.auth.type', 'jwt')
# Refresh the user's last active timestamp asynchronously
# to prevent blocking the request
@@ -365,15 +357,15 @@ async def get_current_user(
)
except Exception as e:
# Delete the token cookie
if request.cookies.get("token"):
response.delete_cookie("token")
if request.cookies.get('token'):
response.delete_cookie('token')
if request.cookies.get("oauth_id_token"):
response.delete_cookie("oauth_id_token")
if request.cookies.get('oauth_id_token'):
response.delete_cookie('oauth_id_token')
# Delete OAuth session if present
if request.cookies.get("oauth_session_id"):
response.delete_cookie("oauth_session_id")
if request.cookies.get('oauth_session_id'):
response.delete_cookie('oauth_session_id')
raise e
@@ -389,31 +381,29 @@ def get_current_user_by_api_key(request, api_key: str):
)
if not request.state.enable_api_keys or (
user.role != "admin"
user.role != 'admin'
and not has_permission(
user.id,
"features.api_keys",
'features.api_keys',
request.app.state.config.USER_PERMISSIONS,
)
):
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
)
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
# Add user info to current span
current_span = trace.get_current_span()
if current_span:
current_span.set_attribute("client.user.id", user.id)
current_span.set_attribute("client.user.email", user.email)
current_span.set_attribute("client.user.role", user.role)
current_span.set_attribute("client.auth.type", "api_key")
current_span.set_attribute('client.user.id', user.id)
current_span.set_attribute('client.user.email', user.email)
current_span.set_attribute('client.user.role', user.role)
current_span.set_attribute('client.auth.type', 'api_key')
Users.update_last_active_by_id(user.id)
return user
def get_verified_user(user=Depends(get_current_user)):
if user.role not in {"user", "admin"}:
if user.role not in {'user', 'admin'}:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@@ -422,7 +412,7 @@ def get_verified_user(user=Depends(get_current_user)):
def get_admin_user(user=Depends(get_current_user)):
if user.role != "admin":
if user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@@ -430,7 +420,7 @@ def get_admin_user(user=Depends(get_current_user)):
return user
def create_admin_user(email: str, password: str, name: str = "Admin"):
def create_admin_user(email: str, password: str, name: str = 'Admin'):
"""
Create an admin user from environment variables.
Used for headless/automated deployments.
@@ -441,24 +431,24 @@ def create_admin_user(email: str, password: str, name: str = "Admin"):
return None
if Users.has_users():
log.debug("Users already exist, skipping admin creation")
log.debug('Users already exist, skipping admin creation')
return None
log.info(f"Creating admin account from environment variables: {email}")
log.info(f'Creating admin account from environment variables: {email}')
try:
hashed = get_password_hash(password)
user = Auths.insert_new_auth(
email=email.lower(),
password=hashed,
name=name,
role="admin",
role='admin',
)
if user:
log.info(f"Admin account created successfully: {email}")
log.info(f'Admin account created successfully: {email}')
return user
else:
log.error("Failed to create admin account from environment variables")
log.error('Failed to create admin account from environment variables')
return None
except Exception as e:
log.error(f"Error creating admin account: {e}")
log.error(f'Error creating admin account: {e}')
return None
+5 -5
View File
@@ -1,16 +1,16 @@
import re
def extract_mentions(message: str, triggerChar: str = "@"):
def extract_mentions(message: str, triggerChar: str = '@'):
# Escape triggerChar in case it's a regex special character
triggerChar = re.escape(triggerChar)
pattern = rf"<{triggerChar}([A-Z]):([^|>]+)"
pattern = rf'<{triggerChar}([A-Z]):([^|>]+)'
matches = re.findall(pattern, message)
return [{"id_type": id_type, "id": id_value} for id_type, id_value in matches]
return [{'id_type': id_type, 'id': id_value} for id_type, id_value in matches]
def replace_mentions(message: str, triggerChar: str = "@", use_label: bool = True):
def replace_mentions(message: str, triggerChar: str = '@', use_label: bool = True):
"""
Replace mentions in the message with either their label (after the pipe `|`)
or their id if no label exists.
@@ -27,5 +27,5 @@ def replace_mentions(message: str, triggerChar: str = "@", use_label: bool = Tru
return label if use_label and label else id_value
# Regex captures: idType, id, optional label
pattern = rf"<{triggerChar}([A-Z]):([^|>]+)(?:\|([^>]+))?>"
pattern = rf'<{triggerChar}([A-Z]):([^|>]+)(?:\|([^>]+))?>'
return re.sub(pattern, replacer, message)
+81 -91
View File
@@ -62,20 +62,20 @@ async def generate_direct_chat_completion(
user: Any,
models: dict,
):
log.info("generate_direct_chat_completion")
log.info('generate_direct_chat_completion')
metadata = form_data.pop("metadata", {})
metadata = form_data.pop('metadata', {})
user_id = metadata.get("user_id")
session_id = metadata.get("session_id")
user_id = metadata.get('user_id')
session_id = metadata.get('session_id')
request_id = str(uuid.uuid4()) # Generate a unique request ID
event_caller = get_event_call(metadata)
channel = f"{user_id}:{session_id}:{request_id}"
logging.info(f"WebSocket channel: {channel}")
channel = f'{user_id}:{session_id}:{request_id}'
logging.info(f'WebSocket channel: {channel}')
if form_data.get("stream"):
if form_data.get('stream'):
q = asyncio.Queue()
async def message_listener(sid, data):
@@ -90,19 +90,19 @@ async def generate_direct_chat_completion(
# Start processing chat completion in background
res = await event_caller(
{
"type": "request:chat:completion",
"data": {
"form_data": form_data,
"model": models[form_data["model"]],
"channel": channel,
"session_id": session_id,
'type': 'request:chat:completion',
'data': {
'form_data': form_data,
'model': models[form_data['model']],
'channel': channel,
'session_id': session_id,
},
}
)
log.info(f"res: {res}")
log.info(f'res: {res}')
if res.get("status", False):
if res.get('status', False):
# Define a generator to stream responses
async def event_generator():
nonlocal q
@@ -110,47 +110,45 @@ async def generate_direct_chat_completion(
while True:
data = await q.get() # Wait for new messages
if isinstance(data, dict):
if "done" in data and data["done"]:
if 'done' in data and data['done']:
break # Stop streaming when 'done' is received
yield f"data: {json.dumps(data)}\n\n"
yield f'data: {json.dumps(data)}\n\n'
elif isinstance(data, str):
if "data:" in data:
yield f"{data}\n\n"
if 'data:' in data:
yield f'{data}\n\n'
else:
yield f"data: {data}\n\n"
yield f'data: {data}\n\n'
except Exception as e:
log.debug(f"Error in event generator: {e}")
log.debug(f'Error in event generator: {e}')
pass
# Define a background task to run the event generator
async def background():
try:
del sio.handlers["/"][channel]
del sio.handlers['/'][channel]
except Exception as e:
pass
# Return the streaming response
return StreamingResponse(
event_generator(), media_type="text/event-stream", background=background
)
return StreamingResponse(event_generator(), media_type='text/event-stream', background=background)
else:
raise Exception(str(res))
else:
res = await event_caller(
{
"type": "request:chat:completion",
"data": {
"form_data": form_data,
"model": models[form_data["model"]],
"channel": channel,
"session_id": session_id,
'type': 'request:chat:completion',
'data': {
'form_data': form_data,
'model': models[form_data['model']],
'channel': channel,
'session_id': session_id,
},
}
)
if "error" in res and res["error"]:
raise Exception(res["error"])
if 'error' in res and res['error']:
raise Exception(res['error'])
return res
@@ -162,7 +160,7 @@ async def generate_chat_completion(
bypass_filter: bool = False,
bypass_system_prompt: bool = False,
):
log.debug(f"generate_chat_completion: {form_data}")
log.debug(f'generate_chat_completion: {form_data}')
if BYPASS_MODEL_ACCESS_CONTROL:
bypass_filter = True
@@ -170,49 +168,47 @@ async def generate_chat_completion(
# handlers (openai/ollama) can read it without exposing it as a query param.
request.state.bypass_filter = bypass_filter
if hasattr(request.state, "metadata"):
if "metadata" not in form_data:
form_data["metadata"] = request.state.metadata
if hasattr(request.state, 'metadata'):
if 'metadata' not in form_data:
form_data['metadata'] = request.state.metadata
else:
form_data["metadata"] = {
**form_data["metadata"],
form_data['metadata'] = {
**form_data['metadata'],
**request.state.metadata,
}
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
models = {
request.state.model["id"]: request.state.model,
request.state.model['id']: request.state.model,
}
log.debug(f"direct connection to model: {models}")
log.debug(f'direct connection to model: {models}')
else:
models = request.app.state.MODELS
model_id = form_data["model"]
model_id = form_data['model']
if model_id not in models:
raise Exception("Model not found")
raise Exception('Model not found')
model = models[model_id]
if getattr(request.state, "direct", False):
return await generate_direct_chat_completion(
request, form_data, user=user, models=models
)
if getattr(request.state, 'direct', False):
return await generate_direct_chat_completion(request, form_data, user=user, models=models)
else:
# Check if user has access to the model
if not bypass_filter and user.role == "user":
if not bypass_filter and user.role == 'user':
try:
check_model_access(user, model)
except Exception as e:
raise e
if model.get("owned_by") == "arena":
model_ids = model.get("info", {}).get("meta", {}).get("model_ids")
filter_mode = model.get("info", {}).get("meta", {}).get("filter_mode")
if model_ids and filter_mode == "exclude":
if model.get('owned_by') == 'arena':
model_ids = model.get('info', {}).get('meta', {}).get('model_ids')
filter_mode = model.get('info', {}).get('meta', {}).get('filter_mode')
if model_ids and filter_mode == 'exclude':
model_ids = [
model["id"]
model['id']
for model in list(request.app.state.MODELS.values())
if model.get("owned_by") != "arena" and model["id"] not in model_ids
if model.get('owned_by') != 'arena' and model['id'] not in model_ids
]
selected_model_id = None
@@ -220,18 +216,16 @@ async def generate_chat_completion(
selected_model_id = random.choice(model_ids)
else:
model_ids = [
model["id"]
for model in list(request.app.state.MODELS.values())
if model.get("owned_by") != "arena"
model['id'] for model in list(request.app.state.MODELS.values()) if model.get('owned_by') != 'arena'
]
selected_model_id = random.choice(model_ids)
form_data["model"] = selected_model_id
form_data['model'] = selected_model_id
if form_data.get("stream") == True:
if form_data.get('stream') == True:
async def stream_wrapper(stream):
yield f"data: {json.dumps({'selected_model_id': selected_model_id})}\n\n"
yield f'data: {json.dumps({"selected_model_id": selected_model_id})}\n\n'
async for chunk in stream:
yield chunk
@@ -244,7 +238,7 @@ async def generate_chat_completion(
)
return StreamingResponse(
stream_wrapper(response.body_iterator),
media_type="text/event-stream",
media_type='text/event-stream',
background=response.background,
)
else:
@@ -258,15 +252,13 @@ async def generate_chat_completion(
bypass_system_prompt=bypass_system_prompt,
)
),
"selected_model_id": selected_model_id,
'selected_model_id': selected_model_id,
}
if model.get("pipe"):
if model.get('pipe'):
# Below does not require bypass_filter because this is the only route the uses this function and it is already bypassing the filter
return await generate_function_chat_completion(
request, form_data, user=user, models=models
)
if model.get("owned_by") == "ollama":
return await generate_function_chat_completion(request, form_data, user=user, models=models)
if model.get('owned_by') == 'ollama':
# Using /ollama/api/chat endpoint
form_data = convert_payload_openai_to_ollama(form_data)
response = await generate_ollama_chat_completion(
@@ -275,8 +267,8 @@ async def generate_chat_completion(
user=user,
bypass_system_prompt=bypass_system_prompt,
)
if form_data.get("stream"):
response.headers["content-type"] = "text/event-stream"
if form_data.get('stream'):
response.headers['content-type'] = 'text/event-stream'
return StreamingResponse(
convert_streaming_response_ollama_to_openai(response),
headers=dict(response.headers),
@@ -300,55 +292,53 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
if not request.app.state.MODELS:
await get_all_models(request, user=user)
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
models = {
request.state.model["id"]: request.state.model,
request.state.model['id']: request.state.model,
}
else:
models = request.app.state.MODELS
data = form_data
model_id = data["model"]
model_id = data['model']
if model_id not in models:
raise Exception("Model not found")
raise Exception('Model not found')
model = models[model_id]
try:
data = await process_pipeline_outlet_filter(request, data, user, models)
except Exception as e:
raise Exception(f"Error: {e}")
raise Exception(f'Error: {e}')
metadata = {
"chat_id": data["chat_id"],
"message_id": data["id"],
"filter_ids": data.get("filter_ids", []),
"session_id": data["session_id"],
"user_id": user.id,
'chat_id': data['chat_id'],
'message_id': data['id'],
'filter_ids': data.get('filter_ids', []),
'session_id': data['session_id'],
'user_id': user.id,
}
extra_params = {
"__event_emitter__": get_event_emitter(metadata),
"__event_call__": get_event_call(metadata),
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
"__metadata__": metadata,
"__request__": request,
"__model__": model,
'__event_emitter__': get_event_emitter(metadata),
'__event_call__': get_event_call(metadata),
'__user__': user.model_dump() if isinstance(user, UserModel) else {},
'__metadata__': metadata,
'__request__': request,
'__model__': model,
}
try:
filter_ids = get_sorted_filter_ids(
request, model, metadata.get("filter_ids", [])
)
filter_ids = get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
filter_functions = Functions.get_functions_by_ids(filter_ids)
result, _ = await process_filter_functions(
request=request,
filter_functions=filter_functions,
filter_type="outlet",
filter_type='outlet',
form_data=data,
extra_params=extra_params,
)
return result
except Exception as e:
raise Exception(f"Error: {e}")
raise Exception(f'Error: {e}')
+65 -76
View File
@@ -16,9 +16,9 @@ class ResultModel(BaseModel):
Execute Code Result Model
"""
stdout: Optional[str] = ""
stderr: Optional[str] = ""
result: Optional[str] = ""
stdout: Optional[str] = ''
stderr: Optional[str] = ''
result: Optional[str] = ''
class JupyterCodeExecuter:
@@ -30,8 +30,8 @@ class JupyterCodeExecuter:
self,
base_url: str,
code: str,
token: str = "",
password: str = "",
token: str = '',
password: str = '',
timeout: int = 60,
):
"""
@@ -46,9 +46,9 @@ class JupyterCodeExecuter:
self.token = token
self.password = password
self.timeout = timeout
self.kernel_id = ""
if self.base_url[-1] != "/":
self.base_url += "/"
self.kernel_id = ''
if self.base_url[-1] != '/':
self.base_url += '/'
self.session = aiohttp.ClientSession(trust_env=True, base_url=self.base_url)
self.params = {}
self.result = ResultModel()
@@ -59,12 +59,10 @@ class JupyterCodeExecuter:
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.kernel_id:
try:
async with self.session.delete(
f"api/kernels/{self.kernel_id}", params=self.params
) as response:
async with self.session.delete(f'api/kernels/{self.kernel_id}', params=self.params) as response:
response.raise_for_status()
except Exception as err:
logger.exception("close kernel failed, %s", err)
logger.exception('close kernel failed, %s', err)
await self.session.close()
async def run(self) -> ResultModel:
@@ -73,23 +71,23 @@ class JupyterCodeExecuter:
await self.init_kernel()
await self.execute_code()
except Exception as err:
logger.exception("execute code failed, %s", err)
self.result.stderr = f"Error: {err}"
logger.exception('execute code failed, %s', err)
self.result.stderr = f'Error: {err}'
return self.result
async def sign_in(self) -> None:
# password authentication
if self.password and not self.token:
async with self.session.get("login") as response:
async with self.session.get('login') as response:
response.raise_for_status()
xsrf_token = response.cookies["_xsrf"].value
xsrf_token = response.cookies['_xsrf'].value
if not xsrf_token:
raise ValueError("_xsrf token not found")
raise ValueError('_xsrf token not found')
self.session.cookie_jar.update_cookies(response.cookies)
self.session.headers.update({"X-XSRFToken": xsrf_token})
self.session.headers.update({'X-XSRFToken': xsrf_token})
async with self.session.post(
"login",
data={"_xsrf": xsrf_token, "password": self.password},
'login',
data={'_xsrf': xsrf_token, 'password': self.password},
allow_redirects=False,
) as response:
response.raise_for_status()
@@ -97,27 +95,22 @@ class JupyterCodeExecuter:
# token authentication
if self.token:
self.params.update({"token": self.token})
self.params.update({'token': self.token})
async def init_kernel(self) -> None:
async with self.session.post(url="api/kernels", params=self.params) as response:
async with self.session.post(url='api/kernels', params=self.params) as response:
response.raise_for_status()
kernel_data = await response.json()
self.kernel_id = kernel_data["id"]
self.kernel_id = kernel_data['id']
def init_ws(self) -> (str, dict):
ws_base = self.base_url.replace("http", "ws", 1)
ws_params = "?" + "&".join([f"{key}={val}" for key, val in self.params.items()])
websocket_url = f"{ws_base}api/kernels/{self.kernel_id}/channels{ws_params if len(ws_params) > 1 else ''}"
ws_base = self.base_url.replace('http', 'ws', 1)
ws_params = '?' + '&'.join([f'{key}={val}' for key, val in self.params.items()])
websocket_url = f'{ws_base}api/kernels/{self.kernel_id}/channels{ws_params if len(ws_params) > 1 else ""}'
ws_headers = {}
if self.password and not self.token:
ws_headers = {
"Cookie": "; ".join(
[
f"{cookie.key}={cookie.value}"
for cookie in self.session.cookie_jar
]
),
'Cookie': '; '.join([f'{cookie.key}={cookie.value}' for cookie in self.session.cookie_jar]),
**self.session.headers,
}
return websocket_url, ws_headers
@@ -126,9 +119,7 @@ class JupyterCodeExecuter:
# initialize ws
websocket_url, ws_headers = self.init_ws()
# execute
async with websockets.connect(
websocket_url, additional_headers=ws_headers
) as ws:
async with websockets.connect(websocket_url, additional_headers=ws_headers) as ws:
await self.execute_in_jupyter(ws)
async def execute_in_jupyter(self, ws) -> None:
@@ -137,71 +128,69 @@ class JupyterCodeExecuter:
await ws.send(
json.dumps(
{
"header": {
"msg_id": msg_id,
"msg_type": "execute_request",
"username": "user",
"session": uuid.uuid4().hex,
"date": "",
"version": "5.3",
'header': {
'msg_id': msg_id,
'msg_type': 'execute_request',
'username': 'user',
'session': uuid.uuid4().hex,
'date': '',
'version': '5.3',
},
"parent_header": {},
"metadata": {},
"content": {
"code": self.code,
"silent": False,
"store_history": True,
"user_expressions": {},
"allow_stdin": False,
"stop_on_error": True,
'parent_header': {},
'metadata': {},
'content': {
'code': self.code,
'silent': False,
'store_history': True,
'user_expressions': {},
'allow_stdin': False,
'stop_on_error': True,
},
"channel": "shell",
'channel': 'shell',
}
)
)
# parse message
stdout, stderr, result = "", "", []
stdout, stderr, result = '', '', []
while True:
try:
# wait for message
message = await asyncio.wait_for(ws.recv(), self.timeout)
message_data = json.loads(message)
# msg id not match, skip
if message_data.get("parent_header", {}).get("msg_id") != msg_id:
if message_data.get('parent_header', {}).get('msg_id') != msg_id:
continue
# check message type
msg_type = message_data.get("msg_type")
msg_type = message_data.get('msg_type')
match msg_type:
case "stream":
if message_data["content"]["name"] == "stdout":
stdout += message_data["content"]["text"]
elif message_data["content"]["name"] == "stderr":
stderr += message_data["content"]["text"]
case "execute_result" | "display_data":
data = message_data["content"]["data"]
if "image/png" in data:
result.append(f"data:image/png;base64,{data['image/png']}")
elif "text/plain" in data:
result.append(data["text/plain"])
case "error":
stderr += "\n".join(message_data["content"]["traceback"])
case "status":
if message_data["content"]["execution_state"] == "idle":
case 'stream':
if message_data['content']['name'] == 'stdout':
stdout += message_data['content']['text']
elif message_data['content']['name'] == 'stderr':
stderr += message_data['content']['text']
case 'execute_result' | 'display_data':
data = message_data['content']['data']
if 'image/png' in data:
result.append(f'data:image/png;base64,{data["image/png"]}')
elif 'text/plain' in data:
result.append(data['text/plain'])
case 'error':
stderr += '\n'.join(message_data['content']['traceback'])
case 'status':
if message_data['content']['execution_state'] == 'idle':
break
except asyncio.TimeoutError:
stderr += "\nExecution timed out."
stderr += '\nExecution timed out.'
break
self.result.stdout = stdout.strip()
self.result.stderr = stderr.strip()
self.result.result = "\n".join(result).strip() if result else ""
self.result.result = '\n'.join(result).strip() if result else ''
async def execute_code_jupyter(
base_url: str, code: str, token: str = "", password: str = "", timeout: int = 60
base_url: str, code: str, token: str = '', password: str = '', timeout: int = 60
) -> dict:
async with JupyterCodeExecuter(
base_url, code, token, password, timeout
) as executor:
async with JupyterCodeExecuter(base_url, code, token, password, timeout) as executor:
result = await executor.run()
return result.model_dump()
+12 -12
View File
@@ -43,35 +43,35 @@ async def generate_embeddings(
bypass_filter = True
# Attach extra metadata from request.state if present
if hasattr(request.state, "metadata"):
if "metadata" not in form_data:
form_data["metadata"] = request.state.metadata
if hasattr(request.state, 'metadata'):
if 'metadata' not in form_data:
form_data['metadata'] = request.state.metadata
else:
form_data["metadata"] = {
**form_data["metadata"],
form_data['metadata'] = {
**form_data['metadata'],
**request.state.metadata,
}
# If "direct" flag present, use only that model
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
models = {
request.state.model["id"]: request.state.model,
request.state.model['id']: request.state.model,
}
else:
models = request.app.state.MODELS
model_id = form_data.get("model")
model_id = form_data.get('model')
if model_id not in models:
raise Exception("Model not found")
raise Exception('Model not found')
model = models[model_id]
# Access filtering
if not getattr(request.state, "direct", False):
if not bypass_filter and user.role == "user":
if not getattr(request.state, 'direct', False):
if not bypass_filter and user.role == 'user':
check_model_access(user, model)
# Ollama backend — use /api/embed which supports batch input natively
if model.get("owned_by") == "ollama":
if model.get('owned_by') == 'ollama':
ollama_payload = convert_embed_payload_openai_to_ollama(form_data)
response = await ollama_embed(
request=request,
+26 -28
View File
@@ -27,22 +27,22 @@ import re
import requests
BASE64_IMAGE_URL_PREFIX = re.compile(r"data:image/\w+;base64,", re.IGNORECASE)
MARKDOWN_IMAGE_URL_PATTERN = re.compile(r"!\[(.*?)\]\((.+?)\)", re.IGNORECASE)
BASE64_IMAGE_URL_PREFIX = re.compile(r'data:image/\w+;base64,', re.IGNORECASE)
MARKDOWN_IMAGE_URL_PATTERN = re.compile(r'!\[(.*?)\]\((.+?)\)', re.IGNORECASE)
def get_image_base64_from_url(url: str) -> Optional[str]:
try:
if url.startswith("http"):
if url.startswith('http'):
# Validate URL to prevent SSRF attacks against local/private networks
validate_url(url)
# Download the image from the URL
response = requests.get(url)
response.raise_for_status()
image_data = response.content
encoded_string = base64.b64encode(image_data).decode("utf-8")
content_type = response.headers.get("Content-Type", "image/png")
return f"data:{content_type};base64,{encoded_string}"
encoded_string = base64.b64encode(image_data).decode('utf-8')
content_type = response.headers.get('Content-Type', 'image/png')
return f'data:{content_type};base64,{encoded_string}'
else:
file = Files.get_file_by_id(url)
@@ -53,10 +53,10 @@ def get_image_base64_from_url(url: str) -> Optional[str]:
file_path = Path(file_path)
if file_path.is_file():
with open(file_path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
with open(file_path, 'rb') as image_file:
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
content_type, _ = mimetypes.guess_type(file_path.name)
return f"data:{content_type};base64,{encoded_string}"
return f'data:{content_type};base64,{encoded_string}'
else:
return None
@@ -66,7 +66,7 @@ def get_image_base64_from_url(url: str) -> Optional[str]:
def get_image_url_from_base64(request, base64_image_string, metadata, user):
if BASE64_IMAGE_URL_PREFIX.match(base64_image_string):
image_url = ""
image_url = ''
# Extract base64 image data from the line
image_data, content_type = get_image_data(base64_image_string)
if image_data is not None:
@@ -89,7 +89,7 @@ def convert_markdown_base64_images(request, content: str, metadata, user):
if len(base64_string) > MIN_REPLACEMENT_URL_LENGTH:
url = get_image_url_from_base64(request, base64_string, metadata, user)
if url:
return f"![{match.group(1)}]({url})"
return f'![{match.group(1)}]({url})'
return match.group(0)
return MARKDOWN_IMAGE_URL_PATTERN.sub(replace, content)
@@ -97,18 +97,16 @@ def convert_markdown_base64_images(request, content: str, metadata, user):
def load_b64_audio_data(b64_str):
try:
if "," in b64_str:
header, b64_data = b64_str.split(",", 1)
if ',' in b64_str:
header, b64_data = b64_str.split(',', 1)
else:
b64_data = b64_str
header = "data:audio/wav;base64"
header = 'data:audio/wav;base64'
audio_data = base64.b64decode(b64_data)
content_type = (
header.split(";")[0].split(":")[1] if ";" in header else "audio/wav"
)
content_type = header.split(';')[0].split(':')[1] if ';' in header else 'audio/wav'
return audio_data, content_type
except Exception as e:
print(f"Error decoding base64 audio data: {e}")
print(f'Error decoding base64 audio data: {e}')
return None, None
@@ -116,9 +114,9 @@ def upload_audio(request, audio_data, content_type, metadata, user):
audio_format = mimetypes.guess_extension(content_type)
file = UploadFile(
file=io.BytesIO(audio_data),
filename=f"generated-{audio_format}", # will be converted to a unique ID on upload_file
filename=f'generated-{audio_format}', # will be converted to a unique ID on upload_file
headers={
"content-type": content_type,
'content-type': content_type,
},
)
file_item = upload_file_handler(
@@ -128,13 +126,13 @@ def upload_audio(request, audio_data, content_type, metadata, user):
process=False,
user=user,
)
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
url = request.app.url_path_for('get_file_content_by_id', id=file_item.id)
return url
def get_audio_url_from_base64(request, base64_audio_string, metadata, user):
if "data:audio/wav;base64" in base64_audio_string:
audio_url = ""
if 'data:audio/wav;base64' in base64_audio_string:
audio_url = ''
# Extract base64 audio data from the line
audio_data, content_type = load_b64_audio_data(base64_audio_string)
if audio_data is not None:
@@ -150,9 +148,9 @@ def get_audio_url_from_base64(request, base64_audio_string, metadata, user):
def get_file_url_from_base64(request, base64_file_string, metadata, user):
if "data:image/png;base64" in base64_file_string:
if 'data:image/png;base64' in base64_file_string:
return get_image_url_from_base64(request, base64_file_string, metadata, user)
elif "data:audio/wav;base64" in base64_file_string:
elif 'data:audio/wav;base64' in base64_file_string:
return get_audio_url_from_base64(request, base64_file_string, metadata, user)
return None
@@ -170,10 +168,10 @@ def get_image_base64_from_file_id(id: str) -> Optional[str]:
if file_path.is_file():
import base64
with open(file_path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
with open(file_path, 'rb') as image_file:
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
content_type, _ = mimetypes.guess_type(file_path.name)
return f"data:{content_type};base64,{encoded_string}"
return f'data:{content_type};base64,{encoded_string}'
else:
return None
except Exception as e:
+27 -42
View File
@@ -14,9 +14,7 @@ def get_function_module(request, function_id, load_from_db=True):
"""
Get the function module by its ID.
"""
function_module, _, _ = get_function_module_from_cache(
request, function_id, load_from_db
)
function_module, _, _ = get_function_module_from_cache(request, function_id, load_from_db)
return function_module
@@ -24,34 +22,29 @@ def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None)
def get_priority(function_id):
try:
function_module = get_function_module(request, function_id)
if function_module and hasattr(function_module, "Valves"):
if function_module and hasattr(function_module, 'Valves'):
valves_db = Functions.get_function_valves_by_id(function_id)
valves = function_module.Valves(**(valves_db if valves_db else {}))
return getattr(valves, "priority", 0)
return getattr(valves, 'priority', 0)
except Exception:
pass
return 0
filter_ids = [function.id for function in Functions.get_global_filter_functions()]
if "info" in model and "meta" in model["info"]:
filter_ids.extend(model["info"]["meta"].get("filterIds", []))
if 'info' in model and 'meta' in model['info']:
filter_ids.extend(model['info']['meta'].get('filterIds', []))
filter_ids = list(set(filter_ids))
active_filter_ids = [
function.id
for function in Functions.get_functions_by_type("filter", active_only=True)
]
active_filter_ids = [function.id for function in Functions.get_functions_by_type('filter', active_only=True)]
def get_active_status(filter_id):
function_module = get_function_module(request, filter_id)
if getattr(function_module, "toggle", None):
if getattr(function_module, 'toggle', None):
return filter_id in (enabled_filter_ids or [])
return True
active_filter_ids = [
filter_id for filter_id in active_filter_ids if get_active_status(filter_id)
]
active_filter_ids = [filter_id for filter_id in active_filter_ids if get_active_status(filter_id)]
filter_ids = [fid for fid in filter_ids if fid in active_filter_ids]
filter_ids.sort(key=lambda fid: (get_priority(fid), fid))
@@ -59,9 +52,7 @@ def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None)
return filter_ids
async def process_filter_functions(
request, filter_functions, filter_type, form_data, extra_params
):
async def process_filter_functions(request, filter_functions, filter_type, form_data, extra_params):
skip_files = None
for function in filter_functions:
@@ -70,53 +61,47 @@ async def process_filter_functions(
if not filter:
continue
function_module = get_function_module(
request, filter_id, load_from_db=(filter_type != "stream")
)
function_module = get_function_module(request, filter_id, load_from_db=(filter_type != 'stream'))
# Prepare handler function
handler = getattr(function_module, filter_type, None)
if not handler:
continue
# Check if the function has a file_handler variable
if filter_type == "inlet" and hasattr(function_module, "file_handler"):
if filter_type == 'inlet' and hasattr(function_module, 'file_handler'):
skip_files = function_module.file_handler
# Apply valves to the function
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
if hasattr(function_module, 'valves') and hasattr(function_module, 'Valves'):
valves = Functions.get_function_valves_by_id(filter_id)
function_module.valves = function_module.Valves(
**(valves if valves else {})
)
function_module.valves = function_module.Valves(**(valves if valves else {}))
try:
# Prepare parameters
sig = inspect.signature(handler)
params = {"body": form_data}
if filter_type == "stream":
params = {"event": form_data}
params = {'body': form_data}
if filter_type == 'stream':
params = {'event': form_data}
params = params | {
k: v
for k, v in {
**extra_params,
"__id__": filter_id,
'__id__': filter_id,
}.items()
if k in sig.parameters
}
# Handle user parameters
if "__user__" in sig.parameters:
if hasattr(function_module, "UserValves"):
if '__user__' in sig.parameters:
if hasattr(function_module, 'UserValves'):
try:
params["__user__"]["valves"] = function_module.UserValves(
**Functions.get_user_valves_by_id_and_user_id(
filter_id, params["__user__"]["id"]
)
params['__user__']['valves'] = function_module.UserValves(
**Functions.get_user_valves_by_id_and_user_id(filter_id, params['__user__']['id'])
)
except Exception as e:
log.exception(f"Failed to get user values: {e}")
log.exception(f'Failed to get user values: {e}')
# Execute handler
if inspect.iscoroutinefunction(handler):
@@ -125,14 +110,14 @@ async def process_filter_functions(
form_data = handler(**params)
except Exception as e:
log.debug(f"Error in {filter_type} handler {filter_id}: {e}")
log.debug(f'Error in {filter_type} handler {filter_id}: {e}')
raise e
# Handle file cleanup for inlet
if skip_files:
if "files" in form_data.get("metadata", {}):
del form_data["metadata"]["files"]
if "files" in form_data:
del form_data["files"]
if 'files' in form_data.get('metadata', {}):
del form_data['metadata']['files']
if 'files' in form_data:
del form_data['files']
return form_data, {}
+1 -3
View File
@@ -20,6 +20,4 @@ def apply_default_group_assignment(
try:
Groups.add_users_to_group(default_group_id, [user_id], db=db)
except Exception as e:
log.error(
f"Failed to add user {user_id} to default group {default_group_id}: {e}"
)
log.error(f'Failed to add user {user_id} to default group {default_group_id}: {e}')
+1 -1
View File
@@ -11,7 +11,7 @@ from open_webui.env import (
def include_user_info_headers(headers, user):
return {
**headers,
FORWARD_USER_INFO_HEADER_USER_NAME: quote(user.name, safe=" "),
FORWARD_USER_INFO_HEADER_USER_NAME: quote(user.name, safe=' '),
FORWARD_USER_INFO_HEADER_USER_ID: user.id,
FORWARD_USER_INFO_HEADER_USER_EMAIL: user.email,
FORWARD_USER_INFO_HEADER_USER_ROLE: user.role,
+96 -138
View File
@@ -13,99 +13,97 @@ from pydantic import BaseModel
log = logging.getLogger(__name__)
default_headers = {"User-Agent": "Mozilla/5.0"}
default_headers = {'User-Agent': 'Mozilla/5.0'}
def queue_prompt(prompt, client_id, base_url, api_key):
log.info("queue_prompt")
p = {"prompt": prompt, "client_id": client_id}
data = json.dumps(p).encode("utf-8")
log.debug(f"queue_prompt data: {data}")
log.info('queue_prompt')
p = {'prompt': prompt, 'client_id': client_id}
data = json.dumps(p).encode('utf-8')
log.debug(f'queue_prompt data: {data}')
try:
req = urllib.request.Request(
f"{base_url}/prompt",
f'{base_url}/prompt',
data=data,
headers={**default_headers, "Authorization": f"Bearer {api_key}"},
headers={**default_headers, 'Authorization': f'Bearer {api_key}'},
)
response = urllib.request.urlopen(req).read()
return json.loads(response)
except Exception as e:
log.exception(f"Error while queuing prompt: {e}")
log.exception(f'Error while queuing prompt: {e}')
raise e
def get_image(filename, subfolder, folder_type, base_url, api_key):
log.info("get_image")
data = {"filename": filename, "subfolder": subfolder, "type": folder_type}
log.info('get_image')
data = {'filename': filename, 'subfolder': subfolder, 'type': folder_type}
url_values = urllib.parse.urlencode(data)
req = urllib.request.Request(
f"{base_url}/view?{url_values}",
headers={**default_headers, "Authorization": f"Bearer {api_key}"},
f'{base_url}/view?{url_values}',
headers={**default_headers, 'Authorization': f'Bearer {api_key}'},
)
with urllib.request.urlopen(req) as response:
return response.read()
def get_image_url(filename, subfolder, folder_type, base_url):
log.info("get_image")
data = {"filename": filename, "subfolder": subfolder, "type": folder_type}
log.info('get_image')
data = {'filename': filename, 'subfolder': subfolder, 'type': folder_type}
url_values = urllib.parse.urlencode(data)
return f"{base_url}/view?{url_values}"
return f'{base_url}/view?{url_values}'
def get_history(prompt_id, base_url, api_key):
log.info("get_history")
log.info('get_history')
req = urllib.request.Request(
f"{base_url}/history/{prompt_id}",
headers={**default_headers, "Authorization": f"Bearer {api_key}"},
f'{base_url}/history/{prompt_id}',
headers={**default_headers, 'Authorization': f'Bearer {api_key}'},
)
with urllib.request.urlopen(req) as response:
return json.loads(response.read())
def get_images(ws, workflow, client_id, base_url, api_key):
prompt_id = queue_prompt(workflow, client_id, base_url, api_key)["prompt_id"]
prompt_id = queue_prompt(workflow, client_id, base_url, api_key)['prompt_id']
output_images = []
while True:
out = ws.recv()
if isinstance(out, str):
message = json.loads(out)
if message["type"] == "executing":
data = message["data"]
if data["node"] is None and data["prompt_id"] == prompt_id:
if message['type'] == 'executing':
data = message['data']
if data['node'] is None and data['prompt_id'] == prompt_id:
break # Execution is done
else:
continue # previews are binary data
history = get_history(prompt_id, base_url, api_key)[prompt_id]
for node_id in history["outputs"]:
node_output = history["outputs"][node_id]
if node_id in workflow and workflow[node_id].get("class_type") in [
"SaveImage",
"PreviewImage",
for node_id in history['outputs']:
node_output = history['outputs'][node_id]
if node_id in workflow and workflow[node_id].get('class_type') in [
'SaveImage',
'PreviewImage',
]:
if "images" in node_output:
for image in node_output["images"]:
url = get_image_url(
image["filename"], image["subfolder"], image["type"], base_url
)
output_images.append({"url": url})
return {"data": output_images}
if 'images' in node_output:
for image in node_output['images']:
url = get_image_url(image['filename'], image['subfolder'], image['type'], base_url)
output_images.append({'url': url})
return {'data': output_images}
async def comfyui_upload_image(image_file_item, base_url, api_key):
url = f"{base_url}/api/upload/image"
url = f'{base_url}/api/upload/image'
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers['Authorization'] = f'Bearer {api_key}'
_, (filename, file_bytes, mime_type) = image_file_item
form = aiohttp.FormData()
form.add_field("image", file_bytes, filename=filename, content_type=mime_type)
form.add_field("type", "input") # required by ComfyUI
form.add_field('image', file_bytes, filename=filename, content_type=mime_type)
form.add_field('type', 'input') # required by ComfyUI
async with aiohttp.ClientSession() as session:
async with session.post(url, data=form, headers=headers) as resp:
@@ -116,7 +114,7 @@ async def comfyui_upload_image(image_file_item, base_url, api_key):
class ComfyUINodeInput(BaseModel):
type: Optional[str] = None
node_ids: list[str] = []
key: Optional[str] = "text"
key: Optional[str] = 'text'
value: Optional[str] = None
@@ -138,76 +136,56 @@ class ComfyUICreateImageForm(BaseModel):
seed: Optional[int] = None
async def comfyui_create_image(
model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key
):
ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://")
async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key):
ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://')
workflow = json.loads(payload.workflow.workflow)
for node in payload.workflow.nodes:
if node.type:
if node.type == "model":
if node.type == 'model':
for node_id in node.node_ids:
workflow[node_id]["inputs"][node.key] = model
elif node.type == "prompt":
workflow[node_id]['inputs'][node.key] = model
elif node.type == 'prompt':
for node_id in node.node_ids:
workflow[node_id]["inputs"][
node.key if node.key else "text"
] = payload.prompt
elif node.type == "negative_prompt":
workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.prompt
elif node.type == 'negative_prompt':
for node_id in node.node_ids:
workflow[node_id]["inputs"][
node.key if node.key else "text"
] = payload.negative_prompt
elif node.type == "width":
workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.negative_prompt
elif node.type == 'width':
for node_id in node.node_ids:
workflow[node_id]["inputs"][
node.key if node.key else "width"
] = payload.width
elif node.type == "height":
workflow[node_id]['inputs'][node.key if node.key else 'width'] = payload.width
elif node.type == 'height':
for node_id in node.node_ids:
workflow[node_id]["inputs"][
node.key if node.key else "height"
] = payload.height
elif node.type == "n":
workflow[node_id]['inputs'][node.key if node.key else 'height'] = payload.height
elif node.type == 'n':
for node_id in node.node_ids:
workflow[node_id]["inputs"][
node.key if node.key else "batch_size"
] = payload.n
elif node.type == "steps":
workflow[node_id]['inputs'][node.key if node.key else 'batch_size'] = payload.n
elif node.type == 'steps':
for node_id in node.node_ids:
workflow[node_id]["inputs"][
node.key if node.key else "steps"
] = payload.steps
elif node.type == "seed":
seed = (
payload.seed
if payload.seed
else random.randint(0, 1125899906842624)
)
workflow[node_id]['inputs'][node.key if node.key else 'steps'] = payload.steps
elif node.type == 'seed':
seed = payload.seed if payload.seed else random.randint(0, 1125899906842624)
for node_id in node.node_ids:
workflow[node_id]["inputs"][node.key] = seed
workflow[node_id]['inputs'][node.key] = seed
else:
for node_id in node.node_ids:
workflow[node_id]["inputs"][node.key] = node.value
workflow[node_id]['inputs'][node.key] = node.value
try:
ws = websocket.WebSocket()
headers = {"Authorization": f"Bearer {api_key}"}
ws.connect(f"{ws_url}/ws?clientId={client_id}", header=headers)
log.info("WebSocket connection established.")
headers = {'Authorization': f'Bearer {api_key}'}
ws.connect(f'{ws_url}/ws?clientId={client_id}', header=headers)
log.info('WebSocket connection established.')
except Exception as e:
log.exception(f"Failed to connect to WebSocket server: {e}")
log.exception(f'Failed to connect to WebSocket server: {e}')
return None
try:
log.info("Sending workflow to WebSocket server.")
log.info(f"Workflow: {workflow}")
images = await asyncio.to_thread(
get_images, ws, workflow, client_id, base_url, api_key
)
log.info('Sending workflow to WebSocket server.')
log.info(f'Workflow: {workflow}')
images = await asyncio.to_thread(get_images, ws, workflow, client_id, base_url, api_key)
except Exception as e:
log.exception(f"Error while receiving images: {e}")
log.exception(f'Error while receiving images: {e}')
images = None
ws.close()
@@ -228,85 +206,65 @@ class ComfyUIEditImageForm(BaseModel):
seed: Optional[int] = None
async def comfyui_edit_image(
model: str, payload: ComfyUIEditImageForm, client_id, base_url, api_key
):
ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://")
async def comfyui_edit_image(model: str, payload: ComfyUIEditImageForm, client_id, base_url, api_key):
ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://')
workflow = json.loads(payload.workflow.workflow)
for node in payload.workflow.nodes:
if node.type:
if node.type == "model":
if node.type == 'model':
for node_id in node.node_ids:
workflow[node_id]["inputs"][node.key] = model
elif node.type == "image":
workflow[node_id]['inputs'][node.key] = model
elif node.type == 'image':
if isinstance(payload.image, list):
# check if multiple images are provided
for idx, node_id in enumerate(node.node_ids):
if idx < len(payload.image):
workflow[node_id]["inputs"][node.key] = payload.image[idx]
workflow[node_id]['inputs'][node.key] = payload.image[idx]
else:
for node_id in node.node_ids:
workflow[node_id]["inputs"][node.key] = payload.image
elif node.type == "prompt":
workflow[node_id]['inputs'][node.key] = payload.image
elif node.type == 'prompt':
for node_id in node.node_ids:
workflow[node_id]["inputs"][
node.key if node.key else "text"
] = payload.prompt
elif node.type == "negative_prompt":
workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.prompt
elif node.type == 'negative_prompt':
for node_id in node.node_ids:
workflow[node_id]["inputs"][
node.key if node.key else "text"
] = payload.negative_prompt
elif node.type == "width":
workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.negative_prompt
elif node.type == 'width':
for node_id in node.node_ids:
workflow[node_id]["inputs"][
node.key if node.key else "width"
] = payload.width
elif node.type == "height":
workflow[node_id]['inputs'][node.key if node.key else 'width'] = payload.width
elif node.type == 'height':
for node_id in node.node_ids:
workflow[node_id]["inputs"][
node.key if node.key else "height"
] = payload.height
elif node.type == "n":
workflow[node_id]['inputs'][node.key if node.key else 'height'] = payload.height
elif node.type == 'n':
for node_id in node.node_ids:
workflow[node_id]["inputs"][
node.key if node.key else "batch_size"
] = payload.n
elif node.type == "steps":
workflow[node_id]['inputs'][node.key if node.key else 'batch_size'] = payload.n
elif node.type == 'steps':
for node_id in node.node_ids:
workflow[node_id]["inputs"][
node.key if node.key else "steps"
] = payload.steps
elif node.type == "seed":
seed = (
payload.seed
if payload.seed
else random.randint(0, 1125899906842624)
)
workflow[node_id]['inputs'][node.key if node.key else 'steps'] = payload.steps
elif node.type == 'seed':
seed = payload.seed if payload.seed else random.randint(0, 1125899906842624)
for node_id in node.node_ids:
workflow[node_id]["inputs"][node.key] = seed
workflow[node_id]['inputs'][node.key] = seed
else:
for node_id in node.node_ids:
workflow[node_id]["inputs"][node.key] = node.value
workflow[node_id]['inputs'][node.key] = node.value
try:
ws = websocket.WebSocket()
headers = {"Authorization": f"Bearer {api_key}"}
ws.connect(f"{ws_url}/ws?clientId={client_id}", header=headers)
log.info("WebSocket connection established.")
headers = {'Authorization': f'Bearer {api_key}'}
ws.connect(f'{ws_url}/ws?clientId={client_id}', header=headers)
log.info('WebSocket connection established.')
except Exception as e:
log.exception(f"Failed to connect to WebSocket server: {e}")
log.exception(f'Failed to connect to WebSocket server: {e}')
return None
try:
log.info("Sending workflow to WebSocket server.")
log.info(f"Workflow: {workflow}")
images = await asyncio.to_thread(
get_images, ws, workflow, client_id, base_url, api_key
)
log.info('Sending workflow to WebSocket server.')
log.info(f'Workflow: {workflow}')
images = await asyncio.to_thread(get_images, ws, workflow, client_id, base_url, api_key)
except Exception as e:
log.exception(f"Error while receiving images: {e}")
log.exception(f'Error while receiving images: {e}')
images = None
ws.close()
+47 -53
View File
@@ -23,7 +23,7 @@ if TYPE_CHECKING:
from loguru import Message, Record
def stdout_format(record: "Record") -> str:
def stdout_format(record: 'Record') -> str:
"""
Generates a formatted string for log records that are output to the console. This format includes a timestamp, log level, source location (module, function, and line), the log message, and any extra data (serialized as JSON).
@@ -32,39 +32,39 @@ def stdout_format(record: "Record") -> str:
Returns:
str: A formatted log string intended for stdout.
"""
if record["extra"]:
record["extra"]["extra_json"] = json.dumps(record["extra"])
extra_format = " - {extra[extra_json]}"
if record['extra']:
record['extra']['extra_json'] = json.dumps(record['extra'])
extra_format = ' - {extra[extra_json]}'
else:
extra_format = ""
extra_format = ''
return (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
"<level>{message}</level>" + extra_format + "\n{exception}"
'<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | '
'<level>{level: <8}</level> | '
'<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - '
'<level>{message}</level>' + extra_format + '\n{exception}'
)
def _json_sink(message: "Message") -> None:
def _json_sink(message: 'Message') -> None:
"""Write log records as single-line JSON to stdout.
Used as a Loguru sink when LOG_FORMAT is set to "json".
"""
record = message.record
log_entry = {
"ts": record["time"].strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
"level": _LEVEL_MAP.get(record["level"].name, record["level"].name.lower()),
"msg": record["message"],
"caller": f"{record['name']}:{record['function']}:{record['line']}",
'ts': record['time'].strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z',
'level': _LEVEL_MAP.get(record['level'].name, record['level'].name.lower()),
'msg': record['message'],
'caller': f'{record["name"]}:{record["function"]}:{record["line"]}',
}
if record["extra"]:
log_entry["extra"] = record["extra"]
if record['extra']:
log_entry['extra'] = record['extra']
if record["exception"] is not None:
log_entry["error"] = "".join(record["exception"].format_exception()).rstrip()
if record['exception'] is not None:
log_entry['error'] = ''.join(record['exception'].format_exception()).rstrip()
sys.stdout.write(json.dumps(log_entry, ensure_ascii=False, default=str) + "\n")
sys.stdout.write(json.dumps(log_entry, ensure_ascii=False, default=str) + '\n')
sys.stdout.flush()
@@ -90,9 +90,7 @@ class InterceptHandler(logging.Handler):
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).bind(
**self._get_extras()
).log(level, record.getMessage())
logger.opt(depth=depth, exception=record.exc_info).bind(**self._get_extras()).log(level, record.getMessage())
if ENABLE_OTEL and ENABLE_OTEL_LOGS:
from open_webui.utils.telemetry.logs import otel_handler
@@ -105,12 +103,12 @@ class InterceptHandler(logging.Handler):
extras = {}
context = trace.get_current_span().get_span_context()
if context.is_valid:
extras["trace_id"] = trace.format_trace_id(context.trace_id)
extras["span_id"] = trace.format_span_id(context.span_id)
extras['trace_id'] = trace.format_trace_id(context.trace_id)
extras['span_id'] = trace.format_span_id(context.span_id)
return extras
def file_format(record: "Record"):
def file_format(record: 'Record'):
"""
Formats audit log records into a structured JSON string for file output.
@@ -121,22 +119,22 @@ def file_format(record: "Record"):
"""
audit_data = {
"id": record["extra"].get("id", ""),
"timestamp": int(record["time"].timestamp()),
"user": record["extra"].get("user", dict()),
"audit_level": record["extra"].get("audit_level", ""),
"verb": record["extra"].get("verb", ""),
"request_uri": record["extra"].get("request_uri", ""),
"response_status_code": record["extra"].get("response_status_code", 0),
"source_ip": record["extra"].get("source_ip", ""),
"user_agent": record["extra"].get("user_agent", ""),
"request_object": record["extra"].get("request_object", b""),
"response_object": record["extra"].get("response_object", b""),
"extra": record["extra"].get("extra", {}),
'id': record['extra'].get('id', ''),
'timestamp': int(record['time'].timestamp()),
'user': record['extra'].get('user', dict()),
'audit_level': record['extra'].get('audit_level', ''),
'verb': record['extra'].get('verb', ''),
'request_uri': record['extra'].get('request_uri', ''),
'response_status_code': record['extra'].get('response_status_code', 0),
'source_ip': record['extra'].get('source_ip', ''),
'user_agent': record['extra'].get('user_agent', ''),
'request_object': record['extra'].get('request_object', b''),
'response_object': record['extra'].get('response_object', b''),
'extra': record['extra'].get('extra', {}),
}
record["extra"]["file_extra"] = json.dumps(audit_data, default=str)
return "{extra[file_extra]}\n"
record['extra']['file_extra'] = json.dumps(audit_data, default=str)
return '{extra[file_extra]}\n'
def start_logger():
@@ -152,10 +150,8 @@ def start_logger():
"""
logger.remove()
audit_filter = lambda record: (
True if ENABLE_AUDIT_STDOUT else "auditable" not in record["extra"]
)
if LOG_FORMAT == "json":
audit_filter = lambda record: (True if ENABLE_AUDIT_STDOUT else 'auditable' not in record['extra'])
if LOG_FORMAT == 'json':
logger.add(
_json_sink,
level=GLOBAL_LOG_LEVEL,
@@ -168,24 +164,22 @@ def start_logger():
format=stdout_format,
filter=audit_filter,
)
if AUDIT_LOG_LEVEL != "NONE" and ENABLE_AUDIT_LOGS_FILE:
if AUDIT_LOG_LEVEL != 'NONE' and ENABLE_AUDIT_LOGS_FILE:
try:
logger.add(
AUDIT_LOGS_FILE_PATH,
level="INFO",
level='INFO',
rotation=AUDIT_LOG_FILE_ROTATION_SIZE,
compression="zip",
compression='zip',
format=file_format,
filter=lambda record: record["extra"].get("auditable") is True,
filter=lambda record: record['extra'].get('auditable') is True,
)
except Exception as e:
logger.error(f"Failed to initialize audit log file handler: {str(e)}")
logger.error(f'Failed to initialize audit log file handler: {str(e)}')
logging.basicConfig(
handlers=[InterceptHandler()], level=GLOBAL_LOG_LEVEL, force=True
)
logging.basicConfig(handlers=[InterceptHandler()], level=GLOBAL_LOG_LEVEL, force=True)
for uvicorn_logger_name in ["uvicorn", "uvicorn.error"]:
for uvicorn_logger_name in ['uvicorn', 'uvicorn.error']:
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
uvicorn_logger.handlers = []
@@ -195,4 +189,4 @@ def start_logger():
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
uvicorn_logger.handlers = [InterceptHandler()]
logger.info(f"GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}")
logger.info(f'GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}')
+20 -28
View File
@@ -20,15 +20,15 @@ def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
after construction does not affect the underlying transport's SSL context.
"""
kwargs = {
"follow_redirects": True,
"verify": False,
'follow_redirects': True,
'verify': False,
}
if timeout is not None:
kwargs["timeout"] = timeout
kwargs['timeout'] = timeout
if headers is not None:
kwargs["headers"] = headers
kwargs['headers'] = headers
if auth is not None:
kwargs["auth"] = auth
kwargs['auth'] = auth
return httpx.AsyncClient(**kwargs)
@@ -52,13 +52,9 @@ class MCPClient:
transport = await exit_stack.enter_async_context(self._streams_context)
read_stream, write_stream, _ = transport
self._session_context = ClientSession(
read_stream, write_stream
) # pylint: disable=W0201
self._session_context = ClientSession(read_stream, write_stream) # pylint: disable=W0201
self.session = await exit_stack.enter_async_context(
self._session_context
)
self.session = await exit_stack.enter_async_context(self._session_context)
with anyio.fail_after(10):
await self.session.initialize()
self.exit_stack = exit_stack.pop_all()
@@ -68,7 +64,7 @@ class MCPClient:
async def list_tool_specs(self) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
raise RuntimeError('MCP client is not connected.')
result = await self.session.list_tools()
tools = result.tools
@@ -81,26 +77,22 @@ class MCPClient:
inputSchema = tool.inputSchema
# TODO: handle outputSchema if needed
outputSchema = getattr(tool, "outputSchema", None)
outputSchema = getattr(tool, 'outputSchema', None)
tool_specs.append(
{"name": name, "description": description, "parameters": inputSchema}
)
tool_specs.append({'name': name, 'description': description, 'parameters': inputSchema})
return tool_specs
async def call_tool(
self, function_name: str, function_args: dict
) -> Optional[dict]:
async def call_tool(self, function_name: str, function_args: dict) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
raise RuntimeError('MCP client is not connected.')
result = await self.session.call_tool(function_name, function_args)
if not result:
raise Exception("No result returned from MCP tool call.")
raise Exception('No result returned from MCP tool call.')
result_dict = result.model_dump(mode="json")
result_content = result_dict.get("content", {})
result_dict = result.model_dump(mode='json')
result_content = result_dict.get('content', {})
if result.isError:
raise Exception(result_content)
@@ -109,24 +101,24 @@ class MCPClient:
async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
raise RuntimeError('MCP client is not connected.')
result = await self.session.list_resources(cursor=cursor)
if not result:
raise Exception("No result returned from MCP list_resources call.")
raise Exception('No result returned from MCP list_resources call.')
result_dict = result.model_dump()
resources = result_dict.get("resources", [])
resources = result_dict.get('resources', [])
return resources
async def read_resource(self, uri: str) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
raise RuntimeError('MCP client is not connected.')
result = await self.session.read_resource(uri)
if not result:
raise Exception("No result returned from MCP read_resource call.")
raise Exception('No result returned from MCP read_resource call.')
result_dict = result.model_dump()
return result_dict
File diff suppressed because it is too large Load Diff
+185 -226
View File
@@ -33,7 +33,7 @@ def get_allow_block_lists(filter_list):
if filter_list:
for d in filter_list:
if d.startswith("!"):
if d.startswith('!'):
# Domains starting with "!" → blocked
block_list.append(d[1:].strip())
else:
@@ -43,9 +43,7 @@ def get_allow_block_lists(filter_list):
return allow_list, block_list
def is_string_allowed(
string: Union[str, Sequence[str]], filter_list: Optional[list[str]] = None
) -> bool:
def is_string_allowed(string: Union[str, Sequence[str]], filter_list: Optional[list[str]] = None) -> bool:
"""
Checks if a string is allowed based on the provided filter list.
:param string: The string or sequence of strings to check (e.g., domain or hostname).
@@ -94,7 +92,7 @@ def get_message_list(messages_map, message_id):
visited_message_ids = set()
while current_message:
message_id = current_message.get("id")
message_id = current_message.get('id')
if message_id in visited_message_ids:
# Cycle detected, break to prevent infinite loop
break
@@ -103,7 +101,7 @@ def get_message_list(messages_map, message_id):
visited_message_ids.add(message_id)
message_list.append(current_message)
parent_id = current_message.get("parentId") # Use .get() for safety
parent_id = current_message.get('parentId') # Use .get() for safety
current_message = messages_map.get(parent_id) if parent_id else None
message_list.reverse()
@@ -111,28 +109,23 @@ def get_message_list(messages_map, message_id):
def get_messages_content(messages: list[dict]) -> str:
return "\n".join(
[
f"{message['role'].upper()}: {get_content_from_message(message)}"
for message in messages
]
)
return '\n'.join([f'{message["role"].upper()}: {get_content_from_message(message)}' for message in messages])
def get_last_user_message_item(messages: list[dict]) -> Optional[dict]:
for message in reversed(messages):
if message["role"] == "user":
if message['role'] == 'user':
return message
return None
def get_content_from_message(message: dict) -> Optional[str]:
if isinstance(message.get("content"), list):
for item in message["content"]:
if item["type"] == "text":
return item["text"]
if isinstance(message.get('content'), list):
for item in message['content']:
if item['type'] == 'text':
return item['text']
else:
return message.get("content")
return message.get('content')
return None
@@ -161,111 +154,101 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
if pending_content or pending_tool_calls:
messages.append(
{
"role": "assistant",
"content": "\n".join(pending_content) if pending_content else "",
**(
{"tool_calls": pending_tool_calls} if pending_tool_calls else {}
),
'role': 'assistant',
'content': '\n'.join(pending_content) if pending_content else '',
**({'tool_calls': pending_tool_calls} if pending_tool_calls else {}),
}
)
pending_content = []
pending_tool_calls = []
for item in output:
item_type = item.get("type", "")
item_type = item.get('type', '')
if item_type == "message":
if item_type == 'message':
# Extract text from output_text content parts
content_parts = item.get("content", [])
text = ""
content_parts = item.get('content', [])
text = ''
for part in content_parts:
if part.get("type") == "output_text":
text += part.get("text", "")
if part.get('type') == 'output_text':
text += part.get('text', '')
if text:
pending_content.append(text)
elif item_type == "function_call":
elif item_type == 'function_call':
# Collect tool calls to batch into assistant message
arguments = item.get("arguments", "{}")
arguments = item.get('arguments', '{}')
# Ensure arguments is always a JSON string
if not isinstance(arguments, str):
arguments = json.dumps(arguments)
pending_tool_calls.append(
{
"id": item.get("call_id", ""),
"type": "function",
"function": {
"name": item.get("name", ""),
"arguments": arguments,
'id': item.get('call_id', ''),
'type': 'function',
'function': {
'name': item.get('name', ''),
'arguments': arguments,
},
}
)
elif item_type == "function_call_output":
elif item_type == 'function_call_output':
# Flush any pending content/tool_calls before adding tool result
flush_pending()
# Extract text from output content parts
output_parts = item.get("output", [])
content = ""
output_parts = item.get('output', [])
content = ''
for part in output_parts:
if part.get("type") == "input_text":
output_text = part.get("text", "")
content += (
str(output_text)
if not isinstance(output_text, str)
else output_text
)
if part.get('type') == 'input_text':
output_text = part.get('text', '')
content += str(output_text) if not isinstance(output_text, str) else output_text
messages.append(
{
"role": "tool",
"tool_call_id": item.get("call_id", ""),
"content": content,
'role': 'tool',
'tool_call_id': item.get('call_id', ''),
'content': content,
}
)
elif item_type == "reasoning":
elif item_type == 'reasoning':
if raw:
# Include reasoning with original tags for LLM re-processing
reasoning_text = ""
source_list = item.get("summary", []) or item.get("content", [])
reasoning_text = ''
source_list = item.get('summary', []) or item.get('content', [])
for part in source_list:
if part.get("type") == "output_text":
reasoning_text += part.get("text", "")
elif "text" in part:
reasoning_text += part.get("text", "")
if part.get('type') == 'output_text':
reasoning_text += part.get('text', '')
elif 'text' in part:
reasoning_text += part.get('text', '')
if reasoning_text:
start_tag = item.get("start_tag", "<think>")
end_tag = item.get("end_tag", "</think>")
pending_content.append(f"{start_tag}{reasoning_text}{end_tag}")
start_tag = item.get('start_tag', '<think>')
end_tag = item.get('end_tag', '</think>')
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
# else: skip reasoning blocks for normal LLM messages
elif item_type == "open_webui:code_interpreter":
elif item_type == 'open_webui:code_interpreter':
# Always include code interpreter content so the LLM knows
# the code was already executed and doesn't retry.
code = item.get("code", "")
code_output = item.get("output", "")
code = item.get('code', '')
code_output = item.get('output', '')
if code:
pending_content.append(
f"<code_interpreter>\n{code}\n</code_interpreter>"
)
pending_content.append(f'<code_interpreter>\n{code}\n</code_interpreter>')
if code_output:
if isinstance(code_output, dict):
stdout = code_output.get("stdout", "")
result = code_output.get("result", "")
stdout = code_output.get('stdout', '')
result = code_output.get('result', '')
output_text = stdout or result
else:
output_text = str(code_output)
if output_text:
pending_content.append(
f"<code_interpreter_output>\n{output_text}\n</code_interpreter_output>"
)
pending_content.append(f'<code_interpreter_output>\n{output_text}\n</code_interpreter_output>')
elif item_type.startswith("open_webui:"):
elif item_type.startswith('open_webui:'):
# Skip other extension types
pass
@@ -288,41 +271,41 @@ def set_last_user_message_content(content: str, messages: list[dict]) -> list[di
Handles both plain-string and list-of-parts content formats.
"""
for message in reversed(messages):
if message.get("role") == "user":
if isinstance(message.get("content"), list):
for item in message["content"]:
if item.get("type") == "text":
item["text"] = content
if message.get('role') == 'user':
if isinstance(message.get('content'), list):
for item in message['content']:
if item.get('type') == 'text':
item['text'] = content
break
else:
message["content"] = content
message['content'] = content
break
return messages
def get_last_assistant_message_item(messages: list[dict]) -> Optional[dict]:
for message in reversed(messages):
if message["role"] == "assistant":
if message['role'] == 'assistant':
return message
return None
def get_last_assistant_message(messages: list[dict]) -> Optional[str]:
for message in reversed(messages):
if message["role"] == "assistant":
if message['role'] == 'assistant':
return get_content_from_message(message)
return None
def get_system_message(messages: list[dict]) -> Optional[dict]:
for message in messages:
if message["role"] == "system":
if message['role'] == 'system':
return message
return None
def remove_system_message(messages: list[dict]) -> list[dict]:
return [message for message in messages if message["role"] != "system"]
return [message for message in messages if message['role'] != 'system']
def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict]]:
@@ -330,32 +313,30 @@ def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict]
def update_message_content(message: dict, content: str, append: bool = True) -> dict:
if isinstance(message["content"], list):
for item in message["content"]:
if item["type"] == "text":
if isinstance(message['content'], list):
for item in message['content']:
if item['type'] == 'text':
if append:
item["text"] = f"{item['text']}\n{content}"
item['text'] = f'{item["text"]}\n{content}'
else:
item["text"] = f"{content}\n{item['text']}"
item['text'] = f'{content}\n{item["text"]}'
else:
if append:
message["content"] = f"{message['content']}\n{content}"
message['content'] = f'{message["content"]}\n{content}'
else:
message["content"] = f"{content}\n{message['content']}"
message['content'] = f'{content}\n{message["content"]}'
return message
def replace_system_message_content(content: str, messages: list[dict]) -> dict:
for message in messages:
if message["role"] == "system":
message["content"] = content
if message['role'] == 'system':
message['content'] = content
break
return messages
def add_or_update_system_message(
content: str, messages: list[dict], append: bool = False
):
def add_or_update_system_message(content: str, messages: list[dict], append: bool = False):
"""
Adds a new system message at the beginning of the messages list
or updates the existing system message at the beginning.
@@ -365,11 +346,11 @@ def add_or_update_system_message(
:return: The updated list of message dictionaries.
"""
if messages and messages[0].get("role") == "system":
if messages and messages[0].get('role') == 'system':
messages[0] = update_message_content(messages[0], content, append)
else:
# Insert at the beginning
messages.insert(0, {"role": "system", "content": content})
messages.insert(0, {'role': 'system', 'content': content})
return messages
@@ -384,20 +365,18 @@ def add_or_update_user_message(content: str, messages: list[dict], append: bool
:return: The updated list of message dictionaries.
"""
if messages and messages[-1].get("role") == "user":
if messages and messages[-1].get('role') == 'user':
messages[-1] = update_message_content(messages[-1], content, append)
else:
# Insert at the end
messages.append({"role": "user", "content": content})
messages.append({'role': 'user', 'content': content})
return messages
def prepend_to_first_user_message_content(
content: str, messages: list[dict]
) -> list[dict]:
def prepend_to_first_user_message_content(content: str, messages: list[dict]) -> list[dict]:
for message in messages:
if message["role"] == "user":
if message['role'] == 'user':
message = update_message_content(message, content, append=False)
break
return messages
@@ -413,21 +392,21 @@ def append_or_update_assistant_message(content: str, messages: list[dict]):
:return: The updated list of message dictionaries.
"""
if messages and messages[-1].get("role") == "assistant":
messages[-1]["content"] = f"{messages[-1]['content']}\n{content}"
if messages and messages[-1].get('role') == 'assistant':
messages[-1]['content'] = f'{messages[-1]["content"]}\n{content}'
else:
# Insert at the end
messages.append({"role": "assistant", "content": content})
messages.append({'role': 'assistant', 'content': content})
return messages
def openai_chat_message_template(model: str):
return {
"id": f"{model}-{str(uuid.uuid4())}",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "logprobs": None, "finish_reason": None}],
'id': f'{model}-{str(uuid.uuid4())}',
'created': int(time.time()),
'model': model,
'choices': [{'index': 0, 'logprobs': None, 'finish_reason': None}],
}
@@ -439,25 +418,25 @@ def openai_chat_chunk_message_template(
usage: Optional[dict] = None,
) -> dict:
template = openai_chat_message_template(model)
template["object"] = "chat.completion.chunk"
template['object'] = 'chat.completion.chunk'
template["choices"][0]["index"] = 0
template["choices"][0]["delta"] = {}
template['choices'][0]['index'] = 0
template['choices'][0]['delta'] = {}
if content:
template["choices"][0]["delta"]["content"] = content
template['choices'][0]['delta']['content'] = content
if reasoning_content:
template["choices"][0]["delta"]["reasoning_content"] = reasoning_content
template['choices'][0]['delta']['reasoning_content'] = reasoning_content
if tool_calls:
template["choices"][0]["delta"]["tool_calls"] = tool_calls
template['choices'][0]['delta']['tool_calls'] = tool_calls
if not content and not reasoning_content and not tool_calls:
template["choices"][0]["finish_reason"] = "stop"
template['choices'][0]['finish_reason'] = 'stop'
if usage:
template["usage"] = usage
template['usage'] = usage
return template
@@ -469,19 +448,19 @@ def openai_chat_completion_message_template(
usage: Optional[dict] = None,
) -> dict:
template = openai_chat_message_template(model)
template["object"] = "chat.completion"
template['object'] = 'chat.completion'
if message is not None:
template["choices"][0]["message"] = {
"role": "assistant",
"content": message,
**({"reasoning_content": reasoning_content} if reasoning_content else {}),
**({"tool_calls": tool_calls} if tool_calls else {}),
template['choices'][0]['message'] = {
'role': 'assistant',
'content': message,
**({'reasoning_content': reasoning_content} if reasoning_content else {}),
**({'tool_calls': tool_calls} if tool_calls else {}),
}
template["choices"][0]["finish_reason"] = "tool_calls" if tool_calls else "stop"
template['choices'][0]['finish_reason'] = 'tool_calls' if tool_calls else 'stop'
if usage:
template["usage"] = usage
template['usage'] = usage
return template
@@ -496,13 +475,13 @@ def get_gravatar_url(email):
hash_hex = hash_object.hexdigest()
# Grab the actual image URL
return f"https://www.gravatar.com/avatar/{hash_hex}?d=mp"
return f'https://www.gravatar.com/avatar/{hash_hex}?d=mp'
def calculate_sha256(file_path, chunk_size):
# Compute SHA-256 hash of a file efficiently in chunks
sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
with open(file_path, 'rb') as f:
while chunk := f.read(chunk_size):
sha256.update(chunk)
return sha256.hexdigest()
@@ -512,17 +491,17 @@ def calculate_sha256_string(string):
# Create a new SHA-256 hash object
sha256_hash = hashlib.sha256()
# Update the hash object with the bytes of the input string
sha256_hash.update(string.encode("utf-8"))
sha256_hash.update(string.encode('utf-8'))
# Get the hexadecimal representation of the hash
hashed_string = sha256_hash.hexdigest()
return hashed_string
def validate_email_format(email: str) -> bool:
if email.endswith("@localhost"):
if email.endswith('@localhost'):
return True
return bool(re.match(r"[^@]+@[^@]+\.[^@]+", email))
return bool(re.match(r'[^@]+@[^@]+\.[^@]+', email))
def sanitize_filename(file_name):
@@ -530,10 +509,10 @@ def sanitize_filename(file_name):
lower_case_file_name = file_name.lower()
# Remove special characters using regular expression
sanitized_file_name = re.sub(r"[^\w\s]", "", lower_case_file_name)
sanitized_file_name = re.sub(r'[^\w\s]', '', lower_case_file_name)
# Replace spaces with dashes
final_file_name = re.sub(r"\s+", "-", sanitized_file_name)
final_file_name = re.sub(r'\s+', '-', sanitized_file_name)
return final_file_name
@@ -543,13 +522,11 @@ def sanitize_text_for_db(text: str) -> str:
if not isinstance(text, str):
return text
# Remove null bytes
text = text.replace("\x00", "").replace("\u0000", "")
text = text.replace('\x00', '').replace('\u0000', '')
# Remove invalid UTF-8 surrogate characters that can cause encoding errors
# This handles cases where binary data or encoding issues introduced surrogates
try:
text = text.encode("utf-8", errors="surrogatepass").decode(
"utf-8", errors="ignore"
)
text = text.encode('utf-8', errors='surrogatepass').decode('utf-8', errors='ignore')
except (UnicodeEncodeError, UnicodeDecodeError):
pass
return text
@@ -582,15 +559,9 @@ def sanitize_metadata(metadata: dict) -> dict:
if isinstance(obj, (str, int, float, bool, type(None))):
return obj
if isinstance(obj, dict):
return {
k: _sanitize(v)
for k, v in obj.items()
if not callable(v) and _is_serializable(v)
}
return {k: _sanitize(v) for k, v in obj.items() if not callable(v) and _is_serializable(v)}
if isinstance(obj, list):
return [
_sanitize(v) for v in obj if not callable(v) and _is_serializable(v)
]
return [_sanitize(v) for v in obj if not callable(v) and _is_serializable(v)]
if callable(obj):
return None
# Last resort: try to see if it's serializable
@@ -622,8 +593,8 @@ def extract_folders_after_data_docs(path):
# Find the index of '/data/docs' in the path
try:
index_data_docs = parts.index("data") + 1
index_docs = parts.index("docs", index_data_docs) + 1
index_data_docs = parts.index('data') + 1
index_docs = parts.index('docs', index_data_docs) + 1
except ValueError:
return []
@@ -632,37 +603,37 @@ def extract_folders_after_data_docs(path):
folders = parts[index_docs:-1]
for idx, _ in enumerate(folders):
tags.append("/".join(folders[: idx + 1]))
tags.append('/'.join(folders[: idx + 1]))
return tags
def parse_duration(duration: str) -> Optional[timedelta]:
if duration == "-1" or duration == "0":
if duration == '-1' or duration == '0':
return None
# Regular expression to find number and unit pairs
pattern = r"(-?\d+(\.\d+)?)(ms|s|m|h|d|w)"
pattern = r'(-?\d+(\.\d+)?)(ms|s|m|h|d|w)'
matches = re.findall(pattern, duration)
if not matches:
raise ValueError("Invalid duration string")
raise ValueError('Invalid duration string')
total_duration = timedelta()
for number, _, unit in matches:
number = float(number)
if unit == "ms":
if unit == 'ms':
total_duration += timedelta(milliseconds=number)
elif unit == "s":
elif unit == 's':
total_duration += timedelta(seconds=number)
elif unit == "m":
elif unit == 'm':
total_duration += timedelta(minutes=number)
elif unit == "h":
elif unit == 'h':
total_duration += timedelta(hours=number)
elif unit == "d":
elif unit == 'd':
total_duration += timedelta(days=number)
elif unit == "w":
elif unit == 'w':
total_duration += timedelta(weeks=number)
return total_duration
@@ -670,52 +641,48 @@ def parse_duration(duration: str) -> Optional[timedelta]:
def parse_ollama_modelfile(model_text):
parameters_meta = {
"mirostat": int,
"mirostat_eta": float,
"mirostat_tau": float,
"num_ctx": int,
"repeat_last_n": int,
"repeat_penalty": float,
"temperature": float,
"seed": int,
"tfs_z": float,
"num_predict": int,
"top_k": int,
"top_p": float,
"num_keep": int,
"presence_penalty": float,
"frequency_penalty": float,
"num_batch": int,
"num_gpu": int,
"use_mmap": bool,
"use_mlock": bool,
"num_thread": int,
'mirostat': int,
'mirostat_eta': float,
'mirostat_tau': float,
'num_ctx': int,
'repeat_last_n': int,
'repeat_penalty': float,
'temperature': float,
'seed': int,
'tfs_z': float,
'num_predict': int,
'top_k': int,
'top_p': float,
'num_keep': int,
'presence_penalty': float,
'frequency_penalty': float,
'num_batch': int,
'num_gpu': int,
'use_mmap': bool,
'use_mlock': bool,
'num_thread': int,
}
data = {"base_model_id": None, "params": {}}
data = {'base_model_id': None, 'params': {}}
# Parse base model
base_model_match = re.search(
r"^FROM\s+(\w+)", model_text, re.MULTILINE | re.IGNORECASE
)
base_model_match = re.search(r'^FROM\s+(\w+)', model_text, re.MULTILINE | re.IGNORECASE)
if base_model_match:
data["base_model_id"] = base_model_match.group(1)
data['base_model_id'] = base_model_match.group(1)
# Parse template
template_match = re.search(
r'TEMPLATE\s+"""(.+?)"""', model_text, re.DOTALL | re.IGNORECASE
)
template_match = re.search(r'TEMPLATE\s+"""(.+?)"""', model_text, re.DOTALL | re.IGNORECASE)
if template_match:
data["params"] = {"template": template_match.group(1).strip()}
data['params'] = {'template': template_match.group(1).strip()}
# Parse stops
stops = re.findall(r'PARAMETER stop "(.*?)"', model_text, re.IGNORECASE)
if stops:
data["params"]["stop"] = stops
data['params']['stop'] = stops
# Parse other parameters from the provided list
for param, param_type in parameters_meta.items():
param_match = re.search(rf"PARAMETER {param} (.+)", model_text, re.IGNORECASE)
param_match = re.search(rf'PARAMETER {param} (.+)', model_text, re.IGNORECASE)
if param_match:
value = param_match.group(1)
@@ -725,39 +692,35 @@ def parse_ollama_modelfile(model_text):
elif param_type is float:
value = float(value)
elif param_type is bool:
value = value.lower() == "true"
value = value.lower() == 'true'
except Exception as e:
log.exception(f"Failed to parse parameter {param}: {e}")
log.exception(f'Failed to parse parameter {param}: {e}')
continue
data["params"][param] = value
data['params'][param] = value
# Parse adapter
adapter_match = re.search(r"ADAPTER (.+)", model_text, re.IGNORECASE)
adapter_match = re.search(r'ADAPTER (.+)', model_text, re.IGNORECASE)
if adapter_match:
data["params"]["adapter"] = adapter_match.group(1)
data['params']['adapter'] = adapter_match.group(1)
# Parse system description
system_desc_match = re.search(
r'SYSTEM\s+"""(.+?)"""', model_text, re.DOTALL | re.IGNORECASE
)
system_desc_match_single = re.search(
r"SYSTEM\s+([^\n]+)", model_text, re.IGNORECASE
)
system_desc_match = re.search(r'SYSTEM\s+"""(.+?)"""', model_text, re.DOTALL | re.IGNORECASE)
system_desc_match_single = re.search(r'SYSTEM\s+([^\n]+)', model_text, re.IGNORECASE)
if system_desc_match:
data["params"]["system"] = system_desc_match.group(1).strip()
data['params']['system'] = system_desc_match.group(1).strip()
elif system_desc_match_single:
data["params"]["system"] = system_desc_match_single.group(1).strip()
data['params']['system'] = system_desc_match_single.group(1).strip()
# Parse messages
messages = []
message_matches = re.findall(r"MESSAGE (\w+) (.+)", model_text, re.IGNORECASE)
message_matches = re.findall(r'MESSAGE (\w+) (.+)', model_text, re.IGNORECASE)
for role, content in message_matches:
messages.append({"role": role, "content": content})
messages.append({'role': role, 'content': content})
if messages:
data["params"]["messages"] = messages
data['params']['messages'] = messages
return data
@@ -769,10 +732,10 @@ def convert_logit_bias_input_to_json(logit_bias_input) -> Optional[str]:
if isinstance(logit_bias_input, dict):
return json.dumps(logit_bias_input)
logit_bias_pairs = logit_bias_input.split(",")
logit_bias_pairs = logit_bias_input.split(',')
logit_bias_json = {}
for pair in logit_bias_pairs:
token, bias = pair.split(":")
token, bias = pair.split(':')
token = str(token.strip())
bias = int(bias.strip())
bias = 100 if bias > 100 else -100 if bias < -100 else bias
@@ -834,13 +797,13 @@ def strict_match_mime_type(supported: list[str] | str, header: str) -> Optional[
try:
if isinstance(supported, str):
supported = supported.split(",")
supported = supported.split(',')
supported = [s for s in supported if s.strip() and "/" in s]
supported = [s for s in supported if s.strip() and '/' in s]
if len(supported) == 0:
# Default to common types if none are specified
supported = ["audio/*", "video/webm"]
supported = ['audio/*', 'video/webm']
match = mimeparse.best_match(supported, header)
if not match:
@@ -854,15 +817,13 @@ def strict_match_mime_type(supported: list[str] | str, header: str) -> Optional[
return match
except Exception as e:
log.exception(f"Failed to match mime type {header}: {e}")
log.exception(f'Failed to match mime type {header}: {e}')
return None
def extract_urls(text: str) -> list[str]:
# Regex pattern to match URLs
url_pattern = re.compile(
r"(https?://[^\s]+)", re.IGNORECASE
) # Matches http and https URLs
url_pattern = re.compile(r'(https?://[^\s]+)', re.IGNORECASE) # Matches http and https URLs
return url_pattern.findall(text)
@@ -882,9 +843,7 @@ async def stream_wrapper(response, session, content_handler=None):
This is more reliable than BackgroundTask which may not run if client disconnects.
"""
try:
stream = (
content_handler(response.content) if content_handler else response.content
)
stream = content_handler(response.content) if content_handler else response.content
async for chunk in stream:
yield chunk
finally:
@@ -906,7 +865,7 @@ def stream_chunks_handler(stream: aiohttp.StreamReader):
return stream
async def yield_safe_stream_chunks():
buffer = b""
buffer = b''
skip_mode = False
async for data, _ in stream.iter_chunks():
@@ -915,9 +874,9 @@ def stream_chunks_handler(stream: aiohttp.StreamReader):
# In skip_mode, if buffer already exceeds the limit, clear it (it's part of an oversized line)
if skip_mode and len(buffer) > max_buffer_size:
buffer = b""
buffer = b''
lines = (buffer + data).split(b"\n")
lines = (buffer + data).split(b'\n')
# Process complete lines (except the last possibly incomplete fragment)
for i in range(len(lines) - 1):
@@ -929,18 +888,18 @@ def stream_chunks_handler(stream: aiohttp.StreamReader):
skip_mode = False
yield line
else:
yield b"data: {}"
yield b"\n"
yield b'data: {}'
yield b'\n'
else:
# Normal mode: check if line exceeds limit
if len(line) > max_buffer_size:
skip_mode = True
yield b"data: {}"
yield b"\n"
log.info(f"Skip mode triggered, line size: {len(line)}")
yield b'data: {}'
yield b'\n'
log.info(f'Skip mode triggered, line size: {len(line)}')
else:
yield line
yield b"\n"
yield b'\n'
# Save the last incomplete fragment
buffer = lines[-1]
@@ -948,13 +907,13 @@ def stream_chunks_handler(stream: aiohttp.StreamReader):
# Check if buffer exceeds limit
if not skip_mode and len(buffer) > max_buffer_size:
skip_mode = True
log.info(f"Skip mode triggered, buffer size: {len(buffer)}")
log.info(f'Skip mode triggered, buffer size: {len(buffer)}')
# Clear oversized buffer to prevent unlimited growth
buffer = b""
buffer = b''
# Process remaining buffer data
if buffer and not skip_mode:
yield buffer
yield b"\n"
yield b'\n'
return yield_safe_stream_chunks()
+141 -170
View File
@@ -41,22 +41,22 @@ async def fetch_ollama_models(request: Request, user: UserModel = None):
raw_ollama_models = await ollama.get_all_models(request, user=user)
return [
{
"id": model["model"],
"name": model["name"],
"object": "model",
"created": int(time.time()),
"owned_by": "ollama",
"ollama": model,
"connection_type": model.get("connection_type", "local"),
"tags": model.get("tags", []),
'id': model['model'],
'name': model['name'],
'object': 'model',
'created': int(time.time()),
'owned_by': 'ollama',
'ollama': model,
'connection_type': model.get('connection_type', 'local'),
'tags': model.get('tags', []),
}
for model in raw_ollama_models["models"]
for model in raw_ollama_models['models']
]
async def fetch_openai_models(request: Request, user: UserModel = None):
openai_response = await openai.get_all_models(request, user=user)
return openai_response["data"]
return openai_response['data']
async def get_all_base_models(request: Request, user: UserModel = None):
@@ -72,9 +72,7 @@ async def get_all_base_models(request: Request, user: UserModel = None):
)
function_task = get_function_models(request)
openai_models, ollama_models, function_models = await asyncio.gather(
openai_task, ollama_task, function_task
)
openai_models, ollama_models, function_models = await asyncio.gather(openai_task, ollama_task, function_task)
return function_models + openai_models + ollama_models
@@ -103,15 +101,15 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
if len(request.app.state.config.EVALUATION_ARENA_MODELS) > 0:
arena_models = [
{
"id": model["id"],
"name": model["name"],
"info": {
"meta": model["meta"],
'id': model['id'],
'name': model['name'],
'info': {
'meta': model['meta'],
},
"object": "model",
"created": int(time.time()),
"owned_by": "arena",
"arena": True,
'object': 'model',
'created': int(time.time()),
'owned_by': 'arena',
'arena': True,
}
for model in request.app.state.config.EVALUATION_ARENA_MODELS
]
@@ -119,45 +117,35 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
# Add default arena model
arena_models = [
{
"id": DEFAULT_ARENA_MODEL["id"],
"name": DEFAULT_ARENA_MODEL["name"],
"info": {
"meta": DEFAULT_ARENA_MODEL["meta"],
'id': DEFAULT_ARENA_MODEL['id'],
'name': DEFAULT_ARENA_MODEL['name'],
'info': {
'meta': DEFAULT_ARENA_MODEL['meta'],
},
"object": "model",
"created": int(time.time()),
"owned_by": "arena",
"arena": True,
'object': 'model',
'created': int(time.time()),
'owned_by': 'arena',
'arena': True,
}
]
models = models + arena_models
global_action_ids = [
function.id for function in Functions.get_global_action_functions()
]
enabled_action_ids = [
function.id
for function in Functions.get_functions_by_type("action", active_only=True)
]
global_action_ids = [function.id for function in Functions.get_global_action_functions()]
enabled_action_ids = [function.id for function in Functions.get_functions_by_type('action', active_only=True)]
global_filter_ids = [
function.id for function in Functions.get_global_filter_functions()
]
enabled_filter_ids = [
function.id
for function in Functions.get_functions_by_type("filter", active_only=True)
]
global_filter_ids = [function.id for function in Functions.get_global_filter_functions()]
enabled_filter_ids = [function.id for function in Functions.get_functions_by_type('filter', active_only=True)]
custom_models = Models.get_all_models()
# Single O(1) lookup: Ollama base names first, then exact IDs (exact wins).
base_model_lookup = {}
for model in models:
if model.get("owned_by") == "ollama":
base_model_lookup.setdefault(model["id"].split(":")[0], model)
base_model_lookup[model["id"]] = model
if model.get('owned_by') == 'ollama':
base_model_lookup.setdefault(model['id'].split(':')[0], model)
base_model_lookup[model['id']] = model
existing_ids = {m["id"] for m in models}
existing_ids = {m['id'] for m in models}
for custom_model in custom_models:
if custom_model.base_model_id is None:
@@ -166,26 +154,22 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
if model:
if custom_model.is_active:
model["name"] = custom_model.name
model["info"] = custom_model.model_dump()
model['name'] = custom_model.name
model['info'] = custom_model.model_dump()
action_ids = []
filter_ids = []
if "info" in model:
if "meta" in model["info"]:
action_ids.extend(
model["info"]["meta"].get("actionIds", [])
)
filter_ids.extend(
model["info"]["meta"].get("filterIds", [])
)
if 'info' in model:
if 'meta' in model['info']:
action_ids.extend(model['info']['meta'].get('actionIds', []))
filter_ids.extend(model['info']['meta'].get('filterIds', []))
if "params" in model["info"]:
del model["info"]["params"]
if 'params' in model['info']:
del model['info']['params']
model["action_ids"] = action_ids
model["filter_ids"] = filter_ids
model['action_ids'] = action_ids
model['filter_ids'] = filter_ids
else:
models.remove(model)
@@ -193,38 +177,36 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
if custom_model.id in existing_ids:
continue
owned_by = "openai"
owned_by = 'openai'
connection_type = None
pipe = None
base_model = base_model_lookup.get(custom_model.base_model_id)
if base_model is None:
base_model = base_model_lookup.get(
custom_model.base_model_id.split(":")[0]
)
base_model = base_model_lookup.get(custom_model.base_model_id.split(':')[0])
if base_model:
owned_by = base_model.get("owned_by", "unknown")
if "pipe" in base_model:
pipe = base_model["pipe"]
connection_type = base_model.get("connection_type", None)
owned_by = base_model.get('owned_by', 'unknown')
if 'pipe' in base_model:
pipe = base_model['pipe']
connection_type = base_model.get('connection_type', None)
model = {
"id": f"{custom_model.id}",
"name": custom_model.name,
"object": "model",
"created": custom_model.created_at,
"owned_by": owned_by,
"connection_type": connection_type,
"preset": True,
**({"pipe": pipe} if pipe is not None else {}),
'id': f'{custom_model.id}',
'name': custom_model.name,
'object': 'model',
'created': custom_model.created_at,
'owned_by': owned_by,
'connection_type': connection_type,
'preset': True,
**({'pipe': pipe} if pipe is not None else {}),
}
info = custom_model.model_dump()
if "params" in info:
if 'params' in info:
# Remove params to avoid exposing sensitive info
del info["params"]
del info['params']
model["info"] = info
model['info'] = info
action_ids = []
filter_ids = []
@@ -232,32 +214,32 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
if custom_model.meta:
meta = custom_model.meta.model_dump()
if "actionIds" in meta:
action_ids.extend(meta["actionIds"])
if 'actionIds' in meta:
action_ids.extend(meta['actionIds'])
if "filterIds" in meta:
filter_ids.extend(meta["filterIds"])
if 'filterIds' in meta:
filter_ids.extend(meta['filterIds'])
model["action_ids"] = action_ids
model["filter_ids"] = filter_ids
model['action_ids'] = action_ids
model['filter_ids'] = filter_ids
models.append(model)
# Process action_ids to get the actions
def get_action_items_from_module(function, module):
actions = []
if hasattr(module, "actions"):
if hasattr(module, 'actions'):
actions = module.actions
return [
{
"id": f"{function.id}.{action['id']}",
"name": action.get("name", f"{function.name} ({action['id']})"),
"description": function.meta.description,
"icon": action.get(
"icon_url",
function.meta.manifest.get("icon_url", None)
or getattr(module, "icon_url", None)
or getattr(module, "icon", None),
'id': f'{function.id}.{action["id"]}',
'name': action.get('name', f'{function.name} ({action["id"]})'),
'description': function.meta.description,
'icon': action.get(
'icon_url',
function.meta.manifest.get('icon_url', None)
or getattr(module, 'icon_url', None)
or getattr(module, 'icon', None),
),
}
for action in actions
@@ -265,12 +247,12 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
else:
return [
{
"id": function.id,
"name": function.name,
"description": function.meta.description,
"icon": function.meta.manifest.get("icon_url", None)
or getattr(module, "icon_url", None)
or getattr(module, "icon", None),
'id': function.id,
'name': function.name,
'description': function.meta.description,
'icon': function.meta.manifest.get('icon_url', None)
or getattr(module, 'icon_url', None)
or getattr(module, 'icon', None),
}
]
@@ -278,27 +260,25 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
def get_filter_items_from_module(function, module):
return [
{
"id": function.id,
"name": function.name,
"description": function.meta.description,
"icon": function.meta.manifest.get("icon_url", None)
or getattr(module, "icon_url", None)
or getattr(module, "icon", None),
"has_user_valves": hasattr(module, "UserValves"),
'id': function.id,
'name': function.name,
'description': function.meta.description,
'icon': function.meta.manifest.get('icon_url', None)
or getattr(module, 'icon_url', None)
or getattr(module, 'icon', None),
'has_user_valves': hasattr(module, 'UserValves'),
}
]
# Batch-prefetch all needed function records to avoid N+1 queries
all_function_ids = set()
for model in models:
all_function_ids.update(model.get("action_ids", []))
all_function_ids.update(model.get("filter_ids", []))
all_function_ids.update(model.get('action_ids', []))
all_function_ids.update(model.get('filter_ids', []))
all_function_ids.update(global_action_ids)
all_function_ids.update(global_filter_ids)
functions_by_id = {
f.id: f for f in Functions.get_functions_by_ids(list(all_function_ids))
}
functions_by_id = {f.id: f for f in Functions.get_functions_by_ids(list(all_function_ids))}
# Pre-warm the function module cache once per unique function ID.
# This ensures each function's DB freshness check runs exactly once,
@@ -307,28 +287,26 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
try:
get_function_module_from_cache(request, function_id)
except Exception as e:
log.info(f"Failed to load function module for {function_id}: {e}")
log.info(f'Failed to load function module for {function_id}: {e}')
# Apply global model defaults to all models
# Per-model overrides take precedence over global defaults
default_metadata = (
getattr(request.app.state.config, "DEFAULT_MODEL_METADATA", None) or {}
)
default_metadata = getattr(request.app.state.config, 'DEFAULT_MODEL_METADATA', None) or {}
if default_metadata:
for model in models:
info = model.get("info")
info = model.get('info')
if info is None:
model["info"] = {"meta": copy.deepcopy(default_metadata)}
model['info'] = {'meta': copy.deepcopy(default_metadata)}
continue
meta = info.setdefault("meta", {})
meta = info.setdefault('meta', {})
for key, value in default_metadata.items():
if key == "capabilities":
if key == 'capabilities':
# Merge capabilities: defaults as base, per-model overrides win
existing = meta.get("capabilities") or {}
meta["capabilities"] = {**value, **existing}
existing = meta.get('capabilities') or {}
meta['capabilities'] = {**value, **existing}
elif meta.get(key) is None:
meta[key] = copy.deepcopy(value)
@@ -339,10 +317,10 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
def get_action_priority(action_id):
try:
function_module = request.app.state.FUNCTIONS.get(action_id)
if function_module and hasattr(function_module, "Valves"):
if function_module and hasattr(function_module, 'Valves'):
valves_db = all_function_valves.get(action_id)
valves = function_module.Valves(**(valves_db if valves_db else {}))
return getattr(valves, "priority", 0)
return getattr(valves, 'priority', 0)
except Exception:
pass
return 0
@@ -350,51 +328,47 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
for model in models:
action_ids = [
action_id
for action_id in list(set(model.pop("action_ids", []) + global_action_ids))
for action_id in list(set(model.pop('action_ids', []) + global_action_ids))
if action_id in enabled_action_ids
]
action_ids.sort(key=lambda aid: (get_action_priority(aid), aid))
filter_ids = [
filter_id
for filter_id in list(set(model.pop("filter_ids", []) + global_filter_ids))
for filter_id in list(set(model.pop('filter_ids', []) + global_filter_ids))
if filter_id in enabled_filter_ids
]
model["actions"] = []
model['actions'] = []
for action_id in action_ids:
action_function = functions_by_id.get(action_id)
if action_function is None:
log.info(f"Action not found: {action_id}")
log.info(f'Action not found: {action_id}')
continue
function_module = request.app.state.FUNCTIONS.get(action_id)
if function_module is None:
log.info(f"Failed to load action module: {action_id}")
log.info(f'Failed to load action module: {action_id}')
continue
model["actions"].extend(
get_action_items_from_module(action_function, function_module)
)
model['actions'].extend(get_action_items_from_module(action_function, function_module))
model["filters"] = []
model['filters'] = []
for filter_id in filter_ids:
filter_function = functions_by_id.get(filter_id)
if filter_function is None:
log.info(f"Filter not found: {filter_id}")
log.info(f'Filter not found: {filter_id}')
continue
function_module = request.app.state.FUNCTIONS.get(filter_id)
if function_module is None:
log.info(f"Failed to load filter module: {filter_id}")
log.info(f'Failed to load filter module: {filter_id}')
continue
if getattr(function_module, "toggle", None):
model["filters"].extend(
get_filter_items_from_module(filter_function, function_module)
)
if getattr(function_module, 'toggle', None):
model['filters'].extend(get_filter_items_from_module(filter_function, function_module))
log.debug(f"get_all_models() returned {len(models)} models")
log.debug(f'get_all_models() returned {len(models)} models')
models_dict = {model["id"]: model for model in models}
models_dict = {model['id']: model for model in models}
if isinstance(request.app.state.MODELS, RedisDict):
request.app.state.MODELS.set(models_dict)
else:
@@ -404,81 +378,78 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
def check_model_access(user, model, db=None):
if model.get("arena"):
meta = model.get("info", {}).get("meta", {})
access_grants = meta.get("access_grants", [])
if model.get('arena'):
meta = model.get('info', {}).get('meta', {})
access_grants = meta.get('access_grants', [])
if not has_access(
user.id,
permission="read",
permission='read',
access_grants=access_grants,
db=db,
):
raise Exception("Model not found")
raise Exception('Model not found')
else:
model_info = Models.get_model_by_id(model.get("id"), db=db)
model_info = Models.get_model_by_id(model.get('id'), db=db)
if not model_info:
raise Exception("Model not found")
raise Exception('Model not found')
elif not (
user.id == model_info.user_id
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_type='model',
resource_id=model_info.id,
permission="read",
permission='read',
db=db,
)
):
raise Exception("Model not found")
raise Exception('Model not found')
def get_filtered_models(models, user, db=None):
# Filter out models that the user does not have access to
if (
user.role == "user"
or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL)
user.role == 'user' or (user.role == 'admin' and not BYPASS_ADMIN_ACCESS_CONTROL)
) and not BYPASS_MODEL_ACCESS_CONTROL:
model_infos = {}
for model in models:
if model.get("arena"):
if model.get('arena'):
continue
info = model.get("info")
info = model.get('info')
if info:
model_infos[model["id"]] = info
model_infos[model['id']] = info
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)}
# Batch-fetch accessible resource IDs in a single query instead of N has_access calls
accessible_model_ids = AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type="model",
resource_type='model',
resource_ids=list(model_infos.keys()),
permission="read",
permission='read',
user_group_ids=user_group_ids,
db=db,
)
filtered_models = []
for model in models:
if model.get("arena"):
meta = model.get("info", {}).get("meta", {})
access_grants = meta.get("access_grants", [])
if model.get('arena'):
meta = model.get('info', {}).get('meta', {})
access_grants = meta.get('access_grants', [])
if has_access(
user.id,
permission="read",
permission='read',
access_grants=access_grants,
user_group_ids=user_group_ids,
):
filtered_models.append(model)
continue
model_info = model_infos.get(model["id"])
model_info = model_infos.get(model['id'])
if model_info:
if (
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model_info.get("user_id")
or model["id"] in accessible_model_ids
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model_info.get('user_id')
or model['id'] in accessible_model_ids
):
filtered_models.append(model)
File diff suppressed because it is too large Load Diff
+116 -130
View File
@@ -23,7 +23,7 @@ def apply_system_prompt_to_body(
# Metadata (WebUI Usage)
if metadata:
variables = metadata.get("variables", {})
variables = metadata.get('variables', {})
if variables:
system = prompt_variables_template(system, variables)
@@ -31,21 +31,15 @@ def apply_system_prompt_to_body(
system = prompt_template(system, user)
if replace:
form_data["messages"] = replace_system_message_content(
system, form_data.get("messages", [])
)
form_data['messages'] = replace_system_message_content(system, form_data.get('messages', []))
else:
form_data["messages"] = add_or_update_system_message(
system, form_data.get("messages", [])
)
form_data['messages'] = add_or_update_system_message(system, form_data.get('messages', []))
return form_data
# inplace function: form_data is modified
def apply_model_params_to_body(
params: dict, form_data: dict, mappings: dict[str, Callable]
) -> dict:
def apply_model_params_to_body(params: dict, form_data: dict, mappings: dict[str, Callable]) -> dict:
if not params:
return form_data
@@ -72,11 +66,11 @@ def remove_open_webui_params(params: dict) -> dict:
dict: The modified dictionary with OpenWebUI parameters removed.
"""
open_webui_params = {
"stream_response": bool,
"stream_delta_chunk_size": int,
"function_calling": str,
"reasoning_tags": list,
"system": str,
'stream_response': bool,
'stream_delta_chunk_size': int,
'function_calling': str,
'reasoning_tags': list,
'system': str,
}
for key in list(params.keys()):
@@ -90,7 +84,7 @@ def remove_open_webui_params(params: dict) -> dict:
def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict:
params = remove_open_webui_params(params)
custom_params = params.pop("custom_params", {})
custom_params = params.pop('custom_params', {})
if custom_params:
# Attempt to parse custom_params if they are strings
for key, value in custom_params.items():
@@ -106,17 +100,17 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict:
params = deep_update(params, custom_params)
mappings = {
"temperature": float,
"top_p": float,
"min_p": float,
"max_tokens": int,
"frequency_penalty": float,
"presence_penalty": float,
"reasoning_effort": str,
"seed": lambda x: x,
"stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x],
"logit_bias": lambda x: x,
"response_format": dict,
'temperature': float,
'top_p': float,
'min_p': float,
'max_tokens': int,
'frequency_penalty': float,
'presence_penalty': float,
'reasoning_effort': str,
'seed': lambda x: x,
'stop': lambda x: [bytes(s, 'utf-8').decode('unicode_escape') for s in x],
'logit_bias': lambda x: x,
'response_format': dict,
}
return apply_model_params_to_body(params, form_data, mappings)
@@ -124,7 +118,7 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict:
def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
params = remove_open_webui_params(params)
custom_params = params.pop("custom_params", {})
custom_params = params.pop('custom_params', {})
if custom_params:
# Attempt to parse custom_params if they are strings
for key, value in custom_params.items():
@@ -141,7 +135,7 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
# Convert OpenAI parameter names to Ollama parameter names if needed.
name_differences = {
"max_tokens": "num_predict",
'max_tokens': 'num_predict',
}
for key, value in name_differences.items():
@@ -152,27 +146,27 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
# See https://github.com/ollama/ollama/blob/main/docs/api.md#request-8
mappings = {
"temperature": float,
"top_p": float,
"seed": lambda x: x,
"mirostat": int,
"mirostat_eta": float,
"mirostat_tau": float,
"num_ctx": int,
"num_batch": int,
"num_keep": int,
"num_predict": int,
"repeat_last_n": int,
"top_k": int,
"min_p": float,
"repeat_penalty": float,
"presence_penalty": float,
"frequency_penalty": float,
"stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x],
"num_gpu": int,
"use_mmap": bool,
"use_mlock": bool,
"num_thread": int,
'temperature': float,
'top_p': float,
'seed': lambda x: x,
'mirostat': int,
'mirostat_eta': float,
'mirostat_tau': float,
'num_ctx': int,
'num_batch': int,
'num_keep': int,
'num_predict': int,
'repeat_last_n': int,
'top_k': int,
'min_p': float,
'repeat_penalty': float,
'presence_penalty': float,
'frequency_penalty': float,
'stop': lambda x: [bytes(s, 'utf-8').decode('unicode_escape') for s in x],
'num_gpu': int,
'use_mmap': bool,
'use_mlock': bool,
'num_thread': int,
}
def parse_json(value: str) -> dict:
@@ -185,9 +179,9 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
return value
ollama_root_params = {
"format": lambda x: parse_json(x),
"keep_alive": lambda x: parse_json(x),
"think": lambda x: x,
'format': lambda x: parse_json(x),
'keep_alive': lambda x: parse_json(x),
'think': lambda x: x,
}
for key, value in ollama_root_params.items():
@@ -197,9 +191,7 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
del params[key]
# Unlike OpenAI, Ollama does not support params directly in the body
form_data["options"] = apply_model_params_to_body(
params, (form_data.get("options", {}) or {}), mappings
)
form_data['options'] = apply_model_params_to_body(params, (form_data.get('options', {}) or {}), mappings)
return form_data
@@ -208,68 +200,66 @@ def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]:
for message in messages:
# Initialize the new message structure with the role
new_message = {"role": message["role"]}
new_message = {'role': message['role']}
content = message.get("content", [])
tool_calls = message.get("tool_calls", None)
tool_call_id = message.get("tool_call_id", None)
content = message.get('content', [])
tool_calls = message.get('tool_calls', None)
tool_call_id = message.get('tool_call_id', None)
# Check if the content is a string (just a simple message)
if isinstance(content, str) and not tool_calls:
# If the content is a string, it's pure text
new_message["content"] = content
new_message['content'] = content
# If message is a tool call, add the tool call id to the message
if tool_call_id:
new_message["tool_call_id"] = tool_call_id
new_message['tool_call_id'] = tool_call_id
elif tool_calls:
# If tool calls are present, add them to the message
ollama_tool_calls = []
for tool_call in tool_calls:
ollama_tool_call = {
"index": tool_call.get("index", 0),
"id": tool_call.get("id", None),
"function": {
"name": tool_call.get("function", {}).get("name", ""),
"arguments": json.loads(
tool_call.get("function", {}).get("arguments", {})
),
'index': tool_call.get('index', 0),
'id': tool_call.get('id', None),
'function': {
'name': tool_call.get('function', {}).get('name', ''),
'arguments': json.loads(tool_call.get('function', {}).get('arguments', {})),
},
}
ollama_tool_calls.append(ollama_tool_call)
new_message["tool_calls"] = ollama_tool_calls
new_message['tool_calls'] = ollama_tool_calls
# Put the content to empty string (Ollama requires an empty string for tool calls)
new_message["content"] = ""
new_message['content'] = ''
else:
# Otherwise, assume the content is a list of dicts, e.g., text followed by an image URL
content_text = ""
content_text = ''
images = []
# Iterate through the list of content items
for item in content:
# Check if it's a text type
if item.get("type") == "text":
content_text += item.get("text", "")
if item.get('type') == 'text':
content_text += item.get('text', '')
# Check if it's an image URL type
elif item.get("type") == "image_url":
img_url = item.get("image_url", {}).get("url", "")
elif item.get('type') == 'image_url':
img_url = item.get('image_url', {}).get('url', '')
if img_url:
# If the image url starts with data:, it's a base64 image and should be trimmed
if img_url.startswith("data:"):
img_url = img_url.split(",")[-1]
if img_url.startswith('data:'):
img_url = img_url.split(',')[-1]
images.append(img_url)
# Add content text (if any)
if content_text:
new_message["content"] = content_text.strip()
new_message['content'] = content_text.strip()
# Add images (if any)
if images:
new_message["images"] = images
new_message['images'] = images
# Append the new formatted message to the result
ollama_messages.append(new_message)
@@ -288,31 +278,27 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
dict: A modified payload compatible with the Ollama API.
"""
# Shallow copy metadata separately (may contain non-picklable objects)
metadata = openai_payload.get("metadata")
openai_payload = copy.deepcopy(
{k: v for k, v in openai_payload.items() if k != "metadata"}
)
metadata = openai_payload.get('metadata')
openai_payload = copy.deepcopy({k: v for k, v in openai_payload.items() if k != 'metadata'})
if metadata is not None:
openai_payload["metadata"] = dict(metadata)
openai_payload['metadata'] = dict(metadata)
ollama_payload = {}
# Mapping basic model and message details
ollama_payload["model"] = openai_payload.get("model")
ollama_payload["messages"] = convert_messages_openai_to_ollama(
openai_payload.get("messages")
)
ollama_payload["stream"] = openai_payload.get("stream", False)
if "tools" in openai_payload:
ollama_payload["tools"] = openai_payload["tools"]
ollama_payload['model'] = openai_payload.get('model')
ollama_payload['messages'] = convert_messages_openai_to_ollama(openai_payload.get('messages'))
ollama_payload['stream'] = openai_payload.get('stream', False)
if 'tools' in openai_payload:
ollama_payload['tools'] = openai_payload['tools']
if "max_tokens" in openai_payload:
ollama_payload["num_predict"] = openai_payload["max_tokens"]
del openai_payload["max_tokens"]
if 'max_tokens' in openai_payload:
ollama_payload['num_predict'] = openai_payload['max_tokens']
del openai_payload['max_tokens']
# If there are advanced parameters in the payload, format them in Ollama's options field
if openai_payload.get("options"):
ollama_payload["options"] = openai_payload["options"]
ollama_options = openai_payload["options"]
if openai_payload.get('options'):
ollama_payload['options'] = openai_payload['options']
ollama_options = openai_payload['options']
def parse_json(value: str) -> dict:
"""
@@ -324,9 +310,9 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
return value
ollama_root_params = {
"format": lambda x: parse_json(x),
"keep_alive": lambda x: parse_json(x),
"think": lambda x: x,
'format': lambda x: parse_json(x),
'keep_alive': lambda x: parse_json(x),
'think': lambda x: x,
}
# Ollama's options field can contain parameters that should be at the root level.
@@ -337,35 +323,35 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
del ollama_options[key]
# Re-Mapping OpenAI's `max_tokens` -> Ollama's `num_predict`
if "max_tokens" in ollama_options:
ollama_options["num_predict"] = ollama_options["max_tokens"]
del ollama_options["max_tokens"]
if 'max_tokens' in ollama_options:
ollama_options['num_predict'] = ollama_options['max_tokens']
del ollama_options['max_tokens']
# Ollama lacks a "system" prompt option. It has to be provided as a direct parameter, so we copy it down.
# Comment: Not sure why this is needed, but we'll keep it for compatibility.
if "system" in ollama_options:
ollama_payload["system"] = ollama_options["system"]
del ollama_options["system"]
if 'system' in ollama_options:
ollama_payload['system'] = ollama_options['system']
del ollama_options['system']
ollama_payload["options"] = ollama_options
ollama_payload['options'] = ollama_options
# If there is the "stop" parameter in the openai_payload, remap it to the ollama_payload.options
if "stop" in openai_payload:
ollama_options = ollama_payload.get("options", {})
ollama_options["stop"] = openai_payload.get("stop")
ollama_payload["options"] = ollama_options
if 'stop' in openai_payload:
ollama_options = ollama_payload.get('options', {})
ollama_options['stop'] = openai_payload.get('stop')
ollama_payload['options'] = ollama_options
if "metadata" in openai_payload:
ollama_payload["metadata"] = openai_payload["metadata"]
if 'metadata' in openai_payload:
ollama_payload['metadata'] = openai_payload['metadata']
if "response_format" in openai_payload:
response_format = openai_payload["response_format"]
format_type = response_format.get("type", None)
if 'response_format' in openai_payload:
response_format = openai_payload['response_format']
format_type = response_format.get('type', None)
schema = response_format.get(format_type, None)
if schema:
format = schema.get("schema", None)
ollama_payload["format"] = format
format = schema.get('schema', None)
ollama_payload['format'] = format
return ollama_payload
@@ -380,19 +366,19 @@ def convert_embedding_payload_openai_to_ollama(openai_payload: dict) -> dict:
Returns:
dict: A payload compatible with the Ollama API embeddings endpoint.
"""
ollama_payload = {"model": openai_payload.get("model")}
input_value = openai_payload.get("input")
ollama_payload = {'model': openai_payload.get('model')}
input_value = openai_payload.get('input')
# Ollama expects 'input' as a list, and 'prompt' as a single string.
if isinstance(input_value, list):
ollama_payload["input"] = input_value
ollama_payload["prompt"] = "\n".join(str(x) for x in input_value)
ollama_payload['input'] = input_value
ollama_payload['prompt'] = '\n'.join(str(x) for x in input_value)
else:
ollama_payload["input"] = [input_value]
ollama_payload["prompt"] = str(input_value)
ollama_payload['input'] = [input_value]
ollama_payload['prompt'] = str(input_value)
# Optionally forward other fields if present
for optional_key in ("options", "truncate", "keep_alive"):
for optional_key in ('options', 'truncate', 'keep_alive'):
if optional_key in openai_payload:
ollama_payload[optional_key] = openai_payload[optional_key]
@@ -411,14 +397,14 @@ def convert_embed_payload_openai_to_ollama(openai_payload: dict) -> dict:
Returns:
dict: A payload compatible with the Ollama /api/embed endpoint.
"""
ollama_payload = {"model": openai_payload.get("model")}
input_value = openai_payload.get("input")
ollama_payload = {'model': openai_payload.get('model')}
input_value = openai_payload.get('input')
# /api/embed accepts 'input' as a string or list of strings directly
ollama_payload["input"] = input_value
ollama_payload['input'] = input_value
# Optionally forward other fields if present
for optional_key in ("truncate", "options", "keep_alive"):
for optional_key in ('truncate', 'options', 'keep_alive'):
if optional_key in openai_payload:
ollama_payload[optional_key] = openai_payload[optional_key]
+22 -26
View File
@@ -29,32 +29,32 @@ class PDFGenerator:
self.messages_html = None
self.form_data = form_data
self.css = Path(STATIC_DIR / "assets" / "pdf-style.css").read_text()
self.css = Path(STATIC_DIR / 'assets' / 'pdf-style.css').read_text()
def format_timestamp(self, timestamp: float) -> str:
"""Convert a UNIX timestamp to a formatted date string."""
try:
date_time = datetime.fromtimestamp(timestamp)
return date_time.strftime("%Y-%m-%d, %H:%M:%S")
return date_time.strftime('%Y-%m-%d, %H:%M:%S')
except (ValueError, TypeError) as e:
# Log the error if necessary
return ""
return ''
def _build_html_message(self, message: Dict[str, Any]) -> str:
"""Build HTML for a single message."""
role = escape(message.get("role", "user"))
content = escape(message.get("content", ""))
timestamp = message.get("timestamp")
role = escape(message.get('role', 'user'))
content = escape(message.get('content', ''))
timestamp = message.get('timestamp')
model = escape(message.get("model") if role == "assistant" else "")
model = escape(message.get('model') if role == 'assistant' else '')
date_str = escape(self.format_timestamp(timestamp) if timestamp else "")
date_str = escape(self.format_timestamp(timestamp) if timestamp else '')
# extends pymdownx extension to convert markdown to html.
# - https://facelessuser.github.io/pymdown-extensions/usage_notes/
# html_content = markdown(content, extensions=["pymdownx.extra"])
content = content.replace("\n", "<br/>")
content = content.replace('\n', '<br/>')
html_message = f"""
<div>
<div>
@@ -106,32 +106,28 @@ class PDFGenerator:
# When running using `pip install` the static directory is in the site packages.
if not FONTS_DIR.exists():
FONTS_DIR = Path(site.getsitepackages()[0]) / "static/fonts"
FONTS_DIR = Path(site.getsitepackages()[0]) / 'static/fonts'
# When running using `pip install -e .` the static directory is in the site packages.
# This path only works if `open-webui serve` is run from the root of this project.
if not FONTS_DIR.exists():
FONTS_DIR = Path(".") / "backend" / "static" / "fonts"
FONTS_DIR = Path('.') / 'backend' / 'static' / 'fonts'
pdf.add_font("NotoSans", "", f"{FONTS_DIR}/NotoSans-Regular.ttf")
pdf.add_font("NotoSans", "b", f"{FONTS_DIR}/NotoSans-Bold.ttf")
pdf.add_font("NotoSans", "i", f"{FONTS_DIR}/NotoSans-Italic.ttf")
pdf.add_font("NotoSansKR", "", f"{FONTS_DIR}/NotoSansKR-Regular.ttf")
pdf.add_font("NotoSansJP", "", f"{FONTS_DIR}/NotoSansJP-Regular.ttf")
pdf.add_font("NotoSansSC", "", f"{FONTS_DIR}/NotoSansSC-Regular.ttf")
pdf.add_font("Twemoji", "", f"{FONTS_DIR}/Twemoji.ttf")
pdf.add_font('NotoSans', '', f'{FONTS_DIR}/NotoSans-Regular.ttf')
pdf.add_font('NotoSans', 'b', f'{FONTS_DIR}/NotoSans-Bold.ttf')
pdf.add_font('NotoSans', 'i', f'{FONTS_DIR}/NotoSans-Italic.ttf')
pdf.add_font('NotoSansKR', '', f'{FONTS_DIR}/NotoSansKR-Regular.ttf')
pdf.add_font('NotoSansJP', '', f'{FONTS_DIR}/NotoSansJP-Regular.ttf')
pdf.add_font('NotoSansSC', '', f'{FONTS_DIR}/NotoSansSC-Regular.ttf')
pdf.add_font('Twemoji', '', f'{FONTS_DIR}/Twemoji.ttf')
pdf.set_font("NotoSans", size=12)
pdf.set_fallback_fonts(
["NotoSansKR", "NotoSansJP", "NotoSansSC", "Twemoji"]
)
pdf.set_font('NotoSans', size=12)
pdf.set_fallback_fonts(['NotoSansKR', 'NotoSansJP', 'NotoSansSC', 'Twemoji'])
pdf.set_auto_page_break(auto=True, margin=15)
# Build HTML messages
messages_html_list: List[str] = [
self._build_html_message(msg) for msg in self.form_data.messages
]
self.messages_html = "<div>" + "".join(messages_html_list) + "</div>"
messages_html_list: List[str] = [self._build_html_message(msg) for msg in self.form_data.messages]
self.messages_html = '<div>' + ''.join(messages_html_list) + '</div>'
# Generate full HTML body
self.html_body = self._generate_html_body()
+82 -114
View File
@@ -20,9 +20,7 @@ from open_webui.models.tools import Tools
log = logging.getLogger(__name__)
def resolve_valves_schema_options(
valves_class: type, schema: dict, user: Any = None
) -> dict:
def resolve_valves_schema_options(valves_class: type, schema: dict, user: Any = None) -> dict:
"""
Resolve dynamic options in a Valves schema.
@@ -66,16 +64,16 @@ def resolve_valves_schema_options(
Returns:
Modified schema dict with resolved options
"""
if not schema or "properties" not in schema:
if not schema or 'properties' not in schema:
return schema
# Make a copy to avoid mutating the original
schema = dict(schema)
schema["properties"] = dict(schema.get("properties", {}))
schema['properties'] = dict(schema.get('properties', {}))
for prop_name, prop_schema in list(schema["properties"].items()):
for prop_name, prop_schema in list(schema['properties'].items()):
# Get the original field info from the Pydantic model
if not hasattr(valves_class, "model_fields"):
if not hasattr(valves_class, 'model_fields'):
continue
field_info = valves_class.model_fields.get(prop_name)
@@ -87,11 +85,11 @@ def resolve_valves_schema_options(
if not json_schema_extra or not isinstance(json_schema_extra, dict):
continue
input_config = json_schema_extra.get("input")
input_config = json_schema_extra.get('input')
if not input_config or not isinstance(input_config, dict):
continue
options = input_config.get("options")
options = input_config.get('options')
if options is None:
continue
@@ -105,9 +103,7 @@ def resolve_valves_schema_options(
elif isinstance(options, str) and options:
method = getattr(valves_class, options, None)
if method is None or not callable(method):
log.warning(
f"options '{options}' not found or not callable on {valves_class.__name__}"
)
log.warning(f"options '{options}' not found or not callable on {valves_class.__name__}")
continue
try:
@@ -118,40 +114,32 @@ def resolve_valves_schema_options(
# Prepare kwargs based on what the method accepts
kwargs = {}
if "__user__" in params and user is not None:
kwargs["__user__"] = (
user.model_dump() if hasattr(user, "model_dump") else user
)
if "user" in params and user is not None:
kwargs["user"] = (
user.model_dump() if hasattr(user, "model_dump") else user
)
if '__user__' in params and user is not None:
kwargs['__user__'] = user.model_dump() if hasattr(user, 'model_dump') else user
if 'user' in params and user is not None:
kwargs['user'] = user.model_dump() if hasattr(user, 'model_dump') else user
resolved_options = method(**kwargs) if kwargs else method()
# Validate return type
if not isinstance(resolved_options, list):
log.warning(
f"Method '{options}' did not return a list for {prop_name}"
)
log.warning(f"Method '{options}' did not return a list for {prop_name}")
continue
except Exception as e:
log.warning(f"Failed to resolve options for {prop_name}: {e}")
log.warning(f'Failed to resolve options for {prop_name}: {e}')
continue
else:
# Invalid options type - skip
continue
# Update the schema with resolved options
schema["properties"][prop_name] = dict(prop_schema)
if "input" not in schema["properties"][prop_name]:
schema["properties"][prop_name]["input"] = {"type": "select"}
schema['properties'][prop_name] = dict(prop_schema)
if 'input' not in schema['properties'][prop_name]:
schema['properties'][prop_name]['input'] = {'type': 'select'}
else:
schema["properties"][prop_name]["input"] = dict(
schema["properties"][prop_name].get("input", {})
)
schema["properties"][prop_name]["input"]["options"] = resolved_options
schema['properties'][prop_name]['input'] = dict(schema['properties'][prop_name].get('input', {}))
schema['properties'][prop_name]['input']['options'] = resolved_options
return schema
@@ -163,7 +151,7 @@ def extract_frontmatter(content):
frontmatter = {}
frontmatter_started = False
frontmatter_ended = False
frontmatter_pattern = re.compile(r"^\s*([a-z_]+):\s*(.*)\s*$", re.IGNORECASE)
frontmatter_pattern = re.compile(r'^\s*([a-z_]+):\s*(.*)\s*$', re.IGNORECASE)
try:
lines = content.splitlines()
@@ -186,7 +174,7 @@ def extract_frontmatter(content):
frontmatter[key.strip()] = value.strip()
except Exception as e:
log.exception(f"Failed to extract frontmatter: {e}")
log.exception(f'Failed to extract frontmatter: {e}')
return {}
return frontmatter
@@ -197,10 +185,10 @@ def replace_imports(content):
Replace the import paths in the content.
"""
replacements = {
"from utils": "from open_webui.utils",
"from apps": "from open_webui.apps",
"from main": "from open_webui.main",
"from config": "from open_webui.config",
'from utils': 'from open_webui.utils',
'from apps': 'from open_webui.apps',
'from main': 'from open_webui.main',
'from config': 'from open_webui.config',
}
for old, new in replacements.items():
@@ -210,22 +198,21 @@ def replace_imports(content):
def load_tool_module_by_id(tool_id, content=None):
if content is None:
tool = Tools.get_tool_by_id(tool_id)
if not tool:
raise Exception(f"Toolkit not found: {tool_id}")
raise Exception(f'Toolkit not found: {tool_id}')
content = tool.content
content = replace_imports(content)
Tools.update_tool_by_id(tool_id, {"content": content})
Tools.update_tool_by_id(tool_id, {'content': content})
else:
frontmatter = extract_frontmatter(content)
# Install required packages found within the frontmatter
install_frontmatter_requirements(frontmatter.get("requirements", ""))
install_frontmatter_requirements(frontmatter.get('requirements', ''))
module_name = f"tool_{tool_id}"
module_name = f'tool_{tool_id}'
module = types.ModuleType(module_name)
sys.modules[module_name] = module
@@ -234,22 +221,22 @@ def load_tool_module_by_id(tool_id, content=None):
temp_file = tempfile.NamedTemporaryFile(delete=False)
temp_file.close()
try:
with open(temp_file.name, "w", encoding="utf-8") as f:
with open(temp_file.name, 'w', encoding='utf-8') as f:
f.write(content)
module.__dict__["__file__"] = temp_file.name
module.__dict__['__file__'] = temp_file.name
# Executing the modified content in the created module's namespace
exec(content, module.__dict__)
frontmatter = extract_frontmatter(content)
log.info(f"Loaded module: {module.__name__}")
log.info(f'Loaded module: {module.__name__}')
# Create and return the object if the class 'Tools' is found in the module
if hasattr(module, "Tools"):
if hasattr(module, 'Tools'):
return module.Tools(), frontmatter
else:
raise Exception("No Tools class found in the module")
raise Exception('No Tools class found in the module')
except Exception as e:
log.error(f"Error loading module: {tool_id}: {e}")
log.error(f'Error loading module: {tool_id}: {e}')
del sys.modules[module_name] # Clean up
raise e
finally:
@@ -260,16 +247,16 @@ def load_function_module_by_id(function_id: str, content: str | None = None):
if content is None:
function = Functions.get_function_by_id(function_id)
if not function:
raise Exception(f"Function not found: {function_id}")
raise Exception(f'Function not found: {function_id}')
content = function.content
content = replace_imports(content)
Functions.update_function_by_id(function_id, {"content": content})
Functions.update_function_by_id(function_id, {'content': content})
else:
frontmatter = extract_frontmatter(content)
install_frontmatter_requirements(frontmatter.get("requirements", ""))
install_frontmatter_requirements(frontmatter.get('requirements', ''))
module_name = f"function_{function_id}"
module_name = f'function_{function_id}'
module = types.ModuleType(module_name)
sys.modules[module_name] = module
@@ -278,30 +265,30 @@ def load_function_module_by_id(function_id: str, content: str | None = None):
temp_file = tempfile.NamedTemporaryFile(delete=False)
temp_file.close()
try:
with open(temp_file.name, "w", encoding="utf-8") as f:
with open(temp_file.name, 'w', encoding='utf-8') as f:
f.write(content)
module.__dict__["__file__"] = temp_file.name
module.__dict__['__file__'] = temp_file.name
# Execute the modified content in the created module's namespace
exec(content, module.__dict__)
frontmatter = extract_frontmatter(content)
log.info(f"Loaded module: {module.__name__}")
log.info(f'Loaded module: {module.__name__}')
# Create appropriate object based on available class type in the module
if hasattr(module, "Pipe"):
return module.Pipe(), "pipe", frontmatter
elif hasattr(module, "Filter"):
return module.Filter(), "filter", frontmatter
elif hasattr(module, "Action"):
return module.Action(), "action", frontmatter
if hasattr(module, 'Pipe'):
return module.Pipe(), 'pipe', frontmatter
elif hasattr(module, 'Filter'):
return module.Filter(), 'filter', frontmatter
elif hasattr(module, 'Action'):
return module.Action(), 'action', frontmatter
else:
raise Exception("No Function class found in the module")
raise Exception('No Function class found in the module')
except Exception as e:
log.error(f"Error loading module: {function_id}: {e}")
log.error(f'Error loading module: {function_id}: {e}')
# Cleanup by removing the module in case of error
del sys.modules[module_name]
Functions.update_function_by_id(function_id, {"is_active": False})
Functions.update_function_by_id(function_id, {'is_active': False})
raise e
finally:
os.unlink(temp_file.name)
@@ -312,35 +299,32 @@ def get_tool_module_from_cache(request, tool_id, load_from_db=True):
# Always load from the database by default
tool = Tools.get_tool_by_id(tool_id)
if not tool:
raise Exception(f"Tool not found: {tool_id}")
raise Exception(f'Tool not found: {tool_id}')
content = tool.content
new_content = replace_imports(content)
if new_content != content:
content = new_content
# Update the tool content in the database
Tools.update_tool_by_id(tool_id, {"content": content})
Tools.update_tool_by_id(tool_id, {'content': content})
if (
hasattr(request.app.state, "TOOL_CONTENTS")
and tool_id in request.app.state.TOOL_CONTENTS
) and (
hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS
if (hasattr(request.app.state, 'TOOL_CONTENTS') and tool_id in request.app.state.TOOL_CONTENTS) and (
hasattr(request.app.state, 'TOOLS') and tool_id in request.app.state.TOOLS
):
if request.app.state.TOOL_CONTENTS[tool_id] == content:
return request.app.state.TOOLS[tool_id], None
tool_module, frontmatter = load_tool_module_by_id(tool_id, content)
else:
if hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS:
if hasattr(request.app.state, 'TOOLS') and tool_id in request.app.state.TOOLS:
return request.app.state.TOOLS[tool_id], None
tool_module, frontmatter = load_tool_module_by_id(tool_id)
if not hasattr(request.app.state, "TOOLS"):
if not hasattr(request.app.state, 'TOOLS'):
request.app.state.TOOLS = {}
if not hasattr(request.app.state, "TOOL_CONTENTS"):
if not hasattr(request.app.state, 'TOOL_CONTENTS'):
request.app.state.TOOL_CONTENTS = {}
request.app.state.TOOLS[tool_id] = tool_module
@@ -357,46 +341,35 @@ def get_function_module_from_cache(request, function_id, load_from_db=True):
function = Functions.get_function_by_id(function_id)
if not function:
raise Exception(f"Function not found: {function_id}")
raise Exception(f'Function not found: {function_id}')
content = function.content
new_content = replace_imports(content)
if new_content != content:
content = new_content
# Update the function content in the database
Functions.update_function_by_id(function_id, {"content": content})
Functions.update_function_by_id(function_id, {'content': content})
if (
hasattr(request.app.state, "FUNCTION_CONTENTS")
and function_id in request.app.state.FUNCTION_CONTENTS
) and (
hasattr(request.app.state, "FUNCTIONS")
and function_id in request.app.state.FUNCTIONS
):
hasattr(request.app.state, 'FUNCTION_CONTENTS') and function_id in request.app.state.FUNCTION_CONTENTS
) and (hasattr(request.app.state, 'FUNCTIONS') and function_id in request.app.state.FUNCTIONS):
if request.app.state.FUNCTION_CONTENTS[function_id] == content:
return request.app.state.FUNCTIONS[function_id], None, None
function_module, function_type, frontmatter = load_function_module_by_id(
function_id, content
)
function_module, function_type, frontmatter = load_function_module_by_id(function_id, content)
else:
# Load from cache (e.g. "stream" hook)
# This is useful for performance reasons
if (
hasattr(request.app.state, "FUNCTIONS")
and function_id in request.app.state.FUNCTIONS
):
if hasattr(request.app.state, 'FUNCTIONS') and function_id in request.app.state.FUNCTIONS:
return request.app.state.FUNCTIONS[function_id], None, None
function_module, function_type, frontmatter = load_function_module_by_id(
function_id
)
function_module, function_type, frontmatter = load_function_module_by_id(function_id)
if not hasattr(request.app.state, "FUNCTIONS"):
if not hasattr(request.app.state, 'FUNCTIONS'):
request.app.state.FUNCTIONS = {}
if not hasattr(request.app.state, "FUNCTION_CONTENTS"):
if not hasattr(request.app.state, 'FUNCTION_CONTENTS'):
request.app.state.FUNCTION_CONTENTS = {}
request.app.state.FUNCTIONS[function_id] = function_module
@@ -407,31 +380,26 @@ def get_function_module_from_cache(request, function_id, load_from_db=True):
def install_frontmatter_requirements(requirements: str):
if not ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS:
log.info(
"ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS is disabled, skipping installation of requirements."
)
log.info('ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS is disabled, skipping installation of requirements.')
return
if OFFLINE_MODE:
log.info("Offline mode enabled, skipping installation of requirements.")
log.info('Offline mode enabled, skipping installation of requirements.')
return
if requirements:
try:
req_list = [req.strip() for req in requirements.split(",")]
log.info(f"Installing requirements: {' '.join(req_list)}")
req_list = [req.strip() for req in requirements.split(',')]
log.info(f'Installing requirements: {" ".join(req_list)}')
subprocess.check_call(
[sys.executable, "-m", "pip", "install"]
+ PIP_OPTIONS
+ req_list
+ PIP_PACKAGE_INDEX_OPTIONS
[sys.executable, '-m', 'pip', 'install'] + PIP_OPTIONS + req_list + PIP_PACKAGE_INDEX_OPTIONS
)
except Exception as e:
log.error(f"Error installing packages: {' '.join(req_list)}")
log.error(f'Error installing packages: {" ".join(req_list)}')
raise e
else:
log.info("No requirements found in frontmatter.")
log.info('No requirements found in frontmatter.')
def install_tool_and_function_dependencies():
@@ -445,19 +413,19 @@ def install_tool_and_function_dependencies():
function_list = Functions.get_functions(active_only=True)
tool_list = Tools.get_tools()
all_dependencies = ""
all_dependencies = ''
try:
for function in function_list:
frontmatter = extract_frontmatter(replace_imports(function.content))
if dependencies := frontmatter.get("requirements"):
all_dependencies += f"{dependencies}, "
if dependencies := frontmatter.get('requirements'):
all_dependencies += f'{dependencies}, '
for tool in tool_list:
# Only install requirements for admin tools
if tool.user and tool.user.role == "admin":
if tool.user and tool.user.role == 'admin':
frontmatter = extract_frontmatter(replace_imports(tool.content))
if dependencies := frontmatter.get("requirements"):
all_dependencies += f"{dependencies}, "
if dependencies := frontmatter.get('requirements'):
all_dependencies += f'{dependencies}, '
install_frontmatter_requirements(all_dependencies.strip(", "))
install_frontmatter_requirements(all_dependencies.strip(', '))
except Exception as e:
log.error(f"Error installing requirements: {e}")
log.error(f'Error installing requirements: {e}')
+3 -7
View File
@@ -35,7 +35,7 @@ class RateLimiter:
self.enabled = enabled
def _bucket_key(self, key: str, bucket_index: int) -> str:
return f"{REDIS_KEY_PREFIX}:ratelimit:{key.lower()}:{bucket_index}"
return f'{REDIS_KEY_PREFIX}:ratelimit:{key.lower()}:{bucket_index}'
def _current_bucket(self) -> int:
return int(time.time()) // self.bucket_size
@@ -84,9 +84,7 @@ class RateLimiter:
self.r.expire(bucket_key, self.window + self.bucket_size)
# Collect buckets
buckets = [
self._bucket_key(key, now_bucket - i) for i in range(self.num_buckets + 1)
]
buckets = [self._bucket_key(key, now_bucket - i) for i in range(self.num_buckets + 1)]
counts = self.r.mget(buckets)
total = sum(int(c) for c in counts if c)
@@ -95,9 +93,7 @@ class RateLimiter:
def _get_count_redis(self, key: str) -> int:
now_bucket = self._current_bucket()
buckets = [
self._bucket_key(key, now_bucket - i) for i in range(self.num_buckets + 1)
]
buckets = [self._bucket_key(key, now_bucket - i) for i in range(self.num_buckets + 1)]
counts = self.r.mget(buckets)
return sum(int(c) for c in counts if c)
+37 -48
View File
@@ -40,7 +40,7 @@ class SentinelRedisProxy:
if not callable(orig_attr):
return orig_attr
FACTORY_METHODS = {"pipeline", "pubsub", "monitor", "client", "transaction"}
FACTORY_METHODS = {'pipeline', 'pubsub', 'monitor', 'client', 'transaction'}
if item in FACTORY_METHODS:
return orig_attr
@@ -61,7 +61,7 @@ class SentinelRedisProxy:
) as e:
if i < REDIS_SENTINEL_MAX_RETRY_COUNT - 1:
log.debug(
"Redis sentinel fail-over (%s). Retry %s/%s",
'Redis sentinel fail-over (%s). Retry %s/%s',
type(e).__name__,
i + 1,
REDIS_SENTINEL_MAX_RETRY_COUNT,
@@ -70,7 +70,7 @@ class SentinelRedisProxy:
time.sleep(REDIS_RECONNECT_DELAY / 1000)
continue
log.error(
"Redis operation failed after %s retries: %s",
'Redis operation failed after %s retries: %s',
REDIS_SENTINEL_MAX_RETRY_COUNT,
e,
)
@@ -94,7 +94,7 @@ class SentinelRedisProxy:
) as e:
if i < REDIS_SENTINEL_MAX_RETRY_COUNT - 1:
log.debug(
"Redis sentinel fail-over (%s). Retry %s/%s",
'Redis sentinel fail-over (%s). Retry %s/%s',
type(e).__name__,
i + 1,
REDIS_SENTINEL_MAX_RETRY_COUNT,
@@ -103,7 +103,7 @@ class SentinelRedisProxy:
await asyncio.sleep(REDIS_RECONNECT_DELAY / 1000)
continue
log.error(
"Redis operation failed after %s retries: %s",
'Redis operation failed after %s retries: %s',
REDIS_SENTINEL_MAX_RETRY_COUNT,
e,
)
@@ -124,7 +124,7 @@ class SentinelRedisProxy:
) as e:
if i < REDIS_SENTINEL_MAX_RETRY_COUNT - 1:
log.debug(
"Redis sentinel fail-over (%s). Retry %s/%s",
'Redis sentinel fail-over (%s). Retry %s/%s',
type(e).__name__,
i + 1,
REDIS_SENTINEL_MAX_RETRY_COUNT,
@@ -133,7 +133,7 @@ class SentinelRedisProxy:
time.sleep(REDIS_RECONNECT_DELAY / 1000)
continue
log.error(
"Redis operation failed after %s retries: %s",
'Redis operation failed after %s retries: %s',
REDIS_SENTINEL_MAX_RETRY_COUNT,
e,
)
@@ -144,15 +144,15 @@ class SentinelRedisProxy:
def parse_redis_service_url(redis_url):
parsed_url = urlparse(redis_url)
if parsed_url.scheme != "redis" and parsed_url.scheme != "rediss":
if parsed_url.scheme != 'redis' and parsed_url.scheme != 'rediss':
raise ValueError("Invalid Redis URL scheme. Must be 'redis' or 'rediss'.")
return {
"username": parsed_url.username or None,
"password": parsed_url.password or None,
"service": parsed_url.hostname or "mymaster",
"port": parsed_url.port or 6379,
"db": int(parsed_url.path.lstrip("/") or 0),
'username': parsed_url.username or None,
'password': parsed_url.password or None,
'service': parsed_url.hostname or 'mymaster',
'port': parsed_url.port or 6379,
'db': int(parsed_url.path.lstrip('/') or 0),
}
@@ -160,14 +160,12 @@ def get_redis_client(async_mode=False):
try:
return get_redis_connection(
redis_url=REDIS_URL,
redis_sentinels=get_sentinels_from_env(
REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT
),
redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT),
redis_cluster=REDIS_CLUSTER,
async_mode=async_mode,
)
except Exception as e:
log.debug(f"Failed to get Redis client: {e}")
log.debug(f'Failed to get Redis client: {e}')
return None
@@ -178,7 +176,6 @@ def get_redis_connection(
async_mode=False,
decode_responses=True,
):
cache_key = (
redis_url,
tuple(redis_sentinels) if redis_sentinels else (),
@@ -199,24 +196,22 @@ def get_redis_connection(
redis_config = parse_redis_service_url(redis_url)
sentinel = redis.sentinel.Sentinel(
redis_sentinels,
port=redis_config["port"],
db=redis_config["db"],
username=redis_config["username"],
password=redis_config["password"],
port=redis_config['port'],
db=redis_config['db'],
username=redis_config['username'],
password=redis_config['password'],
decode_responses=decode_responses,
socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT,
)
connection = SentinelRedisProxy(
sentinel,
redis_config["service"],
redis_config['service'],
async_mode=async_mode,
)
elif redis_cluster:
if not redis_url:
raise ValueError("Redis URL must be provided for cluster mode.")
return redis.cluster.RedisCluster.from_url(
redis_url, decode_responses=decode_responses
)
raise ValueError('Redis URL must be provided for cluster mode.')
return redis.cluster.RedisCluster.from_url(redis_url, decode_responses=decode_responses)
elif redis_url:
connection = redis.from_url(redis_url, decode_responses=decode_responses)
else:
@@ -226,28 +221,24 @@ def get_redis_connection(
redis_config = parse_redis_service_url(redis_url)
sentinel = redis.sentinel.Sentinel(
redis_sentinels,
port=redis_config["port"],
db=redis_config["db"],
username=redis_config["username"],
password=redis_config["password"],
port=redis_config['port'],
db=redis_config['db'],
username=redis_config['username'],
password=redis_config['password'],
decode_responses=decode_responses,
socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT,
)
connection = SentinelRedisProxy(
sentinel,
redis_config["service"],
redis_config['service'],
async_mode=async_mode,
)
elif redis_cluster:
if not redis_url:
raise ValueError("Redis URL must be provided for cluster mode.")
return redis.cluster.RedisCluster.from_url(
redis_url, decode_responses=decode_responses
)
raise ValueError('Redis URL must be provided for cluster mode.')
return redis.cluster.RedisCluster.from_url(redis_url, decode_responses=decode_responses)
elif redis_url:
connection = redis.Redis.from_url(
redis_url, decode_responses=decode_responses
)
connection = redis.Redis.from_url(redis_url, decode_responses=decode_responses)
_CONNECTION_CACHE[cache_key] = connection
return connection
@@ -255,7 +246,7 @@ def get_redis_connection(
def get_sentinels_from_env(sentinel_hosts_env, sentinel_port_env):
if sentinel_hosts_env:
sentinel_hosts = sentinel_hosts_env.split(",")
sentinel_hosts = sentinel_hosts_env.split(',')
sentinel_port = int(sentinel_port_env)
return [(host, sentinel_port) for host in sentinel_hosts]
return []
@@ -263,12 +254,10 @@ def get_sentinels_from_env(sentinel_hosts_env, sentinel_port_env):
def get_sentinel_url_from_env(redis_url, sentinel_hosts_env, sentinel_port_env):
redis_config = parse_redis_service_url(redis_url)
username = redis_config["username"] or ""
password = redis_config["password"] or ""
auth_part = ""
username = redis_config['username'] or ''
password = redis_config['password'] or ''
auth_part = ''
if username or password:
auth_part = f"{username}:{password}@"
hosts_part = ",".join(
f"{host}:{sentinel_port_env}" for host in sentinel_hosts_env.split(",")
)
return f"redis+sentinel://{auth_part}{hosts_part}/{redis_config['db']}/{redis_config['service']}"
auth_part = f'{username}:{password}@'
hosts_part = ','.join(f'{host}:{sentinel_port_env}' for host in sentinel_hosts_env.split(','))
return f'redis+sentinel://{auth_part}{hosts_part}/{redis_config["db"]}/{redis_config["service"]}'
+78 -96
View File
@@ -21,28 +21,28 @@ def normalize_usage(usage: dict) -> dict:
# Map various field names to standard names
input_tokens = (
usage.get("input_tokens") # Already standard
or usage.get("prompt_tokens") # OpenAI
or usage.get("prompt_eval_count") # Ollama
or usage.get("prompt_n") # llama.cpp
usage.get('input_tokens') # Already standard
or usage.get('prompt_tokens') # OpenAI
or usage.get('prompt_eval_count') # Ollama
or usage.get('prompt_n') # llama.cpp
or 0
)
output_tokens = (
usage.get("output_tokens") # Already standard
or usage.get("completion_tokens") # OpenAI
or usage.get("eval_count") # Ollama
or usage.get("predicted_n") # llama.cpp
usage.get('output_tokens') # Already standard
or usage.get('completion_tokens') # OpenAI
or usage.get('eval_count') # Ollama
or usage.get('predicted_n') # llama.cpp
or 0
)
total_tokens = usage.get("total_tokens") or (input_tokens + output_tokens)
total_tokens = usage.get('total_tokens') or (input_tokens + output_tokens)
# Add standardized fields to original data
result = dict(usage)
result["input_tokens"] = int(input_tokens)
result["output_tokens"] = int(output_tokens)
result["total_tokens"] = int(total_tokens)
result['input_tokens'] = int(input_tokens)
result['output_tokens'] = int(output_tokens)
result['total_tokens'] = int(total_tokens)
return result
@@ -50,14 +50,14 @@ def normalize_usage(usage: dict) -> dict:
def convert_ollama_tool_call_to_openai(tool_calls: list) -> list:
openai_tool_calls = []
for tool_call in tool_calls:
function = tool_call.get("function", {})
function = tool_call.get('function', {})
openai_tool_call = {
"index": tool_call.get("index", function.get("index", 0)),
"id": tool_call.get("id", f"call_{str(uuid4())}"),
"type": "function",
"function": {
"name": function.get("name", ""),
"arguments": json.dumps(function.get("arguments", {})),
'index': tool_call.get('index', function.get('index', 0)),
'id': tool_call.get('id', f'call_{str(uuid4())}'),
'type': 'function',
'function': {
'name': function.get('name', ''),
'arguments': json.dumps(function.get('arguments', {})),
},
}
openai_tool_calls.append(openai_tool_call)
@@ -65,69 +65,57 @@ def convert_ollama_tool_call_to_openai(tool_calls: list) -> list:
def convert_ollama_usage_to_openai(data: dict) -> dict:
input_tokens = int(data.get("prompt_eval_count", 0))
output_tokens = int(data.get("eval_count", 0))
input_tokens = int(data.get('prompt_eval_count', 0))
output_tokens = int(data.get('eval_count', 0))
total_tokens = input_tokens + output_tokens
return {
# Standardized fields
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
'input_tokens': input_tokens,
'output_tokens': output_tokens,
'total_tokens': total_tokens,
# OpenAI-compatible fields (for backward compatibility)
"prompt_tokens": input_tokens,
"completion_tokens": output_tokens,
'prompt_tokens': input_tokens,
'completion_tokens': output_tokens,
# Ollama-specific metrics
"response_token/s": (
'response_token/s': (
round(
(
(
data.get("eval_count", 0)
/ ((data.get("eval_duration", 0) / 10_000_000))
)
* 100
),
((data.get('eval_count', 0) / (data.get('eval_duration', 0) / 10_000_000)) * 100),
2,
)
if data.get("eval_duration", 0) > 0
else "N/A"
if data.get('eval_duration', 0) > 0
else 'N/A'
),
"prompt_token/s": (
'prompt_token/s': (
round(
(
(
data.get("prompt_eval_count", 0)
/ ((data.get("prompt_eval_duration", 0) / 10_000_000))
)
* 100
),
((data.get('prompt_eval_count', 0) / (data.get('prompt_eval_duration', 0) / 10_000_000)) * 100),
2,
)
if data.get("prompt_eval_duration", 0) > 0
else "N/A"
if data.get('prompt_eval_duration', 0) > 0
else 'N/A'
),
"total_duration": data.get("total_duration", 0),
"load_duration": data.get("load_duration", 0),
"prompt_eval_count": data.get("prompt_eval_count", 0),
"prompt_eval_duration": data.get("prompt_eval_duration", 0),
"eval_count": data.get("eval_count", 0),
"eval_duration": data.get("eval_duration", 0),
"approximate_total": (lambda s: f"{s // 3600}h{(s % 3600) // 60}m{s % 60}s")(
(data.get("total_duration", 0) or 0) // 1_000_000_000
'total_duration': data.get('total_duration', 0),
'load_duration': data.get('load_duration', 0),
'prompt_eval_count': data.get('prompt_eval_count', 0),
'prompt_eval_duration': data.get('prompt_eval_duration', 0),
'eval_count': data.get('eval_count', 0),
'eval_duration': data.get('eval_duration', 0),
'approximate_total': (lambda s: f'{s // 3600}h{(s % 3600) // 60}m{s % 60}s')(
(data.get('total_duration', 0) or 0) // 1_000_000_000
),
"completion_tokens_details": {
"reasoning_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0,
'completion_tokens_details': {
'reasoning_tokens': 0,
'accepted_prediction_tokens': 0,
'rejected_prediction_tokens': 0,
},
}
def convert_response_ollama_to_openai(ollama_response: dict) -> dict:
model = ollama_response.get("model", "ollama")
message_content = ollama_response.get("message", {}).get("content", "")
reasoning_content = ollama_response.get("message", {}).get("thinking", None)
tool_calls = ollama_response.get("message", {}).get("tool_calls", None)
model = ollama_response.get('model', 'ollama')
message_content = ollama_response.get('message', {}).get('content', '')
reasoning_content = ollama_response.get('message', {}).get('thinking', None)
tool_calls = ollama_response.get('message', {}).get('tool_calls', None)
openai_tool_calls = None
if tool_calls:
@@ -148,33 +136,31 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
async for data in ollama_streaming_response.body_iterator:
data = json.loads(data)
model = data.get("model", "ollama")
message_content = data.get("message", {}).get("content", None)
reasoning_content = data.get("message", {}).get("thinking", None)
tool_calls = data.get("message", {}).get("tool_calls", None)
model = data.get('model', 'ollama')
message_content = data.get('message', {}).get('content', None)
reasoning_content = data.get('message', {}).get('thinking', None)
tool_calls = data.get('message', {}).get('tool_calls', None)
openai_tool_calls = None
if tool_calls:
openai_tool_calls = convert_ollama_tool_call_to_openai(tool_calls)
has_tool_calls = True
done = data.get("done", False)
done = data.get('done', False)
usage = None
if done:
usage = convert_ollama_usage_to_openai(data)
data = openai_chat_chunk_message_template(
model, message_content, reasoning_content, openai_tool_calls, usage
)
data = openai_chat_chunk_message_template(model, message_content, reasoning_content, openai_tool_calls, usage)
if done and has_tool_calls:
data["choices"][0]["finish_reason"] = "tool_calls"
data['choices'][0]['finish_reason'] = 'tool_calls'
line = f"data: {json.dumps(data)}\n\n"
line = f'data: {json.dumps(data)}\n\n'
yield line
yield "data: [DONE]\n\n"
yield 'data: [DONE]\n\n'
def convert_embedding_response_ollama_to_openai(response) -> dict:
@@ -199,51 +185,47 @@ def convert_embedding_response_ollama_to_openai(response) -> dict:
"""
# Ollama batch-style output from /api/embed
# Response format: {"embeddings": [[0.1, 0.2, ...], [0.3, 0.4, ...]], "model": "..."}
if isinstance(response, dict) and "embeddings" in response:
if isinstance(response, dict) and 'embeddings' in response:
openai_data = []
for i, emb in enumerate(response["embeddings"]):
for i, emb in enumerate(response['embeddings']):
# /api/embed returns embeddings as plain float lists
if isinstance(emb, list):
openai_data.append(
{
"object": "embedding",
"embedding": emb,
"index": i,
'object': 'embedding',
'embedding': emb,
'index': i,
}
)
# Also handle dict format for robustness
elif isinstance(emb, dict):
openai_data.append(
{
"object": "embedding",
"embedding": emb.get("embedding"),
"index": emb.get("index", i),
'object': 'embedding',
'embedding': emb.get('embedding'),
'index': emb.get('index', i),
}
)
return {
"object": "list",
"data": openai_data,
"model": response.get("model"),
'object': 'list',
'data': openai_data,
'model': response.get('model'),
}
# Ollama single output
elif isinstance(response, dict) and "embedding" in response:
elif isinstance(response, dict) and 'embedding' in response:
return {
"object": "list",
"data": [
'object': 'list',
'data': [
{
"object": "embedding",
"embedding": response["embedding"],
"index": 0,
'object': 'embedding',
'embedding': response['embedding'],
'index': 0,
}
],
"model": response.get("model"),
'model': response.get('model'),
}
# Already OpenAI-compatible?
elif (
isinstance(response, dict)
and "data" in response
and isinstance(response["data"], list)
):
elif isinstance(response, dict) and 'data' in response and isinstance(response['data'], list):
return response
# Fallback: return as is if unrecognized
+4 -6
View File
@@ -2,9 +2,7 @@ import re
# ANSI escape code pattern - matches all common ANSI sequences
# This includes color codes, cursor movement, and other terminal control sequences
ANSI_ESCAPE_PATTERN = re.compile(
r"\x1b\[[0-9;]*[A-Za-z]|\x1b\([AB]|\x1b[PX^_].*?\x1b\\|\x1b\].*?(?:\x07|\x1b\\)"
)
ANSI_ESCAPE_PATTERN = re.compile(r'\x1b\[[0-9;]*[A-Za-z]|\x1b\([AB]|\x1b[PX^_].*?\x1b\\|\x1b\].*?(?:\x07|\x1b\\)')
def strip_ansi_codes(text: str) -> str:
@@ -20,7 +18,7 @@ def strip_ansi_codes(text: str) -> str:
- Reset codes: \x1b[0m, \x1b[39m
- Cursor movement: \x1b[1A, \x1b[2J, etc.
"""
return ANSI_ESCAPE_PATTERN.sub("", text)
return ANSI_ESCAPE_PATTERN.sub('', text)
def strip_markdown_code_fences(code: str) -> str:
@@ -37,9 +35,9 @@ def strip_markdown_code_fences(code: str) -> str:
"""
code = code.strip()
# Remove opening fence (```python, ```py, ``` etc.)
code = re.sub(r"^```\w*\n?", "", code)
code = re.sub(r'^```\w*\n?', '', code)
# Remove closing fence
code = re.sub(r"\n?```\s*$", "", code)
code = re.sub(r'\n?```\s*$', '', code)
return code.strip()
+36 -36
View File
@@ -39,16 +39,16 @@ def set_security_headers() -> Dict[str, str]:
"""
options = {}
header_setters = {
"CACHE_CONTROL": set_cache_control,
"HSTS": set_hsts,
"PERMISSIONS_POLICY": set_permissions_policy,
"REFERRER_POLICY": set_referrer,
"XCONTENT_TYPE": set_xcontent_type,
"XDOWNLOAD_OPTIONS": set_xdownload_options,
"XFRAME_OPTIONS": set_xframe,
"XPERMITTED_CROSS_DOMAIN_POLICIES": set_xpermitted_cross_domain_policies,
"CONTENT_SECURITY_POLICY": set_content_security_policy,
"REPORTING_ENDPOINTS": set_reporting_endpoints,
'CACHE_CONTROL': set_cache_control,
'HSTS': set_hsts,
'PERMISSIONS_POLICY': set_permissions_policy,
'REFERRER_POLICY': set_referrer,
'XCONTENT_TYPE': set_xcontent_type,
'XDOWNLOAD_OPTIONS': set_xdownload_options,
'XFRAME_OPTIONS': set_xframe,
'XPERMITTED_CROSS_DOMAIN_POLICIES': set_xpermitted_cross_domain_policies,
'CONTENT_SECURITY_POLICY': set_content_security_policy,
'REPORTING_ENDPOINTS': set_reporting_endpoints,
}
for env_var, setter in header_setters.items():
@@ -63,78 +63,78 @@ def set_security_headers() -> Dict[str, str]:
# Set HTTP Strict Transport Security(HSTS) response header
def set_hsts(value: str):
pattern = r"^max-age=(\d+)(;includeSubDomains)?(;preload)?$"
pattern = r'^max-age=(\d+)(;includeSubDomains)?(;preload)?$'
match = re.match(pattern, value, re.IGNORECASE)
if not match:
value = "max-age=31536000;includeSubDomains"
return {"Strict-Transport-Security": value}
value = 'max-age=31536000;includeSubDomains'
return {'Strict-Transport-Security': value}
# Set X-Frame-Options response header
def set_xframe(value: str):
pattern = r"^(DENY|SAMEORIGIN)$"
pattern = r'^(DENY|SAMEORIGIN)$'
match = re.match(pattern, value, re.IGNORECASE)
if not match:
value = "DENY"
return {"X-Frame-Options": value}
value = 'DENY'
return {'X-Frame-Options': value}
# Set Permissions-Policy response header
def set_permissions_policy(value: str):
pattern = r"^(?:(accelerometer|autoplay|camera|clipboard-read|clipboard-write|fullscreen|geolocation|gyroscope|magnetometer|microphone|midi|payment|picture-in-picture|sync-xhr|usb|xr-spatial-tracking)=\((self)?\),?)*$"
pattern = r'^(?:(accelerometer|autoplay|camera|clipboard-read|clipboard-write|fullscreen|geolocation|gyroscope|magnetometer|microphone|midi|payment|picture-in-picture|sync-xhr|usb|xr-spatial-tracking)=\((self)?\),?)*$'
match = re.match(pattern, value, re.IGNORECASE)
if not match:
value = "none"
return {"Permissions-Policy": value}
value = 'none'
return {'Permissions-Policy': value}
# Set Referrer-Policy response header
def set_referrer(value: str):
pattern = r"^(no-referrer|no-referrer-when-downgrade|origin|origin-when-cross-origin|same-origin|strict-origin|strict-origin-when-cross-origin|unsafe-url)$"
pattern = r'^(no-referrer|no-referrer-when-downgrade|origin|origin-when-cross-origin|same-origin|strict-origin|strict-origin-when-cross-origin|unsafe-url)$'
match = re.match(pattern, value, re.IGNORECASE)
if not match:
value = "no-referrer"
return {"Referrer-Policy": value}
value = 'no-referrer'
return {'Referrer-Policy': value}
# Set Cache-Control response header
def set_cache_control(value: str):
pattern = r"^(public|private|no-cache|no-store|must-revalidate|proxy-revalidate|max-age=\d+|s-maxage=\d+|no-transform|immutable)(,\s*(public|private|no-cache|no-store|must-revalidate|proxy-revalidate|max-age=\d+|s-maxage=\d+|no-transform|immutable))*$"
pattern = r'^(public|private|no-cache|no-store|must-revalidate|proxy-revalidate|max-age=\d+|s-maxage=\d+|no-transform|immutable)(,\s*(public|private|no-cache|no-store|must-revalidate|proxy-revalidate|max-age=\d+|s-maxage=\d+|no-transform|immutable))*$'
match = re.match(pattern, value, re.IGNORECASE)
if not match:
value = "no-store, max-age=0"
value = 'no-store, max-age=0'
return {"Cache-Control": value}
return {'Cache-Control': value}
# Set X-Download-Options response header
def set_xdownload_options(value: str):
if value != "noopen":
value = "noopen"
return {"X-Download-Options": value}
if value != 'noopen':
value = 'noopen'
return {'X-Download-Options': value}
# Set X-Content-Type-Options response header
def set_xcontent_type(value: str):
if value != "nosniff":
value = "nosniff"
return {"X-Content-Type-Options": value}
if value != 'nosniff':
value = 'nosniff'
return {'X-Content-Type-Options': value}
# Set X-Permitted-Cross-Domain-Policies response header
def set_xpermitted_cross_domain_policies(value: str):
pattern = r"^(none|master-only|by-content-type|by-ftp-filename)$"
pattern = r'^(none|master-only|by-content-type|by-ftp-filename)$'
match = re.match(pattern, value, re.IGNORECASE)
if not match:
value = "none"
return {"X-Permitted-Cross-Domain-Policies": value}
value = 'none'
return {'X-Permitted-Cross-Domain-Policies': value}
# Set Content-Security-Policy response header
def set_content_security_policy(value: str):
return {"Content-Security-Policy": value}
return {'Content-Security-Policy': value}
# Set Reporting-Endpoints response header
def set_reporting_endpoints(value: str):
return {"Reporting-Endpoints": value}
return {'Reporting-Endpoints': value}
+87 -132
View File
@@ -13,13 +13,11 @@ from open_webui.config import DEFAULT_RAG_TEMPLATE
log = logging.getLogger(__name__)
def get_task_model_id(
default_model_id: str, task_model: str, task_model_external: str, models
) -> str:
def get_task_model_id(default_model_id: str, task_model: str, task_model_external: str, models) -> str:
# Set the task model
task_model_id = default_model_id
# Check if the user has a custom task model and use that model
if models[task_model_id].get("connection_type") == "local":
if models[task_model_id].get('connection_type') == 'local':
if task_model and task_model in models:
task_model_id = task_model
else:
@@ -36,92 +34,70 @@ def prompt_variables_template(template: str, variables: dict[str, str]) -> str:
def prompt_template(template: str, user: Optional[Any] = None) -> str:
USER_VARIABLES = {}
if user:
if hasattr(user, "model_dump"):
if hasattr(user, 'model_dump'):
user = user.model_dump()
if isinstance(user, dict):
user_info = user.get("info", {}) or {}
birth_date = user.get("date_of_birth")
user_info = user.get('info', {}) or {}
birth_date = user.get('date_of_birth')
age = None
if birth_date:
try:
# If birth_date is str, convert to datetime
if isinstance(birth_date, str):
birth_date = datetime.strptime(birth_date, "%Y-%m-%d")
birth_date = datetime.strptime(birth_date, '%Y-%m-%d')
today = datetime.now()
age = (
today.year
- birth_date.year
- (
(today.month, today.day)
< (birth_date.month, birth_date.day)
)
)
age = today.year - birth_date.year - ((today.month, today.day) < (birth_date.month, birth_date.day))
except Exception as e:
pass
USER_VARIABLES = {
"name": str(user.get("name")),
"email": str(user.get("email")),
"location": str(user_info.get("location")),
"bio": str(user.get("bio")),
"gender": str(user.get("gender")),
"birth_date": str(birth_date),
"age": str(age),
'name': str(user.get('name')),
'email': str(user.get('email')),
'location': str(user_info.get('location')),
'bio': str(user.get('bio')),
'gender': str(user.get('gender')),
'birth_date': str(birth_date),
'age': str(age),
}
# Get the current date
current_date = datetime.now()
# Format the date to YYYY-MM-DD
formatted_date = current_date.strftime("%Y-%m-%d")
formatted_time = current_date.strftime("%I:%M:%S %p")
formatted_weekday = current_date.strftime("%A")
formatted_date = current_date.strftime('%Y-%m-%d')
formatted_time = current_date.strftime('%I:%M:%S %p')
formatted_weekday = current_date.strftime('%A')
template = template.replace("{{CURRENT_DATE}}", formatted_date)
template = template.replace("{{CURRENT_TIME}}", formatted_time)
template = template.replace(
"{{CURRENT_DATETIME}}", f"{formatted_date} {formatted_time}"
)
template = template.replace("{{CURRENT_WEEKDAY}}", formatted_weekday)
template = template.replace('{{CURRENT_DATE}}', formatted_date)
template = template.replace('{{CURRENT_TIME}}', formatted_time)
template = template.replace('{{CURRENT_DATETIME}}', f'{formatted_date} {formatted_time}')
template = template.replace('{{CURRENT_WEEKDAY}}', formatted_weekday)
template = template.replace("{{USER_NAME}}", USER_VARIABLES.get("name", "Unknown"))
template = template.replace(
"{{USER_EMAIL}}", USER_VARIABLES.get("email", "Unknown")
)
template = template.replace("{{USER_BIO}}", USER_VARIABLES.get("bio", "Unknown"))
template = template.replace(
"{{USER_GENDER}}", USER_VARIABLES.get("gender", "Unknown")
)
template = template.replace(
"{{USER_BIRTH_DATE}}", USER_VARIABLES.get("birth_date", "Unknown")
)
template = template.replace(
"{{USER_AGE}}", str(USER_VARIABLES.get("age", "Unknown"))
)
template = template.replace(
"{{USER_LOCATION}}", USER_VARIABLES.get("location", "Unknown")
)
template = template.replace('{{USER_NAME}}', USER_VARIABLES.get('name', 'Unknown'))
template = template.replace('{{USER_EMAIL}}', USER_VARIABLES.get('email', 'Unknown'))
template = template.replace('{{USER_BIO}}', USER_VARIABLES.get('bio', 'Unknown'))
template = template.replace('{{USER_GENDER}}', USER_VARIABLES.get('gender', 'Unknown'))
template = template.replace('{{USER_BIRTH_DATE}}', USER_VARIABLES.get('birth_date', 'Unknown'))
template = template.replace('{{USER_AGE}}', str(USER_VARIABLES.get('age', 'Unknown')))
template = template.replace('{{USER_LOCATION}}', USER_VARIABLES.get('location', 'Unknown'))
return template
def replace_prompt_variable(template: str, prompt: str) -> str:
def replacement_function(match):
full_match = match.group(
0
).lower() # Normalize to lowercase for consistent handling
full_match = match.group(0).lower() # Normalize to lowercase for consistent handling
start_length = match.group(1)
end_length = match.group(2)
middle_length = match.group(3)
if full_match == "{{prompt}}":
if full_match == '{{prompt}}':
return prompt
elif start_length is not None:
return prompt[: int(start_length)]
@@ -133,16 +109,16 @@ def replace_prompt_variable(template: str, prompt: str) -> str:
return prompt
start = prompt[: math.ceil(middle_length / 2)]
end = prompt[-math.floor(middle_length / 2) :]
return f"{start}...{end}"
return ""
return f'{start}...{end}'
return ''
# Updated regex pattern to make it case-insensitive with the `(?i)` flag
pattern = r"(?i){{prompt}}|{{prompt:start:(\d+)}}|{{prompt:end:(\d+)}}|{{prompt:middletruncate:(\d+)}}"
pattern = r'(?i){{prompt}}|{{prompt:start:(\d+)}}|{{prompt:end:(\d+)}}|{{prompt:middletruncate:(\d+)}}'
template = re.sub(pattern, replacement_function, template)
return template
def truncate_content(content: str, max_chars: int, mode: str = "middletruncate") -> str:
def truncate_content(content: str, max_chars: int, mode: str = 'middletruncate') -> str:
"""Truncate a string to max_chars using the specified mode.
Modes:
@@ -153,13 +129,13 @@ def truncate_content(content: str, max_chars: int, mode: str = "middletruncate")
if not content or len(content) <= max_chars:
return content
if mode == "start":
if mode == 'start':
return content[:max_chars]
elif mode == "end":
elif mode == 'end':
return content[-max_chars:]
else: # middletruncate
half = max_chars // 2
return f"{content[:half]}...{content[-(max_chars - half):]}"
return f'{content[:half]}...{content[-(max_chars - half) :]}'
def apply_content_filter(messages: list[dict], filter_str: str) -> list[dict]:
@@ -168,7 +144,7 @@ def apply_content_filter(messages: list[dict], filter_str: str) -> list[dict]:
filter_str is like 'middletruncate:500', 'start:200', or 'end:200'.
Returns a new list with truncated content (original messages are not mutated).
"""
parts = filter_str.split(":")
parts = filter_str.split(':')
if len(parts) != 2:
return messages
@@ -178,33 +154,29 @@ def apply_content_filter(messages: list[dict], filter_str: str) -> list[dict]:
except ValueError:
return messages
if mode not in ("middletruncate", "start", "end"):
if mode not in ('middletruncate', 'start', 'end'):
return messages
result = []
for msg in messages:
new_msg = dict(msg)
if isinstance(new_msg.get("content"), str):
new_msg["content"] = truncate_content(new_msg["content"], max_chars, mode)
elif isinstance(new_msg.get("content"), list):
if isinstance(new_msg.get('content'), str):
new_msg['content'] = truncate_content(new_msg['content'], max_chars, mode)
elif isinstance(new_msg.get('content'), list):
new_content = []
for item in new_msg["content"]:
if isinstance(item, dict) and item.get("type") == "text":
for item in new_msg['content']:
if isinstance(item, dict) and item.get('type') == 'text':
new_item = dict(item)
new_item["text"] = truncate_content(
item.get("text", ""), max_chars, mode
)
new_item['text'] = truncate_content(item.get('text', ''), max_chars, mode)
new_content.append(new_item)
else:
new_content.append(item)
new_msg["content"] = new_content
new_msg['content'] = new_content
result.append(new_msg)
return result
def replace_messages_variable(
template: str, messages: Optional[list[dict]] = None
) -> str:
def replace_messages_variable(template: str, messages: Optional[list[dict]] = None) -> str:
def replacement_function(match):
# Groups: (1) filter for bare MESSAGES
# (2) START count, (3) filter for START
@@ -220,7 +192,7 @@ def replace_messages_variable(
# If messages is None, handle it as an empty list
if messages is None:
return ""
return ''
# Select messages based on the variant
if start_length is not None:
@@ -251,12 +223,12 @@ def replace_messages_variable(
return get_messages_content(selected)
template = re.sub(
r"(?:"
r"\{\{MESSAGES(?:\|(\w+:\d+))?\}\}"
r"|\{\{MESSAGES:START:(\d+)(?:\|(\w+:\d+))?\}\}"
r"|\{\{MESSAGES:END:(\d+)(?:\|(\w+:\d+))?\}\}"
r"|\{\{MESSAGES:MIDDLETRUNCATE:(\d+)(?:\|(\w+:\d+))?\}\}"
r")",
r'(?:'
r'\{\{MESSAGES(?:\|(\w+:\d+))?\}\}'
r'|\{\{MESSAGES:START:(\d+)(?:\|(\w+:\d+))?\}\}'
r'|\{\{MESSAGES:END:(\d+)(?:\|(\w+:\d+))?\}\}'
r'|\{\{MESSAGES:MIDDLETRUNCATE:(\d+)(?:\|(\w+:\d+))?\}\}'
r')',
replacement_function,
template,
)
@@ -268,39 +240,37 @@ def replace_messages_variable(
def rag_template(template: str, context: str, query: str):
if template.strip() == "":
if template.strip() == '':
template = DEFAULT_RAG_TEMPLATE
template = prompt_template(template)
if "[context]" not in template and "{{CONTEXT}}" not in template:
log.debug(
"WARNING: The RAG template does not contain the '[context]' or '{{CONTEXT}}' placeholder."
)
if '[context]' not in template and '{{CONTEXT}}' not in template:
log.debug("WARNING: The RAG template does not contain the '[context]' or '{{CONTEXT}}' placeholder.")
if "<context>" in context and "</context>" in context:
if '<context>' in context and '</context>' in context:
log.debug(
"WARNING: Potential prompt injection attack: the RAG "
'WARNING: Potential prompt injection attack: the RAG '
"context contains '<context>' and '</context>'. This might be "
"nothing, or the user might be trying to hack something."
'nothing, or the user might be trying to hack something.'
)
query_placeholders = []
if "[query]" in context:
query_placeholder = "{{QUERY" + str(uuid.uuid4()) + "}}"
template = template.replace("[query]", query_placeholder)
query_placeholders.append((query_placeholder, "[query]"))
if '[query]' in context:
query_placeholder = '{{QUERY' + str(uuid.uuid4()) + '}}'
template = template.replace('[query]', query_placeholder)
query_placeholders.append((query_placeholder, '[query]'))
if "{{QUERY}}" in context:
query_placeholder = "{{QUERY" + str(uuid.uuid4()) + "}}"
template = template.replace("{{QUERY}}", query_placeholder)
query_placeholders.append((query_placeholder, "{{QUERY}}"))
if '{{QUERY}}' in context:
query_placeholder = '{{QUERY' + str(uuid.uuid4()) + '}}'
template = template.replace('{{QUERY}}', query_placeholder)
query_placeholders.append((query_placeholder, '{{QUERY}}'))
template = template.replace("[context]", context)
template = template.replace("{{CONTEXT}}", context)
template = template.replace('[context]', context)
template = template.replace('{{CONTEXT}}', context)
template = template.replace("[query]", query)
template = template.replace("{{QUERY}}", query)
template = template.replace('[query]', query)
template = template.replace('{{QUERY}}', query)
for query_placeholder, original_placeholder in query_placeholders:
template = template.replace(query_placeholder, original_placeholder)
@@ -308,10 +278,7 @@ def rag_template(template: str, context: str, query: str):
return template
def title_generation_template(
template: str, messages: list[dict], user: Optional[Any] = None
) -> str:
def title_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
@@ -321,9 +288,7 @@ def title_generation_template(
return template
def follow_up_generation_template(
template: str, messages: list[dict], user: Optional[Any] = None
) -> str:
def follow_up_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
@@ -332,9 +297,7 @@ def follow_up_generation_template(
return template
def tags_generation_template(
template: str, messages: list[dict], user: Optional[Any] = None
) -> str:
def tags_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
@@ -343,9 +306,7 @@ def tags_generation_template(
return template
def image_prompt_generation_template(
template: str, messages: list[dict], user: Optional[Any] = None
) -> str:
def image_prompt_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
@@ -354,9 +315,7 @@ def image_prompt_generation_template(
return template
def emoji_generation_template(
template: str, prompt: str, user: Optional[Any] = None
) -> str:
def emoji_generation_template(template: str, prompt: str, user: Optional[Any] = None) -> str:
template = replace_prompt_variable(template, prompt)
template = prompt_template(template, user)
@@ -370,7 +329,7 @@ def autocomplete_generation_template(
type: Optional[str] = None,
user: Optional[Any] = None,
) -> str:
template = template.replace("{{TYPE}}", type if type else "")
template = template.replace('{{TYPE}}', type if type else '')
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
@@ -378,9 +337,7 @@ def autocomplete_generation_template(
return template
def query_generation_template(
template: str, messages: list[dict], user: Optional[Any] = None
) -> str:
def query_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
@@ -389,16 +346,14 @@ def query_generation_template(
return template
def moa_response_generation_template(
template: str, prompt: str, responses: list[str]
) -> str:
def moa_response_generation_template(template: str, prompt: str, responses: list[str]) -> str:
def replacement_function(match):
full_match = match.group(0)
start_length = match.group(1)
end_length = match.group(2)
middle_length = match.group(3)
if full_match == "{{prompt}}":
if full_match == '{{prompt}}':
return prompt
elif start_length is not None:
return prompt[: int(start_length)]
@@ -410,22 +365,22 @@ def moa_response_generation_template(
return prompt
start = prompt[: math.ceil(middle_length / 2)]
end = prompt[-math.floor(middle_length / 2) :]
return f"{start}...{end}"
return ""
return f'{start}...{end}'
return ''
template = re.sub(
r"{{prompt}}|{{prompt:start:(\d+)}}|{{prompt:end:(\d+)}}|{{prompt:middletruncate:(\d+)}}",
r'{{prompt}}|{{prompt:start:(\d+)}}|{{prompt:end:(\d+)}}|{{prompt:middletruncate:(\d+)}}',
replacement_function,
template,
)
responses = [f'"""{response}"""' for response in responses]
responses = "\n\n".join(responses)
responses = '\n\n'.join(responses)
template = template.replace("{{responses}}", responses)
template = template.replace('{{responses}}', responses)
return template
def tools_function_calling_generation_template(template: str, tools_specs: str) -> str:
template = template.replace("{{TOOLS}}", tools_specs)
template = template.replace('{{TOOLS}}', tools_specs)
return template
+16 -16
View File
@@ -1,12 +1,12 @@
from opentelemetry.semconv.trace import SpanAttributes as _SpanAttributes
# Span Tags
SPAN_DB_TYPE = "mysql"
SPAN_REDIS_TYPE = "redis"
SPAN_DURATION = "duration"
SPAN_SQL_STR = "sql"
SPAN_SQL_EXPLAIN = "explain"
SPAN_ERROR_TYPE = "error"
SPAN_DB_TYPE = 'mysql'
SPAN_REDIS_TYPE = 'redis'
SPAN_DURATION = 'duration'
SPAN_SQL_STR = 'sql'
SPAN_SQL_EXPLAIN = 'explain'
SPAN_ERROR_TYPE = 'error'
class SpanAttributes(_SpanAttributes):
@@ -14,13 +14,13 @@ class SpanAttributes(_SpanAttributes):
Span Attributes
"""
DB_INSTANCE = "db.instance"
DB_TYPE = "db.type"
DB_IP = "db.ip"
DB_PORT = "db.port"
ERROR_KIND = "error.kind"
ERROR_OBJECT = "error.object"
ERROR_MESSAGE = "error.message"
RESULT_CODE = "result.code"
RESULT_MESSAGE = "result.message"
RESULT_ERRORS = "result.errors"
DB_INSTANCE = 'db.instance'
DB_TYPE = 'db.type'
DB_IP = 'db.ip'
DB_PORT = 'db.port'
ERROR_KIND = 'error.kind'
ERROR_OBJECT = 'error.object'
ERROR_MESSAGE = 'error.message'
RESULT_CODE = 'result.code'
RESULT_MESSAGE = 'result.message'
RESULT_ERRORS = 'result.errors'
@@ -38,7 +38,7 @@ def requests_hook(span: Span, request: PreparedRequest):
Http Request Hook
"""
span.update_name(f"{request.method} {request.url}")
span.update_name(f'{request.method} {request.url}')
span.set_attributes(
attributes={
SpanAttributes.HTTP_URL: request.url,
@@ -70,8 +70,8 @@ def redis_request_hook(span: Span, instance: Union[Redis | RedisCluster], args,
# - redis.cluster.RedisCluster
# Instead of checking the type, we check if the instance has a nodes_manager attribute.
try:
db = ""
if hasattr(instance, "nodes_manager"):
db = ''
if hasattr(instance, 'nodes_manager'):
default_node = instance.nodes_manager.default_node
if not default_node:
return
@@ -79,17 +79,17 @@ def redis_request_hook(span: Span, instance: Union[Redis | RedisCluster], args,
port = default_node.port
else:
connection_kwargs: dict = instance.connection_pool.connection_kwargs
host = connection_kwargs.get("host")
port = connection_kwargs.get("port")
db = connection_kwargs.get("db")
host = connection_kwargs.get('host')
port = connection_kwargs.get('port')
db = connection_kwargs.get('db')
span.set_attributes(
{
SpanAttributes.DB_INSTANCE: f"{host}/{db}",
SpanAttributes.DB_NAME: f"{host}/{db}",
SpanAttributes.DB_INSTANCE: f'{host}/{db}',
SpanAttributes.DB_NAME: f'{host}/{db}',
SpanAttributes.DB_TYPE: SPAN_REDIS_TYPE,
SpanAttributes.DB_PORT: port,
SpanAttributes.DB_IP: host,
SpanAttributes.DB_STATEMENT: " ".join([str(i) for i in args]),
SpanAttributes.DB_STATEMENT: ' '.join([str(i) for i in args]),
SpanAttributes.DB_OPERATION: str(args[0]),
}
)
@@ -102,7 +102,7 @@ def httpx_request_hook(span: Span, request: RequestInfo):
HTTPX Request Hook
"""
span.update_name(f"{request.method.decode()} {str(request.url)}")
span.update_name(f'{request.method.decode()} {str(request.url)}')
span.set_attributes(
attributes={
SpanAttributes.HTTP_URL: str(request.url),
@@ -117,11 +117,7 @@ def httpx_response_hook(span: Span, request: RequestInfo, response: ResponseInfo
"""
span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.status_code)
span.set_status(
StatusCode.ERROR
if response.status_code >= status.HTTP_400_BAD_REQUEST
else StatusCode.OK
)
span.set_status(StatusCode.ERROR if response.status_code >= status.HTTP_400_BAD_REQUEST else StatusCode.OK)
async def httpx_async_request_hook(span: Span, request: RequestInfo):
@@ -132,9 +128,7 @@ async def httpx_async_request_hook(span: Span, request: RequestInfo):
httpx_request_hook(span, request)
async def httpx_async_response_hook(
span: Span, request: RequestInfo, response: ResponseInfo
):
async def httpx_async_response_hook(span: Span, request: RequestInfo, response: ResponseInfo):
"""
Async Response Hook
"""
@@ -147,7 +141,7 @@ def aiohttp_request_hook(span: Span, request: TraceRequestStartParams):
Aiohttp Request Hook
"""
span.update_name(f"{request.method} {str(request.url)}")
span.update_name(f'{request.method} {str(request.url)}')
span.set_attributes(
attributes={
SpanAttributes.HTTP_URL: str(request.url),
@@ -156,20 +150,14 @@ def aiohttp_request_hook(span: Span, request: TraceRequestStartParams):
)
def aiohttp_response_hook(
span: Span, response: Union[TraceRequestExceptionParams, TraceRequestEndParams]
):
def aiohttp_response_hook(span: Span, response: Union[TraceRequestExceptionParams, TraceRequestEndParams]):
"""
Aiohttp Response Hook
"""
if isinstance(response, TraceRequestEndParams):
span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.response.status)
span.set_status(
StatusCode.ERROR
if response.response.status >= status.HTTP_400_BAD_REQUEST
else StatusCode.OK
)
span.set_status(StatusCode.ERROR if response.response.status >= status.HTTP_400_BAD_REQUEST else StatusCode.OK)
elif isinstance(response, TraceRequestExceptionParams):
span.set_status(StatusCode.ERROR)
span.set_attribute(SpanAttributes.ERROR_MESSAGE, str(response.exception))
@@ -191,9 +179,7 @@ class Instrumentor(BaseInstrumentor):
instrument_fastapi(app=self.app)
SQLAlchemyInstrumentor().instrument(engine=self.db_engine)
RedisInstrumentor().instrument(request_hook=redis_request_hook)
RequestsInstrumentor().instrument(
request_hook=requests_hook, response_hook=response_hook
)
RequestsInstrumentor().instrument(request_hook=requests_hook, response_hook=response_hook)
LoggingInstrumentor().instrument()
HTTPXClientInstrumentor().instrument(
request_hook=httpx_request_hook,
@@ -208,7 +194,7 @@ class Instrumentor(BaseInstrumentor):
SystemMetricsInstrumentor().instrument()
def _uninstrument(self, **kwargs):
if getattr(self, "instrumentors", None) is None:
if getattr(self, 'instrumentors', None) is None:
return
for instrumentor in self.instrumentors:
instrumentor.uninstrument()
+3 -3
View File
@@ -24,12 +24,12 @@ from open_webui.env import (
def setup_logging():
headers = []
if OTEL_LOGS_BASIC_AUTH_USERNAME and OTEL_LOGS_BASIC_AUTH_PASSWORD:
auth_string = f"{OTEL_LOGS_BASIC_AUTH_USERNAME}:{OTEL_LOGS_BASIC_AUTH_PASSWORD}"
auth_string = f'{OTEL_LOGS_BASIC_AUTH_USERNAME}:{OTEL_LOGS_BASIC_AUTH_PASSWORD}'
auth_header = b64encode(auth_string.encode()).decode()
headers = [("authorization", f"Basic {auth_header}")]
headers = [('authorization', f'Basic {auth_header}')]
resource = Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
if OTEL_LOGS_OTLP_SPAN_EXPORTER == "http":
if OTEL_LOGS_OTLP_SPAN_EXPORTER == 'http':
exporter = HttpOTLPLogExporter(
endpoint=OTEL_LOGS_EXPORTER_OTLP_ENDPOINT,
headers=headers,
+33 -37
View File
@@ -53,19 +53,15 @@ def _build_meter_provider(resource: Resource) -> MeterProvider:
"""Return a configured MeterProvider."""
headers = []
if OTEL_METRICS_BASIC_AUTH_USERNAME and OTEL_METRICS_BASIC_AUTH_PASSWORD:
auth_string = (
f"{OTEL_METRICS_BASIC_AUTH_USERNAME}:{OTEL_METRICS_BASIC_AUTH_PASSWORD}"
)
auth_string = f'{OTEL_METRICS_BASIC_AUTH_USERNAME}:{OTEL_METRICS_BASIC_AUTH_PASSWORD}'
auth_header = b64encode(auth_string.encode()).decode()
headers = [("authorization", f"Basic {auth_header}")]
headers = [('authorization', f'Basic {auth_header}')]
# Periodic reader pushes metrics over OTLP/gRPC to collector
if OTEL_METRICS_OTLP_SPAN_EXPORTER == "http":
if OTEL_METRICS_OTLP_SPAN_EXPORTER == 'http':
readers: List[PeriodicExportingMetricReader] = [
PeriodicExportingMetricReader(
OTLPHttpMetricExporter(
endpoint=OTEL_METRICS_EXPORTER_OTLP_ENDPOINT, headers=headers
),
OTLPHttpMetricExporter(endpoint=OTEL_METRICS_EXPORTER_OTLP_ENDPOINT, headers=headers),
export_interval_millis=OTEL_METRICS_EXPORT_INTERVAL_MILLIS,
)
]
@@ -84,21 +80,21 @@ def _build_meter_provider(resource: Resource) -> MeterProvider:
# Optional view to limit cardinality: drop user-agent etc.
views: List[View] = [
View(
instrument_name="http.server.duration",
attribute_keys=["http.method", "http.route", "http.status_code"],
instrument_name='http.server.duration',
attribute_keys=['http.method', 'http.route', 'http.status_code'],
),
View(
instrument_name="http.server.requests",
attribute_keys=["http.method", "http.route", "http.status_code"],
instrument_name='http.server.requests',
attribute_keys=['http.method', 'http.route', 'http.status_code'],
),
View(
instrument_name="webui.users.total",
instrument_name='webui.users.total',
),
View(
instrument_name="webui.users.active",
instrument_name='webui.users.active',
),
View(
instrument_name="webui.users.active.today",
instrument_name='webui.users.active.today',
),
]
@@ -118,14 +114,14 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
# Instruments
request_counter = meter.create_counter(
name="http.server.requests",
description="Counts the total number of inbound HTTP requests.",
unit="1",
name='http.server.requests',
description='Counts the total number of inbound HTTP requests.',
unit='1',
)
duration_histogram = meter.create_histogram(
name="http.server.duration",
description="Measures the duration of inbound HTTP requests.",
unit="ms",
name='http.server.duration',
description='Measures the duration of inbound HTTP requests.',
unit='ms',
)
def observe_active_users(
@@ -150,16 +146,16 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
]
meter.create_observable_gauge(
name="webui.users.total",
description="Total number of registered users",
unit="users",
name='webui.users.total',
description='Total number of registered users',
unit='users',
callbacks=[observe_total_registered_users],
)
meter.create_observable_gauge(
name="webui.users.active",
description="Number of currently active users",
unit="users",
name='webui.users.active',
description='Number of currently active users',
unit='users',
callbacks=[observe_active_users],
)
@@ -169,21 +165,21 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
return [metrics.Observation(value=Users.get_num_users_active_today())]
meter.create_observable_gauge(
name="webui.users.active.today",
description="Number of users active since midnight today",
unit="users",
name='webui.users.active.today',
description='Number of users active since midnight today',
unit='users',
callbacks=[observe_users_active_today],
)
# FastAPI middleware
@app.middleware("http")
@app.middleware('http')
async def _metrics_middleware(request: Request, call_next):
start_time = time.perf_counter()
status_code = None
try:
response = await call_next(request)
status_code = getattr(response, "status_code", 500)
status_code = getattr(response, 'status_code', 500)
return response
except Exception:
status_code = 500
@@ -192,13 +188,13 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
# Route template e.g. "/items/{item_id}" instead of real path.
route = request.scope.get("route")
route_path = getattr(route, "path", request.url.path)
route = request.scope.get('route')
route_path = getattr(route, 'path', request.url.path)
attrs: Dict[str, str | int] = {
"http.method": request.method,
"http.route": route_path,
"http.status_code": status_code,
'http.method': request.method,
'http.route': route_path,
'http.status_code': status_code,
}
request_counter.add(1, attrs)
+3 -3
View File
@@ -34,12 +34,12 @@ def setup(app: FastAPI, db_engine: Engine):
# Add basic auth header only if both username and password are not empty
headers = []
if OTEL_BASIC_AUTH_USERNAME and OTEL_BASIC_AUTH_PASSWORD:
auth_string = f"{OTEL_BASIC_AUTH_USERNAME}:{OTEL_BASIC_AUTH_PASSWORD}"
auth_string = f'{OTEL_BASIC_AUTH_USERNAME}:{OTEL_BASIC_AUTH_PASSWORD}'
auth_header = b64encode(auth_string.encode()).decode()
headers = [("authorization", f"Basic {auth_header}")]
headers = [('authorization', f'Basic {auth_header}')]
# otlp export
if OTEL_OTLP_SPAN_EXPORTER == "http":
if OTEL_OTLP_SPAN_EXPORTER == 'http':
exporter = HttpOTLPSpanExporter(
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
headers=headers,
File diff suppressed because it is too large Load Diff
+7 -9
View File
@@ -2,8 +2,8 @@
# Known static asset paths used as default profile images
_ALLOWED_STATIC_PATHS = (
"/user.png",
"/static/favicon.png",
'/user.png',
'/static/favicon.png',
)
@@ -22,10 +22,10 @@ def validate_profile_image_url(url: str) -> str:
return url
_ALLOWED_DATA_PREFIXES = (
"data:image/png",
"data:image/jpeg",
"data:image/gif",
"data:image/webp",
'data:image/png',
'data:image/jpeg',
'data:image/gif',
'data:image/webp',
)
if any(url.startswith(prefix) for prefix in _ALLOWED_DATA_PREFIXES):
return url
@@ -33,6 +33,4 @@ def validate_profile_image_url(url: str) -> str:
if url in _ALLOWED_STATIC_PATHS:
return url
raise ValueError(
"Invalid profile image URL: only data URIs and default avatars are allowed."
)
raise ValueError('Invalid profile image URL: only data URIs and default avatars are allowed.')
+21 -27
View File
@@ -10,42 +10,36 @@ log = logging.getLogger(__name__)
async def post_webhook(name: str, url: str, message: str, event_data: dict) -> bool:
try:
log.debug(f"post_webhook: {url}, {message}, {event_data}")
log.debug(f'post_webhook: {url}, {message}, {event_data}')
payload = {}
# Slack and Google Chat Webhooks
if "https://hooks.slack.com" in url or "https://chat.googleapis.com" in url:
payload["text"] = message
if 'https://hooks.slack.com' in url or 'https://chat.googleapis.com' in url:
payload['text'] = message
# Discord Webhooks
elif "https://discord.com/api/webhooks" in url:
payload["content"] = (
message
if len(message) < 2000
else f"{message[: 2000 - 20]}... (truncated)"
)
elif 'https://discord.com/api/webhooks' in url:
payload['content'] = message if len(message) < 2000 else f'{message[: 2000 - 20]}... (truncated)'
# Microsoft Teams Webhooks
elif "webhook.office.com" in url:
action = event_data.get("action", "undefined")
user_data = event_data.get("user", "{}")
elif 'webhook.office.com' in url:
action = event_data.get('action', 'undefined')
user_data = event_data.get('user', '{}')
if isinstance(user_data, dict):
user_dict = user_data
else:
user_dict = json.loads(user_data)
facts = [
{"name": name, "value": value} for name, value in user_dict.items()
]
facts = [{'name': name, 'value': value} for name, value in user_dict.items()]
payload = {
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
"themeColor": "0076D7",
"summary": message,
"sections": [
'@type': 'MessageCard',
'@context': 'http://schema.org/extensions',
'themeColor': '0076D7',
'summary': message,
'sections': [
{
"activityTitle": message,
"activitySubtitle": f"{name} ({VERSION}) - {action}",
"activityImage": WEBUI_FAVICON_URL,
"facts": facts,
"markdown": True,
'activityTitle': message,
'activitySubtitle': f'{name} ({VERSION}) - {action}',
'activityImage': WEBUI_FAVICON_URL,
'facts': facts,
'markdown': True,
}
],
}
@@ -53,14 +47,14 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict) -> b
else:
payload = {**event_data}
log.debug(f"payload: {payload}")
log.debug(f'payload: {payload}')
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(url, json=payload) as r:
r_text = await r.text()
r.raise_for_status()
log.debug(f"r.text: {r_text}")
log.debug(f'r.text: {r_text}')
return True
except Exception as e: