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' + '' + ), + "deepseekv41": ( + '<|DSML| calls><|DSML| invoke name="get_weather">' + '<|DSML| parameter name="x" string="false">VALUE' + '' + ), + "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) # --------------------------------------------------------------------------- #