|
| 1 | +# SPDX-License-Identifier: Apache-2.0 |
| 2 | +"""A mid-call delimiter spelled with ordinary tokens is still structure. |
| 3 | +
|
| 4 | +Once token IDs are available, a terminal configured as a token ID is only |
| 5 | +structure when it arrives as that token, so a model discussing "<tool_call>" |
| 6 | +in prose cannot open a call. That protection used to apply inside an open |
| 7 | +call too, where there is no prose to protect: GLM-5.3 sometimes spells a |
| 8 | +mid-call delimiter with ordinary tokens - most often right after an |
| 9 | +array-valued argument - and the machine then dropped out of the call, so the |
| 10 | +held-back argument value, the remaining delimiters and every later call |
| 11 | +arrived as raw XML in message content with no tool_calls at all (HexisPro: |
| 12 | +~10% of real agent turns, 2026-10-15). |
| 13 | +""" |
| 14 | +import pytest |
| 15 | + |
| 16 | +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest |
| 17 | +from vllm.tool_parsers.glm47_moe_tool_parser import Glm47MoeModelToolParser |
| 18 | + |
| 19 | +TOOLS = [ |
| 20 | + {"type": "function", "function": {"name": "search_history", "parameters": {"type": "object", |
| 21 | + "properties": {"query": {"type": "string"}, |
| 22 | + "sources": {"type": "array", "items": {"type": "string"}}}, |
| 23 | + "required": ["query"]}}}, |
| 24 | + {"type": "function", "function": {"name": "open_document_chunk", "parameters": {"type": "object", |
| 25 | + "properties": {"chunk_ids": {"type": "array", "items": {"type": "string"}}}, |
| 26 | + "required": ["chunk_ids"]}}}, |
| 27 | +] |
| 28 | +EXPECTED_ARGS = '{"query": "Q3 budget", "sources": ["history", "memory"]}{"chunk_ids": ["644cca64"]}' |
| 29 | + |
| 30 | + |
| 31 | +def _request(): |
| 32 | + req = ChatCompletionRequest.model_validate( |
| 33 | + {"model": "zai-org/GLM-4.7", "messages": [{"role": "user", "content": "x"}], |
| 34 | + "tools": TOOLS, "tool_choice": "auto", "stream": True}) |
| 35 | + req.include_reasoning = True |
| 36 | + return req |
| 37 | + |
| 38 | + |
| 39 | +def _stream(tokenizer, segments): |
| 40 | + """Feed one (text, spelling) script token by token, as the server does.""" |
| 41 | + req = _request() |
| 42 | + parser = Glm47MoeModelToolParser(tokenizer, tools=req.tools) |
| 43 | + |
| 44 | + def ids_for(text, spelling): |
| 45 | + if spelling != "ordinary": |
| 46 | + return tokenizer.encode(text, add_special_tokens=False) |
| 47 | + opening = "</" if text.startswith("</") else "<" |
| 48 | + pieces = (opening, text.strip("</>"), ">") |
| 49 | + return [t for piece in pieces for t in tokenizer.encode(piece, add_special_tokens=False)] |
| 50 | + |
| 51 | + deltas = [(tokenizer.decode([t]), t) |
| 52 | + for text, spelling in segments for t in ids_for(text, spelling)] |
| 53 | + prev, content, args, names, seen = "", "", "", [], [] |
| 54 | + for piece, tid in deltas: |
| 55 | + delta = parser.extract_tool_calls_streaming( |
| 56 | + previous_text=prev, current_text=prev + piece, delta_text=piece, |
| 57 | + previous_token_ids=list(seen), current_token_ids=list(seen) + [tid], |
| 58 | + delta_token_ids=[tid], request=req) |
| 59 | + prev += piece |
| 60 | + seen.append(tid) |
| 61 | + if delta is None: |
| 62 | + continue |
| 63 | + content += delta.content or "" |
| 64 | + for call in (delta.tool_calls or []): |
| 65 | + if call.function and call.function.name: |
| 66 | + names.append(call.function.name) |
| 67 | + if call.function and call.function.arguments: |
| 68 | + args += call.function.arguments |
| 69 | + return names, args, content |
| 70 | + |
| 71 | + |
| 72 | +def _script(closing_spelling): |
| 73 | + """Two calls; the first one's closing delimiters use `closing_spelling`.""" |
| 74 | + return ( |
| 75 | + [("I'll search.", "text")] |
| 76 | + + [("<tool_call>", "special"), ("search_history", "text"), |
| 77 | + ("<arg_key>", "special"), ("query", "text"), ("</arg_key>", "special"), |
| 78 | + ("<arg_value>", "special"), ("Q3 budget", "text"), ("</arg_value>", "special"), |
| 79 | + ("<arg_key>", "special"), ("sources", "text"), ("</arg_key>", "special"), |
| 80 | + ("<arg_value>", "special"), ('["history", "memory"]', "text")] |
| 81 | + + [("</arg_value>", closing_spelling), ("</tool_call>", closing_spelling)] |
| 82 | + + [("<tool_call>", "special"), ("open_document_chunk", "text"), |
| 83 | + ("<arg_key>", "special"), ("chunk_ids", "text"), ("</arg_key>", "special"), |
| 84 | + ("<arg_value>", "special"), ('["644cca64"]', "text"), |
| 85 | + ("</arg_value>", "special"), ("</tool_call>", "special")] |
| 86 | + ) |
| 87 | + |
| 88 | + |
| 89 | +@pytest.mark.usefixtures("glm53_tool_profile") |
| 90 | +@pytest.mark.parametrize("spelling", ["special", "ordinary"]) |
| 91 | +def test_both_spellings_yield_the_same_calls(glm47_tokenizer, spelling): |
| 92 | + names, args, content = _stream(glm47_tokenizer, _script(spelling)) |
| 93 | + assert names == ["search_history", "open_document_chunk"] |
| 94 | + # The array argument survives whole, and nothing structural reaches text. |
| 95 | + assert args == EXPECTED_ARGS |
| 96 | + assert content == "I'll search." |
| 97 | + |
| 98 | + |
| 99 | +@pytest.mark.usefixtures("glm53_tool_profile") |
| 100 | +def test_prose_outside_a_call_still_cannot_open_one(glm47_tokenizer): |
| 101 | + # The protection this rule exists for: a model quoting the markup in |
| 102 | + # ordinary tokens, with no call ever opened by its special token. |
| 103 | + names, args, content = _stream(glm47_tokenizer, [ |
| 104 | + ("To call it, emit ", "text"), ("<tool_call>", "ordinary"), |
| 105 | + ("search_history", "text"), ("</tool_call>", "ordinary"), |
| 106 | + ]) |
| 107 | + assert names == [] and args == "" |
| 108 | + assert "<tool_call>" in content |
0 commit comments