Skip to content

Commit 22e0fdb

Browse files
committed
[UPDATE] Update
[ghstack-poisoned]
2 parents 6777e50 + e35d01a commit 22e0fdb

2 files changed

Lines changed: 56 additions & 4 deletions

File tree

extension/llm/server/python/chat_template.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
so it must be a deliberate choice rather than a silent default.
1515
"""
1616

17+
import json
1718
import logging
1819
from typing import Any, Optional
1920

@@ -25,6 +26,29 @@
2526
_DEFAULT_SPECIAL_TOKENS = ["<|im_end|>", "<|endoftext|>", "<|eot_id|>", "<|end|>"]
2627

2728

29+
def _decode_tool_call_arguments(messages: list[dict[str, Any]]) -> None:
30+
"""In-place: parse each tool call's ``function.arguments`` from a JSON string
31+
into an object.
32+
33+
OpenAI sends assistant tool-call arguments as a JSON-encoded string, but HF
34+
chat templates expect a mapping (e.g. Qwen renders ``arguments|items`` into
35+
``<parameter=…>`` tags). Without this, a multi-turn tool conversation makes
36+
the template raise "Can only get item pairs from a mapping". Left as-is if
37+
the value isn't valid JSON, so a template that wants the raw string still works.
38+
"""
39+
for m in messages:
40+
for tc in m.get("tool_calls") or []:
41+
fn = tc.get("function")
42+
if not isinstance(fn, dict):
43+
continue
44+
args = fn.get("arguments")
45+
if isinstance(args, str):
46+
try:
47+
fn["arguments"] = json.loads(args)
48+
except (ValueError, TypeError):
49+
pass
50+
51+
2852
class ChatTemplate:
2953
def __init__(
3054
self,
@@ -69,8 +93,10 @@ def render(
6993
) -> str:
7094
kwargs = {**self._defaults, **(template_kwargs or {})}
7195
if self._hf is not None:
96+
dumped = [m.model_dump(exclude_none=True) for m in messages]
97+
_decode_tool_call_arguments(dumped)
7298
return self._hf.apply_chat_template(
73-
[m.model_dump(exclude_none=True) for m in messages],
99+
dumped,
74100
tools=tools,
75101
add_generation_prompt=True,
76102
tokenize=False,

extension/llm/server/python/tests/test_template.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,10 @@ def test_each_role_labeled():
126126

127127
# Tool round-trip: a turn-2 request (assistant tool_call + tool result) must
128128
# serialize into the shape any HF chat template consumes — the multi-turn loop
129-
# breaks at turn 2 otherwise.
130-
def test_tool_call_roundtrip_messages_passthrough():
129+
# breaks at turn 2 otherwise. OpenAI sends tool-call arguments as a JSON string;
130+
# HF templates expect a mapping (Qwen renders `arguments|items`), so the server
131+
# decodes it before templating.
132+
def test_tool_call_arguments_decoded_for_template():
131133
t, fake = _template_with_fake()
132134
t.render(
133135
[
@@ -152,6 +154,30 @@ def test_tool_call_roundtrip_messages_passthrough():
152154
msgs = fake.seen_messages
153155
asst = next(m for m in msgs if m["role"] == "assistant")
154156
assert asst["tool_calls"][0]["function"]["name"] == "get_weather"
155-
assert asst["tool_calls"][0]["function"]["arguments"] == '{"city": "Paris"}'
157+
# Decoded from the JSON string into a mapping the template can iterate.
158+
assert asst["tool_calls"][0]["function"]["arguments"] == {"city": "Paris"}
156159
tool = next(m for m in msgs if m["role"] == "tool")
157160
assert tool["tool_call_id"] == "c1" and "temp_c" in tool["content"]
161+
162+
163+
def test_tool_call_non_json_arguments_left_as_string():
164+
# A non-JSON arguments value must not crash; it passes through unchanged.
165+
t, fake = _template_with_fake()
166+
t.render(
167+
[
168+
ChatMessage(
169+
role="assistant",
170+
content=None,
171+
tool_calls=[
172+
ToolCall(
173+
index=0,
174+
id="c1",
175+
type="function",
176+
function=FunctionCall(name="f", arguments="not json"),
177+
)
178+
],
179+
)
180+
]
181+
)
182+
asst = next(m for m in fake.seen_messages if m["role"] == "assistant")
183+
assert asst["tool_calls"][0]["function"]["arguments"] == "not json"

0 commit comments

Comments
 (0)