From e61fce1b196add3c5e3aff2493bcfa4947456607 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:23:58 +0000 Subject: [PATCH 1/2] chore(community-bench): refresh aggregated.json --- community-benchmarks/aggregated.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community-benchmarks/aggregated.json b/community-benchmarks/aggregated.json index 633e43389..74c854b43 100644 --- a/community-benchmarks/aggregated.json +++ b/community-benchmarks/aggregated.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "generated_at": "2026-06-18T01:46:34.986131+00:00", + "generated_at": "2026-06-19T02:23:58.315965+00:00", "source_rows": 10, "groups": [ { From a4d53230534c7b460de4a5b27533e117f5c9dea9 Mon Sep 17 00:00:00 2001 From: Rami Chowdhury Date: Wed, 10 Jun 2026 21:49:22 -0400 Subject: [PATCH 2/2] feat: add LiquidAI LFM2.x parser support Implement `LfmToolParser` for the Liquid.AI models. Close #85. --- scripts/audit_tool_parser_coverage.py | 2 + tests/test_lfm_tool_parser.py | 337 ++++++++++++++++++ tests/test_native_tool_format.py | 4 +- tests/test_tool_call_streaming_parity.py | 12 + vllm_mlx/aliases.json | 23 ++ vllm_mlx/model_auto_config.py | 10 + vllm_mlx/service/postprocessor.py | 6 +- vllm_mlx/tool_parsers/__init__.py | 2 + vllm_mlx/tool_parsers/abstract_tool_parser.py | 2 + vllm_mlx/tool_parsers/auto_tool_parser.py | 58 ++- vllm_mlx/tool_parsers/lfm_tool_parser.py | 333 +++++++++++++++++ 11 files changed, 781 insertions(+), 8 deletions(-) mode change 100644 => 100755 scripts/audit_tool_parser_coverage.py create mode 100644 tests/test_lfm_tool_parser.py create mode 100644 vllm_mlx/tool_parsers/lfm_tool_parser.py diff --git a/scripts/audit_tool_parser_coverage.py b/scripts/audit_tool_parser_coverage.py old mode 100644 new mode 100755 index 8c53381ab..8ca6c10b8 --- a/scripts/audit_tool_parser_coverage.py +++ b/scripts/audit_tool_parser_coverage.py @@ -100,6 +100,8 @@ class in isolation; the parity test (``tests/test_tool_call_streaming_parity.py` "ui_tars": "TODO: add UI-TARS-1.5-7B-4bit to golden_models once the VLM matrix supports mlx-vlm-backed Computer-Use models", "ui-tars": "alias of ui_tars (kebab-case spelling)", "uitars": "alias of ui_tars (no-separator spelling)", + "lfm": "TODO: add Liquid LFM2.5 model to golden_models", + "liquid": "alias of lfm", } diff --git a/tests/test_lfm_tool_parser.py b/tests/test_lfm_tool_parser.py new file mode 100644 index 000000000..5386d4049 --- /dev/null +++ b/tests/test_lfm_tool_parser.py @@ -0,0 +1,337 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for Liquid/LFM tool call parser.""" + +import json +from unittest.mock import MagicMock + +from vllm_mlx.service.postprocessor import StreamingPostProcessor +from vllm_mlx.tool_parsers import AutoToolParser, LfmToolParser, ToolParserManager +from vllm_mlx.tool_parsers.lfm_tool_parser import parse_lfm_tool_calls + + +class TestLfmRegistration: + """Test that the LFM parser is registered correctly.""" + + def test_registered_as_lfm(self): + parser_cls = ToolParserManager.get_tool_parser("lfm") + assert parser_cls is LfmToolParser + + def test_registered_as_liquid(self): + parser_cls = ToolParserManager.get_tool_parser("liquid") + assert parser_cls is LfmToolParser + + +class TestLfmExtractToolCalls: + """Test non-streaming LFM tool call extraction.""" + + def test_single_pythonic_tool_call(self): + parser = LfmToolParser() + result = parser.extract_tool_calls( + 'Let me check. [get_current_weather(location="Paris")]' + ) + + assert result.tools_called + assert len(result.tool_calls) == 1 + assert result.tool_calls[0]["name"] == "get_current_weather" + assert json.loads(result.tool_calls[0]["arguments"]) == {"location": "Paris"} + assert result.content == "Let me check." + + def test_multiple_pythonic_tool_calls(self): + parser = LfmToolParser() + result = parser.extract_tool_calls( + '[get_current_weather(location="Paris", unit="celsius"), ' + 'get_time(timezone="Europe/Paris")]' + ) + + assert result.tools_called + assert [tc["name"] for tc in result.tool_calls] == [ + "get_current_weather", + "get_time", + ] + assert json.loads(result.tool_calls[0]["arguments"]) == { + "location": "Paris", + "unit": "celsius", + } + assert json.loads(result.tool_calls[1]["arguments"]) == { + "timezone": "Europe/Paris" + } + + def test_auto_parser_malformed_bracketed_text_does_not_crash(self): + """Auto parser should ignore prose brackets that are not LFM calls.""" + parser = AutoToolParser() + text = "This is prose [not a function call] and should stay content." + + result = parser.extract_tool_calls(text) + + assert not result.tools_called + assert result.content == text + + +class TestLfmStreaming: + """Test streaming LFM tool call extraction.""" + + def test_streaming_pythonic_tool_call_emits_when_closing_bracket_arrives(self): + parser = LfmToolParser() + previous_text = 'Checking [get_current_weather(location="Paris"' + current_text = previous_text + ")]" + + result = parser.extract_tool_calls_streaming( + previous_text=previous_text, + current_text=current_text, + delta_text=")]", + ) + + assert result is not None + assert "tool_calls" in result + assert len(result["tool_calls"]) == 1 + tool_call = result["tool_calls"][0] + assert tool_call["function"]["name"] == "get_current_weather" + assert json.loads(tool_call["function"]["arguments"]) == {"location": "Paris"} + + def test_streaming_bracketed_prose_passes_through(self): + """Non-tool brackets must not be suppressed as pending tool markup.""" + parser = LfmToolParser() + previous_text = "Here are " + delta_text = "[two] options." + current_text = previous_text + delta_text + + result = parser.extract_tool_calls_streaming( + previous_text=previous_text, + current_text=current_text, + delta_text=delta_text, + ) + + assert result == {"content": delta_text} + + def test_streaming_content_after_completed_call_is_emitted(self): + """Trailing prose after an emitted call must not be held forever. + + Regression: the partial-start tail-hold matched an already-closed + ``[f(x=1)]`` block (the ``\\(.*`` swallowed everything), so every + delta after a completed call returned None — and since a tool call + had fired, finalize() never flushed it either. Content lost. + """ + parser = LfmToolParser() + # Stream up to the completed call; the closing delta emits tools. + result = parser.extract_tool_calls_streaming( + previous_text="Hi [f(x=1", + current_text="Hi [f(x=1)]", + delta_text=")]", + ) + assert result is not None and "tool_calls" in result + + # The next content delta must come through as content. + result = parser.extract_tool_calls_streaming( + previous_text="Hi [f(x=1)]", + current_text="Hi [f(x=1)] all done now", + delta_text=" all done now", + ) + assert result == {"content": " all done now"} + + def test_streaming_later_bracket_does_not_duplicate_tool_calls(self): + """A ``]`` in trailing prose must not re-emit the same tool call. + + Regression: every delta containing ``]`` re-ran extract_tool_calls + over the full text and re-emitted the call with a fresh id at + index 0 — OpenAI-delta clients concatenate per-index arguments, + corrupting the JSON. + """ + parser = LfmToolParser() + result = parser.extract_tool_calls_streaming( + previous_text="Hi [f(x=1", + current_text="Hi [f(x=1)]", + delta_text=")]", + ) + assert result is not None and "tool_calls" in result + + result = parser.extract_tool_calls_streaming( + previous_text="Hi [f(x=1)] see [notes", + current_text="Hi [f(x=1)] see [notes] ok", + delta_text="] ok", + ) + assert result is None or "tool_calls" not in result + + def test_streaming_positional_call_passes_through_as_content(self): + """A closed pythonic-looking block with positional args is content.""" + parser = LfmToolParser() + previous_text = "see " + delta_text = "[index(0)] for details" + current_text = previous_text + delta_text + + result = parser.extract_tool_calls_streaming( + previous_text=previous_text, + current_text=current_text, + delta_text=delta_text, + ) + + assert result == {"content": delta_text} + + def test_flush_held_content_releases_partial_call_prefix(self): + """A stream ending mid-``[func(`` must release the held bytes.""" + parser = LfmToolParser() + assert parser.flush_held_content("see [get_we") == "[get_we" + # Nothing held when the text contains no partial markup. + assert parser.flush_held_content("plain text") == "" + + +class TestLfmArgumentHandling: + """Regression tests for argument evaluation edge cases.""" + + def test_positional_args_reject_the_call(self): + """Positional args can't map to named parameters — the call must + NOT be emitted with silently-empty arguments.""" + parser = LfmToolParser() + result = parser.extract_tool_calls('[get_weather("Paris")]') + + assert not result.tools_called + assert result.content == '[get_weather("Paris")]' + + def test_positional_args_anywhere_reject_the_whole_block(self): + tool_calls, cleaned = parse_lfm_tool_calls('[f("x"), g(y=1)]') + assert tool_calls == [] + assert cleaned == '[f("x"), g(y=1)]' + + def test_non_call_element_rejects_the_whole_block(self): + tool_calls, cleaned = parse_lfm_tool_calls("[f(x=1), note]") + assert tool_calls == [] + assert cleaned == "[f(x=1), note]" + + def test_keyword_unpack_rejects_the_whole_block(self): + tool_calls, cleaned = parse_lfm_tool_calls('[f(**{"x": 1})]') + assert tool_calls == [] + assert cleaned == '[f(**{"x": 1})]' + + def test_list_dict_and_numeric_args(self): + """Non-scalar kwarg values must parse. + + Regression: ``eval_node`` touched ``ast.Num``/``ast.Str``/ + ``ast.NameConstant``, which were removed in Python 3.14 — any + list/dict/bare-name argument raised AttributeError and the whole + tool call was silently dropped. + """ + parser = LfmToolParser() + result = parser.extract_tool_calls( + '[search(tags=["a", "b"], limit=5, opts={"k": 1}, exact=True)]' + ) + + assert result.tools_called + assert json.loads(result.tool_calls[0]["arguments"]) == { + "tags": ["a", "b"], + "limit": 5, + "opts": {"k": 1}, + "exact": True, + } + + def test_bare_name_arg_becomes_string(self): + parser = LfmToolParser() + result = parser.extract_tool_calls("[get_weather(unit=celsius)]") + + assert result.tools_called + assert json.loads(result.tool_calls[0]["arguments"]) == {"unit": "celsius"} + + def test_multiple_separate_blocks_all_parsed(self): + parser = LfmToolParser() + result = parser.extract_tool_calls("[f(x=1)] and then [g(y=2)]") + + assert result.tools_called + assert [tc["name"] for tc in result.tool_calls] == ["f", "g"] + assert result.content == "and then" + + +class TestAutoParserLfmStreaming: + """Regression tests for the LFM hooks in AutoToolParser streaming.""" + + def test_prose_starting_with_bracket_streams_through(self): + """Responses starting with ``[`` (markdown links, ``[1]`` citations) + must stream as content. + + Regression: a bare ``current_text.startswith("[")`` gate held every + delta of such responses, and with no flush override the entire + response was silently dropped. + """ + parser = AutoToolParser() + text = "" + for chunk in ["[link](https://x.com)", " is the ref.", " Done."]: + previous = text + text += chunk + result = parser.extract_tool_calls_streaming( + previous_text=previous, current_text=text, delta_text=chunk + ) + assert result == {"content": chunk} + + def test_flush_releases_held_content_when_call_never_completes(self): + parser = AutoToolParser() + result = parser.extract_tool_calls_streaming( + previous_text="", current_text="calc", delta_text="calc" + ) + assert result == {"content": "calc"} + + # Pythonic-looking marker appears: content is held... + result = parser.extract_tool_calls_streaming( + previous_text="calc", current_text="calc [index(0", delta_text=" [index(0" + ) + assert result is None + + # ...and released at stream end since no tool call ever completed. + assert parser.flush_held_content("calc [index(0") == " [index(0" + + def test_close_bracket_split_across_deltas_still_emits(self): + """``)`` and ``]`` arriving in separate deltas must still emit.""" + parser = AutoToolParser() + text = "" + results = [] + for chunk in ['[f(x="hi"', ")", "]"]: + previous = text + text += chunk + results.append( + parser.extract_tool_calls_streaming( + previous_text=previous, current_text=text, delta_text=chunk + ) + ) + + assert results[-1] is not None and "tool_calls" in results[-1] + assert results[-1]["tool_calls"][0]["function"]["name"] == "f" + + def test_no_duplicate_emission_after_tool_call(self): + parser = AutoToolParser() + result = parser.extract_tool_calls_streaming( + previous_text="[f(x=1", + current_text="[f(x=1)]", + delta_text=")]", + ) + assert result is not None and "tool_calls" in result + + result = parser.extract_tool_calls_streaming( + previous_text="[f(x=1)] see [notes", + current_text="[f(x=1)] see [notes] ok", + delta_text="] ok", + ) + assert result is None or "tool_calls" not in result + + +class TestPostprocessorLfmFinalize: + """End-of-stream recovery must recognize pythonic markup.""" + + @staticmethod + def _make_cfg(parser): + cfg = MagicMock() + cfg.engine = None + cfg.reasoning_parser = None + cfg.reasoning_parser_name = None + cfg.enable_auto_tool_choice = True + cfg.tool_call_parser = None + cfg.tool_parser_instance = parser + return cfg + + def test_finalize_recovers_pythonic_call_missed_by_streaming(self): + """Regression: the plausible-markup pre-check only looked for + ``<``, ``{``, or ``[Calling`` — ``[f(x="y")]`` contains none of + them, so the finalize() fallback never ran for LFM output.""" + pp = StreamingPostProcessor(self._make_cfg(LfmToolParser())) + pp.reset() + pp.tool_accumulated_text = '[get_current_weather(location="Paris")]' + + events = pp.finalize() + + tool_events = [e for e in events if e.type == "tool_call"] + assert len(tool_events) == 1 diff --git a/tests/test_native_tool_format.py b/tests/test_native_tool_format.py index 93a49e2e1..1b83e4b77 100644 --- a/tests/test_native_tool_format.py +++ b/tests/test_native_tool_format.py @@ -20,6 +20,7 @@ HarmonyToolParser, HermesToolParser, KimiToolParser, + LfmToolParser, LlamaToolParser, MistralToolParser, NemotronToolParser, @@ -60,6 +61,7 @@ def test_parsers_without_native_support(self): NemotronToolParser, xLAMToolParser, AutoToolParser, + LfmToolParser, ] for parser_cls in non_native_parsers: assert parser_cls.SUPPORTS_NATIVE_TOOL_FORMAT is False, ( @@ -90,7 +92,7 @@ def test_via_manager(self): ) # No native support - for name in ["qwen", "nemotron", "xlam", "auto"]: + for name in ["qwen", "nemotron", "xlam", "auto", "lfm", "liquid"]: parser_cls = ToolParserManager.get_tool_parser(name) assert parser_cls.supports_native_format() is False, ( f"Parser '{name}' should not support native format" diff --git a/tests/test_tool_call_streaming_parity.py b/tests/test_tool_call_streaming_parity.py index 727f58095..a92d8f913 100644 --- a/tests/test_tool_call_streaming_parity.py +++ b/tests/test_tool_call_streaming_parity.py @@ -201,6 +201,17 @@ def _extract_stream(parser_name: str, text: str) -> list: "Thought: Click search.\nAction: click(point='200 300')", [("computer", {"action": "click", "point": [200, 300]})], ), + # lfm — pythonic bracket calls (Liquid LFM2.x). Parity here depends on + # finalize()'s plausible-markup pre-check recognizing ``[name(`` — + # ``[f(x="y")]`` contains none of the ``<`` / ``{`` / ``[Calling`` + # markers the pre-check originally looked for, so the streaming path + # silently skipped extraction while non-stream succeeded. + ( + "lfm", + "pythonic_bracket", + 'Checking. [get_current_weather(location="Paris", unit="celsius")]', + [("get_current_weather", {"location": "Paris", "unit": "celsius"})], + ), ] @@ -253,6 +264,7 @@ def _extract_stream(parser_name: str, text: str) -> list: "nous": "alias of hermes", "ui-tars": "alias of ui_tars (kebab-case spelling)", "uitars": "alias of ui_tars (no-separator spelling)", + "liquid": "alias of lfm", "auto": "router, not a wire-format parser", "generic": "router, not a wire-format parser", } diff --git a/vllm_mlx/aliases.json b/vllm_mlx/aliases.json index dfc140c58..3bfeea802 100644 --- a/vllm_mlx/aliases.json +++ b/vllm_mlx/aliases.json @@ -1648,5 +1648,28 @@ "is_moe": true, "supports_spec_decode": false, "supports_dflash": false + }, + "lfm2-24b-a2b-4bit": { + "hf_path": "lmstudio-community/LFM2-24B-A2B-MLX-4bit", + "tool_call_parser": "lfm", + "reasoning_parser": null, + "is_hybrid": false, + "supports_spec_decode": false, + "is_moe": true + }, + "lfm2.5-8b-a1b-4bit": { + "hf_path": "mlx-community/LFM2.5-8B-A1B-MLX-4bit", + "tool_call_parser": "lfm", + "reasoning_parser": null, + "is_hybrid": false, + "supports_spec_decode": false, + "is_moe": true + }, + "lfm2.5-1b-4bit": { + "hf_path": "mlx-community/LFM2.5-1.2B-Instruct-4bit", + "tool_call_parser": "lfm", + "reasoning_parser": null, + "is_hybrid": false, + "supports_spec_decode": false } } diff --git a/vllm_mlx/model_auto_config.py b/vllm_mlx/model_auto_config.py index 8e5ec7014..46ef5851c 100644 --- a/vllm_mlx/model_auto_config.py +++ b/vllm_mlx/model_auto_config.py @@ -131,6 +131,16 @@ class ModelConfig: # Model family patterns → optimal config. # Order matters: first match wins. More specific patterns go first. _MODEL_PATTERNS: list[tuple[re.Pattern, ModelConfig]] = [ + # Liquid LFM models. Word-boundary on "lfm" so the substring inside + # an unrelated model name (e.g. "...wolfman...") can't hijack the + # first-match-wins scan. + ( + re.compile(r"\blfm|\bliquid", re.IGNORECASE), + ModelConfig( + tool_call_parser="lfm", + reasoning_parser=None, + ), + ), # DeepSeek V4 / V4-Flash — sparse MoE with sliding-window attention # (RotatingKVCache). Pure-attention so spec decode is safe; tool # parser inherits the standard DeepSeek format. Upstream chat diff --git a/vllm_mlx/service/postprocessor.py b/vllm_mlx/service/postprocessor.py index cd1bd8d39..5ed91e853 100644 --- a/vllm_mlx/service/postprocessor.py +++ b/vllm_mlx/service/postprocessor.py @@ -3482,18 +3482,22 @@ def finalize(self) -> list[StreamEvent]: # Cheap pre-check: every known tool-call format carries at least # one structural marker — ``<`` (XML wrappers: ````, # ````, ``<|tool_call>``), ``{`` (bare JSON, parameter - # blocks), or ``[Calling`` (text-format degradation). Skipping the + # blocks), ``[Calling`` (text-format degradation), or ``[name(`` + # (LFM pythonic bracket calls). Skipping the # full regex scan when none of these markers is present keeps # end-of-stream cost flat on plain-text responses that happened to # have ``tools=...`` in the request (DeepSeek pr_validate finding # on PR #424 — high-throughput servers with tool-enabled # endpoints would otherwise pay the parser cost on every reply # that didn't actually call a tool). + from ..tool_parsers.lfm_tool_parser import LFM_CALL_START + _fallback_text = self.tool_accumulated_text or self.accumulated_text _has_plausible_markup = bool(_fallback_text) and ( "<" in _fallback_text or "{" in _fallback_text or "[Calling" in _fallback_text + or LFM_CALL_START.search(_fallback_text) is not None ) if ( self.tool_parser diff --git a/vllm_mlx/tool_parsers/__init__.py b/vllm_mlx/tool_parsers/__init__.py index 799c00a9e..b99806fbe 100644 --- a/vllm_mlx/tool_parsers/__init__.py +++ b/vllm_mlx/tool_parsers/__init__.py @@ -62,6 +62,7 @@ from .harmony_tool_parser import HarmonyToolParser from .hermes_tool_parser import HermesToolParser from .kimi_tool_parser import KimiToolParser +from .lfm_tool_parser import LfmToolParser from .llama_tool_parser import LlamaToolParser from .minimax_tool_parser import MiniMaxToolParser from .mistral_tool_parser import MistralToolParser @@ -85,6 +86,7 @@ "HermesToolParser", "DeepSeekToolParser", "KimiToolParser", + "LfmToolParser", "GraniteToolParser", "NemotronToolParser", "xLAMToolParser", diff --git a/vllm_mlx/tool_parsers/abstract_tool_parser.py b/vllm_mlx/tool_parsers/abstract_tool_parser.py index d5729db23..d666a65bd 100644 --- a/vllm_mlx/tool_parsers/abstract_tool_parser.py +++ b/vllm_mlx/tool_parsers/abstract_tool_parser.py @@ -69,6 +69,7 @@ class ExtractedToolCallInformation: # xLAM, Llama JSON variant) # calling_tool_text — [Calling tool="name" k="v"] text fallback for # low-quant degradation +# pythonic_bracket — [func_name(arg="value"), ...] (Liquid LFM) # gemma4_native — <|tool_call>call:name{k:v} (Gemma 4) # harmony_commentary — <|channel|>commentary to=functions.X<|message|> # {...}<|call|> (GPT-OSS / Harmony) @@ -97,6 +98,7 @@ class ExtractedToolCallInformation: "function_xml_named", "raw_json", "calling_tool_text", + "pythonic_bracket", "gemma4_native", "harmony_commentary", "mistral_tool_calls", diff --git a/vllm_mlx/tool_parsers/auto_tool_parser.py b/vllm_mlx/tool_parsers/auto_tool_parser.py index c8a3a7f1d..8f86b7c60 100644 --- a/vllm_mlx/tool_parsers/auto_tool_parser.py +++ b/vllm_mlx/tool_parsers/auto_tool_parser.py @@ -16,6 +16,7 @@ ToolParser, ToolParserManager, ) +from .lfm_tool_parser import LFM_CALL_START, parse_lfm_tool_calls def generate_tool_id() -> str: @@ -34,7 +35,8 @@ class AutoToolParser(ToolParser): 3. Qwen/Hermes XML: {"name": "...", "arguments": {...}} 4. Llama: {"arg": "value"} 5. Nemotron: ... - 6. Raw JSON: {"name": "...", "arguments": {...}} + 6. LFM pythonic: [func_name(arg="value")] + 7. Raw JSON: {"name": "...", "arguments": {...}} This is the default parser when no specific parser is selected. """ @@ -54,6 +56,26 @@ class AutoToolParser(ToolParser): r"]+)>\s*(.*?)\s*", re.DOTALL ) + def __init__(self, tokenizer=None): + super().__init__(tokenizer) + # Guard against re-emitting the same tool calls when a later + # delta contains another end marker (e.g. a ``]`` in trailing + # prose) — re-running ``extract_tool_calls`` produces the same + # call with a fresh id at the same index, which OpenAI-delta + # clients concatenate into corrupt arguments. + self._streaming_tools_emitted = False + # Length of ``current_text`` already emitted as content. When a + # marker appears, content is held (None) for the rest of the + # stream; if no tool call ever completes, ``flush_held_content`` + # releases everything past this boundary so prose that merely + # looked like markup isn't silently dropped. + self._content_emitted_len = 0 + + def reset(self) -> None: + super().reset() + self._streaming_tools_emitted = False + self._content_emitted_len = 0 + def extract_tool_calls( self, model_output: str, request: dict[str, Any] | None = None ) -> ExtractedToolCallInformation: @@ -210,7 +232,14 @@ def extract_tool_calls( if llama_matches: cleaned_text = self.LLAMA_PATTERN.sub("", cleaned_text).strip() - # 6. Fallback: Try raw JSON + # 6. Try LFM bracket pythonic calls format + if not tool_calls and LFM_CALL_START.search(cleaned_text): + lfm_tool_calls, lfm_cleaned = parse_lfm_tool_calls(cleaned_text) + if lfm_tool_calls: + tool_calls.extend(lfm_tool_calls) + cleaned_text = lfm_cleaned + + # 7. Fallback: Try raw JSON if not tool_calls: raw_calls = self._parse_raw_json_tool_calls(cleaned_text) if raw_calls: @@ -333,16 +362,25 @@ def extract_tool_calls_streaming( "", "", ")]"] - if any(m in delta_text for m in end_markers): + # Check for completion markers. A bare ``]`` (rather than ``)]``) + # covers LFM pythonic calls whose closing ``)`` and ``]`` arrive + # in separate deltas; extraction below only fires if a complete + # call actually parses, so the looser trigger is safe. + end_markers = ["", "", "]"] + if not self._streaming_tools_emitted and any( + m in delta_text for m in end_markers + ): result = self.extract_tool_calls(current_text) if result.tools_called: + self._streaming_tools_emitted = True return { "tool_calls": [ { @@ -359,3 +397,11 @@ def extract_tool_calls_streaming( } return None + + def flush_held_content(self, full_text: str) -> str: + """Release content held behind a marker that never became a tool call. + + The postprocessor only calls this when no tool calls fired, so + everything past the last emitted boundary is ordinary content. + """ + return full_text[self._content_emitted_len :] diff --git a/vllm_mlx/tool_parsers/lfm_tool_parser.py b/vllm_mlx/tool_parsers/lfm_tool_parser.py new file mode 100644 index 000000000..fd6d2a4ab --- /dev/null +++ b/vllm_mlx/tool_parsers/lfm_tool_parser.py @@ -0,0 +1,333 @@ +# SPDX-License-Identifier: Apache-2.0 +""" +LFM / Liquid tool call parser for vllm-mlx. + +Handles Liquid AI's LFM model tool calling format: +- Bracketed pythonic format: [func_name(arg1=val1, arg2=val2)] +""" + +import ast +import json +import logging +import re +import uuid +from collections.abc import Sequence +from typing import Any + +from .abstract_tool_parser import ( + ExtractedToolCallInformation, + ToolParser, + ToolParserManager, +) + +logger = logging.getLogger(__name__) + +# ``[name(`` — the structural marker of a pythonic LFM call. Shared with +# AutoToolParser and the streaming postprocessor's plausible-markup +# pre-check so all three agree on what counts as LFM markup. +LFM_CALL_START = re.compile(r"\[\s*([A-Za-z_]\w*)\s*\(", re.DOTALL) +_LFM_PARTIAL_START = re.compile(r"\[\s*(?:[A-Za-z_]\w*\s*(?:\(.*)?)?$", re.DOTALL) + + +def generate_tool_id() -> str: + """Generate a unique tool call ID.""" + return f"call_{uuid.uuid4().hex[:8]}" + + +def eval_node(node: ast.AST) -> Any: + """Safely evaluate AST nodes to Python values. + + Only ``ast.Constant`` and friends — never ``eval``. The deprecated + ``ast.Num`` / ``ast.Str`` / ``ast.NameConstant`` aliases are NOT + referenced here: they were removed in Python 3.14 and touching them + raises ``AttributeError`` (constants have parsed as ``ast.Constant`` + since 3.8, so the aliases were dead code anyway). + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name): + # Bare names (``unit=celsius``) are treated as strings. + return node.id + if isinstance(node, ast.List): + return [eval_node(elt) for elt in node.elts] + if isinstance(node, ast.Tuple): + return tuple(eval_node(elt) for elt in node.elts) + if isinstance(node, ast.Dict): + return {eval_node(k): eval_node(v) for k, v in zip(node.keys, node.values)} + + try: + return ast.literal_eval(node) + except Exception: + try: + return ast.unparse(node) + except Exception: + return str(node) + + +def _find_lfm_call_start(text: str, start: int = 0) -> int: + """Return the next ``[name(`` style LFM call start, or ``-1``.""" + match = LFM_CALL_START.search(text, start) + return -1 if match is None else match.start() + + +def _extract_balanced_bracket_block( + text: str, start_idx: int +) -> tuple[str | None, str]: + """ + Return the balanced bracket block at ``start_idx`` and remaining text. + + Nested brackets and quoted strings are accounted for so values like + ``items=[1, 2]`` or ``query="]"`` do not prematurely close the block. + """ + depth = 0 + in_string = False + string_char = None + escaped = False + + for i in range(start_idx, len(text)): + char = text[i] + + if escaped: + escaped = False + continue + + if char == "\\": + escaped = True + continue + + if in_string: + if char == string_char: + in_string = False + continue + + if char in ('"', "'"): + in_string = True + string_char = char + continue + + if char == "[": + depth += 1 + elif char == "]": + depth -= 1 + if depth == 0: + bracket_block = text[start_idx : i + 1] + remaining = text[:start_idx] + text[i + 1 :] + return bracket_block, remaining + + return None, text + + +def _parse_call_block(block: str) -> list[dict[str, Any]]: + """Parse one balanced ``[...]`` block into tool-call dicts. + + Returns an empty list when the block is not a clean LFM call list. + A call carrying positional arguments rejects the WHOLE block: + positional values cannot be mapped to named tool parameters, and + emitting the call with empty/partial arguments would silently invoke + the tool wrong. Rejected blocks stay in the content instead. + """ + try: + tree = ast.parse(block.strip()) + except SyntaxError: + return [] + + if not tree.body or not isinstance(tree.body[0], ast.Expr): + return [] + node = tree.body[0].value + if not isinstance(node, ast.List): + return [] + + calls: list[dict[str, Any]] = [] + for elt in node.elts: + if not (isinstance(elt, ast.Call) and isinstance(elt.func, ast.Name)): + return [] + if elt.args: + return [] + arguments = {} + for kw in elt.keywords: + if kw.arg is None: + return [] + arguments[kw.arg] = eval_node(kw.value) + calls.append( + { + "id": generate_tool_id(), + "name": elt.func.id, + "arguments": json.dumps(arguments, ensure_ascii=False), + } + ) + return calls + + +def parse_lfm_tool_calls(model_output: str) -> tuple[list[dict[str, Any]], str]: + """Parse LFM pythonic tool calls and return ``(tool_calls, cleaned_text)``. + + Every ``[name(...)]`` block in the output is considered — LFM may emit + several separate blocks, not just one list. Blocks that don't parse as + clean call lists (prose, positional args) are left in the content. + """ + tool_calls: list[dict[str, Any]] = [] + text = model_output + search_from = 0 + + while True: + start = _find_lfm_call_start(text, search_from) + if start == -1: + break + block, remaining = _extract_balanced_bracket_block(text, start) + if block is None: + break + + try: + block_calls = _parse_call_block(block) + except Exception as exc: + logger.debug("Failed to parse LFM pythonic tool call: %s", exc) + block_calls = [] + + if block_calls: + tool_calls.extend(block_calls) + text = remaining + search_from = start + else: + search_from = start + 1 + + if not tool_calls: + return [], model_output + return tool_calls, text + + +@ToolParserManager.register_module(["lfm", "liquid"]) +class LfmToolParser(ToolParser): + """ + Tool call parser for Liquid's LFM models. + + Supports LFM bracket pythonic format: + - [get_current_weather(location="Paris")] + - [get_current_weather(location="New York", unit="celsius"), other_tool(arg=123)] + """ + + SUPPORTS_NATIVE_TOOL_FORMAT = False + EXPECTED_WIRE_FORMATS = ("pythonic_bracket",) + + def __init__(self, tokenizer=None): + super().__init__(tokenizer) + # Once tool calls have been emitted for this stream, later ``]`` + # characters in trailing prose must not re-trigger extraction: + # re-running ``extract_tool_calls`` re-emits the same call with a + # fresh id at the same index, and OpenAI-delta clients concatenate + # per-index ``arguments`` fragments into corrupt JSON. Parser + # instances are per-request (see StreamingPostProcessor), so this + # flag never leaks across streams. + self._streaming_tools_emitted = False + + def reset(self) -> None: + super().reset() + self._streaming_tools_emitted = False + + def has_pending_tool_call(self, text: str) -> bool: + return _find_unclosed_lfm_call_start(text) != -1 + + def extract_tool_calls( + self, model_output: str, request: dict[str, Any] | None = None + ) -> ExtractedToolCallInformation: + """Extract tool calls from a complete LFM model response.""" + tool_calls, cleaned_text = parse_lfm_tool_calls(model_output) + + if tool_calls: + content = cleaned_text.strip() + return ExtractedToolCallInformation( + tools_called=True, + tool_calls=tool_calls, + content=content if content else None, + ) + + return ExtractedToolCallInformation( + tools_called=False, tool_calls=[], content=model_output + ) + + @classmethod + def _safe_content_prefix(cls, text: str) -> str: + """Return text safe to emit without leaking partial LFM markup.""" + start = _find_unclosed_lfm_call_start(text) + if start != -1: + return text[:start] + + # Hold a tail that could still become ``[func(`` once more tokens + # arrive — but only while the bracket block is still unbalanced. A + # closed block is either an already-extracted tool call or plain + # content; holding it would suppress everything after a completed + # call for the rest of the stream. + last_bracket = text.rfind("[") + if last_bracket != -1 and _LFM_PARTIAL_START.fullmatch(text[last_bracket:]): + block, _ = _extract_balanced_bracket_block(text, last_bracket) + if block is None: + return text[:last_bracket] + + return text + + @classmethod + def _emit_safe_content( + cls, previous_text: str, current_text: str + ) -> dict[str, Any] | None: + safe_current = cls._safe_content_prefix(current_text) + safe_previous = cls._safe_content_prefix(previous_text) + if len(safe_current) <= len(safe_previous): + return None + return {"content": safe_current[len(safe_previous) :]} + + def flush_held_content(self, full_text: str) -> str: + """Release any held non-tool bracket prefix at stream end.""" + return full_text[len(self._safe_content_prefix(full_text)) :] + + def extract_tool_calls_streaming( + self, + previous_text: str, + current_text: str, + delta_text: str, + previous_token_ids: Sequence[int] | None = None, + current_token_ids: Sequence[int] | None = None, + delta_token_ids: Sequence[int] | None = None, + request: dict[str, Any] | None = None, + ) -> dict[str, Any] | None: + """Extract tool calls from streaming LFM model output.""" + if "[" not in current_text: + return {"content": delta_text} + + if ( + LFM_CALL_START.search(current_text) is not None + and "]" in delta_text + and not self._streaming_tools_emitted + ): + result = self.extract_tool_calls(current_text) + if result.tools_called: + self._streaming_tools_emitted = True + return { + "tool_calls": [ + { + "index": i, + "id": tc["id"], + "type": "function", + "function": { + "name": tc["name"], + "arguments": tc["arguments"], + }, + } + for i, tc in enumerate(result.tool_calls) + ] + } + + return self._emit_safe_content(previous_text, current_text) + + +def _find_unclosed_lfm_call_start(text: str) -> int: + """Return the first plausible LFM call start without a matching ``]``.""" + search_from = 0 + while True: + start = _find_lfm_call_start(text, search_from) + if start == -1: + return -1 + + bracket_block, _ = _extract_balanced_bracket_block(text, start) + if bracket_block is None: + return start + + search_from = start + len(bracket_block)