Source code for experimental.server.runtime.engine

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Checkpoint-native Python API for TensorRT Edge-LLM.

Checkpoints are lowered directly by :mod:`experimental.builder`. Supported
weights remain checkpoint-backed TensorRT inputs and are validated and loaded
once when the runtime starts; this module has no ONNX export/build path.

Example::

    from experimental.server import LLM, SamplingParams

    llm = LLM(model="Qwen/Qwen3.5-0.8B")
    outputs = llm.generate(
        ["What is the capital of France?"],
        SamplingParams(temperature=0.7, max_tokens=256),
    )
    for output in outputs:
        print(output.text)

    # Or start an OpenAI-compatible server:
    llm.serve(port=8000)
"""

import importlib.util
import json
import logging
import math
import os
import sys
import threading
from dataclasses import asdict, dataclass, field, replace
from pathlib import Path
from typing import (TYPE_CHECKING, Any, Dict, Iterator, List, Mapping,
                    Optional, Sequence, Union)

from ..config import ContextCacheConfig
from ..parsing.tool_calling import (ToolConfig, parse_assistant_output,
                                    validate_tool_request)
from ..parsing.tool_chat_template import (ToolChatTemplateFormatter,
                                          needs_tool_chat_template)
from .engine_layout import BundleLayout, EngineType, inspect_bundle

logger = logging.getLogger("edgellm.server")

if TYPE_CHECKING:
    from .engine_build import BuildOptions

_PLUGIN_LIB_NAME = "libNvInfer_edgellm_plugin.so"
_MAX_LOGIT_BIAS_TOKENS = 1024
_MAX_LOGIT_BIAS_TOKEN_ID = (1 << 31) - 1
_MIN_LOGIT_BIAS = -100.0
_MAX_LOGIT_BIAS = 100.0
_DEFAULT_MAX_INPUT_LEN = 4096
_DEFAULT_MAX_BATCH_SIZE = 1
_DEFAULT_MAX_KV_CACHE_CAPACITY = 8192
_DEFAULT_DRAFT_TOP_K = 10
_DEFAULT_DRAFT_STEP = 6
_DEFAULT_VERIFY_TREE_SIZE = 60

# ---------------------------------------------------------------------------
# Public data classes
# ---------------------------------------------------------------------------


[docs] @dataclass class SamplingParams: """Sampling parameters for one generation request.""" temperature: float = 0.7 top_p: float = 0.9 top_k: int = 50 max_tokens: int = 2048 enable_thinking: bool = False disable_spec_decode: bool = False num_logprobs: int = 0 stop: List[str] = field(default_factory=list) logit_bias: Dict[int, float] = field(default_factory=dict) skip_special_tokens: bool = True reuse_context: bool = True cache_generated_tokens: bool = True
@dataclass class LogprobEntry: """One top-K log-probability entry for a single generated token. ``token`` is the piece decoded as UTF-8 with ``errors="replace"`` (a byte-level BPE token may be only part of a multi-byte character, so it can contain U+FFFD); ``bytes`` carries the raw token bytes losslessly. """ token_id: int logprob: float token: str bytes: List[int] def _convert_logprobs(raw) -> List[List[LogprobEntry]]: """Convert the C++/pybind logprobs (list of list of native LogprobEntry with a raw-bytes ``piece``) into engine LogprobEntry dataclasses.""" return [[ LogprobEntry(token_id=e.token_id, logprob=e.logprob, token=e.piece.decode("utf-8", "replace"), bytes=list(e.piece)) for e in step ] for step in raw]
[docs] @dataclass class CompletionOutput: """Output of a single generation request.""" text: str = "" token_ids: List[int] = field(default_factory=list) prompt_tokens: Optional[int] = None finish_reason: Optional[str] = None logprobs: List[List[LogprobEntry]] = field(default_factory=list) tool_calls: List[Dict[str, Any]] = field(default_factory=list) reasoning: Optional[str] = None
[docs] @dataclass class StreamDelta: """Single delta from a streaming generation. Text deltas carry ``text``/``token_ids``; audio deltas (Omni streaming) carry ``audio_bytes`` (int16 LE mono PCM) instead. ``finished`` marks the end of the text stream; generator exhaustion ends the audio stream. """ text: str = "" token_ids: List[int] = field(default_factory=list) prompt_tokens: Optional[int] = None finished: bool = False finish_reason: Optional[str] = None logprobs: List[List[LogprobEntry]] = field(default_factory=list) audio_bytes: Optional[bytes] = None
[docs] @dataclass class AudioParams: """Talker / vocoder knobs for one Omni audio-output request.""" voice: str = "" talker_temperature: float = 0.9 talker_top_k: int = 50 talker_top_p: float = 1.0 repetition_penalty: float = 1.05 max_audio_length: int = 4096 codec_chunk_frames: int = 10 talker_prefill_threshold: int = 4
#: Sample rate of Omni Code2Wav PCM output. OMNI_AUDIO_SAMPLE_RATE = 24000 def _native_audio_params(rt, audio: "AudioParams"): """Convert the AudioParams dataclass to the pybind OmniAudioParams.""" omni_params = rt.OmniAudioParams() omni_params.speaker_name = audio.voice for name, value in asdict(audio).items(): if name != "voice": setattr(omni_params, name, value) return omni_params class _CancellableIterator: """Iterator whose thread-safe close signal can interrupt native waits.""" def __init__(self, iterator, cancel) -> None: self._iterator = iterator self._cancel = cancel self._cancel_lock = threading.Lock() self._cancelled = False def __iter__(self): return self def __next__(self): return next(self._iterator) def close(self) -> None: with self._cancel_lock: if not self._cancelled: self._cancelled = True self._cancel() try: self._iterator.close() except (RuntimeError, ValueError): # Another thread is inside next(). The cancel signal wakes it; the # async adapter waits for that call before releasing admission. pass def _pump_channels(rt, run, text_channel, audio_channel, sem=None): """Drive one generation in a worker thread, yielding StreamDeltas. Shared by the Omni dual-stream path (both channels) and the standalone TTS path (``text_channel=None``). The drain-once retry after is_finished()/is_cancelled() closes the race where the producer finishes between an empty pop and the check. The worker releases ``sem`` when the C++ call returns. """ def _cancel(): if text_channel is not None: text_channel.cancel() audio_channel.cancel() def _iterate(): error_holder = [None] def _run(): try: run() except Exception as error: # noqa: BLE001 - re-raised below error_holder[0] = error _cancel() finally: if sem is not None: sem.release() worker = threading.Thread(target=_run, daemon=True) worker.start() text_done = text_channel is None audio_done = False try: while not (text_done and audio_done): if not text_done: chunk = text_channel.wait_pop(timeout_ms=20) if chunk is None and (text_channel.is_finished() or text_channel.is_cancelled()): chunk = text_channel.try_pop() if chunk is None: text_done = True if chunk is not None: reason = finish_reason_name( rt, chunk.reason) if chunk.finished else None yield StreamDelta( text=chunk.text, token_ids=list(chunk.token_ids), prompt_tokens=(chunk.prompt_token_count if chunk.prompt_token_count >= 0 else None), finished=chunk.finished, finish_reason=reason, logprobs=_convert_logprobs(chunk.logprobs), ) text_done = chunk.finished if not audio_done: # Text drives pacing while it flows (non-blocking audio # poll); once text ends, block on audio instead. audio_chunk = audio_channel.wait_pop( timeout_ms=100 if text_done else 0) if audio_chunk is None and (audio_channel.is_finished() or audio_channel.is_cancelled()): audio_chunk = audio_channel.try_pop() if audio_chunk is None: audio_done = True if audio_chunk is not None: if audio_chunk.pcm16: yield StreamDelta(audio_bytes=audio_chunk.pcm16) audio_done = audio_chunk.is_final finally: if not (text_done and audio_done): _cancel() # Channels own buffers consumed by the native worker. Runtime # teardown and admission release must wait for worker exit. worker.join() if error_holder[0] is not None: raise error_holder[0] return _CancellableIterator(_iterate(), _cancel) def _stream_tts(rt, runtime, text: str, audio: "AudioParams", sem, infer_guard=None, ensure_open=None) -> Iterator["StreamDelta"]: """Run one standalone TTS request; yields audio-only StreamDeltas. ``runtime`` is any pybind object exposing ``handle_request_tts`` (LLMRuntime with the Omni stack loaded, or the TTS-only TTSRuntime). ``infer_guard`` serializes against text inference sharing the same CUDA stream (unused by the TTS-only runtime, which serves no text). """ state = {} def _cancel(): stream = state.get("stream") if stream is not None: stream.close() def _iterate(): omni_params = _native_audio_params(rt, audio) audio_channel = rt.AudioStreamChannel() if sem is not None: sem.acquire() try: if ensure_open is not None: ensure_open() except BaseException: sem.release() raise def _run(): if infer_guard is None: runtime.handle_request_tts(text, omni_params, audio_channel) return with infer_guard: runtime.handle_request_tts(text, omni_params, audio_channel) stream = _pump_channels(rt, _run, None, audio_channel, sem=sem) state["stream"] = stream yield from stream return _CancellableIterator(_iterate(), _cancel) # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _derive_model_id(model: str) -> str: """Return a clean id to advertise via /v1/models and echo in responses. A local checkpoint path would otherwise leak the full filesystem path as the model id, so use its directory name. A Hugging Face ID is kept as-is. """ src = model if src and (os.path.isabs(src) or os.path.isdir(src)): return os.path.basename(os.path.normpath(src)) return src def _read_bundle_builder_config(bundle_dir: str) -> dict: """Read the text runtime profile from a complete model bundle.""" for filename in ("config.json", "base_config.json"): cfg_path = os.path.join(bundle_dir, filename) if os.path.exists(cfg_path): with open(cfg_path) as f: return json.load(f).get("builder_config", {}) return {} @dataclass(frozen=True) class _SpecDecodeRuntimeOptions: """Drafting shape resolved from one speculative engine bundle.""" top_k: int step: int verify_size: int dflash_block_size: int = 0 def _read_json(path: str) -> dict: with open(path, encoding="utf-8") as file: return json.load(file) def _resolve_spec_decode_runtime_options( bundle_dir: str, method: str, num_speculative_tokens: Optional[int], draft_top_k: Optional[int], draft_step: Optional[int], verify_tree_size: Optional[int], ) -> _SpecDecodeRuntimeOptions: """Resolve method-specific defaults against the compiled engine profile.""" if method == "none": return _SpecDecodeRuntimeOptions( draft_top_k or _DEFAULT_DRAFT_TOP_K, draft_step or _DEFAULT_DRAFT_STEP, verify_tree_size or _DEFAULT_VERIFY_TREE_SIZE, ) base = _read_json(os.path.join(bundle_dir, "base_config.json")) draft = _read_json(os.path.join(bundle_dir, "draft_config.json")) engine_method = str(base.get("spec_decode_type", method)) compatible_methods = {method} if method == "mtp": compatible_methods.add("gemma4_mtp") if engine_method not in compatible_methods: raise ValueError( f"requested speculative method {method!r}, but the compiled " f"bundle uses {engine_method!r}") max_verify_size = int( base.get("builder_config", {}).get("max_verify_tree_size", _DEFAULT_VERIFY_TREE_SIZE)) if engine_method == "eagle3": return _SpecDecodeRuntimeOptions( draft_top_k or _DEFAULT_DRAFT_TOP_K, num_speculative_tokens or draft_step or _DEFAULT_DRAFT_STEP, verify_tree_size or max_verify_size, ) if engine_method in {"mtp", "gemma4_mtp"}: top_k = draft_top_k or 1 if engine_method == "gemma4_mtp" and top_k != 1: raise ValueError("Gemma4 MTP supports linear drafting only; " "set draft_top_k=1") step = num_speculative_tokens or draft_step or _DEFAULT_DRAFT_STEP verify_size = (verify_tree_size or (step + 1 if top_k == 1 else max_verify_size)) return _SpecDecodeRuntimeOptions(top_k, step, verify_size) if engine_method in {"dflash", "jetspec"}: if draft_step not in (None, 1): raise ValueError( f"{engine_method} emits a complete block in one draft step; " "set draft_step=1") mode_config = (draft.get(f"{engine_method}_config") or draft.get("dflash_config") or {}) checkpoint_block_size = int( mode_config.get("block_size", draft.get("block_size", 0))) block_size = num_speculative_tokens or checkpoint_block_size if checkpoint_block_size < 2: raise ValueError( f"compiled {engine_method} draft has an invalid proposal " f"block size {checkpoint_block_size}") if not 2 <= block_size <= checkpoint_block_size: raise ValueError( f"{engine_method} num_speculative_tokens must be within the " f"compiled proposal block size [2, {checkpoint_block_size}]") top_k = draft_top_k or 1 verify_size = (verify_tree_size or (block_size if top_k == 1 else max_verify_size)) return _SpecDecodeRuntimeOptions(top_k, 1, verify_size, block_size) if engine_method == "dspark": if draft_step not in (None, 1): raise ValueError( "dspark emits a complete block in one draft step; set " "draft_step=1") mode_config = draft.get("dspark_config") or {} block_size = int( mode_config.get("block_size", draft.get("block_size", 0))) proposal_size = num_speculative_tokens or block_size if not 1 <= proposal_size <= block_size: raise ValueError( "dspark num_speculative_tokens must be within the compiled " f"proposal block size [1, {block_size}]") top_k = draft_top_k or 1 if top_k > 1 and proposal_size != block_size: raise ValueError( "dspark tree drafting always uses the complete checkpoint " "proposal block") verify_size = verify_tree_size or proposal_size + 1 return _SpecDecodeRuntimeOptions(top_k, 1, verify_size) raise ValueError(f"unsupported speculative engine mode {engine_method!r}") def _ensure_plugin_path() -> None: """Set EDGELLM_PLUGIN_PATH if not already set. Searches relative to this package and common build locations. """ if os.environ.get("EDGELLM_PLUGIN_PATH"): return project_root = Path(__file__).resolve().parents[3] search_dirs = [ project_root / "build" / "core", project_root / "build" / "lib", ] for d in search_dirs: candidate = d / _PLUGIN_LIB_NAME if candidate.is_file(): os.environ["EDGELLM_PLUGIN_PATH"] = str(candidate) return def _import_runtime(): """Import the C++ pybind module.""" try: runtime_facade = importlib.import_module("tensorrt_edgellm.runtime") native_package = importlib.import_module("tensorrt_edgellm._native") try: return runtime_facade.load() except native_package.NativeManifestNotFoundError: # Source checkouts have no generated variants.json. pass except ImportError: pass _ensure_plugin_path() try: return importlib.import_module("tensorrt_edgellm._edgellm_runtime") except ImportError: pass project_root = Path(__file__).resolve().parents[3] search_dirs = [] if os.environ.get("EDGELLM_PYBIND_DIR"): search_dirs.append(Path(os.environ["EDGELLM_PYBIND_DIR"])) if os.environ.get("BUILD_DIR"): search_dirs.append(Path(os.environ["BUILD_DIR"]) / "pybind") search_dirs.extend([ project_root / "experimental" / "pybind" / "build", project_root / "build" / "pybind", ]) search_dirs.extend(project_root.glob("build/lib.*")) for cand_dir in search_dirs: if not cand_dir.is_dir(): continue so_files = list(cand_dir.glob("*_edgellm_runtime*.so")) if so_files: spec = importlib.util.spec_from_file_location( "_edgellm_runtime", so_files[0]) mod = importlib.util.module_from_spec(spec) sys.modules["tensorrt_edgellm._edgellm_runtime"] = mod spec.loader.exec_module(mod) return mod raise ImportError( "Could not import _edgellm_runtime. Build the C++ extension first:\n" " TRT_PACKAGE_DIR=/path/to/tensorrt python experimental/server/setup_pybind.py build_ext --inplace" ) def _normalize_logit_bias( logit_bias: Optional[Dict[Any, Any]]) -> Dict[int, float]: """Validate and normalize an OpenAI-compatible logit_bias map.""" if logit_bias is None: return {} if not isinstance(logit_bias, dict): raise ValueError( "'logit_bias' must be an object mapping token IDs to bias values") if len(logit_bias) > _MAX_LOGIT_BIAS_TOKENS: raise ValueError(f"'logit_bias' has {len(logit_bias)} entries; max is " f"{_MAX_LOGIT_BIAS_TOKENS}") normalized: Dict[int, float] = {} for token, bias in logit_bias.items(): if isinstance(token, bool): raise ValueError( f"'logit_bias' token ID {token!r} is not an integer") if isinstance(token, int): token_id = token elif isinstance(token, str): try: token_id = int(token) except ValueError as exc: raise ValueError( f"'logit_bias' token ID {token!r} is not an integer" ) from exc else: raise ValueError( f"'logit_bias' token ID {token!r} is not an integer") if token_id < 0 or token_id > _MAX_LOGIT_BIAS_TOKEN_ID: raise ValueError( f"'logit_bias' token ID must be in " f"[0, {_MAX_LOGIT_BIAS_TOKEN_ID}], got {token_id}") if isinstance(bias, bool) or not isinstance(bias, (int, float)): raise ValueError( f"'logit_bias' value for token ID {token_id} must be a number") try: bias_value = float(bias) except OverflowError as exc: raise ValueError( f"'logit_bias' value for token ID {token_id} must be in " f"[{_MIN_LOGIT_BIAS}, {_MAX_LOGIT_BIAS}], got {bias}") from exc if (not math.isfinite(bias_value) or bias_value < _MIN_LOGIT_BIAS or bias_value > _MAX_LOGIT_BIAS): raise ValueError( f"'logit_bias' value for token ID {token_id} must be in " f"[{_MIN_LOGIT_BIAS}, {_MAX_LOGIT_BIAS}], got {bias_value}") normalized[token_id] = bias_value return normalized def _native_context_cache_config(rt, config: ContextCacheConfig): """Translate validated public configuration to the pybind value type.""" native = rt.ContextCacheConfig() native.enabled = config.enabled native.max_records = config.max_records native.recurrent_snapshot_pool_bytes = config.recurrent_snapshot_pool_bytes native.partial_kv_snapshot_pool_bytes = config.partial_kv_snapshot_pool_bytes return native def _set_context_cache_request_policies(rt, request, params: SamplingParams) -> None: """Map the two public request controls onto native cache policies.""" if not isinstance(params.reuse_context, bool): raise ValueError("reuse_context must be boolean") if not isinstance(params.cache_generated_tokens, bool): raise ValueError("cache_generated_tokens must be boolean") request.context_cache_lookup_policy = ( rt.ContextCacheLookupPolicy.USE_CACHE if params.reuse_context else rt.ContextCacheLookupPolicy.BYPASS) request.context_cache_commit_policy = ( rt.ContextCacheCommitPolicy.INCLUDING_GENERATED_TOKENS if params.cache_generated_tokens else rt.ContextCacheCommitPolicy.PREFILL_STATE_ONLY) def _engine_config_value(builder_config: dict, field_name: str, requested_value: int) -> int: if field_name not in builder_config: return requested_value engine_value = int(builder_config[field_name]) if engine_value != requested_value: logger.warning( "Using %s=%d from engine builder_config instead of requested %d", field_name, engine_value, requested_value, ) return engine_value # --------------------------------------------------------------------------- # LLM class # ---------------------------------------------------------------------------
[docs] class LLM: """Checkpoint-direct entry point for offline and HTTP inference. ``model`` accepts a local checkpoint or Hugging Face ID. The experimental builder compiles every model component into a profile-specific cache bundle and externalizes every supported weight kind. """ #: Selects the HTTP contract without a hierarchy of capability flags. runtime_kind = "chat"
[docs] def __init__( self, model: str, *, cache_dir: str = "", engine_cache_max_size_gb: float = 50.0, clear_engine_cache: bool = False, max_input_len: int = _DEFAULT_MAX_INPUT_LEN, max_batch_size: int = _DEFAULT_MAX_BATCH_SIZE, max_kv_cache_capacity: int = _DEFAULT_MAX_KV_CACHE_CAPACITY, draft_top_k: Optional[int] = None, draft_step: Optional[int] = None, verify_tree_size: Optional[int] = None, build_options: Optional["BuildOptions"] = None, speculative_config: Optional[Any] = None, context_cache_config: Optional[Union[ContextCacheConfig, Mapping[str, Any]]] = None, ): if not model: raise ValueError("'model' must be provided") if (isinstance(engine_cache_max_size_gb, bool) or not math.isfinite(engine_cache_max_size_gb) or engine_cache_max_size_gb <= 0): raise ValueError("engine_cache_max_size_gb must be positive") for name, value in (("draft_top_k", draft_top_k), ("draft_step", draft_step), ("verify_tree_size", verify_tree_size)): if value is None: continue if (isinstance(value, bool) or not isinstance(value, int) or value <= 0): raise ValueError(f"{name} must be a positive integer") self._model_id = _derive_model_id(model) self._draft_top_k = draft_top_k or _DEFAULT_DRAFT_TOP_K self._draft_step = draft_step or _DEFAULT_DRAFT_STEP self._verify_tree_size = (verify_tree_size or _DEFAULT_VERIFY_TREE_SIZE) self._dflash_block_size = 0 self._max_input_len = max_input_len self._max_batch_size = max_batch_size self._max_kv_cache_capacity = max_kv_cache_capacity self._context_cache_config = ContextCacheConfig.parse( context_cache_config) self._tool_template_formatter: Optional[ ToolChatTemplateFormatter] = None self._admission_sem = threading.Semaphore(1) self._infer_lock = threading.Lock() self._close_lock = threading.Lock() # Context-reuse observability: the counters as of the last logged # request, so each request can report its own delta. Guarded because the # streaming path calls the C++ runtime from a background thread. self._ctx_reuse_metric_lock = threading.Lock() self._prev_ctx_reused_tokens = 0 self._prev_ctx_matched_tokens = 0 self._prev_ctx_hit_sequences = 0 self._prev_ctx_admitted_sequences = 0 self._closed = False self._runtime = None from .engine_build import BuildOptions, cache_root, prepare_model options = build_options or BuildOptions( max_input_len=max_input_len, max_batch_size=max_batch_size, max_kv_cache_capacity=max_kv_cache_capacity, ) spec_method = options.spec_type num_speculative_tokens = None if speculative_config: from ..config import SpeculativeConfig spec = SpeculativeConfig.parse(speculative_config) options = replace(options, spec_type=spec.method, draft_model_dir=spec.draft_model) spec_method = spec.method num_speculative_tokens = spec.num_speculative_tokens resolved_top_k = draft_top_k or (10 if spec_method == "eagle3" else 1) if (options.builder_spec_type == "gemma4_mtp" and resolved_top_k != 1): raise ValueError("Gemma4 MTP supports linear drafting only; " "set draft_top_k=1") tree_base = (resolved_top_k > 1 and options.builder_spec_type in {"mtp", "dflash", "jetspec"}) if options.spec_type != "none": options = replace(options, tree_base=tree_base) prepared = prepare_model( model, cache_dir, options, max_cache_size_bytes=int(engine_cache_max_size_gb * (1 << 30)), clear_cache=clear_engine_cache, ) self._cache_dir = cache_root(cache_dir) self._model_dir = prepared.model_dir self._draft_model_dir = prepared.draft_model_dir runtime_options = _resolve_spec_decode_runtime_options( prepared.bundle_dir, spec_method, num_speculative_tokens, draft_top_k, draft_step, verify_tree_size, ) self._draft_top_k = runtime_options.top_k self._draft_step = runtime_options.step self._verify_tree_size = runtime_options.verify_size self._dflash_block_size = runtime_options.dflash_block_size self._init_from_bundle(prepared.bundle_dir) self._load_runtime()
# ------------------------------------------------------------------ # Initialization # ------------------------------------------------------------------ def _init_from_bundle(self, bundle_dir: str) -> None: """Validate one complete checkpoint-native runtime bundle.""" self._layout = inspect_bundle(bundle_dir) if self._layout.engine_type not in (EngineType.LLM, EngineType.SPEC_DECODE): raise ValueError("no checkpoint-direct LLM engine found in " f"{self._layout.root!r}") self._bundle_dir = self._layout.root builder_config = _read_bundle_builder_config(self._bundle_dir) self._max_input_len = _engine_config_value(builder_config, "max_input_len", self._max_input_len) self._max_batch_size = _engine_config_value(builder_config, "max_batch_size", self._max_batch_size) self._max_kv_cache_capacity = _engine_config_value( builder_config, "max_kv_cache_capacity", self._max_kv_cache_capacity) self._media_dir = self._layout.media_dir logger.info("Using cached engine bundle: %s", self._bundle_dir) def _load_runtime(self) -> None: """Load checkpoint-backed weights once, then initialize the runtime.""" self._rt = _import_runtime() context_cache_config = _native_context_cache_config( self._rt, self._context_cache_config) logger.info("Loading runtime bundle from %s", self._bundle_dir) if self._layout.engine_type == EngineType.SPEC_DECODE: logger.info( "Speculative decoding enabled (top_k=%d, step=%d, " "verify_size=%d, block_size=%d)", self._draft_top_k, self._draft_step, self._verify_tree_size, self._dflash_block_size, ) self._runtime = self._rt.LLMRuntime( self._bundle_dir, self._media_dir, {}, self._draft_top_k, self._draft_step, self._verify_tree_size, self._model_dir, self._draft_model_dir, context_cache_config, self._dflash_block_size, ) else: self._runtime = self._rt.LLMRuntime( self._bundle_dir, self._media_dir, {}, self._model_dir, context_cache_config, ) self._runtime.capture_decoding_cuda_graph() self._load_omni_runtime() logger.info("Engine loaded and ready.") def _load_omni_runtime(self) -> None: """Load the Qwen3-Omni audio-output stack when its engines exist.""" if not self._layout.has_speech: return logger.info("Auto-detected Omni engines: talker=%s", self._layout.talker_dir) self._runtime.load_omni(self._layout.talker_dir, self._layout.code_predictor_dir, self._layout.code2wav_dir, self._bundle_dir, self._model_dir) logger.info("Omni audio output ready.") def _tool_template_dirs(self) -> List[str]: return [self._model_dir] def _get_tool_template_formatter(self) -> ToolChatTemplateFormatter: if self._tool_template_formatter is None: self._tool_template_formatter = ToolChatTemplateFormatter( self._tool_template_dirs()) return self._tool_template_formatter def _tool_choice_for_template( self, tool_config: ToolConfig) -> Union[str, Dict[str, Any]]: if tool_config.forced_name: return { "type": "function", "function": { "name": tool_config.forced_name }, } return tool_config.tool_choice def _visual_config(self) -> dict: """Read the model-specific visual component configuration once.""" cached = getattr(self, "_visual_config_cache", None) if cached is not None: return cached cfg: dict = {} root = self._media_dir cfg_path = os.path.join(root, "visual", "config.json") if os.path.isfile(cfg_path): try: with open(cfg_path) as f: cfg = json.load(f) except (OSError, ValueError): cfg = {} self._visual_config_cache = cfg return cfg def _video_model_family(self) -> str: """Frame-sampling family ("qwen" / "internvl" / "nemotron") from the visual engine's model_type. Types without a video path (phi4mm, gemma, ...) are rejected: their runners read only the first frame.""" cached = getattr(self, "_video_family_cache", None) if cached is not None: return cached config = self._visual_config() model_type = config.get("model_type", "") qwen_video_types = ("qwen2_vl", "qwen2_5_vl", "qwen3_vl", "qwen3_5", "qwen3_omni") # Audio-side model types have no video path (qwen3_omni_audio_encoder, # qwen3_omni_code2wav, qwen3_asr*); the omni ones share the qwen3_omni # prefix, so exclude before the prefix match. is_audio_type = any(tag in model_type for tag in ("audio", "code2wav", "asr")) root = self._media_dir has_visual = os.path.isfile( os.path.join(root, "visual", "visual.engine")) if "internvl" in model_type and has_visual: family = "internvl" elif ("nemotron" in model_type and not is_audio_type and has_visual and config.get("supports_video", True)): family = "nemotron" elif (model_type.startswith(qwen_video_types) and not is_audio_type and has_visual): family = "qwen" else: # Covers audio-only engines (audio/ but no visual/) and model # types whose runners have no video path (phi4mm, gemma, ...). raise ValueError( f"video input is not supported for model_type={model_type!r}" " in this runtime bundle; supported families: Qwen-VL " "(qwen2_vl/qwen2_5_vl/qwen3_vl/qwen3_5/qwen3_omni), InternVL, " "and Nemotron-Omni") self._video_family_cache = family return family def _video_frame_limits(self) -> dict: """Engine-profile inputs for frame-count clamping (see video_sampling): builder token bounds from the visual config.json + patch geometry from preprocessor_config.json. Empty dict when unavailable (no clamping).""" cached = getattr(self, "_video_limits_cache", None) if cached is not None: return cached limits: dict = {} cfg = self._visual_config() builder = cfg.get("builder_config") or {} root = self._media_dir pre: dict = {} pre_path = os.path.join(root, "visual", "preprocessor_config.json") if os.path.isfile(pre_path): try: with open(pre_path) as f: pre = json.load(f) except (OSError, ValueError): pre = {} pre = pre.get("image_processor", pre) if builder.get("max_image_tokens"): limits = { "model_type": cfg.get("model_type", ""), "min_image_tokens": int(builder.get("min_image_tokens", 1)), "max_image_tokens": int(builder["max_image_tokens"]), "max_image_tokens_per_image": int(builder.get("max_image_tokens_per_image", 0)), "max_cu_seqlen_groups": int(builder.get("max_cu_seqlen_groups", 0)), "patch_size": int(pre.get("patch_size", 0)), "merge_size": int(pre.get("merge_size", 0)), "temporal_patch_size": int(pre.get("temporal_patch_size", 2)), # Nemotron-Omni video geometry (top-level visual config.json). "video_pruning_rate": float(cfg.get("video_pruning_rate", 0.0)), "video_temporal_patch_size": int(cfg.get("video_temporal_patch_size", 2)), "video_target_num_patches": int(cfg.get("video_target_num_patches", 1024)), "downsample_ratio": float(cfg.get("downsample_ratio", 0.5)), } self._video_limits_cache = limits return limits def _prepare_messages_for_runtime( self, messages: List[Dict[str, Any]], *, tools: Optional[Sequence[Dict[str, Any]]] = None, tool_choice: Optional[Union[str, Dict[str, Any]]] = None, tool_config: Optional[ToolConfig] = None, enable_thinking: bool = False, derive_replay_tail: bool = False, ): """Prepare messages for the C++ runtime. Returns the replay-tail length alongside the prepared messages. It is non-zero only when the caller asked for it, which is what lets a Hybrid+MTP checkpoint be reused across turns. """ tool_config = tool_config or validate_tool_request( messages, tools, tool_choice) template_tools = (tool_config.tools if tool_config.tool_choice != "none" else []) image_buffers = _load_image_buffers(self._rt, messages, self._video_model_family, self._video_frame_limits) if needs_tool_chat_template(messages, template_tools, tool_config.tool_choice): template_tool_choice = None if tool_config.tool_choice != "none": template_tool_choice = self._tool_choice_for_template( tool_config) formatter = self._get_tool_template_formatter() replay_tail_length = 0 if derive_replay_tail: # Derive the multi-turn replay tail from the tokenized template # so a Hybrid+MTP checkpoint can be reused across turns. prompt, replay_tail_length = formatter.format_with_replay_tail( messages, tools=template_tools, tool_choice=template_tool_choice, parallel_tool_calls=tool_config.parallel_tool_calls, enable_thinking=enable_thinking, ) else: prompt = formatter.format( messages, tools=template_tools, tool_choice=template_tool_choice, parallel_tool_calls=tool_config.parallel_tool_calls, add_generation_prompt=True, enable_thinking=enable_thinking, ) cpp_messages = _convert_messages_to_cpp( self._rt, [{ "role": "user", "content": prompt, }], ) return (cpp_messages, image_buffers, False, False, replay_tail_length) cpp_messages = _convert_messages_to_cpp(self._rt, messages) return cpp_messages, image_buffers, True, True, 0 def _make_generation_request( self, messages: List[Dict[str, Any]], params: SamplingParams, *, tools: Optional[Sequence[Dict[str, Any]]] = None, tool_choice: Optional[Union[str, Dict[str, Any]]] = None, tool_config: Optional[ToolConfig] = None, stream_channel: Optional[Any] = None, ): normalized_logit_bias = _normalize_logit_bias(params.logit_bias) tool_config = tool_config or validate_tool_request( messages, tools, tool_choice) # The replay tail only matters for a prefill-state-only commit against a # draft model with reuse enabled: that is the deployment whose # checkpoint must land on a turn boundary the next render reproduces. # The server tests build bare objects that skip __init__, so read the # config defensively and let the `and` chain short-circuit before it # reaches the attributes only a constructed LLM has. cache_config = getattr(self, "_context_cache_config", None) derive_replay_tail = (cache_config is not None and cache_config.enabled and not params.cache_generated_tokens and self.has_draft_model) (cpp_messages, image_buffers, apply_template, add_prompt, replay_tail_length) = (self._prepare_messages_for_runtime( messages, tools=tool_config.tools, tool_choice=tool_config.tool_choice, tool_config=tool_config, enable_thinking=params.enable_thinking, derive_replay_tail=derive_replay_tail, )) audio_buffers = _load_audio_buffers(self._rt, messages) request = self._rt.LLMGenerationRequest() req = self._rt.Request(messages=cpp_messages) req.image_buffers = image_buffers req.audio_buffers = audio_buffers req.stop_strings = params.stop req.logit_bias = normalized_logit_bias request.requests = [req] if stream_channel is not None: request.stream_channels = [stream_channel] request.temperature = params.temperature request.top_p = params.top_p request.top_k = params.top_k request.max_generate_length = params.max_tokens request.apply_chat_template = apply_template request.add_generation_prompt = add_prompt request.enable_thinking = params.enable_thinking request.disable_spec_decode = params.disable_spec_decode request.num_logprobs = params.num_logprobs _set_context_cache_request_policies(self._rt, request, params) request.context_cache_replay_tail_length = replay_tail_length return request def _count_prepared_prompt_tokens(self, request) -> Optional[int]: """Count tokens only for an explicit token-count API request.""" if not hasattr(self._runtime, "count_prompt_tokens"): return None rows = getattr(request, "requests", ()) if any( getattr(row, "image_buffers", ()) or getattr(row, "audio_buffers", ()) or getattr(row, "past_trajectory", None) is not None for row in rows): return None counts = self._runtime.count_prompt_tokens(request) return counts[0] if counts else None def _parse_generation_output( self, text: str, token_ids: List[int], prompt_tokens: Optional[int], finish_reason: Optional[str], tool_config: ToolConfig, *, tool_parser: str = "auto", reasoning_parser: str = "none", ) -> CompletionOutput: parsed = parse_assistant_output( text, tool_config, self._model_dir, tool_parser=tool_parser, reasoning_parser=reasoning_parser, ) tool_calls = [call.to_openai() for call in parsed.tool_calls] return CompletionOutput( text=parsed.content, token_ids=token_ids, prompt_tokens=prompt_tokens, finish_reason="tool_calls" if tool_calls else finish_reason, tool_calls=tool_calls, reasoning=parsed.reasoning or None, ) def _complete_prepared_request( self, request, params: SamplingParams, tool_config: ToolConfig, *, tool_parser: str = "auto", reasoning_parser: str = "none", ) -> CompletionOutput: response = self._handle_request(request) text = response.output_texts[0] if response.output_texts else "" token_ids = response.output_ids[0] if response.output_ids else [] prompt_tokens = (response.prompt_token_counts[0] if response.prompt_token_counts else None) finish_reason = (finish_reason_name(self._rt, response.finish_reasons[0]) if response.finish_reasons else "stop") output = self._parse_generation_output( text, token_ids, prompt_tokens, finish_reason, tool_config, tool_parser=tool_parser, reasoning_parser=reasoning_parser, ) if params.num_logprobs > 0 and response.logprobs: output.logprobs = _convert_logprobs(response.logprobs[0]) return output # ------------------------------------------------------------------ # Inference API # ------------------------------------------------------------------ def _admission(self): """Per-instance gate from media decode through inference completion: queued requests must not each pin decoded frames. Semaphore, not Lock -- streaming releases from the worker/SSE side.""" return self._admission_sem def _infer_guard(self): """Lock serializing every entry into the stateful C++ runtime.""" return self._infer_lock def _ensure_open(self) -> None: if self._closed or self._runtime is None: raise RuntimeError("Edge-LLM runtime is closed") @staticmethod def _ratio(num: int, denom: int) -> float: return num / denom if denom > 0 else 0.0 def _log_context_reuse_metrics(self) -> None: """Emit an INFO line making context-cache reuse visible. ``get_context_cache_metrics()`` returns cumulative coordinator counters for the whole server run, so the per-request figures are the delta since the previous logged request. ContextCacheMetrics carries no total-prompt -token field, so the headline hit rate is sequence-level (hit_sequences / admitted_sequences); reused/matched token counts are reported alongside to show token-level reuse is actually happening. """ cc = self._runtime.get_context_cache_metrics() if cc is None: return with self._ctx_reuse_metric_lock: cum_reused = int(cc.reused_tokens) cum_matched = int(cc.matched_tokens) cum_hit_seqs = int(cc.hit_sequences) cum_admitted = int(cc.admitted_sequences) req_reused = cum_reused - self._prev_ctx_reused_tokens req_matched = cum_matched - self._prev_ctx_matched_tokens req_hit_seqs = cum_hit_seqs - self._prev_ctx_hit_sequences req_admitted = cum_admitted - self._prev_ctx_admitted_sequences self._prev_ctx_reused_tokens = cum_reused self._prev_ctx_matched_tokens = cum_matched self._prev_ctx_hit_sequences = cum_hit_seqs self._prev_ctx_admitted_sequences = cum_admitted logger.info( "[context-reuse] request: reused=%d matched=%d hitSeqs=%d/%d " "hitRate=%.4f | cumulative: reused=%d matched=%d hitSeqs=%d/%d " "hitRate=%.4f records=%d hybridRestores=%d", req_reused, req_matched, req_hit_seqs, req_admitted, self._ratio(req_hit_seqs, req_admitted), cum_reused, cum_matched, cum_hit_seqs, cum_admitted, self._ratio(cum_hit_seqs, cum_admitted), int(cc.current_records), int(cc.hybrid_restores), ) def _handle_request(self, request): """Serialized entry to the C++ runtime.""" with self._infer_guard(): self._ensure_open() response = self._runtime.handle_request(request) # Callable on duck-typed non-LLM objects in the server tests, which have # no config to inherit a class default from. cache_config = getattr(self, "_context_cache_config", None) if cache_config is not None and cache_config.enabled: self._log_context_reuse_metrics() return response
[docs] def close(self) -> None: """Drain active work and release native engines and device memory.""" with self._close_lock: if self._closed: return self._closed = True with self._admission_sem: with self._infer_lock: self._runtime = None
def __enter__(self) -> "LLM": self._ensure_open() return self def __exit__(self, _exc_type, _exc, _traceback) -> None: self.close()
[docs] def generate( self, prompts: Union[str, List[str], List[List[Dict[str, Any]]]], sampling_params: Optional[SamplingParams] = None, *, tools: Optional[Sequence[Dict[str, Any]]] = None, tool_choice: Optional[Union[str, Dict[str, Any]]] = None, tool_parser: str = "auto", reasoning_parser: str = "none", ) -> List[CompletionOutput]: """Generate completions for the given prompts. Args: prompts: A single prompt string, a list of prompt strings, or a list of OpenAI-style message lists. sampling_params: Sampling configuration. Defaults to ``SamplingParams()``. tools: Optional OpenAI-compatible tool definitions. tool_choice: Optional OpenAI-compatible tool choice. Returns: List of ``CompletionOutput`` objects, one per prompt. """ params = sampling_params or SamplingParams() if isinstance(prompts, str): prompts = [prompts] message_batches = [] for p in prompts: if isinstance(p, str): message_batches.append([{"role": "user", "content": p}]) elif isinstance(p, list): message_batches.append(p) else: raise TypeError(f"Unsupported prompt type: {type(p)}") outputs = [] for messages in message_batches: tool_config = validate_tool_request(messages, tools, tool_choice) with self._admission(): self._ensure_open() request = self._make_generation_request( messages, params, tools=tool_config.tools, tool_choice=tool_config.tool_choice, tool_config=tool_config, ) output = self._complete_prepared_request( request, params, tool_config, tool_parser=tool_parser, reasoning_parser=reasoning_parser, ) outputs.append(output) return outputs
[docs] def chat( self, messages: List[Dict[str, Any]], sampling_params: Optional[SamplingParams] = None, *, tools: Optional[Sequence[Dict[str, Any]]] = None, tool_choice: Optional[Union[str, Dict[str, Any]]] = None, tool_parser: str = "auto", reasoning_parser: str = "none", ) -> CompletionOutput: """Single-turn chat completion (convenience wrapper). Args: messages: OpenAI-style message list. sampling_params: Sampling configuration. tools: Optional OpenAI-compatible tool definitions. tool_choice: Optional OpenAI-compatible tool choice. Returns: A single ``CompletionOutput``. """ return self.generate([messages], sampling_params, tools=tools, tool_choice=tool_choice, tool_parser=tool_parser, reasoning_parser=reasoning_parser)[0]
[docs] def generate_stream( self, messages: List[Dict[str, Any]], sampling_params: Optional[SamplingParams] = None, *, tools: Optional[Sequence[Dict[str, Any]]] = None, tool_choice: Optional[Union[str, Dict[str, Any]]] = None, prebuilt_request: Optional[Any] = None, ) -> Iterator[StreamDelta]: """Stream generation deltas for a single message list. Runs ``handleRequest`` in a background thread with a ``StreamChannel`` attached, yielding ``StreamDelta`` objects as tokens are produced. """ params = sampling_params or SamplingParams() state = {} def _cancel(): channel = state.get("channel") if channel is not None: channel.cancel() def _iterate(): self._ensure_open() channel = self._rt.StreamChannel.create() state["channel"] = channel channel.set_skip_special_tokens(params.skip_special_tokens) # The HTTP layer owns admission for a prebuilt request. Direct # callers acquire the per-LLM gate here. sem = None if prebuilt_request is not None else self._admission() if sem is not None: sem.acquire() try: self._ensure_open() if prebuilt_request is not None: request = prebuilt_request request.stream_channels = [channel] else: request = self._make_generation_request( messages, params, tools=tools, tool_choice=tool_choice, stream_channel=channel, ) except BaseException: if sem is not None: sem.release() raise error_holder = [None] def _run(): try: self._handle_request(request) except Exception as exc: error_holder[0] = exc channel.cancel() finally: if sem is not None: sem.release() worker = threading.Thread(target=_run, daemon=True) worker.start() try: while True: chunk = channel.wait_pop(timeout_ms=200) if chunk is None: if channel.is_finished() or channel.is_cancelled(): break continue reason = finish_reason_name( self._rt, chunk.reason) if chunk.finished else None yield StreamDelta( text=chunk.text, token_ids=list(chunk.token_ids), prompt_tokens=(chunk.prompt_token_count if chunk.prompt_token_count >= 0 else None), finished=chunk.finished, finish_reason=reason, logprobs=_convert_logprobs(chunk.logprobs), ) if chunk.finished: break finally: if not (channel.is_finished() or channel.is_cancelled()): channel.cancel() worker.join() if error_holder[0] is not None: raise error_holder[0] return _CancellableIterator(_iterate(), _cancel)
[docs] def generate_stream_with_audio( self, messages: List[Dict[str, Any]], sampling_params: Optional[SamplingParams] = None, *, audio_params: Optional[AudioParams] = None, prebuilt_request: Optional[Any] = None, ) -> Iterator[StreamDelta]: """Stream text and audio deltas for a single Omni request. Runs the Thinker-Talker streaming pipeline in a background thread. Text deltas arrive through a ``StreamChannel`` and PCM chunks through an ``AudioStreamChannel``; the two are interleaved into one generator. Admission follows generate_stream: the HTTP layer owns the gate when it passes ``prebuilt_request``; otherwise it is acquired here. """ if not self._layout.has_speech: raise ValueError("Omni audio output not available: talker / " "code_predictor / code2wav engines not loaded.") params = sampling_params or SamplingParams() state = {} def _cancel(): stream = state.get("stream") if stream is not None: stream.close() for name in ("text_channel", "audio_channel"): channel = state.get(name) if channel is not None: channel.cancel() def _iterate(): self._ensure_open() channel = self._rt.StreamChannel.create() audio_channel = self._rt.AudioStreamChannel() state["text_channel"] = channel state["audio_channel"] = audio_channel channel.set_skip_special_tokens(True) omni_params = _native_audio_params(self._rt, audio_params or AudioParams()) sem = None if prebuilt_request is not None else self._admission() if sem is not None: sem.acquire() try: self._ensure_open() if prebuilt_request is not None: request = prebuilt_request request.stream_channels = [channel] else: request = self._make_generation_request( messages, params, stream_channel=channel, ) except BaseException: if sem is not None: sem.release() raise def _run(): # Audio and text share one runtime and CUDA stream. with self._infer_guard(): self._ensure_open() self._runtime.handle_request_streaming_audio( request, audio_channel, omni_params) stream = _pump_channels(self._rt, _run, channel, audio_channel, sem=sem) state["stream"] = stream yield from stream return _CancellableIterator(_iterate(), _cancel)
# ------------------------------------------------------------------ # Server API # ------------------------------------------------------------------
[docs] def generate_speech_stream( self, text: str, audio_params: Optional[AudioParams] = None, ) -> Iterator[StreamDelta]: """Standalone TTS on the Omni stack: synthesize ``text`` directly. No Thinker generation pass — the input text goes straight to the Talker. Yields audio-only StreamDeltas. """ if not self._layout.has_speech: raise ValueError("TTS not available: Omni audio components " "(talker/code_predictor/code2wav) not loaded") sem = self._admission() return _stream_tts(self._rt, self._runtime, text, audio_params or AudioParams(), sem, infer_guard=self._infer_guard(), ensure_open=self._ensure_open)
[docs] def list_voices(self) -> List[str]: """Speaker names accepted as ``voice``; empty when not Omni-capable.""" if not self._layout.has_speech: return [] self._ensure_open() return sorted(self._runtime.get_speaker_names())
[docs] def serve(self, host: str = "0.0.0.0", port: int = 8000, *, served_model_name: str = "", api_key: str = "", reasoning_parser: str = "auto", tool_call_parser: str = "auto", enable_auto_tool_choice: bool = False, max_queued_requests: int = 16, queue_timeout: float = 600.0, allowed_local_media_path: Optional[str] = None) -> None: """Start the HTTP frontend for this runtime.""" from ..api.app import run_http_server from ..config import ApiConfig from .engine_client import EngineClient config = ApiConfig( host=host, port=port, served_model_name=served_model_name, api_key=api_key, reasoning_parser=reasoning_parser, tool_call_parser=tool_call_parser, enable_auto_tool_choice=enable_auto_tool_choice, max_queued_requests=max_queued_requests, queue_timeout=queue_timeout, allowed_local_media_path=allowed_local_media_path or "", ) run_http_server(EngineClient(self, config), config)
# ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ @property def model_dir(self) -> str: """Path to the resolved model checkpoint.""" return self._model_dir @property def model_id(self) -> str: """User-facing model identifier supplied at initialization.""" return self._model_id @property def bundle_dir(self) -> str: """Profile-specific engine bundle selected from the cache.""" return self._bundle_dir @property def cache_dir(self) -> str: """Root containing downloaded checkpoints and built bundles.""" return self._cache_dir @property def max_batch_size(self) -> int: """Maximum batch size supported by the loaded engine.""" return self._max_batch_size @property def video_capable(self) -> bool: """Whether this model bundle supports video input.""" try: self._video_model_family() return True except ValueError: return False @property def has_draft_model(self) -> bool: """Whether speculative decoding is active.""" return self._runtime.has_draft_model() @property def context_cache_enabled(self) -> bool: """Whether this runtime reuses matching text prefixes.""" return self._context_cache_config.enabled
[docs] def get_context_cache_metrics(self): """Return native reuse counters, or ``None`` when reuse is disabled.""" with self._infer_guard(): self._ensure_open() return self._runtime.get_context_cache_metrics()
@property def bundle_layout(self) -> BundleLayout: """Immutable component contract for the selected model bundle.""" return self._layout
[docs] class TTS: """Checkpoint-direct serving for a model-owned TTS component stack.""" runtime_kind = "tts" has_draft_model = False
[docs] def __init__( self, model: str, *, cache_dir: str = "", engine_cache_max_size_gb: float = 50.0, clear_engine_cache: bool = False, max_input_len: int = _DEFAULT_MAX_INPUT_LEN, max_batch_size: int = _DEFAULT_MAX_BATCH_SIZE, max_kv_cache_capacity: int = _DEFAULT_MAX_KV_CACHE_CAPACITY, build_options: Optional["BuildOptions"] = None, ) -> None: if not model: raise ValueError("'model' must be provided") if (isinstance(engine_cache_max_size_gb, bool) or not math.isfinite(engine_cache_max_size_gb) or engine_cache_max_size_gb <= 0): raise ValueError("engine_cache_max_size_gb must be positive") from .engine_build import BuildOptions, cache_root, prepare_model options = build_options or BuildOptions( max_input_len=max_input_len, max_batch_size=max_batch_size, max_kv_cache_capacity=max_kv_cache_capacity, ) if options.spec_type != "none": raise ValueError( "standalone TTS models do not support speculative decoding") prepared = prepare_model( model, cache_dir, options, max_cache_size_bytes=int(engine_cache_max_size_gb * (1 << 30)), clear_cache=clear_engine_cache, ) self._layout = inspect_bundle(prepared.bundle_dir) if not self._layout.has_speech: raise ValueError( f"model {model!r} does not provide a complete TTS runtime") self._model_dir = prepared.model_dir self._model_id = _derive_model_id(model) self._cache_dir = cache_root(cache_dir) self._bundle_dir = prepared.bundle_dir self._rt = _import_runtime() logger.info("Loading TTS runtime from %s", prepared.bundle_dir) self._runtime = self._rt.TTSRuntime( talker_engine_dir=self._layout.talker_dir, code_predictor_engine_dir=self._layout.code_predictor_dir, code2wav_engine_dir=self._layout.code2wav_dir, tokenizer_dir=self._layout.talker_dir, checkpoint_dir=prepared.model_dir, ) logger.info("TTS runtime ready") self._admission_sem = threading.Semaphore(1) self._close_lock = threading.Lock() self._closed = False
def _admission(self): """Per-instance admission gate (mirrors LLM._admission).""" return self._admission_sem
[docs] def generate_speech_stream( self, text: str, audio_params: Optional[AudioParams] = None, ) -> Iterator[StreamDelta]: """Synthesize ``text``; yields audio-only StreamDeltas.""" sem = self._admission() return _stream_tts(self._rt, self._runtime, text, audio_params or AudioParams(), sem, ensure_open=self._ensure_open)
def _ensure_open(self) -> None: if self._closed or self._runtime is None: raise RuntimeError("Edge-LLM runtime is closed")
[docs] def list_voices(self) -> List[str]: """Speaker names accepted as ``voice``.""" self._ensure_open() return sorted(self._runtime.get_speaker_names())
[docs] def close(self) -> None: """Drain active speech generation and release native resources.""" with self._close_lock: if self._closed: return self._closed = True with self._admission_sem: self._runtime = None
def __enter__(self) -> "TTS": self._ensure_open() return self def __exit__(self, _exc_type, _exc, _traceback) -> None: self.close() @property def model_id(self) -> str: return self._model_id @property def model_dir(self) -> str: return self._model_dir @property def bundle_dir(self) -> str: return self._bundle_dir @property def cache_dir(self) -> str: return self._cache_dir @property def bundle_layout(self) -> BundleLayout: """Immutable component contract for the selected model bundle.""" return self._layout
[docs] def serve(self, host: str = "0.0.0.0", port: int = 8000) -> None: """Start the HTTP server (speech endpoint only).""" from ..api.app import run_http_server from ..config import ApiConfig from .engine_client import EngineClient config = ApiConfig(host=host, port=port) run_http_server(EngineClient(self, config), config)
[docs] def load_model(**kwargs): """Select the model-specific runtime from provider checkpoint metadata.""" from .engine_build import resolve_model_dir original_model = kwargs["model"] resolved = resolve_model_dir(original_model, kwargs.get("cache_dir", "")) with open(os.path.join(resolved, "config.json"), encoding="utf-8") as file: model_type = json.load(file).get("model_type") if model_type == "qwen3_tts": runtime_class = TTS for name in ("draft_top_k", "draft_step", "verify_tree_size"): if kwargs.pop(name, None) is not None: raise ValueError( f"standalone TTS models do not support {name}") if kwargs.pop("speculative_config", None): raise ValueError( "standalone TTS models do not support speculative decoding") context_cache = ContextCacheConfig.parse( kwargs.pop("context_cache_config", None)) if context_cache.enabled: raise ValueError( "standalone TTS models do not use a KV context cache") else: runtime_class = LLM runtime = runtime_class(**{**kwargs, "model": resolved}) runtime._model_id = _derive_model_id(original_model) return runtime
# --------------------------------------------------------------------------- # Message conversion & image loading # --------------------------------------------------------------------------- def finish_reason_name(rt_module, reason) -> Optional[str]: """Map a C++ FinishReason enum value to its OpenAI-compatible string. NOT_FINISHED maps to None — reaching this function with a non-terminal reason indicates a bug; surfacing None instead of silently returning "stop" makes it visible. The fallback "stop" catches truly-unknown enum values (e.g. future C++ enum additions). STOP_WORDS and END_ID both map to "stop" since OpenAI does not distinguish them. """ return { rt_module.FinishReason.NOT_FINISHED: None, rt_module.FinishReason.END_ID: "stop", rt_module.FinishReason.LENGTH: "length", rt_module.FinishReason.CANCELLED: "cancelled", rt_module.FinishReason.ERROR: "error", rt_module.FinishReason.STOP_WORDS: "stop", }.get(reason, "stop") def _convert_messages_to_cpp(rt_module, messages: List[Dict[str, Any]]): """Convert Python message dicts to C++ Message objects.""" cpp_messages = [] for msg in messages: cpp_msg = rt_module.Message() cpp_msg.role = msg["role"] content = msg["content"] contents_list = [] if isinstance(content, str): contents_list.append(rt_module.MessageContent("text", content)) elif isinstance(content, list): for item in content: if isinstance(item, str): contents_list.append(rt_module.MessageContent( "text", item)) elif isinstance(item, dict): ct = item.get("type", "text") if ct == "text": contents_list.append( rt_module.MessageContent( "text", item.get("text", ""), )) elif ct in ("image", "image_url"): contents_list.append( rt_module.MessageContent("image", "")) elif ct in ("video", "video_url"): # Frames are decoded out-of-band by _load_image_buffers; the chat # template expands this placeholder into the video triplet and the # ViT runner keys off ImageData.isVideo. contents_list.append( rt_module.MessageContent("video", "")) elif ct in ("audio", "input_audio", "audio_url"): # Audio bytes are decoded out-of-band by # `_load_audio_buffers`; the chat template just emits # an opaque audio placeholder here. The per-model # audio runner expands that into model-specific # special tokens (Qwen3: <|audio_start|> + # N×<|audio_pad|> + <|audio_end|>; Nemotron-Omni: # N×<so_embedding>). contents_list.append( rt_module.MessageContent("audio", "")) else: raise ValueError(f"Unsupported content type: {ct}") cpp_msg.contents = contents_list cpp_messages.append(cpp_msg) return cpp_messages def _load_image_buffers(rt_module, messages: List[Dict[str, Any]], video_family_fn=lambda: "qwen", video_frame_limits_fn=lambda: {}): """Build the ordered ImageData list for the messages: images and videos share one list the C++ runner matches positionally against the <|image_pad|> / <|video_pad|> placeholders, so append in message order.""" images = [] items = [ item for msg in messages if isinstance(msg.get("content"), list) for item in msg["content"] if isinstance(item, dict) ] from ..media.media_source import resolve_image_message image_sources = { id(item): resolve_image_message(item) for item in items if item.get("type") in ("image", "image_url") } # Videos and images share one engine token profile: track the remaining # budget so multiple media cannot each claim full capacity. Lazy so # non-video requests never touch the video family whitelist. has_video = any( item.get("type") in ("video", "video_url") for item in items) family = video_family_fn() if has_video else "qwen" if has_video and family == "nemotron": # The C++ Nemotron video path handles exactly one video and no mixed-in # images per request (batch of one); reject other layouts here rather # than letting them fail inside the runner. n_videos = sum(1 for it in items if it.get("type") in ("video", "video_url")) n_images = sum(1 for it in items if it.get("type") in ("image", "image_url")) if n_videos > 1 or n_images > 0: raise ValueError( "Nemotron-Omni video requests support exactly one video and no " f"images (got {n_videos} videos, {n_images} images)") limits = video_frame_limits_fn() if has_video else {} budget = limits.get("max_image_tokens") if limits else None video_tokens = 0 # Pre-pruning token count for the engine-minimum check: the ViT processes # every tubelet, so Nemotron's EVS-pruned estimate would understate what the # min-profile actually receives. Non-EVS families track the same value. video_raw_tokens = 0 # Request-wide decoded-pixel budget: several videos each under the # per-video ceiling must not jointly exhaust host memory. pixel_budget = None # Request-wide cu_seqlens group budget (Qwen only; InternVL has no # cu_seqlens binding): use builder-recorded capacity or derive it from the # current component profile. cu_budget = None if limits and family != "internvl": cu_budget = (limits.get("max_cu_seqlen_groups") or limits["max_image_tokens"] // max(1, limits.get("min_image_tokens", 1))) image_upper = 0 # Phase 1: reserve every image up front so the video sampler's budget is # order-independent ([image, video] and [video, image] behave identically). if budget is not None: from ..media.video_sampling import estimate_image_tokens for item in items: if item.get("type") not in ("image", "image_url"): continue source = image_sources[id(item)] if isinstance(source, str) and os.path.isfile(source): est = estimate_image_tokens(source, family, limits, do_resize=bool( item.get("do_resize", True))) else: est = int(limits.get("max_image_tokens_per_image", 0)) image_upper += est budget -= est if cu_budget is not None: # One cu_seqlens entry per image (Qwen families only; # InternVL has no cu_seqlens binding). cu_budget -= 1 # Phase 2: build the buffers in original message order (the C++ runner # matches them positionally against the placeholders). for item in items: itype = item.get("type") if itype in ("image", "image_url"): source = image_sources[id(item)] image = (rt_module.load_image_from_bytes(source) if isinstance( source, bytes) else rt_module.load_image_from_path(source)) image.do_resize = bool(item.get("do_resize", True)) images.append(image) elif itype in ("video", "video_url"): from ..media.video_sampling import (MAX_DECODE_PIXELS, load_video_buffer) if pixel_budget is None: pixel_budget = MAX_DECODE_PIXELS buffer, est_tokens, used_px, used_groups = load_video_buffer( rt_module, item, family, frame_limits=limits, budget=budget, pixel_budget=pixel_budget, cu_budget=cu_budget) images.append(buffer) video_tokens += est_tokens raw_tokens = est_tokens if family == "nemotron": from ..media.video_sampling import _nemotron_tubelet_geometry geom = _nemotron_tubelet_geometry(limits) if geom: t_frames, tokens_per_tubelet, _q = geom raw_tokens = (-(-buffer.frames // t_frames)) \ * tokens_per_tubelet video_raw_tokens += raw_tokens pixel_budget -= used_px if cu_budget is not None: cu_budget -= used_groups if budget is not None: budget -= est_tokens # Engine bounds are request-wide (all media accumulate in one ViT batch), # so validate after the loop: two videos jointly reaching the minimum are # fine, one alone may not be. if cu_budget is not None and cu_budget < 0: raise ValueError( "request media exceed the visual engine's cu_seqlens capacity; " "reduce the media count") if budget is not None and budget < 0: raise ValueError( "request media need more visual tokens than the engine's " f"budget of {limits['max_image_tokens']}; reduce the media in " "the request") if (video_raw_tokens or image_upper) and limits and \ limits.get("min_image_tokens"): # The engine minimum is request-wide; the upper estimate is pre-EVS # (raw tubelets for Nemotron). It can fall short for a too-short clip or # do_resize=false media; resized per-item images are floored above this. upper_tokens = video_raw_tokens + image_upper if upper_tokens < limits["min_image_tokens"]: raise ValueError( f"request media yield ~{upper_tokens} visual tokens but the " f"engine needs at least {limits['min_image_tokens']}; use " "longer videos or raise nframes/fps") return images def _load_audio_buffers(rt_module, messages: List[Dict[str, Any]]): """Load audio content from messages into AudioData buffers. Returns an empty list when no audio is present, keeping the byte-identical fast path for text-only and image-only requests. """ from ..media.audio_preprocess import load_audio_buffers return load_audio_buffers(rt_module, messages)