Files
sglang/python/sglang/srt/entrypoints/openai/serving_base.py
leavelet 75d7d8772e Unify over-length errors into the PayloadTooLargeError 413 format
Over-long inputs produced two different client errors depending on
which bound rejected them: the TokenizerManager pre-check (raw
context_len) returned 413 PayloadTooLargeError ('The input (N tokens)
is longer than the model's context length (M tokens).'), while inputs
between that and the scheduler's stricter effective limit hit
validate_input_length and returned 400 BAD_REQUEST with different
wording (and a confusing 'X exceeds X' message since the check is >=).

Unify on the 413 format end to end:
- validate_input_length wording now matches the TokenizerManager
  message, reporting the effective per-request limit.
- set_finish_with_abort takes status_code/err_type; the scheduler
  length-rejection sites abort with REQUEST_ENTITY_TOO_LARGE +
  PayloadTooLargeError. The batch handler previously queued the
  over-long request WITHOUT marking it aborted (it proceeded to
  prefill) — also fixed.
- Non-streaming aborts with 413 raise PayloadTooLargeError (now a
  ValueError subclass so raw /generate-style endpoints that only
  catch ValueError still respond; the OpenAI layer's except clause
  is reordered to win and emit the 413 format).
- Streaming abort responses prefer the scheduler-provided err_type
  over the HTTPStatus name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:01:02 +00:00

379 lines
14 KiB
Python

from __future__ import annotations
import json
import logging
import uuid
from abc import ABC, abstractmethod
from http import HTTPStatus
from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union
import orjson
from fastapi import HTTPException, Request
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
if TYPE_CHECKING:
from sglang.srt.managers.tokenizer_manager import TokenizerManager
logger = logging.getLogger(__name__)
# Base class for specific endpoint handlers
class OpenAIServingBase(ABC):
"""Abstract base class for OpenAI endpoint handlers"""
def __init__(self, tokenizer_manager: TokenizerManager):
self.tokenizer_manager = tokenizer_manager
self.allowed_custom_labels = (
set(
self.tokenizer_manager.server_args.tokenizer_metrics_allowed_custom_labels
)
if isinstance(self.tokenizer_manager.server_args, ServerArgs)
and self.tokenizer_manager.server_args.tokenizer_metrics_allowed_custom_labels
else None
)
@staticmethod
def _streaming_http_error_type_and_status(status_code) -> tuple[str, int]:
if isinstance(status_code, HTTPStatus):
return status_code.name, status_code.value
try:
status = HTTPStatus(int(status_code))
return status.name, status.value
except Exception:
logger.warning(
"Invalid streaming abort status_code=%r; using INTERNAL_SERVER_ERROR",
status_code,
)
return (
HTTPStatus.INTERNAL_SERVER_ERROR.name,
HTTPStatus.INTERNAL_SERVER_ERROR.value,
)
def _parse_model_parameter(self, model: str) -> Tuple[str, Optional[str]]:
"""Parse 'base-model:adapter-name' syntax to extract LoRA adapter.
Returns (base_model, adapter_name) or (model, None) if no colon present.
"""
if ":" not in model:
return model, None
# Split on first colon only to handle model paths with multiple colons
parts = model.split(":", 1)
base_model = parts[0].strip()
adapter_name = parts[1].strip() or None
return base_model, adapter_name
def _resolve_lora_path(
self,
request_model: str,
explicit_lora_path: Optional[Union[str, List[Optional[str]]]],
) -> Optional[Union[str, List[Optional[str]]]]:
"""Resolve LoRA adapter with priority: model parameter > explicit lora_path.
Returns adapter name or None. Supports both single values and lists (batches).
"""
_, adapter_from_model = self._parse_model_parameter(request_model)
# Model parameter adapter takes precedence
if adapter_from_model is not None:
return adapter_from_model
# Fall back to explicit lora_path
return explicit_lora_path
async def handle_request(
self, request: OpenAIServingRequest, raw_request: Request
) -> Union[Any, StreamingResponse, ErrorResponse]:
"""Handle the specific request type with common pattern
If you want to override this method, you should be careful to record the validation time.
"""
received_time = monotonic_time()
try:
# Validate request
error_msg = self._validate_request(request)
if error_msg:
return self.create_error_response(error_msg)
# Log the raw OpenAI request payload before conversion to tokenized form.
request_logger = self.tokenizer_manager.request_logger
if request_logger.log_requests and request_logger.log_requests_level >= 2:
request_logger.log_openai_received_request(request, request=raw_request)
# Convert to internal format
adapted_request, processed_request = self._convert_to_internal_request(
request, raw_request
)
if isinstance(adapted_request, (GenerateReqInput, EmbeddingReqInput)):
# Only set timing fields if adapted_request supports them
adapted_request.received_time = received_time
# Note(Xinyuan): raw_request below is only used for detecting the connection of the client
if hasattr(request, "stream") and request.stream:
return await self._handle_streaming_request(
adapted_request, processed_request, raw_request
)
else:
return await self._handle_non_streaming_request(
adapted_request, processed_request, raw_request
)
except HTTPException as e:
return self.create_error_response(
message=e.detail, err_type=str(e.status_code), status_code=e.status_code
)
except PayloadTooLargeError as e:
# Must precede ValueError: PayloadTooLargeError subclasses it.
return self.create_error_response(
message=str(e),
err_type="PayloadTooLargeError",
status_code=413,
)
except ValueError as e:
return self.create_error_response(
message=str(e),
err_type="BadRequest",
status_code=400,
)
except DS32EncodingError as e:
logger.info(f"DS32EncodingError: {e}")
return self.create_error_response(
message=str(e),
err_type="BadRequest",
status_code=400,
)
except Exception as e:
logger.exception(f"Error in request: {e}")
return self.create_error_response(
message=f"Internal server error: {str(e)}",
err_type="InternalServerError",
status_code=500,
)
@abstractmethod
def _request_id_prefix(self) -> str:
"""Generate request ID based on request type"""
pass
def _generate_request_id_base(self, request: OpenAIServingRequest) -> Optional[str]:
"""Generate request ID based on request type"""
return None
# TODO(chang): the rid is used in io_strcut check and often violates `The rid should be a list` AssertionError
# Temporarily return None in this function until the rid logic is clear.
if rid := getattr(request, "rid", None):
return rid
return f"{self._request_id_prefix()}{uuid.uuid4().hex}"
def _compute_extra_key(self, request: OpenAIServingRequest) -> Optional[str]:
"""Compute the final extra_key by concatenating cache_salt and extra_key if both are provided."""
parts = []
for key in ["cache_salt", "extra_key"]:
value = getattr(request, key, None)
if value:
if not isinstance(value, str):
raise TypeError(
f"Value of {key} must be a string, but got {type(value).__name__}"
)
parts.append(value)
return "".join(parts) if parts else None
@abstractmethod
def _convert_to_internal_request(
self,
request: OpenAIServingRequest,
raw_request: Request = None,
) -> tuple[GenerateReqInput, OpenAIServingRequest]:
"""Convert OpenAI request to internal format"""
pass
async def _handle_streaming_request(
self,
adapted_request: GenerateReqInput,
request: OpenAIServingRequest,
raw_request: Request,
) -> Union[StreamingResponse, ErrorResponse, ORJSONResponse]:
"""Handle streaming request
Override this method in child classes that support streaming requests.
"""
return self.create_error_response(
message=f"{self.__class__.__name__} does not support streaming requests",
err_type="NotImplementedError",
status_code=501,
)
async def _handle_non_streaming_request(
self,
adapted_request: GenerateReqInput,
request: OpenAIServingRequest,
raw_request: Request,
) -> Union[Any, ErrorResponse, ORJSONResponse]:
"""Handle non-streaming request
Override this method in child classes that support non-streaming requests.
"""
return self.create_error_response(
message=f"{self.__class__.__name__} does not support non-streaming requests",
err_type="NotImplementedError",
status_code=501,
)
def _validate_request(self, _: OpenAIServingRequest) -> Optional[str]:
"""Validate request"""
pass
def create_error_response(
self,
message: str,
err_type: str = "BadRequestError",
status_code: int = 400,
param: Optional[str] = None,
) -> ORJSONResponse:
"""Create an error response"""
# TODO: remove fastapi dependency in openai and move response handling to the entrypoint
error = ErrorResponse(
object="error",
message=message,
type=err_type,
param=param,
code=status_code,
)
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,
err_type: str = "BadRequestError",
status_code: int = 400,
) -> str:
"""Create a streaming error response"""
error = ErrorResponse(
object="error",
message=message,
type=err_type,
param=None,
code=status_code,
)
return json.dumps({"error": error.model_dump()})
def extract_custom_labels(self, raw_request):
if (
not self.allowed_custom_labels
or not self.tokenizer_manager.server_args.tokenizer_metrics_custom_labels_header
):
return None
custom_labels = None
header = (
self.tokenizer_manager.server_args.tokenizer_metrics_custom_labels_header
)
try:
raw_labels = (
orjson.loads(raw_request.headers.get(header))
if raw_request and raw_request.headers.get(header)
else None
)
except json.JSONDecodeError as e:
logger.exception(f"Error in request: {e}")
raw_labels = None
if isinstance(raw_labels, dict):
custom_labels = {
label: value
for label, value in raw_labels.items()
if label in self.allowed_custom_labels
}
return custom_labels
def extract_routing_key(self, raw_request):
if raw_request is None:
return None
return raw_request.headers.get("x-smg-routing-key")
def extract_routed_dp_rank_from_header(
self, raw_request: Request, body_routed_dp_rank: Optional[int] = None
) -> Optional[int]:
"""Extract routed_dp_rank from HTTP header, with higher priority than routed_dp_rank in body.
Header name: X-Data-Parallel-Rank (case-insensitive in HTTP/1.1/2)
"""
if raw_request is None:
return body_routed_dp_rank
header_value = raw_request.headers.get("x-data-parallel-rank")
if header_value is not None:
try:
header_dp_rank = int(header_value)
if (
body_routed_dp_rank is not None
and header_dp_rank != body_routed_dp_rank
):
logger.debug(
f"X-Data-Parallel-Rank header ({header_dp_rank}) overrides "
f"body routed_dp_rank ({body_routed_dp_rank})"
)
return header_dp_rank
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid X-Data-Parallel-Rank header: must be an integer, got '{header_value}'",
)
return body_routed_dp_rank