Files
wehub-resource-sync 770d92cb1f
Lint / lint (push) Has been cancelled
Build Docs / Deploy Docs (push) Has been cancelled
Windows CI / Windows (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:23:58 +08:00

1279 lines
47 KiB
Python

"""The MLC LLM Serving engine base class."""
import ast
import asyncio
import json
import numbers
import queue
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import ( # noqa: UP035
Any,
Callable,
ClassVar,
Dict,
List,
Literal,
Optional,
Tuple,
Union,
)
import tvm
from tvm.runtime import Device
from mlc_llm.protocol import openai_api_protocol
from mlc_llm.protocol.conversation_protocol import Conversation
from mlc_llm.protocol.generation_config import GenerationConfig
from mlc_llm.protocol.mlc_chat_config import MLCChatConfig
from mlc_llm.serve import data, engine_utils
from mlc_llm.serve.config import EngineConfig
from mlc_llm.serve.event_trace_recorder import EventTraceRecorder
from mlc_llm.support import download_cache, logging
from mlc_llm.support.auto_device import detect_device
from mlc_llm.support.style import green
from mlc_llm.tokenizers import TextStreamer, Tokenizer
logger = logging.getLogger(__name__)
@dataclass
class ModelInfo:
"""The model info dataclass.
Parameters
----------
model : str
The identifier of the input model.
It may be a compiled model's id (e.g., "Llama-2-7b-chat-hf-q4f16_1"),
or a full path to a model directory
(e.g., "dist/prebuilt/mlc-chat-Llama-2-7b-chat-hf-q4f16_1")
model_lib : Optional[str]
The path to the compiled library of the model.
E.g., "dist/prebuilt/lib/Llama-2-7b-chat-hf-q4f16_1-cuda.so"
"""
model: str
model_lib: Optional[str] = None
def _check_engine_config(
model: str,
model_lib: Optional[str],
mode: Literal["local", "interactive", "server"],
engine_config: EngineConfig,
) -> None:
"""Check if the given engine config is valid."""
if engine_config.model is not None and engine_config.model != model:
raise ValueError(
f'The argument "model" of engine constructor is "{model}", while the "model" '
f'field in argument "engine_config" is "{engine_config.model}". '
'Please set the "engine_config.model" to None or set it to the same as the '
'argument "model".'
)
if (
engine_config.model_lib is not None
and model_lib is not None
and engine_config.model_lib != model_lib
):
raise ValueError(
f'The argument "model_lib" of engine constructor is "{model_lib}", while the '
f'"model_lib" field in argument "engine_config" is "{engine_config.model_lib}". '
'Please set the "engine_config.model_lib" to None or set it to the same as the '
'argument "model_lib".'
)
if engine_config.mode is not None and engine_config.mode != mode:
raise ValueError(
f'The argument "mode" of engine constructor is "{mode}", while the '
f'"mode" field in argument "engine_config" is "{engine_config.mode}". '
'Please set the "engine_config.mode" to None or set it to the same as the '
'argument "mode".'
)
if engine_config.kv_cache_page_size != 16:
raise ValueError(
'KV cache only supports page size 16, while the "kv_cache_page_size" field in '
f'argument "engine_config" is "{engine_config.kv_cache_page_size}". '
'Please set "engine_config.kv_cache_page_size" to 16.'
)
def _parse_models(
model: str,
model_lib: Optional[str],
additional_models: List[Union[str, Tuple[str, str]]], # noqa: UP006
) -> List[ModelInfo]: # noqa: UP006
"""Parse the specified model paths and model libs.
Return a list of ModelInfo, which is a wrapper class of the model path + lib path.
"""
models = [ModelInfo(model, model_lib)]
for additional_model in additional_models:
if isinstance(additional_model, str):
models.append(ModelInfo(additional_model))
else:
models.append(ModelInfo(additional_model[0], additional_model[1]))
return models
def _process_model_args(
models: List[ModelInfo], # noqa: UP006
device: tvm.runtime.Device,
engine_config: EngineConfig,
) -> Tuple[List[Tuple[str, str]], List[str], Conversation]: # noqa: UP006
"""Process the input ModelInfo to get the engine initialization arguments."""
conversation: Optional[Conversation] = None
config_file_paths: List[str] = [] # noqa: UP006
def _convert_model_info(model: ModelInfo) -> Tuple[str, str]: # noqa: UP006
nonlocal conversation
model_path = download_cache.get_or_download_model(model.model)
mlc_config_path = model_path / "mlc-chat-config.json"
config_file_paths.append(str(mlc_config_path))
with open(mlc_config_path, encoding="utf-8") as file:
mlc_chat_config = MLCChatConfig.model_validate_json(file.read())
if conversation is None:
conversation = mlc_chat_config.conv_template
if model.model_lib is not None:
# do model lib search if the model lib is provided
# error out if file not found
if model.model_lib.startswith("mock://"):
model_lib = model.model_lib
logger.info("[DEBUG] mock test: %s", model_lib)
elif Path(model.model_lib).is_file():
model_lib = model.model_lib
logger.info("Using library model: %s", model_lib)
else:
raise FileNotFoundError(
f"The `model_lib` you passed in is not a file: {model.model_lib}.\n"
)
else:
# Run jit if model_lib is not provided
# NOTE: we only import jit when necessary
# so the engine do not have to depend on compilation
from mlc_llm.interface import jit
model_compile_overrides = {
"context_window_size": engine_config.max_single_sequence_length,
"prefill_chunk_size": engine_config.prefill_chunk_size,
"sliding_window_size": engine_config.sliding_window_size,
"attention_sink_size": engine_config.attention_sink_size,
"tensor_parallel_shards": engine_config.tensor_parallel_shards,
"pipeline_parallel_stages": engine_config.pipeline_parallel_stages,
"max_batch_size": engine_config.max_num_sequence,
"opt": engine_config.opt,
}
model_lib = jit.jit(
model_path=model_path,
overrides=model_compile_overrides,
device=device,
).model_lib_path
return str(model_path), model_lib
model_args: List[Tuple[str, str]] = [_convert_model_info(model) for model in models] # noqa: UP006
assert conversation is not None
return model_args, config_file_paths, conversation
def _print_engine_mode_logging_msg(
mode: Literal["local", "interactive", "server"],
) -> None:
"""Print the logging info for engine mode selection."""
if mode == "local":
logger.info(
"The selected engine mode is %s. "
"We choose small max batch size and KV cache capacity to use less GPU memory.",
green(mode),
)
elif mode == "interactive":
logger.info(
"The selected engine mode is %s. "
"We fix max batch size to 1 for interactive single sequence use.",
green(mode),
)
else:
logger.info(
"The selected engine mode is %s. "
"We use as much GPU memory as possible (within the limit "
"of gpu_memory_utilization).",
green(mode),
)
if mode != "local":
logger.info(
"If you have low concurrent requests and want to use less GPU memory, "
'please select mode "local".'
)
if mode != "interactive":
logger.info(
"If you don't have concurrent requests and only use the engine interactively, "
'please select mode "interactive".'
)
if mode != "server":
logger.info(
"If you have high concurrent requests and want to maximize the GPU memory utilization, "
'please select mode "server".'
)
class EngineMetrics:
"""Class to store the result returned by engine metrics"""
metrics: dict
def __init__(self, metrics):
self.metrics = metrics
def __str__(self):
return self.metrics.__str__()
def __repr__(self):
return self.metrics.__repr__()
def __getitem__(self, key):
return self.metrics[key]
def prometheus_text(self) -> str:
"""Convert engine metrics into prometheus text format
Returns
-------
text: str
The metrics in prometheus text format
"""
output_lines = [
"# NOTE: these metrics count token in the unit of serving model's tokenization",
"# be careful when comparing them to client-side metrics that may use",
"# different tokenization to standardize across models.\n",
]
def traverse(comment_scope, key_prefix, curr_value):
if isinstance(curr_value, dict):
if comment_scope:
output_lines.append(f"\n# {comment_scope}")
# first prioritize metrics in current scope
for key, value in curr_value.items():
if isinstance(value, numbers.Number):
output_lines.append(f"{key_prefix}{key}\t{value}")
# then look into nested scopes if any
for key, value in curr_value.items():
if isinstance(value, dict) and len(value) != 0:
traverse(f"{comment_scope}/{key}", f"{key_prefix}{key}_", value)
traverse("", "", self.metrics)
return "\n".join(output_lines)
def _query_engine_metrics(engine):
"""Query engine metrics via debug options"""
dummy_message = {"role": "user", "context": ""}
for response in engine.chat.completions.create(
messages=[dummy_message],
model="model",
stream=True,
stream_options={"include_usage": True},
extra_body={"debug_config": {"special_request": "query_engine_metrics"}},
):
if response.usage is not None:
return EngineMetrics(response.usage.extra)
raise RuntimeError("query_engine metrics did not get metrics back")
async def _async_query_engine_metrics(engine):
"""Query engine metrics via debug options"""
dummy_message = {"role": "user", "context": ""}
result = None
async for response in await engine.chat.completions.create(
messages=[dummy_message],
model="model",
stream=True,
stream_options={"include_usage": True},
extra_body={"debug_config": {"special_request": "query_engine_metrics"}},
):
if response.usage is not None:
assert result is None
result = EngineMetrics(response.usage.extra)
if result is not None:
return result
raise RuntimeError("query_engine metrics did not get metrics back")
@dataclass
class CallbackStreamOutput:
"""The output of MLCEngine._generate and AsyncMLCEngine._generate
Attributes
----------
delta_text : str
The delta text generated since the last output.
delta_logprob_json_strs : Optional[List[str]]
The list of logprob JSON strings since the last output,
or None if the request does not require logprobs.
finish_reason : Optional[str]
The finish reason of the request, or None if unfinished.
request_final_usage_json_str: Optional[str]
The usage json which appears in last chunk,
when it appears all other fields will be empty
"""
delta_text: str
delta_logprob_json_strs: Optional[List[str]] # noqa: UP006
finish_reason: Optional[str]
request_final_usage_json_str: Optional[str]
class AsyncRequestStream:
"""The asynchronous stream for requests in AsyncMLCEngine.
Each request has its own unique stream.
The stream exposes the method `push` for engine to push new generated
delta text to the stream, and the method `finish` for engine to mark
the finish of generation.
The stream implements `__aiter__` and `__anext__`, which the engine
can use to iterates all the generated tokens in order asynchronously.
"""
# The asynchronous queue to hold elements of either a list of
# CallbackStreamOutput or an exception.
_queue: asyncio.Queue[
Union[List[CallbackStreamOutput], Exception] # noqa: UP006
]
# The finish flag.
_finished: bool
def __init__(self) -> None:
self._queue = asyncio.Queue()
self._finished = False
def push(self, item_or_exception: Union[List[CallbackStreamOutput], Exception]) -> None: # noqa: UP006
"""Push a new token to the stream."""
if self._finished:
# No new item is expected after finish.
self._queue.put_nowait(
RuntimeError(
"The request has already finished. "
"The stream is not supposed to accept new items."
)
)
return
self._queue.put_nowait(item_or_exception)
def finish(self) -> None:
"""Mark the finish of the generation in the stream."""
self._queue.put_nowait(StopIteration())
self._finished = True
def __aiter__(self):
return self
async def __anext__(self) -> List[CallbackStreamOutput]: # noqa: UP006
result = await self._queue.get()
if isinstance(result, StopIteration):
raise StopAsyncIteration
if isinstance(result, Exception):
raise result
return result
class EngineState:
"""The engine states that the request stream callback function may use.
This class is used for both AsyncMLCEngine and MLCEngine.
AsyncMLCEngine uses the fields and methods starting with "async",
and MLCEngine uses the ones starting with "sync".
- For AsyncMLCEngine, the state contains an asynchronous event loop,
the streamers and the number of unfinished generations for each request
being processed.
- For MLCEngine, the state contains a callback output blocking queue,
the text streamers and the number of unfinished requests.
We use this state class to avoid the callback function from capturing
the AsyncMLCEngine.
The state also optionally maintains an event trace recorder, which can
provide Chrome tracing when enabled.
"""
trace_recorder = None
# States used for AsyncMLCEngine
async_event_loop: Optional[asyncio.AbstractEventLoop] = None
async_streamers: ClassVar[Dict[str, Tuple[AsyncRequestStream, List[TextStreamer]]]] = {} # noqa: UP006
# States used for MLCEngine
sync_output_queue: queue.Queue = queue.Queue()
sync_text_streamers: ClassVar[List[TextStreamer]] = [] # noqa: UP006
def __init__(self, enable_tracing: bool) -> None:
"""Constructor."""
if enable_tracing:
self.trace_recorder = EventTraceRecorder()
def record_event(self, request_id: str, event: str) -> None:
"""Record a event for the input request in the trace
recorder when the recorder exists.
Parameters
----------
request_id : str
The subject request of the event.
event : str
The event in a string name.
It can have one of the following patterns:
- "start xxx", which marks the start of event "xxx",
- "finish xxx", which marks the finish of event "xxx",
- "yyy", which marks the instant event "yyy".
The "starts" and "finishes" will be automatically paired in the trace recorder.
"""
if self.trace_recorder is None:
return
self.trace_recorder.add_event(request_id, event)
def get_request_stream_callback(
self, kind: Literal["async", "sync"]
) -> Callable[[List[data.RequestStreamOutput]], None]: # noqa: UP006
"""Construct a callback function and return.
The callback function has signature
"Callable[[List[data.RequestStreamOutput]], None]",
whose input is a list of "data.RequestStreamOutput".
Each "data.RequestStreamOutput" is the delta output of a request,
generated from the engine.
"""
f_callback = (
self._async_request_stream_callback
if kind == "async"
else self._sync_request_stream_callback
)
def _callback(delta_outputs: List[data.RequestStreamOutput]) -> None: # noqa: UP006
f_callback(delta_outputs)
return _callback
def async_lazy_init_event_loop(self) -> None:
"""Lazily set the asyncio event loop so that the event
loop is the main driving event loop of the process.
"""
if self.async_event_loop is None:
self.async_event_loop = asyncio.get_event_loop()
def _async_request_stream_callback(self, delta_outputs: List[data.RequestStreamOutput]) -> None: # noqa: UP006
"""The request stream callback function for AsyncMLCEngine to stream back
the request generation results.
Note
----
This callback function uses `call_soon_threadsafe` in asyncio to
schedule the invocation in the event loop, so that the underlying
callback logic will be executed asynchronously in the future rather
than right now.
"""
# Schedule a callback run in the event loop without executing right now.
# NOTE: This function causes GIL during execution.
self.async_event_loop.call_soon_threadsafe(
self._async_request_stream_callback_impl, delta_outputs
)
def _async_request_stream_callback_impl(
self,
delta_outputs: List[data.RequestStreamOutput], # noqa: UP006
) -> None:
"""The underlying implementation of request stream callback for AsyncMLCEngine."""
for delta_output in delta_outputs:
request_id, stream_outputs = delta_output.unpack()
streamers = self.async_streamers.get(request_id, None)
if streamers is None:
continue
self.record_event(request_id, event="start callback")
stream, text_streamers = streamers
# final chunk is now always indicated by a chunk
# where usage json is present
# the backend engine always streams back this chunk
# regardless of include_usage option
is_final_chunk = stream_outputs[0].request_final_usage_json_str is not None
if is_final_chunk:
# stream back this final usage chunk
output = CallbackStreamOutput(
delta_text="",
delta_logprob_json_strs=None,
finish_reason=None,
request_final_usage_json_str=stream_outputs[0].request_final_usage_json_str,
)
stream.push([output])
stream.finish()
self.async_streamers.pop(request_id, None)
continue
outputs = []
for stream_output, text_streamer in zip(stream_outputs, text_streamers):
self.record_event(request_id, event="start detokenization")
delta_text = stream_output.extra_prefix_string + (
text_streamer.put(stream_output.delta_token_ids)
if len(stream_output.delta_token_ids) > 0
else ""
)
if stream_output.finish_reason is not None:
delta_text += text_streamer.finish()
self.record_event(request_id, event="finish detokenization")
outputs.append(
CallbackStreamOutput(
delta_text=delta_text,
delta_logprob_json_strs=stream_output.delta_logprob_json_strs,
finish_reason=stream_output.finish_reason,
request_final_usage_json_str=None,
)
)
# Push new delta text to the stream.
stream.push(outputs)
self.record_event(request_id, event="finish callback")
def _sync_request_stream_callback(self, delta_outputs: List[data.RequestStreamOutput]) -> None: # noqa: UP006
"""The request stream callback function for MLCEngine to stream back
the request generation results.
"""
# Put the delta outputs to the queue in the unblocking way.
self.sync_output_queue.put_nowait(delta_outputs)
class MLCEngineBase:
"""The base engine class, which implements common functions that
are shared by MLCEngine and AsyncMLCEngine.
This class wraps a threaded engine that runs on a standalone
thread inside and streams back the delta generated results via
callback functions. The internal threaded engine keeps running an
loop that drives the engine.
MLCEngine and AsyncMLCEngine inherits this MLCEngineBase class, and implements
their own methods to process the delta generated results received
from callback functions and yield the processed delta results in
the forms of standard API protocols.
Checkout subclasses AsyncMLCEngine/MLCEngine for the docstring of constructor parameters.
"""
def __init__(
self,
kind: Literal["async", "sync"],
model: str,
device: Union[str, tvm.runtime.Device],
model_lib: Optional[str],
mode: Literal["local", "interactive", "server"],
engine_config: Optional[EngineConfig],
enable_tracing: bool,
) -> 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, Device)
(
model_args,
model_config_paths,
self.conv_template,
) = _process_model_args(models, device, engine_config)
# - Load the raw model config into dict
self.model_config_dicts = []
for i, model_info in enumerate(models):
model_info.model_lib = model_args[i][1]
with open(model_config_paths[i], encoding="utf-8") as file:
self.model_config_dicts.append(json.load(file))
# - Print logging info for regarding the mode selection.
if engine_config.verbose:
_print_engine_mode_logging_msg(mode)
# - Initialize engine state and engine.
self.state = EngineState(enable_tracing)
module = tvm.get_global_func("mlc.serve.create_threaded_engine", allow_missing=False)()
self._ffi = {
key: module[key]
for key in [
"add_request",
"abort_request",
"run_background_loop",
"run_background_stream_back_loop",
"reload",
"init_threaded_engine",
"exit_background_loop",
"create_request",
"get_complete_engine_config",
"reset",
"debug_call_func_on_all_worker",
]
}
self.tokenizer = Tokenizer(model_args[0][0])
self._ffi["init_threaded_engine"](
device,
self.state.get_request_stream_callback(kind),
self.state.trace_recorder,
)
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
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._ffi["reload"](engine_config.asjson())
self.engine_config = EngineConfig.from_json(self._ffi["get_complete_engine_config"]())
self.max_input_sequence_length = min(
self.engine_config.max_single_sequence_length,
self.engine_config.max_total_sequence_length,
)
def __del__(self):
"""deleter, auto terminate"""
self.terminate()
def terminate(self):
"""Terminate the engine."""
if hasattr(self, "_terminated") and self._terminated:
return
self._terminated = True
if not hasattr(self, "_ffi"):
return
self._ffi["exit_background_loop"]()
if hasattr(self, "_background_loop_thread"):
self._background_loop_thread.join()
if hasattr(self, "_background_stream_back_loop_thread"):
self._background_stream_back_loop_thread.join()
def _debug_call_func_on_all_worker(
self, func_name: str, func_args: Optional[str] = None
) -> None:
"""Call the given global function on all workers. Only for debug purpose."""
self._ffi["debug_call_func_on_all_worker"](func_name, func_args)
def reset(self):
"""Reset the engine, clear the running data and metrics."""
return self._ffi["reset"]()
def process_chat_completion_request(
request: openai_api_protocol.ChatCompletionRequest,
request_id: str,
engine_state: EngineState,
model_config: Dict[str, Any], # noqa: UP006
f_tokenize: Callable[[str], List[int]], # noqa: UP006
max_input_sequence_length: int,
conv_template: Conversation,
) -> Tuple[List[Union[List[int], data.Data]], GenerationConfig, bool, int]: # noqa: UP006
"""Process the given ChatCompletionRequest, apply request validity
checks, and return the processed prompts, and other info.
Parameters
----------
request : openai_api_protocol.ChatCompletionRequest
The request to be processed and checked.
request_id : str
The id of the request.
engine_state : EngineState
The state of the engine.
model_config : Dict[str, Any]
The model configuration dictionary.
f_tokenize : Callable[[str], List[int]]
The tokenizer encode function.
max_input_sequence_length : int
The maximum allowed total prompt length.
conv_template : Conversation
The conversation template of the model.
Returns
-------
prompts : List[Union[List[int], data.Data]]
The prompts, in a list.
Each element is a list of token ids or a "data.Data" instance.
generation_cfg : GenerationConfig
The generation config of the request got from the input request.
use_function_calling : bool
A boolean flag indicating if the request uses function call.
prompt_length : int
The total prompt length.
"""
engine_state.record_event(request_id, event="receive request")
# - Check if unsupported arguments are specified.
engine_utils.check_unsupported_fields(request)
# - Process messages and update the conversation template in three steps:
# i. Check the message validity.
# ii. Add the input messages to the conversation template.
# iii. Add the additional message for the assistant.
request.check_message_validity()
# - Check for function calling usage and update the conversation template
request.check_function_call_usage(conv_template)
for message in request.messages:
role = message.role
content = message.content
if role == "system":
assert isinstance(content, str)
conv_template.system_message = content if content is not None else ""
continue
conv_template.messages.append((role, content))
conv_template.messages.append(("assistant", None))
# - Get the prompt from template, and encode to token ids.
# - Check prompt length
engine_state.record_event(request_id, event="start tokenization")
prompts = engine_utils.process_prompts(conv_template.as_prompt(model_config), f_tokenize)
engine_state.record_event(request_id, event="finish tokenization")
if conv_template.system_prefix_token_ids is not None:
if isinstance(prompts[0], list):
prompts[0] = conv_template.system_prefix_token_ids + prompts[0]
else:
prompts.insert(0, conv_template.system_prefix_token_ids)
prompt_length = engine_utils.check_and_get_prompts_length(prompts, max_input_sequence_length)
# Process generation config. Create request id.
generation_cfg = engine_utils.get_generation_config(
request,
extra_stop_token_ids=conv_template.stop_token_ids,
extra_stop_str=conv_template.stop_str,
)
return prompts, generation_cfg, conv_template.use_function_calling, prompt_length
def process_chat_completion_stream_output(
delta_outputs: List[CallbackStreamOutput], # noqa: UP006
request: openai_api_protocol.ChatCompletionRequest,
request_id: str,
engine_state: EngineState,
use_function_calling: bool,
finish_reasons: List[Optional[str]], # noqa: UP006
) -> Optional[openai_api_protocol.ChatCompletionStreamResponse]:
"""Process the delta outputs of a single request of ChatCompletion,
convert the delta output to ChatCompletionStreamResponse and return.
Parameters
----------
delta_outputs : List[CallbackStreamOutput]
The delta outputs of a request.
The list length is the number of parallel generation specified by "n".
Each element corresponds to a generation.
request_id : str
The id of the request.
engine_state : EngineState
The state of the engine.
use_function_calling : bool
A boolean flag indicating if the request uses function call.
finish_reasons : List[Optional[str]]
The list of finish reasons of each generation.
The list length is the number of parallel generation specified by "n".
This list is updated in place.
Returns
-------
response : Optional[openai_api_protocol.ChatCompletionStreamResponse]
The converted OpenAI API ChatCompletionStreamResponse instance.
It can be none when there is no content.
"""
# we always stream back the final chunk with usage
is_final_chunk = delta_outputs[0].request_final_usage_json_str is not None
if is_final_chunk:
assert len(delta_outputs) == 1
engine_state.record_event(request_id, event="yield final usage")
response = openai_api_protocol.ChatCompletionStreamResponse(
id=request_id,
choices=[],
model=request.model,
system_fingerprint="",
usage=openai_api_protocol.CompletionUsage.model_validate_json(
delta_outputs[0].request_final_usage_json_str
),
)
# non streaming mode always comes with usage
if not request.stream:
return response
# skip usage if stream option does not indicate include usage
if request.stream_options is None:
return None
if not request.stream_options.include_usage:
return None
return response
# normal chunk
assert len(delta_outputs) == request.n
choices = []
for i, delta_output in enumerate(delta_outputs):
finish_reason_updated = False
if delta_output.finish_reason is not None and finish_reasons[i] is None:
finish_reasons[i] = (
delta_output.finish_reason if not use_function_calling else "tool_calls"
)
finish_reason_updated = True
if not finish_reason_updated and delta_output.delta_text == "":
# Ignore empty delta text when finish reason is not updated.
engine_state.record_event(request_id, event="skip empty delta text")
continue
choices.append(
openai_api_protocol.ChatCompletionStreamResponseChoice(
index=i,
finish_reason=finish_reasons[i],
delta=openai_api_protocol.ChatCompletionMessage(
content=delta_output.delta_text, role="assistant"
),
logprobs=(
openai_api_protocol.LogProbs(
content=[
openai_api_protocol.LogProbsContent.model_validate_json(
logprob_json_str
)
for logprob_json_str in delta_output.delta_logprob_json_strs
]
)
if delta_output.delta_logprob_json_strs is not None
else None
),
)
)
if len(choices) == 0:
# Skip return when there is no delta output and no number of completion tokens.
return None
response = openai_api_protocol.ChatCompletionStreamResponse(
id=request_id, choices=choices, model=request.model, system_fingerprint=""
)
engine_state.record_event(request_id, event="yield delta output")
return response
def process_completion_request(
request: openai_api_protocol.CompletionRequest,
request_id: str,
engine_state: EngineState,
tokenizer: Tokenizer,
max_input_sequence_length: int,
conv_template: Conversation,
) -> Tuple[List[int], GenerationConfig, int, Optional[openai_api_protocol.CompletionResponse]]: # noqa: UP006
"""Process the given CompletionRequest, apply request validity
checks, and return the processed prompts, and other info.
Parameters
----------
request : openai_api_protocol.CompletionRequest
The request to be processed and checked.
request_id : str
The id of the request.
engine_state : EngineState
The state of the engine.
tokenizer : Tokenizer
The tokenizer instance of the model.
max_input_sequence_length : int
The maximum allowed total prompt length.
conv_template : Conversation
The conversation template of the model.
Returns
-------
prompt : List[int]
The prompt in a list of token ids.
generation_cfg : GenerationConfig
The generation config of the request got from the input request.
prompt_length : int
The total prompt length.
echo_response : Optional[openai_api_protocol.CompletionResponse]
The CompletionResponse of the echoing part, when argument "echo"
of the input request is specified.
"""
engine_state.record_event(request_id, event="receive request")
# - Check if unsupported arguments are specified.
engine_utils.check_unsupported_fields(request)
# - Process prompt and check validity.
engine_state.record_event(request_id, event="start tokenization")
prompts = engine_utils.process_prompts(request.prompt, tokenizer.encode)
engine_state.record_event(request_id, event="finish tokenization")
prompt_length = engine_utils.check_and_get_prompts_length(prompts, max_input_sequence_length)
prompt = prompts[0]
assert isinstance(prompt, list)
# Process generation config. Create request id.
generation_cfg = engine_utils.get_generation_config(
request,
extra_stop_token_ids=conv_template.stop_token_ids,
extra_stop_str=conv_template.stop_str,
)
# - Echo back the prompt.
echo_response = None
if request.echo:
text = tokenizer.decode(prompt)
response = openai_api_protocol.CompletionResponse(
id=request_id,
choices=[
openai_api_protocol.CompletionResponseChoice(index=i, text=text)
for i in range(generation_cfg.n)
],
model=request.model,
usage=None,
)
echo_response = response
return prompt, generation_cfg, prompt_length, echo_response
def get_logprobs_from_delta(
delta_logprob_json_strs: List[str], # noqa: UP006
) -> openai_api_protocol.CompletionLogProbs:
"""Convert json strings containing logprobs information to
completion response format (OpenAI API compatible)
Parameters
----------
delta_logprob_json_strs : List[str]
Logprobs information packed in json strings and
kept in the delta outputs of a request.
Returns
-------
logprobs : openai_api_protocol.CompletionLogProbs
Logprobs information extracted from json string and converted to completion response format
"""
token_logprobs = []
tokens = []
top_logprobs = []
for logprob_json_str in delta_logprob_json_strs:
content = openai_api_protocol.LogProbsContent.model_validate_json(logprob_json_str)
tokens.append(content.token)
token_logprobs.append(content.logprob)
top_logprob_dict = {}
for top_logprob in content.top_logprobs:
top_logprob_dict[top_logprob.token] = top_logprob.logprob
top_logprobs.append(top_logprob_dict)
return openai_api_protocol.CompletionLogProbs(
# TODO(vvchernov): support text_offset
text_offset=None,
token_logprobs=token_logprobs,
tokens=tokens,
top_logprobs=top_logprobs,
)
def process_completion_stream_output(
delta_outputs: List[CallbackStreamOutput], # noqa: UP006
request: openai_api_protocol.CompletionRequest,
request_id: str,
engine_state: EngineState,
finish_reasons: List[Optional[str]], # noqa: UP006
) -> Optional[openai_api_protocol.CompletionResponse]:
"""Process the delta outputs of a single request of Completion,
convert the delta output to CompletionResponse and return.
Parameters
----------
delta_outputs : List[CallbackStreamOutput]
The delta outputs of a request.
The list length is the number of parallel generation specified by "n".
Each element corresponds to a generation.
request: openai_api_protocol.CompletionRequest
Information about the request
request_id : str
The id of the request.
engine_state : EngineState
The state of the engine.
finish_reasons : List[Optional[str]]
The list of finish reasons of each generation.
The list length is the number of parallel generation specified by "n".
This list is updated in place.
Returns
-------
response : Optional[openai_api_protocol.CompletionResponse]
The converted OpenAI API CompletionResponse instance.
It can be none when there is no content.
"""
# we always stream back the final chunk with usage
is_final_chunk = delta_outputs[0].request_final_usage_json_str is not None
if is_final_chunk:
assert len(delta_outputs) == 1
engine_state.record_event(request_id, event="yield final usage")
response = openai_api_protocol.CompletionResponse(
id=request_id,
choices=[],
model=request.model,
system_fingerprint="",
usage=openai_api_protocol.CompletionUsage.model_validate_json(
delta_outputs[0].request_final_usage_json_str
),
)
# non streaming mode always comes with usage
if not request.stream:
return response
if request.stream_options is None:
return None
if not request.stream_options.include_usage:
return None
return response
# normal chunk
assert len(delta_outputs) == request.n
choices = []
for i, delta_output in enumerate(delta_outputs):
finish_reason_updated = False
if delta_output.finish_reason is not None and finish_reasons[i] is None:
finish_reasons[i] = delta_output.finish_reason
finish_reason_updated = True
if not finish_reason_updated and delta_output.delta_text == "":
# Ignore empty delta text when finish reason is not updated.
continue
if delta_output.delta_logprob_json_strs is not None:
logprobs = get_logprobs_from_delta(delta_output.delta_logprob_json_strs)
else:
logprobs = None
choices.append(
openai_api_protocol.CompletionResponseChoice(
index=i,
finish_reason=finish_reasons[i],
text=delta_output.delta_text,
logprobs=logprobs,
)
)
if len(choices) == 0:
# Skip return when there is no delta output and no number of completion tokens.
return None
response = openai_api_protocol.CompletionResponse(
id=request_id,
choices=choices,
model=request.model,
usage=None,
)
engine_state.record_event(request_id, event="yield delta output")
return response
def create_completion_suffix_response(
request: openai_api_protocol.CompletionRequest,
request_id: str,
finish_reasons: List[Optional[str]], # noqa: UP006
) -> Optional[openai_api_protocol.CompletionResponse]:
"""Create the suffix response of Completion request
when the request requires suffix.
Parameters
----------
request : openai_api_protocol.CompletionRequest
The request whose suffix response if to be created.
request_id : str
The id of the request.
finish_reasons : List[Optional[str]]
The list of finish reasons of each generation.
The list length is the number of parallel generation specified by "n".
This list is updated in place.
Returns
-------
suffix_response : Optional[openai_api_protocol.CompletionResponse]
The created OpenAI API CompletionResponse instance for the suffix.
Or None if the request does not require suffix.
"""
# - Echo the suffix.
if request.suffix is None:
return None
assert all(finish_reason is not None for finish_reason in finish_reasons)
response = openai_api_protocol.CompletionResponse(
id=request_id,
choices=[
openai_api_protocol.CompletionResponseChoice(
index=i,
finish_reason=finish_reason,
text=request.suffix,
)
for i, finish_reason in enumerate(finish_reasons)
],
model=request.model,
usage=None,
)
return response
def convert_function_str_to_json(stringified_calls: str) -> List[Union[Dict, None]]: # noqa: UP006
"""Convert a (possibly list) of function call string to a list of json objects.
Return None for invalid function call string."""
def parse_function_call(call_str: str):
node = ast.parse(call_str, mode="eval")
call_node = node.body
if isinstance(call_node, ast.Call) and isinstance(call_node.func, ast.Name):
name = call_node.func.id
arguments = {}
for keyword in call_node.keywords:
arguments[keyword.arg] = ast.literal_eval(keyword.value)
return {"name": name, "arguments": arguments}
return None
if (
stringified_calls[0] == "[" and stringified_calls[-1] == "]"
): # hacky way to check if string list
calls = ast.literal_eval(stringified_calls)
else:
calls = [stringified_calls]
function_calls_json = [parse_function_call(call_str) for call_str in calls]
return function_calls_json
def process_function_call_output(
output_texts: List[str], # noqa: UP006
finish_reasons: List[str], # noqa: UP006
) -> Tuple[bool, List[List[openai_api_protocol.ChatToolCall]]]: # noqa: UP006
"""Process the potential function call results outputted by model,
according to the finish reasons.
Return whether the output has function call, and the list of tool calls.
"""
n = len(output_texts)
tool_calls_list: List[List[openai_api_protocol.ChatToolCall]] = [[] for _ in range(n)] # noqa: UP006
use_function_calling = any(finish_reason == "tool_calls" for finish_reason in finish_reasons)
if use_function_calling:
for i, output_text in enumerate(output_texts):
try:
fn_json_list = convert_function_str_to_json(output_text)
except (SyntaxError, ValueError):
output_text = "Got an invalid function call output from model"
finish_reasons[i] = "error"
else:
tool_calls_list[i] = [
openai_api_protocol.ChatToolCall(
type="function",
function=openai_api_protocol.ChatFunctionCall(
name=fn_json_obj["name"], arguments=fn_json_obj["arguments"]
),
)
for fn_json_obj in fn_json_list
if fn_json_obj is not None
]
if len(tool_calls_list[i]) == 0:
output_texts[i] = "Got an invalid function call output from model"
finish_reasons[i] = "error"
else:
finish_reasons[i] = "tool_calls"
return use_function_calling, tool_calls_list
def wrap_chat_completion_response(
request_id: str,
model: str,
output_texts: List[str], # noqa: UP006
finish_reasons: List[str], # noqa: UP006
tool_calls_list: List[List[openai_api_protocol.ChatToolCall]], # noqa: UP006
logprob_results: Optional[List[List[openai_api_protocol.LogProbsContent]]], # noqa: UP006
use_function_calling: bool,
usage: Optional[Dict[str, Any]], # noqa: UP006
) -> openai_api_protocol.ChatCompletionResponse:
"""Wrap the non-streaming chat completion results to ChatCompletionResponse instance."""
return openai_api_protocol.ChatCompletionResponse(
id=request_id,
choices=[
openai_api_protocol.ChatCompletionResponseChoice(
index=i,
finish_reason=finish_reasons[i],
message=(
openai_api_protocol.ChatCompletionMessage(role="assistant", content=output_text)
if not use_function_calling or finish_reason == "error"
else openai_api_protocol.ChatCompletionMessage(
role="assistant", tool_calls=tool_calls
)
),
logprobs=(
openai_api_protocol.LogProbs(content=logprob_results[i])
if logprob_results is not None
else None
),
)
for i, (output_text, finish_reason, tool_calls) in enumerate(
zip(output_texts, finish_reasons, tool_calls_list)
)
],
model=model,
system_fingerprint="",
usage=usage,
)
def wrap_completion_response(
request_id: str,
model: str,
output_texts: List[str], # noqa: UP006
finish_reasons: List[str], # noqa: UP006
logprob_results: List[Optional[openai_api_protocol.CompletionLogProbs]], # noqa: UP006
usage: openai_api_protocol.CompletionUsage,
) -> openai_api_protocol.CompletionResponse:
"""Wrap the non-streaming completion results to CompletionResponse instance."""
return openai_api_protocol.CompletionResponse(
id=request_id,
choices=[
openai_api_protocol.CompletionResponseChoice(
index=i,
finish_reason=finish_reason,
text=output_text,
logprobs=logprob_results[i],
)
for i, (output_text, finish_reason) in enumerate(zip(output_texts, finish_reasons))
],
model=model,
usage=usage,
)