Coverage for open_webui/utils/tools.py: 13%
877 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1from __future__ import annotations
3import asyncio
4import base64
5import copy
6import inspect
7import logging
8import os
9import re
10from functools import cache, partial, update_wrapper
11from typing import (
12 Any,
13 Awaitable,
14 Callable,
15 Optional,
16 Type,
17 get_args,
18 get_type_hints,
19)
20from urllib.parse import quote, urlencode
22import aiohttp
23import yaml
24from fastapi import Request
25from langchain_core.utils.function_calling import (
26 convert_to_openai_function as convert_pydantic_model_to_openai_function_spec,
27)
28from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
29from open_webui.env import (
30 AIOHTTP_CLIENT_ALLOW_REDIRECTS,
31 AIOHTTP_CLIENT_SESSION_SSL,
32 AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
33 AIOHTTP_CLIENT_TIMEOUT,
34 AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER,
35 AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
36 ENABLE_FORWARD_USER_INFO_HEADERS,
37 ENABLE_PLUGINS,
38 FORWARD_SESSION_INFO_HEADER_CHAT_ID,
39 FORWARD_SESSION_INFO_HEADER_MESSAGE_ID,
40 REDIS_KEY_PREFIX,
41)
42from open_webui.models.access_grants import AccessGrants
43from open_webui.models.config import Config
44from open_webui.models.groups import Groups
45from open_webui.models.tools import Tools
46from open_webui.models.users import UserModel
47from open_webui.tools.builtin import (
48 add_memory,
49 ask_user,
50 calculate_timestamp,
51 create_automation,
52 create_calendar_event,
53 create_tasks,
54 delegate_task,
55 delete_automation,
56 delete_calendar_event,
57 delete_memory,
58 edit_image,
59 execute_code,
60 fetch_url,
61 generate_image,
62 get_current_timestamp,
63 grep_chat_files,
64 grep_knowledge_files,
65 kb_exec,
66 list_automations,
67 list_chat_files,
68 list_knowledge,
69 list_knowledge_bases,
70 list_memories,
71 list_memory_paths,
72 notify,
73 query_chat_files,
74 query_knowledge_bases,
75 query_knowledge_files,
76 read_memory_path,
77 replace_memory_content,
78 replace_note_content,
79 search_calendar_events,
80 search_channel_messages,
81 search_channels,
82 search_chats,
83 search_knowledge_bases,
84 search_knowledge_files,
85 search_memories,
86 search_notes,
87 search_web,
88 timer,
89 toggle_automation,
90 update_automation,
91 update_calendar_event,
92 update_memory,
93 update_task,
94 view_channel_message,
95 view_channel_thread,
96 view_chat,
97 view_file,
98 view_knowledge_file,
99 view_note,
100 view_skill,
101 write_note,
102)
103from open_webui.utils.access_control import has_access, has_connection_access, has_permission
104from open_webui.utils.chat_id import is_saved_chat_id
105from open_webui.utils.headers import (
106 bearer_auth_header,
107 get_custom_headers,
108 include_user_info_headers,
109 normalize_bearer_token,
110)
111from open_webui.utils.json_codec import JSONCodec
112from open_webui.utils.misc import is_string_allowed
113from open_webui.utils.plugin import get_tool_contents_cache, get_tools_cache, load_tool_module_by_id
114from open_webui.utils.terminals import (
115 TERMINAL_CONTEXT_HEADER,
116 get_terminal_server_url,
117 terminal_context_available,
118 terminal_context_config,
119 terminal_context_id,
120)
121from pydantic import BaseModel, Field, create_model
122from pydantic.fields import FieldInfo
124log = logging.getLogger(__name__)
127async def build_tool_server_headers(
128 connection: dict,
129 request,
130 user,
131 server_id: str = '',
132 metadata: dict | None = None,
133 extra_params: dict | None = None,
134) -> tuple[dict, dict]:
135 """Build auth headers and cookies for a tool server connection.
137 Handles bearer, session, system_oauth, and oauth_2.1 auth types plus
138 custom header interpolation and user-info forwarding.
139 Shared by MCP and OpenAPI paths.
141 Returns (headers, cookies).
142 """
143 extra_params = extra_params or {}
144 metadata = metadata or {}
146 auth_type = connection.get('auth_type', 'bearer')
147 headers = {}
148 cookies = getattr(request, 'cookies', {}) if connection.get('forward_cookies', False) else {}
150 if auth_type == 'bearer':
151 headers.update(bearer_auth_header(connection.get('key', '')))
152 elif auth_type == 'session':
153 headers.update(bearer_auth_header(request.state.token.credentials))
154 elif auth_type == 'system_oauth':
155 oauth_token = extra_params.get('__oauth_token__', None)
156 if oauth_token:
157 headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
158 elif auth_type in ('oauth_2.1', 'oauth_2.1_static'):
159 try:
160 splits = server_id.split(':')
161 oauth_server_id = splits[-1] if len(splits) > 1 else server_id
162 connection_type = connection.get('type', 'openapi')
163 oauth_token = await request.app.state.oauth_client_manager.get_oauth_token(
164 user.id, f'{connection_type}:{oauth_server_id}'
165 )
166 if oauth_token:
167 headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
168 except Exception as e:
169 log.error(f'Error getting OAuth token: {e}')
171 # Interpolate template vars in custom connection headers
172 connection_headers = connection.get('headers', None)
173 if connection_headers and isinstance(connection_headers, dict):
174 headers.update(await get_custom_headers(connection_headers, user, metadata))
176 # Add user info headers if enabled
177 if ENABLE_FORWARD_USER_INFO_HEADERS and user:
178 headers = include_user_info_headers(headers, user)
179 if metadata.get('chat_id'):
180 headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata['chat_id']
181 if metadata.get('message_id'):
182 headers[FORWARD_SESSION_INFO_HEADER_MESSAGE_ID] = metadata['message_id']
184 return headers, cookies
187# Let no function be called without need, and let what
188# it yields justify the cost of running it.
189async def get_async_tool_function_and_apply_extra_params(
190 function: Callable, extra_params: dict, function_introspection=None
191) -> Callable[..., Awaitable]:
192 if function_introspection is None:
193 sig = inspect.signature(function)
194 try:
195 type_hints = get_type_hints(function)
196 except Exception:
197 type_hints = {}
198 else:
199 sig, type_hints = function_introspection
201 def coerce_kwargs(kwargs):
202 for name, value in kwargs.items():
203 if name not in sig.parameters or value is None:
204 continue
206 annotation = type_hints.get(name, sig.parameters[name].annotation)
207 args = set(get_args(annotation))
208 if isinstance(value, str) and (annotation is int or args == {int, type(None)}):
209 kwargs[name] = int(value)
210 elif (
211 isinstance(value, (int, float))
212 and not isinstance(value, bool)
213 and (annotation is str or args == {str, type(None)})
214 ):
215 kwargs[name] = str(value)
216 return kwargs
218 extra_params = {k: v for k, v in extra_params.items() if k in sig.parameters}
219 partial_func = partial(function, **extra_params)
221 # Remove the 'frozen' keyword arguments from the signature
222 # python-genai uses the signature to infer the tool properties for native function calling
223 parameters = []
224 for name, parameter in sig.parameters.items():
225 # Exclude keyword arguments that are frozen
226 if name in extra_params:
227 continue
228 # Keep remaining parameters
229 parameters.append(parameter)
231 new_sig = inspect.Signature(parameters=parameters, return_annotation=sig.return_annotation)
233 if inspect.iscoroutinefunction(function):
234 # wrap the functools.partial as python-genai has trouble with it
235 # https://github.com/googleapis/python-genai/issues/907
236 async def new_function(*args, **kwargs):
237 return await partial_func(*args, **coerce_kwargs(kwargs))
239 else:
240 # Make it a coroutine function when it is not already
241 async def new_function(*args, **kwargs):
242 return partial_func(*args, **coerce_kwargs(kwargs))
244 update_wrapper(new_function, function)
245 new_function.__signature__ = new_sig
247 new_function.__function__ = function # type: ignore
248 new_function.__extra_params__ = extra_params # type: ignore
250 return new_function
253async def get_updated_tool_function(function: Callable, extra_params: dict):
254 # Get the original function and merge updated params
255 __function__ = getattr(function, '__function__', None)
256 __extra_params__ = getattr(function, '__extra_params__', None)
258 if __function__ is not None and __extra_params__ is not None:
259 return await get_async_tool_function_and_apply_extra_params(
260 __function__,
261 {**__extra_params__, **extra_params},
262 )
264 return function
267async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extra_params: dict) -> dict[str, dict]:
268 """Load tools for the given tool_ids, checking access control."""
269 if not ENABLE_PLUGINS:
270 return {}
272 if not tool_ids:
273 return {}
275 tools_dict = {}
277 # Get user's group memberships for access control checks
278 user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
280 # Batch-fetch all DB tools in one query instead of one per tool_id
281 tool_models = await Tools.get_tools_by_ids(tool_ids)
283 for tool_id in tool_ids:
284 tool = tool_models.get(tool_id)
285 if tool:
286 # Check access control for local tools
287 if (
288 not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
289 and tool.user_id != user.id
290 and not await AccessGrants.has_access(
291 user_id=user.id,
292 resource_type='tool',
293 resource_id=tool.id,
294 permission='read',
295 user_group_ids=user_group_ids,
296 )
297 ):
298 log.warning(f'Access denied to tool {tool_id} for user {user.id}')
299 continue
301 tools_cache = get_tools_cache(request)
302 tool_contents_cache = get_tool_contents_cache(request)
303 module = tools_cache.get(tool_id)
304 if module is None or tool_contents_cache.get(tool_id) != tool.content:
305 module, _ = await load_tool_module_by_id(tool_id, content=tool.content)
306 tools_cache[tool_id] = module
307 tool_contents_cache[tool_id] = tool.content
309 __user__ = {
310 **extra_params['__user__'],
311 }
313 # Set valves for the tool
314 if hasattr(module, 'valves') and hasattr(module, 'Valves'):
315 valves = await Tools.get_tool_valves_by_id(tool_id) or {}
316 module.valves = module.Valves(**valves)
317 if hasattr(module, 'UserValves'):
318 __user__['valves'] = module.UserValves( # type: ignore
319 **await Tools.get_user_valves_by_id_and_user_id(tool_id, user.id)
320 )
322 for spec in tool.specs:
323 # TODO: Fix hack for OpenAI API
324 # Some times breaks OpenAI but others don't. Leaving the comment
325 for val in spec.get('parameters', {}).get('properties', {}).values():
326 if val.get('type') == 'str':
327 val['type'] = 'string'
329 # Remove internal reserved parameters (e.g. __id__, __user__)
330 spec['parameters']['properties'] = {
331 key: val for key, val in spec['parameters']['properties'].items() if not key.startswith('__')
332 }
334 # convert to function that takes only model params and inserts custom params
335 function_name = spec['name']
336 tool_function = getattr(module, function_name)
337 callable = await get_async_tool_function_and_apply_extra_params(
338 tool_function,
339 {
340 **extra_params,
341 '__id__': tool_id,
342 '__user__': __user__,
343 },
344 )
346 # TODO: Support Pydantic models as parameters
347 if callable.__doc__ and callable.__doc__.strip() != '':
348 s = re.split(':(param|return)', callable.__doc__, 1)
349 spec['description'] = s[0]
350 else:
351 spec['description'] = function_name
353 tool_dict = {
354 'tool_id': tool_id,
355 'callable': callable,
356 'spec': spec,
357 # Misc info
358 'metadata': {
359 'file_handler': hasattr(module, 'file_handler') and module.file_handler,
360 'citation': hasattr(module, 'citation') and module.citation,
361 },
362 }
364 # Handle function name collisions
365 while function_name in tools_dict:
366 log.warning(f'Tool {function_name} already exists in another tools!')
367 # Prepend tool ID to function name
368 function_name = f'{tool_id}_{function_name}'
370 tools_dict[function_name] = tool_dict
371 else:
372 if tool_id.startswith('server:'):
373 splits = tool_id.split(':')
375 if len(splits) == 2:
376 type = 'openapi'
377 server_id = splits[1]
378 elif len(splits) == 3:
379 type = splits[1]
380 server_id = splits[2]
382 server_id_splits = server_id.split('|')
383 if len(server_id_splits) == 2:
384 server_id = server_id_splits[0]
385 function_names = server_id_splits[1].split(',')
387 if type == 'openapi':
388 tool_server_data = None
389 for server in await get_tool_servers(request):
390 if server['id'] == server_id:
391 tool_server_data = server
392 break
394 if tool_server_data is None:
395 log.warning(f'Tool server data not found for {server_id}')
396 continue
398 tool_server_idx = tool_server_data.get('idx', 0)
399 connections = await Config.get('tool_server.connections', [])
400 if tool_server_idx >= len(connections):
401 log.warning(
402 f'Tool server index {tool_server_idx} out of range '
403 f'(have {len(connections)} connections), skipping server {server_id}'
404 )
405 continue
406 tool_server_connection = connections[tool_server_idx]
408 # Check access control for tool server
409 if not await has_connection_access(user, tool_server_connection, user_group_ids):
410 log.warning(f'Access denied to tool server {server_id} for user {user.id}')
411 continue
413 specs = tool_server_data.get('specs', [])
414 function_name_filter_list = tool_server_connection.get('config', {}).get(
415 'function_name_filter_list', ''
416 )
418 if isinstance(function_name_filter_list, str):
419 function_name_filter_list = function_name_filter_list.split(',')
421 for spec in specs:
422 function_name = spec['name']
423 if function_name_filter_list:
424 if not is_string_allowed(function_name, function_name_filter_list):
425 # Skip this function
426 continue
428 metadata = extra_params.get('__metadata__', {})
429 headers, cookies = await build_tool_server_headers(
430 tool_server_connection,
431 request,
432 user,
433 server_id=server_id,
434 metadata=metadata,
435 extra_params=extra_params,
436 )
437 headers.setdefault('Content-Type', 'application/json')
439 async def make_tool_function(function_name, tool_server_data, headers, cookies):
440 async def tool_function(**kwargs):
441 return await execute_tool_server(
442 url=tool_server_data['url'],
443 headers=headers,
444 cookies=cookies,
445 name=function_name,
446 params=kwargs,
447 server_data=tool_server_data,
448 )
450 return tool_function
452 tool_function = await make_tool_function(function_name, tool_server_data, headers, cookies)
454 callable = await get_async_tool_function_and_apply_extra_params(
455 tool_function,
456 {},
457 )
459 tool_dict = {
460 'tool_id': tool_id,
461 'callable': callable,
462 'spec': clean_openai_tool_schema(spec),
463 # Misc info
464 'type': 'external',
465 }
467 # Handle function name collisions
468 while function_name in tools_dict:
469 log.warning(f'Tool {function_name} already exists in another tools!')
470 # Prepend server ID to function name
471 function_name = f'{server_id}_{function_name}'
473 tools_dict[function_name] = tool_dict
475 else:
476 continue
478 return tools_dict
481def get_attached_knowledge(model: dict, metadata: dict) -> list[dict]:
482 model_meta = model.get('info', {}).get('meta', {})
483 knowledge = []
484 seen = set()
486 for source, items in (
487 ('model', model_meta.get('knowledge') or []),
488 ('folder', metadata.get('folder_knowledge') or []),
489 ):
490 for item in items:
491 if not isinstance(item, dict):
492 continue
493 key = (item.get('type'), item.get('id'))
494 if not all(key) or key in seen:
495 continue
496 knowledge.append({**item, 'source': source})
497 seen.add(key)
499 file_context_enabled = (model_meta.get('capabilities') or {}).get('file_context', True)
500 if not file_context_enabled:
501 for item in metadata.get('files') or []:
502 if not isinstance(item, dict) or item.get('type') not in ('collection', 'note'):
503 continue
504 key = (item.get('type'), item.get('id'))
505 if not all(key) or key in seen:
506 continue
507 knowledge.append(
508 {
509 'type': item.get('type'),
510 'id': item.get('id'),
511 'name': item.get('name'),
512 'source': 'chat',
513 }
514 )
515 seen.add(key)
517 return knowledge
520async def get_builtin_tools(
521 request: Request, extra_params: dict, features: dict = None, model: dict = None, is_note_chat: bool = False
522) -> dict[str, dict]:
523 """
524 Get built-in tools for native function calling.
525 Only returns tools when BOTH the global config is enabled AND the model capability allows it.
526 """
527 tools_dict = {}
528 builtin_functions = []
529 features = features or {}
530 model = model or {}
532 # Helper to get model capabilities (defaults to True if not specified)
533 def get_model_capability(name: str, default: bool = True) -> bool:
534 return (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get(name, default)
536 # Helper to check if a builtin tool category is enabled via meta.builtinTools
537 # Defaults to True if not specified (backward compatible)
538 def is_builtin_tool_enabled(category: str, default: bool = True) -> bool:
539 builtin_tools = model.get('info', {}).get('meta', {}).get('builtinTools', {})
540 return builtin_tools.get(category, default)
542 # Helper to check user-level feature permission (admins always pass)
543 user = extra_params.get('__user__', {})
544 config = await Config.get_many(
545 'memories.enable',
546 'web.search.enable',
547 'image_generation.enable',
548 'images.edit.enable',
549 'code_interpreter.enable',
550 'notes.enable',
551 'channels.enable',
552 'automations.enable',
553 'calendar.enable',
554 'ui.enable_user_webhooks',
555 'subagents.enable',
556 'subagents.background_enabled',
557 )
559 async def has_user_permission(feature_key: str) -> bool:
560 if user.get('role') == 'admin':
561 return True
562 return await has_permission(
563 user.get('id', ''),
564 f'features.{feature_key}',
565 await Config.get('user.permissions'),
566 )
568 async def has_user_chat_permission(permission_key: str) -> bool:
569 if user.get('role') == 'admin':
570 return True
571 return await has_permission(
572 user.get('id', ''),
573 f'chat.{permission_key}',
574 await Config.get('user.permissions'),
575 )
577 # Time utilities - available for date calculations
578 if is_builtin_tool_enabled('time'):
579 builtin_functions.extend([get_current_timestamp, calculate_timestamp])
581 if is_builtin_tool_enabled('user_input', True):
582 builtin_functions.append(ask_user)
584 metadata = extra_params.get('__metadata__') or {}
585 chat_files = metadata.get('files') or extra_params.get('__files__') or []
586 has_chat_files = any(
587 isinstance(item, dict)
588 and item.get('type', 'file') == 'file'
589 and (item.get('id') or item.get('url'))
590 and not str(item.get('id') or item.get('url')).startswith(('http://', 'https://', 'data:'))
591 for item in chat_files
592 )
594 if (
595 is_builtin_tool_enabled('files')
596 and get_model_capability('file_upload')
597 and not get_model_capability('file_context')
598 and has_chat_files
599 and await has_user_chat_permission('file_upload')
600 ):
601 builtin_functions.extend([list_chat_files, query_chat_files, grep_chat_files, view_file])
603 # Knowledge base tools - conditional injection based on model knowledge
604 # If model has attached knowledge (any type), only provide query_knowledge_files
605 # Otherwise, provide all KB browsing tools
606 model_knowledge = get_attached_knowledge(model, metadata)
607 if is_builtin_tool_enabled('knowledge'):
608 from open_webui.env import ENABLE_KB_EXEC
610 if ENABLE_KB_EXEC:
611 builtin_functions.append(kb_exec)
612 builtin_functions.append(query_knowledge_files)
613 # Notes attached to the model need view_note since kb_exec is file-only
614 if model_knowledge:
615 knowledge_types = {item.get('type') for item in model_knowledge}
616 if 'note' in knowledge_types:
617 builtin_functions.append(view_note)
618 if not model_knowledge:
619 builtin_functions.append(query_knowledge_bases)
620 builtin_functions.append(search_knowledge_bases)
621 elif model_knowledge:
622 builtin_functions.extend(
623 [list_knowledge, search_knowledge_files, grep_knowledge_files, query_knowledge_files]
624 )
626 knowledge_types = {item.get('type') for item in model_knowledge}
627 if 'file' in knowledge_types or 'collection' in knowledge_types:
628 builtin_functions.extend([view_file, view_knowledge_file])
629 if 'note' in knowledge_types:
630 builtin_functions.append(view_note)
631 else:
632 builtin_functions.extend(
633 [
634 list_knowledge_bases,
635 search_knowledge_bases,
636 query_knowledge_bases,
637 grep_knowledge_files,
638 search_knowledge_files,
639 query_knowledge_files,
640 view_knowledge_file,
641 ]
642 )
644 # Chats tools - search and fetch user's chat history
645 if is_builtin_tool_enabled('chats'):
646 builtin_functions.extend([search_chats, view_chat])
648 if (
649 is_builtin_tool_enabled('subagents')
650 and config.get('subagents.enable')
651 and getattr(request.state, 'internal', False) is not True
652 and getattr(request.state, 'direct', False) is not True
653 ):
654 builtin_functions.extend([delegate_task, timer])
656 # Add memory tools when memory is enabled and the model allows this builtin category.
657 if (
658 is_builtin_tool_enabled('memory')
659 and config.get('memories.enable')
660 and features.get('memory')
661 and get_model_capability('memory')
662 and await has_user_permission('memories')
663 ):
664 builtin_functions.extend(
665 [
666 search_memories,
667 list_memory_paths,
668 read_memory_path,
669 list_memories,
670 update_memory,
671 add_memory,
672 replace_memory_content,
673 delete_memory,
674 ]
675 )
677 # Add web search tools if builtin category enabled AND enabled globally AND model has web_search capability
678 if (
679 is_builtin_tool_enabled('web_search')
680 and config.get('web.search.enable')
681 and get_model_capability('web_search')
682 and features.get('web_search')
683 and await has_user_permission('web_search')
684 ):
685 builtin_functions.extend([search_web, fetch_url])
687 # Add image generation/edit tools if builtin category enabled,
688 # globally enabled, and allowed by model capability.
689 if (
690 is_builtin_tool_enabled('image_generation')
691 and config.get('image_generation.enable')
692 and get_model_capability('image_generation')
693 and features.get('image_generation')
694 and await has_user_permission('image_generation')
695 ):
696 builtin_functions.append(generate_image)
697 if (
698 is_builtin_tool_enabled('image_generation')
699 and config.get('images.edit.enable')
700 and get_model_capability('image_generation')
701 and features.get('image_generation')
702 and await has_user_permission('image_generation')
703 ):
704 builtin_functions.append(edit_image)
706 # Add code interpreter tool if builtin category enabled,
707 # globally enabled, and allowed by model capability.
708 if (
709 is_builtin_tool_enabled('code_interpreter')
710 and config.get('code_interpreter.enable')
711 and get_model_capability('code_interpreter')
712 and features.get('code_interpreter')
713 and await has_user_permission('code_interpreter')
714 ):
715 builtin_functions.append(execute_code)
717 # Notes tools - search, view, create, and update user's notes
718 if is_note_chat or (
719 is_builtin_tool_enabled('notes') and config.get('notes.enable') and await has_user_permission('notes')
720 ):
721 builtin_functions.extend([search_notes, view_note, write_note, replace_note_content])
723 # Channels tools - search channels and messages
724 if is_builtin_tool_enabled('channels') and config.get('channels.enable') and await has_user_permission('channels'):
725 builtin_functions.extend(
726 [
727 search_channels,
728 search_channel_messages,
729 view_channel_thread,
730 view_channel_message,
731 ]
732 )
734 # Skills tools - view_skill allows model to load full skill instructions on demand
735 if extra_params.get('__skill_ids__'):
736 builtin_functions.append(view_skill)
738 # Task management - break down complex work into trackable steps
739 # Task state is stored on the chats row; local/channel IDs do not have one.
740 if is_builtin_tool_enabled('tasks') and is_saved_chat_id(metadata.get('chat_id')):
741 builtin_functions.extend([create_tasks, update_task])
743 # Automation tools - create and manage scheduled automations from chat
744 if (
745 is_builtin_tool_enabled('automations')
746 and config.get('automations.enable')
747 and await has_user_permission('automations')
748 ):
749 builtin_functions.extend(
750 [create_automation, update_automation, list_automations, toggle_automation, delete_automation]
751 )
753 # Calendar tools - search/create/update/delete events
754 if is_builtin_tool_enabled('calendar') and config.get('calendar.enable') and await has_user_permission('calendar'):
755 builtin_functions.extend(
756 [search_calendar_events, create_calendar_event, update_calendar_event, delete_calendar_event]
757 )
759 if (
760 is_builtin_tool_enabled('notifications')
761 and config.get('ui.enable_user_webhooks')
762 and await has_user_permission('webhooks')
763 ):
764 builtin_functions.append(notify)
766 if getattr(request.state, 'internal', False) is True:
767 from open_webui.utils.subagents import MUTATING_MEMORY_TOOLS
769 builtin_functions = [func for func in builtin_functions if func.__name__ not in MUTATING_MEMORY_TOOLS]
771 for func in builtin_functions:
772 callable = await get_async_tool_function_and_apply_extra_params(
773 func,
774 {
775 '__request__': request,
776 '__user__': extra_params.get('__user__', {}),
777 '__event_emitter__': extra_params.get('__event_emitter__'),
778 '__event_call__': extra_params.get('__event_call__'),
779 '__metadata__': extra_params.get('__metadata__'),
780 '__files__': chat_files,
781 '__chat_id__': extra_params.get('__chat_id__'),
782 '__message_id__': extra_params.get('__message_id__'),
783 '__model_knowledge__': model_knowledge,
784 },
785 get_builtin_function_introspection(func),
786 )
788 spec = get_builtin_tool_spec(func)
789 if func.__name__ == 'delegate_task' and not config.get('subagents.background_enabled'):
790 parameters = spec.get('parameters', {})
791 parameters.get('properties', {}).pop('background', None)
792 if isinstance(parameters.get('required'), list):
793 parameters['required'] = [name for name in parameters['required'] if name != 'background']
795 tools_dict[func.__name__] = {
796 'tool_id': f'builtin:{func.__name__}',
797 'callable': callable,
798 'spec': spec,
799 'type': 'builtin',
800 }
802 return tools_dict
805def parse_description(docstring: str | None) -> str:
806 """
807 Parse a function's docstring to extract the description.
809 Args:
810 docstring (str): The docstring to parse.
812 Returns:
813 str: The description.
814 """
816 if not docstring:
817 return ''
819 lines = [line.strip() for line in docstring.strip().split('\n')]
820 description_lines: list[str] = []
822 for line in lines:
823 if re.match(r':param', line) or re.match(r':return', line):
824 break
826 description_lines.append(line)
828 return '\n'.join(description_lines)
831def parse_docstring(docstring):
832 """
833 Parse a function's docstring to extract parameter descriptions in reST format.
835 Args:
836 docstring (str): The docstring to parse.
838 Returns:
839 dict: A dictionary where keys are parameter names and values are descriptions.
840 """
841 if not docstring:
842 return {}
844 # Regex to match `:param name: description` format
845 param_pattern = re.compile(r':param (\w+):\s*(.*)')
846 param_descriptions = {}
847 current_param = None
849 for line in docstring.splitlines():
850 line = line.strip()
851 match = param_pattern.match(line)
852 if match:
853 param_name, param_description = match.groups()
854 current_param = None if param_name.startswith('__') else param_name
855 if current_param:
856 param_descriptions[current_param] = param_description
857 continue
859 if line.startswith(':'):
860 current_param = None
861 continue
863 if current_param and line:
864 param_descriptions[current_param] = '\n'.join(filter(None, [param_descriptions[current_param], line]))
866 return param_descriptions
869def convert_function_to_pydantic_model(func: Callable, function_introspection=None) -> type[BaseModel]:
870 """
871 Converts a Python function's type hints and docstring to a Pydantic model,
872 including support for nested types, default values, and descriptions.
874 Args:
875 func: The function whose type hints and docstring should be converted.
876 model_name: The name of the generated Pydantic model.
878 Returns:
879 A Pydantic model class.
880 """
881 if function_introspection is None:
882 type_hints = get_type_hints(func)
883 signature = inspect.signature(func)
884 else:
885 signature, type_hints = function_introspection
886 parameters = signature.parameters
888 docstring = func.__doc__
890 function_description = parse_description(docstring)
891 function_param_descriptions = parse_docstring(docstring)
893 field_defs = {}
894 for name, param in parameters.items():
895 type_hint = type_hints.get(name, Any)
896 default_value = param.default if param.default is not param.empty else ...
898 param_description = function_param_descriptions.get(name, None)
900 if param_description:
901 field_defs[name] = (
902 type_hint,
903 Field(default_value, description=param_description),
904 )
905 else:
906 field_defs[name] = type_hint, default_value
908 model = create_model(func.__name__, **field_defs)
909 model.__doc__ = function_description
911 return model
914def clean_properties(schema: dict):
915 if not isinstance(schema, dict):
916 return
918 if 'anyOf' in schema:
919 non_null_types = [t for t in schema['anyOf'] if t.get('type') != 'null']
920 if len(non_null_types) == 1:
921 schema.update(non_null_types[0])
922 del schema['anyOf']
923 else:
924 schema['anyOf'] = non_null_types
926 if 'default' in schema and schema['default'] is None:
927 del schema['default']
929 # fix missing type
930 if 'type' not in schema and 'anyOf' not in schema and 'properties' not in schema:
931 schema['type'] = 'string'
933 if 'properties' in schema:
934 for prop_name, prop_schema in schema['properties'].items():
935 clean_properties(prop_schema)
937 if 'items' in schema:
938 clean_properties(schema['items'])
941def clean_openai_tool_schema(spec: dict) -> dict:
943 cleaned_spec = copy.deepcopy(spec)
945 if 'parameters' in cleaned_spec:
946 clean_properties(cleaned_spec['parameters'])
948 return cleaned_spec
951def add_terminal_display_file_inline_param(spec: dict) -> dict:
952 spec = copy.deepcopy(spec)
953 if spec.get('name') != 'display_file':
954 return spec
956 spec['description'] = (
957 f'{spec.get("description", "")} '
958 'Set inline=true when the file should be shown inline in the chat message instead of opening the file viewer. '
959 'Set page for PDF, DOCX, and PPTX files when you want the preview to open at a specific 1-based page or slide. '
960 'After display_file succeeds, do not display the same file again or emit Markdown for it.'
961 ).strip()
962 parameters = spec.setdefault('parameters', {'type': 'object', 'properties': {}, 'required': []})
963 parameters.setdefault('type', 'object')
964 properties = parameters.setdefault('properties', {})
965 properties['inline'] = {
966 'type': 'boolean',
967 'description': 'Show the file inline in the chat message instead of opening the file viewer.',
968 }
969 properties['page'] = {
970 'type': 'integer',
971 'minimum': 1,
972 'description': 'For PDF, DOCX, and PPTX files, open the preview at this 1-based page or slide number.',
973 }
974 return spec
977@cache
978def get_builtin_function_introspection(func: Callable):
979 try:
980 type_hints = get_type_hints(func)
981 except Exception:
982 type_hints = {}
983 return inspect.signature(func), type_hints
986@cache
987def build_builtin_tool_spec_json(func: Callable) -> str:
988 pydantic_model = convert_function_to_pydantic_model(func, get_builtin_function_introspection(func))
989 spec = convert_pydantic_model_to_openai_function_spec(pydantic_model)
990 return JSONCodec.dumps(clean_openai_tool_schema(spec))
993def get_builtin_tool_spec(func: Callable) -> dict:
994 # callers mutate the spec, so parse a fresh copy out of the cached JSON
995 return JSONCodec.loads(build_builtin_tool_spec_json(func))
998def get_functions_from_tool(tool: object) -> list[Callable]:
999 return [
1000 getattr(tool, func)
1001 for func in dir(tool)
1002 if callable(getattr(tool, func)) # checks if the attribute is callable (a method or function).
1003 and not func.startswith('_') # filters out internal methods (starting with _) and special (dunder) methods.
1004 and not inspect.isclass(
1005 getattr(tool, func)
1006 ) # ensures that the callable is not a class itself, just a method or function.
1007 ]
1010def get_tool_specs(tool_module: object) -> list[dict]:
1011 function_models = map(convert_function_to_pydantic_model, get_functions_from_tool(tool_module))
1013 specs = [
1014 clean_openai_tool_schema(convert_pydantic_model_to_openai_function_spec(function_model))
1015 for function_model in function_models
1016 ]
1018 return specs
1021# Valid HTTP methods per OpenAPI 3.x – used to skip extension keys (x-*)
1022# and non-operation path-item fields (summary, description, servers, parameters).
1023OPENAPI_HTTP_METHODS = {'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'}
1026def resolve_schema(schema, components, resolved_schemas=None):
1027 """
1028 Recursively resolves a JSON schema using OpenAPI components.
1029 """
1030 if not schema:
1031 return {}
1033 if resolved_schemas is None:
1034 resolved_schemas = set()
1036 if '$ref' in schema:
1037 ref_path = schema['$ref']
1038 schema_name = ref_path.split('/')[-1]
1040 if schema_name in resolved_schemas:
1041 # Avoid infinite recursion on circular references
1042 return {}
1044 ref_parts = ref_path.strip('#/').split('/')
1045 resolved = components
1046 for part in ref_parts[1:]: # Skip the initial 'components'
1047 resolved = resolved.get(part, {})
1048 # Per-path visited set so sibling refs to the same schema still resolve
1049 return resolve_schema(resolved, components, resolved_schemas | {schema_name})
1051 resolved_schema = copy.deepcopy(schema)
1053 # Recursively resolve inner schemas
1054 if 'properties' in resolved_schema:
1055 for prop, prop_schema in resolved_schema['properties'].items():
1056 resolved_schema['properties'][prop] = resolve_schema(prop_schema, components, resolved_schemas)
1058 if 'items' in resolved_schema:
1059 resolved_schema['items'] = resolve_schema(resolved_schema['items'], components, resolved_schemas)
1061 # Resolve composition keywords (oneOf, anyOf, allOf) which may contain $ref
1062 for keyword in ('oneOf', 'anyOf', 'allOf'):
1063 if keyword in resolved_schema and isinstance(resolved_schema[keyword], list):
1064 resolved_schema[keyword] = [
1065 resolve_schema(inner, components, resolved_schemas) for inner in resolved_schema[keyword]
1066 ]
1068 return resolved_schema
1071def convert_openapi_to_tool_payload(openapi_spec):
1072 """
1073 Converts an OpenAPI specification into a custom tool payload structure.
1075 Args:
1076 openapi_spec (dict): The OpenAPI specification as a Python dict.
1078 Returns:
1079 list: A list of tool payloads.
1080 """
1081 tool_payload = []
1083 for path, methods in openapi_spec.get('paths', {}).items():
1084 if not isinstance(methods, dict):
1085 continue
1087 # Path-level parameters apply to all operations under this path
1088 # unless overridden at the operation level (matched by name + in).
1089 path_level_params = methods.get('parameters', [])
1090 if not isinstance(path_level_params, list):
1091 path_level_params = []
1093 for method, operation in methods.items():
1094 if method not in OPENAPI_HTTP_METHODS:
1095 continue
1096 if not isinstance(operation, dict):
1097 continue
1098 if operation.get('operationId'):
1099 tool = {
1100 'name': operation.get('operationId'),
1101 'description': operation.get(
1102 'description',
1103 operation.get('summary', 'No description available.'),
1104 ),
1105 'parameters': {'type': 'object', 'properties': {}, 'required': []},
1106 }
1108 # Merge path-level and operation-level parameters.
1109 # Operation-level params override path-level params with the
1110 # same (name, in) pair per the OpenAPI spec.
1111 op_params = operation.get('parameters', [])
1112 if not isinstance(op_params, list):
1113 op_params = []
1114 merged_params = {}
1115 for param in path_level_params:
1116 if isinstance(param, dict) and param.get('name'):
1117 merged_params[(param['name'], param.get('in', ''))] = param
1118 for param in op_params:
1119 if isinstance(param, dict) and param.get('name'):
1120 merged_params[(param['name'], param.get('in', ''))] = param
1122 for param in merged_params.values():
1123 param_name = param.get('name')
1124 if not param_name:
1125 continue
1126 param_schema = param.get('schema', {})
1127 description = param_schema.get('description', '')
1128 if not description:
1129 description = param.get('description') or ''
1130 if param_schema.get('enum') and isinstance(param_schema.get('enum'), list):
1131 description += f'. Possible values: {", ".join(str(v) for v in param_schema.get("enum"))}'
1132 param_property = {
1133 'type': param_schema.get('type') or 'string',
1134 'description': description,
1135 }
1137 # Include items property for array types (required by OpenAI)
1138 if param_schema.get('type') == 'array' and 'items' in param_schema:
1139 param_property['items'] = param_schema['items']
1141 # Filter out None values to prevent schema validation errors
1142 param_property = {k: v for k, v in param_property.items() if v is not None}
1144 tool['parameters']['properties'][param_name] = param_property
1145 if param.get('required'):
1146 tool['parameters']['required'].append(param_name)
1148 # Extract and resolve requestBody if available
1149 request_body = operation.get('requestBody')
1150 if request_body:
1151 content = request_body.get('content', {})
1152 json_schema = content.get('application/json', {}).get('schema')
1153 if json_schema:
1154 resolved_schema = resolve_schema(json_schema, openapi_spec.get('components', {}))
1156 if resolved_schema.get('properties'):
1157 tool['parameters']['properties'].update(resolved_schema['properties'])
1158 if 'required' in resolved_schema:
1159 tool['parameters']['required'] = list(
1160 set(tool['parameters']['required'] + resolved_schema['required'])
1161 )
1162 elif resolved_schema.get('type') == 'array':
1163 tool['parameters'] = resolved_schema # special case for array
1165 tool_payload.append(tool)
1167 return tool_payload
1170async def set_tool_servers(request: Request):
1171 try:
1172 request.app.state.TOOL_SERVERS = await get_tool_servers_data(await Config.get('tool_server.connections', []))
1173 except Exception as e:
1174 log.error(f'Error fetching tool server data: {e}')
1175 request.app.state.TOOL_SERVERS = getattr(request.app.state, 'TOOL_SERVERS', None) or []
1177 try:
1178 if request.app.state.redis is not None: 1178 ↛ 1179line 1178 didn't jump to line 1179 because the condition on line 1178 was never true
1179 await request.app.state.redis.set(
1180 f'{REDIS_KEY_PREFIX}:tool_servers', JSONCodec.dumps(request.app.state.TOOL_SERVERS)
1181 )
1182 except Exception as e:
1183 log.error(f'Error caching tool_servers to Redis: {e}')
1185 return request.app.state.TOOL_SERVERS
1188async def get_tool_servers(request: Request):
1189 try:
1190 tool_servers = None
1191 if request.app.state.redis is not None: 1191 ↛ 1192line 1191 didn't jump to line 1192 because the condition on line 1191 was never true
1192 try:
1193 data = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:tool_servers')
1194 if data is not None:
1195 tool_servers = JSONCodec.loads(data)
1196 request.app.state.TOOL_SERVERS = tool_servers
1197 except Exception as e:
1198 log.error(f'Error fetching tool_servers from Redis: {e}')
1200 if tool_servers is None: 1200 ↛ 1203line 1200 didn't jump to line 1203 because the condition on line 1200 was always true
1201 tool_servers = await set_tool_servers(request)
1203 return tool_servers
1204 except Exception as e:
1205 log.error(f'Failed to load tool servers, skipping: {e}')
1206 return getattr(request.app.state, 'TOOL_SERVERS', None) or []
1209async def get_terminal_cwd(
1210 base_url: str,
1211 headers: dict,
1212 cookies: dict | None = None,
1213) -> str | None:
1214 """Fetch the current working directory from a terminal server."""
1215 try:
1216 cwd_url = f'{base_url.rstrip("/")}/files/cwd'
1217 async with aiohttp.ClientSession(
1218 timeout=aiohttp.ClientTimeout(total=5),
1219 trust_env=True,
1220 ) as session:
1221 async with session.get(
1222 cwd_url, headers=headers, cookies=cookies or {}, ssl=AIOHTTP_CLIENT_SESSION_SSL
1223 ) as resp:
1224 if resp.status == 200:
1225 data = await resp.json()
1226 return data.get('cwd')
1227 except Exception as e:
1228 log.debug('Failed to fetch terminal CWD: %s', e)
1229 return None
1232async def get_terminal_system_prompt(
1233 base_url: str,
1234 headers: dict,
1235 cookies: dict | None = None,
1236) -> str | None:
1237 """Fetch the system prompt from a terminal server.
1239 Checks ``/api/config`` for the ``system`` feature flag first;
1240 only fetches ``/system`` if the flag is present. Returns *None*
1241 silently when the server doesn't support the endpoint.
1242 """
1243 base = base_url.rstrip('/')
1244 try:
1245 async with aiohttp.ClientSession(
1246 timeout=aiohttp.ClientTimeout(total=3),
1247 trust_env=True,
1248 ) as session:
1249 # 1. Check feature flag
1250 async with session.get(
1251 f'{base}/api/config', headers=headers, cookies=cookies or {}, ssl=AIOHTTP_CLIENT_SESSION_SSL
1252 ) as resp:
1253 if resp.status != 200:
1254 return None
1255 config = await resp.json()
1256 if not config.get('features', {}).get('system'):
1257 return None
1259 # 2. Fetch system prompt
1260 async with session.get(
1261 f'{base}/system', headers=headers, cookies=cookies or {}, ssl=AIOHTTP_CLIENT_SESSION_SSL
1262 ) as resp:
1263 if resp.status == 200:
1264 data = await resp.json()
1265 return data.get('prompt')
1266 except Exception as e:
1267 log.debug('Failed to fetch terminal system prompt: %s', e)
1268 return None
1271async def set_terminal_servers(request: Request):
1272 """Load and cache OpenAPI specs from all TERMINAL_SERVER_CONNECTIONS."""
1273 connections = await Config.get('terminal_server.connections', []) or []
1275 # Build server configs compatible with get_tool_servers_data
1276 # Terminal connections store id/name at top level; translate to info dict
1277 server_configs = []
1278 for connection in connections:
1279 if not connection.get('url'):
1280 continue
1282 enabled = connection.get('enabled', True)
1284 base_url = get_terminal_server_url(connection)
1286 server_configs.append(
1287 {
1288 'url': base_url,
1289 'key': normalize_bearer_token(connection.get('key', '')),
1290 'auth_type': connection.get('auth_type', 'bearer'),
1291 'path': connection.get('path', '/openapi.json'),
1292 'spec_type': 'url',
1293 # get_tool_servers_data reads config.enable to filter active servers
1294 'config': {'enable': enabled},
1295 'info': {
1296 'id': connection.get('id', ''),
1297 'name': connection.get('name', ''),
1298 },
1299 }
1300 )
1302 request.app.state.TERMINAL_SERVERS = await get_tool_servers_data(server_configs)
1304 # Fetch system prompts concurrently (runs at cache time, not per-request)
1305 connections_by_id = {c.get('id'): c for c in connections if c.get('id')}
1307 async def _fetch_system_prompt(server):
1308 connection = connections_by_id.get(server.get('id'))
1309 if not connection:
1310 return
1311 headers = {}
1312 if connection.get('auth_type', 'bearer') == 'bearer':
1313 headers.update(bearer_auth_header(connection.get('key', '')))
1314 if connection.get('policy_id'):
1315 headers['X-User-Id'] = 'system'
1316 prompt = await get_terminal_system_prompt(server['url'], headers)
1317 if prompt:
1318 server['system_prompt'] = prompt
1320 await asyncio.gather(
1321 *[_fetch_system_prompt(s) for s in request.app.state.TERMINAL_SERVERS],
1322 return_exceptions=True,
1323 )
1325 if request.app.state.redis is not None: 1325 ↛ 1326line 1325 didn't jump to line 1326 because the condition on line 1325 was never true
1326 await request.app.state.redis.set(
1327 f'{REDIS_KEY_PREFIX}:terminal_servers', JSONCodec.dumps(request.app.state.TERMINAL_SERVERS)
1328 )
1330 return request.app.state.TERMINAL_SERVERS
1333async def get_terminal_servers(request: Request):
1334 """Return cached terminal server specs, loading if needed."""
1335 terminal_servers = None
1336 if request.app.state.redis is not None:
1337 try:
1338 data = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:terminal_servers')
1339 if data is not None:
1340 terminal_servers = JSONCodec.loads(data)
1341 connections = await Config.get('terminal_server.connections', []) or []
1342 if terminal_servers or not any(
1343 connection.get('url') and connection.get('enabled', True) for connection in connections
1344 ):
1345 request.app.state.TERMINAL_SERVERS = terminal_servers
1346 else:
1347 terminal_servers = None
1348 except Exception as e:
1349 log.error(f'Error fetching terminal_servers from Redis: {e}')
1351 if terminal_servers is None:
1352 terminal_servers = await set_terminal_servers(request)
1354 return terminal_servers
1357async def get_terminal_tools(
1358 request: Request,
1359 terminal_id: str,
1360 user: UserModel,
1361 extra_params: dict,
1362) -> dict[str, dict] | tuple[dict[str, dict], str | None]:
1363 """Resolve tools for a terminal server identified by terminal_id.
1365 - Finds the connection in TERMINAL_SERVER_CONNECTIONS
1366 - Checks access_grants
1367 - Loads specs from cache
1368 - Builds callables that route through the terminal proxy
1369 """
1370 connections = await Config.get('terminal_server.connections', []) or []
1371 connection = next(
1372 (terminal_connection for terminal_connection in connections if terminal_connection.get('id') == terminal_id),
1373 None,
1374 )
1375 if connection is None:
1376 raise RuntimeError(f"Terminal server '{terminal_id}' not found")
1377 if not connection.get('enabled', True):
1378 raise RuntimeError(f"Terminal server '{terminal_id}' is disabled")
1380 user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
1381 if not await has_connection_access(user, connection, user_group_ids):
1382 raise RuntimeError(f'Access denied to terminal {terminal_id}')
1384 # Find the cached spec data for this terminal
1385 terminal_servers = await get_terminal_servers(request)
1386 server_data = next((server for server in terminal_servers if server.get('id') == terminal_id), None)
1387 if server_data is None:
1388 raise RuntimeError(f"Terminal server '{terminal_id}' is unavailable")
1390 specs = server_data.get('specs', [])
1391 if not specs:
1392 raise RuntimeError(f"Terminal server '{terminal_id}' has no available tools")
1394 # Build auth headers
1395 auth_type = connection.get('auth_type', 'bearer')
1396 cookies = getattr(request, 'cookies', {}) if connection.get('forward_cookies', False) else {}
1397 headers = {'Content-Type': 'application/json', 'X-User-Id': user.id}
1399 if auth_type == 'bearer':
1400 headers.update(bearer_auth_header(connection.get('key', '')))
1401 elif auth_type == 'session':
1402 headers.update(bearer_auth_header(request.state.token.credentials))
1403 elif auth_type == 'system_oauth':
1404 oauth_token = extra_params.get('__oauth_token__', None)
1405 if oauth_token:
1406 headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
1407 # auth_type == "none": no Authorization header
1409 # Use chat_id as the per-session key for cwd tracking
1410 metadata = extra_params.get('__metadata__', {})
1411 terminal_context = 'automation' if metadata.get('automation_id') else 'chat'
1412 if not terminal_context_available(connection, terminal_context):
1413 raise RuntimeError(f"Terminal server '{terminal_id}' is not available for {terminal_context}")
1415 session_id = metadata.get('chat_id')
1416 if session_id:
1417 headers['X-Session-Id'] = session_id
1419 context_id = terminal_context_id(connection, metadata, terminal_context)
1420 config = terminal_context_config(connection, terminal_context)
1421 if isinstance(config, dict) and config.get('context_id') in {'chat_id', 'automation_id'} and not context_id:
1422 raise RuntimeError(f"Terminal server '{terminal_id}' requires a saved {terminal_context} context")
1423 if context_id:
1424 headers[TERMINAL_CONTEXT_HEADER] = context_id
1426 # Fetch live with the user's credentials so prompt changes apply without a restart
1427 terminal_cwd, system_prompt = await asyncio.gather(
1428 get_terminal_cwd(server_data['url'], headers, cookies),
1429 get_terminal_system_prompt(server_data['url'], headers, cookies),
1430 )
1431 if not system_prompt:
1432 system_prompt = server_data.get('system_prompt')
1434 tools_dict = {}
1435 for spec in specs:
1436 function_name = spec['name']
1437 tool_spec = clean_openai_tool_schema(add_terminal_display_file_inline_param(spec))
1439 if function_name == 'run_command' and terminal_cwd:
1440 tool_spec['description'] = (
1441 tool_spec.get('description', '') + f'\n\nThe current working directory is: {terminal_cwd}'
1442 )
1444 async def make_tool_function(fn_name, srv_data, hdrs, cks):
1445 async def tool_function(**kwargs):
1446 params = dict(kwargs)
1447 if fn_name == 'display_file':
1448 params.pop('page', None)
1449 return await execute_tool_server(
1450 url=srv_data['url'],
1451 headers=hdrs,
1452 cookies=cks,
1453 name=fn_name,
1454 params=params,
1455 server_data=srv_data,
1456 )
1458 return tool_function
1460 tool_function = await make_tool_function(function_name, server_data, headers, cookies)
1461 callable = await get_async_tool_function_and_apply_extra_params(tool_function, {})
1463 tools_dict[function_name] = {
1464 'tool_id': f'terminal:{terminal_id}',
1465 'callable': callable,
1466 'spec': tool_spec,
1467 'type': 'terminal',
1468 }
1470 return tools_dict, system_prompt
1473async def get_tool_server_data(url: str, headers: dict | None) -> dict[str, Any]:
1474 _headers = {
1475 'Accept': 'application/json',
1476 'Content-Type': 'application/json',
1477 }
1479 if headers:
1480 _headers.update(headers)
1482 error = None
1483 try:
1484 timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA)
1485 async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
1486 async with session.get(url, headers=_headers, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL) as response:
1487 if response.status != 200:
1488 error_body = await response.json()
1489 raise Exception(error_body)
1491 text_content = await response.text()
1493 # Check if URL ends with .yaml or .yml to determine format
1494 if url.lower().endswith(('.yaml', '.yml')):
1495 res = yaml.safe_load(text_content)
1496 else:
1497 try:
1498 res = JSONCodec.loads(text_content)
1499 except JSONCodec.JSONDecodeError:
1500 # Fall back to YAML for non-.yml URLs that aren't valid JSON
1501 res = yaml.safe_load(text_content)
1503 except (aiohttp.ClientConnectionError, TimeoutError) as err:
1504 error = str(err) or type(err).__name__
1505 log.error(f'Could not fetch tool server spec from {url}: {error}')
1506 raise Exception(error)
1507 except Exception as err:
1508 log.exception(f'Could not fetch tool server spec from {url}')
1509 if isinstance(err, dict) and 'detail' in err: 1509 ↛ 1510line 1509 didn't jump to line 1510 because the condition on line 1509 was never true
1510 error = err['detail']
1511 else:
1512 error = str(err)
1513 raise Exception(error)
1515 log.debug('Fetched data: %s', res)
1516 return res
1519async def get_tool_servers_data(servers: list[dict[str, Any]]) -> list[dict[str, Any]]:
1520 # Prepare list of enabled servers along with their original index
1522 tasks = []
1523 server_entries = []
1524 for idx, server in enumerate(servers):
1525 if server.get('config', {}).get('enable') and server.get('type', 'openapi') == 'openapi':
1526 info = server.get('info', {})
1528 auth_type = server.get('auth_type', 'bearer')
1529 token = None
1531 if auth_type == 'bearer':
1532 token = server.get('key', '')
1533 elif auth_type == 'none': 1533 ↛ 1535line 1533 didn't jump to line 1535 because the condition on line 1533 was never true
1534 # No authentication
1535 pass
1537 id = info.get('id')
1538 if not id:
1539 id = str(idx)
1541 server_url = server.get('url')
1542 spec_type = server.get('spec_type', 'url')
1544 # Create async tasks to fetch data
1545 task = None
1546 if spec_type == 'url': 1546 ↛ 1555line 1546 didn't jump to line 1555 because the condition on line 1546 was always true
1547 # Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
1548 openapi_path = server.get('path', 'openapi.json')
1549 spec_url = get_tool_server_url(server_url, openapi_path)
1550 # Fetch from URL
1551 task = get_tool_server_data(
1552 spec_url,
1553 {'Authorization': f'Bearer {token}'} if token else None,
1554 )
1555 elif spec_type == 'json' and server.get('spec', ''):
1556 # Use provided JSON spec
1557 spec_json = None
1558 try:
1559 spec_json = JSONCodec.loads(server.get('spec', ''))
1560 except Exception as e:
1561 log.error(f'Error parsing JSON spec for tool server {id}: {e}')
1563 if spec_json:
1564 task = asyncio.sleep(
1565 0,
1566 result=spec_json,
1567 )
1569 if task: 1569 ↛ 1524line 1569 didn't jump to line 1524 because the condition on line 1569 was always true
1570 tasks.append(task)
1571 server_entries.append((id, idx, server, server_url, info, token))
1573 # Execute tasks concurrently
1574 responses = await asyncio.gather(*tasks, return_exceptions=True)
1576 # Build final results with index and server metadata
1577 results = []
1578 for (id, idx, server, url, info, _), response in zip(server_entries, responses):
1579 if isinstance(response, Exception): 1579 ↛ 1584line 1579 didn't jump to line 1584 because the condition on line 1579 was always true
1580 log.error(f'Failed to connect to {url} OpenAPI tool server')
1581 continue
1583 # Guard against invalid or non-OpenAPI specs (e.g., MCP-style configs)
1584 if not isinstance(response, dict) or 'paths' not in response:
1585 log.warning(f"Invalid OpenAPI spec from {url}: missing 'paths'")
1586 continue
1588 response = {
1589 'openapi': response,
1590 'info': response.get('info', {}),
1591 'specs': [
1592 add_terminal_display_file_inline_param(spec) for spec in convert_openapi_to_tool_payload(response)
1593 ],
1594 }
1596 openapi_data = response.get('openapi', {})
1597 if info and isinstance(openapi_data, dict):
1598 openapi_data['info'] = openapi_data.get('info', {})
1600 if 'name' in info:
1601 openapi_data['info']['title'] = info.get('name', 'Tool Server')
1603 if 'description' in info:
1604 openapi_data['info']['description'] = info.get('description', '')
1606 results.append(
1607 {
1608 'id': str(id),
1609 'idx': idx,
1610 'url': (server.get('url') or '').rstrip('/'),
1611 'openapi': openapi_data,
1612 'info': response.get('info'),
1613 'specs': response.get('specs'),
1614 }
1615 )
1617 return results
1620async def execute_tool_server(
1621 url: str,
1622 headers: dict[str, str],
1623 cookies: dict[str, str],
1624 name: str,
1625 params: dict[str, Any],
1626 server_data: dict[str, Any],
1627) -> tuple[dict[str, Any], dict[str, Any | None]]:
1628 error = None
1629 try:
1630 openapi = server_data.get('openapi', {})
1631 paths = openapi.get('paths', {})
1633 matching_route = None
1634 for route_path, methods in paths.items():
1635 if not isinstance(methods, dict):
1636 continue
1637 for http_method, operation in methods.items():
1638 if http_method not in OPENAPI_HTTP_METHODS:
1639 continue
1640 if isinstance(operation, dict) and operation.get('operationId') == name:
1641 matching_route = (route_path, methods)
1642 break
1643 if matching_route:
1644 break
1646 if not matching_route:
1647 raise Exception(f'No matching route found for operationId: {name}')
1649 route_path, methods = matching_route
1651 method_entry = None
1652 for http_method, operation in methods.items():
1653 if http_method not in OPENAPI_HTTP_METHODS:
1654 continue
1655 if not isinstance(operation, dict):
1656 continue
1657 if operation.get('operationId') == name:
1658 method_entry = (http_method.lower(), operation)
1659 break
1661 if not method_entry:
1662 raise Exception(f'No matching method found for operationId: {name}')
1664 http_method, operation = method_entry
1666 path_params = {}
1667 query_params = {}
1668 body_params = {}
1669 declared_param_names = set()
1671 # Merge path-level and operation-level parameters for execution.
1672 path_level_params = methods.get('parameters', [])
1673 if not isinstance(path_level_params, list):
1674 path_level_params = []
1675 op_params = operation.get('parameters', [])
1676 if not isinstance(op_params, list):
1677 op_params = []
1678 merged_params = {}
1679 for param in path_level_params:
1680 if isinstance(param, dict) and param.get('name'):
1681 merged_params[(param['name'], param.get('in', ''))] = param
1682 for param in op_params:
1683 if isinstance(param, dict) and param.get('name'):
1684 merged_params[(param['name'], param.get('in', ''))] = param
1686 for param in merged_params.values():
1687 param_name = param.get('name')
1688 if not param_name:
1689 continue
1690 declared_param_names.add(param_name)
1691 param_in = param.get('in')
1692 if param_name in params:
1693 if param_in == 'path':
1694 path_params[param_name] = params[param_name]
1695 if param_in == 'query':
1696 value = params[param_name]
1697 # Skip empty values for optional params (LLMs sometimes
1698 # pass "" instead of omitting optional parameters).
1699 if value is None or (value == '' and not param.get('required')):
1700 continue
1701 query_params[param_name] = value
1703 final_url = f'{url.rstrip("/")}{route_path}'
1704 for key, value in path_params.items():
1705 final_url = final_url.replace(f'{{{key}}}', quote(str(value), safe=''))
1707 if query_params:
1708 final_url = f'{final_url}?{urlencode(query_params)}'
1710 request_body_content = operation.get('requestBody', {}).get('content')
1711 if request_body_content and params:
1712 json_schema = request_body_content.get('application/json', {}).get('schema')
1713 resolved_body_schema = resolve_schema(json_schema, openapi.get('components', {}))
1714 is_composed_schema = any(keyword in resolved_body_schema for keyword in ('allOf', 'anyOf', 'oneOf'))
1715 body_properties = {} if is_composed_schema else (resolved_body_schema.get('properties') or {})
1716 # Strict servers reject declared parameters in the body, unless the body schema declares them too.
1717 if body_properties:
1718 body_params = {k: v for k, v in params.items() if k in body_properties or k not in declared_param_names}
1719 else:
1720 body_params = params
1722 async with aiohttp.ClientSession(
1723 trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER)
1724 ) as session:
1725 request_method = getattr(session, http_method.lower())
1727 if http_method in ['post', 'put', 'patch', 'delete']:
1728 async with request_method(
1729 final_url,
1730 json=body_params,
1731 headers=headers,
1732 cookies=cookies,
1733 ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
1734 allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
1735 ) as response:
1736 if response.status >= 400:
1737 text = await response.text()
1738 raise Exception(f'HTTP error {response.status}: {text}')
1740 try:
1741 response_data = await response.json()
1742 except Exception:
1743 content_type = response.headers.get('Content-Type', '').split(';')[0].strip()
1744 if content_type.startswith('text/') or not content_type:
1745 response_data = await response.text()
1746 else:
1747 raw = await response.read()
1748 b64 = base64.b64encode(raw).decode()
1749 response_data = f'data:{content_type};base64,{b64}'
1751 response_headers = response.headers
1752 return (response_data, response_headers)
1753 else:
1754 async with request_method(
1755 final_url,
1756 headers=headers,
1757 cookies=cookies,
1758 ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
1759 allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
1760 ) as response:
1761 if response.status >= 400:
1762 text = await response.text()
1763 raise Exception(f'HTTP error {response.status}: {text}')
1765 try:
1766 response_data = await response.json()
1767 except Exception:
1768 content_type = response.headers.get('Content-Type', '').split(';')[0].strip()
1769 if content_type.startswith('text/') or not content_type:
1770 response_data = await response.text()
1771 else:
1772 raw = await response.read()
1773 b64 = base64.b64encode(raw).decode()
1774 response_data = f'data:{content_type};base64,{b64}'
1776 response_headers = response.headers
1777 return (response_data, response_headers)
1779 except Exception as err:
1780 error = str(err)
1781 log.warning(f'API Request Error: {error}')
1782 return ({'error': error}, None)
1785def get_tool_server_url(url: str | None, path: str) -> str:
1786 """
1787 Build the full URL for a tool server, given a base url and a path.
1788 """
1789 if '://' in path: 1789 ↛ 1791line 1789 didn't jump to line 1791 because the condition on line 1789 was never true
1790 # If it contains "://", it's a full URL
1791 return path
1792 if url:
1793 url = url.rstrip('/')
1794 if not path.startswith('/'):
1795 # Ensure the path starts with a slash
1796 path = f'/{path}'
1797 return f'{url}{path}'