refac
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 Loguru’s 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)}')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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""
|
||||
return f''
|
||||
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:
|
||||
|
||||
@@ -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, {}
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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
|
||||
|
||||
+1447
-1844
File diff suppressed because it is too large
Load Diff
+185
-226
@@ -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
@@ -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)
|
||||
|
||||
|
||||
+291
-475
File diff suppressed because it is too large
Load Diff
+116
-130
@@ -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]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"]}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+303
-388
File diff suppressed because it is too large
Load Diff
@@ -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.')
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user