Files
wehub-resource-sync 59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

709 lines
22 KiB
Python

# Copyright (c) 2026 LightSeek Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Unit tests for the smg orchestrator lifecycle."""
from __future__ import annotations
import asyncio
import json
import os
import signal
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.join(REPO_ROOT, "python"))
from tokenspeed.cli._argsplit import OrchestratorOpts
from tokenspeed.cli.serve_smg import (
_DEFAULT_SMG_DISABLE_FLAGS,
DEEPSEEK_V4_REASONING_PARSER,
DEEPSEEK_V4_TOOL_CALL_PARSER,
_args_with_default_model_parsers,
_gateway_args_with_default_log_level,
_gateway_args_with_default_policy,
_gateway_args_with_default_port,
_gateway_args_with_default_prometheus_port,
_gateway_args_with_default_reasoning_parser,
_gateway_args_with_defaults,
_gateway_args_with_smg_disable_defaults,
_is_deepseek_v4_model,
_prewarm_hf_tokenizer,
_user_host_port_from_gateway_args,
_user_model_id,
run_smg,
)
def _make_proc(returncode: int | None = None) -> MagicMock:
proc = MagicMock()
proc.returncode = returncode
proc.stdout = asyncio.StreamReader()
proc.stderr = asyncio.StreamReader()
proc.stdout.feed_eof()
proc.stderr.feed_eof()
proc.terminate = MagicMock()
proc.kill = MagicMock()
proc.wait = AsyncMock(return_value=returncode if returncode is not None else 0)
return proc
def test_gateway_args_default_port_is_8000():
gateway_args = _gateway_args_with_default_port(["--model", "/tmp/x"])
assert gateway_args == ["--model", "/tmp/x", "--port", "8000"]
assert _user_host_port_from_gateway_args(gateway_args) == ("0.0.0.0", 8000)
def test_gateway_args_preserve_user_port():
gateway_args = _gateway_args_with_default_port(["--port", "8413"])
assert gateway_args == ["--port", "8413"]
assert _user_host_port_from_gateway_args(gateway_args) == ("0.0.0.0", 8413)
def test_gateway_args_default_reasoning_parser_is_passthrough():
gateway_args = _gateway_args_with_default_reasoning_parser(["--model", "/tmp/x"])
assert gateway_args == ["--model", "/tmp/x", "--reasoning-parser", "passthrough"]
def test_gateway_args_preserve_user_reasoning_parser():
gateway_args = _gateway_args_with_default_reasoning_parser(
["--reasoning-parser", "qwen3"]
)
assert gateway_args == ["--reasoning-parser", "qwen3"]
def test_gateway_args_default_policy_is_passthrough():
gateway_args = _gateway_args_with_default_policy(["--model", "/tmp/x"])
assert gateway_args == ["--model", "/tmp/x", "--policy", "passthrough"]
def test_gateway_args_preserve_user_policy():
gateway_args = _gateway_args_with_default_policy(["--policy", "round_robin"])
assert gateway_args == ["--policy", "round_robin"]
def test_gateway_args_defaults_include_port_and_reasoning_parser():
gateway_args = _gateway_args_with_defaults(["--model", "/tmp/x"])
assert gateway_args == [
"--model",
"/tmp/x",
"--port",
"8000",
"--reasoning-parser",
"passthrough",
"--disable-circuit-breaker",
"--disable-retries",
"--policy",
"passthrough",
"--tokenizer-cache-enable-l0",
"--tokenizer-cache-enable-l1",
"--log-level",
"warn",
"--prometheus-port",
"8413",
]
def test_gateway_args_defaults_inject_passthrough_policy():
"""``ts serve`` fronts a single backend, so the default routing policy is
``passthrough`` (no load balancing / monitoring / KV-event subscription)."""
gateway_args = _gateway_args_with_defaults(["--model", "/tmp/x"])
assert "--policy" in gateway_args
idx = gateway_args.index("--policy")
assert gateway_args[idx + 1] == "passthrough"
def test_gateway_args_defaults_preserve_user_policy():
"""An explicit operator ``--policy`` is never overridden by the default."""
gateway_args = _gateway_args_with_defaults(
["--model", "/tmp/x", "--policy", "round_robin"]
)
# Exactly one --policy (default not appended on top of the explicit value).
assert gateway_args.count("--policy") == 1
idx = gateway_args.index("--policy")
assert gateway_args[idx + 1] == "round_robin"
def test_gateway_args_default_log_level_is_warn():
gateway_args = _gateway_args_with_default_log_level(["--model", "/tmp/x"])
assert gateway_args == ["--model", "/tmp/x", "--log-level", "warn"]
def test_gateway_args_preserve_user_log_level():
gateway_args = _gateway_args_with_default_log_level(["--log-level", "debug"])
assert gateway_args == ["--log-level", "debug"]
def test_gateway_args_default_prometheus_port_is_8413():
gateway_args = _gateway_args_with_default_prometheus_port(["--model", "/tmp/x"])
assert gateway_args == ["--model", "/tmp/x", "--prometheus-port", "8413"]
def test_gateway_args_preserve_user_prometheus_port():
gateway_args = _gateway_args_with_default_prometheus_port(
["--prometheus-port", "29000"]
)
assert gateway_args == ["--prometheus-port", "29000"]
def test_smg_disable_flags_appended_when_absent():
gateway_args = _gateway_args_with_smg_disable_defaults(["--model", "/tmp/x"])
assert gateway_args == [
"--model",
"/tmp/x",
"--disable-circuit-breaker",
"--disable-retries",
]
def test_smg_disable_flags_not_duplicated():
"""Idempotent: passing the flag explicitly must not double it up for smg's argparse."""
gateway_args = _gateway_args_with_smg_disable_defaults(
["--disable-circuit-breaker"]
)
assert gateway_args == [
"--disable-circuit-breaker",
"--disable-retries",
]
def test_smg_disable_flag_set_covers_both():
assert _DEFAULT_SMG_DISABLE_FLAGS == (
"--disable-circuit-breaker",
"--disable-retries",
)
def test_user_model_id_extracts_value():
assert (
_user_model_id(["--model", "nvidia/Qwen3.5-397B-A17B-NVFP4", "--port", "8000"])
== "nvidia/Qwen3.5-397B-A17B-NVFP4"
)
def test_user_model_id_returns_none_when_absent():
assert _user_model_id(["--port", "8000"]) is None
def test_user_model_id_returns_none_when_model_lacks_value():
assert _user_model_id(["--model"]) is None
def test_deepseek_v4_model_id_gets_default_parsers():
model = "deepseek-ai/DeepSeek-V4-Flash"
engine_args, gateway_args = _args_with_default_model_parsers(
["--model", model],
["--model", model],
)
assert engine_args == [
"--model",
model,
"--reasoning-parser",
DEEPSEEK_V4_REASONING_PARSER,
]
assert gateway_args == [
"--model",
model,
"--reasoning-parser",
DEEPSEEK_V4_REASONING_PARSER,
"--tool-call-parser",
DEEPSEEK_V4_TOOL_CALL_PARSER,
]
def test_deepseek_v4_parser_defaults_preserve_explicit_user_values():
model = "deepseek-ai/DeepSeek-V4-Flash"
engine_args, gateway_args = _args_with_default_model_parsers(
["--model", model, "--reasoning-parser", "none"],
[
"--model",
model,
"--reasoning-parser",
"none",
"--tool-call-parser",
"json",
],
)
assert engine_args == ["--model", model, "--reasoning-parser", "none"]
assert gateway_args == [
"--model",
model,
"--reasoning-parser",
"none",
"--tool-call-parser",
"json",
]
def test_deepseek_v4_parser_defaults_fill_missing_parser_only():
model = "deepseek-ai/DeepSeek-V4-Flash"
engine_args, gateway_args = _args_with_default_model_parsers(
["--model", model, "--reasoning-parser", "none"],
["--model", model, "--reasoning-parser", "none"],
)
assert engine_args == ["--model", model, "--reasoning-parser", "none"]
assert gateway_args == [
"--model",
model,
"--reasoning-parser",
"none",
"--tool-call-parser",
DEEPSEEK_V4_TOOL_CALL_PARSER,
]
engine_args, gateway_args = _args_with_default_model_parsers(
["--model", model],
["--model", model, "--tool-call-parser", "json"],
)
assert engine_args == [
"--model",
model,
"--reasoning-parser",
DEEPSEEK_V4_REASONING_PARSER,
]
assert gateway_args == [
"--model",
model,
"--tool-call-parser",
"json",
"--reasoning-parser",
DEEPSEEK_V4_REASONING_PARSER,
]
def test_deepseek_v4_default_reasoning_parser_survives_gateway_defaults():
model = "deepseek-ai/DeepSeek-V4-Flash"
_, gateway_args = _args_with_default_model_parsers(
["--model", model],
["--model", model],
)
gateway_args = _gateway_args_with_defaults(gateway_args)
# Exactly one reasoning parser, and it is the deepseek-v4 default — the
# generic passthrough reasoning-parser default must not be layered on top.
# Check the parser slot rather than bare membership: the value "passthrough"
# (== DEFAULT_REASONING_PARSER) now also appears as the --policy value, which
# is an unrelated flag.
assert gateway_args.count("--reasoning-parser") == 1
idx = gateway_args.index("--reasoning-parser")
assert gateway_args[idx + 1] == DEEPSEEK_V4_REASONING_PARSER
def test_local_deepseek_v4_config_is_detected(tmp_path):
(tmp_path / "config.json").write_text(json.dumps({"model_type": "deepseek_v4"}))
assert _is_deepseek_v4_model(str(tmp_path))
def test_prewarm_skips_local_path(tmp_path):
with patch(
"huggingface_hub.snapshot_download", side_effect=AssertionError("must not call")
) as sd:
_prewarm_hf_tokenizer(str(tmp_path))
sd.assert_not_called()
def test_prewarm_skips_empty():
with patch("huggingface_hub.snapshot_download") as sd:
_prewarm_hf_tokenizer("")
sd.assert_not_called()
def test_prewarm_fetches_tokenizer_artifacts_for_hf_id():
with patch("huggingface_hub.snapshot_download") as sd:
_prewarm_hf_tokenizer("nvidia/Qwen3.5-397B-A17B-NVFP4")
sd.assert_called_once()
_, kwargs = sd.call_args
assert kwargs["repo_id"] == "nvidia/Qwen3.5-397B-A17B-NVFP4"
# Patterns must include tokenizer.json and the surrounding JSON configs;
# avoid pulling weight files (no `*.safetensors` etc.).
patterns = set(kwargs["allow_patterns"])
assert "tokenizer*" in patterns
assert "*.json" in patterns
def test_prewarm_swallows_download_errors():
with patch(
"huggingface_hub.snapshot_download", side_effect=RuntimeError("HF down")
):
# Must not raise — smg's own retry path is the fallback.
_prewarm_hf_tokenizer("nvidia/Qwen3.5-397B-A17B-NVFP4")
@pytest.mark.asyncio
async def test_engine_start_timeout_kills_engine_and_exits_nonzero():
engine = _make_proc()
opts = OrchestratorOpts(engine_startup_timeout=0)
with patch(
"tokenspeed.cli.serve_smg.spawn_engine", AsyncMock(return_value=engine)
), patch(
"tokenspeed.cli.serve_smg.wait_grpc_serving",
AsyncMock(side_effect=TimeoutError("engine never reached SERVING")),
), patch(
"tokenspeed.cli.serve_smg.terminate_then_kill", AsyncMock()
) as tk:
rc = await run_smg(
engine_args=[],
gateway_args=[],
opts=opts,
user_host="127.0.0.1",
user_port=8000,
)
assert rc != 0
tk.assert_awaited_with(engine, drain_timeout=opts.drain_timeout)
@pytest.mark.asyncio
async def test_gateway_first_then_engine_on_clean_shutdown():
"""Gateway is SIGTERMed before the engine (front before back)."""
engine = _make_proc(returncode=0)
gateway = _make_proc(returncode=0)
call_order: list[str] = []
async def tracked_term(proc, *, drain_timeout):
if proc is gateway:
call_order.append("gateway")
elif proc is engine:
call_order.append("engine")
startup_done = asyncio.Event()
async def engine_wait():
await startup_done.wait()
return 0
async def gateway_wait():
await startup_done.wait()
return 0
engine.wait = AsyncMock(side_effect=engine_wait)
gateway.wait = AsyncMock(side_effect=gateway_wait)
async def probe_then_schedule_release(*args, **kwargs):
# Schedule release for the next tick so probe success is recorded
# before gateway.wait() resolves.
loop = asyncio.get_running_loop()
loop.call_later(0.05, startup_done.set)
opts = OrchestratorOpts(engine_startup_timeout=10, gateway_startup_timeout=10)
with patch(
"tokenspeed.cli.serve_smg.spawn_engine", AsyncMock(return_value=engine)
), patch(
"tokenspeed.cli.serve_smg.spawn_gateway", AsyncMock(return_value=gateway)
), patch(
"tokenspeed.cli.serve_smg.wait_grpc_serving", AsyncMock()
), patch(
"tokenspeed.cli.serve_smg.wait_http_ready",
side_effect=probe_then_schedule_release,
), patch(
"tokenspeed.cli.serve_smg.terminate_then_kill", side_effect=tracked_term
):
rc = await run_smg(
engine_args=[],
gateway_args=[],
opts=opts,
user_host="127.0.0.1",
user_port=8000,
)
assert call_order == ["gateway", "engine"]
assert rc == 0
@pytest.mark.asyncio
async def test_signal_handlers_installed_before_spawning_engine():
"""Signal handlers must be live before any subprocess."""
call_order: list[str] = []
real_loop = asyncio.get_running_loop()
real_add_signal_handler = real_loop.add_signal_handler
def tracking_add_signal_handler(sig, callback, *args):
call_order.append(f"add_signal_handler:{sig}")
return real_add_signal_handler(sig, callback, *args)
async def tracking_spawn_engine(*args, **kwargs):
call_order.append("spawn_engine")
raise RuntimeError("simulated spawn failure")
opts = OrchestratorOpts()
with patch.object(
real_loop, "add_signal_handler", side_effect=tracking_add_signal_handler
), patch(
"tokenspeed.cli.serve_smg.spawn_engine", side_effect=tracking_spawn_engine
):
with pytest.raises(RuntimeError, match="simulated spawn failure"):
await run_smg(
engine_args=[],
gateway_args=[],
opts=opts,
user_host="127.0.0.1",
user_port=8000,
)
# Both signals must have their handlers registered BEFORE spawn_engine.
assert "spawn_engine" in call_order
spawn_idx = call_order.index("spawn_engine")
sigterm_idx = call_order.index(f"add_signal_handler:{signal.SIGTERM}")
sigint_idx = call_order.index(f"add_signal_handler:{signal.SIGINT}")
assert sigterm_idx < spawn_idx, (
f"SIGTERM handler installed after spawn_engine " f"(order: {call_order})"
)
assert sigint_idx < spawn_idx, (
f"SIGINT handler installed after spawn_engine " f"(order: {call_order})"
)
@pytest.mark.asyncio
async def test_stop_during_engine_probe_exits_zero():
"""SIGTERM during engine readiness probe is a clean exit (rc=0)."""
engine = _make_proc(returncode=0)
opts = OrchestratorOpts(engine_startup_timeout=10)
async def slow_probe(*args, **kwargs):
await asyncio.sleep(60)
async def hung_wait():
await asyncio.sleep(60)
engine.wait = AsyncMock(side_effect=hung_wait)
stop = asyncio.Event()
loop = asyncio.get_running_loop()
loop.call_later(0.05, stop.set)
with patch(
"tokenspeed.cli.serve_smg.spawn_engine", AsyncMock(return_value=engine)
), patch(
"tokenspeed.cli.serve_smg.wait_grpc_serving", side_effect=slow_probe
), patch(
"tokenspeed.cli.serve_smg.terminate_then_kill", AsyncMock()
), patch(
"tokenspeed.cli.serve_smg.kill_process_tree", lambda *a, **kw: None
):
rc = await run_smg(
engine_args=[],
gateway_args=[],
opts=opts,
user_host="127.0.0.1",
user_port=8000,
_stop_event=stop,
)
assert rc == 0
@pytest.mark.asyncio
async def test_first_nonzero_child_exit_propagates():
engine = _make_proc(returncode=42)
gateway = _make_proc(returncode=0)
opts = OrchestratorOpts()
startup_done = asyncio.Event()
async def engine_wait():
await startup_done.wait()
return 42
async def gateway_wait():
await asyncio.Event().wait()
engine.wait = AsyncMock(side_effect=engine_wait)
gateway.wait = AsyncMock(side_effect=gateway_wait)
async def gateway_probe_then_release(*args, **kwargs):
loop = asyncio.get_running_loop()
loop.call_later(0.05, startup_done.set)
with patch(
"tokenspeed.cli.serve_smg.spawn_engine", AsyncMock(return_value=engine)
), patch(
"tokenspeed.cli.serve_smg.spawn_gateway", AsyncMock(return_value=gateway)
), patch(
"tokenspeed.cli.serve_smg.wait_grpc_serving", AsyncMock()
), patch(
"tokenspeed.cli.serve_smg.wait_http_ready",
side_effect=gateway_probe_then_release,
), patch(
"tokenspeed.cli.serve_smg.terminate_then_kill", AsyncMock()
):
rc = await run_smg(
engine_args=[],
gateway_args=[],
opts=opts,
user_host="127.0.0.1",
user_port=8000,
)
assert rc == 42
@pytest.mark.asyncio
async def test_engine_exit_during_probe_fails_fast():
"""If the engine exits before gRPC SERVING, return rc=1 immediately."""
engine = _make_proc(returncode=2)
opts = OrchestratorOpts(engine_startup_timeout=600)
async def hung_probe(*args, **kwargs):
await asyncio.sleep(60)
engine.wait = AsyncMock(return_value=2)
with patch(
"tokenspeed.cli.serve_smg.spawn_engine", AsyncMock(return_value=engine)
), patch(
"tokenspeed.cli.serve_smg.wait_grpc_serving", side_effect=hung_probe
), patch(
"tokenspeed.cli.serve_smg.terminate_then_kill", AsyncMock()
), patch(
"tokenspeed.cli.serve_smg.kill_process_tree", lambda *a, **kw: None
):
rc = await run_smg(
engine_args=[],
gateway_args=[],
opts=opts,
user_host="127.0.0.1",
user_port=8000,
)
assert rc == 1
@pytest.mark.asyncio
async def test_gateway_exit_during_probe_fails_fast():
"""If the gateway exits during wait_http_ready, return rc=1 immediately."""
engine = _make_proc(returncode=0)
gateway = _make_proc(returncode=2)
opts = OrchestratorOpts(engine_startup_timeout=10, gateway_startup_timeout=600)
async def hung_http(*args, **kwargs):
await asyncio.sleep(60)
gateway.wait = AsyncMock(return_value=2)
with patch(
"tokenspeed.cli.serve_smg.spawn_engine", AsyncMock(return_value=engine)
), patch(
"tokenspeed.cli.serve_smg.spawn_gateway", AsyncMock(return_value=gateway)
), patch(
"tokenspeed.cli.serve_smg.wait_grpc_serving", AsyncMock()
), patch(
"tokenspeed.cli.serve_smg.wait_http_ready", side_effect=hung_http
), patch(
"tokenspeed.cli.serve_smg.terminate_then_kill", AsyncMock()
), patch(
"tokenspeed.cli.serve_smg.kill_process_tree", lambda *a, **kw: None
):
rc = await run_smg(
engine_args=[],
gateway_args=[],
opts=opts,
user_host="127.0.0.1",
user_port=8000,
)
assert rc == 1
def test_run_smg_from_args_sets_process_title(monkeypatch):
"""Orchestrator sets proc title to 'ts-serve' so pgrep -f ts-serve finds it."""
captured = {}
def fake_run(*a, **kw):
return 0
monkeypatch.setattr("tokenspeed.cli.serve_smg.asyncio.run", fake_run)
monkeypatch.setattr(
"tokenspeed.cli.serve_smg._check_serve_extra_installed", lambda: None
)
fake_setproctitle = type(
"M", (), {"setproctitle": lambda title: captured.setdefault("title", title)}
)
monkeypatch.setitem(sys.modules, "setproctitle", fake_setproctitle)
from argparse import Namespace
from tokenspeed.cli.serve_smg import run_smg_from_args
try:
run_smg_from_args(Namespace(), ["--model", "/tmp/x"])
except SystemExit:
pass
assert captured.get("title") == "ts-serve"
def test_run_smg_from_args_applies_deepseek_v4_parser_defaults(monkeypatch):
captured = {}
async def fake_run_smg(**kwargs):
captured.update(kwargs)
return 0
monkeypatch.setattr("tokenspeed.cli.serve_smg.print_logo", lambda: None)
monkeypatch.setattr(
"tokenspeed.cli.serve_smg._check_serve_extra_installed", lambda: None
)
monkeypatch.setattr(
"tokenspeed.cli.serve_smg._prewarm_hf_tokenizer", lambda _: None
)
monkeypatch.setattr("tokenspeed.cli.serve_smg.run_smg", fake_run_smg)
from argparse import Namespace
from tokenspeed.cli.serve_smg import run_smg_from_args
with pytest.raises(SystemExit) as exc:
run_smg_from_args(Namespace(), ["deepseek-ai/DeepSeek-V4-Flash"])
assert exc.value.code == 0
assert captured["engine_args"] == [
"--model",
"deepseek-ai/DeepSeek-V4-Flash",
"--reasoning-parser",
DEEPSEEK_V4_REASONING_PARSER,
]
assert captured["gateway_args"][:6] == [
"--model",
"deepseek-ai/DeepSeek-V4-Flash",
"--reasoning-parser",
DEEPSEEK_V4_REASONING_PARSER,
"--tool-call-parser",
DEEPSEEK_V4_TOOL_CALL_PARSER,
]