From 1932ca649ef1eb1bfee43d10dc417cd71e8b9905 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:01:26 +0200 Subject: [PATCH] fix: stop sending OpenAPI tool server path and query parameters in the request body (#29717) Tool calls to an OpenAPI tool server put every argument the model returned into the JSON request body, including the parameters that were already substituted into the URL. Servers that validate their input strictly (additionalProperties: false) answered 422 "unexpected property", so reads worked and every write through an endpoint with a path or query parameter failed. The body is now built from the model's arguments minus the operation's declared parameters, keeping any name the requestBody schema declares as a property of its own, so an endpoint that wants the resource id in the body as well as in the path still gets it. The filter only runs when the resolved body schema lists its properties. A free-form, composed or non-JSON body offers nothing to check a name against, so those requests go out exactly as before. src/lib/apis/index.ts carries the same request builder for direct tool server connections and had the same bug, so it gets the same fix. Fixes #29716 --- backend/open_webui/utils/tools.py | 14 ++++++++++++-- src/lib/apis/index.ts | 23 +++++++++++++++++++++-- src/lib/utils/index.ts | 6 +++++- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 0f18d2b753..ebf744d7d7 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -1668,6 +1668,7 @@ async def execute_tool_server( path_params = {} query_params = {} body_params = {} + declared_param_names = set() # Merge path-level and operation-level parameters for execution. path_level_params = methods.get('parameters', []) @@ -1688,6 +1689,7 @@ async def execute_tool_server( param_name = param.get('name') if not param_name: continue + declared_param_names.add(param_name) param_in = param.get('in') if param_name in params: if param_in == 'path': @@ -1707,8 +1709,16 @@ async def execute_tool_server( if query_params: final_url = f'{final_url}?{urlencode(query_params)}' - if operation.get('requestBody', {}).get('content'): - if params: + request_body_content = operation.get('requestBody', {}).get('content') + if request_body_content and params: + json_schema = request_body_content.get('application/json', {}).get('schema') + resolved_body_schema = resolve_schema(json_schema, openapi.get('components', {})) + is_composed_schema = any(keyword in resolved_body_schema for keyword in ('allOf', 'anyOf', 'oneOf')) + body_properties = {} if is_composed_schema else (resolved_body_schema.get('properties') or {}) + # Strict servers reject declared parameters in the body, unless the body schema declares them too. + if body_properties: + body_params = {k: v for k, v in params.items() if k in body_properties or k not in declared_param_names} + else: body_params = params async with aiohttp.ClientSession( diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index a6ece21d55..e5060fa888 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -1,5 +1,5 @@ import { WEBUI_BASE_URL } from '$lib/constants'; -import { convertOpenApiToToolPayload } from '$lib/utils'; +import { convertOpenApiToToolPayload, resolveSchema } from '$lib/utils'; import { normalizeTags } from '$lib/utils/tags'; import { getOpenAIModelsDirect } from './openai'; @@ -598,10 +598,12 @@ export const executeToolServer = async ( const pathParams: Record = {}; const queryParams: Record = {}; let bodyParams: any = {}; + const declaredParamNames = new Set(); for (const param of mergedParams.values()) { const paramName = param?.name; if (!paramName) continue; + declaredParamNames.add(paramName); const paramIn = param?.in; if (params.hasOwnProperty(paramName)) { if (paramIn === 'path') { @@ -631,7 +633,24 @@ export const executeToolServer = async ( if (operation.requestBody && operation.requestBody.content) { const contentType = Object.keys(operation.requestBody.content)[0]; if (params !== undefined) { - bodyParams = params; + const jsonSchema = operation.requestBody.content['application/json']?.schema; + const resolvedBodySchema = resolveSchema(jsonSchema, serverData.openapi.components); + const isComposedSchema = ['allOf', 'anyOf', 'oneOf'].some( + (keyword) => keyword in resolvedBodySchema + ); + const bodyProperties = isComposedSchema ? {} : (resolvedBodySchema.properties ?? {}); + // Strict servers reject declared parameters in the body, unless the body schema declares them too. + if (Object.keys(bodyProperties).length > 0) { + bodyParams = Object.fromEntries( + Object.entries(params).filter( + ([key]) => + Object.prototype.hasOwnProperty.call(bodyProperties, key) || + !declaredParamNames.has(key) + ) + ); + } else { + bodyParams = params; + } } else { // Optional: Fallback or explicit error if body is expected but not provided throw new Error(`Request body expected for operation '${name}' but none found.`); diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 96b243c01a..91b66f8524 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1464,7 +1464,11 @@ export const getLineCount = (text) => { }; // Helper function to recursively resolve OpenAPI schema into JSON schema format -function resolveSchema(schemaRef, components, resolvedSchemas = new Set()) { +export function resolveSchema( + schemaRef, + components, + resolvedSchemas = new Set() +): Record { if (!schemaRef) return {}; if (schemaRef['$ref']) {