Support v1/responses and use harmony in serving_chat (#8837)
Signed-off-by: Xinyuan Tong <justinning0323@outlook.com> Signed-off-by: Xinyuan Tong <xinyuantong.cs@gmail.com> Co-authored-by: Xinyuan Tong <justinning0323@outlook.com> Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
This commit is contained in:
co-authored by
Xinyuan Tong
Xinyuan Tong
parent
cbbd685a46
commit
92cc32d9fc
@@ -14,9 +14,18 @@
|
||||
"""Pydantic models for OpenAI API protocol"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Dict, List, Optional, TypeAlias, Union
|
||||
|
||||
from openai.types.responses import (
|
||||
ResponseFunctionToolCall,
|
||||
ResponseInputItemParam,
|
||||
ResponseOutputItem,
|
||||
ResponseReasoningItem,
|
||||
)
|
||||
from openai.types.responses.response import ToolChoice
|
||||
from openai.types.responses.tool import Tool
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field,
|
||||
@@ -84,6 +93,7 @@ class UsageInfo(BaseModel):
|
||||
completion_tokens: Optional[int] = 0
|
||||
# only used to return cached tokens when --enable-cache-report is set
|
||||
prompt_tokens_details: Optional[Dict[str, int]] = None
|
||||
reasoning_tokens: Optional[int] = 0
|
||||
|
||||
|
||||
class StreamOptions(BaseModel):
|
||||
@@ -428,6 +438,13 @@ class ChatCompletionRequest(BaseModel):
|
||||
default="auto", examples=["none"]
|
||||
) # noqa
|
||||
return_hidden_states: bool = False
|
||||
reasoning_effort: Optional[Literal["low", "medium", "high"]] = Field(
|
||||
default="medium",
|
||||
description="Constrains effort on reasoning for reasoning models. "
|
||||
"'low' is the least effort, 'high' is the most effort. Reducing reasoning effort can "
|
||||
"result in faster responses and fewer tokens used on reasoning in a response. "
|
||||
"Currently only supported for OpenAI models.",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
@@ -619,6 +636,196 @@ OpenAIServingRequest = Union[
|
||||
]
|
||||
|
||||
|
||||
# Response API protocol definitions
|
||||
class ResponseReasoningParam(BaseModel):
|
||||
"""Reasoning parameters for responses."""
|
||||
|
||||
effort: Optional[Literal["low", "medium", "high"]] = Field(
|
||||
default="medium",
|
||||
description="Constrains effort on reasoning for reasoning models.",
|
||||
)
|
||||
|
||||
|
||||
class ResponseTool(BaseModel):
|
||||
"""Tool definition for responses."""
|
||||
|
||||
type: Literal["web_search_preview", "code_interpreter"] = Field(
|
||||
description="Type of tool to enable"
|
||||
)
|
||||
|
||||
|
||||
ResponseInputOutputItem: TypeAlias = Union[
|
||||
ResponseInputItemParam,
|
||||
"ResponseReasoningItem",
|
||||
ResponseFunctionToolCall,
|
||||
]
|
||||
|
||||
|
||||
class ResponsesRequest(BaseModel):
|
||||
"""Request body for v1/responses endpoint."""
|
||||
|
||||
# Core OpenAI API fields (ordered by official documentation)
|
||||
background: Optional[bool] = False
|
||||
include: Optional[
|
||||
List[
|
||||
Literal[
|
||||
"code_interpreter_call.outputs",
|
||||
"computer_call_output.output.image_url",
|
||||
"file_search_call.results",
|
||||
"message.input_image.image_url",
|
||||
"message.output_text.logprobs",
|
||||
"reasoning.encrypted_content",
|
||||
]
|
||||
]
|
||||
] = None
|
||||
input: Union[str, List[ResponseInputOutputItem]]
|
||||
instructions: Optional[str] = None
|
||||
max_output_tokens: Optional[int] = None
|
||||
max_tool_calls: Optional[int] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
model: Optional[str] = None # Made optional to match vLLM
|
||||
parallel_tool_calls: Optional[bool] = True
|
||||
previous_response_id: Optional[str] = None
|
||||
reasoning: Optional[ResponseReasoningParam] = None
|
||||
service_tier: Literal["auto", "default", "flex", "scale", "priority"] = "auto"
|
||||
store: Optional[bool] = True
|
||||
stream: Optional[bool] = False
|
||||
temperature: Optional[float] = None
|
||||
tool_choice: Literal["auto", "required", "none"] = "auto"
|
||||
tools: List[ResponseTool] = Field(default_factory=list)
|
||||
top_logprobs: Optional[int] = 0
|
||||
top_p: Optional[float] = None
|
||||
truncation: Optional[Literal["auto", "disabled"]] = "disabled"
|
||||
user: Optional[str] = None
|
||||
|
||||
# Extra SGLang parameters
|
||||
request_id: str = Field(
|
||||
default_factory=lambda: f"resp_{uuid.uuid4().hex}",
|
||||
description="The request_id related to this request. If the caller does not set it, a random uuid will be generated.",
|
||||
)
|
||||
priority: int = Field(default=0, description="Request priority")
|
||||
|
||||
# SGLang-specific sampling parameters
|
||||
frequency_penalty: float = 0.0
|
||||
presence_penalty: float = 0.0
|
||||
stop: Optional[Union[str, List[str]]] = None
|
||||
top_k: int = -1
|
||||
min_p: float = 0.0
|
||||
repetition_penalty: float = 1.0
|
||||
|
||||
# Default sampling parameters
|
||||
_DEFAULT_SAMPLING_PARAMS = {
|
||||
"temperature": 0.7,
|
||||
"top_p": 1.0,
|
||||
"top_k": -1,
|
||||
"min_p": 0.0,
|
||||
"repetition_penalty": 1.0,
|
||||
}
|
||||
|
||||
def to_sampling_params(
|
||||
self, default_max_tokens: int, default_params: Optional[Dict] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Convert to sampling parameters for generation."""
|
||||
if default_params is None:
|
||||
default_params = {}
|
||||
|
||||
# Use max_output_tokens if available, otherwise use max_tokens for backwards compatibility
|
||||
if self.max_output_tokens is not None:
|
||||
max_tokens = min(self.max_output_tokens, default_max_tokens)
|
||||
else:
|
||||
max_tokens = default_max_tokens
|
||||
|
||||
# Avoid exceed the context length by minus 1 token
|
||||
max_tokens -= 1
|
||||
|
||||
# Get parameters with defaults
|
||||
temperature = self.temperature
|
||||
if temperature is None:
|
||||
temperature = default_params.get(
|
||||
"temperature", self._DEFAULT_SAMPLING_PARAMS["temperature"]
|
||||
)
|
||||
|
||||
top_p = self.top_p
|
||||
if top_p is None:
|
||||
top_p = default_params.get("top_p", self._DEFAULT_SAMPLING_PARAMS["top_p"])
|
||||
|
||||
params = {
|
||||
"max_new_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"top_p": top_p,
|
||||
"frequency_penalty": self.frequency_penalty,
|
||||
"presence_penalty": self.presence_penalty,
|
||||
"stop": self.stop,
|
||||
"top_k": self.top_k,
|
||||
"min_p": self.min_p,
|
||||
"repetition_penalty": self.repetition_penalty,
|
||||
}
|
||||
|
||||
# Apply any additional default parameters
|
||||
for key, value in default_params.items():
|
||||
if key not in params or params[key] is None:
|
||||
params[key] = value
|
||||
|
||||
return params
|
||||
|
||||
|
||||
class PromptTokenUsageInfo(BaseModel):
|
||||
"""Prompt token usage details."""
|
||||
|
||||
cached_tokens: int = 0
|
||||
|
||||
|
||||
class ResponsesResponse(BaseModel):
|
||||
"""Response body for v1/responses endpoint."""
|
||||
|
||||
id: str = Field(default_factory=lambda: f"resp_{time.time()}")
|
||||
object: Literal["response"] = "response"
|
||||
created_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
model: str
|
||||
|
||||
output: List[
|
||||
Union[ResponseOutputItem, ResponseReasoningItem, ResponseFunctionToolCall]
|
||||
] = Field(default_factory=list)
|
||||
status: Literal["queued", "in_progress", "completed", "failed", "cancelled"]
|
||||
usage: Optional[UsageInfo] = None
|
||||
parallel_tool_calls: bool = True
|
||||
tool_choice: str = "auto"
|
||||
tools: List[ResponseTool] = Field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_request(
|
||||
cls,
|
||||
request: ResponsesRequest,
|
||||
sampling_params: Any,
|
||||
model_name: str,
|
||||
created_time: int,
|
||||
output: List[
|
||||
Union[ResponseOutputItem, ResponseReasoningItem, ResponseFunctionToolCall]
|
||||
],
|
||||
status: str,
|
||||
usage: Optional[UsageInfo],
|
||||
) -> "ResponsesResponse":
|
||||
"""Create a response from a request."""
|
||||
return cls(
|
||||
id=request.request_id,
|
||||
created_at=created_time,
|
||||
model=model_name,
|
||||
output=output,
|
||||
status=status,
|
||||
usage=usage,
|
||||
parallel_tool_calls=request.parallel_tool_calls or True,
|
||||
tool_choice=request.tool_choice,
|
||||
tools=request.tools,
|
||||
)
|
||||
|
||||
|
||||
class RequestResponseMetadata(BaseModel):
|
||||
"""Metadata for request/response tracking."""
|
||||
|
||||
request_id: str
|
||||
final_usage_info: Optional[UsageInfo] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageProcessingResult:
|
||||
"""Result of processing chat messages and applying templates.
|
||||
@@ -645,3 +852,22 @@ class MessageProcessingResult:
|
||||
modalities: List[str]
|
||||
stop: List[str]
|
||||
tool_call_constraint: Optional[Any] = None
|
||||
|
||||
|
||||
class ResponseReasoningTextContent(BaseModel):
|
||||
text: str
|
||||
type: Literal["reasoning_text"] = "reasoning_text"
|
||||
|
||||
|
||||
class ResponseReasoningItem(BaseModel):
|
||||
id: str
|
||||
content: list[ResponseReasoningTextContent] = Field(default_factory=list)
|
||||
summary: list = Field(default_factory=list)
|
||||
type: Literal["reasoning"] = "reasoning"
|
||||
encrypted_content: Optional[str] = None
|
||||
status: Optional[Literal["in_progress", "completed", "incomplete"]]
|
||||
|
||||
|
||||
ResponseInputOutputItem: TypeAlias = Union[
|
||||
ResponseInputItemParam, "ResponseReasoningItem", ResponseFunctionToolCall
|
||||
]
|
||||
|
||||
@@ -7,8 +7,18 @@ from typing import Any, AsyncGenerator, Dict, List, Optional, Union
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import ORJSONResponse, StreamingResponse
|
||||
from openai_harmony import Message as OpenAIMessage
|
||||
|
||||
from sglang.srt.conversation import generate_chat_conv
|
||||
from sglang.srt.entrypoints.harmony_utils import (
|
||||
get_developer_message,
|
||||
get_stop_tokens_for_assistant_actions,
|
||||
get_streamable_parser_for_assistant,
|
||||
get_system_message,
|
||||
parse_chat_input,
|
||||
parse_output_into_messages,
|
||||
render_for_completion,
|
||||
)
|
||||
from sglang.srt.entrypoints.openai.protocol import (
|
||||
ChatCompletionRequest,
|
||||
ChatCompletionResponse,
|
||||
@@ -51,6 +61,26 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
):
|
||||
super().__init__(tokenizer_manager)
|
||||
self.template_manager = template_manager
|
||||
self.use_harmony = (
|
||||
self.tokenizer_manager.model_config.hf_config.model_type == "gpt_oss"
|
||||
)
|
||||
|
||||
if self.use_harmony:
|
||||
from sglang.srt.function_call.harmony_tool_parser import (
|
||||
HarmonyToolCallParser,
|
||||
)
|
||||
|
||||
self.harmony_tool_parser = HarmonyToolCallParser()
|
||||
|
||||
# NOTE While OpenAI's chat completion API supports browsing
|
||||
# for some models, currently vLLM doesn't support it. Please use the
|
||||
# Responses API instead.
|
||||
self.supports_browsing = False
|
||||
self.browser_tool = None
|
||||
# NOTE: Chat completion API does not support code interpreter.
|
||||
# Please use the Responses API instead.
|
||||
self.supports_code_interpreter = False
|
||||
self.python_tool = None
|
||||
|
||||
def _request_id_prefix(self) -> str:
|
||||
return "chatcmpl-"
|
||||
@@ -77,41 +107,66 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
is_multimodal = self.tokenizer_manager.model_config.is_multimodal
|
||||
|
||||
# Process messages and apply chat template
|
||||
processed_messages = self._process_messages(request, is_multimodal)
|
||||
if not self.use_harmony:
|
||||
processed_messages = self._process_messages(request, is_multimodal)
|
||||
|
||||
# Build sampling parameters
|
||||
sampling_params = self._build_sampling_params(
|
||||
request, processed_messages.stop, processed_messages.tool_call_constraint
|
||||
)
|
||||
# Build sampling parameters
|
||||
sampling_params = self._build_sampling_params(
|
||||
request,
|
||||
processed_messages.stop,
|
||||
processed_messages.tool_call_constraint,
|
||||
)
|
||||
|
||||
# Handle single vs multiple requests
|
||||
if is_multimodal:
|
||||
prompt_kwargs = {"text": processed_messages.prompt}
|
||||
else:
|
||||
if isinstance(processed_messages.prompt_ids, str):
|
||||
prompt_kwargs = {"text": processed_messages.prompt_ids}
|
||||
# Handle single vs multiple requests
|
||||
if is_multimodal:
|
||||
prompt_kwargs = {"text": processed_messages.prompt}
|
||||
else:
|
||||
prompt_kwargs = {"input_ids": processed_messages.prompt_ids}
|
||||
if isinstance(processed_messages.prompt_ids, str):
|
||||
prompt_kwargs = {"text": processed_messages.prompt_ids}
|
||||
else:
|
||||
prompt_kwargs = {"input_ids": processed_messages.prompt_ids}
|
||||
|
||||
adapted_request = GenerateReqInput(
|
||||
**prompt_kwargs,
|
||||
image_data=processed_messages.image_data,
|
||||
video_data=processed_messages.video_data,
|
||||
audio_data=processed_messages.audio_data,
|
||||
sampling_params=sampling_params,
|
||||
return_logprob=request.logprobs,
|
||||
logprob_start_len=-1,
|
||||
top_logprobs_num=request.top_logprobs or 0,
|
||||
stream=request.stream,
|
||||
return_text_in_logprobs=True,
|
||||
modalities=processed_messages.modalities,
|
||||
lora_path=request.lora_path,
|
||||
bootstrap_host=request.bootstrap_host,
|
||||
bootstrap_port=request.bootstrap_port,
|
||||
bootstrap_room=request.bootstrap_room,
|
||||
return_hidden_states=request.return_hidden_states,
|
||||
rid=request.rid,
|
||||
)
|
||||
adapted_request = GenerateReqInput(
|
||||
**prompt_kwargs,
|
||||
image_data=processed_messages.image_data,
|
||||
video_data=processed_messages.video_data,
|
||||
audio_data=processed_messages.audio_data,
|
||||
sampling_params=sampling_params,
|
||||
return_logprob=request.logprobs,
|
||||
logprob_start_len=-1,
|
||||
top_logprobs_num=request.top_logprobs or 0,
|
||||
stream=request.stream,
|
||||
return_text_in_logprobs=True,
|
||||
modalities=processed_messages.modalities,
|
||||
lora_path=request.lora_path,
|
||||
bootstrap_host=request.bootstrap_host,
|
||||
bootstrap_port=request.bootstrap_port,
|
||||
bootstrap_room=request.bootstrap_room,
|
||||
return_hidden_states=request.return_hidden_states,
|
||||
rid=request.rid,
|
||||
)
|
||||
else:
|
||||
processed_messages, prompt_ids = self._make_request_with_harmony(request)
|
||||
|
||||
adapted_request = GenerateReqInput(
|
||||
input_ids=prompt_ids,
|
||||
sampling_params=self._build_sampling_params(
|
||||
request,
|
||||
request.stop,
|
||||
tool_call_constraint=None,
|
||||
),
|
||||
stream=request.stream,
|
||||
return_logprob=request.logprobs,
|
||||
logprob_start_len=-1,
|
||||
top_logprobs_num=request.top_logprobs or 0,
|
||||
return_text_in_logprobs=True,
|
||||
lora_path=request.lora_path,
|
||||
bootstrap_host=request.bootstrap_host,
|
||||
bootstrap_port=request.bootstrap_port,
|
||||
bootstrap_room=request.bootstrap_room,
|
||||
return_hidden_states=request.return_hidden_states,
|
||||
rid=request.rid,
|
||||
)
|
||||
|
||||
return adapted_request, request
|
||||
|
||||
@@ -402,6 +457,12 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
cached_tokens = {}
|
||||
hidden_states = {}
|
||||
|
||||
# Harmony tracking
|
||||
if self.use_harmony:
|
||||
harmony_parsers = [
|
||||
get_streamable_parser_for_assistant() for _ in range(request.n)
|
||||
]
|
||||
|
||||
try:
|
||||
async for content in self.tokenizer_manager.generate_request(
|
||||
adapted_request, raw_request
|
||||
@@ -449,14 +510,57 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
yield f"data: {chunk.model_dump_json()}\n\n"
|
||||
|
||||
# Process content delta
|
||||
stream_buffer = stream_buffers.get(index, "")
|
||||
delta = content["text"][len(stream_buffer) :]
|
||||
stream_buffers[index] = stream_buffer + delta
|
||||
if self.use_harmony:
|
||||
harmony_parser = harmony_parsers[index]
|
||||
|
||||
new_token_ids = content["output_ids"]
|
||||
for token_id in new_token_ids:
|
||||
harmony_parser.process(token_id)
|
||||
|
||||
is_final = harmony_parser.current_channel == "final"
|
||||
is_analysis = harmony_parser.current_channel == "analysis"
|
||||
delta = harmony_parser.last_content_delta or ""
|
||||
|
||||
if is_analysis:
|
||||
choice_data = ChatCompletionResponseStreamChoice(
|
||||
index=index,
|
||||
delta=DeltaMessage(reasoning_content=delta),
|
||||
finish_reason=None,
|
||||
)
|
||||
chunk = ChatCompletionStreamResponse(
|
||||
id=content["meta_info"]["id"],
|
||||
created=int(time.time()),
|
||||
choices=[choice_data],
|
||||
model=request.model,
|
||||
)
|
||||
yield f"data: {chunk.model_dump_json()}\n\n"
|
||||
continue
|
||||
|
||||
choice_data = ChatCompletionResponseStreamChoice(
|
||||
index=index,
|
||||
delta=DeltaMessage(content=delta if delta else None),
|
||||
finish_reason=None,
|
||||
matched_stop=None,
|
||||
logprobs=choice_logprobs,
|
||||
)
|
||||
chunk = ChatCompletionStreamResponse(
|
||||
id=content["meta_info"]["id"],
|
||||
created=int(time.time()),
|
||||
choices=[choice_data],
|
||||
model=request.model,
|
||||
)
|
||||
yield f"data: {chunk.model_dump_json()}\n\n"
|
||||
continue
|
||||
else:
|
||||
stream_buffer = stream_buffers.get(index, "")
|
||||
delta = content["text"][len(stream_buffer) :]
|
||||
stream_buffers[index] = stream_buffer + delta
|
||||
|
||||
# Handle reasoning content
|
||||
if (
|
||||
self.tokenizer_manager.server_args.reasoning_parser
|
||||
and request.separate_reasoning
|
||||
and not self.use_harmony
|
||||
):
|
||||
reasoning_text, delta = self._process_reasoning_stream(
|
||||
index, delta, reasoning_parser_dict, content, request
|
||||
@@ -475,8 +579,27 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
)
|
||||
yield f"data: {chunk.model_dump_json()}\n\n"
|
||||
|
||||
if self.use_harmony and not is_final:
|
||||
choice_data = ChatCompletionResponseStreamChoice(
|
||||
index=index,
|
||||
delta=DeltaMessage(reasoning_content=delta),
|
||||
finish_reason=None,
|
||||
)
|
||||
chunk = ChatCompletionStreamResponse(
|
||||
id=content["meta_info"]["id"],
|
||||
created=int(time.time()),
|
||||
choices=[choice_data],
|
||||
model=request.model,
|
||||
)
|
||||
yield f"data: {chunk.model_dump_json()}\n\n"
|
||||
|
||||
# Handle tool calls
|
||||
if request.tool_choice != "none" and request.tools:
|
||||
# TODO: support tool call parsing for harmony
|
||||
if (
|
||||
request.tool_choice != "none"
|
||||
and request.tools
|
||||
and not self.use_harmony
|
||||
):
|
||||
async for chunk in self._process_tool_call_stream(
|
||||
index,
|
||||
delta,
|
||||
@@ -502,7 +625,7 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
if delta:
|
||||
choice_data = ChatCompletionResponseStreamChoice(
|
||||
index=index,
|
||||
delta=DeltaMessage(content=delta if delta else None),
|
||||
delta=DeltaMessage(content=delta),
|
||||
finish_reason=None,
|
||||
matched_stop=None,
|
||||
logprobs=choice_logprobs,
|
||||
@@ -640,6 +763,76 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
|
||||
finish_reason = ret_item["meta_info"]["finish_reason"]
|
||||
text = ret_item["text"]
|
||||
output_ids = ret_item["output_ids"]
|
||||
|
||||
if self.use_harmony:
|
||||
parser = parse_output_into_messages(output_ids)
|
||||
output_msgs = parser.messages
|
||||
if len(output_msgs) == 0:
|
||||
# The generation has stopped during reasoning.
|
||||
is_tool_call = False
|
||||
reasoning_content = parser.current_content
|
||||
final_content = None
|
||||
elif len(output_msgs) == 1:
|
||||
# The generation has stopped during final message.
|
||||
is_tool_call = False
|
||||
reasoning_content = output_msgs[0].content[0].text
|
||||
final_content = parser.current_content
|
||||
else:
|
||||
if len(output_msgs) != 2:
|
||||
raise ValueError(
|
||||
"Expected 2 output messages (reasoning and final), "
|
||||
f"but got {len(output_msgs)}."
|
||||
)
|
||||
reasoning_msg, final_msg = output_msgs
|
||||
reasoning_content = reasoning_msg.content[0].text
|
||||
final_content = final_msg.content[0].text
|
||||
is_tool_call = final_msg.recipient is not None
|
||||
|
||||
if is_tool_call:
|
||||
# Extract tool call information from final message
|
||||
tool_call = (
|
||||
self.harmony_tool_parser.extract_tool_calls_from_message(
|
||||
final_msg
|
||||
)
|
||||
)
|
||||
tool_calls = [tool_call] if tool_call else []
|
||||
|
||||
message = ChatMessage(
|
||||
role="assistant",
|
||||
reasoning_content=reasoning_content,
|
||||
content=None, # Tool calls don't have regular content
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
else:
|
||||
# Normal message
|
||||
message = ChatMessage(
|
||||
role="assistant",
|
||||
reasoning_content=reasoning_content,
|
||||
content=final_content,
|
||||
)
|
||||
|
||||
if is_tool_call:
|
||||
finish_reason_type = "tool_calls"
|
||||
elif finish_reason:
|
||||
finish_reason_type = (
|
||||
finish_reason["type"] if finish_reason else "stop"
|
||||
)
|
||||
else:
|
||||
finish_reason_type = "stop"
|
||||
choice_data = ChatCompletionResponseChoice(
|
||||
index=idx,
|
||||
message=message,
|
||||
logprobs=choice_logprobs,
|
||||
finish_reason=finish_reason_type,
|
||||
matched_stop=(
|
||||
finish_reason["matched"]
|
||||
if finish_reason and "matched" in finish_reason
|
||||
else None
|
||||
),
|
||||
)
|
||||
choices.append(choice_data)
|
||||
continue
|
||||
|
||||
# Handle reasoning content
|
||||
reasoning_text = None
|
||||
@@ -978,3 +1171,33 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
return f"data: {chunk.model_dump_json()}\n\n"
|
||||
|
||||
return None
|
||||
|
||||
def _make_request_with_harmony(
|
||||
self,
|
||||
request: ChatCompletionRequest,
|
||||
):
|
||||
messages: list[OpenAIMessage] = []
|
||||
|
||||
# Add system message.
|
||||
# In Chat Completion API, browsing is enabled by default if the model
|
||||
# supports it.
|
||||
assert not self.supports_browsing
|
||||
assert not self.supports_code_interpreter
|
||||
sys_msg = get_system_message(
|
||||
reasoning_effort=request.reasoning_effort,
|
||||
browser_description=None,
|
||||
python_description=None,
|
||||
)
|
||||
messages.append(sys_msg)
|
||||
|
||||
# Add developer message.
|
||||
dev_msg = get_developer_message()
|
||||
messages.append(dev_msg)
|
||||
|
||||
# Add user message.
|
||||
for chat_msg in request.messages:
|
||||
messages.append(parse_chat_input(chat_msg))
|
||||
|
||||
# Render prompt token ids.
|
||||
prompt_token_ids = render_for_completion(messages)
|
||||
return messages, prompt_token_ids
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
try:
|
||||
from mcp import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.types import ListToolsResult
|
||||
except ImportError:
|
||||
logger.warning("Ignoring mcp import error")
|
||||
|
||||
from openai_harmony import ToolDescription, ToolNamespaceConfig
|
||||
|
||||
|
||||
async def list_server_and_tools(server_url: str):
|
||||
|
||||
async with sse_client(url=server_url) as streams, ClientSession(
|
||||
*streams
|
||||
) as session:
|
||||
initialize_response = await session.initialize()
|
||||
list_tools_response = await session.list_tools()
|
||||
return initialize_response, list_tools_response
|
||||
|
||||
|
||||
def trim_schema(schema: dict) -> dict:
|
||||
# Turn JSON Schema from MCP generated into Harmony's variant.
|
||||
if "title" in schema:
|
||||
del schema["title"]
|
||||
if "default" in schema and schema["default"] is None:
|
||||
del schema["default"]
|
||||
if "anyOf" in schema:
|
||||
# Turn "anyOf": [{"type": "type-1"}, {"type": "type-2"}]
|
||||
# into "type": ["type-1", "type-2"]
|
||||
# if there's more than 1 types, also remove "null" type as Harmony will
|
||||
# just ignore it
|
||||
types = [
|
||||
type_dict["type"]
|
||||
for type_dict in schema["anyOf"]
|
||||
if type_dict["type"] != "null"
|
||||
]
|
||||
schema["type"] = types
|
||||
del schema["anyOf"]
|
||||
if "properties" in schema:
|
||||
schema["properties"] = {
|
||||
k: trim_schema(v) for k, v in schema["properties"].items()
|
||||
}
|
||||
return schema
|
||||
|
||||
|
||||
def post_process_tools_description(
|
||||
list_tools_result: "ListToolsResult",
|
||||
) -> "ListToolsResult":
|
||||
# Adapt the MCP tool result for Harmony
|
||||
for tool in list_tools_result.tools:
|
||||
tool.inputSchema = trim_schema(tool.inputSchema)
|
||||
|
||||
# Some tools schema don't need to be part of the prompt (e.g. simple text
|
||||
# in text out for Python)
|
||||
list_tools_result.tools = [
|
||||
tool
|
||||
for tool in list_tools_result.tools
|
||||
if getattr(tool.annotations, "include_in_prompt", True)
|
||||
]
|
||||
|
||||
return list_tools_result
|
||||
|
||||
|
||||
class ToolServer(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def has_tool(self, tool_name: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_tool_description(self, tool_name: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_tool_session(self, tool_name: str) -> AbstractAsyncContextManager[Any]: ...
|
||||
|
||||
|
||||
class MCPToolServer(ToolServer):
|
||||
|
||||
def __init__(self):
|
||||
self.harmony_tool_descriptions = {}
|
||||
|
||||
async def add_tool_server(self, server_url: str):
|
||||
tool_urls = server_url.split(",")
|
||||
self.harmony_tool_descriptions = {}
|
||||
self.urls: dict[str, str] = {}
|
||||
for url in tool_urls:
|
||||
url = f"http://{url}/sse"
|
||||
initialize_response, list_tools_response = await list_server_and_tools(url)
|
||||
|
||||
list_tools_response = post_process_tools_description(list_tools_response)
|
||||
|
||||
tool_from_mcp = ToolNamespaceConfig(
|
||||
name=initialize_response.serverInfo.name,
|
||||
description=initialize_response.instructions,
|
||||
tools=[
|
||||
ToolDescription.new(
|
||||
name=tool.name,
|
||||
description=tool.description,
|
||||
parameters=tool.inputSchema,
|
||||
)
|
||||
for tool in list_tools_response.tools
|
||||
],
|
||||
)
|
||||
self.harmony_tool_descriptions[tool_from_mcp.name] = tool_from_mcp
|
||||
if tool_from_mcp.name not in self.urls:
|
||||
self.urls[tool_from_mcp.name] = url
|
||||
else:
|
||||
logger.warning(
|
||||
"Tool %s already exists. Ignoring duplicate tool server %s",
|
||||
tool_from_mcp.name,
|
||||
url,
|
||||
)
|
||||
|
||||
def has_tool(self, tool_name: str):
|
||||
return tool_name in self.harmony_tool_descriptions
|
||||
|
||||
def get_tool_description(self, tool_name: str):
|
||||
return self.harmony_tool_descriptions.get(tool_name)
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_tool_session(self, tool_name: str):
|
||||
url = self.urls.get(tool_name)
|
||||
if url:
|
||||
async with sse_client(url=url) as streams, ClientSession(
|
||||
*streams
|
||||
) as session:
|
||||
await session.initialize()
|
||||
yield session
|
||||
else:
|
||||
logger.warning("Tool %s not found", tool_name)
|
||||
|
||||
|
||||
class DemoToolServer(ToolServer):
|
||||
|
||||
def __init__(self):
|
||||
from sglang.srt.entrypoints.tool import (
|
||||
HarmonyBrowserTool,
|
||||
HarmonyPythonTool,
|
||||
Tool,
|
||||
)
|
||||
|
||||
self.tools: dict[str, Tool] = {}
|
||||
browser_tool = HarmonyBrowserTool()
|
||||
if browser_tool.enabled:
|
||||
self.tools["browser"] = browser_tool
|
||||
python_tool = HarmonyPythonTool()
|
||||
if python_tool.enabled:
|
||||
self.tools["python"] = python_tool
|
||||
|
||||
def has_tool(self, tool_name: str):
|
||||
return tool_name in self.tools
|
||||
|
||||
def get_tool_description(self, tool_name: str):
|
||||
if tool_name not in self.tools:
|
||||
return None
|
||||
if tool_name == "browser":
|
||||
return ToolNamespaceConfig.browser()
|
||||
elif tool_name == "python":
|
||||
return ToolNamespaceConfig.python()
|
||||
else:
|
||||
raise ValueError(f"Unknown tool {tool_name}")
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_tool_session(self, tool_name: str):
|
||||
yield self.tools[tool_name]
|
||||
Reference in New Issue
Block a user