Files
sgl-project--sglang/python/sglang/test/tool_call_test_runner.py
T
wehub-resource-sync 94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:38:16 +08:00

404 lines
12 KiB
Python

import json
from dataclasses import dataclass
from typing import List, Optional
import openai
from sglang.srt.utils import kill_process_tree
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
ModelLaunchSettings,
popen_launch_server,
)
@dataclass
class ToolCallTestParams:
test_basic: bool = True
test_auto: bool = True
test_streaming: bool = True
test_required: bool = True
test_none: bool = True
test_specific: bool = True
test_strict: bool = True
test_multiturn: bool = True
test_thinking: bool = False # model-specific, e.g. DeepSeek
test_reasoning_usage: bool = False # verify usage.reasoning_tokens > 0
test_parallel: bool = True
test_streaming_parallel: bool = True
@dataclass
class ToolCallTestResult:
model: str
passed: bool
num_passed: int
num_total: int
failures: List[str]
variant: Optional[str] = None
# ---- tool definitions ----
ADD_TOOL = {
"type": "function",
"function": {
"name": "add",
"description": "Compute the sum of two integers",
"parameters": {
"type": "object",
"properties": {
"a": {"type": "integer", "description": "First integer"},
"b": {"type": "integer", "description": "Second integer"},
},
"required": ["a", "b"],
},
},
}
WEATHER_TOOL = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
},
"required": ["city"],
},
},
}
ADD_TOOL_STRICT = {
"type": "function",
"function": {**ADD_TOOL["function"], "strict": True},
}
WEATHER_TOOL_STRICT = {
"type": "function",
"function": {**WEATHER_TOOL["function"], "strict": True},
}
def _call(
client,
model,
content,
tools=None,
tool_choice="required",
temperature=0.1,
**kwargs,
):
"""Single-turn tool call request. Defaults to ADD_TOOL_STRICT + required."""
return client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": content}],
tools=tools or [ADD_TOOL_STRICT],
tool_choice=tool_choice,
temperature=temperature,
**kwargs,
)
# ---- test cases ----
def _test_basic_format(client, model):
"""Format + field placement: tool_calls present, content empty, valid JSON args."""
response = _call(client, model, "Compute 3 + 5")
msg = response.choices[0].message
assert msg.tool_calls and len(msg.tool_calls) > 0
assert not msg.content, f"content should be empty, got: {msg.content}"
tc = msg.tool_calls[0]
assert tc.function.name == "add", f"expected 'add', got '{tc.function.name}'"
assert isinstance(json.loads(tc.function.arguments), dict)
assert response.choices[0].finish_reason == "tool_calls"
def _test_auto(client, model):
"""tool_choice=auto should populate tool_calls, not content (#17942)."""
response = _call(client, model, "Compute 3 + 5", tool_choice="auto")
msg = response.choices[0].message
assert msg.tool_calls and len(msg.tool_calls) > 0
assert not msg.content, f"content should be empty, got: {msg.content}"
assert response.choices[0].finish_reason == "tool_calls"
def _test_streaming(client, model):
"""Streaming chunks should concatenate to valid JSON."""
response = _call(client, model, "Compute 5 + 7", stream=True)
chunks = list(response)
assert len(chunks) > 0
arg_fragments = []
name = None
for chunk in chunks:
if chunk.choices[0].delta.tool_calls:
tc = chunk.choices[0].delta.tool_calls[0]
name = tc.function.name or name
if tc.function.arguments:
arg_fragments.append(tc.function.arguments)
assert name == "add", f"expected 'add', got '{name}'"
args = json.loads("".join(arg_fragments))
assert "a" in args and "b" in args
assert chunks[-1].choices[0].finish_reason == "tool_calls"
def _test_required(client, model):
"""tool_choice='required' must return a tool call even for unrelated queries."""
response = _call(
client,
model,
"What is the capital of France?",
tools=[ADD_TOOL, WEATHER_TOOL],
)
assert response.choices[0].message.tool_calls
def _test_none(client, model):
"""tool_choice='none' must not return any tool call."""
response = _call(client, model, "What is 1+1?", tool_choice="none")
assert response.choices[0].message.tool_calls is None
assert response.choices[0].finish_reason == "stop"
def _test_specific(client, model):
"""Specifying a function name should return that function."""
response = _call(
client,
model,
"What is the capital of France?",
tools=[ADD_TOOL, WEATHER_TOOL],
tool_choice={"type": "function", "function": {"name": "get_weather"}},
)
tc = response.choices[0].message.tool_calls
assert tc and tc[0].function.name == "get_weather"
def _test_strict(client, model):
"""strict: true should enforce schema on arguments."""
response = _call(client, model, "Compute 5 - 7")
args = json.loads(response.choices[0].message.tool_calls[0].function.arguments)
assert "a" in args and "b" in args
def _test_multiturn(client, model):
"""Pass tool result back, model should reply based on it."""
# turn 1: get tool call
messages = [{"role": "user", "content": "What is 3 + 5?"}]
r1 = client.chat.completions.create(
model=model,
messages=messages,
tools=[ADD_TOOL_STRICT],
tool_choice="required",
temperature=0.1,
)
tc = r1.choices[0].message.tool_calls[0]
# turn 2: pass result back
messages.append(r1.choices[0].message)
messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"content": "8",
"name": tc.function.name,
}
)
r2 = client.chat.completions.create(
model=model,
messages=messages,
tools=[ADD_TOOL],
temperature=0.1,
)
assert "8" in (r2.choices[0].message.content or "")
def _test_thinking(client, model):
"""After tool result with thinking enabled, output should be in content not reasoning_content."""
thinking_body = {"thinking": {"type": "enabled", "budget_tokens": 1024}}
# turn 1
messages = [{"role": "user", "content": "What is 3 + 5?"}]
r1 = client.chat.completions.create(
model=model,
messages=messages,
tools=[ADD_TOOL_STRICT],
tool_choice="required",
temperature=0.1,
extra_body=thinking_body,
)
tc = r1.choices[0].message.tool_calls[0]
# turn 2
messages.append(r1.choices[0].message)
messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"content": "8",
"name": tc.function.name,
}
)
r2 = client.chat.completions.create(
model=model,
messages=messages,
tools=[ADD_TOOL],
temperature=0.1,
extra_body=thinking_body,
)
content = r2.choices[0].message.content or ""
assert "8" in content, f"expected '8' in content, got: {content}"
def _test_reasoning_usage(client, model):
"""With thinking enabled, usage.reasoning_tokens should be reported as > 0."""
thinking_body = {"thinking": {"type": "enabled", "budget_tokens": 1024}}
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": "What is 3 + 5?"}],
tools=[ADD_TOOL_STRICT],
tool_choice="required",
temperature=0.1,
extra_body=thinking_body,
)
usage = response.usage
assert usage is not None, "usage should not be None"
assert (
usage.reasoning_tokens and usage.reasoning_tokens > 0
), f"expected reasoning_tokens > 0, got {usage.reasoning_tokens}"
if usage.completion_tokens_details:
detail_reasoning = usage.completion_tokens_details.get("reasoning_tokens", 0)
assert (
detail_reasoning > 0
), f"expected completion_tokens_details.reasoning_tokens > 0, got {detail_reasoning}"
def _test_parallel(client, model):
"""Single request should return multiple tool calls."""
response = _call(
client,
model,
"Please call both functions: use add to compute 3+5, and use get_weather to check the weather in Tokyo.",
tools=[ADD_TOOL_STRICT, WEATHER_TOOL_STRICT],
tool_choice="auto",
temperature=0,
)
tc = response.choices[0].message.tool_calls
assert tc and len(tc) >= 2, f"expected >= 2 tool calls, got {len(tc) if tc else 0}"
def _test_streaming_parallel(client, model):
"""Streaming with tool_choice=auto should return multiple tool calls."""
response = _call(
client,
model,
"What is 3+5 and what is the weather in Tokyo?",
tools=[ADD_TOOL, WEATHER_TOOL],
tool_choice="auto",
stream=True,
)
# collect tool calls from streaming chunks
tool_calls = {}
for chunk in response:
if not chunk.choices[0].delta.tool_calls:
continue
for tc in chunk.choices[0].delta.tool_calls:
idx = tc.index
if idx not in tool_calls:
tool_calls[idx] = {"name": "", "arguments": ""}
if tc.function.name:
tool_calls[idx]["name"] = tc.function.name
if tc.function.arguments:
tool_calls[idx]["arguments"] += tc.function.arguments
assert len(tool_calls) >= 2, f"expected >= 2 tool calls, got {len(tool_calls)}"
for idx, tc in tool_calls.items():
assert tc["name"], f"tool call {idx} missing function name"
args = json.loads(tc["arguments"])
assert isinstance(args, dict), f"tool call {idx} arguments not a dict"
_TESTS = [
("basic_format", _test_basic_format, "test_basic"),
# ("auto", _test_auto, "test_auto"),
("streaming", _test_streaming, "test_streaming"),
("required", _test_required, "test_required"),
("none", _test_none, "test_none"),
("specific", _test_specific, "test_specific"),
("strict", _test_strict, "test_strict"),
("multiturn", _test_multiturn, "test_multiturn"),
("thinking", _test_thinking, "test_thinking"),
# ("reasoning_usage", _test_reasoning_usage, "test_reasoning_usage"),
("parallel", _test_parallel, "test_parallel"),
# ("streaming_parallel", _test_streaming_parallel, "test_streaming_parallel"),
]
# ---- runner ----
def run_tool_call_test(
model: ModelLaunchSettings,
params: ToolCallTestParams,
base_url: Optional[str] = None,
) -> ToolCallTestResult:
"""Launch server, run enabled test cases, return results."""
base_url = base_url or DEFAULT_URL_FOR_TEST
print(f"\n{'=' * 60}")
print(f"Running TOOL CALL test for {model.model_path}")
if model.variant:
print(f" Variant: {model.variant}")
print(f"{'=' * 60}\n")
process = None
try:
process = popen_launch_server(
model.model_path,
base_url,
other_args=model.extra_args,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
env=model.env,
)
client = openai.Client(api_key="sk-test", base_url=base_url + "/v1")
passed_list = []
failed_list = []
for name, fn, flag in _TESTS:
if not getattr(params, flag):
continue
try:
fn(client, model.model_path)
passed_list.append(name)
print(f" PASS: {name}")
except Exception as e:
failed_list.append(f"{name}: {e}")
print(f" FAIL: {name}: {e}")
total = len(passed_list) + len(failed_list)
print(f"\n Result: {len(passed_list)}/{total} passed")
return ToolCallTestResult(
model=model.model_path,
passed=len(failed_list) == 0,
num_passed=len(passed_list),
num_total=total,
failures=failed_list,
variant=model.variant,
)
except Exception as e:
print(f" Server launch failed: {e}")
return ToolCallTestResult(
model=model.model_path,
passed=False,
num_passed=0,
num_total=0,
failures=[f"Server launch failed: {e}"],
variant=model.variant,
)
finally:
if process:
kill_process_tree(process.pid)