Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions tensorrt_llm/serve/extensions/deepseek_v41.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,28 +30,34 @@ class DeepseekV41ServingExtension(ServingExtension):
"""Tool namespaces in the form the DeepSeek-V4.1 checkpoint takes them."""

def serialize_tool(self, tool) -> Dict[str, Any]:
"""Dump a namespaced tool with its namespace, any other tool as usual.
"""Dump a tool with only the fields the client set, a namespaced one with its namespace.

The checkpoint's encoder reads a ``namespace`` (name and description)
beside ``function``. It shows the tool as ``<namespace>::<name>``, the
name the model was trained to call it by, and puts the namespace
description in front of the tool's own. A tool without a namespace is
dumped exactly as on the generic path.
The checkpoint's encoder prints each tool as JSON and reads no field by
name, so unlike the generic dump, which is made for chat templates, a
tool declared without ``parameters`` is dumped without the key. The
encoder reads a ``namespace`` (name and description) beside
``function``. It shows the tool as ``<namespace>::<name>``, the name
the model was trained to call it by, and puts the namespace description
in front of the tool's own. The namespaced form also leaves out a
description the tool or its namespace did not declare.
"""
dumped = tool.model_dump()
dumped = tool.model_dump(exclude_none=True)
if not isinstance(tool, NamespacedFunctionToolParam):
return dumped
if "::" in tool.namespace or "::" in tool.declared_name:
# The encoder rejects such a name; the flattened one renders.
return dumped
function = dumped["function"]
function["name"] = tool.declared_name
function["description"] = tool.declared_description
if tool.declared_description is None:
function.pop("description", None)
else:
function["description"] = tool.declared_description
namespace = {"name": tool.namespace}
if tool.namespace_description is not None:
namespace["description"] = tool.namespace_description
return {
"type": dumped["type"],
"namespace": {
"name": tool.namespace,
"description": tool.namespace_description,
},
"namespace": namespace,
"function": function,
}
3 changes: 2 additions & 1 deletion tensorrt_llm/serve/harmony_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,8 @@ def harmony_developer_message(
for tool in tools:
func_def = tool.get("function", {})
func_name = func_def.get("name", "")
func_description = func_def.get("description", "")
# A request tool dumps an unset description as None.
func_description = func_def.get("description") or ""
func_parameters = func_def.get("parameters", {})

if func_name:
Expand Down
2 changes: 1 addition & 1 deletion tensorrt_llm/serve/render/prompt_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class UnsupportedRenderError(ValueError):


class HarmonyConversionError(RuntimeError):
"""The Harmony adapter could not convert the messages (a tool without a description)."""
"""The Harmony adapter could not convert the messages (tool-call arguments not a string)."""


class UnrenderableMessagesError(UnsupportedRenderError):
Expand Down
4 changes: 2 additions & 2 deletions tensorrt_llm/serve/render/serving.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ async def render_chat(self, body: Dict[str, Any]) -> RenderedRequest:
except ValueError as error:
raise RenderRequestError(400, str(error)) from error
except RuntimeError as error:
# Harmony reports messages it cannot convert (a tool without a description,
# null content) as a RuntimeError; the chat route answers those with a 400.
# Harmony reports messages it cannot convert (tool-call arguments that are not a
# string, null content) as a RuntimeError; the chat route answers those with a 400.
if not resources.use_harmony:
raise
raise RenderRequestError(400, str(error)) from error
Expand Down
2 changes: 1 addition & 1 deletion tensorrt_llm/serve/responses_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,7 @@ def _get_developer_message(instructions: Optional[str] = None,
function_tool_descriptions = [
ToolDescription.new(
name=tool.name,
description=tool.description,
description=tool.description or "",
parameters=tool.parameters,
) for tool in function_tools
]
Expand Down
16 changes: 13 additions & 3 deletions tensorrt_llm/serve/serving_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ class ServingExtension:

# Part of the rendering fingerprint: bump it when a revision of the extension changes
# the prompt it renders, so prepared requests from the older revision are refused.
render_version: int = 1
render_version: int = 2

def render_prompt(
self, request: "ChatCompletionRequest", res: Any = None
Expand All @@ -87,8 +87,18 @@ def allows_required_tool_choice(self) -> bool:
return False

def serialize_tool(self, tool) -> Dict[str, Any]:
"""Dump one request tool into the dict handed to the chat template."""
return tool.model_dump()
"""Dump one request tool into the dict handed to the chat template.

An optional field the client left unset or ``null`` (``strict``,
``description``) is omitted rather than dumped as ``null``, which a
template that prints tools as JSON would show the model. ``parameters``
is always there, ``null`` for a tool declared without any: templates
read it by name (``tool.function.parameters | tojson``) and fail when
it is missing. Every field the client set, such as ``strict``, is kept,
and ``parameters`` is passed through as sent.
"""
unset = {name for name, value in tool.function if value is None and name != "parameters"}
return tool.model_dump(exclude={"function": unset})

def dynamic_tools(self, messages) -> List[Dict[str, Any]]:
"""Tool declarations carried on messages rather than on the request."""
Expand Down
33 changes: 32 additions & 1 deletion tests/unittest/llmapi/apps/test_anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
UsageInfo,
_response_format_to_guided_decoding_params,
)
from tensorrt_llm.serve.serving_extensions import apply_model_chat_extensions
from tensorrt_llm.serve.serving_extensions import apply_model_chat_extensions, get_serving_extension

# These tests are CPU-only (no GPU, engine or sockets) and run in the
# CPU-Generic CI stage, which selects with `-m cpu_only`.
Expand Down Expand Up @@ -549,6 +549,37 @@ def test_client_tool_with_a_schema_is_converted(tool):
assert chat.tool_choice == "auto"


_READ_SCHEMA = {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}


@pytest.mark.parametrize("model_type", [None, "deepseek_v41"], ids=["generic", "deepseek_v41"])
@pytest.mark.parametrize(
"declared",
[
pytest.param({"name": "read", "input_schema": _READ_SCHEMA}, id="schema_only"),
pytest.param(
{"name": "read", "description": "Read a file.", "input_schema": _READ_SCHEMA},
id="described",
),
pytest.param({"name": "read", "input_schema": _READ_SCHEMA, "strict": True}, id="strict"),
],
)
def test_a_converted_tool_reaches_the_template_with_only_the_declared_fields(declared, model_type):
"""A field the client left out does not reach the chat template as ``null``."""
chat = convert_anthropic_request(make_request(tools=[declared]))
function = {"name": "read"}
if "description" in declared:
function["description"] = declared["description"]
function["parameters"] = _READ_SCHEMA
if "strict" in declared:
function["strict"] = declared["strict"]
extension = get_serving_extension(model_type)

assert [extension.serialize_tool(tool) for tool in chat.tools] == [
{"type": "function", "function": function}
]


@pytest.mark.parametrize(
("tool_type", "tool_name"),
[
Expand Down
41 changes: 27 additions & 14 deletions tests/unittest/llmapi/apps/test_render_callers.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ def test_an_explicit_use_harmony_renders_without_a_resolvable_model_type(
assert rendered.token_ids == tokenize_harmony_chat_request(chat_request())

def test_a_harmony_conversion_error_is_routed_on_no_ids(self, harmony_vocab) -> None:
# A tool without a description cannot be converted to Harmony.
# Tool-call arguments sent as an object, not a JSON string, cannot be converted to Harmony.
from tensorrt_llm.serve.router import KvCacheAwareRouter

router = KvCacheAwareRouter(
Expand All @@ -511,13 +511,24 @@ def test_a_harmony_conversion_error_is_routed_on_no_ids(self, harmony_vocab) ->
tokens_per_block=32,
use_harmony=True,
)
no_description = {
"type": "function",
"function": {"name": "f", "parameters": {"type": "object", "properties": {}}},
call = {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {"name": "get_weather", "arguments": {"city": "paris"}},
}
],
}
named = {"type": "function", "function": {"name": "f"}}
named = {"type": "function", "function": {"name": "get_weather"}}
for tool_choice in (None, named):
request = chat_request(tools=[no_description], tool_choice=tool_choice)
request = chat_request(
messages=[{"role": "user", "content": "weather in paris?"}, call],
tools=[WEATHER_TOOL],
tool_choice=tool_choice,
)

assert router._tokenize(request) == [[]]
assert request.prompt_token_ids is None
Expand Down Expand Up @@ -709,17 +720,19 @@ def test_a_harmony_server_counts_the_harmony_prompt(self, tokenizer, harmony_voc
assert response.json() == {"input_tokens": len(expected)}

def test_a_harmony_server_estimates_what_harmony_cannot_convert(
self, tokenizer, harmony_vocab
self, tokenizer, harmony_vocab, monkeypatch
) -> None:
# A tool without a description is rejected by Harmony; the count falls back to the
# template count.
body = {
**self.BODY,
"tools": [{"name": "f", "input_schema": {"type": "object", "properties": {}}}],
}
# When Harmony rejects the request, the count falls back to the template count.
from tensorrt_llm.serve.harmony_adapter import HarmonyAdapter
from tensorrt_llm.serve.render import HarmonyConversionError

def reject(*args, **kwargs):
raise HarmonyConversionError("cannot convert")

monkeypatch.setattr(HarmonyAdapter, "openai_to_harmony_tokens", reject)

response = self._client(tokenizer, use_harmony=True).post(
"/v1/messages/count_tokens", json=body
"/v1/messages/count_tokens", json=self.BODY
)

assert response.status_code == 200, response.text
Expand Down
63 changes: 58 additions & 5 deletions tests/unittest/llmapi/apps/test_render_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import dataclasses
import gc
import warnings
from pathlib import Path
from types import SimpleNamespace

import pytest
Expand Down Expand Up @@ -65,6 +66,15 @@

pytestmark = pytest.mark.cpu_only

# Shipped for ``trtllm-serve --chat_template``; it reads each tool's fields by name.
_DEEPSEEK_V31_TOOL_TEMPLATE = (
Path(__file__).parents[4]
/ "examples"
/ "serve"
/ "chat_templates"
/ "tool_chat_template_deepseekv31.jinja"
)


@pytest.fixture(scope="module")
def tokenizer():
Expand Down Expand Up @@ -93,15 +103,37 @@ def test_renders_and_tokenizes_like_the_template_and_the_tokenizer(self, tokeniz
assert result.token_ids == ids
assert result.prefix_applied is False

def test_tools_reach_the_template(self, tokenizer) -> None:
request = chat_request(tools=[WEATHER_TOOL])
text, ids = _expected_ids(tokenizer, request, tools=[t.model_dump() for t in request.tools])
@pytest.mark.parametrize("strict", [None, True], ids=["no_strict", "strict"])
def test_tools_reach_the_template_as_the_client_declared_them(self, tokenizer, strict) -> None:
tool = copy.deepcopy(WEATHER_TOOL)
if strict is not None:
tool["function"]["strict"] = strict
request = chat_request(tools=[tool])
text, ids = _expected_ids(tokenizer, request, tools=[tool])

result = render_chat(request, resources(tokenizer))

assert "get_weather" in result.text # the real template prints the tool schema
# The real template prints each tool as JSON: no field the client left out
# shows up as null, and one it set is printed.
assert "get_weather" in result.text
assert ('"strict"' in result.text) is (strict is not None)
assert result.text == text
assert result.token_ids == ids

def test_a_tool_without_parameters_renders_with_a_template_that_reads_them(
self, tokenizer
) -> None:
"""The template prints ``tool.function.parameters | tojson``: it gets ``null``."""
tool = {
"type": "function",
"function": {"name": "get_time", "description": "Current UTC time"},
}
res = resources(tokenizer, default_chat_template=_DEEPSEEK_V31_TOOL_TEMPLATE.read_text())

result = render_chat(chat_request(tools=[tool]), res, tokenize=False)

assert "\n### get_time\nDescription: Current UTC time\n\nParameters: null\n" in result.text

def test_tokenize_false_leaves_only_the_text(self, tokenizer) -> None:
result = render_chat(chat_request(), resources(tokenizer), tokenize=False)
assert result.text is not None
Expand Down Expand Up @@ -551,6 +583,26 @@ def test_the_harmony_reasoning_effort_reaches_the_ids(self, harmony_vocab) -> No

assert low.token_ids != high.token_ids

def test_harmony_renders_a_tool_without_a_description_like_an_empty_one(
self, harmony_vocab
) -> None:
def ids(**function):
tool = {"type": "function", "function": {"name": "get_weather", **function}}
return render_chat(chat_request(tools=[tool]), harmony_resources()).token_ids

parameters = WEATHER_TOOL["function"]["parameters"]
assert ids(parameters=parameters) == ids(description="", parameters=parameters)

def test_harmony_renders_a_tool_declared_by_name_alone_as_taking_no_arguments(
self, harmony_vocab
) -> None:
from tensorrt_llm.serve.harmony_adapter import get_harmony_adapter

tool = {"type": "function", "function": {"name": "get_time"}}
ids = render_chat(chat_request(tools=[tool]), harmony_resources()).token_ids

assert "type get_time = () => any;" in get_harmony_adapter().encoding.decode(ids)

def test_a_gpt_oss_model_without_the_harmony_switch_uses_the_template(
self, harmony_vocab, tokenizer
) -> None:
Expand Down Expand Up @@ -580,7 +632,8 @@ async def run():
conversation=conversation,
mm_placeholder_counts=counts,
add_generation_prompt=True,
tools=[tool.model_dump() for tool in request.tools],
# Serialized as the chat route serializes them.
tools=[res.extension.serialize_tool(tool) for tool in request.tools],
tokenize=True,
),
mm_coroutine,
Expand Down
17 changes: 13 additions & 4 deletions tests/unittest/llmapi/apps/test_render_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,11 +378,20 @@ class TestHarmonyConversionErrors:
"""Harmony reports messages it cannot convert as a RuntimeError; the chat route answers 400."""

def test_a_conversion_failure_is_a_400_not_a_500(self, harmony_vocab) -> None:
# A tool without a description: the real Harmony conversion rejects it.
body = {
**CHAT_BODY,
"tools": [{"type": "function", "function": {"name": "f", "parameters": {}}}],
# Tool-call arguments sent as an object, not a JSON string: the real Harmony conversion
# rejects them.
call = {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {"name": "get_weather", "arguments": {"city": "paris"}},
}
],
}
body = {**CHAT_BODY, "messages": [*CHAT_BODY["messages"], call], "tools": [WEATHER_TOOL]}

response = TestClient(build_render_app(harmony_resources())).post(
"/v1/chat/completions/render", json=body
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,12 @@ def _checkpoint_tools():
)
tools = []
for namespace, name, description, parameters in _DECLARED:
# Every function the server hands over carries `strict`, unset, and the
# encoder renders every key it is given.
function = {
"name": name,
"description": description,
"parameters": freeform.function.parameters if parameters is None else parameters,
"strict": None,
}
# The encoder renders every key it is given. A function carries its name,
# its description when the tool declares one, and its parameters.
function = {"name": name}
if description is not None:
function["description"] = description
function["parameters"] = freeform.function.parameters if parameters is None else parameters
tool = {"type": "function", "function": function}
if namespace is not None:
tool["namespace"] = {"name": namespace, "description": _NAMESPACES[namespace]}
Expand Down Expand Up @@ -179,6 +177,14 @@ def test_namespaced_tools_are_shown_in_the_checkpoint_form():
assert descriptions["functions::exec"] == "Runs code."


@pytest.mark.parametrize("model_type", [MODEL_TYPE, "generic"])
def test_a_tool_is_shown_without_fields_it_left_unset(model_type):
"""No field a tool did not set is shown, as ``null`` or otherwise."""
for schema in _tool_schemas(_render(_request(), model_type=model_type)):
assert set(schema) <= {"name", "description", "parameters"}
assert None not in schema.values()


def test_a_request_without_namespaces_renders_as_on_the_generic_path():
"""Only namespaced tools change; the generic path is a model with no extension."""
flat_tools = [tool for tool in _responses_tools() if tool["type"] != "namespace"]
Expand Down
Loading
Loading