import json import queue import threading from collections.abc import Iterator from typing import Any, Callable, Dict, List, Literal, Optional, Union # noqa: UP035 import tvm from mlc_llm.protocol import debug_protocol, openai_api_protocol from mlc_llm.serve import engine_utils from mlc_llm.serve.engine_base import ( EngineConfig, EngineMetrics, _check_engine_config, _parse_models, _process_model_args, _query_engine_metrics, detect_device, ) from mlc_llm.tokenizers import Tokenizer class EngineState: sync_queue: queue.Queue def get_request_stream_callback(self) -> Callable[[str], None]: # ChatCompletionStreamResponse def _callback(chat_completion_stream_responses_json_str: str) -> None: self._sync_request_stream_callback(chat_completion_stream_responses_json_str) return _callback def _sync_request_stream_callback(self, chat_completion_stream_responses_json_str: str) -> None: # Put the delta outputs to the queue in the unblocking way. self.sync_queue.put_nowait(chat_completion_stream_responses_json_str) def handle_chat_completion( self, ffi: dict, request_json_str: str, include_usage: bool, request_id: str ) -> Iterator[openai_api_protocol.ChatCompletionStreamResponse]: """Helper class to handle chat completion Note ---- ffi is explicitly passed in to avoid cylic dependency as ffi will capture EngineState """ self.sync_queue = queue.Queue() ffi["chat_completion"](request_json_str, request_id) try: last_chunk_arrived = False while not last_chunk_arrived: chat_completion_responses_json_str = self.sync_queue.get() chat_completion_responses_list = json.loads(chat_completion_responses_json_str) for chat_completion_response_json_dict in chat_completion_responses_list: chat_completion_response = ( openai_api_protocol.ChatCompletionStreamResponse.model_validate( chat_completion_response_json_dict ) ) # the chunk with usage is always the last chunk if chat_completion_response.usage is not None: if include_usage: yield chat_completion_response last_chunk_arrived = True break yield chat_completion_response except Exception as exception: ffi["abort"](request_id) raise exception class BackgroundLoops: """Helper class to keep track of background loops""" def __init__(self, ffi: dict): self._ffi = ffi # important: avoid self reference in closure background_loop = self._ffi["run_background_loop"] background_stream_back_loop = self._ffi["run_background_stream_back_loop"] # Create the background engine-driving thread and start the loop. self._background_loop_thread: threading.Thread = threading.Thread(target=background_loop) self._background_stream_back_loop_thread: threading.Thread = threading.Thread( target=background_stream_back_loop ) self._background_loop_thread.start() self._background_stream_back_loop_thread.start() self._terminated = False def __del__(self): self.terminate() def terminate(self): if self._terminated: return self._terminated = True self._ffi["exit_background_loop"]() self._background_loop_thread.join() self._background_stream_back_loop_thread.join() class Completions: """Completions class to be compatible with OpenAI API""" _ffi: dict _state: EngineState _background_loops: BackgroundLoops def __init__(self, ffi: dict, state: EngineState, background_loops: BackgroundLoops): self._ffi = ffi self._state = state self._background_loops = background_loops def create( self, *, messages: List[Dict[str, Any]], # noqa: UP006 model: Optional[str] = None, frequency_penalty: Optional[float] = None, presence_penalty: Optional[float] = None, logprobs: bool = False, top_logprobs: int = 0, logit_bias: Optional[Dict[int, float]] = None, # noqa: UP006 max_tokens: Optional[int] = None, n: int = 1, seed: Optional[int] = None, stop: Optional[Union[str, List[str]]] = None, # noqa: UP006 stream: bool = True, stream_options: Optional[Dict[str, Any]] = None, # noqa: UP006 temperature: Optional[float] = None, top_p: Optional[float] = None, tools: Optional[List[Dict[str, Any]]] = None, # noqa: UP006 tool_choice: Optional[Union[Literal["none", "auto"], Dict]] = None, # noqa: UP006 user: Optional[str] = None, response_format: Optional[Dict[str, Any]] = None, # noqa: UP006 request_id: Optional[str] = None, extra_body: Optional[Dict[str, Any]] = None, # noqa: UP006 ) -> Iterator[openai_api_protocol.ChatCompletionStreamResponse]: if request_id is None: request_id = f"chatcmpl-{engine_utils.random_uuid()}" debug_config = extra_body.get("debug_config", None) if extra_body is not None else None if not stream: raise ValueError("JSONFFIEngine only support stream=True") request = openai_api_protocol.ChatCompletionRequest( messages=[ openai_api_protocol.ChatCompletionMessage.model_validate(message) for message in messages ], model=model, frequency_penalty=frequency_penalty, presence_penalty=presence_penalty, logprobs=logprobs, top_logprobs=top_logprobs, logit_bias=logit_bias, max_tokens=max_tokens, n=n, seed=seed, stop=stop, stream=stream, stream_options=( openai_api_protocol.StreamOptions.model_validate(stream_options) if stream_options is not None else None ), temperature=temperature, top_p=top_p, tools=( [openai_api_protocol.ChatTool.model_validate(tool) for tool in tools] if tools is not None else None ), tool_choice=tool_choice, user=user, response_format=( openai_api_protocol.RequestResponseFormat.model_validate(response_format) if response_format is not None else None ), debug_config=( debug_protocol.DebugConfig.model_validate(debug_config) if debug_config is not None else None ), ) chatcmpl_generator = self._state.handle_chat_completion( self._ffi, request.model_dump_json(by_alias=True), include_usage=( request.stream_options is not None and request.stream_options.include_usage ), request_id=request_id, ) for response in chatcmpl_generator: yield response class Chat: """Chat class to be compatible with OpenAI API""" completions: Completions def __init__(self, ffi: dict, state: EngineState, background_loops: BackgroundLoops): self.completions = Completions(ffi, state, background_loops) class JSONFFIEngine: chat: Chat def __init__( self, model: str, device: Union[str, tvm.runtime.Device] = "auto", *, model_lib: Optional[str] = None, mode: Literal["local", "interactive", "server"] = "local", engine_config: Optional[EngineConfig] = None, ) -> None: # - Check the fields fields of `engine_config`. if engine_config is None: engine_config = EngineConfig() _check_engine_config(model, model_lib, mode, engine_config) # - Initialize model loading info. models = _parse_models(model, model_lib, engine_config.additional_models) if isinstance(device, str): device = detect_device(device) assert isinstance(device, tvm.runtime.Device) model_args = _process_model_args(models, device, engine_config)[0] # - Load the raw model config into dict for i, model_info in enumerate(models): model_info.model_lib = model_args[i][1] # - Initialize engine state and engine. self._state = EngineState() module = tvm.get_global_func("mlc.json_ffi.CreateJSONFFIEngine", allow_missing=False)() self._ffi = { key: module[key] for key in [ "init_background_engine", "reload", "unload", "reset", "chat_completion", "abort", "run_background_loop", "run_background_stream_back_loop", "exit_background_loop", ] } self.tokenizer = Tokenizer(model_args[0][0]) self._background_loops = BackgroundLoops(self._ffi) engine_config.model = model_args[0][0] engine_config.model_lib = model_args[0][1] engine_config.additional_models = model_args[1:] engine_config.mode = mode self.engine_config = engine_config self._ffi["init_background_engine"]( device.dlpack_device_type(), device.index, self._state.get_request_stream_callback(), ) self._ffi["reload"](self.engine_config.asjson()) self.chat = Chat(self._ffi, self._state, self._background_loops) def metrics(self) -> EngineMetrics: """Get the engine metrics.""" return _query_engine_metrics(self) def _raw_chat_completion( self, request_json_str: str, include_usage: bool, request_id: str ) -> Iterator[openai_api_protocol.ChatCompletionStreamResponse]: """Raw chat completion API""" return self._state.handle_chat_completion( self._ffi, request_json_str, include_usage, request_id ) def terminate(self): """Explicitly terminate the engine""" self._background_loops.terminate() def _test_reload(self): self._ffi["reload"](self.engine_config.asjson()) def _test_reset(self): self._ffi["reset"]() def _test_unload(self): self._ffi["unload"]()