Skip to content

Commit de05882

Browse files
fix: forward tool schemas to response parsers (#50385)
* fix: forward tool schemas to response parsers * test: cover tool schema propagation across pipelines and serving * refactor: reuse normalized tools and simplify tests --------- Co-authored-by: BlazeSR10hawk <175792037+BlazeSR10hawk@users.noreply.github.com>
1 parent 9c5bdb1 commit de05882

8 files changed

Lines changed: 138 additions & 9 deletions

File tree

‎src/transformers/cli/serving/chat_completion.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ async def handle_request(self, body: dict, request_id: str) -> StreamingResponse
163163
inputs,
164164
gen_config,
165165
gen_manager=gen_manager,
166+
tools=body.get("tools"),
166167
)
167168
else:
168169
return await self._non_streaming(
@@ -173,6 +174,7 @@ async def handle_request(self, body: dict, request_id: str) -> StreamingResponse
173174
inputs,
174175
gen_config,
175176
gen_manager=gen_manager,
177+
tools=body.get("tools"),
176178
)
177179

178180
# ----- streaming -----
@@ -186,9 +188,10 @@ def _streaming(
186188
inputs: dict,
187189
gen_config: "GenerationConfig",
188190
gen_manager: BaseGenerateManager,
191+
tools=None,
189192
) -> StreamingResponse:
190193
"""Stream tokens as SSE via DirectStreamer."""
191-
response_parser = build_response_parser(processor, model, inputs["input_ids"])
194+
response_parser = build_response_parser(processor, model, inputs["input_ids"], tools=tools)
192195
queue, streamer = gen_manager.generate_streaming(
193196
model,
194197
processor,
@@ -288,6 +291,7 @@ async def _non_streaming(
288291
inputs: dict,
289292
gen_config: "GenerationConfig",
290293
gen_manager: BaseGenerateManager,
294+
tools=None,
291295
) -> JSONResponse:
292296
"""Run generation and return a JSONResponse."""
293297
content, input_len, generated_ids = await gen_manager.generate_non_streaming(
@@ -303,7 +307,7 @@ async def _non_streaming(
303307
)
304308

305309
content, reasoning_content, parsed_tool_calls = parse_assistant_message(
306-
processor, model, generated_ids, input_ids=inputs["input_ids"], cleaned_content=content
310+
processor, model, generated_ids, input_ids=inputs["input_ids"], cleaned_content=content, tools=tools
307311
)
308312
tool_calls = None
309313
if parsed_tool_calls:

‎src/transformers/cli/serving/response.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,7 @@ async def handle_request(self, body: dict, request_id: str) -> StreamingResponse
430430
inputs,
431431
gen_config,
432432
gen_manager=gen_manager,
433+
tools=tools,
433434
)
434435
else:
435436
return await self._non_streaming(
@@ -441,6 +442,7 @@ async def handle_request(self, body: dict, request_id: str) -> StreamingResponse
441442
inputs,
442443
gen_config,
443444
gen_manager=gen_manager,
445+
tools=tools,
444446
)
445447

446448
# ----- input conversion -----
@@ -569,9 +571,10 @@ def _streaming(
569571
inputs: dict,
570572
gen_config: "GenerationConfig",
571573
gen_manager: BaseGenerateManager,
574+
tools=None,
572575
) -> StreamingResponse:
573576
"""Generate a streaming Responses API reply (SSE) using DirectStreamer."""
574-
response_parser = build_response_parser(processor, model, inputs["input_ids"])
577+
response_parser = build_response_parser(processor, model, inputs["input_ids"], tools=tools)
575578
queue, streamer = gen_manager.generate_streaming(
576579
model,
577580
processor,
@@ -675,14 +678,20 @@ async def _non_streaming(
675678
inputs: dict,
676679
gen_config: "GenerationConfig",
677680
gen_manager: BaseGenerateManager,
681+
tools=None,
678682
) -> JSONResponse:
679683
"""Generate a non-streaming Responses API reply (single JSON)."""
680684
content, input_len, generated_ids = await gen_manager.generate_non_streaming(
681685
model, processor, inputs, gen_config, request_id=request_id
682686
)
683687

684688
content, reasoning_content, parsed_tool_calls = parse_assistant_message(
685-
processor, model, generated_ids, input_ids=inputs["input_ids"], cleaned_content=content
689+
processor,
690+
model,
691+
generated_ids,
692+
input_ids=inputs["input_ids"],
693+
cleaned_content=content,
694+
tools=tools,
686695
)
687696

688697
output_items = []

‎src/transformers/cli/serving/utils.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ def build_response_parser(
237237
processor,
238238
model: "PreTrainedModel",
239239
input_ids,
240+
tools=None,
240241
) -> "ResponseParser | None":
241242
"""Build a streaming :class:`ResponseParser` for the current generation.
242243
@@ -253,7 +254,7 @@ def build_response_parser(
253254
if template is None:
254255
return None
255256
tokenizer = getattr(processor, "tokenizer", processor)
256-
return ResponseParser(template, prefix=_decode_prefix(tokenizer, input_ids))
257+
return ResponseParser(template, prefix=_decode_prefix(tokenizer, input_ids), tools=tools)
257258

258259

259260
def _normalize_tool_call(value: dict) -> ToolCall:
@@ -293,6 +294,7 @@ def parse_assistant_message(
293294
generated_ids,
294295
input_ids,
295296
cleaned_content: str = "",
297+
tools=None,
296298
) -> tuple[str, str | None, list[ToolCall] | None]:
297299
"""Parse a full assistant message into ``(content, reasoning, tool_calls)``.
298300
@@ -308,7 +310,9 @@ def parse_assistant_message(
308310
if template is None:
309311
return cleaned_content, None, None
310312
tokenizer = getattr(processor, "tokenizer", processor)
311-
parsed = tokenizer.parse_response(generated_ids, template, prefix=_decode_prefix(tokenizer, input_ids))
313+
parsed = tokenizer.parse_response(
314+
generated_ids, template, prefix=_decode_prefix(tokenizer, input_ids), tools=tools
315+
)
312316
# Absent ``content`` key means the model emitted only tool calls / thinking;
313317
# surface that as an empty string rather than swapping in the raw decode.
314318
content = parsed.get("content", "")

‎src/transformers/pipelines/any_to_any.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,8 @@ def _sanitize_parameters(
233233
if skip_special_tokens is not None:
234234
postprocess_params["skip_special_tokens"] = skip_special_tokens
235235
postprocess_params["generation_mode"] = generation_mode
236+
if "tools" in kwargs:
237+
postprocess_params["tools"] = kwargs["tools"]
236238
return preprocess_params, forward_kwargs, postprocess_params
237239

238240
@overload
@@ -439,6 +441,7 @@ def postprocess(
439441
return_type=None,
440442
continue_final_message=None,
441443
skip_special_tokens=None,
444+
tools=None,
442445
**postprocess_kwargs,
443446
):
444447
input_texts = model_outputs["prompt_text"]
@@ -514,7 +517,9 @@ def postprocess(
514517
# New-style templates need to see the prompt as `prefix`, because chat
515518
# templates often pre-write part of the assistant message (e.g. an
516519
# opening <think> tag), which affects parsing.
517-
assistant_message = self.tokenizer.parse_response(generated_text, prefix=decoded_input)
520+
assistant_message = self.tokenizer.parse_response(
521+
generated_text, prefix=decoded_input, tools=tools
522+
)
518523
else:
519524
assistant_message = {"role": "assistant", "content": generated_text}
520525
generated_text = list(prompt_text.messages) + [assistant_message]

‎src/transformers/pipelines/image_text_to_text.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ def _sanitize_parameters(
194194
if skip_special_tokens is not None:
195195
postprocess_params["skip_special_tokens"] = skip_special_tokens
196196

197+
if "tools" in kwargs:
198+
postprocess_params["tools"] = kwargs["tools"]
197199
return preprocess_params, forward_kwargs, postprocess_params
198200

199201
@overload
@@ -403,6 +405,7 @@ def postprocess(
403405
return_type=ReturnType.FULL_TEXT,
404406
continue_final_message=None,
405407
skip_special_tokens=None,
408+
tools=None,
406409
**postprocess_kwargs,
407410
):
408411
input_texts = model_outputs["prompt_text"]
@@ -472,7 +475,9 @@ def postprocess(
472475
# New-style templates need to see the prompt as `prefix`, because chat
473476
# templates often pre-write part of the assistant message (e.g. an
474477
# opening <think> tag), which affects parsing.
475-
assistant_message = self.tokenizer.parse_response(generated_text, prefix=decoded_input)
478+
assistant_message = self.tokenizer.parse_response(
479+
generated_text, prefix=decoded_input, tools=tools
480+
)
476481
else:
477482
assistant_message = {"role": "assistant", "content": generated_text}
478483
generated_text = list(prompt_text.messages) + [assistant_message]

‎src/transformers/pipelines/text_generation.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ def _sanitize_parameters(
204204

205205
# postprocess kwargs
206206
postprocess_params = {}
207+
if tools is not None:
208+
postprocess_params["tools"] = tools
207209
if return_full_text is not None and return_type is None:
208210
if return_text is not None:
209211
raise ValueError("`return_text` is mutually exclusive with `return_full_text`")
@@ -427,6 +429,7 @@ def postprocess(
427429
clean_up_tokenization_spaces=None,
428430
continue_final_message=None,
429431
skip_special_tokens=None,
432+
tools=None,
430433
):
431434
generated_sequence = model_outputs["generated_sequence"][0]
432435
input_ids = model_outputs["input_ids"]
@@ -487,7 +490,9 @@ def postprocess(
487490
skip_special_tokens=skip_special_tokens,
488491
clean_up_tokenization_spaces=clean_up_tokenization_spaces,
489492
)
490-
assistant_message = self.tokenizer.parse_response(all_text, prefix=prompt_prefix)
493+
assistant_message = self.tokenizer.parse_response(
494+
all_text, prefix=prompt_prefix, tools=tools
495+
)
491496
else:
492497
# If there's no template, then we have to assume it's all content
493498
assistant_message = {"role": "assistant", "content": all_text}

‎tests/cli/test_serve.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
GenerationState,
4040
Modality,
4141
ToolCall,
42+
build_response_parser,
4243
get_response_template,
4344
parse_assistant_message,
4445
response_events_to_chunks,
@@ -1904,6 +1905,41 @@ def test_parse_assistant_message_tool_call(self):
19041905
called_template = tokenizer.parse_response.call_args.args[1]
19051906
self.assertIs(called_template, template)
19061907

1908+
def test_tool_schema_forwarded_to_serving_parsers(self):
1909+
tokenizer = MagicMock()
1910+
tokenizer.response_template = {
1911+
"defaults": {"role": "assistant"},
1912+
"fields": {},
1913+
}
1914+
tokenizer.decode.return_value = ""
1915+
tokenizer.parse_response.return_value = {"role": "assistant"}
1916+
1917+
processor = MagicMock()
1918+
model = MagicMock()
1919+
processor.tokenizer = tokenizer
1920+
1921+
tools = [
1922+
{
1923+
"type": "function",
1924+
"function": {"name": "set_alarm"},
1925+
}
1926+
]
1927+
1928+
for schema in (None, tools):
1929+
with self.subTest(tools=schema):
1930+
parse_assistant_message(processor, model, [2], [1], tools=schema)
1931+
self.assertEqual(
1932+
tokenizer.parse_response.call_args.kwargs["tools"],
1933+
schema,
1934+
)
1935+
1936+
with patch("transformers.cli.serving.utils.ResponseParser") as parser:
1937+
build_response_parser(processor, model, [1], tools=schema)
1938+
self.assertEqual(
1939+
parser.call_args.kwargs["tools"],
1940+
schema,
1941+
)
1942+
19071943
def test_parse_assistant_message_multiple_tool_calls(self):
19081944
tokenizer = MagicMock()
19091945
tokenizer.response_template = None

‎tests/pipelines/test_pipelines_text_generation.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,67 @@ def test_small_chat_model_with_response_template_prefix(self):
302302
self.assertIsInstance(parsed_message["reasoning_content"], str)
303303
self.assertGreater(len(parsed_message["reasoning_content"]), 0)
304304

305+
@require_torch
306+
def test_small_chat_model_forwards_tool_schema(self):
307+
text_generator = pipeline(
308+
"text-generation",
309+
model="hf-internal-testing/tiny-gpt2-with-chatml-template",
310+
)
311+
text_generator.tokenizer.chat_template = (
312+
"{% for m in messages %}"
313+
"{{ '<|im_start|>' + m['role'] + '\n' + m['content'] + '<|im_end|>\n' }}"
314+
"{% endfor %}"
315+
"{% if add_generation_prompt %}"
316+
"{{ '<|im_start|>assistant\n<tool_call>\n<function=set_alarm>\n'"
317+
" + '<parameter=hour>7</parameter>\n'"
318+
" + '<parameter=enabled>false</parameter>\n'"
319+
" + '</function>\n</tool_call>' }}"
320+
"{% endif %}"
321+
)
322+
text_generator.tokenizer.response_template = {
323+
"defaults": {"role": "assistant"},
324+
"start_anchor": "<|im_start|>assistant\n",
325+
"fields": {
326+
"tool_calls": {
327+
"open_pattern": r"<tool_call>\s*<function=(?P<name>\w+)>",
328+
"close": "</tool_call>",
329+
"repeats": True,
330+
"content": "xml-inline",
331+
"content_args": {"tag_pattern": r"<parameter=(?P<key>\w+)>\s*(?P<value>.*?)\s*</parameter>"},
332+
"transform": {
333+
"type": "function",
334+
"function": {
335+
"name": "{name}",
336+
"arguments": "{content}",
337+
},
338+
},
339+
}
340+
},
341+
}
342+
tools = [
343+
{
344+
"type": "function",
345+
"function": {
346+
"name": "set_alarm",
347+
"parameters": {
348+
"type": "object",
349+
"properties": {
350+
"hour": {"type": "integer"},
351+
"enabled": {"type": "boolean"},
352+
},
353+
},
354+
},
355+
}
356+
]
357+
result = text_generator(
358+
[{"role": "user", "content": "Set alarm"}],
359+
tools=tools,
360+
do_sample=False,
361+
max_new_tokens=1,
362+
)
363+
arguments = result[0]["generated_text"][-1]["tool_calls"][0]["function"]["arguments"]
364+
self.assertEqual(arguments, {"hour": 7, "enabled": False})
365+
305366
@require_torch
306367
def test_return_full_text_false_with_chat_template(self):
307368
"""Regression test for #45854: return_full_text=False must not include prompt when using chat template."""

0 commit comments

Comments
 (0)