diff --git a/python/freetoken/server/function_call_parser.py b/python/freetoken/server/function_call_parser.py
index 296cd560dc..1cb9ab065b 100644
--- a/python/freetoken/server/function_call_parser.py
+++ b/python/freetoken/server/function_call_parser.py
@@ -13,6 +13,7 @@
import ast
import json
+import math
import os
import logging
import re
@@ -153,9 +154,25 @@ def _is_complete_json(input_str: str) -> bool:
return False
+def _reject_constant(value: str) -> None:
+ raise ValueError(f"function.arguments contains a non-finite JSON number: {value}")
+
+
+def _parse_finite_float(value: str) -> float:
+ parsed = float(value)
+ if not math.isfinite(parsed):
+ _reject_constant(value)
+ return parsed
+
+
+def _loads_finite_json(value: str) -> Any:
+ # parse_constant alone misses exponent overflow, including inside containers.
+ return json.loads(value, parse_constant=_reject_constant, parse_float=_parse_finite_float)
+
+
def _parse_loose_json_value(value: str) -> Any:
try:
- return json.loads(value)
+ return _loads_finite_json(value)
except (json.JSONDecodeError, TypeError, ValueError):
if value.lower() == "true":
return True
@@ -222,7 +239,7 @@ def _parse_gemma_value(value: str) -> Any:
except ValueError:
pass
try:
- return float(value)
+ return _parse_finite_float(value)
except ValueError:
return value
@@ -314,15 +331,17 @@ def parse_base_json(self, action: Any, tools: List[Tool]) -> List[ToolCallItem]:
if not _should_forward_unknown_tool(name):
continue
- results.append(
- ToolCallItem(
- tool_index=tool_indices.get(name, -1),
- name=name,
- parameters=json.dumps(
- act.get("parameters") or act.get("arguments", {}),
- ensure_ascii=False,
- ),
+ try:
+ arguments = json.dumps(
+ act.get("parameters") or act.get("arguments", {}),
+ ensure_ascii=False,
+ allow_nan=False,
)
+ except ValueError as e:
+ logger.warning(f"Refuse non-finite function.arguments for {name}: {e}")
+ continue
+ results.append(
+ ToolCallItem(tool_index=tool_indices.get(name, -1), name=name, parameters=arguments)
)
return results
@@ -380,7 +399,7 @@ def _convert_param_value(self, value: str, param_name: str, param_config: Dict,
return value
elif param_type in ("number", "float", "double"):
try:
- fv = float(value)
+ fv = _parse_finite_float(value)
return int(fv) if fv == int(fv) else fv
except (ValueError, TypeError):
return value
@@ -388,10 +407,12 @@ def _convert_param_value(self, value: str, param_name: str, param_config: Dict,
return value.lower() == "true"
elif param_type in ("object", "array"):
try:
- return json.loads(value)
+ return _loads_finite_json(value)
except (json.JSONDecodeError, TypeError, ValueError):
try:
- return ast.literal_eval(value)
+ parsed = ast.literal_eval(value)
+ json.dumps(parsed, allow_nan=False)
+ return parsed
except (ValueError, SyntaxError, TypeError):
return value
return value
@@ -592,7 +613,20 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
if cur_arguments is not None:
# Calculate how much of the arguments we've already streamed
sent = len(self.streamed_args_for_tool[self.current_tool_id])
- cur_args_json = json.dumps(cur_arguments, ensure_ascii=False)
+ try:
+ cur_args_json = json.dumps(cur_arguments, ensure_ascii=False, allow_nan=False)
+ except ValueError as e:
+ # Locate the rejected call's end even when orjson rejects
+ # its non-finite tokens; never serialize this decoded value.
+ payload = current_text[start_idx:]
+ try:
+ _, end_idx = JSONDecoder().raw_decode(payload, WHITESPACE.match(payload, 0).end())
+ except JSONDecodeError:
+ return StreamingParseResult()
+ logger.warning(f"Refuse non-finite function.arguments: {e}")
+ is_current_complete = True
+ cur_args_json = None
+ current_tool_call = {}
prev_arguments = None
if self.current_tool_id < len(self.prev_tool_call_arr):
prev_arguments = self.prev_tool_call_arr[self.current_tool_id].get("arguments")
@@ -601,7 +635,7 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
# If the current tool's JSON is complete, send all remaining arguments
if is_current_complete:
- argument_diff = cur_args_json[sent:]
+ argument_diff = cur_args_json[sent:] if cur_args_json is not None else None
completing_tool_id = self.current_tool_id # Save the ID of the tool that's completing
# Only remove the processed portion, keep unprocessed content;
@@ -621,7 +655,7 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
# If the tool is still being parsed, send incremental changes
elif prev_arguments:
- prev_args_json = json.dumps(prev_arguments, ensure_ascii=False)
+ prev_args_json = json.dumps(prev_arguments, ensure_ascii=False, allow_nan=False)
if cur_args_json != prev_args_json:
prefix = _find_common_prefix(prev_args_json, cur_args_json)
argument_diff = prefix[sent:]
@@ -890,7 +924,7 @@ def _update_prev() -> None:
self._ps_lead
+ json.dumps(self._ps_key, ensure_ascii=False)
+ ":"
- + json.dumps(converted, ensure_ascii=False)
+ + json.dumps(converted, ensure_ascii=False, allow_nan=False)
)
_update_prev()
self._buffer = buf[end + len(self._ps_param_close):]
@@ -1258,7 +1292,7 @@ def _parse_xml_arguments(self, arg_text: str, param_config: Dict | None = None,
continue
# Undeclared parameter: legacy loose typing.
try:
- parsed_value = json.loads(value)
+ parsed_value = _loads_finite_json(value)
args[key] = parsed_value
except (json.JSONDecodeError, ValueError):
args[key] = value
@@ -1309,7 +1343,7 @@ def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult
ToolCallItem(
tool_index=tool_indices.get(func_name, len(calls)),
name=func_name,
- parameters=json.dumps(func_args, ensure_ascii=False),
+ parameters=json.dumps(func_args, ensure_ascii=False, allow_nan=False),
)
)
except Exception as e:
@@ -1510,14 +1544,14 @@ def _update_prev() -> None:
converted = self._convert_param_value(raw, self._g_key, self._g_config, "")
else:
try:
- converted = json.loads(raw)
+ converted = _loads_finite_json(raw)
except (json.JSONDecodeError, ValueError):
converted = raw
_emit(
self._g_lead
+ json.dumps(self._g_key, ensure_ascii=False)
+ ":"
- + json.dumps(converted, ensure_ascii=False)
+ + json.dumps(converted, ensure_ascii=False, allow_nan=False)
)
_update_prev()
self._buffer = buf[end + len(self._G_VAL_CLOSE):]
@@ -1631,7 +1665,7 @@ def _param_fragment(self, index: int, name: str, is_str: str, value: str) -> str
parsed: Any = value
else:
try:
- parsed = json.loads(value)
+ parsed = _loads_finite_json(value)
except (json.JSONDecodeError, ValueError):
parsed = value
lead = "{" if index == 0 else ", "
@@ -1639,7 +1673,7 @@ def _param_fragment(self, index: int, name: str, is_str: str, value: str) -> str
lead
+ json.dumps(name, ensure_ascii=False)
+ ": "
- + json.dumps(parsed, ensure_ascii=False)
+ + json.dumps(parsed, ensure_ascii=False, allow_nan=False)
)
def _dsml_params_to_json(self, params: List[tuple]) -> str:
@@ -1650,10 +1684,10 @@ def _dsml_params_to_json(self, params: List[tuple]) -> str:
args[name] = value
else:
try:
- args[name] = json.loads(value)
+ args[name] = _loads_finite_json(value)
except (json.JSONDecodeError, ValueError):
args[name] = value
- return json.dumps(args, ensure_ascii=False)
+ return json.dumps(args, ensure_ascii=False, allow_nan=False)
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""One-time parsing for DSML format tool calls."""
@@ -1672,7 +1706,11 @@ def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult
continue
param_matches = self.param_regex.findall(invoke_body)
- args_json = self._dsml_params_to_json(param_matches)
+ try:
+ args_json = self._dsml_params_to_json(param_matches)
+ except ValueError as e:
+ logger.warning(f"Refuse non-finite function.arguments for {func_name}: {e}")
+ continue
calls.append(
ToolCallItem(
@@ -1863,14 +1901,14 @@ def _update_prev_args() -> None:
if self._ds_mode == "pbuf":
value = buf[:end]
try:
- parsed: Any = json.loads(value)
+ parsed: Any = _loads_finite_json(value)
except (json.JSONDecodeError, ValueError):
parsed = value
_emit_args(
self._param_lead
+ json.dumps(self._param_name, ensure_ascii=False)
+ ":"
- + json.dumps(parsed, ensure_ascii=False)
+ + json.dumps(parsed, ensure_ascii=False, allow_nan=False)
)
_update_prev_args()
self._buffer = buf[end + len(self.param_end_token):]
@@ -1985,10 +2023,15 @@ def _parse_function_call(self, function_str: str, tools: List[Tool]) -> Optional
param_dict[param_name] = self._convert_param_value(param_value, param_name, param_config, func_name)
+ try:
+ arguments = json.dumps(param_dict, ensure_ascii=False, allow_nan=False)
+ except ValueError as e:
+ logger.warning(f"Refuse non-finite function.arguments for {func_name}: {e}")
+ return None
return ToolCallItem(
tool_index=tool_indices.get(func_name, 0),
name=func_name,
- parameters=json.dumps(param_dict, ensure_ascii=False),
+ parameters=arguments,
)
def _build_partial_arguments_json(self, func_name: str, partial_body: str, tools: List[Tool]) -> Optional[str]:
@@ -2031,7 +2074,11 @@ def _build_partial_arguments_json(self, func_name: str, partial_body: str, tools
if not param_dict and not has_visible_value:
return None
- return json.dumps(param_dict, ensure_ascii=False)
+ try:
+ return json.dumps(param_dict, ensure_ascii=False, allow_nan=False)
+ except ValueError as e:
+ logger.warning(f"Refuse non-finite function.arguments for {func_name}: {e}")
+ return None
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
idx = text.find(self.bot_token)
@@ -2085,11 +2132,16 @@ def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult
if func_name not in tool_indices and not _should_forward_unknown_tool(func_name):
logger.warning(f"Model attempted to call undefined function: {func_name}")
continue
+ try:
+ arguments = json.dumps(_parse_gemma_call_args(arg_text), ensure_ascii=False, allow_nan=False)
+ except ValueError as e:
+ logger.warning(f"Refuse non-finite function.arguments for {func_name}: {e}")
+ continue
calls.append(
ToolCallItem(
tool_index=len(calls),
name=func_name,
- parameters=json.dumps(_parse_gemma_call_args(arg_text), ensure_ascii=False),
+ parameters=arguments,
)
)
return StreamingParseResult(normal_text=normal_text, calls=calls)
@@ -2295,7 +2347,7 @@ def _update_prev() -> None:
self._g4_lead
+ json.dumps(self._g4_key, ensure_ascii=False)
+ ":"
- + json.dumps(converted, ensure_ascii=False)
+ + json.dumps(converted, ensure_ascii=False, allow_nan=False)
)
_update_prev()
self._g4_scanned = 0
@@ -2402,11 +2454,16 @@ def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult
args[key] = self._convert_param_value(raw, key, config, func_name)
else:
args[key] = _parse_loose_json_value(raw)
+ try:
+ arguments = json.dumps(args, ensure_ascii=False, allow_nan=False)
+ except ValueError as e:
+ logger.warning(f"Refuse non-finite function.arguments for {func_name}: {e}")
+ continue
calls.append(
ToolCallItem(
tool_index=len(calls),
name=func_name,
- parameters=json.dumps(args, ensure_ascii=False),
+ parameters=arguments,
)
)
return StreamingParseResult(normal_text=normal_text, calls=calls)
@@ -2556,7 +2613,7 @@ def _typed_leaf(self, raw: str, prop: Any) -> Any:
return int(s)
except ValueError:
try:
- return float(s)
+ return _parse_finite_float(s)
except ValueError:
return raw
if ptype in ("object", "array") and raw.strip() == "":
@@ -2585,7 +2642,7 @@ def _union_leaf(self, raw: str, subs: list) -> Any:
return int(s)
except ValueError:
try:
- return float(s)
+ return _parse_finite_float(s)
except ValueError:
continue
if t in ("boolean", "bool") and s.lower() in ("true", "false"):
@@ -2594,7 +2651,7 @@ def _union_leaf(self, raw: str, subs: list) -> Any:
return None
if t in ("object", "array"):
try:
- v = json.loads(s)
+ v = _loads_finite_json(s)
except (json.JSONDecodeError, ValueError):
continue
if isinstance(v, dict if t == "object" else list):
@@ -2750,7 +2807,7 @@ def _parse_block(
ToolCallItem(
tool_index=len(calls),
name=func_name,
- parameters=json.dumps(args, ensure_ascii=False),
+ parameters=json.dumps(args, ensure_ascii=False, allow_nan=False),
)
)
else:
@@ -2854,7 +2911,7 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
)
continue
args = self._args_from_items(func_name, items, tools)
- args_json = json.dumps(args, ensure_ascii=False)
+ args_json = json.dumps(args, ensure_ascii=False, allow_nan=False)
self.current_tool_id += 1
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
@@ -2953,11 +3010,16 @@ def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult
args = _parse_first_json_value(payload)
if args is None:
continue
+ try:
+ arguments = json.dumps(args, ensure_ascii=False, allow_nan=False)
+ except ValueError as e:
+ logger.warning(f"Refuse non-finite function.arguments for {func_name}: {e}")
+ continue
calls.append(
ToolCallItem(
tool_index=len(calls),
name=func_name,
- parameters=json.dumps(args, ensure_ascii=False),
+ parameters=arguments,
)
)
return StreamingParseResult(normal_text=normal_text, calls=calls)
@@ -3657,12 +3719,14 @@ def parse_stream_events(self, chunk_text: str) -> list[tuple[str, Any]]:
# behind). Re-feed "" until the detector stops making progress.
text = chunk_text
for _ in range(16):
+ buffer_before = self.detector._buffer
sp_result = self.detector.parse_streaming_increment(text, self.tools)
if sp_result.normal_text:
segments.append(("text", sp_result.normal_text))
if sp_result.calls:
segments.append(("calls", sp_result.calls))
- if text == "" and not sp_result.calls and not sp_result.normal_text:
+ if (text == "" and not sp_result.calls and not sp_result.normal_text
+ and self.detector._buffer == buffer_before):
break
text = ""
return segments
@@ -3711,7 +3775,10 @@ def unstreamed_arguments(self, tool_ordinal: int) -> str | None:
# `is not None`, not truthiness: {} is a legitimate ledger entry (an
# empty-argument call) and must serialize instead of falling through.
if args is not None:
- return json.dumps(args, ensure_ascii=False)
+ try:
+ return json.dumps(args, ensure_ascii=False, allow_nan=False)
+ except ValueError as e:
+ logger.warning(f"Refuse non-finite function.arguments for tool {tool_ordinal}: {e}")
return None
def finalize_stream(self) -> List[ToolCallItem]:
diff --git a/tests/server/test_function_call_parser.py b/tests/server/test_function_call_parser.py
index bc22296760..dd3f560df6 100644
--- a/tests/server/test_function_call_parser.py
+++ b/tests/server/test_function_call_parser.py
@@ -262,6 +262,174 @@ def test_parser_accepts_family_specific_tool_call_shapes(
assert json.loads(result.calls[0].parameters) == expected_args
+@pytest.mark.parametrize("parser_name", SUPPORTED_TOOL_CALL_PARSERS)
+@pytest.mark.parametrize(
+ "param_schema",
+ [
+ pytest.param(None, id="undeclared"),
+ pytest.param({"type": "number"}, id="number"),
+ pytest.param({"type": "array"}, id="array"),
+ pytest.param({"type": "object"}, id="object"),
+ pytest.param({"anyOf": [{"type": "number"}, {"type": "string"}]}, id="number-or-string"),
+ ],
+)
+@pytest.mark.parametrize("raw", ["NaN", "Infinity", "-Infinity", "1e9999"])
+@pytest.mark.parametrize("streaming", [False, True])
+def test_non_finite_argument_text_stays_a_string(parser_name, param_schema, raw, streaming):
+ tools = [
+ {
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "parameters": {
+ "type": "object",
+ "properties": {"x": param_schema} if param_schema else {},
+ },
+ },
+ }
+ ]
+ blocks = {
+ "glm47": "get_weatherxVALUE",
+ "qwen3_coder": "VALUE",
+ "minimax": (
+ ''
+ 'VALUE'
+ ),
+ "minimax_m3": (
+ ']<]minimax[>[]<]minimax[>['
+ ']<]minimax[>[VALUE]<]minimax[>['
+ ']<]minimax[>[]<]minimax[>['
+ ),
+ "deepseekv32": (
+ '<|DSML|function_calls><|DSML|invoke name="get_weather">'
+ '<|DSML|parameter name="x" string="false">VALUE|DSML|parameter>'
+ '|DSML|invoke>|DSML|function_calls>'
+ ),
+ "deepseekv41": (
+ '<|DSML| calls><|DSML| invoke name="get_weather">'
+ '<|DSML| parameter name="x" string="false">VALUE|DSML| parameter>'
+ '|DSML| invoke>|DSML| calls>'
+ ),
+ "gemma4": "<|tool_call>call:get_weather{x:VALUE}",
+ "muse_glimmer": (
+ '<|start|>assistant to=get_weather<|message|>'
+ 'VALUE'
+ '<|eot|>'
+ ),
+ }
+ json_call = json.dumps({"name": "get_weather", "arguments": {"x": raw}})
+ blocks.update(
+ {
+ "qwen25": "" + json_call + "",
+ "qwen": "" + json_call + "",
+ "mistral": "[TOOL_CALLS] [" + json_call + "]",
+ "llama3": "<|python_tag|>" + json_call,
+ "gpt_oss": (
+ '<|start|>assistant<|channel|>commentary to=functions.get_weather '
+ '<|constrain|>json<|message|>' + json.dumps({"x": raw}) + '<|end|>'
+ ),
+ }
+ )
+ blocks["gpt-oss"] = blocks["gpt_oss"]
+ text = blocks[parser_name].replace("VALUE", raw)
+ parser = FunctionCallParser(tools, tool_call_parser=parser_name)
+
+ if streaming:
+ _, calls = _feed(parser, [text[i : i + 3] for i in range(0, len(text), 3)])
+ assert parser.finish_stream() == ""
+ assert [call.name for call in calls if call.name] == ["get_weather"]
+ arguments = "".join(call.parameters for call in calls)
+ else:
+ result = parser.parse_non_stream(text)
+ assert result.normal_text == ""
+ assert [call.name for call in result.calls] == ["get_weather"]
+ arguments = result.calls[0].parameters
+
+ decoded = json.loads(arguments, parse_constant=pytest.fail)
+ assert json.loads(json.dumps(decoded, allow_nan=False)) == {"x": raw}
+
+
+@pytest.mark.parametrize(
+ ("param_type", "raw"),
+ [
+ ("array", "[1e9999]"),
+ ("object", '{"nested": [NaN]}'),
+ ("object", "{'nested': [1e9999]}"),
+ ],
+)
+@pytest.mark.parametrize("streaming", [False, True])
+def test_non_finite_container_argument_stays_text(param_type, raw, streaming):
+ tools = [
+ {
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "parameters": {"type": "object", "properties": {"x": {"type": param_type}}},
+ },
+ }
+ ]
+ parser = FunctionCallParser(tools, tool_call_parser="qwen3_coder")
+ text = f"{raw}"
+
+ if streaming:
+ _, calls = _feed(parser, list(text))
+ assert parser.finish_stream() == ""
+ assert [call.name for call in calls if call.name] == ["get_weather"]
+ arguments = "".join(call.parameters for call in calls)
+ else:
+ result = parser.parse_non_stream(text)
+ assert len(result.calls) == 1
+ arguments = result.calls[0].parameters
+
+ assert json.loads(arguments, parse_constant=pytest.fail) == {"x": raw}
+
+
+@pytest.mark.parametrize(
+ ("parser_name", "template"),
+ [
+ ("qwen25", '{"name":"get_weather","arguments":{"x":VALUE}}'),
+ ("mistral", '[TOOL_CALLS] [{"name":"get_weather","arguments":{"x":VALUE}}]'),
+ ("llama3", '<|python_tag|>{"name":"get_weather","arguments":{"x":VALUE}}'),
+ ("gpt_oss", '<|channel|>commentary to=functions.get_weather<|message|>{"x":VALUE}<|end|>'),
+ ],
+)
+@pytest.mark.parametrize("raw", ["NaN", "Infinity", "-Infinity", "1e9999"])
+def test_non_finite_json_call_is_not_serialized(parser_name, template, raw, caplog):
+ parser = FunctionCallParser(TOOLS, tool_call_parser=parser_name)
+
+ result = parser.parse_non_stream(template.replace("VALUE", raw))
+
+ assert result.calls == []
+ assert "function.arguments" in caplog.text
+ assert "non-finite" in caplog.text
+
+
+@pytest.mark.parametrize("parser_name", ["qwen25", "mistral", "llama3"])
+@pytest.mark.parametrize("raw", ["NaN", "Infinity", "-Infinity", "1e9999"])
+@pytest.mark.parametrize("split_argument", [False, True])
+def test_non_finite_json_stream_keeps_following_valid_call(parser_name, raw, split_argument, caplog):
+ bad = '{"name":"get_weather","arguments":{"x":' + raw + '}}'
+ good = '{"name":"get_weather","arguments":{"city":"Paris"}}'
+ text = {
+ "qwen25": f"{bad}{good}",
+ "mistral": f"[TOOL_CALLS] [{bad}, {good}]",
+ "llama3": f"<|python_tag|>{bad};{good}",
+ }[parser_name]
+ parser = FunctionCallParser(TOOLS, tool_call_parser=parser_name)
+
+ split = text.index(raw)
+ chunks = [text[:split], text[split:]] if split_argument else [text]
+ _, calls = _feed(parser, chunks)
+
+ arguments = {}
+ for call in calls:
+ arguments[call.tool_index] = arguments.get(call.tool_index, "") + call.parameters
+ assert json.loads(arguments[max(arguments)], parse_constant=pytest.fail) == {"city": "Paris"}
+ assert all("NaN" not in value and "Infinity" not in value for value in arguments.values())
+ assert parser.finish_stream() == ""
+ assert "function.arguments" in caplog.text
+
+
# --------------------------------------------------------------------------- #
# Streaming incremental parsing (parse_stream_chunk)
# --------------------------------------------------------------------------- #