chore: import upstream snapshot with attribution
Lint / lint (push) Has been cancelled
Build Docs / Deploy Docs (push) Has been cancelled
Windows CI / Windows (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:23:58 +08:00
commit 770d92cb1f
694 changed files with 114634 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
"""Definitions of pydantic models for API entry points and configurations
Note
----
We use the following convention
- filename_protocol If the classes can appear in an API endpoint
- filename_config For other config classes
"""
@@ -0,0 +1,278 @@
"""The standard conversation protocol in MLC LLM"""
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, Union # noqa: UP035
from pydantic import BaseModel, Field, field_validator
# The message placeholders in the message prompts according to roles.
class MessagePlaceholders(Enum):
"""The message placeholders in the message prompts according to roles."""
SYSTEM = "{system_message}"
USER = "{user_message}"
ASSISTANT = "{assistant_message}"
TOOL = "{tool_message}"
FUNCTION = "{function_string}"
T = TypeVar("T", bound="BaseModel")
class Conversation(BaseModel):
"""Class that specifies the convention template of conversation
and contains the conversation history.
Given a conversation template, the corresponding prompt generated out
from it is usually in the following format:
<<system>><<messages[0][0]>><<role_content_sep>><<messages[0][1]>><<seps[0]>>
<<messages[1][0]>><<role_content_sep>><<messages[1][1]>><<seps[1]>>
...
<<messages[2][0]>><<role_content_sep>><<messages[2][1]>><<seps[0]>>
<<roles[1]>><<role_empty_sep>>
"""
# Optional name of the template.
name: Optional[str] = None
# The system prompt template, it optionally contains the system
# message placeholder, and the placeholder will be replaced with
# the system message below.
system_template: str = MessagePlaceholders.SYSTEM.value
# The content of the system prompt (without the template format).
system_message: str = ""
# The system token ids to be prepended at the beginning of tokenized
# generated prompt.
system_prefix_token_ids: Optional[List[int]] = None # noqa: UP006
# Whether or not to append user role and separator after the system message.
# This is mainly for [INST] [/INST] style prompt format
add_role_after_system_message: bool = True
# The conversation roles
roles: Dict[str, str] # noqa: UP006
# The roles prompt template, it optionally contains the defaults
# message placeholders and will be replaced by actual content
role_templates: Dict[str, str] # noqa: UP006
# The conversation history messages.
# Each message is a pair of strings, denoting "(role, content)".
# The content can be None.
messages: List[Tuple[str, Optional[Union[str, List[Dict]]]]] = Field(default_factory=lambda: []) # noqa: UP006
# The separators between messages when concatenating into a single prompt.
# List size should be either 1 or 2.
# - When size is 1, the separator will be used between adjacent messages.
# - When size is 2, seps[0] is used after user message, and
# seps[1] is used after assistant message.
seps: List[str] # noqa: UP006
# The separator between the role and the content in a message.
role_content_sep: str = ""
# The separator between the role and empty contents.
role_empty_sep: str = ""
# The stop criteria
stop_str: List[str] = Field(default_factory=lambda: []) # noqa: UP006
stop_token_ids: List[int] = Field(default_factory=lambda: []) # noqa: UP006
# When True, strip `<think>...</think>` blocks (and any trailing whitespace)
# from historical assistant messages before rendering the prompt, mirroring
# Qwen3's official HF chat template. Only historical turns before the last
# user message are affected; reasoning on the most recent assistant turn is
# preserved for tool-call prefill scenarios.
strip_reasoning_in_history: bool = False
# Function call fields
function_string: str = ""
# whether using function calling or not, helps check for output message format in API call
use_function_calling: bool = False
def __init__(self, role_templates: Optional[Dict[str, str]] = None, **kwargs): # noqa: UP006
# Defaults templates which would be overridden by model specific templates
_role_templates: Dict[str, str] = { # noqa: UP006
"user": MessagePlaceholders.USER.value,
"assistant": MessagePlaceholders.ASSISTANT.value,
"tool": MessagePlaceholders.TOOL.value,
}
if role_templates is not None:
_role_templates.update(role_templates)
super().__init__(role_templates=_role_templates, **kwargs)
@field_validator("seps")
@classmethod
def check_message_seps(cls, seps: List[str]) -> List[str]: # noqa: UP006
"""Check if the input message separators has size 1 or 2."""
if len(seps) == 0 or len(seps) > 2:
raise ValueError("seps should have size 1 or 2.")
return seps
def to_json_dict(self) -> Dict[str, Any]: # noqa: UP006
"""Convert to a json dictionary"""
return self.model_dump(by_alias=True, exclude_none=True)
@classmethod
def from_json_dict(cls: Type[T], json_dict: Dict[str, Any]) -> T: # noqa: UP006
"""Convert from a json dictionary"""
return Conversation.model_validate(json_dict)
def as_prompt(self, config=None) -> List[Any]: # noqa: UP006
"""Convert the conversation template and history messages to
a single prompt.
Returns
-------
prompts : List[Union[str, "mlc_llm.serve.data.Data"]]
The prompts converted from the conversation messages.
We use Any in the signature to avoid cyclic import.
"""
from ..serve import data
# - Get the system message.
system_msg = self.system_template.replace(
MessagePlaceholders.SYSTEM.value, self.system_message
)
# - Get the message strings.
message_list: List[Union[str, data.Data]] = [] # noqa: UP006
separators = list(self.seps)
if len(separators) == 1:
separators.append(separators[0])
if system_msg != "":
message_list.append(system_msg)
messages = (
_strip_reasoning_in_history(self.messages)
if self.strip_reasoning_in_history
else self.messages
)
for i, (role, content) in enumerate(messages):
if role not in self.roles.keys():
raise ValueError(f'Role "{role}" is not a supported role in {self.roles.keys()}')
separator = separators[role == "assistant"] # check assistant role
if content is None:
message_list.append(self.roles[role] + self.role_empty_sep)
continue
role_prefix = (
""
# Do not append role prefix if this is the first message and there
# is already a system message
if (not self.add_role_after_system_message and system_msg != "" and i == 0)
else self.roles[role] + self.role_content_sep
)
if isinstance(content, str):
message_list.append(
role_prefix
+ self.role_templates[role].replace(
MessagePlaceholders[role.upper()].value, content
)
+ separator
)
continue
message_list.append(role_prefix)
for item in content:
assert isinstance(item, dict), "Content should be a string or a list of dicts"
assert "type" in item, "Content item should have a type field"
if item["type"] == "text":
message = self.role_templates[role].replace(
MessagePlaceholders[role.upper()].value, item["text"]
)
message_list.append(message)
elif item["type"] == "image_url":
assert config is not None, "Model config is required"
image_url = _get_url_from_item(item)
message_list.append(data.ImageData.from_url(image_url, config))
message_list.append("\n")
else:
raise ValueError(f"Unsupported content type: {item['type']}")
message_list.append(separator)
prompt = _combine_consecutive_messages(message_list)
if not any(isinstance(item, data.ImageData) for item in message_list):
# Replace the last function string placeholder with actual function string
prompt[0] = self.function_string.join(
prompt[0].rsplit(MessagePlaceholders.FUNCTION.value, 1)
)
# Replace with remaining function string placeholders with empty string
prompt[0] = prompt[0].replace(MessagePlaceholders.FUNCTION.value, "")
return prompt
def _get_url_from_item(item: Dict) -> str: # noqa: UP006
image_url: str
assert "image_url" in item, "Content item should have an image_url field"
if isinstance(item["image_url"], str):
image_url = item["image_url"]
elif isinstance(item["image_url"], dict):
assert "url" in item["image_url"], (
"Content image_url item should be a string or a dict with a url field"
)
image_url = item["image_url"]["url"]
else:
raise ValueError(
"Content image_url item type not supported. "
"Should be a string or a dict with a url field."
)
return image_url
def _strip_reasoning_in_history(
messages: List[Tuple[str, Optional[Union[str, List[Dict]]]]], # noqa: UP006
) -> List[Tuple[str, Optional[Union[str, List[Dict]]]]]: # noqa: UP006
"""Strip `<think>...</think>` blocks from assistant messages that precede
the last user message, matching Qwen3's HF chat-template behavior. The last
assistant message (if any) is preserved so tool-call prefill continuations
keep their reasoning context.
"""
last_user_idx = -1
for i, (role, _) in enumerate(messages):
if role == "user":
last_user_idx = i
result: List[Tuple[str, Optional[Union[str, List[Dict]]]]] = [] # noqa: UP006
for i, (role, content) in enumerate(messages):
if (
role == "assistant"
and i < last_user_idx
and isinstance(content, str)
and "</think>" in content
):
content = content.split("</think>")[-1].lstrip("\n")
result.append((role, content))
return result
def _combine_consecutive_messages(messages: List[Any]) -> List[Any]: # noqa: UP006
"""Combining consecutive strings into one.
Parameters
----------
messages : List[Union[str, "mlc_llm.serve.data.Data"]]
The input messages to be combined.
We use Any in the signature to avoid cyclic import.
Returns
-------
updated_messages : List[Union[str, "mlc_llm.serve.data.Data"]]
The combined messages
"""
if len(messages) == 0:
return []
combined_messages = [messages[0]]
for message in messages[1:]:
if isinstance(message, str) and isinstance(combined_messages[-1], str):
combined_messages[-1] += message
else:
combined_messages.append(message)
return combined_messages
+49
View File
@@ -0,0 +1,49 @@
"""Debug protocols in MLC LLM"""
from typing import Literal, Optional
from pydantic import BaseModel
class DisaggConfig(BaseModel):
"""The class of metadata used in microserving APIs."""
kind: Optional[Literal["prepare_receive", "remote_send", "start_generation"]] = None
# "kv_append_metadata" is base64-encoded and is thus a string.
kv_append_metadata: Optional[str] = None
# "kv_window_begin" and "kv_window_end" denote the KV interval of interests.
# "kv_window_end" supports Python style negative indexing.
# The concrete meaning varies for different special request kind:
# - For "prepare_receive", the begin is always 0, and "[0:end]" denotes
# the KV range to prefill on a prefill instance.
# - For "remote_send", "[begin:end]" means the KV range to compute prefill
# and send to the decode instance.
# - For "start_generation", the end is always None, and "[begin:]" denotes
# the KV range to prefill locally on the decode instance.
kv_window_begin: Optional[int] = None
kv_window_end: Optional[int] = None
# KV data destination group offset
dst_group_offset: Optional[int] = None
class DebugConfig(BaseModel):
"""The class of debug options.
These optionals are available to engine
but won't be available to serving endpoint
unless an explicit --enable-debug passed
"""
ignore_eos: bool = False
pinned_system_prompt: bool = False
special_request: Optional[Literal["query_engine_metrics"]] = None
grammar_execution_mode: Literal["constraint", "jump_forward"] = "jump_forward"
disagg_config: Optional[DisaggConfig] = None
"""Special request indicators
Special requests are handled by engine differently and do not go
through the normal engine step flow.
The results to these requests are returned as field of "usage"
"""
+35
View File
@@ -0,0 +1,35 @@
"""Error protocols in MLC LLM"""
from http import HTTPStatus
from typing import Optional
import fastapi
from pydantic import BaseModel
class BadRequestError(ValueError):
"""The exception for bad requests in engines."""
def __init__(self, *args: object) -> None:
super().__init__(*args)
class ErrorResponse(BaseModel):
"""The class of error response."""
object: str = "error"
message: str
code: Optional[int] = None
def create_error_response(status_code: HTTPStatus, message: str) -> fastapi.responses.JSONResponse:
"""Create a JSON response that reports error with regarding the input message."""
return fastapi.responses.JSONResponse(
ErrorResponse(message=message, code=status_code.value).model_dump_json(by_alias=True),
status_code=status_code.value,
)
async def bad_request_error_handler(_request: fastapi.Request, e: BadRequestError):
"""The handler of BadRequestError that converts an exception into error response."""
return create_error_response(status_code=HTTPStatus.BAD_REQUEST, message=e.args[0])
@@ -0,0 +1,32 @@
"""Low-level generation config class"""
from typing import Dict, List, Optional # noqa: UP035
from pydantic import BaseModel
from .debug_protocol import DebugConfig
from .openai_api_protocol import RequestResponseFormat
class GenerationConfig(BaseModel):
"""The generation configuration dataclass.
This is a config class used by Engine internally.
"""
n: int = 1
temperature: Optional[float] = None
top_p: Optional[float] = None
frequency_penalty: Optional[float] = None
presence_penalty: Optional[float] = None
repetition_penalty: Optional[float] = None
logprobs: bool = False
top_logprobs: int = 0
logit_bias: Optional[Dict[int, float]] = None # noqa: UP006
# internally we use -1 to represent infinite
max_tokens: int = -1
seed: Optional[int] = None
stop_strs: Optional[List[str]] = None # noqa: UP006
stop_token_ids: Optional[List[int]] = None # noqa: UP006
response_format: Optional[RequestResponseFormat] = None
debug_config: Optional[Optional[DebugConfig]] = None
@@ -0,0 +1,72 @@
"""Protocols in MLC LLM for MicroServing."""
from pydantic import BaseModel
from mlc_llm.protocol.openai_api_protocol import CompletionRequest
class PrepRecvRequest(CompletionRequest):
"""The extra request body for prep_recv request in MicroServing.
Attributes
----------
kv_window_end : int
[0, kv_window_end] denotes the KV range of the prompt to prefill on
a prefill instance.
The entries of this KV range will be allocated on the decode instance.
"""
end: int
class PrepRecvResponse(BaseModel):
"""The response body for prep_recv request in MicroServing.
Attributes
----------
prefix_matched_length : int
The matched common prefix length on the decode instance when
prefix cache is enabled, or 0 if there is no prefix cache.
kv_append_metadata : str
The metadata of the KV range on the destination decode instance.
"""
kv_append_metadata: str
prefix_matched_length: int
class RemoteSendRequest(CompletionRequest):
"""The extra request body for remote_send request in MicroServing.
Attributes
----------
kv_window_begin : int
Denote the start of the KV range to prefill.
kv_window_end : int
Denote the end of the KV range to prefill.
kv_append_metadata : str
The metadata of the KV range on the destination decode instance.
dst_group_offset : int
The node group offset of the destination decode instance.
"""
begin: int
end: int
kv_addr_info: str
recv_rank: int
class StartGenerateRequest(CompletionRequest):
"""The extra request body for start_generate request in MicroServing.
Attributes
----------
kv_window_begin : int
Denote the start of the KV range to prefill on the decode instance.
"""
begin: int
@@ -0,0 +1,78 @@
"""Schema for mlc-chat-config"""
from typing import Any, Dict, List, Literal, Optional, Union # noqa: UP035
from pydantic import BaseModel, Field
from mlc_llm.support.constants import MLC_CHAT_CONFIG_VERSION
from .conversation_protocol import Conversation
MLC_CHAT_SYSTEM_DEFAULT = {
"pad_token_id": 0,
"bos_token_id": 1,
"eos_token_id": 2,
"temperature": 1.0,
"presence_penalty": 0.0,
"frequency_penalty": 0.0,
"repetition_penalty": 1.0,
"top_p": 1.0,
}
"""system default values."""
class MLCChatConfig(BaseModel):
"""Fields in the dumped `mlc-chat-config.json` file."""
# Version control
version: str = MLC_CHAT_CONFIG_VERSION
# use alias to avoid protected namespace conflict with pydantic
field_model_type: str = Field(alias="model_type")
quantization: str
# use alias to avoid protected namespace conflict with pydantic
field_model_config: Dict[str, Any] = Field(alias="model_config") # noqa: UP006
vocab_size: int
context_window_size: int
sliding_window_size: int
prefill_chunk_size: int
attention_sink_size: int
tensor_parallel_shards: int
pipeline_parallel_stages: int = 1
# Configuration of text generation
active_vocab_size: int = None
temperature: Optional[float] = None
presence_penalty: Optional[float] = None
frequency_penalty: Optional[float] = None
repetition_penalty: Optional[float] = None
top_p: Optional[float] = None
# Tokenizer configuration
tokenizer_files: List[str] = Field(default_factory=list) # noqa: UP006
# The content of tokenizer.TokenizerInfo
tokenizer_info: Dict[str, Any] = Field(default_factory=dict) # noqa: UP006
# conversation template
conv_template: Conversation
# extra fields from generation_config.json
# NOTE: they are not being used for now in MLCEngine
# but we keep them for book-keep purposes
pad_token_id: Optional[int] = None
bos_token_id: Optional[int] = None
eos_token_id: Optional[Union[int, List[int]]] = None # noqa: UP006
field_model_task: Literal["chat", "embedding"] = Field(default="chat", alias="model_task")
embedding_metadata: Optional[Dict[str, Any]] = None # noqa: UP006
def get_system_defaults_for_missing_fields(self) -> Dict[str, Any]: # noqa: UP006
"""Apply system default value for fields that are None
Note
----
We implement default setting in this way so we can lazily create
MLCChatConfig, override its optional values then
apply_system_defaults in the end.
"""
res = {}
for key, value in MLC_CHAT_SYSTEM_DEFAULT.items():
if getattr(self, key) is None:
res[key] = value
return res
@@ -0,0 +1,462 @@
"""Protocols in MLC LLM for OpenAI API.
Adapted from FastChat's OpenAI protocol:
https://github.com/lm-sys/FastChat/blob/main/fastchat/protocol/openai_api_protocol.py
"""
import json
import time
from typing import Any, Dict, List, Literal, Optional, Tuple, Union # noqa: UP035
import shortuuid
from pydantic import BaseModel, Field, field_validator, model_validator
from .conversation_protocol import Conversation
from .debug_protocol import DebugConfig
from .error_protocol import BadRequestError
################ Commons ################
# OPenAI API compatible limits
CHAT_COMPLETION_MAX_TOP_LOGPROBS = 20
COMPLETION_MAX_TOP_LOGPROBS = 5
class ListResponse(BaseModel):
object: str = "list"
data: List[Any] # noqa: UP006
class TopLogProbs(BaseModel):
token: str
logprob: float
bytes: Optional[List[int]] # noqa: UP006
class LogProbsContent(BaseModel):
token: str
logprob: float
bytes: Optional[List[int]] # noqa: UP006
top_logprobs: List[TopLogProbs] = [] # noqa: UP006
class LogProbs(BaseModel):
content: List[LogProbsContent] # noqa: UP006
class CompletionLogProbs(BaseModel):
# The position of the token in the concatenated str: prompt + completion_text
# TODO(vvchernov): skip optional after support
text_offset: Optional[List[int]] # noqa: UP006
token_logprobs: List[float] # noqa: UP006
tokens: List[str] # noqa: UP006
top_logprobs: List[Dict[str, float]] # noqa: UP006
class CompletionUsage(BaseModel):
prompt_tokens: int
completion_tokens: int
total_tokens: int
extra: Optional[Dict[str, Any]] = None # noqa: UP006
"""Extra metrics and info that may be returned by debug_config
"""
class StreamOptions(BaseModel):
include_usage: Optional[bool]
################ v1/embeddings ################
class EmbeddingRequest(BaseModel):
"""OpenAI "v1/embeddings" request protocol.
API reference: https://platform.openai.com/docs/api-reference/embeddings/create
"""
input: Union[str, List[str], List[int], List[List[int]]] # noqa: UP006
model: Optional[str] = None
encoding_format: Literal["float", "base64"] = "float"
dimensions: Optional[int] = None
user: Optional[str] = None
@field_validator("input")
@classmethod
def validate_input(cls, v):
"""Check that the input is not an empty list.
Note: empty strings are allowed — encoder models produce valid
embeddings from [CLS]+[SEP] tokens alone.
"""
if isinstance(v, list) and len(v) == 0:
raise ValueError("Input list must not be empty.")
return v
class EmbeddingObject(BaseModel):
object: str = "embedding"
embedding: Union[List[float], str] # noqa: UP006
index: int
class EmbeddingUsage(BaseModel):
prompt_tokens: int
total_tokens: int
class EmbeddingResponse(BaseModel):
"""OpenAI "v1/embeddings" response protocol.
API reference: https://platform.openai.com/docs/api-reference/embeddings/object
"""
object: str = "list"
data: List[EmbeddingObject] # noqa: UP006
model: Optional[str] = None
usage: EmbeddingUsage
################ v1/models ################
class ModelResponse(BaseModel):
"""OpenAI "v1/models" response protocol.
API reference: https://platform.openai.com/docs/api-reference/models/object
"""
id: str
created: int = Field(default_factory=lambda: int(time.time()))
object: str = "model"
owned_by: str = "MLC-LLM"
################ v1/completions ################
class RequestResponseFormat(BaseModel):
type: Literal["text", "json_object"] = "text"
json_schema: Optional[str] = Field(default=None, alias="schema")
"""This field is named json_schema instead of schema because BaseModel defines a method called
schema. During construction of RequestResponseFormat, key "schema" still should be used:
`RequestResponseFormat(type="json_object", schema="{}")`
"""
class CompletionRequest(BaseModel):
"""OpenAI completion request protocol.
API reference: https://platform.openai.com/docs/api-reference/completions/create
"""
model: Optional[str] = None
prompt: Union[str, List[int]] # noqa: UP006
best_of: int = 1
echo: bool = False
frequency_penalty: Optional[float] = None
presence_penalty: Optional[float] = None
logprobs: Optional[int] = None
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 = False
stream_options: Optional[StreamOptions] = None
suffix: Optional[str] = None
temperature: Optional[float] = None
top_p: Optional[float] = None
user: Optional[str] = None
response_format: Optional[RequestResponseFormat] = None
debug_config: Optional[DebugConfig] = None
@field_validator("frequency_penalty", "presence_penalty")
@classmethod
def check_penalty_range(cls, penalty_value: Optional[float]) -> Optional[float]:
"""Check if the penalty value is in range [-2, 2]."""
if penalty_value and (penalty_value < -2 or penalty_value > 2):
raise ValueError("Penalty value should be in range [-2, 2].")
return penalty_value
@field_validator("logit_bias")
@classmethod
def check_logit_bias(
cls,
logit_bias_value: Optional[Dict[int, float]], # noqa: UP006
) -> Optional[Dict[int, float]]: # noqa: UP006
"""Check if the logit bias key is given as an integer."""
if logit_bias_value is None:
return None
for token_id, bias in logit_bias_value.items():
if abs(bias) > 100:
raise ValueError(
"Logit bias value should be in range [-100, 100], while value "
f"{bias} is given for token id {token_id}"
)
return logit_bias_value
@model_validator(mode="after")
def check_logprobs(self) -> "CompletionRequest":
"""Check if the logprobs requirements are valid."""
if self.logprobs is not None and (
self.logprobs < 0 or self.logprobs > COMPLETION_MAX_TOP_LOGPROBS
):
raise ValueError(f'"logprobs" must be in range [0, {COMPLETION_MAX_TOP_LOGPROBS}]')
return self
class CompletionResponseChoice(BaseModel):
finish_reason: Optional[Literal["stop", "length", "preempt"]] = None
index: int = 0
logprobs: Optional[CompletionLogProbs] = None
text: str
class CompletionResponse(BaseModel):
"""OpenAI completion response protocol.
API reference: https://platform.openai.com/docs/api-reference/completions/object
"""
id: str
choices: List[CompletionResponseChoice] # noqa: UP006
created: int = Field(default_factory=lambda: int(time.time()))
model: Optional[str] = None
object: str = "text_completion"
usage: Optional[CompletionUsage] = None
################ v1/chat/completions ################
class ChatFunction(BaseModel):
description: Optional[str] = None
name: str
parameters: Dict # noqa: UP006
class ChatTool(BaseModel):
type: Literal["function"]
function: ChatFunction
class ChatFunctionCall(BaseModel):
name: str
arguments: Union[None, Dict[str, Any]] = None # noqa: UP006
class ChatToolCall(BaseModel):
id: str = Field(default_factory=lambda: f"call_{shortuuid.random()}")
type: Literal["function"]
function: ChatFunctionCall
class ChatCompletionMessage(BaseModel):
content: Optional[Union[str, List[Dict]]] = None # noqa: UP006
role: Literal["system", "user", "assistant", "tool"]
name: Optional[str] = None
tool_calls: Optional[List[ChatToolCall]] = None # noqa: UP006
tool_call_id: Optional[str] = None
class ChatCompletionRequest(BaseModel):
"""OpenAI chat completion request protocol.
API reference: https://platform.openai.com/docs/api-reference/chat/create
"""
messages: List[ChatCompletionMessage] # 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 = False
stream_options: Optional[StreamOptions] = None
temperature: Optional[float] = None
top_p: Optional[float] = None
tools: Optional[List[ChatTool]] = None # noqa: UP006
tool_choice: Optional[Union[Literal["none", "auto"], Dict]] = None # noqa: UP006
user: Optional[str] = None
response_format: Optional[RequestResponseFormat] = None
# NOTE: debug_config is not part of OpenAI protocol
# we add it to enable extra debug options
debug_config: Optional[DebugConfig] = None
@field_validator("frequency_penalty", "presence_penalty")
@classmethod
def check_penalty_range(cls, penalty_value: Optional[float]) -> Optional[float]:
"""Check if the penalty value is in range [-2, 2]."""
if penalty_value and (penalty_value < -2 or penalty_value > 2):
raise ValueError("Penalty value should be in range [-2, 2].")
return penalty_value
@field_validator("logit_bias")
@classmethod
def check_logit_bias(
cls,
logit_bias_value: Optional[Dict[int, float]], # noqa: UP006
) -> Optional[Dict[int, float]]: # noqa: UP006
"""Check if the logit bias key is given as an integer."""
if logit_bias_value is None:
return None
for token_id, bias in logit_bias_value.items():
if abs(bias) > 100:
raise ValueError(
"Logit bias value should be in range [-100, 100], while value "
f"{bias} is given for token id {token_id}"
)
return logit_bias_value
@model_validator(mode="after")
def check_logprobs(self) -> "ChatCompletionRequest":
"""Check if the logprobs requirements are valid."""
if self.top_logprobs < 0 or self.top_logprobs > CHAT_COMPLETION_MAX_TOP_LOGPROBS:
raise ValueError(
f'"top_logprobs" must be in range [0, {CHAT_COMPLETION_MAX_TOP_LOGPROBS}]'
)
if not self.logprobs and self.top_logprobs > 0:
raise ValueError('"logprobs" must be True to support "top_logprobs"')
return self
@model_validator(mode="after")
def check_stream_options(self) -> "ChatCompletionRequest":
"""Check stream options"""
if self.stream_options is None:
return self
if not self.stream:
raise ValueError("stream must be set to True when stream_options is present")
return self
@model_validator(mode="after")
def check_debug_config(self) -> "ChatCompletionRequest":
"""Check debug config"""
if self.debug_config is None:
return self
if self.debug_config.special_request is None:
return self
if not self.stream:
raise ValueError("DebugConfig.special_request requires stream=True")
if self.stream_options is None or not self.stream_options.include_usage:
raise ValueError("DebugConfig.special_request requires include_usage in stream_options")
return self
def check_message_validity(self) -> None:
"""Check if the given chat messages are valid. Return error message if invalid."""
for i, message in enumerate(self.messages):
if message.role == "system" and i != 0:
raise BadRequestError(
f"System prompt at position {i} in the message list is invalid."
)
if message.tool_call_id is not None:
if message.role != "tool":
raise BadRequestError("Non-tool message having `tool_call_id` is invalid.")
if isinstance(message.content, list):
if message.role != "user":
raise BadRequestError("Non-user message having a list of content is invalid.")
if message.tool_calls is not None:
if message.role != "assistant":
raise BadRequestError("Non-assistant message having `tool_calls` is invalid.")
raise BadRequestError("Assistant message having `tool_calls` is not supported yet.")
def check_function_call_usage(self, conv_template: Conversation) -> None:
"""Check if function calling is used and update the conversation template.
Return error message if invalid request format for function calling.
"""
# return if no tools are provided or tool_choice is set to none
if self.tools is None or (isinstance(self.tool_choice, str) and self.tool_choice == "none"):
conv_template.use_function_calling = False
return
# select the tool based on the tool_choice if specified
if isinstance(self.tool_choice, dict):
if self.tool_choice["type"] != "function":
raise BadRequestError("Only 'function' tool choice is supported")
if len(self.tool_choice["function"]) > 1:
raise BadRequestError("Only one tool is supported when tool_choice is specified")
for tool in self.tools:
if tool.function.name == self.tool_choice["function"]["name"]:
conv_template.use_function_calling = True
conv_template.function_string = tool.function.model_dump_json(by_alias=True)
return
raise BadRequestError(
f"The tool_choice function {self.tool_choice['function']['name']}"
" is not found in the tools list"
)
if isinstance(self.tool_choice, str) and self.tool_choice != "auto":
raise BadRequestError(f"Invalid tool_choice value: {self.tool_choice}")
function_list = []
for tool in self.tools:
if tool.type != "function":
raise BadRequestError("Only 'function' tool type is supported")
function_list.append(tool.function.model_dump(by_alias=True))
conv_template.use_function_calling = True
conv_template.function_string = json.dumps(function_list)
class ChatCompletionResponseChoice(BaseModel):
finish_reason: Optional[Literal["stop", "length", "tool_calls", "error"]] = None
index: int = 0
message: ChatCompletionMessage
logprobs: Optional[LogProbs] = None
class ChatCompletionStreamResponseChoice(BaseModel):
finish_reason: Optional[Literal["stop", "length", "tool_calls", "error"]] = None
index: int = 0
delta: ChatCompletionMessage
logprobs: Optional[LogProbs] = None
class ChatCompletionResponse(BaseModel):
"""OpenAI completion response protocol.
API reference: https://platform.openai.com/docs/api-reference/chat/object
"""
id: str
choices: List[ChatCompletionResponseChoice] # noqa: UP006
created: int = Field(default_factory=lambda: int(time.time()))
model: Optional[str] = None
system_fingerprint: str
object: Literal["chat.completion"] = "chat.completion"
usage: Optional[CompletionUsage] = None
class ChatCompletionStreamResponse(BaseModel):
"""OpenAI completion stream response protocol.
API reference: https://platform.openai.com/docs/api-reference/chat/streaming
"""
id: str
choices: List[ChatCompletionStreamResponseChoice] # noqa: UP006
created: int = Field(default_factory=lambda: int(time.time()))
model: Optional[str] = None
system_fingerprint: str
object: Literal["chat.completion.chunk"] = "chat.completion.chunk"
usage: Optional[CompletionUsage] = None
def openai_api_get_unsupported_fields(
request: Union[CompletionRequest, ChatCompletionRequest],
) -> List[str]: # noqa: UP006
"""Get the unsupported fields in the request."""
unsupported_field_default_values: List[Tuple[str, Any]] = [ # noqa: UP006
("best_of", 1),
]
unsupported_fields: List[str] = [] # noqa: UP006
for field, value in unsupported_field_default_values:
if hasattr(request, field) and getattr(request, field) != value:
unsupported_fields.append(field)
return unsupported_fields