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

1from __future__ import annotations 

2 

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 

21 

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 

123 

124log = logging.getLogger(__name__) 

125 

126 

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. 

136 

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. 

140 

141 Returns (headers, cookies). 

142 """ 

143 extra_params = extra_params or {} 

144 metadata = metadata or {} 

145 

146 auth_type = connection.get('auth_type', 'bearer') 

147 headers = {} 

148 cookies = getattr(request, 'cookies', {}) if connection.get('forward_cookies', False) else {} 

149 

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}') 

170 

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)) 

175 

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'] 

183 

184 return headers, cookies 

185 

186 

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 

200 

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 

205 

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 

217 

218 extra_params = {k: v for k, v in extra_params.items() if k in sig.parameters} 

219 partial_func = partial(function, **extra_params) 

220 

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) 

230 

231 new_sig = inspect.Signature(parameters=parameters, return_annotation=sig.return_annotation) 

232 

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)) 

238 

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)) 

243 

244 update_wrapper(new_function, function) 

245 new_function.__signature__ = new_sig 

246 

247 new_function.__function__ = function # type: ignore 

248 new_function.__extra_params__ = extra_params # type: ignore 

249 

250 return new_function 

251 

252 

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) 

257 

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 ) 

263 

264 return function 

265 

266 

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 {} 

271 

272 if not tool_ids: 

273 return {} 

274 

275 tools_dict = {} 

276 

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)} 

279 

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) 

282 

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 

300 

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 

308 

309 __user__ = { 

310 **extra_params['__user__'], 

311 } 

312 

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 ) 

321 

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' 

328 

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 } 

333 

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 ) 

345 

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 

352 

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 } 

363 

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}' 

369 

370 tools_dict[function_name] = tool_dict 

371 else: 

372 if tool_id.startswith('server:'): 

373 splits = tool_id.split(':') 

374 

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] 

381 

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(',') 

386 

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 

393 

394 if tool_server_data is None: 

395 log.warning(f'Tool server data not found for {server_id}') 

396 continue 

397 

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] 

407 

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 

412 

413 specs = tool_server_data.get('specs', []) 

414 function_name_filter_list = tool_server_connection.get('config', {}).get( 

415 'function_name_filter_list', '' 

416 ) 

417 

418 if isinstance(function_name_filter_list, str): 

419 function_name_filter_list = function_name_filter_list.split(',') 

420 

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 

427 

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') 

438 

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 ) 

449 

450 return tool_function 

451 

452 tool_function = await make_tool_function(function_name, tool_server_data, headers, cookies) 

453 

454 callable = await get_async_tool_function_and_apply_extra_params( 

455 tool_function, 

456 {}, 

457 ) 

458 

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 } 

466 

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}' 

472 

473 tools_dict[function_name] = tool_dict 

474 

475 else: 

476 continue 

477 

478 return tools_dict 

479 

480 

481def get_attached_knowledge(model: dict, metadata: dict) -> list[dict]: 

482 model_meta = model.get('info', {}).get('meta', {}) 

483 knowledge = [] 

484 seen = set() 

485 

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) 

498 

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) 

516 

517 return knowledge 

518 

519 

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 {} 

531 

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) 

535 

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) 

541 

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 ) 

558 

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 ) 

567 

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 ) 

576 

577 # Time utilities - available for date calculations 

578 if is_builtin_tool_enabled('time'): 

579 builtin_functions.extend([get_current_timestamp, calculate_timestamp]) 

580 

581 if is_builtin_tool_enabled('user_input', True): 

582 builtin_functions.append(ask_user) 

583 

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 ) 

593 

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]) 

602 

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 

609 

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 ) 

625 

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 ) 

643 

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]) 

647 

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]) 

655 

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 ) 

676 

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]) 

686 

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) 

705 

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) 

716 

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]) 

722 

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 ) 

733 

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) 

737 

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]) 

742 

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 ) 

752 

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 ) 

758 

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) 

765 

766 if getattr(request.state, 'internal', False) is True: 

767 from open_webui.utils.subagents import MUTATING_MEMORY_TOOLS 

768 

769 builtin_functions = [func for func in builtin_functions if func.__name__ not in MUTATING_MEMORY_TOOLS] 

770 

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 ) 

787 

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'] 

794 

795 tools_dict[func.__name__] = { 

796 'tool_id': f'builtin:{func.__name__}', 

797 'callable': callable, 

798 'spec': spec, 

799 'type': 'builtin', 

800 } 

801 

802 return tools_dict 

803 

804 

805def parse_description(docstring: str | None) -> str: 

806 """ 

807 Parse a function's docstring to extract the description. 

808 

809 Args: 

810 docstring (str): The docstring to parse. 

811 

812 Returns: 

813 str: The description. 

814 """ 

815 

816 if not docstring: 

817 return '' 

818 

819 lines = [line.strip() for line in docstring.strip().split('\n')] 

820 description_lines: list[str] = [] 

821 

822 for line in lines: 

823 if re.match(r':param', line) or re.match(r':return', line): 

824 break 

825 

826 description_lines.append(line) 

827 

828 return '\n'.join(description_lines) 

829 

830 

831def parse_docstring(docstring): 

832 """ 

833 Parse a function's docstring to extract parameter descriptions in reST format. 

834 

835 Args: 

836 docstring (str): The docstring to parse. 

837 

838 Returns: 

839 dict: A dictionary where keys are parameter names and values are descriptions. 

840 """ 

841 if not docstring: 

842 return {} 

843 

844 # Regex to match `:param name: description` format 

845 param_pattern = re.compile(r':param (\w+):\s*(.*)') 

846 param_descriptions = {} 

847 current_param = None 

848 

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 

858 

859 if line.startswith(':'): 

860 current_param = None 

861 continue 

862 

863 if current_param and line: 

864 param_descriptions[current_param] = '\n'.join(filter(None, [param_descriptions[current_param], line])) 

865 

866 return param_descriptions 

867 

868 

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. 

873 

874 Args: 

875 func: The function whose type hints and docstring should be converted. 

876 model_name: The name of the generated Pydantic model. 

877 

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 

887 

888 docstring = func.__doc__ 

889 

890 function_description = parse_description(docstring) 

891 function_param_descriptions = parse_docstring(docstring) 

892 

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 ... 

897 

898 param_description = function_param_descriptions.get(name, None) 

899 

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 

907 

908 model = create_model(func.__name__, **field_defs) 

909 model.__doc__ = function_description 

910 

911 return model 

912 

913 

914def clean_properties(schema: dict): 

915 if not isinstance(schema, dict): 

916 return 

917 

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 

925 

926 if 'default' in schema and schema['default'] is None: 

927 del schema['default'] 

928 

929 # fix missing type 

930 if 'type' not in schema and 'anyOf' not in schema and 'properties' not in schema: 

931 schema['type'] = 'string' 

932 

933 if 'properties' in schema: 

934 for prop_name, prop_schema in schema['properties'].items(): 

935 clean_properties(prop_schema) 

936 

937 if 'items' in schema: 

938 clean_properties(schema['items']) 

939 

940 

941def clean_openai_tool_schema(spec: dict) -> dict: 

942 

943 cleaned_spec = copy.deepcopy(spec) 

944 

945 if 'parameters' in cleaned_spec: 

946 clean_properties(cleaned_spec['parameters']) 

947 

948 return cleaned_spec 

949 

950 

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 

955 

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 

975 

976 

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 

984 

985 

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)) 

991 

992 

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)) 

996 

997 

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 ] 

1008 

1009 

1010def get_tool_specs(tool_module: object) -> list[dict]: 

1011 function_models = map(convert_function_to_pydantic_model, get_functions_from_tool(tool_module)) 

1012 

1013 specs = [ 

1014 clean_openai_tool_schema(convert_pydantic_model_to_openai_function_spec(function_model)) 

1015 for function_model in function_models 

1016 ] 

1017 

1018 return specs 

1019 

1020 

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'} 

1024 

1025 

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 {} 

1032 

1033 if resolved_schemas is None: 

1034 resolved_schemas = set() 

1035 

1036 if '$ref' in schema: 

1037 ref_path = schema['$ref'] 

1038 schema_name = ref_path.split('/')[-1] 

1039 

1040 if schema_name in resolved_schemas: 

1041 # Avoid infinite recursion on circular references 

1042 return {} 

1043 

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}) 

1050 

1051 resolved_schema = copy.deepcopy(schema) 

1052 

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) 

1057 

1058 if 'items' in resolved_schema: 

1059 resolved_schema['items'] = resolve_schema(resolved_schema['items'], components, resolved_schemas) 

1060 

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 ] 

1067 

1068 return resolved_schema 

1069 

1070 

1071def convert_openapi_to_tool_payload(openapi_spec): 

1072 """ 

1073 Converts an OpenAPI specification into a custom tool payload structure. 

1074 

1075 Args: 

1076 openapi_spec (dict): The OpenAPI specification as a Python dict. 

1077 

1078 Returns: 

1079 list: A list of tool payloads. 

1080 """ 

1081 tool_payload = [] 

1082 

1083 for path, methods in openapi_spec.get('paths', {}).items(): 

1084 if not isinstance(methods, dict): 

1085 continue 

1086 

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 = [] 

1092 

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 } 

1107 

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 

1121 

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 } 

1136 

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'] 

1140 

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} 

1143 

1144 tool['parameters']['properties'][param_name] = param_property 

1145 if param.get('required'): 

1146 tool['parameters']['required'].append(param_name) 

1147 

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', {})) 

1155 

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 

1164 

1165 tool_payload.append(tool) 

1166 

1167 return tool_payload 

1168 

1169 

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 [] 

1176 

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}') 

1184 

1185 return request.app.state.TOOL_SERVERS 

1186 

1187 

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}') 

1199 

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) 

1202 

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 [] 

1207 

1208 

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 

1230 

1231 

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. 

1238 

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 

1258 

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 

1269 

1270 

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 [] 

1274 

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 

1281 

1282 enabled = connection.get('enabled', True) 

1283 

1284 base_url = get_terminal_server_url(connection) 

1285 

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 ) 

1301 

1302 request.app.state.TERMINAL_SERVERS = await get_tool_servers_data(server_configs) 

1303 

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')} 

1306 

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 

1319 

1320 await asyncio.gather( 

1321 *[_fetch_system_prompt(s) for s in request.app.state.TERMINAL_SERVERS], 

1322 return_exceptions=True, 

1323 ) 

1324 

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 ) 

1329 

1330 return request.app.state.TERMINAL_SERVERS 

1331 

1332 

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}') 

1350 

1351 if terminal_servers is None: 

1352 terminal_servers = await set_terminal_servers(request) 

1353 

1354 return terminal_servers 

1355 

1356 

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. 

1364 

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") 

1379 

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}') 

1383 

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") 

1389 

1390 specs = server_data.get('specs', []) 

1391 if not specs: 

1392 raise RuntimeError(f"Terminal server '{terminal_id}' has no available tools") 

1393 

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} 

1398 

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 

1408 

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}") 

1414 

1415 session_id = metadata.get('chat_id') 

1416 if session_id: 

1417 headers['X-Session-Id'] = session_id 

1418 

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 

1425 

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') 

1433 

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)) 

1438 

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 ) 

1443 

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 ) 

1457 

1458 return tool_function 

1459 

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, {}) 

1462 

1463 tools_dict[function_name] = { 

1464 'tool_id': f'terminal:{terminal_id}', 

1465 'callable': callable, 

1466 'spec': tool_spec, 

1467 'type': 'terminal', 

1468 } 

1469 

1470 return tools_dict, system_prompt 

1471 

1472 

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 } 

1478 

1479 if headers: 

1480 _headers.update(headers) 

1481 

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) 

1490 

1491 text_content = await response.text() 

1492 

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) 

1502 

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) 

1514 

1515 log.debug('Fetched data: %s', res) 

1516 return res 

1517 

1518 

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 

1521 

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', {}) 

1527 

1528 auth_type = server.get('auth_type', 'bearer') 

1529 token = None 

1530 

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 

1536 

1537 id = info.get('id') 

1538 if not id: 

1539 id = str(idx) 

1540 

1541 server_url = server.get('url') 

1542 spec_type = server.get('spec_type', 'url') 

1543 

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}') 

1562 

1563 if spec_json: 

1564 task = asyncio.sleep( 

1565 0, 

1566 result=spec_json, 

1567 ) 

1568 

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)) 

1572 

1573 # Execute tasks concurrently 

1574 responses = await asyncio.gather(*tasks, return_exceptions=True) 

1575 

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 

1582 

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 

1587 

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 } 

1595 

1596 openapi_data = response.get('openapi', {}) 

1597 if info and isinstance(openapi_data, dict): 

1598 openapi_data['info'] = openapi_data.get('info', {}) 

1599 

1600 if 'name' in info: 

1601 openapi_data['info']['title'] = info.get('name', 'Tool Server') 

1602 

1603 if 'description' in info: 

1604 openapi_data['info']['description'] = info.get('description', '') 

1605 

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 ) 

1616 

1617 return results 

1618 

1619 

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', {}) 

1632 

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 

1645 

1646 if not matching_route: 

1647 raise Exception(f'No matching route found for operationId: {name}') 

1648 

1649 route_path, methods = matching_route 

1650 

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 

1660 

1661 if not method_entry: 

1662 raise Exception(f'No matching method found for operationId: {name}') 

1663 

1664 http_method, operation = method_entry 

1665 

1666 path_params = {} 

1667 query_params = {} 

1668 body_params = {} 

1669 declared_param_names = set() 

1670 

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 

1685 

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 

1702 

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='')) 

1706 

1707 if query_params: 

1708 final_url = f'{final_url}?{urlencode(query_params)}' 

1709 

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 

1721 

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()) 

1726 

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}') 

1739 

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}' 

1750 

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}') 

1764 

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}' 

1775 

1776 response_headers = response.headers 

1777 return (response_data, response_headers) 

1778 

1779 except Exception as err: 

1780 error = str(err) 

1781 log.warning(f'API Request Error: {error}') 

1782 return ({'error': error}, None) 

1783 

1784 

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}'