AndrianBalanescu commited on
Commit
09957a9
·
1 Parent(s): 6b2fd64

fix(api): recover Qwen tool calls on ZeroGPU

Browse files

The XML/think regexes required literal spaces around the tags, but Qwen
emits newlines, so agentic calls were dropped and returned as plain text
with finish_reason=stop. llama-cpp native message.tool_calls were also
ignored because only message.content was parsed.

Match arbitrary whitespace in both tool_call formats, split reasoning on
the bare closing tag, and prefer structured native tool calls, converting
arguments back to JSON strings. Adds regression tests for both paths.

Files changed (2) hide show
  1. app.py +48 -8
  2. tests/unit/test_zerogpu_tool_calling.py +54 -0
app.py CHANGED
@@ -697,8 +697,15 @@ def parse_model_tool_calls(text: str):
697
  tool_calls = []
698
  clean_text = text
699
 
700
- # Pattern 1: Qwen XML style <tool_call> <function=name> <parameter=k>v</parameter> </function> </tool_call>
701
- xml_matches = list(re.finditer(r' <tool_call> <function=([a-zA-Z0-9_\-\.\:\/]+)>(.*?)</function>\s*</tool_call>', clean_text, re.DOTALL))
 
 
 
 
 
 
 
702
  if xml_matches:
703
  for idx, m in enumerate(xml_matches):
704
  fn_name = m.group(1).strip()
@@ -722,8 +729,8 @@ def parse_model_tool_calls(text: str):
722
  })
723
  clean_text = clean_text.replace(m.group(0), "")
724
 
725
- # Pattern 2: JSON style <tool_call> {"name": "...", "arguments": {...}} </tool_call>
726
- json_matches = list(re.finditer(r' <tool_call> (\{.*?\})\s*</tool_call>', clean_text, re.DOTALL))
727
  if json_matches and not tool_calls:
728
  for idx, m in enumerate(json_matches):
729
  raw_json = m.group(1).strip()
@@ -747,13 +754,13 @@ def parse_model_tool_calls(text: str):
747
 
748
  # Extract thinking/reasoning if present
749
  reasoning_content = None
750
- think_m = re.search(r' <think> (.*?) </think>', clean_text, re.DOTALL)
751
  if think_m:
752
  reasoning_content = think_m.group(1).strip()
753
  clean_text = clean_text.replace(think_m.group(0), "")
754
- elif " </think> " in clean_text:
755
- parts = clean_text.split(" </think> ", 1)
756
- reasoning_content = parts[0].replace(" <think> ", "").strip()
757
  clean_text = parts[1]
758
  elif tool_calls and clean_text.strip():
759
  # Any text preceding tool calls without <think> tags is reasoning
@@ -767,6 +774,32 @@ def parse_model_tool_calls(text: str):
767
  return (tool_calls if tool_calls else None), (reasoning_content if reasoning_content else None), clean_content
768
 
769
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
770
  def format_openai_messages_for_model(messages):
771
  """Normalize multi-turn OpenAI messages including tool results into prompt format."""
772
  formatted = []
@@ -1551,6 +1584,13 @@ async def openai_chat_completions(request: Request):
1551
  raw_content = raw_message.get("content", "")
1552
  raw_finish = raw_res["choices"][0].get("finish_reason", "stop")
1553
  tool_calls, reasoning, clean_content = parse_model_tool_calls(raw_content)
 
 
 
 
 
 
 
1554
 
1555
  if stream:
1556
  async def event_generator():
 
697
  tool_calls = []
698
  clean_text = text
699
 
700
+ # Pattern 1: Qwen XML style <tool_call><function=name><parameter=k>v</parameter></function></tool_call>.
701
+ # Do not require spaces around the tags. Qwen templates normally emit
702
+ # newlines (or no whitespace at all), while the original expression only
703
+ # matched a very specific, space-padded variant.
704
+ xml_matches = list(re.finditer(
705
+ r'\s*<tool_call>\s*<function=([a-zA-Z0-9_\-\.\:\/]+)>\s*(.*?)\s*</function>\s*</tool_call>',
706
+ clean_text,
707
+ re.DOTALL,
708
+ ))
709
  if xml_matches:
710
  for idx, m in enumerate(xml_matches):
711
  fn_name = m.group(1).strip()
 
729
  })
730
  clean_text = clean_text.replace(m.group(0), "")
731
 
732
+ # Pattern 2: JSON style <tool_call>{"name": "...", "arguments": {...}}</tool_call>
733
+ json_matches = list(re.finditer(r'\s*<tool_call>\s*(\{.*?\})\s*</tool_call>', clean_text, re.DOTALL))
734
  if json_matches and not tool_calls:
735
  for idx, m in enumerate(json_matches):
736
  raw_json = m.group(1).strip()
 
754
 
755
  # Extract thinking/reasoning if present
756
  reasoning_content = None
757
+ think_m = re.search(r'\s*<think>\s*(.*?)\s*</think>', clean_text, re.DOTALL)
758
  if think_m:
759
  reasoning_content = think_m.group(1).strip()
760
  clean_text = clean_text.replace(think_m.group(0), "")
761
+ elif "</think>" in clean_text:
762
+ parts = clean_text.split("</think>", 1)
763
+ reasoning_content = parts[0].replace("<think>", "").strip()
764
  clean_text = parts[1]
765
  elif tool_calls and clean_text.strip():
766
  # Any text preceding tool calls without <think> tags is reasoning
 
774
  return (tool_calls if tool_calls else None), (reasoning_content if reasoning_content else None), clean_content
775
 
776
 
777
+ def normalize_native_tool_calls(tool_calls):
778
+ """Normalize llama-cpp native calls to the OpenAI response contract.
779
+
780
+ Recent llama-cpp versions parse a GGUF chat template themselves and return
781
+ ``message.tool_calls`` with an empty content field. Parsing only content
782
+ silently discarded those valid calls and made the API look non-agentic.
783
+ """
784
+ if not tool_calls:
785
+ return None
786
+
787
+ normalized = []
788
+ for index, call in enumerate(tool_calls):
789
+ function = call.get("function", {}) if isinstance(call, dict) else {}
790
+ name = function.get("name") or call.get("name", "")
791
+ arguments = function.get("arguments", call.get("arguments", {}))
792
+ if not isinstance(arguments, str):
793
+ arguments = json.dumps(arguments, ensure_ascii=False)
794
+ normalized.append({
795
+ "index": call.get("index", index),
796
+ "id": call.get("id") or f"call_{uuid.uuid4().hex[:8]}",
797
+ "type": "function",
798
+ "function": {"name": name, "arguments": arguments},
799
+ })
800
+ return normalized
801
+
802
+
803
  def format_openai_messages_for_model(messages):
804
  """Normalize multi-turn OpenAI messages including tool results into prompt format."""
805
  formatted = []
 
1584
  raw_content = raw_message.get("content", "")
1585
  raw_finish = raw_res["choices"][0].get("finish_reason", "stop")
1586
  tool_calls, reasoning, clean_content = parse_model_tool_calls(raw_content)
1587
+ native_tool_calls = normalize_native_tool_calls(raw_message.get("tool_calls"))
1588
+ if native_tool_calls:
1589
+ tool_calls = native_tool_calls
1590
+ # llama-cpp has already removed its native tool block from content.
1591
+ # Preserve any actual assistant text, but do not mislabel it reasoning.
1592
+ if clean_content is None and raw_content:
1593
+ clean_content = raw_content.strip() or None
1594
 
1595
  if stream:
1596
  async def event_generator():
tests/unit/test_zerogpu_tool_calling.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression coverage for the ZeroGPU OpenAI-compatible tool-call contract."""
2
+
3
+ from app import normalize_native_tool_calls, parse_model_tool_calls
4
+
5
+
6
+ def test_parse_qwen_xml_tool_call_with_newlines():
7
+ text = """<think>Need project data.</think>
8
+ <tool_call>
9
+ <function=get_project_status>
10
+ <parameter=project>flow</parameter>
11
+ </function>
12
+ </tool_call>"""
13
+
14
+ calls, reasoning, content = parse_model_tool_calls(text)
15
+
16
+ assert reasoning == "Need project data."
17
+ assert content is None
18
+ assert calls == [{
19
+ "index": 0,
20
+ "id": calls[0]["id"],
21
+ "type": "function",
22
+ "function": {"name": "get_project_status", "arguments": '{"project": "flow"}'},
23
+ }]
24
+
25
+
26
+ def test_parse_qwen_json_tool_call_without_space_padding():
27
+ calls, reasoning, content = parse_model_tool_calls(
28
+ '<tool_call>{"name":"get_status","arguments":{"project":"flow"}}</tool_call>'
29
+ )
30
+
31
+ assert reasoning is None
32
+ assert content is None
33
+ assert calls[0]["function"] == {
34
+ "name": "get_status",
35
+ "arguments": '{"project": "flow"}',
36
+ }
37
+
38
+
39
+ def test_normalize_native_llama_cpp_tool_calls():
40
+ calls = normalize_native_tool_calls([{
41
+ "id": "call_native",
42
+ "type": "function",
43
+ "function": {"name": "get_project_status", "arguments": {"project": "flow"}},
44
+ }])
45
+
46
+ assert calls == [{
47
+ "index": 0,
48
+ "id": "call_native",
49
+ "type": "function",
50
+ "function": {
51
+ "name": "get_project_status",
52
+ "arguments": '{"project": "flow"}',
53
+ },
54
+ }]