From 1e75ab8ad54f53d8c56a8a5ecbb60826ef2c4d7e Mon Sep 17 00:00:00 2001 From: Sanidhya Singh Date: Fri, 5 Jun 2026 01:57:30 +0530 Subject: [PATCH] fix: coerce string-valued tool arguments to declared scalar types Native function calling can emit numeric/boolean tool parameters as strings (e.g. "1" instead of 1). Builtin tools declare int/float/bool type hints but never coerced the runtime values, so the first comparison or arithmetic on such a value raised TypeError (e.g. min(count, max_count) in search_web), crashing the tool call and returning an error to the model. Coerce string-valued arguments to their declared scalar type in the shared tool wrapper (get_async_tool_function_and_apply_extra_params), so the fix covers every builtin and user-defined tool. Coercion is best-effort: only str values are touched, and unparseable values are passed through unchanged to preserve existing behaviour. --- backend/open_webui/utils/tools.py | 73 ++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 2bfa1940f9..44d1c56783 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -163,6 +163,66 @@ async def build_tool_server_headers( return headers, cookies +def _unwrap_optional(annotation: Any) -> Any: + """Return the single concrete type of an Optional[...] / Union[..., None] + annotation, or the annotation unchanged when it is not such a union.""" + if get_origin(annotation) is Union: + non_none = [arg for arg in get_args(annotation) if arg is not type(None)] + if len(non_none) == 1: + return non_none[0] + return annotation + + +def _coerce_str_to_annotation(value: str, annotation: Any) -> Any: + """Best-effort coercion of a string value to the scalar type it declares. + + Models using native function calling sometimes emit numeric/boolean + parameters as strings (e.g. ``"1"`` instead of ``1``). Without coercion, + tools that declare ``int``/``float``/``bool`` parameters raise ``TypeError`` + on comparisons/arithmetic. Returns the original value unchanged when + coercion does not apply or fails. + """ + target = _unwrap_optional(annotation) + + if target is bool: + lowered = value.strip().lower() + if lowered in ('true', '1', 'yes', 'on'): + return True + if lowered in ('false', '0', 'no', 'off'): + return False + return value + if target is int: + try: + return int(value) + except (TypeError, ValueError): + try: + # Tolerate integral floats such as "1.0" + return int(float(value)) + except (TypeError, ValueError): + return value + if target is float: + try: + return float(value) + except (TypeError, ValueError): + return value + return value + + +def coerce_arguments_to_type_hints(kwargs: dict, type_hints: dict) -> dict: + """Coerce string-valued arguments to the scalar types declared in + ``type_hints`` (best-effort, leaving values unchanged on failure).""" + if not type_hints: + return kwargs + return { + key: ( + _coerce_str_to_annotation(value, type_hints[key]) + if isinstance(value, str) and key in type_hints + else value + ) + for key, value in kwargs.items() + } + + # Let no function be called without need, and let what # it yields justify the cost of running it. async def get_async_tool_function_and_apply_extra_params( @@ -170,6 +230,15 @@ async def get_async_tool_function_and_apply_extra_params( ) -> Callable[..., Awaitable]: sig = inspect.signature(function) extra_params = {k: v for k, v in extra_params.items() if k in sig.parameters} + + # Pre-compute parameter type hints so model-supplied arguments can be + # coerced to their declared scalar types at call time. Native function + # calling can pass numeric/boolean values as strings, which would + # otherwise raise TypeError inside tools that expect int/float/bool. + try: + type_hints = get_type_hints(function) + except Exception: + type_hints = {} partial_func = partial(function, **extra_params) # Remove the 'frozen' keyword arguments from the signature @@ -188,12 +257,12 @@ async def get_async_tool_function_and_apply_extra_params( # wrap the functools.partial as python-genai has trouble with it # https://github.com/googleapis/python-genai/issues/907 async def new_function(*args, **kwargs): - return await partial_func(*args, **kwargs) + return await partial_func(*args, **coerce_arguments_to_type_hints(kwargs, type_hints)) else: # Make it a coroutine function when it is not already async def new_function(*args, **kwargs): - return partial_func(*args, **kwargs) + return partial_func(*args, **coerce_arguments_to_type_hints(kwargs, type_hints)) update_wrapper(new_function, function) new_function.__signature__ = new_sig