Files

449 lines
16 KiB
Python
Raw Permalink Normal View History

from __future__ import annotations
import logging
2024-06-10 23:40:27 -07:00
import os
2024-06-23 20:31:40 -07:00
import re
2024-08-08 09:46:14 +02:00
import subprocess
2024-08-28 00:10:27 +02:00
import sys
2024-09-20 00:12:52 +02:00
import tempfile
import types
from importlib import util
2026-01-22 03:55:07 +04:00
from typing import Any
2024-06-10 23:40:27 -07:00
2026-02-21 15:35:34 -06:00
from open_webui.env import (
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS,
OFFLINE_MODE,
2026-02-21 15:35:34 -06:00
PIP_OPTIONS,
PIP_PACKAGE_INDEX_OPTIONS,
)
2026-04-24 18:20:10 +09:00
from open_webui.models.functions import FunctionModel, Functions
2024-12-10 00:54:13 -08:00
from open_webui.models.tools import Tools
2024-06-10 23:40:27 -07:00
2024-11-21 23:14:05 -05:00
log = logging.getLogger(__name__)
2024-06-10 23:40:27 -07:00
2026-03-17 17:58:01 -05:00
def resolve_valves_schema_options(valves_class: type, schema: dict, user: Any = None) -> dict:
2026-01-22 03:55:07 +04:00
"""
Resolve dynamic options in a Valves schema.
For properties with `input.options`, this function handles two cases:
- List: Used directly as dropdown options
- String: Treated as method name, called to get options dynamically
Usage in Valves:
class UserValves(BaseModel):
# Static options
priority: str = Field(
default="medium",
json_schema_extra={
"input": {
"type": "select",
"options": ["low", "medium", "high"]
}
}
)
# Dynamic options (method name)
model: str = Field(
default="",
json_schema_extra={
"input": {
"type": "select",
"options": "get_model_options"
}
}
)
@classmethod
def get_model_options(cls, __user__=None) -> list[dict]:
return [{"value": "gpt-4", "label": "GPT-4"}]
Args:
valves_class: The Valves or UserValves Pydantic model class
schema: The JSON schema dict from valves_class.schema()
user: Optional user object passed to methods that accept __user__
Returns:
Modified schema dict with resolved options
"""
2026-03-17 17:58:01 -05:00
if not schema or 'properties' not in schema:
2026-01-22 03:55:07 +04:00
return schema
# Make a copy to avoid mutating the original
schema = dict(schema)
2026-03-17 17:58:01 -05:00
schema['properties'] = dict(schema.get('properties', {}))
2026-01-22 03:55:07 +04:00
2026-03-17 17:58:01 -05:00
for prop_name, prop_schema in list(schema['properties'].items()):
2026-01-22 03:55:07 +04:00
# Get the original field info from the Pydantic model
2026-03-17 17:58:01 -05:00
if not hasattr(valves_class, 'model_fields'):
2026-01-22 03:55:07 +04:00
continue
field_info = valves_class.model_fields.get(prop_name)
if not field_info:
continue
# Check json_schema_extra for options
json_schema_extra = field_info.json_schema_extra
if not json_schema_extra or not isinstance(json_schema_extra, dict):
continue
2026-03-17 17:58:01 -05:00
input_config = json_schema_extra.get('input')
2026-01-22 03:55:07 +04:00
if not input_config or not isinstance(input_config, dict):
continue
2026-03-17 17:58:01 -05:00
options = input_config.get('options')
2026-01-22 03:55:07 +04:00
if options is None:
continue
resolved_options = None
# Case 1: options is already a list - use directly
if isinstance(options, list):
resolved_options = options
# Case 2: options is a string - treat as method name
elif isinstance(options, str) and options:
method = getattr(valves_class, options, None)
if method is None or not callable(method):
2026-03-17 17:58:01 -05:00
log.warning(f"options '{options}' not found or not callable on {valves_class.__name__}")
2026-01-22 03:55:07 +04:00
continue
try:
import inspect
sig = inspect.signature(method)
params = sig.parameters
# Prepare kwargs based on what the method accepts
kwargs = {}
2026-03-17 17:58:01 -05:00
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
2026-01-22 03:55:07 +04:00
resolved_options = method(**kwargs) if kwargs else method()
# Validate return type
if not isinstance(resolved_options, list):
2026-03-17 17:58:01 -05:00
log.warning(f"Method '{options}' did not return a list for {prop_name}")
2026-01-22 03:55:07 +04:00
continue
except Exception as e:
2026-03-17 17:58:01 -05:00
log.warning(f'Failed to resolve options for {prop_name}: {e}')
2026-01-22 03:55:07 +04:00
continue
else:
# Invalid options type - skip
continue
# Update the schema with resolved options
2026-03-17 17:58:01 -05:00
schema['properties'][prop_name] = dict(prop_schema)
if 'input' not in schema['properties'][prop_name]:
schema['properties'][prop_name]['input'] = {'type': 'select'}
2026-01-22 03:55:07 +04:00
else:
2026-03-17 17:58:01 -05:00
schema['properties'][prop_name]['input'] = dict(schema['properties'][prop_name].get('input', {}))
schema['properties'][prop_name]['input']['options'] = resolved_options
2026-01-22 03:55:07 +04:00
return schema
2024-09-05 20:35:58 +02:00
def extract_frontmatter(content):
2024-06-23 20:31:40 -07:00
"""
2024-09-05 20:35:58 +02:00
Extract frontmatter as a dictionary from the provided content string.
2024-06-23 20:31:40 -07:00
"""
frontmatter = {}
2024-06-23 20:37:41 -07:00
frontmatter_started = False
frontmatter_ended = False
2026-03-17 17:58:01 -05:00
frontmatter_pattern = re.compile(r'^\s*([a-z_]+):\s*(.*)\s*$', re.IGNORECASE)
2024-06-23 20:37:41 -07:00
try:
2024-09-05 20:35:58 +02:00
lines = content.splitlines()
if len(lines) < 1 or lines[0].strip() != '"""':
# The content doesn't start with triple quotes
return {}
frontmatter_started = True
for line in lines[1:]:
if '"""' in line:
if frontmatter_started:
frontmatter_ended = True
break
if frontmatter_started and not frontmatter_ended:
match = frontmatter_pattern.match(line)
if match:
key, value = match.groups()
frontmatter[key.strip()] = value.strip()
2024-06-23 20:37:41 -07:00
except Exception as e:
2026-03-17 17:58:01 -05:00
log.exception(f'Failed to extract frontmatter: {e}')
2024-06-23 20:37:41 -07:00
return {}
2024-06-23 20:31:40 -07:00
return frontmatter
2024-09-04 19:55:20 +02:00
def replace_imports(content):
"""
Replace the import paths in the content.
"""
replacements = {
2026-03-17 17:58:01 -05:00
'from utils': 'from open_webui.utils',
'from apps': 'from open_webui.apps',
'from main': 'from open_webui.main',
'from config': 'from open_webui.config',
2024-09-04 19:55:20 +02:00
}
for old, new in replacements.items():
content = content.replace(old, new)
return content
2024-09-04 19:55:20 +02:00
2026-03-24 04:49:48 -05:00
# May the intent of the one who wrote it survive every
# import and transformation, as a deed survives the generations.
2026-04-12 14:22:11 -05:00
async def load_tool_module_by_id(tool_id, content=None):
2024-09-04 19:55:20 +02:00
if content is None:
2026-04-12 14:22:11 -05:00
tool = await Tools.get_tool_by_id(tool_id)
2024-09-04 19:55:20 +02:00
if not tool:
2026-03-17 17:58:01 -05:00
raise Exception(f'Toolkit not found: {tool_id}')
2024-09-04 19:55:20 +02:00
content = tool.content
2024-09-04 19:57:41 +02:00
content = replace_imports(content)
2026-04-12 14:22:11 -05:00
await Tools.update_tool_by_id(tool_id, {'content': content})
else:
frontmatter = extract_frontmatter(content)
# Install required packages found within the frontmatter
2026-03-17 17:58:01 -05:00
install_frontmatter_requirements(frontmatter.get('requirements', ''))
2024-09-04 19:55:20 +02:00
2026-03-17 17:58:01 -05:00
module_name = f'tool_{tool_id}'
2024-09-04 19:55:20 +02:00
module = types.ModuleType(module_name)
sys.modules[module_name] = module
2024-06-10 23:40:27 -07:00
2024-09-20 00:12:52 +02:00
# Create a temporary file and use it to define `__file__` so
# that it works as expected from the module's perspective.
2024-09-20 00:12:52 +02:00
temp_file = tempfile.NamedTemporaryFile(delete=False)
temp_file.close()
2024-06-10 23:40:27 -07:00
try:
2026-03-17 17:58:01 -05:00
with open(temp_file.name, 'w', encoding='utf-8') as f:
2024-09-20 00:12:52 +02:00
f.write(content)
2026-03-17 17:58:01 -05:00
module.__dict__['__file__'] = temp_file.name
2024-09-04 19:55:20 +02:00
# Executing the modified content in the created module's namespace
exec(content, module.__dict__)
frontmatter = extract_frontmatter(content)
2026-03-17 17:58:01 -05:00
log.info(f'Loaded module: {module.__name__}')
2024-09-04 19:55:20 +02:00
# Create and return the object if the class 'Tools' is found in the module
2026-03-17 17:58:01 -05:00
if hasattr(module, 'Tools'):
2024-06-23 20:31:40 -07:00
return module.Tools(), frontmatter
2024-06-10 23:40:27 -07:00
else:
2026-03-17 17:58:01 -05:00
raise Exception('No Tools class found in the module')
2024-06-10 23:40:27 -07:00
except Exception as e:
2026-03-17 17:58:01 -05:00
log.error(f'Error loading module: {tool_id}: {e}')
2024-09-04 19:55:20 +02:00
del sys.modules[module_name] # Clean up
2024-06-10 23:40:27 -07:00
raise e
finally:
2024-09-20 00:12:52 +02:00
os.unlink(temp_file.name)
2024-06-20 00:37:02 -07:00
2026-04-12 14:22:11 -05:00
async def load_function_module_by_id(function_id: str, content: str | None = None):
2024-09-04 19:55:20 +02:00
if content is None:
2026-04-12 14:22:11 -05:00
function = await Functions.get_function_by_id(function_id)
2024-09-04 19:55:20 +02:00
if not function:
2026-03-17 17:58:01 -05:00
raise Exception(f'Function not found: {function_id}')
2024-09-04 19:55:20 +02:00
content = function.content
2024-09-04 19:57:41 +02:00
content = replace_imports(content)
2026-04-12 14:22:11 -05:00
await Functions.update_function_by_id(function_id, {'content': content})
else:
frontmatter = extract_frontmatter(content)
2026-03-17 17:58:01 -05:00
install_frontmatter_requirements(frontmatter.get('requirements', ''))
2026-03-17 17:58:01 -05:00
module_name = f'function_{function_id}'
2024-09-04 19:55:20 +02:00
module = types.ModuleType(module_name)
sys.modules[module_name] = module
2024-06-20 00:37:02 -07:00
2024-09-20 00:12:52 +02:00
# Create a temporary file and use it to define `__file__` so
# that it works as expected from the module's perspective.
2024-09-20 00:12:52 +02:00
temp_file = tempfile.NamedTemporaryFile(delete=False)
temp_file.close()
2024-06-20 00:37:02 -07:00
try:
2026-03-17 17:58:01 -05:00
with open(temp_file.name, 'w', encoding='utf-8') as f:
2024-09-20 00:12:52 +02:00
f.write(content)
2026-03-17 17:58:01 -05:00
module.__dict__['__file__'] = temp_file.name
2024-09-04 19:55:20 +02:00
# Execute the modified content in the created module's namespace
exec(content, module.__dict__)
frontmatter = extract_frontmatter(content)
2026-03-17 17:58:01 -05:00
log.info(f'Loaded module: {module.__name__}')
2024-09-04 19:55:20 +02:00
# Create appropriate object based on available class type in the module
2026-03-17 17:58:01 -05:00
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
2024-06-20 00:37:02 -07:00
else:
2026-03-17 17:58:01 -05:00
raise Exception('No Function class found in the module')
2024-06-20 00:37:02 -07:00
except Exception as e:
2026-03-17 17:58:01 -05:00
log.error(f'Error loading module: {function_id}: {e}')
# Cleanup by removing the module in case of error
del sys.modules[module_name]
2024-09-04 19:55:20 +02:00
2026-04-12 14:22:11 -05:00
await Functions.update_function_by_id(function_id, {'is_active': False})
2024-06-20 00:37:02 -07:00
raise e
finally:
2024-09-20 00:12:52 +02:00
os.unlink(temp_file.name)
2024-08-08 09:46:14 +02:00
2024-08-14 13:49:18 +01:00
2026-04-12 14:22:11 -05:00
async def get_tool_module_from_cache(request, tool_id, load_from_db=True):
2025-09-26 19:01:22 -05:00
if load_from_db:
# Always load from the database by default
2026-04-12 14:22:11 -05:00
tool = await Tools.get_tool_by_id(tool_id)
2025-09-26 19:01:22 -05:00
if not tool:
2026-03-17 17:58:01 -05:00
raise Exception(f'Tool not found: {tool_id}')
2025-09-26 19:01:22 -05:00
content = tool.content
new_content = replace_imports(content)
if new_content != content:
content = new_content
# Update the tool content in the database
2026-04-12 14:22:11 -05:00
await Tools.update_tool_by_id(tool_id, {'content': content})
2025-09-26 19:01:22 -05:00
2026-03-17 17:58:01 -05:00
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
2025-09-26 19:01:22 -05:00
):
if request.app.state.TOOL_CONTENTS[tool_id] == content:
return request.app.state.TOOLS[tool_id], None
2026-04-12 14:22:11 -05:00
tool_module, frontmatter = await load_tool_module_by_id(tool_id, content)
2025-09-26 19:01:22 -05:00
else:
2026-03-17 17:58:01 -05:00
if hasattr(request.app.state, 'TOOLS') and tool_id in request.app.state.TOOLS:
2025-09-26 19:01:22 -05:00
return request.app.state.TOOLS[tool_id], None
2026-04-12 14:22:11 -05:00
tool_module, frontmatter = await load_tool_module_by_id(tool_id)
2025-09-26 19:01:22 -05:00
2026-03-17 17:58:01 -05:00
if not hasattr(request.app.state, 'TOOLS'):
2025-09-26 19:01:22 -05:00
request.app.state.TOOLS = {}
2026-03-17 17:58:01 -05:00
if not hasattr(request.app.state, 'TOOL_CONTENTS'):
2025-09-26 19:01:22 -05:00
request.app.state.TOOL_CONTENTS = {}
request.app.state.TOOLS[tool_id] = tool_module
request.app.state.TOOL_CONTENTS[tool_id] = content
return tool_module, frontmatter
2026-04-24 18:48:21 +09:00
async def get_function_module_from_cache(
request, function_id, function: FunctionModel | None = None, load_from_db=True
):
2025-05-27 16:06:00 +04:00
if load_from_db:
2025-05-28 01:42:42 +04:00
# Always load from the database by default
# This is useful for hooks like "inlet" or "outlet" where the content might change
# and we want to ensure the latest content is used.
2026-04-24 18:20:10 +09:00
if function is None:
function = await Functions.get_function_by_id(function_id)
2025-05-27 16:06:00 +04:00
if not function:
2026-03-17 17:58:01 -05:00
raise Exception(f'Function not found: {function_id}')
2025-05-27 16:06:00 +04:00
content = function.content
new_content = replace_imports(content)
if new_content != content:
content = new_content
# Update the function content in the database
2026-04-12 14:22:11 -05:00
await Functions.update_function_by_id(function_id, {'content': content})
2025-05-27 16:06:00 +04:00
if (
2026-03-17 17:58:01 -05:00
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):
2025-05-27 16:06:00 +04:00
if request.app.state.FUNCTION_CONTENTS[function_id] == content:
return request.app.state.FUNCTIONS[function_id], None, None
2026-04-12 14:22:11 -05:00
function_module, function_type, frontmatter = await load_function_module_by_id(function_id, content)
2025-05-27 16:06:00 +04:00
else:
# Load from cache (e.g. "stream" hook)
2025-05-28 01:42:42 +04:00
# This is useful for performance reasons
2026-03-17 17:58:01 -05:00
if hasattr(request.app.state, 'FUNCTIONS') and function_id in request.app.state.FUNCTIONS:
2025-05-28 01:38:24 +04:00
return request.app.state.FUNCTIONS[function_id], None, None
2025-05-27 16:06:00 +04:00
2026-04-12 14:22:11 -05:00
function_module, function_type, frontmatter = await load_function_module_by_id(function_id)
2026-03-17 17:58:01 -05:00
if not hasattr(request.app.state, 'FUNCTIONS'):
request.app.state.FUNCTIONS = {}
2026-03-17 17:58:01 -05:00
if not hasattr(request.app.state, 'FUNCTION_CONTENTS'):
2025-05-27 14:39:35 +04:00
request.app.state.FUNCTION_CONTENTS = {}
request.app.state.FUNCTIONS[function_id] = function_module
2025-05-27 14:39:35 +04:00
request.app.state.FUNCTION_CONTENTS[function_id] = content
return function_module, function_type, frontmatter
2026-05-09 05:24:50 +09:00
_installed_requirements = set()
def install_frontmatter_requirements(requirements: str):
2026-05-09 05:24:50 +09:00
global _installed_requirements
2026-02-19 14:14:36 -06:00
if not ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS:
2026-03-17 17:58:01 -05:00
log.info('ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS is disabled, skipping installation of requirements.')
2026-02-19 14:14:36 -06:00
return
2026-01-03 18:43:12 +04:00
if OFFLINE_MODE:
2026-03-17 17:58:01 -05:00
log.info('Offline mode enabled, skipping installation of requirements.')
2026-01-03 18:43:12 +04:00
return
2024-08-08 09:46:14 +02:00
if requirements:
try:
2026-03-17 17:58:01 -05:00
req_list = [req.strip() for req in requirements.split(',')]
2026-05-09 05:24:50 +09:00
new_reqs = [req for req in req_list if req and req not in _installed_requirements]
2026-05-09 15:25:27 +09:00
2026-05-09 05:24:50 +09:00
if not new_reqs:
return
log.info(f'Installing requirements: {" ".join(new_reqs)}')
2025-03-18 06:39:42 -07:00
subprocess.check_call(
2026-05-09 05:24:50 +09:00
[sys.executable, '-m', 'pip', 'install'] + PIP_OPTIONS + new_reqs + PIP_PACKAGE_INDEX_OPTIONS
2025-03-18 06:39:42 -07:00
)
2026-05-09 05:24:50 +09:00
_installed_requirements.update(new_reqs)
except Exception as e:
2026-05-09 05:24:50 +09:00
log.error(f'Error installing packages: {" ".join(new_reqs)}')
raise e
2024-08-08 09:46:14 +02:00
else:
2026-03-17 17:58:01 -05:00
log.info('No requirements found in frontmatter.')
2026-04-12 14:22:11 -05:00
async def install_tool_and_function_dependencies():
"""
Install all dependencies for all admin tools and active functions.
By first collecting all dependencies from the frontmatter of each tool and function,
and then installing them using pip. Duplicates or similar version specifications are
handled by pip as much as possible.
"""
2026-04-12 14:22:11 -05:00
function_list = await Functions.get_functions(active_only=True)
tool_list = await Tools.get_tools()
2026-03-17 17:58:01 -05:00
all_dependencies = ''
try:
for function in function_list:
frontmatter = extract_frontmatter(replace_imports(function.content))
2026-03-17 17:58:01 -05:00
if dependencies := frontmatter.get('requirements'):
all_dependencies += f'{dependencies}, '
for tool in tool_list:
# Only install requirements for admin tools
2026-03-17 17:58:01 -05:00
if tool.user and tool.user.role == 'admin':
frontmatter = extract_frontmatter(replace_imports(tool.content))
2026-03-17 17:58:01 -05:00
if dependencies := frontmatter.get('requirements'):
all_dependencies += f'{dependencies}, '
2026-03-17 17:58:01 -05:00
install_frontmatter_requirements(all_dependencies.strip(', '))
except Exception as e:
2026-03-17 17:58:01 -05:00
log.error(f'Error installing requirements: {e}')