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
709 lines
22 KiB
Python
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,
|
|
]
|