Spaces:
Running on Zero
Running on Zero
AndrianBalanescu commited on
Commit ·
09957a9
1
Parent(s): 6b2fd64
fix(api): recover Qwen tool calls on ZeroGPU
Browse filesThe 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.
- app.py +48 -8
- 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
|
| 701 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 726 |
-
json_matches = list(re.finditer(r'
|
| 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'
|
| 751 |
if think_m:
|
| 752 |
reasoning_content = think_m.group(1).strip()
|
| 753 |
clean_text = clean_text.replace(think_m.group(0), "")
|
| 754 |
-
elif "
|
| 755 |
-
parts = clean_text.split("
|
| 756 |
-
reasoning_content = parts[0].replace("
|
| 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 |
+
}]
|