Add overridable hooks for custom chat serving implementations (#25807)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Randall Lin
2026-05-21 11:21:25 +08:00
committed by GitHub
co-authored by Cursor
parent 74c6294ba9
commit 791a2f057f
7 changed files with 371 additions and 167 deletions
+3 -2
View File
@@ -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."""