Add overridable hooks for custom chat serving implementations (#25807)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -92,7 +92,6 @@ from sglang.srt.entrypoints.openai.protocol import (
|
|||||||
TokenizeRequest,
|
TokenizeRequest,
|
||||||
V1RerankReqInput,
|
V1RerankReqInput,
|
||||||
)
|
)
|
||||||
from sglang.srt.entrypoints.openai.serving_chat import OpenAIServingChat
|
|
||||||
from sglang.srt.entrypoints.openai.serving_classify import OpenAIServingClassify
|
from sglang.srt.entrypoints.openai.serving_classify import OpenAIServingClassify
|
||||||
from sglang.srt.entrypoints.openai.serving_completions import OpenAIServingCompletion
|
from sglang.srt.entrypoints.openai.serving_completions import OpenAIServingCompletion
|
||||||
from sglang.srt.entrypoints.openai.serving_embedding import OpenAIServingEmbedding
|
from sglang.srt.entrypoints.openai.serving_embedding import OpenAIServingEmbedding
|
||||||
@@ -316,9 +315,11 @@ async def lifespan(fast_api_app: FastAPI):
|
|||||||
fast_api_app.state.openai_serving_completion = OpenAIServingCompletion(
|
fast_api_app.state.openai_serving_completion = OpenAIServingCompletion(
|
||||||
_global_state.tokenizer_manager, _global_state.template_manager
|
_global_state.tokenizer_manager, _global_state.template_manager
|
||||||
)
|
)
|
||||||
fast_api_app.state.openai_serving_chat = OpenAIServingChat(
|
fast_api_app.state.openai_serving_chat = (
|
||||||
|
_global_state.tokenizer_manager.serving_chat_class(
|
||||||
_global_state.tokenizer_manager, _global_state.template_manager
|
_global_state.tokenizer_manager, _global_state.template_manager
|
||||||
)
|
)
|
||||||
|
)
|
||||||
fast_api_app.state.openai_serving_embedding = OpenAIServingEmbedding(
|
fast_api_app.state.openai_serving_embedding = OpenAIServingEmbedding(
|
||||||
_global_state.tokenizer_manager, _global_state.template_manager
|
_global_state.tokenizer_manager, _global_state.template_manager
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
"""Pydantic models for OpenAI API protocol"""
|
"""Pydantic models for OpenAI API protocol"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -23,10 +25,12 @@ from typing import (
|
|||||||
List,
|
List,
|
||||||
NamedTuple,
|
NamedTuple,
|
||||||
Optional,
|
Optional,
|
||||||
|
Protocol,
|
||||||
Tuple,
|
Tuple,
|
||||||
TypeAlias,
|
TypeAlias,
|
||||||
Union,
|
Union,
|
||||||
get_args,
|
get_args,
|
||||||
|
runtime_checkable,
|
||||||
)
|
)
|
||||||
|
|
||||||
from openai.types.responses import (
|
from openai.types.responses import (
|
||||||
@@ -88,6 +92,42 @@ class ErrorResponse(BaseModel):
|
|||||||
code: int
|
code: int
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ParsedResponseFields(Protocol):
|
||||||
|
"""Protocol for parsed response fields from custom renderers."""
|
||||||
|
|
||||||
|
content: Optional[str]
|
||||||
|
tool_calls: Optional[List[Dict]]
|
||||||
|
reasoning_content: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseParserProtocol(Protocol):
|
||||||
|
"""Protocol for custom response parsers.
|
||||||
|
|
||||||
|
Implementations parse model output tokens into structured OpenAI response fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def parse_response(
|
||||||
|
self, output_ids: List[int]
|
||||||
|
) -> Union[ParsedResponseFields, ErrorResponse]:
|
||||||
|
"""Parse complete response from output token IDs."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def build_streaming_sse_chunks(
|
||||||
|
self,
|
||||||
|
output_ids: List[int],
|
||||||
|
index: int,
|
||||||
|
chunk_id: str,
|
||||||
|
model: str,
|
||||||
|
usage: Optional[Dict],
|
||||||
|
) -> Tuple[List[str], bool, Optional[str]]:
|
||||||
|
"""Parse streaming tokens and build SSE chunks.
|
||||||
|
|
||||||
|
Returns: (sse_chunks, has_tool_calls, error_message)
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
class LogProbs(BaseModel):
|
class LogProbs(BaseModel):
|
||||||
text_offset: List[int] = Field(default_factory=list)
|
text_offset: List[int] = Field(default_factory=list)
|
||||||
token_logprobs: List[Optional[float]] = Field(default_factory=list)
|
token_logprobs: List[Optional[float]] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -5,11 +5,19 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from enum import Enum
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Optional, Union
|
from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
|
||||||
|
class ThinkingMode(str, Enum):
|
||||||
|
"""Mode for message encoding - chat vs thinking/reasoning."""
|
||||||
|
|
||||||
|
CHAT = "chat"
|
||||||
|
THINKING = "thinking"
|
||||||
|
|
||||||
|
|
||||||
import jinja2
|
import jinja2
|
||||||
import msgspec
|
|
||||||
import orjson
|
import orjson
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import ORJSONResponse, StreamingResponse
|
from fastapi.responses import ORJSONResponse, StreamingResponse
|
||||||
@@ -30,6 +38,7 @@ from sglang.srt.entrypoints.openai.protocol import (
|
|||||||
FunctionResponse,
|
FunctionResponse,
|
||||||
LogProbs,
|
LogProbs,
|
||||||
MessageProcessingResult,
|
MessageProcessingResult,
|
||||||
|
ResponseParserProtocol,
|
||||||
SglExt,
|
SglExt,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
ToolCallProcessingResult,
|
ToolCallProcessingResult,
|
||||||
@@ -37,6 +46,7 @@ from sglang.srt.entrypoints.openai.protocol import (
|
|||||||
TopLogprob,
|
TopLogprob,
|
||||||
)
|
)
|
||||||
from sglang.srt.entrypoints.openai.serving_base import OpenAIServingBase
|
from sglang.srt.entrypoints.openai.serving_base import OpenAIServingBase
|
||||||
|
from sglang.srt.entrypoints.openai.sse_utils import build_sse_content
|
||||||
from sglang.srt.entrypoints.openai.usage_processor import UsageProcessor
|
from sglang.srt.entrypoints.openai.usage_processor import UsageProcessor
|
||||||
from sglang.srt.entrypoints.openai.utils import (
|
from sglang.srt.entrypoints.openai.utils import (
|
||||||
cached_tokens_details_from_dict,
|
cached_tokens_details_from_dict,
|
||||||
@@ -56,75 +66,6 @@ from sglang.srt.parser.conversation import generate_chat_conv
|
|||||||
from sglang.srt.parser.jinja_template_utils import process_content_for_template_format
|
from sglang.srt.parser.jinja_template_utils import process_content_for_template_format
|
||||||
from sglang.srt.parser.reasoning_parser import ReasoningParser
|
from sglang.srt.parser.reasoning_parser import ReasoningParser
|
||||||
|
|
||||||
_SSE_DATA_B = b"data: "
|
|
||||||
_SSE_NL_B = b"\n\n"
|
|
||||||
|
|
||||||
|
|
||||||
class _StreamDelta(msgspec.Struct, omit_defaults=True):
|
|
||||||
# OpenAI Python SDK's ChoiceDelta does not declare reasoning_content; it is
|
|
||||||
# surfaced via pydantic `extra`. With omit_defaults=True, defaulting to
|
|
||||||
# None would drop the key entirely from the SSE payload, making
|
|
||||||
# `data.reasoning_content` raise AttributeError on the client. Keep it
|
|
||||||
# required (no default) so it is always serialized as null or a string.
|
|
||||||
reasoning_content: Optional[str]
|
|
||||||
role: Optional[str] = None
|
|
||||||
content: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class _StreamChoice(msgspec.Struct):
|
|
||||||
index: int
|
|
||||||
delta: _StreamDelta
|
|
||||||
logprobs: Optional[dict] = None
|
|
||||||
finish_reason: Optional[str] = None
|
|
||||||
matched_stop: Union[None, int, str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class _StreamChunk(msgspec.Struct, omit_defaults=True):
|
|
||||||
id: str
|
|
||||||
object: str
|
|
||||||
created: int
|
|
||||||
model: str
|
|
||||||
choices: List[_StreamChoice]
|
|
||||||
usage: Optional[dict] = None
|
|
||||||
|
|
||||||
|
|
||||||
_stream_encoder = msgspec.json.Encoder()
|
|
||||||
|
|
||||||
|
|
||||||
def _fast_sse_content(
|
|
||||||
chunk_id: str,
|
|
||||||
created: int,
|
|
||||||
model: str,
|
|
||||||
index: int,
|
|
||||||
role: Optional[str] = None,
|
|
||||||
content: Optional[str] = None,
|
|
||||||
reasoning_content: Optional[str] = None,
|
|
||||||
finish_reason: Optional[str] = None,
|
|
||||||
logprobs: Optional[dict] = None,
|
|
||||||
matched_stop: Union[None, int, str] = None,
|
|
||||||
usage: Optional[dict] = None,
|
|
||||||
) -> str:
|
|
||||||
delta = _StreamDelta(
|
|
||||||
role=role, content=content, reasoning_content=reasoning_content
|
|
||||||
)
|
|
||||||
choice = _StreamChoice(
|
|
||||||
index=index,
|
|
||||||
delta=delta,
|
|
||||||
logprobs=logprobs,
|
|
||||||
finish_reason=finish_reason,
|
|
||||||
matched_stop=matched_stop,
|
|
||||||
)
|
|
||||||
chunk = _StreamChunk(
|
|
||||||
id=chunk_id,
|
|
||||||
object="chat.completion.chunk",
|
|
||||||
created=created,
|
|
||||||
model=model,
|
|
||||||
choices=[choice],
|
|
||||||
usage=usage,
|
|
||||||
)
|
|
||||||
return (_SSE_DATA_B + _stream_encoder.encode(chunk) + _SSE_NL_B).decode()
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sglang.srt.managers.template_manager import TemplateManager
|
from sglang.srt.managers.template_manager import TemplateManager
|
||||||
from sglang.srt.managers.tokenizer_manager import TokenizerManager
|
from sglang.srt.managers.tokenizer_manager import TokenizerManager
|
||||||
@@ -234,9 +175,12 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Which Python-based chat encoder (if any) bypasses apply_chat_template.
|
# Which Python-based chat encoder (if any) bypasses apply_chat_template.
|
||||||
# Values: "dsv32", "dsv4", or None.
|
# Values: "dsv32", "dsv4", or custom values set by subclass. None for default.
|
||||||
self.chat_encoding_spec = self._resolve_chat_encoding_spec()
|
self.chat_encoding_spec = self._resolve_chat_encoding_spec()
|
||||||
|
|
||||||
|
# Per-request response parser for custom decoding (set by _encode_messages)
|
||||||
|
self._response_parser: Optional[ResponseParserProtocol] = None
|
||||||
|
|
||||||
def _handle_last_assistant_message(
|
def _handle_last_assistant_message(
|
||||||
self,
|
self,
|
||||||
messages: List[Dict[str, Any]],
|
messages: List[Dict[str, Any]],
|
||||||
@@ -294,6 +238,10 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
return prompt_ids + encoded
|
return prompt_ids + encoded
|
||||||
|
|
||||||
def _resolve_chat_encoding_spec(self) -> Optional[str]:
|
def _resolve_chat_encoding_spec(self) -> Optional[str]:
|
||||||
|
"""Determine which chat encoding spec to use.
|
||||||
|
|
||||||
|
Override in subclass to add custom encoding specs.
|
||||||
|
"""
|
||||||
if self.tool_call_parser == "deepseekv4":
|
if self.tool_call_parser == "deepseekv4":
|
||||||
return "dsv4"
|
return "dsv4"
|
||||||
if self.tool_call_parser == "deepseekv32":
|
if self.tool_call_parser == "deepseekv32":
|
||||||
@@ -316,6 +264,121 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
def _request_id_prefix(self) -> str:
|
def _request_id_prefix(self) -> str:
|
||||||
return "chatcmpl-"
|
return "chatcmpl-"
|
||||||
|
|
||||||
|
def _encode_messages(
|
||||||
|
self,
|
||||||
|
messages: List[Dict[str, Any]],
|
||||||
|
request: ChatCompletionRequest,
|
||||||
|
thinking_mode: ThinkingMode,
|
||||||
|
) -> Optional[List[int]]:
|
||||||
|
"""Encode messages for custom chat_encoding_spec values.
|
||||||
|
|
||||||
|
Returns prompt_ids if handled, None to use default encoding.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _decode_response(self, ret_item: Dict[str, Any]) -> Union[str, ErrorResponse]:
|
||||||
|
"""Extract text from response."""
|
||||||
|
return ret_item["text"]
|
||||||
|
|
||||||
|
def _get_parsed_response_fields(
|
||||||
|
self,
|
||||||
|
reasoning_text: Optional[str],
|
||||||
|
tool_calls: Optional[List[Dict]],
|
||||||
|
) -> tuple[Optional[str], Optional[List[Dict]]]:
|
||||||
|
"""Post-process reasoning and tool_calls before building response."""
|
||||||
|
return reasoning_text, tool_calls
|
||||||
|
|
||||||
|
async def _generate_stream_content(
|
||||||
|
self,
|
||||||
|
content: Dict[str, Any],
|
||||||
|
index: int,
|
||||||
|
request: ChatCompletionRequest,
|
||||||
|
stream_offsets: Dict[int, int],
|
||||||
|
reasoning_parser_dict: Dict,
|
||||||
|
parser_dict: Dict,
|
||||||
|
has_tool_calls: Dict[int, bool],
|
||||||
|
choice_logprobs: Optional[Dict],
|
||||||
|
finish_reason_type: Optional[str],
|
||||||
|
continuous_usage_stats: bool,
|
||||||
|
prompt_tokens: Dict[int, int],
|
||||||
|
reasoning_tokens: Dict[int, int],
|
||||||
|
completion_tokens: Dict[int, int],
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""Generate SSE chunks for streaming content."""
|
||||||
|
offset = stream_offsets.get(index, 0)
|
||||||
|
if self.tokenizer_manager.server_args.incremental_streaming_output:
|
||||||
|
delta = content["text"]
|
||||||
|
else:
|
||||||
|
delta = content["text"][offset:]
|
||||||
|
stream_offsets[index] = len(content["text"])
|
||||||
|
|
||||||
|
# Handle reasoning content
|
||||||
|
if self.reasoning_parser and request.separate_reasoning:
|
||||||
|
reasoning_text, delta = self._process_reasoning_stream(
|
||||||
|
index, delta, reasoning_parser_dict, content, request
|
||||||
|
)
|
||||||
|
if reasoning_text:
|
||||||
|
usage = None
|
||||||
|
if continuous_usage_stats:
|
||||||
|
usage = UsageProcessor.calculate_token_usage(
|
||||||
|
prompt_tokens=prompt_tokens.get(index, 0),
|
||||||
|
reasoning_tokens=reasoning_tokens.get(index, 0),
|
||||||
|
completion_tokens=completion_tokens.get(index, 0),
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
yield build_sse_content(
|
||||||
|
chunk_id=content["meta_info"]["id"],
|
||||||
|
created=int(time.time()),
|
||||||
|
model=request.model,
|
||||||
|
index=index,
|
||||||
|
reasoning_content=reasoning_text,
|
||||||
|
usage=usage,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle tool calls
|
||||||
|
if request.tool_choice != "none" and request.tools and self.tool_call_parser:
|
||||||
|
async for chunk in self._process_tool_call_stream(
|
||||||
|
index,
|
||||||
|
delta,
|
||||||
|
parser_dict,
|
||||||
|
content,
|
||||||
|
request,
|
||||||
|
has_tool_calls,
|
||||||
|
continuous_usage_stats,
|
||||||
|
):
|
||||||
|
if chunk:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
# Send any remaining tool call arguments when generation finishes
|
||||||
|
if finish_reason_type is not None and index in parser_dict:
|
||||||
|
parser = parser_dict[index]
|
||||||
|
remaining_chunk = self._check_for_unstreamed_tool_args(
|
||||||
|
parser, content, request, index
|
||||||
|
)
|
||||||
|
if remaining_chunk:
|
||||||
|
yield remaining_chunk
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Regular content
|
||||||
|
if delta:
|
||||||
|
usage = None
|
||||||
|
if continuous_usage_stats:
|
||||||
|
usage = UsageProcessor.calculate_token_usage(
|
||||||
|
prompt_tokens=prompt_tokens.get(index, 0),
|
||||||
|
reasoning_tokens=reasoning_tokens.get(index, 0),
|
||||||
|
completion_tokens=completion_tokens.get(index, 0),
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
yield build_sse_content(
|
||||||
|
chunk_id=content["meta_info"]["id"],
|
||||||
|
created=int(time.time()),
|
||||||
|
model=request.model,
|
||||||
|
index=index,
|
||||||
|
content=delta,
|
||||||
|
logprobs=choice_logprobs,
|
||||||
|
usage=usage,
|
||||||
|
)
|
||||||
|
|
||||||
def _validate_request(self, request: ChatCompletionRequest) -> Optional[str]:
|
def _validate_request(self, request: ChatCompletionRequest) -> Optional[str]:
|
||||||
"""Validate that the input is valid."""
|
"""Validate that the input is valid."""
|
||||||
if not request.messages:
|
if not request.messages:
|
||||||
@@ -530,13 +593,22 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
|
|
||||||
template_content_format = self.template_manager.jinja_template_content_format
|
template_content_format = self.template_manager.jinja_template_content_format
|
||||||
|
|
||||||
if self.chat_encoding_spec is not None:
|
# Try custom encoding first (override in subclass for custom renderers)
|
||||||
# Per-request wins; env is fallback default for benchmark
|
|
||||||
# workflows that can't pass per-request chat_template_kwargs.
|
|
||||||
thinking_requested = (request.chat_template_kwargs or {}).get(
|
thinking_requested = (request.chat_template_kwargs or {}).get(
|
||||||
"thinking", envs.SGLANG_DEFAULT_THINKING.get()
|
"thinking", envs.SGLANG_DEFAULT_THINKING.get()
|
||||||
)
|
)
|
||||||
thinking_mode = "thinking" if thinking_requested else "chat"
|
thinking_mode = (
|
||||||
|
ThinkingMode.THINKING if thinking_requested else ThinkingMode.CHAT
|
||||||
|
)
|
||||||
|
prompt_ids = self._encode_messages(
|
||||||
|
[msg.model_dump() for msg in request.messages], request, thinking_mode
|
||||||
|
)
|
||||||
|
|
||||||
|
if prompt_ids is not None:
|
||||||
|
# Custom encoding handled it - no further processing needed
|
||||||
|
pass
|
||||||
|
elif self.chat_encoding_spec is not None:
|
||||||
|
# dsv4/dsv32 encoding path
|
||||||
messages = [msg.model_dump() for msg in request.messages]
|
messages = [msg.model_dump() for msg in request.messages]
|
||||||
|
|
||||||
# dsv4/dsv32 are text-only and consume string content; flatten
|
# dsv4/dsv32 are text-only and consume string content; flatten
|
||||||
@@ -572,6 +644,7 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
if request.tools:
|
if request.tools:
|
||||||
messages[0]["tools"] = [tool.model_dump() for tool in request.tools]
|
messages[0]["tools"] = [tool.model_dump() for tool in request.tools]
|
||||||
|
|
||||||
|
# Default encoding (dsv4/dsv32)
|
||||||
if self.chat_encoding_spec == "dsv4":
|
if self.chat_encoding_spec == "dsv4":
|
||||||
# V4 encoder only accepts "max" / "high" / None.
|
# V4 encoder only accepts "max" / "high" / None.
|
||||||
# OpenAI protocol defaults to "medium" which V4 rejects; drop it.
|
# OpenAI protocol defaults to "medium" which V4 rejects; drop it.
|
||||||
@@ -593,6 +666,7 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
thinking_mode=thinking_mode,
|
thinking_mode=thinking_mode,
|
||||||
reasoning_effort=v4_reasoning_effort,
|
reasoning_effort=v4_reasoning_effort,
|
||||||
)
|
)
|
||||||
|
prompt_ids = self.tokenizer_manager.tokenizer.encode(real_input)
|
||||||
else:
|
else:
|
||||||
real_input = encoding_dsv32.encode_messages(
|
real_input = encoding_dsv32.encode_messages(
|
||||||
messages, thinking_mode=thinking_mode
|
messages, thinking_mode=thinking_mode
|
||||||
@@ -897,7 +971,7 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
# First chunk with role
|
# First chunk with role
|
||||||
if is_firsts.get(index, True):
|
if is_firsts.get(index, True):
|
||||||
is_firsts[index] = False
|
is_firsts[index] = False
|
||||||
yield _fast_sse_content(
|
yield build_sse_content(
|
||||||
chunk_id=content["meta_info"]["id"],
|
chunk_id=content["meta_info"]["id"],
|
||||||
created=int(time.time()),
|
created=int(time.time()),
|
||||||
model=request.model,
|
model=request.model,
|
||||||
@@ -907,85 +981,24 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
)
|
)
|
||||||
stream_started = True
|
stream_started = True
|
||||||
|
|
||||||
offset = stream_offsets.get(index, 0)
|
# Generate streaming content (override in subclass for custom behavior)
|
||||||
if self.tokenizer_manager.server_args.incremental_streaming_output:
|
async for chunk in self._generate_stream_content(
|
||||||
# content["text"] is already the incremental delta
|
content=content,
|
||||||
delta = content["text"]
|
|
||||||
else:
|
|
||||||
delta = content["text"][offset:]
|
|
||||||
stream_offsets[index] = len(content["text"])
|
|
||||||
|
|
||||||
# Handle reasoning content
|
|
||||||
if self.reasoning_parser and request.separate_reasoning:
|
|
||||||
reasoning_text, delta = self._process_reasoning_stream(
|
|
||||||
index, delta, reasoning_parser_dict, content, request
|
|
||||||
)
|
|
||||||
if reasoning_text:
|
|
||||||
usage = None
|
|
||||||
if continuous_usage_stats:
|
|
||||||
usage = UsageProcessor.calculate_token_usage(
|
|
||||||
prompt_tokens=prompt_tokens.get(index, 0),
|
|
||||||
reasoning_tokens=reasoning_tokens.get(index, 0),
|
|
||||||
completion_tokens=completion_tokens.get(index, 0),
|
|
||||||
).model_dump()
|
|
||||||
|
|
||||||
yield _fast_sse_content(
|
|
||||||
chunk_id=content["meta_info"]["id"],
|
|
||||||
created=int(time.time()),
|
|
||||||
model=request.model,
|
|
||||||
index=index,
|
index=index,
|
||||||
reasoning_content=reasoning_text,
|
request=request,
|
||||||
usage=usage,
|
stream_offsets=stream_offsets,
|
||||||
)
|
reasoning_parser_dict=reasoning_parser_dict,
|
||||||
|
parser_dict=parser_dict,
|
||||||
# Handle tool calls
|
has_tool_calls=has_tool_calls,
|
||||||
if (
|
choice_logprobs=choice_logprobs,
|
||||||
request.tool_choice != "none"
|
finish_reason_type=finish_reason_type,
|
||||||
and request.tools
|
continuous_usage_stats=continuous_usage_stats,
|
||||||
and self.tool_call_parser
|
prompt_tokens=prompt_tokens,
|
||||||
|
reasoning_tokens=reasoning_tokens,
|
||||||
|
completion_tokens=completion_tokens,
|
||||||
):
|
):
|
||||||
async for chunk in self._process_tool_call_stream(
|
|
||||||
index,
|
|
||||||
delta,
|
|
||||||
parser_dict,
|
|
||||||
content,
|
|
||||||
request,
|
|
||||||
has_tool_calls,
|
|
||||||
continuous_usage_stats,
|
|
||||||
):
|
|
||||||
if chunk:
|
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
# Send any remaining tool call arguments when generation finishes
|
|
||||||
if finish_reason_type is not None and index in parser_dict:
|
|
||||||
parser = parser_dict[index]
|
|
||||||
remaining_chunk = self._check_for_unstreamed_tool_args(
|
|
||||||
parser, content, request, index
|
|
||||||
)
|
|
||||||
if remaining_chunk:
|
|
||||||
yield remaining_chunk
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Regular content
|
|
||||||
if delta:
|
|
||||||
usage = None
|
|
||||||
if continuous_usage_stats:
|
|
||||||
usage = UsageProcessor.calculate_token_usage(
|
|
||||||
prompt_tokens=prompt_tokens.get(index, 0),
|
|
||||||
reasoning_tokens=reasoning_tokens.get(index, 0),
|
|
||||||
completion_tokens=completion_tokens.get(index, 0),
|
|
||||||
).model_dump()
|
|
||||||
|
|
||||||
yield _fast_sse_content(
|
|
||||||
chunk_id=content["meta_info"]["id"],
|
|
||||||
created=int(time.time()),
|
|
||||||
model=request.model,
|
|
||||||
index=index,
|
|
||||||
content=delta,
|
|
||||||
logprobs=choice_logprobs,
|
|
||||||
usage=usage,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send finish_reason chunks for each index that completed
|
# Send finish_reason chunks for each index that completed
|
||||||
for idx, finish_reason_data in finish_reasons.items():
|
for idx, finish_reason_data in finish_reasons.items():
|
||||||
finish_reason_type = finish_reason_data["type"]
|
finish_reason_type = finish_reason_data["type"]
|
||||||
@@ -996,7 +1009,7 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
final_finish_reason = "tool_calls"
|
final_finish_reason = "tool_calls"
|
||||||
|
|
||||||
matched_stop = finish_reason_data.get("matched")
|
matched_stop = finish_reason_data.get("matched")
|
||||||
yield _fast_sse_content(
|
yield build_sse_content(
|
||||||
chunk_id=content["meta_info"]["id"],
|
chunk_id=content["meta_info"]["id"],
|
||||||
created=int(time.time()),
|
created=int(time.time()),
|
||||||
model=request.model,
|
model=request.model,
|
||||||
@@ -1141,21 +1154,23 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
hidden_states = process_hidden_states_from_ret(ret_item, request)
|
hidden_states = process_hidden_states_from_ret(ret_item, request)
|
||||||
|
|
||||||
finish_reason = ret_item["meta_info"]["finish_reason"]
|
finish_reason = ret_item["meta_info"]["finish_reason"]
|
||||||
text = ret_item["text"]
|
|
||||||
|
text = self._decode_response(ret_item)
|
||||||
|
if isinstance(text, ErrorResponse):
|
||||||
|
return ORJSONResponse(content=text.model_dump(), status_code=text.code)
|
||||||
|
|
||||||
# Handle reasoning content
|
# Handle reasoning content
|
||||||
reasoning_text = None
|
reasoning_text = None
|
||||||
reasoning_parser = self.reasoning_parser
|
if self.reasoning_parser and request.separate_reasoning:
|
||||||
if reasoning_parser and request.separate_reasoning:
|
force_reasoning = (
|
||||||
is_force_reasoning = (
|
|
||||||
self.template_manager.force_reasoning
|
self.template_manager.force_reasoning
|
||||||
or self._get_reasoning_from_request(request)
|
or self._get_reasoning_from_request(request)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
parser = ReasoningParser(
|
parser = ReasoningParser(
|
||||||
model_type=reasoning_parser,
|
model_type=self.reasoning_parser,
|
||||||
stream_reasoning=False,
|
stream_reasoning=False,
|
||||||
force_reasoning=is_force_reasoning,
|
force_reasoning=force_reasoning,
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
reasoning_text, text = parser.parse_non_stream(text)
|
reasoning_text, text = parser.parse_non_stream(text)
|
||||||
@@ -1183,6 +1198,10 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
history_tool_calls_cnt,
|
history_tool_calls_cnt,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
reasoning_text, tool_calls = self._get_parsed_response_fields(
|
||||||
|
reasoning_text, tool_calls
|
||||||
|
)
|
||||||
|
|
||||||
choice_data = ChatCompletionResponseChoice(
|
choice_data = ChatCompletionResponseChoice(
|
||||||
index=idx,
|
index=idx,
|
||||||
message=ChatMessage(
|
message=ChatMessage(
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""SSE chunk building utilities for OpenAI chat completions streaming."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
|
||||||
|
_SSE_DATA_B = b"data: "
|
||||||
|
_SSE_NL_B = b"\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
class StreamDelta(msgspec.Struct, omit_defaults=True):
|
||||||
|
"""Delta content for streaming responses.
|
||||||
|
|
||||||
|
OpenAI Python SDK's ChoiceDelta does not declare reasoning_content; it is
|
||||||
|
surfaced via pydantic `extra`. With omit_defaults=True, defaulting to
|
||||||
|
None would drop the key entirely from the SSE payload, making
|
||||||
|
`data.reasoning_content` raise AttributeError on the client. Keep it
|
||||||
|
required (no default) so it is always serialized as null or a string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
reasoning_content: Optional[str]
|
||||||
|
role: Optional[str] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StreamChoice(msgspec.Struct):
|
||||||
|
"""A single choice in a streaming response."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
delta: StreamDelta
|
||||||
|
logprobs: Optional[dict] = None
|
||||||
|
finish_reason: Optional[str] = None
|
||||||
|
matched_stop: Union[None, int, str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StreamChunk(msgspec.Struct, omit_defaults=True):
|
||||||
|
"""A complete streaming chunk."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
object: str
|
||||||
|
created: int
|
||||||
|
model: str
|
||||||
|
choices: List[StreamChoice]
|
||||||
|
usage: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
_stream_encoder = msgspec.json.Encoder()
|
||||||
|
|
||||||
|
|
||||||
|
def build_sse_content(
|
||||||
|
chunk_id: str,
|
||||||
|
created: int,
|
||||||
|
model: str,
|
||||||
|
index: int,
|
||||||
|
role: Optional[str] = None,
|
||||||
|
content: Optional[str] = None,
|
||||||
|
reasoning_content: Optional[str] = None,
|
||||||
|
finish_reason: Optional[str] = None,
|
||||||
|
logprobs: Optional[dict] = None,
|
||||||
|
matched_stop: Union[None, int, str] = None,
|
||||||
|
usage: Optional[dict] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build an SSE chunk string for content/reasoning updates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chunk_id: Request ID for this chunk
|
||||||
|
created: Unix timestamp
|
||||||
|
model: Model name
|
||||||
|
index: Choice index
|
||||||
|
role: Message role (usually "assistant")
|
||||||
|
content: Text content delta
|
||||||
|
reasoning_content: Reasoning/thinking content delta
|
||||||
|
finish_reason: Finish reason if done
|
||||||
|
logprobs: Log probabilities if requested
|
||||||
|
matched_stop: Stop token/string that was matched
|
||||||
|
usage: Token usage statistics
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SSE-formatted string "data: {...}\\n\\n"
|
||||||
|
"""
|
||||||
|
delta = StreamDelta(role=role, content=content, reasoning_content=reasoning_content)
|
||||||
|
choice = StreamChoice(
|
||||||
|
index=index,
|
||||||
|
delta=delta,
|
||||||
|
logprobs=logprobs,
|
||||||
|
finish_reason=finish_reason,
|
||||||
|
matched_stop=matched_stop,
|
||||||
|
)
|
||||||
|
chunk = StreamChunk(
|
||||||
|
id=chunk_id,
|
||||||
|
object="chat.completion.chunk",
|
||||||
|
created=created,
|
||||||
|
model=model,
|
||||||
|
choices=[choice],
|
||||||
|
usage=usage,
|
||||||
|
)
|
||||||
|
return (_SSE_DATA_B + _stream_encoder.encode(chunk) + _SSE_NL_B).decode()
|
||||||
@@ -216,6 +216,16 @@ class InputFormat(Enum):
|
|||||||
class TokenizerManager(TokenizerControlMixin, TokenizerManagerScoreMixin):
|
class TokenizerManager(TokenizerControlMixin, TokenizerManagerScoreMixin):
|
||||||
"""TokenizerManager is a process that tokenizes the text."""
|
"""TokenizerManager is a process that tokenizes the text."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def serving_chat_class(self):
|
||||||
|
"""Return the serving chat class for OpenAI API.
|
||||||
|
|
||||||
|
Override in subclass to provide custom serving behavior.
|
||||||
|
"""
|
||||||
|
from sglang.srt.entrypoints.openai.serving_chat import OpenAIServingChat
|
||||||
|
|
||||||
|
return OpenAIServingChat
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
server_args: ServerArgs,
|
server_args: ServerArgs,
|
||||||
|
|||||||
@@ -475,5 +475,20 @@ class TestValidationEdgeCases(unittest.TestCase):
|
|||||||
self.assertEqual(len(restored_request.messages), len(original_request.messages))
|
self.assertEqual(len(restored_request.messages), len(original_request.messages))
|
||||||
|
|
||||||
|
|
||||||
|
class TestParsedResponseFieldsProtocol(unittest.TestCase):
|
||||||
|
"""Test ParsedResponseFields protocol."""
|
||||||
|
|
||||||
|
def test_parsed_response_fields_protocol(self):
|
||||||
|
"""ParsedResponseFields protocol works with isinstance."""
|
||||||
|
from sglang.srt.entrypoints.openai.protocol import ParsedResponseFields
|
||||||
|
|
||||||
|
class MockFields:
|
||||||
|
content = "hello"
|
||||||
|
tool_calls = None
|
||||||
|
reasoning_content = None
|
||||||
|
|
||||||
|
self.assertIsInstance(MockFields(), ParsedResponseFields)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
@@ -1474,6 +1474,26 @@ class ServingChatTestCase(unittest.TestCase):
|
|||||||
result = self.chat._apply_conversation_template(req, is_multimodal=False)
|
result = self.chat._apply_conversation_template(req, is_multimodal=False)
|
||||||
self.assertEqual(result.prompt, "BASE_PROMPT")
|
self.assertEqual(result.prompt, "BASE_PROMPT")
|
||||||
|
|
||||||
|
# ------------- hook method tests -------------
|
||||||
|
def test_encode_messages_returns_none_by_default(self):
|
||||||
|
"""Default _encode_messages returns None (use standard encoding)."""
|
||||||
|
result = self.chat._encode_messages([], Mock(), False)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_decode_response_returns_text(self):
|
||||||
|
"""Default _decode_response returns ret_item['text']."""
|
||||||
|
ret_item = {"text": "Hello world", "output_ids": [1, 2, 3]}
|
||||||
|
result = self.chat._decode_response(ret_item)
|
||||||
|
self.assertEqual(result, "Hello world")
|
||||||
|
|
||||||
|
def test_get_parsed_response_fields_passthrough(self):
|
||||||
|
"""Default _get_parsed_response_fields passes through values."""
|
||||||
|
reasoning = "thinking..."
|
||||||
|
tool_calls = [{"name": "foo"}]
|
||||||
|
r, t = self.chat._get_parsed_response_fields(reasoning, tool_calls)
|
||||||
|
self.assertEqual(r, reasoning)
|
||||||
|
self.assertEqual(t, tool_calls)
|
||||||
|
|
||||||
|
|
||||||
class TestProcessToolCallsWithRequiredToolChoice(unittest.TestCase):
|
class TestProcessToolCallsWithRequiredToolChoice(unittest.TestCase):
|
||||||
"""Test _process_tool_calls with tool_choice='required' uses model-specific parser."""
|
"""Test _process_tool_calls with tool_choice='required' uses model-specific parser."""
|
||||||
|
|||||||
Reference in New Issue
Block a user