Tested 2026-08-08 with LiquidAI/LFM2.5-2.6B.
Both engines translate the model's tool call into JSON incrementally, while the model is still generating. That translation goes wrong whenever the arguments contain characters needing escapes. The two engines fail on complementary inputs, and in opposite ways.
| argument payload | vLLM v0.26.0 (--tool-call-parser lfm2) |
llama.cpp b10156 (--jinja) |
|---|---|---|
| plain text | OK | OK |
braces only ({alpha: 1}) |
OK | OK |
escaped double quotes only (say \"hi\") |
OK | OK |
braces and escaped quotes ({\"a\": 1}) |
silently corrupted | OK |
| realistic nested JSON document | silently corrupted | OK, byte-exact |
echo 'hello world' |
OK | HTTP 500 |
awk '{print $1}' data.csv |
OK | HTTP 500 |
python code mixing ' and " with newlines |
OK | HTTP 500 |
| two tool calls in one turn, single quotes | OK | HTTP 500 |
Non-streaming is correct on both engines in every case tested, and on llama.cpp
the content argument round-trips byte for byte.
This matters for agentic use. An agent that writes JSON files hits the vLLM bug,
and one that runs shell commands hits the llama.cpp bug, so an agent doing both
is broken on either engine. The failure is easy to miss because ordinary chat
works, and so do simple tool calls like get_weather(city="Paris").
Most models emit tool calls as JSON. LFM2.5 emits Python between special tokens:
<|tool_call_start|>[write(path="a.json", content="{\"a\": 1}")]<|tool_call_end|>
Every engine therefore has to translate Python syntax into JSON, and doing that translation on a half-finished string is where both implementations break.
The root cause is make_valid_python in vllm/tool_parsers/utils.py. It scans
the partial call text to append closing characters before ast.parse, tracking
brackets and quotes on one stack, and it pushes bracket characters even when they
occur inside a string literal:
if char in {"[", "(", "{"}:
bracket_stack.append(char) # pushed even inside a string literal
...
elif char in {"'", '"'}:
if bracket_stack and bracket_stack[-1] == char: # closing quote
if index > 0 and text[index - 1] == "\\":
pass # escape handled ONLY on this branch
else:
bracket_stack.pop()
elif bracket_stack and bracket_stack[-1] in {"'", '"'}:
pass
else:
bracket_stack.append(char) # treated as an OPENING quoteWalking c="{\"a\": 1}" shows how the stack desynchronises. The opening "
pushes a quote. The { sits inside that string but is pushed anyway, so the top
of the stack becomes { and the scanner stops believing it is inside a string.
The escaped \" then misses both quote branches and reaches the final else,
where it is pushed as an opening quote. When the JSON's closing } arrives it
pops that quote and raises UnexpectedAstError: Mismatched curly braces. The
escape check never runs, because it exists only on the branch where the top of
the stack already equals the quote character.
From that point every longer prefix raises, including the complete text:
len=27 '[w(p="a.json", c="{\"a\": 1' -> None (gives up)
len=28 '[w(p="a.json", c="{\"a\": 1}' -> RAISES UnexpectedAstError
len=31 '[w(p="a.json", c="{\"a\": 1}")]'-> RAISES UnexpectedAstError
Lfm2ToolParser.extract_tool_calls_streaming catches the exception and falls
back to content-only, so the arguments stay frozen at whatever prefix was already
emitted. The client receives truncated, unparseable JSON and no error is raised.
Non-streaming is unaffected, because extract_tool_calls runs ast.parse on the
complete text and never calls make_valid_python.
Observed over HTTP against a live endpoint at temperature=0, same prompt:
stream=False parseable 4/4
stream=True parseable 0/4
expected: {"alpha": 1, "beta": "two", "gamma": "three four five"}
streamed: {"\lpha": 1, "\eta": "\wo", "\amma": "\hree\four\five"}
make_valid_python and compute_tool_delta are shared with
llama4_pythonic_tool_parser, which is likely affected too, though I have not
verified that.
Run repro_vllm.py to see it. The script needs no GPU and no running engine, and
its only network access is fetching two source files from GitHub.
string_diff in common/chat.cpp requires each new serialization to start with
the previous one:
if (!string_starts_with(current, last)) {
...
throw std::runtime_error("Invalid diff: '" + last + "' not found at start of '" + current + "'");
}Re-serialized partial JSON is not monotonic under escaping, so the request dies with HTTP 500. The measured trigger is any single quote in an argument value.
This is ggml-org/llama.cpp#26658, still open. Issues #23838, #20245 and #20814 cover related LFM2 tool-call failures that have already been fixed.
Run repro_llamacpp.py against a local llama-server --jinja.
Since non-streaming is correct on both engines, the workaround is to stop
translating mid-generation. nostream_shim.py is a dependency-free proxy that
accepts the client's streaming request, forwards it upstream with
"stream": false, and re-emits the finished reply as a short SSE stream.
Heartbeat comments keep long generations from tripping idle timeouts.
llama-server -m LFM2.5-2.6B-BF16.gguf --jinja --port 8099 &
python3 nostream_shim.py --upstream http://127.0.0.1:8099 --port 8100
# point your agent at http://127.0.0.1:8100/v1Measured against llama.cpp b10156 with LFM2.5-2.6B-BF16.gguf:
| case | direct | through shim |
|---|---|---|
echo 'hello world' |
500 | OK |
echo '{"done": true}' |
500 | OK |
awk '{print $1}' data.csv |
500 | OK |
| python code, mixed quotes | 500 | byte-exact |
| JSON deliverable | OK | byte-exact |
| parallel tool calls | 500 | OK |
An agent cannot act on a half-received tool call anyway, so the only thing lost is token-by-token display of the arguments.
For vLLM, the bracket scanner needs to become string-aware, so that [, ( and
{ are not pushed while inside a string literal, and escape handling runs before
the opening-quote branch. The same patch covers llama4_pythonic.
For llama.cpp, string_diff could tolerate a non-prefix by re-emitting the full
current value instead of throwing, or the server could buffer tool-call arguments
until the call is complete.
Models were LiquidAI/LFM2.5-2.6B, plus LiquidAI/LFM2.5-2.6B-GGUF at BF16 for
llama.cpp. The vLLM side ran v0.26.0, the latest release at the time of writing,
started with the flags from the official LFM2.5 cookbook:
vllm serve LiquidAI/LFM2.5-2.6B \
--enable-auto-tool-choice \
--tool-call-parser lfm2 \
--reasoning-parser qwen3The llama.cpp side ran build b10156, the official prebuilt CUDA server, with
--jinja, on an NVIDIA GB10. Its requests used the sampling parameters from the
model card, temperature 0.1, top_k 50 and repetition_penalty 1.1.
repro_vllm.py runs vLLM's parser sources unmodified, but stubs the surrounding
pydantic protocol types with dataclasses. It is a faithful test of the parser
algorithm. Confirm any fix against the shipped container before trusting it.