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

614 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.
"""``ts serve`` orchestrator: spawn smg gateway + gRPC engine, tag logs, probe
readiness, and tear down gateway-first on shutdown."""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import signal
import sys
from pathlib import Path
from tokenspeed.cli._argsplit import OrchestratorOpts, split_argv
from tokenspeed.cli._logo import print_logo
from tokenspeed.cli._logprefix import ENGINE_TAG, GATEWAY_TAG, tag_stream
from tokenspeed.cli._proc import (
spawn_engine,
spawn_gateway,
terminate_then_kill,
wait_grpc_serving,
wait_http_ready,
)
from tokenspeed.runtime.utils.network import get_free_port
from tokenspeed.runtime.utils.process import kill_process_tree
logger = logging.getLogger(__name__)
DEFAULT_GATEWAY_HOST = "0.0.0.0"
DEFAULT_GATEWAY_PORT = 8000
DEFAULT_REASONING_PARSER = "passthrough"
DEEPSEEK_V4_REASONING_PARSER = "deepseek_v31"
DEEPSEEK_V4_TOOL_CALL_PARSER = "deepseek_v4"
GLM_REASONING_PARSER = "glm45"
GLM_TOOL_CALL_PARSER = "glm47_moe"
DEFAULT_SMG_LOG_LEVEL = "warn"
DEFAULT_SMG_PROMETHEUS_PORT = 8413
# smg routing policy for ``ts serve``. Distinct from DEFAULT_REASONING_PARSER,
# which happens to share the "passthrough" string but configures an unrelated
# flag (--reasoning-parser).
DEFAULT_SMG_POLICY = "passthrough"
# smg reliability knobs we always want disabled when launched under
# ts serve. These are tokenspeed-internal defaults: not surfaced via
# the ts CLI, not routed through split_argv.
_DEFAULT_SMG_DISABLE_FLAGS = (
"--disable-circuit-breaker",
"--disable-retries",
)
def _check_serve_extra_installed() -> None:
import importlib.util
missing: list[str] = []
if importlib.util.find_spec("smg") is None:
missing.append("tokenspeed-smg")
if importlib.util.find_spec("smg_grpc_servicer.tokenspeed.server") is None:
missing.append("tokenspeed-smg-grpc-servicer")
if missing:
sys.stderr.write(
"ts serve requires the bundled gateway packages, normally installed\n"
"as part of `tokenspeed`. Reinstall tokenspeed to restore them:\n\n"
" pip install --force-reinstall --no-deps tokenspeed\n\n"
"or install them explicitly:\n\n"
" pip install \\\n"
" tokenspeed-smg \\\n"
" tokenspeed-smg-grpc-servicer \\\n"
" tokenspeed-smg-grpc-proto\n\n"
f"Missing: {', '.join(missing)}\n"
)
sys.exit(1)
def _user_host_port_from_gateway_args(gateway_args: list[str]) -> tuple[str, int]:
"""Pull --host / --port out of the gateway-bound argv.
Defaults match TokenSpeed's public serving endpoint. The argv MUST
be in canonical ``[--flag, value, ...]`` form as produced by
``split_argv``; equals-form (``--port=8000``) is not handled here.
"""
host = DEFAULT_GATEWAY_HOST
port = DEFAULT_GATEWAY_PORT
it = iter(gateway_args)
for token in it:
if token == "--host":
host = next(it)
elif token == "--port":
port = int(next(it))
return host, port
def _gateway_args_with_default_port(gateway_args: list[str]) -> list[str]:
if "--port" in gateway_args:
return gateway_args
return [*gateway_args, "--port", str(DEFAULT_GATEWAY_PORT)]
def _gateway_args_with_default_reasoning_parser(gateway_args: list[str]) -> list[str]:
if "--reasoning-parser" in gateway_args:
return gateway_args
return [*gateway_args, "--reasoning-parser", DEFAULT_REASONING_PARSER]
def _gateway_args_with_smg_disable_defaults(gateway_args: list[str]) -> list[str]:
"""Append the smg reliability-disable switches if they are not already there."""
result = list(gateway_args)
for flag in _DEFAULT_SMG_DISABLE_FLAGS:
if flag not in result:
result.append(flag)
return result
def _gateway_args_with_default_policy(gateway_args: list[str]) -> list[str]:
"""Front smg's single backend with the ``passthrough`` routing policy.
``ts serve`` always orchestrates exactly one engine endpoint, so smg's binary
default (``cache_aware``) is pure overhead here: it runs the load-aware worker
monitor and subscribes to the engine's KV events (``SubscribeKvEvents``).
Against engines that predate that RPC the subscription surfaced as
``NotImplementedError: Method not implemented`` (smg#1794). The ``passthrough``
policy (smg#1797) forwards every request to the single healthy worker with no
load balancing, load monitoring, or KV-event subscription.
Default-when-unset: an explicit operator ``--policy`` is preserved.
NOTE: ``--policy`` is whitelisted by smg's clap ``value_parser`` — a gateway
that predates smg#1797 rejects ``passthrough`` and fails to start. This
injection therefore requires a bundled ``tokenspeed-smg`` that ships smg#1797;
the pin in ``python/pyproject.toml`` must be bumped to such a release in
lockstep with this default.
"""
if "--policy" in gateway_args:
return gateway_args
return [*gateway_args, "--policy", DEFAULT_SMG_POLICY]
_TOKENIZER_CACHE_FLAGS = (
"--tokenizer-cache-enable-l0",
"--tokenizer-cache-enable-l1",
)
def _gateway_args_with_default_tokenizer_cache(gateway_args: list[str]) -> list[str]:
"""Default smg tokenizer caches (L0 + L1) ON for gateway-fronted launches.
For agentic / chat-completions traffic with a shared system prompt + history,
L1 prefix-caching at special-token boundaries cuts TTFT by ~30% (verified
end-to-end on mm25). smg's own clap defaults leave both layers OFF.
Opt-out: operators can pass ``--no-tokenizer-cache-enable-l0`` and/or
``--no-tokenizer-cache-enable-l1`` to ``ts serve``. The ``--no-`` form is
intercepted here (smg's clap doesn't accept it natively) and prevents the
positive injection for that layer.
"""
result = list(gateway_args)
for flag in _TOKENIZER_CACHE_FLAGS:
no_flag = "--no-" + flag[2:]
if no_flag in result:
# Operator opted out: strip the --no- marker (smg rejects it)
# and skip the positive injection for this layer.
while no_flag in result:
result.remove(no_flag)
continue
if flag not in result:
result.append(flag)
return result
def _gateway_args_with_default_log_level(gateway_args: list[str]) -> list[str]:
if "--log-level" in gateway_args:
return gateway_args
return [*gateway_args, "--log-level", DEFAULT_SMG_LOG_LEVEL]
def _gateway_args_with_default_prometheus_port(gateway_args: list[str]) -> list[str]:
"""Pin the smg Prometheus exporter to ``DEFAULT_SMG_PROMETHEUS_PORT``.
smg's own default (``29000``) collides easily when multiple ``ts serve``
instances share a host or when a previous run hasn't released the
port yet — the gateway then exits early and the tokenizer
registration job never runs, surfacing later as
``tokenizer_not_found`` on the first request. Pinning a tokenspeed-
specific default keeps the port stable for our deployments while
still allowing an explicit override.
"""
if "--prometheus-port" in gateway_args:
return gateway_args
return [*gateway_args, "--prometheus-port", str(DEFAULT_SMG_PROMETHEUS_PORT)]
def _user_model_id(gateway_args: list[str]) -> str | None:
"""Return the value of ``--model`` from a split gateway argv, or ``None``."""
try:
idx = gateway_args.index("--model")
except ValueError:
return None
if idx + 1 >= len(gateway_args):
return None
return gateway_args[idx + 1]
def _is_deepseek_v4_model(model_id: str | None) -> bool:
if not model_id:
return False
normalized = model_id.lower().replace("_", "-")
compact = normalized.replace("-", "")
if "deepseek-v4" in normalized or "deepseekv4" in compact:
return True
config_path = Path(model_id) / "config.json"
if not config_path.is_file():
return False
try:
with config_path.open() as f:
config = json.load(f)
except (OSError, json.JSONDecodeError):
return False
if not isinstance(config, dict):
return False
architectures = config.get("architectures") or []
return (
config.get("model_type") == "deepseek_v4"
or "DeepseekV4ForCausalLM" in architectures
)
def _is_glm_dsa_model(model_id: str | None) -> bool:
if not model_id:
return False
normalized = model_id.lower().replace("_", "-")
compact = normalized.replace("-", "")
if "glm-5" in normalized or "glm5" in compact:
return True
config_path = Path(model_id) / "config.json"
if not config_path.is_file():
return False
try:
with config_path.open() as f:
config = json.load(f)
except (OSError, json.JSONDecodeError):
return False
if not isinstance(config, dict):
return False
architectures = config.get("architectures") or []
return config.get("model_type") == "glm_moe_dsa" or any(
arch in {"GlmMoeDsaForCausalLM", "GlmMoeDsaForCausalLMNextN"}
for arch in architectures
)
def _args_with_default_model_parsers(
engine_args: list[str], gateway_args: list[str]
) -> tuple[list[str], list[str]]:
"""Apply model-family parser defaults before smg gateway defaults.
Reasoning parser defaults must be visible to both processes: smg extracts
reasoning_content after generation, while the engine uses the same parser
name to defer json_schema grammars past the reasoning channel.
"""
model_id = _user_model_id(gateway_args) or _user_model_id(engine_args)
engine_result = list(engine_args)
gateway_result = list(gateway_args)
if _is_deepseek_v4_model(model_id):
if (
"--reasoning-parser" not in engine_result
and "--reasoning-parser" not in gateway_result
):
engine_result.extend(["--reasoning-parser", DEEPSEEK_V4_REASONING_PARSER])
gateway_result.extend(["--reasoning-parser", DEEPSEEK_V4_REASONING_PARSER])
if "--tool-call-parser" not in gateway_result:
gateway_result.extend(["--tool-call-parser", DEEPSEEK_V4_TOOL_CALL_PARSER])
elif _is_glm_dsa_model(model_id):
if (
"--reasoning-parser" not in engine_result
and "--reasoning-parser" not in gateway_result
):
engine_result.extend(["--reasoning-parser", GLM_REASONING_PARSER])
gateway_result.extend(["--reasoning-parser", GLM_REASONING_PARSER])
if "--tool-call-parser" not in gateway_result:
gateway_result.extend(["--tool-call-parser", GLM_TOOL_CALL_PARSER])
return engine_result, gateway_result
def _prewarm_hf_tokenizer(model_id: str) -> None:
"""Download tokenizer artifacts to the HF cache before the gateway boots.
smg fires its ``AddTokenizer`` job asynchronously after the engine
reports SERVING. On fast runners (e.g. b300) the first eval request
can race that fetch and fail with ``tokenizer_not_found``. Pulling
tokenizer files into the HF cache up front keeps the registration
fast regardless of engine startup speed.
"""
if not model_id or os.path.isdir(model_id):
return
try:
from huggingface_hub import snapshot_download
except ImportError:
return
try:
snapshot_download(
repo_id=model_id,
allow_patterns=[
"tokenizer*",
"special_tokens_map*",
"vocab*",
"merges*",
"*.json",
],
)
except Exception as exc: # noqa: BLE001
logger.warning("HF tokenizer prewarm failed for %s: %s", model_id, exc)
def _gateway_args_with_defaults(gateway_args: list[str]) -> list[str]:
gateway_args = _gateway_args_with_default_port(gateway_args)
gateway_args = _gateway_args_with_default_reasoning_parser(gateway_args)
gateway_args = _gateway_args_with_smg_disable_defaults(gateway_args)
gateway_args = _gateway_args_with_default_policy(gateway_args)
gateway_args = _gateway_args_with_default_tokenizer_cache(gateway_args)
gateway_args = _gateway_args_with_default_log_level(gateway_args)
return _gateway_args_with_default_prometheus_port(gateway_args)
async def _start_control_server(
*,
gateway_url: str,
engine_grpc_addr: str,
host: str,
port: int,
timeout: float = 30.0,
) -> bool:
"""Start the control HTTP server in a daemon thread and wait for it to bind.
Runs uvicorn alongside smg without blocking the orchestrator event loop.
Returns True once the server is accepting connections, or False if it
failed to bind (e.g. the port is already in use) or did not come up within
``timeout`` seconds. Non-fatal: the smg gateway runs independently.
"""
import threading
from tokenspeed.runtime.entrypoints.http_server import build_server
server = build_server(
gateway_url=gateway_url,
engine_grpc_addr=engine_grpc_addr,
host=host,
port=port,
)
thread = threading.Thread(target=server.run, daemon=True, name="ts-http-server")
thread.start()
# uvicorn sets `started = True` only after the socket is bound and serving.
loop = asyncio.get_running_loop()
start = loop.time()
deadline = start + timeout
while not server.started:
if not thread.is_alive():
return False # uvicorn raised during startup (e.g. AddrInUse)
if loop.time() >= deadline:
return False
await asyncio.sleep(0.05)
logger.info("control server bound in %.2fs", loop.time() - start)
return True
async def _stream_to(proc, tag: str) -> None:
await asyncio.gather(
tag_stream(proc.stdout, tag, sys.stdout),
tag_stream(proc.stderr, tag, sys.stderr),
)
async def _drain_log(task: asyncio.Task, timeout: float = 2.0) -> None:
try:
await asyncio.wait_for(task, timeout=timeout)
except (asyncio.TimeoutError, asyncio.CancelledError):
task.cancel()
class _ShutdownDuringStartup(Exception):
pass
class _ChildExitedDuringStartup(Exception):
pass
async def _probe_or_stop(
probe_coro, stop_event: asyncio.Event, *, proc=None, label: str = ""
):
"""Race a readiness probe against the stop event and (optionally) the
subprocess's own exit.
- probe success → return result
- stop event → raise ``_ShutdownDuringStartup``
- proc exits → raise ``_ChildExitedDuringStartup`` with returncode + label
"""
probe_task = asyncio.create_task(probe_coro)
stop_task = asyncio.create_task(stop_event.wait())
tasks = [probe_task, stop_task]
proc_task = None
if proc is not None:
proc_task = asyncio.create_task(proc.wait())
tasks.append(proc_task)
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
if proc_task is not None and proc_task in done:
rc = proc_task.result()
raise _ChildExitedDuringStartup(
f"{label} subprocess exited with rc={rc} during startup; "
f"see [{label}] log lines above for the cause"
)
if stop_task in done:
raise _ShutdownDuringStartup()
return probe_task.result()
async def run_smg(
*,
engine_args: list[str],
gateway_args: list[str],
opts: OrchestratorOpts,
user_host: str,
user_port: int,
_stop_event: asyncio.Event | None = None,
) -> int:
"""Lifecycle loop. Returns the orchestrator's exit code."""
engine = None
gateway = None
engine_log: asyncio.Task | None = None
gateway_log: asyncio.Task | None = None
# Install signal handlers before spawning any subprocess so a Ctrl-C
# during the readiness probe doesn't skip terminate_then_kill.
stop = _stop_event if _stop_event is not None else asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
try:
loop.add_signal_handler(sig, stop.set)
except NotImplementedError:
pass # Windows: signal handlers via asyncio aren't supported. Out of scope.
try:
engine_port = get_free_port()
engine = await spawn_engine(engine_args, host="127.0.0.1", port=engine_port)
engine_log = asyncio.create_task(_stream_to(engine, ENGINE_TAG))
await _probe_or_stop(
wait_grpc_serving(
f"127.0.0.1:{engine_port}",
timeout=float(opts.engine_startup_timeout),
),
stop,
proc=engine,
label=ENGINE_TAG,
)
gateway = await spawn_gateway(
gateway_args, engine_host="127.0.0.1", engine_port=engine_port
)
gateway_log = asyncio.create_task(_stream_to(gateway, GATEWAY_TAG))
await _probe_or_stop(
wait_http_ready(
f"http://{user_host}:{user_port}/readiness",
timeout=float(opts.gateway_startup_timeout),
),
stop,
proc=gateway,
label=GATEWAY_TAG,
)
sys.stdout.write(f"ts serve ready on http://{user_host}:{user_port}\n")
sys.stdout.flush()
control_port = (
opts.control_port if opts.control_port is not None else user_port + 1
)
control_ok = await _start_control_server(
gateway_url=f"http://{user_host}:{user_port}",
engine_grpc_addr=f"127.0.0.1:{engine_port}",
host=user_host,
port=control_port,
)
if control_ok:
sys.stdout.write(
f"ts control server ready on http://{user_host}:{control_port}\n"
)
else:
sys.stderr.write(
f"WARNING: ts control server failed to bind on "
f"http://{user_host}:{control_port} (port in use?); "
f"serving continues without it\n"
)
sys.stdout.flush()
engine_wait = asyncio.create_task(engine.wait())
gateway_wait = asyncio.create_task(gateway.wait())
stop_wait = asyncio.create_task(stop.wait())
done, pending = await asyncio.wait(
[engine_wait, gateway_wait, stop_wait],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
rc_engine = engine.returncode if engine.returncode is not None else 0
rc_gateway = gateway.returncode if gateway.returncode is not None else 0
if rc_engine != 0:
return rc_engine
if rc_gateway != 0:
return rc_gateway
return 0
except _ChildExitedDuringStartup as exc:
logger.error("startup failed: %s", exc)
return 1
except _ShutdownDuringStartup:
logger.info("shutdown signal received during startup; exiting cleanly")
return 0
except TimeoutError as exc:
logger.error("startup failed: %s", exc)
return 1
except KeyboardInterrupt:
logger.info("interrupted; exiting cleanly")
return 0
finally:
# Shutdown order: gateway first, then engine.
if gateway is not None:
await terminate_then_kill(gateway, drain_timeout=opts.drain_timeout)
if engine is not None:
await terminate_then_kill(engine, drain_timeout=opts.drain_timeout)
drain_tasks = [
_drain_log(t) for t in (engine_log, gateway_log) if t is not None
]
if drain_tasks:
await asyncio.gather(*drain_tasks, return_exceptions=True)
# Final reap: walk only the children of our two known subprocesses —
# never os.getpid(), which under pytest would walk the test runner's
# children and SIGKILL unrelated test fixtures.
for proc in (engine, gateway):
if proc is not None:
try:
kill_process_tree(proc.pid, include_parent=False)
except Exception: # noqa: BLE001
pass
def run_smg_from_args(args: argparse.Namespace, raw_argv: list[str]) -> None:
"""Entry point called from cli/__main__.py for ``ts serve``."""
try:
import setproctitle
setproctitle.setproctitle("ts-serve")
except ImportError:
pass
print_logo()
_check_serve_extra_installed()
split = split_argv(raw_argv)
engine_args, gateway_args = _args_with_default_model_parsers(
split.engine, split.gateway
)
gateway_args = _gateway_args_with_defaults(gateway_args)
user_host, user_port = _user_host_port_from_gateway_args(gateway_args)
model_id = _user_model_id(gateway_args)
if model_id is not None:
_prewarm_hf_tokenizer(model_id)
rc = asyncio.run(
run_smg(
engine_args=engine_args,
gateway_args=gateway_args,
opts=split.opts,
user_host=user_host,
user_port=user_port,
)
)
sys.exit(rc)