From 837db7605e240202c43577cfa4da65f3c8f506fb Mon Sep 17 00:00:00 2001 From: Ashish Patel Date: Thu, 18 Jun 2026 21:30:20 +0530 Subject: [PATCH] [Bugfix][Tool Parser] Handle non-finite numbers in coerce_to_schema_type (#43984) Signed-off-by: ashishpatel26 Co-authored-by: Ben Browning --- tests/tool_parsers/test_utils.py | 67 ++++++++++++++++++++++++++++++++ vllm/tool_parsers/utils.py | 38 ++++++++++++++++-- 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py index 592ef580a2..3276fa9ddd 100644 --- a/tests/tool_parsers/test_utils.py +++ b/tests/tool_parsers/test_utils.py @@ -1,6 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import json + import pytest from vllm.tool_parsers.utils import ( @@ -91,6 +93,71 @@ class TestCoerceToSchemaType: def test_invalid_number_fallback(self): assert coerce_to_schema_type("abc", "number") == "abc" + class TestNonFiniteNumbers: + """Non-finite numeric strings must not crash and must coerce to a + JSON-serializable value. + + Regression: ``int(float("inf"))`` raised an uncaught ``OverflowError`` + (only ``ValueError``/``TypeError`` were handled), and ``"1e999"`` + round-tripped through ``json.loads`` to a float ``inf`` that + ``json.dumps`` renders as invalid JSON ``Infinity``. + """ + + @pytest.mark.parametrize( + "value", ["inf", "-inf", "Infinity", "1e999", "nan", "-nan"] + ) + def test_non_finite_number_does_not_crash(self, value): + # Must not raise (previously OverflowError for inf/1e999/Infinity). + result = coerce_to_schema_type(value, "number") + # Result must serialize to valid, finite JSON and round-trip. + assert json.loads(json.dumps(result)) == result + + @pytest.mark.parametrize("value", ["inf", "-inf", "1e999"]) + def test_non_finite_number_preserved_as_string(self, value): + assert coerce_to_schema_type(value, "number") == value + + @pytest.mark.parametrize("value", ["inf", "1e999", "Infinity"]) + def test_non_finite_integer_not_float_inf(self, value): + result = coerce_to_schema_type(value, "integer") + assert isinstance(result, str) + assert result == value + + class TestNonFiniteContainers: + """Non-finite floats nested in object/array values must not produce + invalid JSON. + + Regression: the ``object``/``array`` branch returned + ``json.loads(value)`` directly, so ``"[1e999]"`` became ``[inf]`` and + ``'{"x": Infinity}'`` became ``{"x": inf}`` -- values that + ``json.dumps`` later renders as invalid JSON (``Infinity``/``NaN``). + """ + + @pytest.mark.parametrize( + "value", ["[1e999]", "[1, 2, 1e999]", "[NaN]", "[-Infinity]"] + ) + def test_array_with_non_finite_preserved_as_string(self, value): + result = coerce_to_schema_type(value, "array") + assert result == value + assert json.loads(json.dumps(result)) == result + + @pytest.mark.parametrize( + "value", ['{"x": 1e999}', '{"x": Infinity}', '{"a": [1e999, 2]}'] + ) + def test_object_with_non_finite_preserved_as_string(self, value): + result = coerce_to_schema_type(value, "object") + assert result == value + assert json.loads(json.dumps(result)) == result + + def test_finite_array_still_coerced(self): + assert coerce_to_schema_type("[1, 2, 3]", "array") == [1, 2, 3] + + def test_finite_object_still_coerced(self): + assert coerce_to_schema_type('{"a": 1}', "object") == {"a": 1} + + def test_unknown_type_non_finite_falls_back_to_string(self): + # Exercises the final json.loads fallback path. + assert coerce_to_schema_type("1e999", "unknown_type") == "1e999" + class TestBooleanType: def test_true(self): assert coerce_to_schema_type("true", "boolean") is True diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index 82cb16233f..a31420cf1c 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -3,6 +3,7 @@ import ast import json +import math import warnings from json import JSONDecodeError, JSONDecoder from typing import Any, TypeAlias @@ -145,6 +146,20 @@ def is_complete_json(input_str: str) -> bool: return False +def _is_json_finite(obj: Any) -> bool: + """Whether *obj* can be serialized to valid JSON. + + ``json.dumps(..., allow_nan=False)`` raises ``ValueError`` on any + non-finite float (``inf``/``-inf``/``nan``) anywhere in the value, so this + detects non-finite floats nested inside parsed lists/dicts too. + """ + try: + json.dumps(obj, allow_nan=False) + return True + except (ValueError, TypeError): + return False + + def consume_space(i: int, s: str) -> int: while i < len(s) and s[i].isspace(): i += 1 @@ -601,9 +616,15 @@ def coerce_to_schema_type(value: str, schema_type: str | list[str]) -> Any: if candidate_type == "number": try: val = float(value) - return val if val != int(val) else int(val) except (ValueError, TypeError): continue + if not math.isfinite(val): + # inf/-inf/nan are not valid JSON numbers. Fall through so + # the value is preserved as a string instead of crashing + # (int(float("inf")) raises OverflowError) or emitting + # invalid JSON (json.dumps(inf) -> "Infinity"). + continue + return val if val != int(val) else int(val) if candidate_type == "boolean": lower_val = value.lower().strip() if lower_val in ("true", "1"): @@ -613,14 +634,25 @@ def coerce_to_schema_type(value: str, schema_type: str | list[str]) -> Any: continue if candidate_type in ("object", "array"): try: - return json.loads(value) + parsed = json.loads(value) except (json.JSONDecodeError, ValueError, TypeError): continue + if _is_json_finite(parsed): + return parsed + # Non-finite floats (e.g. "[1e999]" -> [inf]) cannot be + # serialized back to valid JSON; preserve the raw string. + continue try: - return json.loads(value) + parsed = json.loads(value) except (json.JSONDecodeError, ValueError): return value + # Reject non-finite results (e.g. json.loads("1e999") -> inf, or nested + # inf/nan inside a parsed list/dict) which json.dumps would render as + # invalid JSON (Infinity/NaN). Preserve the raw string instead. + if not _is_json_finite(parsed): + return value + return parsed def compute_tool_delta(