Align OpenAI serving behavior with Para deployments

Absorb PR 11's final Para compatibility surface as an opt-in OpenAI serving layer rather than hard-coding business defaults into protocol models. The change adds server args for Para chat defaults, Kimi/GLM compatibility, tool-choice normalization, tool-role text flattening, and streaming first-chunk error preflight while preserving default upstream behavior unless explicitly enabled.

Reasoning token usage is also propagated through chat/completion usage paths, with GLM compatibility emitting completion_tokens_details.reasoning_tokens. Low-risk protocol fixes accept string image_url content parts and preserve GLM function-call argument value whitespace.

Constraint: Online Para-compatible deployments require request/response semantics that differ from default OpenAI serving behavior.

Constraint: Current CP/HiCache/bs>1 work must not be coupled to OpenAI serving compatibility changes.

Rejected: Merge PR 11 history directly | intermediate commits briefly hard-code chat max_tokens=32768 before later gating it by server args.

Rejected: Enable Para compatibility by default | would change non-Para OpenAI-compatible deployments.

Confidence: high

Scope-risk: moderate

Directive: Keep Para-specific serving policies behind explicit server args unless the business contract changes globally.

Tested: PYTHONPATH=python:. python -m unittest discover -s test/registered/unit/entrypoints/openai -p 'test_para_serving_protocol.py' -v (19 tests OK)

Tested: python -m py_compile modified OpenAI serving, tokenizer manager, server_args, function-call detector, and test files

Not-tested: Live router/prefill/decode OpenAI serving E2E after enabling Para flags.

Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
laoyao0822
2026-06-11 05:59:08 +08:00
parent 8cfdb0466e
commit 9c8e3e99cb
11 changed files with 1262 additions and 7 deletions

View File

@@ -0,0 +1,159 @@
# Para OpenAI Serving 对齐说明
本文总结当前分支对 `sglang-para` OpenAI API / serving 相关行为的对齐范围、默认策略和启用方式。
## 总体策略
本分支已经实现 Para 侧高风险 serving 行为,但为了降低耦合,**默认不启用 Para 兼容策略**。需要对齐 Para 业务行为时,通过 server args 显式打开。
默认保持上游/本分支原行为:
- chat 请求不自动填 `max_tokens=32768`
- 不强制把 `tool_choice` 改为 `auto`
- 不按模型路径自动启用 Kimi / GLM 特化逻辑。
- 不默认 flatten tool role 的 content list。
- streaming 不默认预拉首 chunk 做 HTTP error 转换。
## 启用 Para 兼容的参数
完整启用 Para OpenAI serving 对齐时,可在启动参数中加入:
```bash
--openai-chat-default-max-tokens 32768 \
--openai-force-tool-choice-auto \
--openai-kimi-compat \
--openai-glm-compat \
--openai-flatten-tool-role-text-content \
--openai-streaming-error-preflight
```
参数含义:
| 参数 | 默认值 | 启用后行为 |
| ----------------------------------------- | ------- | ----------------------------------------------------------------------------------------- |
| `--openai-chat-default-max-tokens 32768` | `0` | chat 请求未传 `max_tokens/max_completion_tokens` 时,在 serving 层补默认输出上限。 |
| `--openai-force-tool-choice-auto` | `False` | 将显式非 `auto` 的 chat `tool_choice``auto` 服务。 |
| `--openai-kimi-compat` | `False` | 根据 `model_path` 识别 Kimi启用 Kimi `thinking` 映射和固定采样参数。 |
| `--openai-glm-compat` | `False` | 根据 `model_path` 识别 GLM启用 GLM tool choice 降级、413 超长错误和 GLM usage details。 |
| `--openai-flatten-tool-role-text-content` | `False` | 将 tool role 的纯 text content parts list flatten 成字符串后交给 chat template。 |
| `--openai-streaming-error-preflight` | `False` | streaming 先预拉首 chunk若首 chunk 是错误,转成普通 HTTP error而不是 SSE 内报错。 |
## 已对齐的功能点
### 1. Chat 默认输出长度
Para 行为:未传 `max_tokens/max_completion_tokens` 时默认 `32768`
当前实现:
- `ChatCompletionRequest.max_tokens` 仍保持 `None`,避免协议模型硬编码业务默认值。
- serving 层读取 `openai_chat_default_max_tokens`
-`--openai-chat-default-max-tokens 32768` 后,效果与 Para 一致。
### 2. Kimi 兼容
启用 `--openai-kimi-compat` 后:
- 支持 chat 请求中的 `thinking` 字段。
- Kimi 模型下将 `thinking.type != "disabled"` 映射到 `chat_template_kwargs["thinking"]`
- 对 Kimi 应用 Para 固定采样参数:
- `top_p=0.95`
- `presence_penalty=0.0`
- `frequency_penalty=0.0`
- `n=1`
### 3. GLM 兼容
启用 `--openai-glm-compat` 后:
- 根据 `model_path` 包含 `glm` 识别 GLM。
- GLM 下 `tool_choice="required"` 降级为 `"auto"`
- GLM input token 已超过 context length 时,抛 `PayloadTooLargeError`OpenAI serving 返回 HTTP `413`
- GLM chat usage 使用 `completion_tokens_details.reasoning_tokens`
未启用时GLM 超长输入仍走普通 `ValueError -> HTTP 400` 路径。
### 4. Tool choice 全局兼容
启用 `--openai-force-tool-choice-auto` 后:
- chat 请求中显式非 `auto``tool_choice` 会在 serving 层转为 `auto`
- 协议层不再改写 `tool_choice`,便于关闭该行为并降低耦合。
### 5. Tool role content flatten
启用 `--openai-flatten-tool-role-text-content` 后:
-`role="tool"` 且 content 是纯 text parts list 的消息,将内容拼成字符串。
- 仅 flatten 纯 text parts包含其他结构字段的 list 保持原样,避免破坏依赖结构化 tool content 的模板。
### 6. GLM function call 参数保留空格
无条件对齐 Para
- `glm4_moe_detector.py`
- `glm47_moe_detector.py`
两处 detector 不再对参数值执行 `strip()`,只 strip 参数 key。这样可以保留模型输出或客户端参数值里的前后空格。
### 7. `image_url` 字符串兼容
无条件对齐 Para
- 支持 OpenAI multimodal content part 中 `image_url` 直接传字符串。
- Pydantic validator 自动转成 `{ "url": ... }`
### 8. Streaming 首包错误转换
启用 `--openai-streaming-error-preflight` 后:
- chat streaming 会先拉取首个 chunk。
- 如果首 chunk 是 SSE error payload会转成普通 HTTP error response。
- 如果首 chunk 正常,会 prepend 回 stream不影响正常 streaming。
### 9. Usage / reasoning tokens
已对齐 Para usage 聚合能力:
- `UsageInfo` 增加 `completion_tokens_details`
- `UsageProcessor` 聚合 `reasoning_tokens`
- Chat/Completion serving 都向 usage processor 传递 reasoning token 计数。
- 启用 GLM compat 后GLM chat 使用 `completion_tokens_details.reasoning_tokens`
## 主要改动文件
- `python/sglang/srt/server_args.py`
- `python/sglang/srt/entrypoints/openai/protocol.py`
- `python/sglang/srt/entrypoints/openai/serving_chat.py`
- `python/sglang/srt/entrypoints/openai/serving_base.py`
- `python/sglang/srt/entrypoints/openai/usage_processor.py`
- `python/sglang/srt/entrypoints/openai/serving_completions.py`
- `python/sglang/srt/managers/tokenizer_manager.py`
- `python/sglang/srt/function_call/glm4_moe_detector.py`
- `python/sglang/srt/function_call/glm47_moe_detector.py`
- `test/registered/unit/entrypoints/openai/test_para_serving_protocol.py`
## 验证
本地 macOS 使用 uv 虚拟环境完成轻量单测验证:
```bash
VIRTUAL_ENV=/private/tmp/sglang-para-test-venv \
PATH=/private/tmp/sglang-para-test-venv/bin:$PATH \
UV_CACHE_DIR=/private/tmp/uv-cache \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=python:. \
uv run --active --no-sync python -m unittest discover \
-s test/registered/unit/entrypoints/openai \
-p 'test_para_serving_protocol.py' -v
```
结果:`Ran 19 tests ... OK`
同时执行:
```bash
govctl check
```
结果:通过。

View File

@@ -128,6 +128,12 @@ class PromptTokensDetails(BaseModel):
cached_tokens: int = 0
class CompletionTokensDetails(BaseModel):
"""Details about completion tokens."""
reasoning_tokens: int = 0
class UsageInfo(BaseModel):
prompt_tokens: int = 0
total_tokens: int = 0
@@ -135,6 +141,7 @@ class UsageInfo(BaseModel):
# Used to return cached tokens info when --enable-cache-report is set
prompt_tokens_details: Optional[PromptTokensDetails] = None
reasoning_tokens: Optional[int] = 0
completion_tokens_details: Optional[CompletionTokensDetails] = None
class StreamOptions(BaseModel):
@@ -426,6 +433,13 @@ class ChatCompletionMessageContentImageURL(BaseModel):
max_dynamic_patch: Optional[int] = None
min_dynamic_patch: Optional[int] = None
@model_validator(mode="before")
@classmethod
def coerce_string(cls, values):
if isinstance(values, str):
return {"url": values}
return values
class ChatCompletionMessageContentVideoURL(BaseModel):
url: str
@@ -615,6 +629,7 @@ class ChatCompletionRequest(BaseModel):
separate_reasoning: bool = True
stream_reasoning: bool = True
chat_template_kwargs: Optional[Dict] = None
thinking: Optional[Dict] = None
# SGLang multimodal tiling controls (extensions)
max_dynamic_patch: Optional[int] = None
@@ -743,13 +758,16 @@ class ChatCompletionRequest(BaseModel):
stop: List[str],
model_generation_config: Dict[str, Any],
tool_call_constraint: Optional[ToolCallConstraint] = None,
fixed_sampling_overrides: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Convert request to sampling parameters.
Priority: user value > model generation_config > OpenAI defaults
Priority: fixed_sampling_overrides (if any) > user value > model_generation_config > OpenAI defaults
"""
def get_param(param_name: str):
if fixed_sampling_overrides and param_name in fixed_sampling_overrides:
return fixed_sampling_overrides[param_name]
value = getattr(self, param_name)
if value is None:
return model_generation_config.get(
@@ -779,7 +797,11 @@ class ChatCompletionRequest(BaseModel):
"repetition_penalty": get_param("repetition_penalty"),
"regex": self.regex,
"ebnf": self.ebnf,
"n": self.n,
"n": (
fixed_sampling_overrides["n"]
if fixed_sampling_overrides and "n" in fixed_sampling_overrides
else self.n
),
"no_stop_trim": self.no_stop_trim,
"ignore_eos": self.ignore_eos,
"skip_special_tokens": self.skip_special_tokens,

View File

@@ -14,6 +14,7 @@ from fastapi.responses import ORJSONResponse, StreamingResponse
from sglang.srt.entrypoints.openai.encoding_dsv32 import DS32EncodingError
from sglang.srt.entrypoints.openai.protocol import ErrorResponse, OpenAIServingRequest
from sglang.srt.managers.io_struct import EmbeddingReqInput, GenerateReqInput
from sglang.srt.managers.tokenizer_manager import PayloadTooLargeError
from sglang.srt.observability.req_time_stats import monotonic_time
from sglang.srt.server_args import ServerArgs
@@ -135,6 +136,12 @@ class OpenAIServingBase(ABC):
err_type="BadRequest",
status_code=400,
)
except PayloadTooLargeError as e:
return self.create_error_response(
message=str(e),
err_type="PayloadTooLargeError",
status_code=413,
)
except DS32EncodingError as e:
logger.info(f"DS32EncodingError: {e}")
return self.create_error_response(
@@ -242,6 +249,52 @@ class OpenAIServingBase(ABC):
)
return ORJSONResponse(content=error.model_dump(), status_code=status_code)
def create_error_response_from_first_streaming_chunk(
self,
first_chunk: str,
) -> Optional[ORJSONResponse]:
if not isinstance(first_chunk, str):
return None
first_chunk = first_chunk.strip()
if not first_chunk.startswith("data:"):
return None
data = first_chunk[len("data:") :].strip()
if data == "[DONE]":
return None
try:
payload = json.loads(data)
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
error = payload.get("error")
if not isinstance(error, dict):
return None
status_code = (
error.get("code")
or error.get("status")
or error.get("status_code")
or 500
)
if not isinstance(status_code, int) or not 100 <= status_code <= 599:
status_code = 500
return self.create_error_response(
message=error.get(
"message",
"Streaming request failed before first chunk.",
),
err_type=error.get("type", "InternalServerError"),
status_code=status_code,
param=error.get("param"),
)
def create_streaming_error_response(
self,
message: str,

View File

@@ -48,6 +48,7 @@ from sglang.srt.function_call.function_call_parser import FunctionCallParser
from sglang.srt.function_call.json_array_parser import JsonArrayParser
from sglang.srt.function_call.utils import get_json_schema_constraint
from sglang.srt.managers.io_struct import GenerateReqInput
from sglang.srt.managers.tokenizer_manager import PayloadTooLargeError
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.reasoning_parser import ReasoningParser
@@ -133,6 +134,16 @@ class OpenAIServingChat(OpenAIServingBase):
and self.tokenizer_manager.model_config.hf_config.model_type == "gpt_oss"
)
# Model-specific Para compatibility is controlled by server args so local
# deployments can opt out without editing OpenAI protocol models.
model_path = self.tokenizer_manager.server_args.model_path.lower()
self.is_kimi = self._get_server_arg("openai_kimi_compat", False) and (
"kimi" in model_path
)
self.is_glm = self._get_server_arg("openai_glm_compat", False) and (
"glm" in model_path
)
self.use_dpsk_v32_encoding = self._use_dpsk_v32_encoding()
def _handle_last_assistant_message(
@@ -327,8 +338,42 @@ class OpenAIServingChat(OpenAIServingBase):
def _request_id_prefix(self) -> str:
return "chatcmpl-"
def _get_server_arg(self, name: str, default: Any) -> Any:
server_args = getattr(self.tokenizer_manager, "server_args", None)
return getattr(server_args, name, default)
def _apply_openai_serving_defaults(
self, request: ChatCompletionRequest
) -> None:
if request.max_completion_tokens is not None or request.max_tokens is not None:
return
default_max_tokens = self._get_server_arg(
"openai_chat_default_max_tokens", 0
)
if default_max_tokens and default_max_tokens > 0:
request.max_tokens = default_max_tokens
def _validate_request(self, request: ChatCompletionRequest) -> Optional[str]:
"""Validate that the input is valid."""
self._apply_openai_serving_defaults(request)
if (
self._get_server_arg("openai_force_tool_choice_auto", False)
and request.tool_choice != "auto"
):
request.tool_choice = "auto"
if (
getattr(self, "is_glm", False)
and isinstance(request.tool_choice, str)
and request.tool_choice.lower() == "required"
):
logger.warning(
"tool_choice='required' is being downgraded to 'auto' for GLM model."
)
request.tool_choice = "auto"
if not request.messages:
return "Messages cannot be empty."
@@ -373,13 +418,50 @@ class OpenAIServingChat(OpenAIServingBase):
if schema is None:
return "schema_ is required for json_schema response format request."
if getattr(self, "is_kimi", False):
is_think_mode = not (
request.chat_template_kwargs
and request.chat_template_kwargs.get("thinking") is False
)
expected_params = self._get_kimi_fixed_params(is_think_mode)
for param, expected_value in expected_params.items():
user_value = getattr(request, param)
if user_value is not None and abs(user_value - expected_value) >= 1e-3:
return (
f"Parameter '{param}' cannot be overridden. "
f"Expected: {expected_value}, Got: {user_value}"
)
user_temperature = getattr(request, "temperature")
if user_temperature is not None and (
user_temperature < 0.0 or user_temperature > 1.0
):
return (
"Parameter `temperature` must be in [0, 1.0]. "
f"Got: {user_temperature}"
)
return None
@staticmethod
def _get_kimi_fixed_params(is_think_mode: bool) -> Dict[str, float]:
"""Return Kimi's fixed sampling parameters based on thinking mode."""
return {
# Keep Para behavior: temperature is validated as a range, but not forced.
# "temperature": 1.0 if is_think_mode else 0.6,
"top_p": 0.95,
"presence_penalty": 0.0,
"frequency_penalty": 0.0,
"n": 1,
}
def _convert_to_internal_request(
self,
request: ChatCompletionRequest,
raw_request: Request = None,
) -> tuple[GenerateReqInput, ChatCompletionRequest]:
self._apply_openai_serving_defaults(request)
reasoning_effort = (
request.chat_template_kwargs.pop("reasoning_effort", None)
if request.chat_template_kwargs
@@ -404,10 +486,18 @@ class OpenAIServingChat(OpenAIServingBase):
raise
# Build sampling parameters
fixed_overrides = None
if getattr(self, "is_kimi", False):
is_think_mode = not (
request.chat_template_kwargs
and request.chat_template_kwargs.get("thinking") is False
)
fixed_overrides = self._get_kimi_fixed_params(is_think_mode)
sampling_params = request.to_sampling_params(
stop=processed_messages.stop,
model_generation_config=self.default_sampling_params,
tool_call_constraint=processed_messages.tool_call_constraint,
fixed_sampling_overrides=fixed_overrides,
)
# Handle single vs multiple requests
@@ -530,6 +620,13 @@ class OpenAIServingChat(OpenAIServingBase):
template_content_format = self.template_manager.jinja_template_content_format
if getattr(self, "is_kimi", False) and request.thinking is not None:
if request.chat_template_kwargs is None:
request.chat_template_kwargs = {}
request.chat_template_kwargs["thinking"] = (
request.thinking.get("type") != "disabled"
)
if self.use_dpsk_v32_encoding:
thinking_mode = (
"thinking"
@@ -587,6 +684,30 @@ class OpenAIServingChat(OpenAIServingBase):
modalities,
)
# Normalize tool role content: OpenAI clients may send content as a
# list of content parts, but most chat templates expect a plain
# string for tool messages. Only flatten pure text parts; preserve
# lists that carry tool-semantic fields for templates that iterate
# over those structures.
if (
self._get_server_arg(
"openai_flatten_tool_role_text_content", False
)
and processed_msg["role"] == "tool"
and isinstance(processed_msg.get("content"), list)
):
parts = processed_msg["content"]
is_openai_text_parts = all(
(isinstance(part, dict) and part.get("type") == "text")
or isinstance(part, str)
for part in parts
)
if is_openai_text_parts:
processed_msg["content"] = "\n".join(
part.get("text", "") if isinstance(part, dict) else part
for part in parts
)
# per the Transformers docs & maintainers, tool call arguments in
# assistant-role messages with tool_calls need to be dicts not JSON str -
# this is how tool-use chat templates will expect them moving forwards
@@ -745,8 +866,37 @@ class OpenAIServingChat(OpenAIServingBase):
raw_request: Request,
) -> StreamingResponse:
"""Handle streaming chat completion request"""
if not self._get_server_arg("openai_streaming_error_preflight", False):
return StreamingResponse(
self._generate_chat_stream(adapted_request, request, raw_request),
media_type="text/event-stream",
background=self.tokenizer_manager.create_abort_task(adapted_request),
)
generator = self._generate_chat_stream(adapted_request, request, raw_request)
try:
first_chunk = await generator.__anext__()
except PayloadTooLargeError as e:
return self.create_error_response(
str(e), status_code=413, err_type="PayloadTooLargeError"
)
except ValueError as e:
return self.create_error_response(str(e))
first_chunk_error_response = self.create_error_response_from_first_streaming_chunk(
first_chunk
)
if first_chunk_error_response is not None:
return first_chunk_error_response
async def prepend_first_chunk():
yield first_chunk
async for chunk in generator:
yield chunk
return StreamingResponse(
self._generate_chat_stream(adapted_request, request, raw_request),
prepend_first_chunk(),
media_type="text/event-stream",
background=self.tokenizer_manager.create_abort_task(adapted_request),
)
@@ -772,6 +922,7 @@ class OpenAIServingChat(OpenAIServingBase):
# Usage tracking
prompt_tokens = {}
completion_tokens = {}
reasoning_tokens = {}
cached_tokens = {}
hidden_states = {}
routed_experts = {}
@@ -786,6 +937,9 @@ class OpenAIServingChat(OpenAIServingBase):
completion_tokens[index] = content["meta_info"].get(
"completion_tokens", 0
)
reasoning_tokens[index] = content["meta_info"].get(
"reasoning_tokens", 0
)
cached_tokens[index] = content["meta_info"].get("cached_tokens", 0)
hidden_states[index] = content["meta_info"].get("hidden_states", None)
routed_experts[index] = content["meta_info"].get("routed_experts", None)
@@ -874,6 +1028,8 @@ class OpenAIServingChat(OpenAIServingBase):
chunk.usage = UsageProcessor.calculate_token_usage(
prompt_tokens=prompt_tokens.get(index, 0),
completion_tokens=completion_tokens.get(index, 0),
reasoning_tokens=reasoning_tokens.get(index, 0),
use_completion_details=self.is_glm,
)
yield f"data: {chunk.model_dump_json()}\n\n"
@@ -929,6 +1085,8 @@ class OpenAIServingChat(OpenAIServingBase):
chunk.usage = UsageProcessor.calculate_token_usage(
prompt_tokens=prompt_tokens.get(index, 0),
completion_tokens=completion_tokens.get(index, 0),
reasoning_tokens=reasoning_tokens.get(index, 0),
use_completion_details=self.is_glm,
)
yield f"data: {chunk.model_dump_json()}\n\n"
@@ -1012,6 +1170,8 @@ class OpenAIServingChat(OpenAIServingBase):
cached_tokens,
n_choices=request.n,
enable_cache_report=self.tokenizer_manager.server_args.enable_cache_report,
reasoning_tokens=reasoning_tokens,
use_completion_details=self.is_glm,
)
usage_chunk = ChatCompletionStreamResponse(
id=content["meta_info"]["id"],
@@ -1151,6 +1311,7 @@ class OpenAIServingChat(OpenAIServingBase):
ret,
n_choices=request.n,
enable_cache_report=self.tokenizer_manager.server_args.enable_cache_report,
use_completion_details=self.is_glm,
)
return ChatCompletionResponse(
@@ -1470,9 +1631,12 @@ class OpenAIServingChat(OpenAIServingBase):
if request.stream_options and request.stream_options.continuous_usage_stats:
prompt_tokens = content["meta_info"].get("prompt_tokens", 0)
completion_tokens = content["meta_info"].get("completion_tokens", 0)
reasoning_tokens = content["meta_info"].get("reasoning_tokens", 0)
chunk.usage = UsageProcessor.calculate_token_usage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
reasoning_tokens=reasoning_tokens,
use_completion_details=self.is_glm,
)
yield f"data: {chunk.model_dump_json()}\n\n"
@@ -1521,9 +1685,12 @@ class OpenAIServingChat(OpenAIServingBase):
if request.stream_options and request.stream_options.continuous_usage_stats:
prompt_tokens = content["meta_info"].get("prompt_tokens", 0)
completion_tokens = content["meta_info"].get("completion_tokens", 0)
reasoning_tokens = content["meta_info"].get("reasoning_tokens", 0)
chunk.usage = UsageProcessor.calculate_token_usage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
reasoning_tokens=reasoning_tokens,
use_completion_details=self.is_glm,
)
yield f"data: {chunk.model_dump_json()}\n\n"

View File

@@ -204,6 +204,7 @@ class OpenAIServingCompletion(OpenAIServingBase):
# Usage tracking
prompt_tokens = {}
completion_tokens = {}
reasoning_tokens = {}
cached_tokens = {}
hidden_states = {}
routed_experts = {}
@@ -219,6 +220,9 @@ class OpenAIServingCompletion(OpenAIServingBase):
completion_tokens[index] = content["meta_info"].get(
"completion_tokens", 0
)
reasoning_tokens[index] = content["meta_info"].get(
"reasoning_tokens", 0
)
cached_tokens[index] = content["meta_info"].get("cached_tokens", 0)
hidden_states[index] = content["meta_info"].get("hidden_states", None)
routed_experts[index] = content["meta_info"].get("routed_experts", None)
@@ -312,6 +316,7 @@ class OpenAIServingCompletion(OpenAIServingBase):
chunk.usage = UsageProcessor.calculate_token_usage(
prompt_tokens=prompt_tokens.get(index, 0),
completion_tokens=completion_tokens.get(index, 0),
reasoning_tokens=reasoning_tokens.get(index, 0),
)
yield f"data: {chunk.model_dump_json()}\n\n"
@@ -364,6 +369,7 @@ class OpenAIServingCompletion(OpenAIServingBase):
cached_tokens,
n_choices=request.n,
enable_cache_report=self.tokenizer_manager.server_args.enable_cache_report,
reasoning_tokens=reasoning_tokens,
)
final_usage_chunk = CompletionStreamResponse(
id=content["meta_info"]["id"],

View File

@@ -2,7 +2,11 @@ from __future__ import annotations
from typing import Any, Dict, List, Mapping, Optional, final
from sglang.srt.entrypoints.openai.protocol import PromptTokensDetails, UsageInfo
from sglang.srt.entrypoints.openai.protocol import (
CompletionTokensDetails,
PromptTokensDetails,
UsageInfo,
)
@final
@@ -19,10 +23,14 @@ class UsageProcessor:
responses: List[Dict[str, Any]],
n_choices: int = 1,
enable_cache_report: bool = False,
use_completion_details: bool = False,
) -> UsageInfo:
completion_tokens = sum(
r["meta_info"].get("completion_tokens", 0) for r in responses
)
reasoning_tokens = sum(
r["meta_info"].get("reasoning_tokens", 0) for r in responses
)
prompt_tokens = sum(
responses[i]["meta_info"].get("prompt_tokens", 0)
@@ -41,6 +49,8 @@ class UsageProcessor:
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
cached_tokens=cached_details,
reasoning_tokens=reasoning_tokens,
use_completion_details=use_completion_details,
)
@staticmethod
@@ -50,12 +60,17 @@ class UsageProcessor:
cached_tokens: Mapping[int, int],
n_choices: int,
enable_cache_report: bool = False,
reasoning_tokens: Optional[Mapping[int, int]] = None,
use_completion_details: bool = False,
) -> UsageInfo:
# index % n_choices == 0 marks the first choice of a prompt
total_prompt_tokens = sum(
tok for idx, tok in prompt_tokens.items() if idx % n_choices == 0
)
total_completion_tokens = sum(completion_tokens.values())
total_reasoning_tokens = (
sum(reasoning_tokens.values()) if reasoning_tokens is not None else 0
)
cached_details = (
UsageProcessor._details_if_cached(
@@ -69,6 +84,8 @@ class UsageProcessor:
prompt_tokens=total_prompt_tokens,
completion_tokens=total_completion_tokens,
cached_tokens=cached_details,
reasoning_tokens=total_reasoning_tokens,
use_completion_details=use_completion_details,
)
@staticmethod
@@ -76,11 +93,27 @@ class UsageProcessor:
prompt_tokens: int,
completion_tokens: int,
cached_tokens: Optional[PromptTokensDetails] = None,
reasoning_tokens: Optional[int] = 0,
use_completion_details: bool = False,
) -> UsageInfo:
"""Calculate token usage information"""
if use_completion_details:
return UsageInfo(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
prompt_tokens_details=cached_tokens,
completion_tokens_details=(
CompletionTokensDetails(reasoning_tokens=reasoning_tokens)
if reasoning_tokens
else None
),
)
return UsageInfo(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
prompt_tokens_details=cached_tokens,
reasoning_tokens=reasoning_tokens,
)

View File

@@ -759,7 +759,6 @@ class Glm47MoeDetector(BaseFormatDetector):
arguments = {}
for arg_key, arg_value in pairs:
arg_key = arg_key.strip()
arg_value = arg_value.strip()
arg_type = get_argument_type(func_name, arg_key, tools)
parsed_value, is_good_json = parse_arguments(arg_value, arg_type)

View File

@@ -613,7 +613,6 @@ class Glm4MoeDetector(BaseFormatDetector):
arguments = {}
for arg_key, arg_value in pairs:
arg_key = arg_key.strip()
arg_value = arg_value.strip()
arg_type = get_argument_type(func_name, arg_key, tools)
parsed_value, is_good_json = parse_arguments(arg_value, arg_type)

View File

@@ -121,6 +121,11 @@ asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
_REQUEST_STATE_WAIT_TIMEOUT = envs.SGLANG_REQUEST_STATE_WAIT_TIMEOUT.get()
class PayloadTooLargeError(Exception):
"""Exception raised when a request payload exceeds the model context length."""
logger = logging.getLogger(__name__)
@@ -806,10 +811,16 @@ class TokenizerManager(TokenizerCommunicatorMixin, TokenizerManagerMultiItemMixi
del input_ids[_max_req_len:]
input_token_num = len(input_ids)
else:
raise ValueError(
error_msg = (
f"The input ({input_token_num} tokens) is longer than the "
f"model's context length ({self.context_len} tokens)."
)
if (
getattr(self.server_args, "openai_glm_compat", False)
and "glm" in self.model_path.lower()
):
raise PayloadTooLargeError(error_msg)
raise ValueError(error_msg)
# Validate total tokens (input + max_new_tokens)
max_new_tokens = obj.sampling_params.get("max_new_tokens")
@@ -836,6 +847,11 @@ class TokenizerManager(TokenizerCommunicatorMixin, TokenizerManagerMultiItemMixi
f"{max_new_tokens} tokens for the completion. Please reduce the number "
f"of tokens in the input messages or the completion to fit within the limit."
)
if (
getattr(self.server_args, "openai_glm_compat", False)
and "glm" in self.model_path.lower()
):
raise PayloadTooLargeError(error_msg)
raise ValueError(error_msg)
# Validate embedding requests

View File

@@ -454,6 +454,12 @@ class ServerArgs:
tool_call_parser: Optional[str] = None
tool_server: Optional[str] = None
sampling_defaults: str = "model"
openai_chat_default_max_tokens: int = 0
openai_force_tool_choice_auto: bool = False
openai_kimi_compat: bool = False
openai_glm_compat: bool = False
openai_flatten_tool_role_text_content: bool = False
openai_streaming_error_preflight: bool = False
# Data parallelism
dp_size: int = 1
@@ -4688,6 +4694,66 @@ class ServerArgs:
"'model' uses the model's generation_config.json to get the recommended "
"sampling parameters if available. Default is 'model'.",
)
parser.add_argument(
"--openai-chat-default-max-tokens",
type=int,
default=ServerArgs.openai_chat_default_max_tokens,
help=(
"Default max_tokens applied to OpenAI chat requests that omit both "
"max_tokens and max_completion_tokens. Set to 0 or a negative value "
"to preserve the upstream unset default."
),
)
parser.add_argument(
"--openai-force-tool-choice-auto",
action="store_true",
dest="openai_force_tool_choice_auto",
default=ServerArgs.openai_force_tool_choice_auto,
help=(
"Enable Para-compatible normalization that serves explicit "
"non-auto chat tool_choice values as 'auto'."
),
)
parser.add_argument(
"--openai-kimi-compat",
action="store_true",
dest="openai_kimi_compat",
default=ServerArgs.openai_kimi_compat,
help=(
"Enable Para-compatible Kimi handling, including thinking field "
"mapping and fixed Kimi sampling parameter enforcement."
),
)
parser.add_argument(
"--openai-glm-compat",
action="store_true",
dest="openai_glm_compat",
default=ServerArgs.openai_glm_compat,
help=(
"Enable Para-compatible GLM handling, including required tool_choice "
"downgrade, 413 context overflow mapping, and GLM usage details."
),
)
parser.add_argument(
"--openai-flatten-tool-role-text-content",
action="store_true",
dest="openai_flatten_tool_role_text_content",
default=ServerArgs.openai_flatten_tool_role_text_content,
help=(
"Enable Para-compatible flattening of pure text content-part lists "
"on OpenAI tool-role messages before chat-template rendering."
),
)
parser.add_argument(
"--openai-streaming-error-preflight",
action="store_true",
dest="openai_streaming_error_preflight",
default=ServerArgs.openai_streaming_error_preflight,
help=(
"Enable prefetching the first OpenAI streaming chunk to convert "
"pre-stream errors into normal HTTP error responses."
),
)
# Data parallelism
parser.add_argument(

View File

@@ -0,0 +1,735 @@
import asyncio
import sys
import types
import unittest
from pathlib import Path
from types import SimpleNamespace
from pydantic import BaseModel
def _install_openai_response_stubs():
responses_mod = types.ModuleType("openai.types.responses")
response_mod = types.ModuleType("openai.types.responses.response")
tool_mod = types.ModuleType("openai.types.responses.tool")
class _OpenAIStubModel(BaseModel):
pass
for name in (
"ResponseFunctionToolCall",
"ResponseInputItemParam",
"ResponseOutputItem",
"ResponseOutputMessage",
"ResponseOutputText",
"ResponseReasoningItem",
):
setattr(responses_mod, name, type(name, (_OpenAIStubModel,), {}))
response_mod.ToolChoice = type("ToolChoice", (_OpenAIStubModel,), {})
tool_mod.Tool = type("Tool", (_OpenAIStubModel,), {})
sys.modules.setdefault("openai", types.ModuleType("openai"))
sys.modules.setdefault("openai.types", types.ModuleType("openai.types"))
sys.modules["openai.types.responses"] = responses_mod
sys.modules["openai.types.responses.response"] = response_mod
sys.modules["openai.types.responses.tool"] = tool_mod
_install_openai_response_stubs()
from sglang.srt.entrypoints.openai.protocol import ChatCompletionRequest
from sglang.srt.entrypoints.openai.usage_processor import UsageProcessor
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=1, suite="stage-a-test-cpu")
_REPO_ROOT = Path(__file__).resolve().parents[5]
class TestParaChatDefaults(unittest.TestCase):
def test_chat_protocol_leaves_omitted_max_tokens_unset(self):
request = ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "hello"}],
)
self.assertIsNone(request.max_tokens)
def test_serving_applies_configured_para_default_max_tokens(self):
_install_serving_chat_stubs()
from sglang.srt.entrypoints.openai.serving_chat import OpenAIServingChat
request = ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "hello"}],
)
serving = OpenAIServingChat.__new__(OpenAIServingChat)
serving.tokenizer_manager = SimpleNamespace(
server_args=SimpleNamespace(openai_chat_default_max_tokens=32768)
)
serving._apply_openai_serving_defaults(request)
self.assertEqual(request.max_tokens, 32768)
sampling_params = request.to_sampling_params(stop=[], model_generation_config={})
self.assertEqual(sampling_params["max_new_tokens"], 32768)
def test_serving_can_disable_para_default_max_tokens(self):
_install_serving_chat_stubs()
from sglang.srt.entrypoints.openai.serving_chat import OpenAIServingChat
request = ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "hello"}],
)
serving = OpenAIServingChat.__new__(OpenAIServingChat)
serving.tokenizer_manager = SimpleNamespace(
server_args=SimpleNamespace(openai_chat_default_max_tokens=0)
)
serving._apply_openai_serving_defaults(request)
self.assertIsNone(request.max_tokens)
def _install_serving_chat_stubs():
"""Install lightweight stubs for optional serving dependencies.
The Para alignment tests run on macOS in a small uv virtualenv. They exercise
request/serving glue without requiring the full SGLang server dependency set.
"""
import json
orjson_mod = types.ModuleType("orjson")
orjson_mod.loads = json.loads
orjson_mod.dumps = lambda obj, **_: json.dumps(obj).encode()
sys.modules["orjson"] = orjson_mod
fastapi_mod = types.ModuleType("fastapi")
fastapi_mod.Request = type("Request", (), {})
fastapi_mod.HTTPException = type("HTTPException", (Exception,), {})
responses_mod = types.ModuleType("fastapi.responses")
class ORJSONResponse:
def __init__(self, content=None, status_code=200, **kwargs):
self.content = content
self.status_code = status_code
self.kwargs = kwargs
class StreamingResponse:
def __init__(self, content=None, media_type=None, background=None, **kwargs):
self.content = content
self.media_type = media_type
self.background = background
self.kwargs = kwargs
responses_mod.ORJSONResponse = ORJSONResponse
responses_mod.StreamingResponse = StreamingResponse
sys.modules["fastapi"] = fastapi_mod
sys.modules["fastapi.responses"] = responses_mod
jsonschema_mod = types.ModuleType("jsonschema")
class Draft202012Validator:
@staticmethod
def check_schema(_schema):
return None
jsonschema_mod.Draft202012Validator = Draft202012Validator
jsonschema_mod.SchemaError = type("SchemaError", (Exception,), {})
sys.modules["jsonschema"] = jsonschema_mod
serving_base_mod = types.ModuleType("sglang.srt.entrypoints.openai.serving_base")
class OpenAIServingBase:
def __init__(self, tokenizer_manager):
self.tokenizer_manager = tokenizer_manager
def create_error_response(
self, message, err_type="BadRequestError", status_code=400, param=None
):
return ORJSONResponse(
content={
"message": message,
"type": err_type,
"param": param,
"code": status_code,
},
status_code=status_code,
)
def create_streaming_error_response(self, message):
return json.dumps({"error": {"message": message}})
def create_error_response_from_first_streaming_chunk(self, first_chunk):
if not isinstance(first_chunk, str):
return None
first_chunk = first_chunk.strip()
if not first_chunk.startswith("data:"):
return None
data = first_chunk[len("data:") :].strip()
if data == "[DONE]":
return None
try:
payload = json.loads(data)
except json.JSONDecodeError:
return None
error = payload.get("error") if isinstance(payload, dict) else None
if not isinstance(error, dict):
return None
status_code = (
error.get("code")
or error.get("status")
or error.get("status_code")
or 500
)
return self.create_error_response(
message=error.get(
"message", "Streaming request failed before first chunk."
),
err_type=error.get("type", "InternalServerError"),
status_code=status_code if isinstance(status_code, int) else 500,
param=error.get("param"),
)
def extract_custom_labels(self, _raw_request):
return None
def extract_routed_dp_rank_from_header(self, _raw_request, routed_dp_rank):
return routed_dp_rank
serving_base_mod.OpenAIServingBase = OpenAIServingBase
sys.modules["sglang.srt.entrypoints.openai.serving_base"] = serving_base_mod
io_struct_mod = types.ModuleType("sglang.srt.managers.io_struct")
class GenerateReqInput:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
io_struct_mod.GenerateReqInput = GenerateReqInput
io_struct_mod.EmbeddingReqInput = type("EmbeddingReqInput", (), {})
sys.modules["sglang.srt.managers.io_struct"] = io_struct_mod
tokenizer_manager_mod = types.ModuleType("sglang.srt.managers.tokenizer_manager")
tokenizer_manager_mod.PayloadTooLargeError = type(
"PayloadTooLargeError", (Exception,), {}
)
sys.modules["sglang.srt.managers.tokenizer_manager"] = tokenizer_manager_mod
encoding_mod = types.ModuleType("sglang.srt.entrypoints.openai.encoding_dsv32")
encoding_mod.encode_messages = lambda messages, thinking_mode="chat": str(
(messages, thinking_mode)
)
encoding_mod.DS32EncodingError = type("DS32EncodingError", (Exception,), {})
sys.modules["sglang.srt.entrypoints.openai.encoding_dsv32"] = encoding_mod
utils_mod = types.ModuleType("sglang.srt.entrypoints.openai.utils")
utils_mod.process_cached_tokens_details_from_ret = lambda *_args, **_kwargs: None
utils_mod.process_hidden_states_from_ret = lambda *_args, **_kwargs: None
utils_mod.process_routed_experts_from_ret = lambda *_args, **_kwargs: None
utils_mod.to_openai_style_logprobs = lambda *_args, **_kwargs: None
sys.modules["sglang.srt.entrypoints.openai.utils"] = utils_mod
core_types_mod = types.ModuleType("sglang.srt.function_call.core_types")
core_types_mod.ToolCallItem = type("ToolCallItem", (), {})
sys.modules["sglang.srt.function_call.core_types"] = core_types_mod
function_call_parser_mod = types.ModuleType(
"sglang.srt.function_call.function_call_parser"
)
function_call_parser_mod.FunctionCallParser = type("FunctionCallParser", (), {})
sys.modules[
"sglang.srt.function_call.function_call_parser"
] = function_call_parser_mod
json_array_parser_mod = types.ModuleType(
"sglang.srt.function_call.json_array_parser"
)
json_array_parser_mod.JsonArrayParser = type("JsonArrayParser", (), {})
sys.modules["sglang.srt.function_call.json_array_parser"] = json_array_parser_mod
function_utils_mod = types.ModuleType("sglang.srt.function_call.utils")
function_utils_mod.get_json_schema_constraint = lambda *_args, **_kwargs: None
sys.modules["sglang.srt.function_call.utils"] = function_utils_mod
conversation_mod = types.ModuleType("sglang.srt.parser.conversation")
conversation_mod.generate_chat_conv = lambda *_args, **_kwargs: None
sys.modules["sglang.srt.parser.conversation"] = conversation_mod
jinja_utils_mod = types.ModuleType("sglang.srt.parser.jinja_template_utils")
jinja_utils_mod.process_content_for_template_format = (
lambda msg, *_args, **_kwargs: msg
)
sys.modules["sglang.srt.parser.jinja_template_utils"] = jinja_utils_mod
reasoning_mod = types.ModuleType("sglang.srt.parser.reasoning_parser")
reasoning_mod.ReasoningParser = type("ReasoningParser", (), {})
sys.modules["sglang.srt.parser.reasoning_parser"] = reasoning_mod
def _install_actual_serving_base_stubs():
_install_serving_chat_stubs()
req_time_stats_mod = types.ModuleType(
"sglang.srt.observability.req_time_stats"
)
req_time_stats_mod.monotonic_time = lambda: 0.0
sys.modules["sglang.srt.observability.req_time_stats"] = req_time_stats_mod
server_args_mod = types.ModuleType("sglang.srt.server_args")
server_args_mod.ServerArgs = type("ServerArgs", (), {})
sys.modules["sglang.srt.server_args"] = server_args_mod
sys.modules.pop("sglang.srt.entrypoints.openai.serving_base", None)
def _install_function_call_stubs():
import json
from dataclasses import dataclass
orjson_mod = types.ModuleType("orjson")
orjson_mod.loads = json.loads
orjson_mod.dumps = lambda obj, **_: json.dumps(obj).encode()
sys.modules["orjson"] = orjson_mod
partial_json_mod = types.ModuleType("partial_json_parser")
partial_json_mod.loads = lambda data, _flags=None: json.loads(data)
sys.modules["partial_json_parser"] = partial_json_mod
exceptions_mod = types.ModuleType("partial_json_parser.core.exceptions")
exceptions_mod.MalformedJSON = type("MalformedJSON", (Exception,), {})
sys.modules["partial_json_parser.core.exceptions"] = exceptions_mod
options_mod = types.ModuleType("partial_json_parser.core.options")
class Allow:
STR = 1
OBJ = 2
ARR = 4
ALL = STR | OBJ | ARR
options_mod.Allow = Allow
sys.modules["partial_json_parser.core.options"] = options_mod
core_types_mod = types.ModuleType("sglang.srt.function_call.core_types")
class ToolCallItem(BaseModel):
tool_index: int
name: str | None = None
parameters: str
class StreamingParseResult(BaseModel):
normal_text: str = ""
calls: list[ToolCallItem] = []
@dataclass
class StructureInfo:
begin: str
end: str
trigger: str
core_types_mod.ToolCallItem = ToolCallItem
core_types_mod.StreamingParseResult = StreamingParseResult
core_types_mod.StructureInfo = StructureInfo
core_types_mod._GetInfoFunc = object
sys.modules["sglang.srt.function_call.core_types"] = core_types_mod
function_utils_mod = types.ModuleType("sglang.srt.function_call.utils")
function_utils_mod._find_common_prefix = lambda left, right: ""
function_utils_mod._is_complete_json = lambda data: True
function_utils_mod._partial_json_loads = (
lambda data, _flags=None: (json.loads(data), len(data))
)
function_utils_mod.infer_type_from_json_schema = (
lambda schema: schema.get("type") if isinstance(schema, dict) else None
)
function_utils_mod.get_json_schema_constraint = lambda *_args, **_kwargs: None
sys.modules["sglang.srt.function_call.utils"] = function_utils_mod
class TestParaKimiAlignment(unittest.TestCase):
def test_chat_accepts_thinking_and_fixed_sampling_overrides_win(self):
request = ChatCompletionRequest(
model="kimi-test-model",
messages=[{"role": "user", "content": "hello"}],
thinking={"type": "disabled"},
top_p=0.1,
n=2,
)
self.assertEqual(request.thinking, {"type": "disabled"})
sampling_params = request.to_sampling_params(
stop=[],
model_generation_config={"top_p": 0.7},
fixed_sampling_overrides={"top_p": 0.95, "n": 1},
)
self.assertEqual(sampling_params["top_p"], 0.95)
self.assertEqual(sampling_params["n"], 1)
def test_kimi_thinking_maps_to_chat_template_kwargs(self):
_install_serving_chat_stubs()
from sglang.srt.entrypoints.openai.serving_chat import OpenAIServingChat
captured = {}
class FakeTokenizer:
def apply_chat_template(self, messages, **kwargs):
captured["messages"] = messages
captured["kwargs"] = kwargs
return [1, 2, 3]
serving = OpenAIServingChat.__new__(OpenAIServingChat)
serving.is_kimi = True
serving.use_dpsk_v32_encoding = False
serving.template_manager = SimpleNamespace(
jinja_template_content_format="openai"
)
serving.tokenizer_manager = SimpleNamespace(tokenizer=FakeTokenizer())
request = ChatCompletionRequest(
model="kimi-test-model",
messages=[{"role": "user", "content": "hello"}],
thinking={"type": "disabled"},
)
serving._apply_jinja_template(request, tools=None, is_multimodal=False)
self.assertEqual(request.chat_template_kwargs, {"thinking": False})
self.assertEqual(captured["kwargs"]["thinking"], False)
class TestParaGlmAlignment(unittest.TestCase):
def test_serving_chat_detects_glm_model_path(self):
_install_serving_chat_stubs()
from sglang.srt.entrypoints.openai.serving_chat import OpenAIServingChat
tokenizer_manager = SimpleNamespace(
server_args=SimpleNamespace(
tool_call_parser=None,
reasoning_parser=None,
model_path="/models/GLM-4.5",
openai_glm_compat=True,
openai_kimi_compat=False,
),
model_config=SimpleNamespace(
get_default_sampling_params=lambda: {},
hf_config=SimpleNamespace(model_type="glm", architectures=[]),
),
tokenizer=SimpleNamespace(chat_template="template"),
)
serving = OpenAIServingChat(
tokenizer_manager,
template_manager=SimpleNamespace(jinja_template_content_format="openai"),
)
self.assertTrue(serving.is_glm)
self.assertFalse(serving.is_kimi)
def test_glm_required_tool_choice_is_downgraded_to_auto(self):
_install_serving_chat_stubs()
from sglang.srt.entrypoints.openai.serving_chat import OpenAIServingChat
serving = OpenAIServingChat.__new__(OpenAIServingChat)
serving.is_glm = True
serving.is_kimi = False
serving.tokenizer_manager = SimpleNamespace(
server_args=SimpleNamespace(
context_length=65536,
allow_auto_truncate=False,
openai_force_tool_choice_auto=False,
openai_chat_default_max_tokens=32768,
)
)
request = ChatCompletionRequest(
model="glm-test-model",
messages=[{"role": "user", "content": "hello"}],
tools=[
{
"type": "function",
"function": {"name": "lookup", "parameters": {"type": "object"}},
}
],
tool_choice="required",
)
error = serving._validate_request(request)
self.assertIsNone(error)
self.assertEqual(request.tool_choice, "auto")
class TestParaToolChoiceAlignment(unittest.TestCase):
def test_protocol_preserves_explicit_non_auto_tool_choice(self):
required_request = ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "hello"}],
tools=[
{
"type": "function",
"function": {"name": "lookup", "parameters": {"type": "object"}},
}
],
tool_choice="required",
)
none_request = ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "hello"}],
tool_choice="none",
)
self.assertEqual(required_request.tool_choice, "required")
self.assertEqual(none_request.tool_choice, "none")
def test_serving_arg_coerces_explicit_non_auto_tool_choice_to_auto(self):
_install_serving_chat_stubs()
from sglang.srt.entrypoints.openai.serving_chat import OpenAIServingChat
serving = OpenAIServingChat.__new__(OpenAIServingChat)
serving.is_glm = False
serving.is_kimi = False
serving.tokenizer_manager = SimpleNamespace(
server_args=SimpleNamespace(
context_length=65536,
allow_auto_truncate=False,
openai_force_tool_choice_auto=True,
openai_chat_default_max_tokens=32768,
)
)
request = ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "hello"}],
tool_choice="none",
)
error = serving._validate_request(request)
self.assertIsNone(error)
self.assertEqual(request.tool_choice, "auto")
class TestParaToolCallAlignment(unittest.TestCase):
def test_tool_role_text_content_parts_are_flattened_for_chat_templates(self):
_install_serving_chat_stubs()
from sglang.srt.entrypoints.openai.serving_chat import OpenAIServingChat
captured = {}
class FakeTokenizer:
def apply_chat_template(self, messages, **kwargs):
captured["messages"] = messages
captured["kwargs"] = kwargs
return [1, 2, 3]
serving = OpenAIServingChat.__new__(OpenAIServingChat)
serving.is_kimi = False
serving.use_dpsk_v32_encoding = False
serving.template_manager = SimpleNamespace(
jinja_template_content_format="openai"
)
serving.tokenizer_manager = SimpleNamespace(
tokenizer=FakeTokenizer(),
server_args=SimpleNamespace(openai_flatten_tool_role_text_content=True),
)
request = ChatCompletionRequest(
model="test-model",
messages=[
{
"role": "tool",
"tool_call_id": "call-1",
"content": [
{"type": "text", "text": "first line"},
{"type": "text", "text": "second line"},
],
}
],
)
serving._apply_jinja_template(request, tools=None, is_multimodal=False)
self.assertEqual(
captured["messages"][0]["content"],
"first line\nsecond line",
)
def test_glm_detectors_preserve_argument_value_whitespace(self):
_install_function_call_stubs()
from sglang.srt.function_call.glm4_moe_detector import Glm4MoeDetector
from sglang.srt.function_call.glm47_moe_detector import Glm47MoeDetector
for detector_cls in (Glm4MoeDetector, Glm47MoeDetector):
with self.subTest(detector=detector_cls.__name__):
arguments = detector_cls()._parse_argument_pairs(
[(" payload ", " keep surrounding spaces ")],
func_name="missing_tool",
tools=[],
)
self.assertEqual(
arguments["payload"],
" keep surrounding spaces ",
)
class TestParaImageUrlAlignment(unittest.TestCase):
def test_image_url_string_is_coerced_to_url_object(self):
request = ChatCompletionRequest(
model="test-model",
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": "https://example.test/image.png",
}
],
}
],
)
image_part = request.messages[0].content[0]
self.assertEqual(image_part.image_url.url, "https://example.test/image.png")
class TestParaPayloadTooLargeAlignment(unittest.TestCase):
def test_tokenizer_manager_raises_payload_too_large_for_glm_input_overflow(self):
source = (
_REPO_ROOT / "python/sglang/srt/managers/tokenizer_manager.py"
).read_text()
self.assertIn("class PayloadTooLargeError(Exception):", source)
self.assertIn('getattr(self.server_args, "openai_glm_compat", False)', source)
self.assertIn('"glm" in self.model_path.lower()', source)
self.assertIn("raise PayloadTooLargeError(error_msg)", source)
def test_serving_base_maps_payload_too_large_to_http_413(self):
source = (
_REPO_ROOT / "python/sglang/srt/entrypoints/openai/serving_base.py"
).read_text()
self.assertIn(
"from sglang.srt.managers.tokenizer_manager import PayloadTooLargeError",
source,
)
self.assertIn("except PayloadTooLargeError as e:", source)
self.assertIn('err_type="PayloadTooLargeError"', source)
self.assertIn("status_code=413", source)
class TestParaStreamingErrorAlignment(unittest.TestCase):
def test_serving_base_builds_http_error_from_first_streaming_chunk(self):
_install_actual_serving_base_stubs()
from sglang.srt.entrypoints.openai.serving_base import OpenAIServingBase
class ConcreteServingBase(OpenAIServingBase):
def _request_id_prefix(self):
return "test-"
def _convert_to_internal_request(self, request, raw_request=None):
return request, request
serving = ConcreteServingBase.__new__(ConcreteServingBase)
response = serving.create_error_response_from_first_streaming_chunk(
'data: {"error": {"message": "too large", "type": '
'"PayloadTooLargeError", "code": 413, "param": "messages"}}\n\n'
)
self.assertEqual(response.status_code, 413)
self.assertEqual(response.content["message"], "too large")
self.assertEqual(response.content["type"], "PayloadTooLargeError")
self.assertEqual(response.content["param"], "messages")
def test_chat_streaming_prefetch_returns_http_error_before_sse(self):
_install_serving_chat_stubs()
from sglang.srt.entrypoints.openai.serving_chat import OpenAIServingChat
async def error_generator():
yield (
'data: {"error": {"message": "too large", "type": '
'"PayloadTooLargeError", "code": 413}}\n\n'
)
serving = OpenAIServingChat.__new__(OpenAIServingChat)
serving.tokenizer_manager = SimpleNamespace(
server_args=SimpleNamespace(openai_streaming_error_preflight=True),
create_abort_task=lambda _adapted_request: None
)
serving._generate_chat_stream = (
lambda adapted_request, request, raw_request: error_generator()
)
response = asyncio.run(
serving._handle_streaming_request(
adapted_request=SimpleNamespace(),
request=ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "hello"}],
stream=True,
),
raw_request=None,
)
)
self.assertEqual(response.status_code, 413)
self.assertEqual(response.content["message"], "too large")
class TestParaUsageAlignment(unittest.TestCase):
def test_usage_processor_aggregates_reasoning_tokens(self):
usage = UsageProcessor.calculate_response_usage(
[
{
"meta_info": {
"prompt_tokens": 10,
"completion_tokens": 3,
"reasoning_tokens": 2,
}
},
{
"meta_info": {
"prompt_tokens": 10,
"completion_tokens": 4,
"reasoning_tokens": 5,
}
},
],
n_choices=2,
)
self.assertEqual(usage.prompt_tokens, 10)
self.assertEqual(usage.completion_tokens, 7)
self.assertEqual(usage.reasoning_tokens, 7)
def test_usage_processor_reports_glm_completion_token_details(self):
usage = UsageProcessor.calculate_streaming_usage(
prompt_tokens={0: 10, 1: 10},
completion_tokens={0: 3, 1: 4},
reasoning_tokens={0: 2, 1: 5},
cached_tokens={},
n_choices=2,
use_completion_details=True,
)
self.assertEqual(usage.prompt_tokens, 10)
self.assertEqual(usage.completion_tokens, 7)
self.assertIsNotNone(usage.completion_tokens_details)
self.assertEqual(usage.completion_tokens_details.reasoning_tokens, 7)
def test_chat_serving_passes_glm_completion_detail_flag(self):
source = (
_REPO_ROOT / "python/sglang/srt/entrypoints/openai/serving_chat.py"
).read_text()
self.assertIn("reasoning_tokens = {}", source)
self.assertIn('reasoning_tokens[index] = content["meta_info"].get(', source)
self.assertGreaterEqual(source.count("use_completion_details=self.is_glm"), 3)
if __name__ == "__main__":
unittest.main(verbosity=2)