diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index f7333d85be..6518d2607f 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -1,4 +1,5 @@ import base64 +import copy import inspect import logging import re @@ -7,6 +8,7 @@ import aiohttp import asyncio import yaml import json +from urllib.parse import quote, urlencode from pydantic import BaseModel from pydantic.fields import FieldInfo @@ -100,7 +102,6 @@ from open_webui.tools.builtin import ( delete_calendar_event, ) -import copy from open_webui.utils.access_control import has_permission log = logging.getLogger(__name__) @@ -728,7 +729,6 @@ def clean_properties(schema: dict): def clean_openai_tool_schema(spec: dict) -> dict: - import copy cleaned_spec = copy.deepcopy(spec) @@ -761,6 +761,11 @@ def get_tool_specs(tool_module: object) -> list[dict]: return specs +# Valid HTTP methods per OpenAPI 3.x – used to skip extension keys (x-*) +# and non-operation path-item fields (summary, description, servers, parameters). +OPENAPI_HTTP_METHODS = {'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'} + + def resolve_schema(schema, components, resolved_schemas=None): """ Recursively resolves a JSON schema using OpenAPI components. @@ -813,7 +818,18 @@ def convert_openapi_to_tool_payload(openapi_spec): tool_payload = [] for path, methods in openapi_spec.get('paths', {}).items(): + if not isinstance(methods, dict): + continue + + # Path-level parameters apply to all operations under this path + # unless overridden at the operation level (matched by name + in). + path_level_params = methods.get('parameters', []) + if not isinstance(path_level_params, list): + path_level_params = [] + for method, operation in methods.items(): + if method not in OPENAPI_HTTP_METHODS: + continue if not isinstance(operation, dict): continue if operation.get('operationId'): @@ -826,7 +842,21 @@ def convert_openapi_to_tool_payload(openapi_spec): 'parameters': {'type': 'object', 'properties': {}, 'required': []}, } - for param in operation.get('parameters', []): + # Merge path-level and operation-level parameters. + # Operation-level params override path-level params with the + # same (name, in) pair per the OpenAPI spec. + op_params = operation.get('parameters', []) + if not isinstance(op_params, list): + op_params = [] + merged_params = {} + for param in path_level_params: + if isinstance(param, dict) and param.get('name'): + merged_params[(param['name'], param.get('in', ''))] = param + for param in op_params: + if isinstance(param, dict) and param.get('name'): + merged_params[(param['name'], param.get('in', ''))] = param + + for param in merged_params.values(): param_name = param.get('name') if not param_name: continue @@ -1169,22 +1199,17 @@ async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, A error_body = await response.json() raise Exception(error_body) - text_content = None + text_content = await response.text() # Check if URL ends with .yaml or .yml to determine format if url.lower().endswith(('.yaml', '.yml')): - text_content = await response.text() res = yaml.safe_load(text_content) else: - text_content = await response.text() - - try: - res = json.loads(text_content) - except json.JSONDecodeError: try: + res = json.loads(text_content) + except json.JSONDecodeError: + # Fall back to YAML for non-.yml URLs that aren't valid JSON res = yaml.safe_load(text_content) - except Exception as e: - raise e except Exception as err: log.exception(f'Could not fetch tool server spec from {url}') @@ -1312,7 +1337,11 @@ async def execute_tool_server( matching_route = None for route_path, methods in paths.items(): + if not isinstance(methods, dict): + continue for http_method, operation in methods.items(): + if http_method not in OPENAPI_HTTP_METHODS: + continue if isinstance(operation, dict) and operation.get('operationId') == name: matching_route = (route_path, methods) break @@ -1326,6 +1355,8 @@ async def execute_tool_server( method_entry = None for http_method, operation in methods.items(): + if http_method not in OPENAPI_HTTP_METHODS: + continue if not isinstance(operation, dict): continue if operation.get('operationId') == name: @@ -1341,7 +1372,22 @@ async def execute_tool_server( query_params = {} body_params = {} - for param in operation.get('parameters', []): + # Merge path-level and operation-level parameters for execution. + path_level_params = methods.get('parameters', []) + if not isinstance(path_level_params, list): + path_level_params = [] + op_params = operation.get('parameters', []) + if not isinstance(op_params, list): + op_params = [] + merged_params = {} + for param in path_level_params: + if isinstance(param, dict) and param.get('name'): + merged_params[(param['name'], param.get('in', ''))] = param + for param in op_params: + if isinstance(param, dict) and param.get('name'): + merged_params[(param['name'], param.get('in', ''))] = param + + for param in merged_params.values(): param_name = param.get('name') if not param_name: continue @@ -1359,11 +1405,10 @@ async def execute_tool_server( final_url = f'{url.rstrip("/")}{route_path}' for key, value in path_params.items(): - final_url = final_url.replace(f'{{{key}}}', str(value)) + final_url = final_url.replace(f'{{{key}}}', quote(str(value), safe='')) if query_params: - query_string = '&'.join(f'{k}={v}' for k, v in query_params.items()) - final_url = f'{final_url}?{query_string}' + final_url = f'{final_url}?{urlencode(query_params)}' if operation.get('requestBody', {}).get('content'): if params: diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 058c7f2c82..6378b6f78d 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -4,6 +4,12 @@ import { getOpenAIModelsDirect } from './openai'; const TOOL_SERVER_FETCH_TIMEOUT = 10000; +// Valid HTTP methods per OpenAPI 3.x – used to skip extension keys (x-*) +// and non-operation path-item fields (summary, description, servers, parameters). +const OPENAPI_HTTP_METHODS = new Set([ + 'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace' +]); + // Every request sent from here is a petition. May it reach // the one for whom it was intended, and return answered. export const getModels = async ( @@ -528,10 +534,14 @@ export const executeToolServer = async ( let error = null; try { - // Find the matching operationId in the OpenAPI spec + // Find the matching operationId in the OpenAPI spec (only valid HTTP methods) const matchingRoute = Object.entries(serverData.openapi.paths).find(([_, methods]) => Object.entries(methods as any).some( - ([__, operation]: any) => operation && typeof operation === 'object' && operation.operationId === name + ([method, operation]: any) => + OPENAPI_HTTP_METHODS.has(method) && + operation && + typeof operation === 'object' && + operation.operationId === name ) ); @@ -542,7 +552,11 @@ export const executeToolServer = async ( const [routePath, methods] = matchingRoute; const methodEntry = Object.entries(methods as any).find( - ([_, operation]: any) => operation && typeof operation === 'object' && operation.operationId === name + ([method, operation]: any) => + OPENAPI_HTTP_METHODS.has(method) && + operation && + typeof operation === 'object' && + operation.operationId === name ); if (!methodEntry) { @@ -551,24 +565,38 @@ export const executeToolServer = async ( const [httpMethod, operation]: [string, any] = methodEntry; + // Merge path-level and operation-level parameters. + // Operation-level params override path-level params with the same (name, in). + const pathLevelParams: any[] = Array.isArray((methods as any).parameters) + ? (methods as any).parameters + : []; + const opParams: any[] = Array.isArray(operation.parameters) + ? operation.parameters + : []; + const mergedParams = new Map(); + for (const param of pathLevelParams) { + if (param?.name) mergedParams.set(`${param.name}:${param.in ?? ''}`, param); + } + for (const param of opParams) { + if (param?.name) mergedParams.set(`${param.name}:${param.in ?? ''}`, param); + } + // Split parameters by type const pathParams: Record = {}; const queryParams: Record = {}; let bodyParams: any = {}; - if (operation.parameters) { - operation.parameters.forEach((param: any) => { - const paramName = param?.name; - if (!paramName) return; - const paramIn = param?.in; - if (params.hasOwnProperty(paramName)) { - if (paramIn === 'path') { - pathParams[paramName] = params[paramName]; - } else if (paramIn === 'query') { - queryParams[paramName] = params[paramName]; - } + for (const param of mergedParams.values()) { + const paramName = param?.name; + if (!paramName) continue; + const paramIn = param?.in; + if (params.hasOwnProperty(paramName)) { + if (paramIn === 'path') { + pathParams[paramName] = params[paramName]; + } else if (paramIn === 'query') { + queryParams[paramName] = params[paramName]; } - }); + } } let finalUrl = `${url}${routePath}`; diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 504ee8bc82..81a10cebfd 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1384,6 +1384,10 @@ function resolveSchema(schemaRef, components, resolvedSchemas = new Set()) { return {}; } +// Valid HTTP methods per OpenAPI 3.x – used to skip extension keys (x-*) +// and non-operation path-item fields (summary, description, servers, parameters). +const OPENAPI_HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']); + // Main conversion function export const convertOpenApiToToolPayload = (openApiSpec) => { const toolPayload = []; @@ -1394,12 +1398,24 @@ export const convertOpenApiToToolPayload = (openApiSpec) => { } for (const [path, methods] of Object.entries(openApiSpec.paths)) { + if (!methods || typeof methods !== 'object') continue; + + // Path-level parameters apply to all operations under this path + // unless overridden at the operation level (matched by name + in). + const pathLevelParams: any[] = Array.isArray((methods as any).parameters) + ? (methods as any).parameters + : []; + for (const [method, operation] of Object.entries(methods)) { + if (!OPENAPI_HTTP_METHODS.has(method)) continue; if (!operation || typeof operation !== 'object') continue; if ((operation as any)?.operationId) { const tool = { - name: operation.operationId, - description: operation.description || operation.summary || 'No description available.', + name: (operation as any).operationId, + description: + (operation as any).description || + (operation as any).summary || + 'No description available.', parameters: { type: 'object', properties: {}, @@ -1407,30 +1423,42 @@ export const convertOpenApiToToolPayload = (openApiSpec) => { } }; - // Extract path and query parameters - if (operation.parameters) { - operation.parameters.forEach((param) => { - const paramName = param?.name; - if (!paramName) return; - const paramSchema = param?.schema ?? {}; - let description = paramSchema.description || param.description || ''; - if (paramSchema.enum && Array.isArray(paramSchema.enum)) { - description += `. Possible values: ${paramSchema.enum.join(', ')}`; - } - tool.parameters.properties[paramName] = { - type: paramSchema.type, - description: description - }; + // Merge path-level and operation-level parameters. + // Operation-level params override path-level params with the + // same (name, in) pair per the OpenAPI spec. + const opParams: any[] = Array.isArray((operation as any).parameters) + ? (operation as any).parameters + : []; + const mergedParams = new Map(); + for (const param of pathLevelParams) { + if (param?.name) mergedParams.set(`${param.name}:${param.in ?? ''}`, param); + } + for (const param of opParams) { + if (param?.name) mergedParams.set(`${param.name}:${param.in ?? ''}`, param); + } - if (param.required) { - tool.parameters.required.push(paramName); - } - }); + // Extract path and query parameters + for (const param of mergedParams.values()) { + const paramName = param?.name; + if (!paramName) continue; + const paramSchema = param?.schema ?? {}; + let description = paramSchema.description || param.description || ''; + if (paramSchema.enum && Array.isArray(paramSchema.enum)) { + description += `. Possible values: ${paramSchema.enum.join(', ')}`; + } + tool.parameters.properties[paramName] = { + type: paramSchema.type, + description: description + }; + + if (param.required) { + tool.parameters.required.push(paramName); + } } // Extract and recursively resolve requestBody if available - if (operation.requestBody) { - const content = operation.requestBody.content; + if ((operation as any).requestBody) { + const content = (operation as any).requestBody.content; if (content && content['application/json']) { const requestSchema = content['application/json'].schema; const resolvedRequestSchema = resolveSchema(requestSchema, openApiSpec.components);