94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
2409 lines
100 KiB
Python
2409 lines
100 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# Adapted from vLLM's OpenAIServingResponses
|
|
"""Handler for /v1/responses requests"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
from contextlib import AsyncExitStack
|
|
from http import HTTPStatus
|
|
from typing import TYPE_CHECKING, Any, AsyncGenerator, AsyncIterator, Optional, Union
|
|
|
|
import jinja2
|
|
import openai.types.responses as openai_responses_types
|
|
import orjson
|
|
from fastapi import Request
|
|
from fastapi.responses import ORJSONResponse
|
|
from openai.types.responses import (
|
|
ResponseOutputMessage,
|
|
ResponseOutputText,
|
|
ResponseReasoningItem,
|
|
)
|
|
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
|
|
from openai.types.responses.response_reasoning_item import (
|
|
Content as ResponseReasoningTextContent,
|
|
)
|
|
from openai.types.responses.response_reasoning_item import (
|
|
Summary as ResponseReasoningSummary,
|
|
)
|
|
from openai.types.responses.response_reasoning_summary_part_added_event import (
|
|
Part as ResponseReasoningSummaryAddedPart,
|
|
)
|
|
from openai.types.responses.response_reasoning_summary_part_done_event import (
|
|
Part as ResponseReasoningSummaryDonePart,
|
|
)
|
|
from openai_harmony import Message as OpenAIMessage
|
|
|
|
from sglang.srt.entrypoints.context import (
|
|
ConversationContext,
|
|
HarmonyContext,
|
|
SimpleContext,
|
|
StreamingHarmonyContext,
|
|
)
|
|
from sglang.srt.entrypoints.harmony_utils import (
|
|
get_developer_message,
|
|
get_stop_tokens_for_assistant_actions,
|
|
get_system_message,
|
|
get_user_message,
|
|
parse_output_message,
|
|
parse_remaining_state,
|
|
parse_response_input,
|
|
render_for_completion,
|
|
)
|
|
from sglang.srt.entrypoints.openai.protocol import (
|
|
ChatCompletionMessageParam,
|
|
ChatCompletionRequest,
|
|
Function,
|
|
MessageProcessingResult,
|
|
PromptTokenUsageInfo,
|
|
RequestResponseMetadata,
|
|
ResponsesRequest,
|
|
ResponsesResponse,
|
|
Tool,
|
|
UsageInfo,
|
|
)
|
|
from sglang.srt.entrypoints.openai.serving_chat import OpenAIServingChat
|
|
from sglang.srt.entrypoints.openai.tool_server import MCPToolServer, ToolServer
|
|
from sglang.srt.function_call.function_call_parser import FunctionCallParser
|
|
from sglang.srt.function_call.json_array_parser import JsonArrayParser
|
|
from sglang.srt.managers.io_struct import GenerateReqInput
|
|
from sglang.srt.parser.reasoning_parser import ReasoningParser
|
|
from sglang.srt.utils import random_uuid
|
|
|
|
if TYPE_CHECKING:
|
|
from sglang.srt.managers.tokenizer_manager import TokenizerManager
|
|
from sglang.srt.parser.template_manager import TemplateManager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class OpenAIServingResponses(OpenAIServingChat):
|
|
"""Handler for /v1/responses requests"""
|
|
|
|
def __init__(
|
|
self,
|
|
tokenizer_manager: TokenizerManager,
|
|
template_manager: TemplateManager,
|
|
*,
|
|
enable_prompt_tokens_details: bool = False,
|
|
tool_server: Optional[ToolServer] = None,
|
|
) -> None:
|
|
super().__init__(tokenizer_manager, template_manager)
|
|
|
|
# template_manager is already set by parent class
|
|
self.reasoning_parser = self.tokenizer_manager.server_args.reasoning_parser
|
|
self.enable_prompt_tokens_details = enable_prompt_tokens_details
|
|
|
|
# Parent OpenAIServingChat.__init__ already populated default_sampling_params.
|
|
if not isinstance(self.default_sampling_params, dict):
|
|
self.default_sampling_params = {}
|
|
|
|
self.supports_browsing = (
|
|
tool_server.has_tool("browser") if tool_server else False
|
|
)
|
|
self.supports_code_interpreter = (
|
|
tool_server.has_tool("python") if tool_server else False
|
|
)
|
|
self.tool_server = tool_server
|
|
# Get from model config
|
|
self.use_harmony = (
|
|
self.tokenizer_manager.model_config.hf_config.model_type == "gpt_oss"
|
|
)
|
|
|
|
if self.use_harmony:
|
|
# OpenAI models have two EOS-like tokens: <|return|> and <|call|>.
|
|
# We need to add them to the stop token ids.
|
|
if "stop_token_ids" not in self.default_sampling_params:
|
|
self.default_sampling_params["stop_token_ids"] = []
|
|
self.default_sampling_params["stop_token_ids"].extend(
|
|
get_stop_tokens_for_assistant_actions()
|
|
)
|
|
|
|
# Response storage for background and retrieval operations
|
|
# Note: In production, this should use a proper storage backend (Redis, database)
|
|
# with TTL/expiration to prevent memory leaks
|
|
self.response_store: dict[str, ResponsesResponse] = {}
|
|
self.response_store_lock = asyncio.Lock()
|
|
|
|
# Message storage for conversation continuity
|
|
# Note: In production, this should use a proper storage backend (Redis, database)
|
|
# with TTL/expiration to prevent memory leaks
|
|
self.msg_store: dict[
|
|
str, Union[list[ChatCompletionMessageParam], list[OpenAIMessage]]
|
|
] = {}
|
|
|
|
self.background_tasks: dict[str, asyncio.Task] = {}
|
|
|
|
@staticmethod
|
|
def _has_response_tool(request: ResponsesRequest, *tool_types: str) -> bool:
|
|
return any(tool.type in tool_types for tool in (request.tools or []))
|
|
|
|
# error helpers dedicated for v1/responses
|
|
def create_error_response(
|
|
self,
|
|
message: str,
|
|
err_type: str = "invalid_request_error",
|
|
status_code: int = 400,
|
|
param: Optional[str] = None,
|
|
) -> ORJSONResponse:
|
|
nested_error = {
|
|
"message": message,
|
|
"type": err_type,
|
|
"param": param,
|
|
"code": status_code,
|
|
}
|
|
return ORJSONResponse(content={"error": nested_error}, status_code=status_code)
|
|
|
|
def create_streaming_error_response(
|
|
self,
|
|
message: str,
|
|
err_type: str = "BadRequestError",
|
|
status_code: int = 400,
|
|
) -> str:
|
|
return json.dumps(
|
|
{
|
|
"error": {
|
|
"message": message,
|
|
"type": err_type,
|
|
"param": None,
|
|
"code": status_code,
|
|
}
|
|
}
|
|
)
|
|
|
|
def _request_id_prefix(self) -> str:
|
|
return "resp_"
|
|
|
|
async def create_responses(
|
|
self,
|
|
request: ResponsesRequest,
|
|
raw_request: Optional[Request] = None,
|
|
) -> Union[AsyncGenerator[str, None], ResponsesResponse, ORJSONResponse]:
|
|
# Validate model
|
|
if not self.tokenizer_manager:
|
|
return self.create_error_response("Model not loaded")
|
|
|
|
# FIXME: If the engine is dead, raise an error
|
|
# This is required for the streaming case
|
|
|
|
# ``tool_choice="required"`` only works with ``function`` tools.
|
|
if request.tool_choice == "required" and not any(
|
|
tool.type == "function" for tool in (request.tools or [])
|
|
):
|
|
return self.create_error_response(
|
|
'tool_choice="required" requires at least one tool with '
|
|
'type="function"; other built-in tool types cannot be forced.'
|
|
)
|
|
|
|
if (
|
|
self.use_harmony
|
|
and self._has_response_tool(request, "web_search", "web_search_preview")
|
|
and not self.supports_browsing
|
|
):
|
|
return self.create_error_response(
|
|
"web_search requires a browser backend. Set EXA_API_KEY on the "
|
|
"SGLang server to enable native Exa-backed web search, or "
|
|
"configure a browser MCP tool server. Create an Exa API key at "
|
|
"https://dashboard.exa.ai/api-keys."
|
|
)
|
|
|
|
# Handle the previous response ID
|
|
prev_response_id = request.previous_response_id
|
|
if prev_response_id is not None:
|
|
if not prev_response_id.startswith("resp_"):
|
|
return self._make_invalid_id_error(prev_response_id)
|
|
async with self.response_store_lock:
|
|
prev_response = self.response_store.get(prev_response_id)
|
|
if prev_response is None:
|
|
return self._make_not_found_error(prev_response_id)
|
|
else:
|
|
prev_response = None
|
|
|
|
try:
|
|
model_name = request.model
|
|
tokenizer = self.tokenizer_manager.tokenizer
|
|
processed_messages: Optional[MessageProcessingResult] = None
|
|
|
|
if self.use_harmony:
|
|
messages, request_prompts, engine_prompts = (
|
|
self._make_request_with_harmony(request, prev_response)
|
|
)
|
|
else:
|
|
(
|
|
messages,
|
|
request_prompts,
|
|
engine_prompts,
|
|
processed_messages,
|
|
) = await self._make_request(request, prev_response, tokenizer)
|
|
|
|
except (ValueError, TypeError, RuntimeError, jinja2.TemplateError) as e:
|
|
logger.exception("Error in preprocessing prompt inputs")
|
|
return self.create_error_response(f"{e} {e.__cause__}")
|
|
|
|
request_metadata = RequestResponseMetadata(request_id=request.request_id)
|
|
if raw_request:
|
|
raw_request.state.request_metadata = request_metadata
|
|
|
|
if (
|
|
self.tool_server is not None
|
|
and isinstance(self.tool_server, MCPToolServer)
|
|
and (request.background or request.stream)
|
|
and request.tools
|
|
and any(
|
|
tool.type in ("web_search", "web_search_preview", "code_interpreter")
|
|
for tool in request.tools
|
|
)
|
|
):
|
|
return self.create_error_response(
|
|
"MCP tool server is not supported in background mode and "
|
|
"streaming mode"
|
|
)
|
|
|
|
# Schedule the request and get the result generator
|
|
generators: list[AsyncGenerator[Any, None]] = []
|
|
tool_list = []
|
|
if self.use_harmony:
|
|
if self.supports_browsing:
|
|
tool_list.append("browser")
|
|
if self.supports_code_interpreter:
|
|
tool_list.append("python")
|
|
async with AsyncExitStack() as exit_stack:
|
|
try:
|
|
if self.tool_server is not None:
|
|
tool_session_ctxs: dict[str, Any] = {
|
|
tool_name: exit_stack.enter_async_context(
|
|
self.tool_server.get_tool_session(tool_name)
|
|
)
|
|
for tool_name in tool_list
|
|
}
|
|
tool_sessions = {}
|
|
for tool_name in tool_list:
|
|
tool_sessions[tool_name] = await tool_session_ctxs[tool_name]
|
|
else:
|
|
assert len(tool_list) == 0
|
|
tool_sessions = {}
|
|
for i, engine_prompt in enumerate(engine_prompts):
|
|
# Calculate default max tokens from context length minus prompt length
|
|
if isinstance(engine_prompt, list):
|
|
prompt_length = len(engine_prompt)
|
|
elif isinstance(engine_prompt, str):
|
|
prompt_length = len(tokenizer.encode(engine_prompt))
|
|
else:
|
|
prompt_length = 0
|
|
|
|
context_len = (
|
|
self.tokenizer_manager.model_config.context_len
|
|
if hasattr(self.tokenizer_manager.model_config, "context_len")
|
|
else 4096
|
|
)
|
|
# Account for reserved tokens (e.g., EAGLE speculative decoding slots)
|
|
# that the tokenizer_manager adds during validation
|
|
num_reserved_tokens = self.tokenizer_manager.num_reserved_tokens
|
|
default_max_tokens = max(
|
|
context_len - prompt_length - num_reserved_tokens, 512
|
|
) # Ensure minimum 512 tokens
|
|
sampling_params = request.to_sampling_params(
|
|
default_max_tokens,
|
|
self.default_sampling_params,
|
|
stop=(
|
|
processed_messages.stop
|
|
if processed_messages
|
|
else request.stop
|
|
),
|
|
tool_call_constraint=(
|
|
processed_messages.tool_call_constraint
|
|
if processed_messages
|
|
else None
|
|
),
|
|
)
|
|
|
|
context: ConversationContext
|
|
if self.use_harmony:
|
|
if request.stream:
|
|
context = StreamingHarmonyContext(messages, tool_sessions)
|
|
else:
|
|
context = HarmonyContext(messages, tool_sessions)
|
|
else:
|
|
context = SimpleContext()
|
|
|
|
# Create GenerateReqInput for SGLang
|
|
if isinstance(engine_prompt, str):
|
|
prompt_kwargs = {"text": engine_prompt}
|
|
else:
|
|
prompt_kwargs = {"input_ids": engine_prompt}
|
|
|
|
adapted_request = GenerateReqInput(
|
|
**prompt_kwargs,
|
|
image_data=(
|
|
processed_messages.image_data
|
|
if processed_messages
|
|
else None
|
|
),
|
|
video_data=(
|
|
processed_messages.video_data
|
|
if processed_messages
|
|
else None
|
|
),
|
|
audio_data=(
|
|
processed_messages.audio_data
|
|
if processed_messages
|
|
else None
|
|
),
|
|
modalities=(
|
|
processed_messages.modalities
|
|
if processed_messages
|
|
else None
|
|
),
|
|
sampling_params=sampling_params,
|
|
stream=request.stream,
|
|
rid=request.request_id,
|
|
session_id=request.session_id,
|
|
extra_key=self._compute_extra_key(request),
|
|
background=request.background,
|
|
)
|
|
|
|
generator = self._generate_with_builtin_tools(
|
|
request.request_id,
|
|
request_prompts[i],
|
|
adapted_request,
|
|
sampling_params,
|
|
context,
|
|
raw_request=raw_request,
|
|
priority=request.priority,
|
|
)
|
|
generators.append(generator)
|
|
except ValueError as e:
|
|
return self.create_error_response(str(e))
|
|
|
|
assert len(generators) == 1
|
|
(result_generator,) = generators
|
|
|
|
# Store the input messages
|
|
if request.store:
|
|
self.msg_store[request.request_id] = messages
|
|
|
|
if request.background:
|
|
created_time = int(time.time())
|
|
response = ResponsesResponse.from_request(
|
|
request,
|
|
sampling_params,
|
|
model_name=model_name,
|
|
created_time=created_time,
|
|
output=[],
|
|
status="queued",
|
|
usage=None,
|
|
)
|
|
async with self.response_store_lock:
|
|
self.response_store[response.id] = response
|
|
|
|
# Run the request in the background
|
|
task = asyncio.create_task(
|
|
self._run_background_request(
|
|
request,
|
|
sampling_params,
|
|
result_generator,
|
|
context,
|
|
model_name,
|
|
tokenizer,
|
|
request_metadata,
|
|
created_time,
|
|
),
|
|
name=f"create_{response.id}",
|
|
)
|
|
|
|
# For cleanup
|
|
self.background_tasks[response.id] = task
|
|
task.add_done_callback(
|
|
lambda _: self.background_tasks.pop(response.id, None)
|
|
)
|
|
return response
|
|
|
|
if request.stream:
|
|
if self.use_harmony:
|
|
return self.responses_stream_generator(
|
|
request,
|
|
sampling_params,
|
|
result_generator,
|
|
context,
|
|
model_name,
|
|
tokenizer,
|
|
request_metadata,
|
|
)
|
|
return self.responses_stream_generator_non_harmony(
|
|
request,
|
|
sampling_params,
|
|
result_generator,
|
|
model_name,
|
|
tokenizer,
|
|
request_metadata,
|
|
)
|
|
try:
|
|
result: Union[ORJSONResponse, ResponsesResponse] = (
|
|
await self.responses_full_generator(
|
|
request,
|
|
sampling_params,
|
|
result_generator,
|
|
context,
|
|
model_name,
|
|
tokenizer,
|
|
request_metadata,
|
|
)
|
|
)
|
|
return result
|
|
except Exception as e:
|
|
return self.create_error_response(str(e))
|
|
return self.create_error_response("Unknown error")
|
|
|
|
async def _make_request(
|
|
self,
|
|
request: ResponsesRequest,
|
|
prev_response: Optional[ResponsesResponse],
|
|
tokenizer: Any,
|
|
):
|
|
messages = self._construct_input_messages(request, prev_response)
|
|
|
|
chat_tools = self._response_tools_to_chat_tools(request)
|
|
chat_request = ChatCompletionRequest(
|
|
model=request.model,
|
|
messages=messages,
|
|
stream=request.stream,
|
|
tools=chat_tools or None,
|
|
tool_choice=request.tool_choice if chat_tools else "none",
|
|
parallel_tool_calls=(
|
|
request.parallel_tool_calls
|
|
if request.parallel_tool_calls is not None
|
|
else True
|
|
),
|
|
stop=request.stop,
|
|
)
|
|
|
|
is_multimodal = self.tokenizer_manager.model_config.is_multimodal
|
|
processed_messages = self._process_messages(chat_request, is_multimodal)
|
|
|
|
if is_multimodal:
|
|
request_prompts = [processed_messages.prompt]
|
|
engine_prompts = [processed_messages.prompt]
|
|
else:
|
|
request_prompts = [processed_messages.prompt_ids]
|
|
engine_prompts = [processed_messages.prompt_ids]
|
|
|
|
return messages, request_prompts, engine_prompts, processed_messages
|
|
|
|
def _make_request_with_harmony(
|
|
self,
|
|
request: ResponsesRequest,
|
|
prev_response: Optional[ResponsesResponse],
|
|
):
|
|
if request.tool_choice != "auto":
|
|
raise NotImplementedError(
|
|
"Only 'auto' tool_choice is supported in " "response API"
|
|
)
|
|
messages = self._construct_input_messages_with_harmony(request, prev_response)
|
|
prompt_token_ids = render_for_completion(messages)
|
|
engine_prompt = prompt_token_ids
|
|
return messages, [prompt_token_ids], [engine_prompt]
|
|
|
|
async def responses_full_generator(
|
|
self,
|
|
request: ResponsesRequest,
|
|
sampling_params: Any,
|
|
result_generator: AsyncIterator[Any],
|
|
context: ConversationContext,
|
|
model_name: str,
|
|
tokenizer: Any,
|
|
request_metadata: RequestResponseMetadata,
|
|
created_time: Optional[int] = None,
|
|
) -> Union[ResponsesResponse, ORJSONResponse]:
|
|
if created_time is None:
|
|
created_time = int(time.time())
|
|
|
|
try:
|
|
async for _ in result_generator:
|
|
pass
|
|
except asyncio.CancelledError:
|
|
return self.create_error_response("Client disconnected")
|
|
except ValueError as e:
|
|
return self.create_error_response(str(e))
|
|
|
|
if self.use_harmony:
|
|
assert isinstance(context, HarmonyContext)
|
|
output = self._make_response_output_items_with_harmony(context)
|
|
# num_reasoning_tokens isn't wired through HarmonyContext yet; stays 0.
|
|
num_prompt_tokens = context.num_prompt_tokens
|
|
num_generated_tokens = context.num_output_tokens
|
|
num_cached_tokens = context.num_cached_tokens
|
|
num_reasoning_tokens = context.num_reasoning_tokens
|
|
else:
|
|
assert isinstance(context, SimpleContext)
|
|
final_res = context.last_output
|
|
assert final_res is not None
|
|
|
|
output = self._make_response_output_items(
|
|
request, final_res["text"], tokenizer
|
|
)
|
|
|
|
# Calculate usage from actual output
|
|
num_reasoning_tokens = 0
|
|
meta_info = None
|
|
if isinstance(final_res, dict) and isinstance(
|
|
final_res.get("meta_info"), dict
|
|
):
|
|
meta_info = final_res["meta_info"]
|
|
elif hasattr(final_res, "meta_info"):
|
|
meta_info = final_res.meta_info
|
|
|
|
if meta_info is not None:
|
|
num_prompt_tokens = meta_info.get("prompt_tokens", 0)
|
|
num_generated_tokens = meta_info.get("completion_tokens", 0)
|
|
num_cached_tokens = meta_info.get("cached_tokens", 0)
|
|
num_reasoning_tokens = meta_info.get("reasoning_tokens", 0)
|
|
elif isinstance(final_res, dict) and (
|
|
final_res.get("prompt_token_ids") is not None
|
|
or final_res.get("output_ids") is not None
|
|
):
|
|
prompt_token_ids = final_res.get("prompt_token_ids") or []
|
|
output_token_ids = final_res.get("output_ids") or []
|
|
num_prompt_tokens = len(prompt_token_ids)
|
|
num_generated_tokens = len(output_token_ids)
|
|
num_cached_tokens = final_res.get("num_cached_tokens", 0)
|
|
elif hasattr(final_res, "prompt_token_ids") and hasattr(
|
|
final_res, "outputs"
|
|
):
|
|
# Fallback calculation if meta_info not available
|
|
num_prompt_tokens = (
|
|
len(final_res.prompt_token_ids) if final_res.prompt_token_ids else 0
|
|
)
|
|
num_generated_tokens = (
|
|
len(final_res.outputs[0].token_ids)
|
|
if final_res.outputs and final_res.outputs[0].token_ids
|
|
else 0
|
|
)
|
|
num_cached_tokens = getattr(final_res, "num_cached_tokens", 0)
|
|
else:
|
|
# Final fallback
|
|
num_prompt_tokens = 0
|
|
num_generated_tokens = 0
|
|
num_cached_tokens = 0
|
|
num_reasoning_tokens = 0
|
|
|
|
usage = UsageInfo(
|
|
prompt_tokens=num_prompt_tokens,
|
|
completion_tokens=num_generated_tokens,
|
|
total_tokens=num_prompt_tokens + num_generated_tokens,
|
|
reasoning_tokens=num_reasoning_tokens,
|
|
)
|
|
if self.enable_prompt_tokens_details and num_cached_tokens:
|
|
usage.prompt_tokens_details = PromptTokenUsageInfo(
|
|
cached_tokens=num_cached_tokens
|
|
)
|
|
request_metadata.final_usage_info = usage
|
|
|
|
response = ResponsesResponse.from_request(
|
|
request,
|
|
sampling_params,
|
|
model_name=model_name,
|
|
created_time=created_time,
|
|
output=output,
|
|
status="completed",
|
|
usage=usage,
|
|
)
|
|
|
|
if request.store:
|
|
async with self.response_store_lock:
|
|
stored_response = self.response_store.get(response.id)
|
|
# If the response is already cancelled, don't update it
|
|
if stored_response is None or stored_response.status != "cancelled":
|
|
self.response_store[response.id] = response
|
|
|
|
return response
|
|
|
|
@staticmethod
|
|
def _wants_reasoning_summary(request: ResponsesRequest) -> bool:
|
|
return request.reasoning is not None and request.reasoning.summary is not None
|
|
|
|
def _is_thinking_enabled_for_request(self, request: ResponsesRequest) -> bool:
|
|
"""Whether to start the reasoning detector in thinking mode."""
|
|
if not self.reasoning_parser:
|
|
return False
|
|
effort = request.reasoning.effort if request.reasoning is not None else None
|
|
if self.reasoning_parser == "hunyuan":
|
|
return effort not in (None, "none", "no_think")
|
|
if self.template_manager.force_reasoning:
|
|
return True
|
|
config = self.template_manager.reasoning_config
|
|
if config is None:
|
|
# Parser-only models (DeepSeek-R1, …) carry the thinking default in
|
|
# the detector itself.
|
|
detector = getattr(self, "_reasoning_detector", None)
|
|
mode = getattr(detector, "reasoning_default", None) if detector else None
|
|
if mode is None or mode == "always":
|
|
return mode == "always"
|
|
if mode == "mistral":
|
|
return effort is not None and effort != "none"
|
|
if mode in ("thinking", "enable_thinking"):
|
|
return effort != "none"
|
|
if mode in ("explicit_thinking", "explicit_enable_thinking"):
|
|
return False
|
|
return False
|
|
if config.special_case == "always":
|
|
return True
|
|
if config.special_case == "mistral":
|
|
return effort is not None and effort != "none"
|
|
if config.toggle_param is None or config.default_enabled is None:
|
|
return False
|
|
if effort == "none":
|
|
return False
|
|
return bool(config.default_enabled)
|
|
|
|
def _make_response_output_items(
|
|
self,
|
|
request: ResponsesRequest,
|
|
final_output: Any,
|
|
tokenizer: Any,
|
|
):
|
|
if self.reasoning_parser:
|
|
# Templates that prefill ``<think>`` only emit the close tag, so
|
|
# start the detector in thinking mode.
|
|
reasoning_parser = ReasoningParser(
|
|
model_type=self.reasoning_parser,
|
|
stream_reasoning=False,
|
|
force_reasoning=self._is_thinking_enabled_for_request(request),
|
|
request=request,
|
|
tokenizer=self.tokenizer_manager.tokenizer,
|
|
)
|
|
reasoning_content, content = reasoning_parser.parse_non_stream(final_output)
|
|
else:
|
|
reasoning_content = None
|
|
content = final_output
|
|
|
|
output_items = []
|
|
if reasoning_content:
|
|
# Mirror the single parsed blob into ``summary`` when the caller opts
|
|
# in via ``reasoning.summary``; full trace stays in ``content``.
|
|
wants_summary = self._wants_reasoning_summary(request)
|
|
reasoning_item = ResponseReasoningItem(
|
|
id=f"rs_{random_uuid()}",
|
|
type="reasoning",
|
|
summary=(
|
|
[
|
|
ResponseReasoningSummary(
|
|
type="summary_text", text=reasoning_content
|
|
)
|
|
]
|
|
if wants_summary
|
|
else []
|
|
),
|
|
content=[
|
|
ResponseReasoningTextContent(
|
|
type="reasoning_text", text=reasoning_content
|
|
),
|
|
],
|
|
status=None,
|
|
)
|
|
output_items.append(reasoning_item)
|
|
|
|
chat_tools = self._response_tools_to_chat_tools(request)
|
|
is_required = request.tool_choice == "required"
|
|
tool_call_items: list[ResponseFunctionToolCall] = []
|
|
parsed_via_native = False
|
|
if (
|
|
content
|
|
and chat_tools
|
|
and self.tool_call_parser
|
|
and request.tool_choice != "none"
|
|
):
|
|
parser = FunctionCallParser(
|
|
chat_tools,
|
|
self.tool_call_parser,
|
|
tokenizer=self.tokenizer_manager.tokenizer,
|
|
)
|
|
should_try_native = (
|
|
not is_required or parser.detector.supports_structural_tag()
|
|
)
|
|
if should_try_native and parser.has_tool_call(content):
|
|
try:
|
|
content, call_info_list = parser.parse_non_stream(content)
|
|
for call_info in call_info_list:
|
|
tool_call_items.append(
|
|
ResponseFunctionToolCall(
|
|
arguments=call_info.parameters or "",
|
|
call_id=f"call_{random_uuid()[:24]}",
|
|
type="function_call",
|
|
name=call_info.name,
|
|
id=f"fc_{random_uuid()[:8]}",
|
|
status="completed",
|
|
)
|
|
)
|
|
parsed_via_native = bool(call_info_list)
|
|
except Exception as e:
|
|
logger.error("Tool call parsing error: %s", e)
|
|
|
|
if content and chat_tools and is_required and not parsed_via_native:
|
|
try:
|
|
tool_call_data = orjson.loads(content)
|
|
if isinstance(tool_call_data, dict):
|
|
tool_call_data = [tool_call_data]
|
|
if isinstance(tool_call_data, list):
|
|
for tool in tool_call_data:
|
|
if not isinstance(tool, dict) or "name" not in tool:
|
|
continue
|
|
arguments = json.dumps(
|
|
tool.get("parameters", {}), ensure_ascii=False
|
|
)
|
|
tool_call_items.append(
|
|
ResponseFunctionToolCall(
|
|
arguments=arguments,
|
|
call_id=f"call_{random_uuid()[:24]}",
|
|
type="function_call",
|
|
name=tool["name"],
|
|
id=f"fc_{random_uuid()[:8]}",
|
|
status="completed",
|
|
)
|
|
)
|
|
content = ""
|
|
except Exception as e:
|
|
logger.error("Required tool JSON parse error: %s", e)
|
|
|
|
if content:
|
|
output_text = ResponseOutputText(
|
|
text=content,
|
|
annotations=[], # TODO
|
|
type="output_text",
|
|
logprobs=None, # TODO
|
|
)
|
|
message = ResponseOutputMessage(
|
|
id=f"msg_{random_uuid()}",
|
|
content=[output_text],
|
|
role="assistant",
|
|
status="completed",
|
|
type="message",
|
|
)
|
|
output_items.append(message)
|
|
output_items.extend(tool_call_items)
|
|
return output_items
|
|
|
|
def _make_response_output_items_with_harmony(
|
|
self,
|
|
context: HarmonyContext,
|
|
):
|
|
output_items = []
|
|
num_init_messages = context.num_init_messages
|
|
for msg in context.messages[num_init_messages:]:
|
|
output_items.extend(parse_output_message(msg))
|
|
# Handle the generation stopped in the middle (if any).
|
|
last_items = parse_remaining_state(context.parser)
|
|
if last_items:
|
|
output_items.extend(last_items)
|
|
return output_items
|
|
|
|
@staticmethod
|
|
def _response_tools_to_chat_tools(request: ResponsesRequest) -> list[Tool]:
|
|
# Only ``function`` tools flow to chat; built-ins go through harmony.
|
|
chat_tools = []
|
|
for tool in request.tools:
|
|
if tool.type != "function":
|
|
continue
|
|
chat_tools.append(
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name=tool.name,
|
|
description=tool.description,
|
|
parameters=tool.parameters,
|
|
strict=tool.strict,
|
|
),
|
|
)
|
|
)
|
|
return chat_tools
|
|
|
|
@staticmethod
|
|
def _normalize_response_content_part_for_chat(content_part: Any) -> Any:
|
|
# Default detail=\"auto\" and lift flat min/max_dynamic_patch onto
|
|
# image_url so the image preprocessor sees them.
|
|
if hasattr(content_part, "model_dump"):
|
|
content_part = content_part.model_dump(exclude_none=True)
|
|
if not isinstance(content_part, dict):
|
|
return content_part
|
|
|
|
part_type = content_part.get("type")
|
|
if part_type in ("input_text", "output_text"):
|
|
return {"type": "text", "text": content_part.get("text", "")}
|
|
|
|
if part_type == "input_image":
|
|
image_url = content_part.get("image_url")
|
|
if isinstance(image_url, dict):
|
|
image_url_obj = image_url.copy()
|
|
else:
|
|
image_url_obj = {"url": image_url}
|
|
if not image_url_obj.get("detail"):
|
|
image_url_obj["detail"] = content_part.get("detail") or "auto"
|
|
for key in ("min_dynamic_patch", "max_dynamic_patch"):
|
|
if key in content_part and key not in image_url_obj:
|
|
image_url_obj[key] = content_part[key]
|
|
return {"type": "image_url", "image_url": image_url_obj}
|
|
|
|
if part_type == "text":
|
|
return content_part
|
|
|
|
if part_type == "image_url":
|
|
image_url = content_part.get("image_url")
|
|
if isinstance(image_url, str):
|
|
image_url = {
|
|
"url": image_url,
|
|
"detail": content_part.get("detail", "auto"),
|
|
}
|
|
elif isinstance(image_url, dict):
|
|
image_url = image_url.copy()
|
|
if not image_url.get("detail"):
|
|
image_url["detail"] = content_part.get("detail") or "auto"
|
|
return {**content_part, "image_url": image_url}
|
|
|
|
return content_part
|
|
|
|
@classmethod
|
|
def _normalize_response_message_for_chat(cls, message: Any) -> Any:
|
|
"""Convert one Responses-API input item to a chat-completions message."""
|
|
if hasattr(message, "model_dump"):
|
|
message = message.model_dump(exclude_none=True)
|
|
if not isinstance(message, dict):
|
|
return message
|
|
|
|
# Most chat templates only recognize system/user/assistant/tool;
|
|
# collapse ``developer`` to ``system`` at the boundary.
|
|
if message.get("role") == "developer":
|
|
message = {**message, "role": "system"}
|
|
|
|
msg_type = message.get("type")
|
|
if msg_type == "function_call":
|
|
# Coerce ``arguments`` to a valid JSON-object string so the chat
|
|
# template's unconditional ``orjson.loads`` survives truncated or
|
|
# dict-shaped echoes.
|
|
raw = message.get("arguments")
|
|
if isinstance(raw, str):
|
|
try:
|
|
parsed = orjson.loads(raw) if raw else None
|
|
except orjson.JSONDecodeError:
|
|
parsed = None
|
|
if not isinstance(parsed, dict):
|
|
raw = "{}"
|
|
elif isinstance(raw, dict):
|
|
raw = orjson.dumps(raw).decode("utf-8")
|
|
else:
|
|
raw = "{}"
|
|
return {
|
|
"role": "assistant",
|
|
"tool_calls": [
|
|
{
|
|
"id": message.get("call_id") or message.get("id"),
|
|
"type": "function",
|
|
"function": {
|
|
"name": message.get("name"),
|
|
"arguments": raw,
|
|
},
|
|
}
|
|
],
|
|
}
|
|
if msg_type == "function_call_output":
|
|
return {
|
|
"role": "tool",
|
|
"tool_call_id": message.get("call_id"),
|
|
"content": message.get("output", ""),
|
|
}
|
|
# Reasoning items render as {role: assistant, reasoning_content};
|
|
# empty ones drop instead of injecting an empty assistant block.
|
|
if msg_type == "reasoning":
|
|
# Prefer ``summary``; fall back to ``content`` only when summary
|
|
# is empty, since clients often populate both with the same text.
|
|
def _collect(parts):
|
|
out: list[str] = []
|
|
for entry in parts or []:
|
|
if isinstance(entry, dict):
|
|
text = entry.get("text")
|
|
if text:
|
|
out.append(text)
|
|
return out
|
|
|
|
text_parts = _collect(message.get("summary"))
|
|
if not text_parts:
|
|
text_parts = _collect(message.get("content"))
|
|
if not text_parts:
|
|
return None
|
|
return {
|
|
"role": "assistant",
|
|
"reasoning_content": "\n".join(text_parts),
|
|
}
|
|
if msg_type not in (None, "message"):
|
|
raise ValueError(f"Unsupported Responses API input item type: {msg_type!r}")
|
|
|
|
content = message.get("content")
|
|
if not isinstance(content, list):
|
|
return {
|
|
k: v
|
|
for k, v in message.items()
|
|
if v is not None and k not in ("id", "status", "type")
|
|
}
|
|
|
|
return {
|
|
k: v
|
|
for k, v in {
|
|
**message,
|
|
"content": [
|
|
cls._normalize_response_content_part_for_chat(part)
|
|
for part in content
|
|
],
|
|
}.items()
|
|
if v is not None and k not in ("id", "status", "type")
|
|
}
|
|
|
|
@staticmethod
|
|
def _output_message_text(output_item: Any) -> Optional[str]:
|
|
"""Return assistant text from a ``message`` output item (joining
|
|
``output_text`` parts with newlines), or None for non-message items."""
|
|
if isinstance(output_item, ResponseReasoningItem):
|
|
return None
|
|
if hasattr(output_item, "model_dump"):
|
|
output_item = output_item.model_dump(exclude_none=True)
|
|
if not isinstance(output_item, dict):
|
|
return None
|
|
if output_item.get("type") != "message":
|
|
return None
|
|
|
|
text_parts = []
|
|
for content in output_item.get("content") or []:
|
|
if isinstance(content, ResponseOutputText):
|
|
text_parts.append(content.text)
|
|
continue
|
|
if hasattr(content, "model_dump"):
|
|
content = content.model_dump(exclude_none=True)
|
|
if isinstance(content, dict) and content.get("type") == "output_text":
|
|
text = content.get("text")
|
|
if text is not None:
|
|
text_parts.append(text)
|
|
|
|
return "\n".join(text_parts) if text_parts else None
|
|
|
|
@staticmethod
|
|
def _merge_consecutive_assistant_messages(
|
|
messages: list,
|
|
) -> list:
|
|
"""Collapse runs of consecutive ``assistant`` dicts into one entry,
|
|
joining ``content`` and concatenating ``tool_calls`` and
|
|
``reasoning_content`` so a logical turn renders as a single block."""
|
|
merged: list = []
|
|
for msg in messages:
|
|
if (
|
|
isinstance(msg, dict)
|
|
and msg.get("role") == "assistant"
|
|
and merged
|
|
and isinstance(merged[-1], dict)
|
|
and merged[-1].get("role") == "assistant"
|
|
):
|
|
prev = merged[-1] = dict(merged[-1])
|
|
# Lift mixed str/list content to list parts so non-text parts
|
|
# (e.g. image_url) survive when the two sides differ in shape.
|
|
new_content = msg.get("content")
|
|
if new_content is not None and new_content != "":
|
|
prev_content = prev.get("content")
|
|
if prev_content is None or prev_content == "":
|
|
prev["content"] = new_content
|
|
elif isinstance(prev_content, str) and isinstance(new_content, str):
|
|
sep = "\n\n" if prev_content and new_content else ""
|
|
prev["content"] = prev_content + sep + new_content
|
|
else:
|
|
|
|
def _as_parts(c):
|
|
if isinstance(c, list):
|
|
return list(c)
|
|
if isinstance(c, str) and c:
|
|
return [{"type": "text", "text": c}]
|
|
return []
|
|
|
|
prev["content"] = _as_parts(prev_content) + _as_parts(
|
|
new_content
|
|
)
|
|
new_calls = msg.get("tool_calls")
|
|
if new_calls:
|
|
prev_calls = prev.get("tool_calls") or []
|
|
prev["tool_calls"] = prev_calls + list(new_calls)
|
|
new_reasoning = msg.get("reasoning_content")
|
|
if new_reasoning:
|
|
prev_reasoning = prev.get("reasoning_content")
|
|
prev["reasoning_content"] = (
|
|
f"{prev_reasoning}\n{new_reasoning}"
|
|
if prev_reasoning
|
|
else new_reasoning
|
|
)
|
|
continue
|
|
merged.append(msg)
|
|
return merged
|
|
|
|
def _construct_input_messages(
|
|
self,
|
|
request: ResponsesRequest,
|
|
prev_response: Optional[ResponsesResponse] = None,
|
|
) -> list[ChatCompletionMessageParam]:
|
|
messages: list[ChatCompletionMessageParam] = []
|
|
if request.instructions:
|
|
messages.append(
|
|
{
|
|
"role": "system",
|
|
"content": request.instructions,
|
|
}
|
|
)
|
|
|
|
# Prepend the conversation history
|
|
if prev_response is not None:
|
|
# Add the previous messages
|
|
prev_msg = self.msg_store[prev_response.id]
|
|
messages.extend(prev_msg)
|
|
|
|
for output_item in prev_response.output:
|
|
assistant_text = self._output_message_text(output_item)
|
|
if assistant_text is None:
|
|
continue
|
|
messages.append({"role": "assistant", "content": assistant_text})
|
|
|
|
# Append the new input
|
|
# Responses API supports simple text inputs without chat format
|
|
if isinstance(request.input, str):
|
|
messages.append({"role": "user", "content": request.input})
|
|
else:
|
|
for input_item in request.input:
|
|
normalized = self._normalize_response_message_for_chat(input_item)
|
|
if normalized is not None:
|
|
messages.append(normalized) # type: ignore
|
|
|
|
# One Responses-API assistant turn maps to multiple input items
|
|
# (message + function_call(s)); collapse them into one chat message
|
|
# so chat templates render a single assistant block per turn.
|
|
messages = self._merge_consecutive_assistant_messages(messages)
|
|
|
|
# Most chat templates expect a single leading ``system`` message;
|
|
# coalesce any ``instructions`` + interleaved ``developer`` entries.
|
|
system_chunks: list[str] = []
|
|
other_msgs: list = []
|
|
for m in messages:
|
|
if isinstance(m, dict) and m.get("role") == "system":
|
|
content = m.get("content")
|
|
if isinstance(content, str):
|
|
system_chunks.append(content)
|
|
elif isinstance(content, list):
|
|
for part in content:
|
|
if isinstance(part, dict):
|
|
text = part.get("text")
|
|
if isinstance(text, str):
|
|
system_chunks.append(text)
|
|
else:
|
|
other_msgs.append(m)
|
|
if system_chunks:
|
|
return [
|
|
{"role": "system", "content": "\n\n".join(system_chunks)}
|
|
] + other_msgs
|
|
return other_msgs
|
|
|
|
def _construct_input_messages_with_harmony(
|
|
self,
|
|
request: ResponsesRequest,
|
|
prev_response: Optional[ResponsesResponse],
|
|
) -> list[OpenAIMessage]:
|
|
messages: list[OpenAIMessage] = []
|
|
if prev_response is None:
|
|
# New conversation.
|
|
reasoning_effort = request.reasoning.effort if request.reasoning else None
|
|
tool_types = [tool.type for tool in request.tools]
|
|
enable_browser = (
|
|
any(t in tool_types for t in ("web_search", "web_search_preview"))
|
|
and self.tool_server is not None
|
|
)
|
|
enable_code_interpreter = (
|
|
"code_interpreter" in tool_types and self.tool_server is not None
|
|
)
|
|
sys_msg = get_system_message(
|
|
reasoning_effort=reasoning_effort,
|
|
browser_description=(
|
|
self.tool_server.get_tool_description("browser")
|
|
if self.tool_server and enable_browser
|
|
else None
|
|
),
|
|
python_description=(
|
|
self.tool_server.get_tool_description("python")
|
|
if self.tool_server and enable_code_interpreter
|
|
else None
|
|
),
|
|
)
|
|
messages.append(sys_msg)
|
|
dev_msg = get_developer_message(request.instructions, request.tools)
|
|
messages.append(dev_msg)
|
|
else:
|
|
# Continue the previous conversation.
|
|
# FIXME: Currently, request params like reasoning and
|
|
# instructions are ignored.
|
|
prev_msgs = self.msg_store[prev_response.id]
|
|
# Remove the previous chain-of-thoughts if there is a new "final"
|
|
# message.
|
|
if (
|
|
len(prev_msgs) > 0
|
|
and hasattr(prev_msgs[-1], "channel")
|
|
and prev_msgs[-1].channel == "final"
|
|
): # type: ignore[union-attr]
|
|
prev_final_msg_idx = -1
|
|
for i in range(len(prev_msgs) - 2, -1, -1):
|
|
if (
|
|
hasattr(prev_msgs[i], "channel")
|
|
and prev_msgs[i].channel == "final"
|
|
): # type: ignore[union-attr]
|
|
prev_final_msg_idx = i
|
|
break
|
|
recent_turn_msgs = prev_msgs[prev_final_msg_idx + 1 :]
|
|
del prev_msgs[prev_final_msg_idx + 1 :]
|
|
for msg in recent_turn_msgs:
|
|
if (
|
|
hasattr(msg, "channel") and msg.channel != "analysis"
|
|
): # type: ignore[union-attr]
|
|
prev_msgs.append(msg)
|
|
messages.extend(prev_msgs)
|
|
# Append the new input.
|
|
# Responses API supports simple text inputs without chat format.
|
|
if isinstance(request.input, str):
|
|
messages.append(get_user_message(request.input))
|
|
else:
|
|
if prev_response is not None:
|
|
prev_outputs = list(prev_response.output)
|
|
else:
|
|
prev_outputs = []
|
|
for response_msg in request.input:
|
|
messages.append(parse_response_input(response_msg, prev_outputs))
|
|
if isinstance(response_msg, ResponseFunctionToolCall):
|
|
prev_outputs.append(response_msg)
|
|
return messages
|
|
|
|
async def _run_background_request(
|
|
self,
|
|
request: ResponsesRequest,
|
|
sampling_params: Any,
|
|
result_generator: AsyncIterator[Any],
|
|
context: ConversationContext,
|
|
model_name: str,
|
|
tokenizer: Any,
|
|
request_metadata: RequestResponseMetadata,
|
|
created_time: Optional[int] = None,
|
|
*args,
|
|
**kwargs,
|
|
):
|
|
try:
|
|
# Update the status to "in_progress"
|
|
async with self.response_store_lock:
|
|
stored_response = self.response_store.get(request.request_id)
|
|
assert stored_response is not None
|
|
stored_response.status = "in_progress"
|
|
|
|
response = await self.responses_full_generator(
|
|
request,
|
|
sampling_params,
|
|
result_generator,
|
|
context,
|
|
model_name,
|
|
tokenizer,
|
|
request_metadata,
|
|
created_time,
|
|
*args,
|
|
**kwargs,
|
|
)
|
|
except Exception as e:
|
|
logger.exception("Background request failed for %s", request.request_id)
|
|
response = self.create_error_response(str(e))
|
|
|
|
if isinstance(response, ORJSONResponse):
|
|
# If the request has failed, update the status to "failed"
|
|
response_id = request.request_id
|
|
async with self.response_store_lock:
|
|
stored_response = self.response_store.get(response_id)
|
|
assert stored_response is not None
|
|
if stored_response.status not in ("completed", "cancelled"):
|
|
stored_response.status = "failed"
|
|
|
|
async def retrieve_responses(
|
|
self,
|
|
response_id: str,
|
|
) -> Union[ResponsesResponse, ORJSONResponse]:
|
|
if not response_id.startswith("resp_"):
|
|
return self._make_invalid_id_error(response_id)
|
|
|
|
async with self.response_store_lock:
|
|
response = self.response_store.get(response_id)
|
|
|
|
if response is None:
|
|
return self._make_not_found_error(response_id)
|
|
return response
|
|
|
|
async def cancel_responses(
|
|
self,
|
|
response_id: str,
|
|
) -> Union[ResponsesResponse, ORJSONResponse]:
|
|
if not response_id.startswith("resp_"):
|
|
return self._make_invalid_id_error(response_id)
|
|
|
|
async with self.response_store_lock:
|
|
response = self.response_store.get(response_id)
|
|
if response is None:
|
|
return self._make_not_found_error(response_id)
|
|
|
|
prev_status = response.status
|
|
if prev_status not in ("queued", "in_progress"):
|
|
return self.create_error_response(
|
|
err_type="invalid_request_error",
|
|
message="Cannot cancel a synchronous response.",
|
|
)
|
|
|
|
# Update the status to "cancelled"
|
|
response.status = "cancelled"
|
|
|
|
# The response_id is the same as the rid used when submitting the request
|
|
self.tokenizer_manager.abort_request(rid=response_id)
|
|
|
|
if task := self.background_tasks.get(response_id):
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
logger.exception("Background task for %s was cancelled", response_id)
|
|
return response
|
|
|
|
def _make_invalid_id_error(self, response_id: str):
|
|
return self.create_error_response(
|
|
message=(
|
|
f"Invalid 'response_id': '{response_id}'. "
|
|
"Expected an ID that begins with 'resp'."
|
|
),
|
|
err_type="invalid_request_error",
|
|
param="response_id",
|
|
)
|
|
|
|
def _make_not_found_error(self, response_id: str):
|
|
return self.create_error_response(
|
|
message=f"Response with id '{response_id}' not found.",
|
|
err_type="invalid_request_error",
|
|
status_code=HTTPStatus.NOT_FOUND,
|
|
param="response_id",
|
|
)
|
|
|
|
async def responses_stream_generator(
|
|
self,
|
|
request: ResponsesRequest,
|
|
sampling_params: Any,
|
|
result_generator: AsyncIterator[StreamingHarmonyContext],
|
|
context: StreamingHarmonyContext,
|
|
model_name: str,
|
|
tokenizer: Any,
|
|
request_metadata: RequestResponseMetadata,
|
|
created_time: Optional[int] = None,
|
|
) -> AsyncGenerator[str, None]:
|
|
# TODO:
|
|
# 1. Handle disconnect
|
|
|
|
created_time = created_time or int(time.time())
|
|
|
|
sequence_number = 0
|
|
|
|
def _send_event(event):
|
|
nonlocal sequence_number
|
|
# Set sequence_number if the event has this attribute
|
|
if hasattr(event, "sequence_number"):
|
|
event.sequence_number = sequence_number
|
|
sequence_number += 1
|
|
# Get event type from the event's type field if it exists
|
|
event_type = getattr(event, "type", "unknown")
|
|
return (
|
|
f"event: {event_type}\n"
|
|
f"data: {event.model_dump_json(indent=None)}\n\n"
|
|
)
|
|
|
|
current_content_index = 0
|
|
current_output_index = 0
|
|
current_item_id = f"item_{random_uuid()}"
|
|
sent_output_item_added = False
|
|
|
|
initial_response = ResponsesResponse.from_request(
|
|
request,
|
|
sampling_params,
|
|
model_name=model_name,
|
|
created_time=created_time,
|
|
output=[],
|
|
status="in_progress",
|
|
usage=None,
|
|
).model_dump()
|
|
yield _send_event(
|
|
openai_responses_types.ResponseCreatedEvent(
|
|
type="response.created",
|
|
sequence_number=-1,
|
|
response=initial_response,
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseInProgressEvent(
|
|
type="response.in_progress",
|
|
sequence_number=-1,
|
|
response=initial_response,
|
|
)
|
|
)
|
|
|
|
async for ctx in result_generator:
|
|
|
|
# Only process context objects that implement the `is_expecting_start()` method,
|
|
# which indicates they support per-turn streaming (e.g., StreamingHarmonyContext).
|
|
# Contexts without this method are skipped, as they do not represent a new turn
|
|
# or are not compatible with per-turn handling in the /v1/responses endpoint.
|
|
if not hasattr(ctx, "is_expecting_start"):
|
|
continue
|
|
|
|
if ctx.is_expecting_start():
|
|
current_output_index += 1
|
|
sent_output_item_added = False
|
|
|
|
if len(ctx.parser.messages) > 0:
|
|
previous_item = ctx.parser.messages[-1]
|
|
if previous_item.recipient is not None:
|
|
# Deal with tool call here
|
|
pass
|
|
elif previous_item.channel == "analysis":
|
|
reasoning_item = ResponseReasoningItem(
|
|
id=f"rs_{random_uuid()}",
|
|
type="reasoning",
|
|
summary=[],
|
|
content=[
|
|
ResponseReasoningTextContent(
|
|
text=previous_item.content[0].text,
|
|
type="reasoning_text",
|
|
),
|
|
],
|
|
status="completed",
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseReasoningTextDoneEvent(
|
|
type="response.reasoning_text.done",
|
|
item_id=current_item_id,
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
content_index=current_content_index,
|
|
text=previous_item.content[0].text,
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseOutputItemDoneEvent(
|
|
type="response.output_item.done",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item=reasoning_item,
|
|
)
|
|
)
|
|
elif previous_item.channel == "final":
|
|
text_content = openai_responses_types.ResponseOutputText(
|
|
type="output_text",
|
|
text=previous_item.content[0].text,
|
|
annotations=[],
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseTextDoneEvent(
|
|
type="response.output_text.done",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
content_index=current_content_index,
|
|
text=previous_item.content[0].text,
|
|
logprobs=[],
|
|
item_id=current_item_id,
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseContentPartDoneEvent(
|
|
type="response.content_part.done",
|
|
sequence_number=-1,
|
|
item_id=current_item_id,
|
|
output_index=current_output_index,
|
|
content_index=current_content_index,
|
|
part=text_content,
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseOutputItemDoneEvent(
|
|
type="response.output_item.done",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item=openai_responses_types.ResponseOutputMessage(
|
|
id=current_item_id,
|
|
type="message",
|
|
role="assistant",
|
|
content=[text_content],
|
|
status="completed",
|
|
),
|
|
)
|
|
)
|
|
|
|
if ctx.parser.last_content_delta:
|
|
if (
|
|
ctx.parser.current_channel == "final"
|
|
and ctx.parser.current_recipient is None
|
|
):
|
|
if not sent_output_item_added:
|
|
sent_output_item_added = True
|
|
yield _send_event(
|
|
openai_responses_types.ResponseOutputItemAddedEvent(
|
|
type="response.output_item.added",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item=openai_responses_types.ResponseOutputMessage(
|
|
id=current_item_id,
|
|
type="message",
|
|
role="assistant",
|
|
content=[],
|
|
status="in_progress",
|
|
),
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseContentPartAddedEvent(
|
|
type="response.content_part.added",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item_id=current_item_id,
|
|
content_index=current_content_index,
|
|
part=openai_responses_types.ResponseOutputText(
|
|
type="output_text",
|
|
text="",
|
|
annotations=[],
|
|
logprobs=None,
|
|
),
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseTextDeltaEvent(
|
|
type="response.output_text.delta",
|
|
sequence_number=-1,
|
|
content_index=current_content_index,
|
|
output_index=current_output_index,
|
|
item_id=current_item_id,
|
|
delta=ctx.parser.last_content_delta,
|
|
# TODO, use logprobs from ctx.last_request_output
|
|
logprobs=[],
|
|
)
|
|
)
|
|
elif (
|
|
ctx.parser.current_channel == "analysis"
|
|
and ctx.parser.current_recipient is None
|
|
):
|
|
if not sent_output_item_added:
|
|
sent_output_item_added = True
|
|
yield _send_event(
|
|
openai_responses_types.ResponseOutputItemAddedEvent(
|
|
type="response.output_item.added",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item=openai_responses_types.ResponseReasoningItem(
|
|
type="reasoning",
|
|
id=current_item_id,
|
|
summary=[],
|
|
status="in_progress",
|
|
),
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseContentPartAddedEvent(
|
|
type="response.content_part.added",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item_id=current_item_id,
|
|
content_index=current_content_index,
|
|
# TODO: migrate this to
|
|
# ResponseReasoningTextContent for now
|
|
part=openai_responses_types.ResponseOutputText(
|
|
type="output_text",
|
|
text="",
|
|
annotations=[],
|
|
logprobs=None,
|
|
),
|
|
)
|
|
)
|
|
# TODO: migrate to OpenAI types once updated.
|
|
yield _send_event(
|
|
openai_responses_types.ResponseReasoningTextDeltaEvent(
|
|
type="response.reasoning_text.delta",
|
|
item_id=current_item_id,
|
|
output_index=current_output_index,
|
|
content_index=current_content_index,
|
|
delta=ctx.parser.last_content_delta,
|
|
sequence_number=-1,
|
|
)
|
|
)
|
|
|
|
if ctx.is_assistant_action_turn() and len(ctx.parser.messages) > 0:
|
|
previous_item = ctx.parser.messages[-1]
|
|
if (
|
|
self.supports_browsing
|
|
and previous_item.recipient is not None
|
|
and previous_item.recipient.startswith("browser.")
|
|
):
|
|
function_name = previous_item.recipient[len("browser.") :]
|
|
action = None
|
|
parsed_args = orjson.loads(previous_item.content[0].text)
|
|
if function_name == "search":
|
|
action = openai_responses_types.response_function_web_search.ActionSearch(
|
|
type="search",
|
|
query=parsed_args["query"],
|
|
)
|
|
elif function_name == "open":
|
|
action = openai_responses_types.response_function_web_search.ActionOpenPage(
|
|
type="open_page",
|
|
# TODO: translate to url
|
|
url=f"cursor:{parsed_args.get('cursor', '')}",
|
|
)
|
|
elif function_name == "find":
|
|
action = openai_responses_types.response_function_web_search.ActionFind(
|
|
type="find",
|
|
pattern=parsed_args["pattern"],
|
|
# TODO: translate to url
|
|
url=f"cursor:{parsed_args.get('cursor', '')}",
|
|
)
|
|
else:
|
|
raise ValueError(f"Unknown function name: {function_name}")
|
|
|
|
yield _send_event(
|
|
openai_responses_types.ResponseOutputItemAddedEvent(
|
|
type="response.output_item.added",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item=openai_responses_types.response_function_web_search.ResponseFunctionWebSearch(
|
|
# TODO: generate a unique id for web search call
|
|
type="web_search_call",
|
|
id=current_item_id,
|
|
action=action,
|
|
status="in_progress",
|
|
),
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseWebSearchCallInProgressEvent(
|
|
type="response.web_search_call.in_progress",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item_id=current_item_id,
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseWebSearchCallSearchingEvent(
|
|
type="response.web_search_call.searching",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item_id=current_item_id,
|
|
)
|
|
)
|
|
|
|
# enqueue
|
|
yield _send_event(
|
|
openai_responses_types.ResponseWebSearchCallCompletedEvent(
|
|
type="response.web_search_call.completed",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item_id=current_item_id,
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseOutputItemDoneEvent(
|
|
type="response.output_item.done",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item=openai_responses_types.ResponseFunctionWebSearch(
|
|
type="web_search_call",
|
|
id=current_item_id,
|
|
action=action,
|
|
status="completed",
|
|
),
|
|
)
|
|
)
|
|
|
|
if (
|
|
self.supports_code_interpreter
|
|
and previous_item.recipient is not None
|
|
and previous_item.recipient.startswith("python")
|
|
):
|
|
yield _send_event(
|
|
openai_responses_types.ResponseOutputItemAddedEvent(
|
|
type="response.output_item.added",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item=openai_responses_types.ResponseCodeInterpreterToolCallParam(
|
|
type="code_interpreter_call",
|
|
id=current_item_id,
|
|
code="",
|
|
container_id="auto",
|
|
outputs=[],
|
|
status="in_progress",
|
|
),
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseCodeInterpreterCallInProgressEvent(
|
|
type="response.code_interpreter_call.in_progress",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item_id=current_item_id,
|
|
)
|
|
)
|
|
# TODO: do we need to add delta event here?
|
|
yield _send_event(
|
|
openai_responses_types.ResponseCodeInterpreterCallCodeDoneEvent(
|
|
type="response.code_interpreter_call_code.done",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item_id=current_item_id,
|
|
code=previous_item.content[0].text,
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseCodeInterpreterCallInterpretingEvent(
|
|
type="response.code_interpreter_call.interpreting",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item_id=current_item_id,
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseCodeInterpreterCallCompletedEvent(
|
|
type="response.code_interpreter_call.completed",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item_id=current_item_id,
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseOutputItemDoneEvent(
|
|
type="response.output_item.done",
|
|
sequence_number=-1,
|
|
output_index=current_output_index,
|
|
item=openai_responses_types.ResponseCodeInterpreterToolCallParam(
|
|
type="code_interpreter_call",
|
|
id=current_item_id,
|
|
code=previous_item.content[0].text,
|
|
container_id="auto",
|
|
# TODO: add outputs here
|
|
outputs=[],
|
|
status="completed",
|
|
),
|
|
)
|
|
)
|
|
|
|
async def empty_async_generator():
|
|
for _ in ():
|
|
yield
|
|
|
|
final_response = await self.responses_full_generator(
|
|
request,
|
|
sampling_params,
|
|
empty_async_generator(),
|
|
context,
|
|
model_name,
|
|
tokenizer,
|
|
request_metadata,
|
|
created_time=created_time,
|
|
)
|
|
# Convert final_response to the format expected by ResponseCompletedEvent
|
|
response_dict = final_response.model_dump()
|
|
# OpenAI SDK's Tool union may not know extended types; drop echo.
|
|
response_dict["tools"] = []
|
|
|
|
# Convert UsageInfo to ResponseUsage format
|
|
if response_dict.get("usage"):
|
|
usage_info = response_dict["usage"]
|
|
response_dict["usage"] = {
|
|
"input_tokens": usage_info.get("prompt_tokens", 0),
|
|
"input_tokens_details": {
|
|
"cached_tokens": usage_info.get("cached_tokens", 0)
|
|
},
|
|
"output_tokens": usage_info.get("completion_tokens", 0),
|
|
"output_tokens_details": {
|
|
"reasoning_tokens": usage_info.get("reasoning_tokens", 0)
|
|
},
|
|
"total_tokens": usage_info.get("total_tokens", 0),
|
|
}
|
|
|
|
yield _send_event(
|
|
openai_responses_types.ResponseCompletedEvent(
|
|
type="response.completed",
|
|
sequence_number=-1,
|
|
response=response_dict,
|
|
)
|
|
)
|
|
|
|
async def responses_stream_generator_non_harmony(
|
|
self,
|
|
request: ResponsesRequest,
|
|
sampling_params: Any,
|
|
result_generator: AsyncIterator[Any],
|
|
model_name: str,
|
|
tokenizer: Any,
|
|
request_metadata: RequestResponseMetadata,
|
|
created_time: Optional[int] = None,
|
|
) -> AsyncGenerator[str, None]:
|
|
"""Stream a /v1/responses response as typed OpenAI SSE events for
|
|
non-harmony models. Each engine chunk is run through the reasoning
|
|
and function-call parsers; leftover text becomes
|
|
``response.output_text.delta``.
|
|
"""
|
|
|
|
created_time = created_time or int(time.time())
|
|
sequence_number = 0
|
|
|
|
def _send_event(event):
|
|
nonlocal sequence_number
|
|
if hasattr(event, "sequence_number"):
|
|
event.sequence_number = sequence_number
|
|
sequence_number += 1
|
|
event_type = getattr(event, "type", "unknown")
|
|
return (
|
|
f"event: {event_type}\n"
|
|
f"data: {event.model_dump_json(indent=None)}\n\n"
|
|
)
|
|
|
|
# The streaming Response* event models echo ``tools`` through a
|
|
# narrower OpenAI SDK Tool union; strip it to avoid pydantic
|
|
# validation failures on extended tool types.
|
|
def _sanitize_response_dict(d: dict) -> dict:
|
|
d["tools"] = []
|
|
return d
|
|
|
|
initial_response = _sanitize_response_dict(
|
|
ResponsesResponse.from_request(
|
|
request,
|
|
sampling_params,
|
|
model_name=model_name,
|
|
created_time=created_time,
|
|
output=[],
|
|
status="in_progress",
|
|
usage=None,
|
|
).model_dump()
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseCreatedEvent(
|
|
type="response.created",
|
|
sequence_number=-1,
|
|
response=initial_response,
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseInProgressEvent(
|
|
type="response.in_progress",
|
|
sequence_number=-1,
|
|
response=initial_response,
|
|
)
|
|
)
|
|
|
|
chat_tools = self._response_tools_to_chat_tools(request)
|
|
is_required = request.tool_choice == "required"
|
|
tool_parser: Optional[Union[FunctionCallParser, JsonArrayParser]] = None
|
|
if chat_tools and request.tool_choice != "none":
|
|
native_supports_structural_tag = False
|
|
if self.tool_call_parser:
|
|
probe = FunctionCallParser(
|
|
chat_tools,
|
|
self.tool_call_parser,
|
|
tokenizer=self.tokenizer_manager.tokenizer,
|
|
)
|
|
native_supports_structural_tag = (
|
|
probe.detector.supports_structural_tag()
|
|
)
|
|
if is_required and not native_supports_structural_tag:
|
|
tool_parser = JsonArrayParser()
|
|
elif self.tool_call_parser:
|
|
tool_parser = FunctionCallParser(
|
|
chat_tools,
|
|
self.tool_call_parser,
|
|
tokenizer=self.tokenizer_manager.tokenizer,
|
|
)
|
|
reasoning_parser_obj: Optional[ReasoningParser] = None
|
|
if self.reasoning_parser:
|
|
reasoning_parser_obj = ReasoningParser(
|
|
model_type=self.reasoning_parser,
|
|
stream_reasoning=True,
|
|
force_reasoning=self._is_thinking_enabled_for_request(request),
|
|
request=request,
|
|
tokenizer=self.tokenizer_manager.tokenizer,
|
|
)
|
|
|
|
current_output_index = -1
|
|
reasoning_state = {
|
|
"open": False,
|
|
"item_id": "",
|
|
"output_index": -1,
|
|
"text": "",
|
|
}
|
|
message_state = {
|
|
"open": False,
|
|
"item_id": "",
|
|
"output_index": -1,
|
|
"text": "",
|
|
}
|
|
tool_call_states: dict[int, dict[str, Any]] = {}
|
|
# Items closed during the stream, in wire order. Feeds the final
|
|
# ``response.completed`` snapshot and the stored response.
|
|
emitted_items: list = []
|
|
|
|
prompt_tokens = 0
|
|
completion_tokens = 0
|
|
cached_tokens = 0
|
|
total_tokens_meta = 0
|
|
reasoning_tokens_meta = 0
|
|
finish_reason: Optional[dict[str, Any]] = None
|
|
stream_offset = 0
|
|
incremental = self.tokenizer_manager.server_args.incremental_streaming_output
|
|
|
|
def _open_reasoning_item() -> str:
|
|
nonlocal current_output_index
|
|
current_output_index += 1
|
|
item_id = f"rs_{random_uuid()}"
|
|
reasoning_state.update(
|
|
open=True, item_id=item_id, output_index=current_output_index, text=""
|
|
)
|
|
return item_id
|
|
|
|
wants_summary = self._wants_reasoning_summary(request)
|
|
|
|
def _close_reasoning_item():
|
|
if not reasoning_state["open"]:
|
|
return []
|
|
text = reasoning_state["text"]
|
|
completed_item = ResponseReasoningItem(
|
|
id=reasoning_state["item_id"],
|
|
type="reasoning",
|
|
summary=(
|
|
[ResponseReasoningSummary(type="summary_text", text=text)]
|
|
if wants_summary
|
|
else []
|
|
),
|
|
content=[
|
|
ResponseReasoningTextContent(type="reasoning_text", text=text),
|
|
],
|
|
status="completed",
|
|
)
|
|
events: list = []
|
|
if wants_summary:
|
|
events.append(
|
|
_send_event(
|
|
openai_responses_types.ResponseReasoningSummaryTextDoneEvent(
|
|
type="response.reasoning_summary_text.done",
|
|
item_id=reasoning_state["item_id"],
|
|
sequence_number=-1,
|
|
output_index=reasoning_state["output_index"],
|
|
summary_index=0,
|
|
text=text,
|
|
)
|
|
)
|
|
)
|
|
events.append(
|
|
_send_event(
|
|
openai_responses_types.ResponseReasoningSummaryPartDoneEvent(
|
|
type="response.reasoning_summary_part.done",
|
|
item_id=reasoning_state["item_id"],
|
|
sequence_number=-1,
|
|
output_index=reasoning_state["output_index"],
|
|
summary_index=0,
|
|
part=ResponseReasoningSummaryDonePart(
|
|
type="summary_text", text=text
|
|
),
|
|
)
|
|
)
|
|
)
|
|
else:
|
|
events.append(
|
|
_send_event(
|
|
openai_responses_types.ResponseReasoningTextDoneEvent(
|
|
type="response.reasoning_text.done",
|
|
item_id=reasoning_state["item_id"],
|
|
sequence_number=-1,
|
|
output_index=reasoning_state["output_index"],
|
|
content_index=0,
|
|
text=text,
|
|
)
|
|
)
|
|
)
|
|
events += [
|
|
_send_event(
|
|
openai_responses_types.ResponseOutputItemDoneEvent(
|
|
type="response.output_item.done",
|
|
sequence_number=-1,
|
|
output_index=reasoning_state["output_index"],
|
|
item=completed_item,
|
|
)
|
|
),
|
|
]
|
|
emitted_items.append(completed_item)
|
|
reasoning_state["open"] = False
|
|
return events
|
|
|
|
def _open_message_item() -> str:
|
|
nonlocal current_output_index
|
|
current_output_index += 1
|
|
item_id = f"msg_{random_uuid()}"
|
|
message_state.update(
|
|
open=True, item_id=item_id, output_index=current_output_index, text=""
|
|
)
|
|
return item_id
|
|
|
|
def _close_message_item():
|
|
if not message_state["open"]:
|
|
return []
|
|
text = message_state["text"]
|
|
text_content = openai_responses_types.ResponseOutputText(
|
|
type="output_text", text=text, annotations=[], logprobs=None
|
|
)
|
|
completed_item = ResponseOutputMessage(
|
|
id=message_state["item_id"],
|
|
type="message",
|
|
role="assistant",
|
|
content=[text_content],
|
|
status="completed",
|
|
)
|
|
events = [
|
|
_send_event(
|
|
openai_responses_types.ResponseTextDoneEvent(
|
|
type="response.output_text.done",
|
|
sequence_number=-1,
|
|
output_index=message_state["output_index"],
|
|
content_index=0,
|
|
text=text,
|
|
logprobs=[],
|
|
item_id=message_state["item_id"],
|
|
)
|
|
),
|
|
_send_event(
|
|
openai_responses_types.ResponseContentPartDoneEvent(
|
|
type="response.content_part.done",
|
|
sequence_number=-1,
|
|
item_id=message_state["item_id"],
|
|
output_index=message_state["output_index"],
|
|
content_index=0,
|
|
part=text_content,
|
|
)
|
|
),
|
|
_send_event(
|
|
openai_responses_types.ResponseOutputItemDoneEvent(
|
|
type="response.output_item.done",
|
|
sequence_number=-1,
|
|
output_index=message_state["output_index"],
|
|
item=completed_item,
|
|
)
|
|
),
|
|
]
|
|
emitted_items.append(completed_item)
|
|
message_state["open"] = False
|
|
return events
|
|
|
|
def _close_tool_call_state(tool_index: int):
|
|
state = tool_call_states.get(tool_index)
|
|
if state is None or state.get("done"):
|
|
return []
|
|
arguments = state["arguments"]
|
|
completed_item = ResponseFunctionToolCall(
|
|
arguments=arguments,
|
|
call_id=state["call_id"],
|
|
name=state["name"] or "",
|
|
type="function_call",
|
|
id=state["item_id"],
|
|
status="completed",
|
|
)
|
|
events = [
|
|
_send_event(
|
|
openai_responses_types.ResponseFunctionCallArgumentsDoneEvent(
|
|
type="response.function_call_arguments.done",
|
|
sequence_number=-1,
|
|
item_id=state["item_id"],
|
|
output_index=state["output_index"],
|
|
arguments=arguments,
|
|
name=state["name"] or "",
|
|
)
|
|
),
|
|
_send_event(
|
|
openai_responses_types.ResponseOutputItemDoneEvent(
|
|
type="response.output_item.done",
|
|
sequence_number=-1,
|
|
output_index=state["output_index"],
|
|
item=completed_item,
|
|
)
|
|
),
|
|
]
|
|
emitted_items.append(completed_item)
|
|
state["done"] = True
|
|
return events
|
|
|
|
try:
|
|
async for ctx in result_generator:
|
|
if isinstance(ctx, dict):
|
|
chunk = ctx
|
|
else:
|
|
chunk = getattr(ctx, "last_output", None)
|
|
if not isinstance(chunk, dict):
|
|
continue
|
|
meta = chunk.get("meta_info") or {}
|
|
prompt_tokens = meta.get("prompt_tokens", prompt_tokens)
|
|
completion_tokens = meta.get("completion_tokens", completion_tokens)
|
|
cached_tokens = meta.get("cached_tokens", cached_tokens)
|
|
total_tokens_meta = meta.get("total_tokens", total_tokens_meta)
|
|
reasoning_tokens_meta = meta.get(
|
|
"reasoning_tokens", reasoning_tokens_meta
|
|
)
|
|
finish_reason = meta.get("finish_reason") or finish_reason
|
|
|
|
text = chunk.get("text", "") or ""
|
|
if incremental:
|
|
delta = text
|
|
else:
|
|
delta = text[stream_offset:]
|
|
stream_offset = len(text)
|
|
if not delta and finish_reason is None:
|
|
continue
|
|
|
|
if reasoning_parser_obj is not None:
|
|
reasoning_chunk, delta = reasoning_parser_obj.parse_stream_chunk(
|
|
delta
|
|
)
|
|
else:
|
|
reasoning_chunk = None
|
|
|
|
if reasoning_chunk:
|
|
if message_state["open"]:
|
|
for ev in _close_message_item():
|
|
yield ev
|
|
if not reasoning_state["open"]:
|
|
item_id = _open_reasoning_item()
|
|
yield _send_event(
|
|
openai_responses_types.ResponseOutputItemAddedEvent(
|
|
type="response.output_item.added",
|
|
sequence_number=-1,
|
|
output_index=reasoning_state["output_index"],
|
|
item=ResponseReasoningItem(
|
|
id=item_id,
|
|
type="reasoning",
|
|
summary=[],
|
|
content=[],
|
|
status="in_progress",
|
|
),
|
|
)
|
|
)
|
|
# Clients that opt into ``reasoning.summary`` render
|
|
# off the ``reasoning_summary_text.*`` event stream,
|
|
# so mirror the trace into a summary part.
|
|
if wants_summary:
|
|
yield _send_event(
|
|
openai_responses_types.ResponseReasoningSummaryPartAddedEvent(
|
|
type="response.reasoning_summary_part.added",
|
|
item_id=item_id,
|
|
output_index=reasoning_state["output_index"],
|
|
summary_index=0,
|
|
part=ResponseReasoningSummaryAddedPart(
|
|
type="summary_text", text=""
|
|
),
|
|
sequence_number=-1,
|
|
)
|
|
)
|
|
reasoning_state["text"] += reasoning_chunk
|
|
if wants_summary:
|
|
yield _send_event(
|
|
openai_responses_types.ResponseReasoningSummaryTextDeltaEvent(
|
|
type="response.reasoning_summary_text.delta",
|
|
item_id=reasoning_state["item_id"],
|
|
output_index=reasoning_state["output_index"],
|
|
summary_index=0,
|
|
delta=reasoning_chunk,
|
|
sequence_number=-1,
|
|
)
|
|
)
|
|
else:
|
|
yield _send_event(
|
|
openai_responses_types.ResponseReasoningTextDeltaEvent(
|
|
type="response.reasoning_text.delta",
|
|
item_id=reasoning_state["item_id"],
|
|
output_index=reasoning_state["output_index"],
|
|
content_index=0,
|
|
delta=reasoning_chunk,
|
|
sequence_number=-1,
|
|
)
|
|
)
|
|
|
|
if not delta:
|
|
continue
|
|
|
|
if isinstance(tool_parser, JsonArrayParser):
|
|
sp = tool_parser.parse_streaming_increment(delta, chat_tools)
|
|
normal_text, tool_calls = sp.normal_text or "", sp.calls
|
|
elif tool_parser is not None:
|
|
normal_text, tool_calls = tool_parser.parse_stream_chunk(delta)
|
|
else:
|
|
normal_text, tool_calls = delta, []
|
|
|
|
# Close any open tool-call item before opening a message so
|
|
# ``output_item.done`` lands before the next ``added``.
|
|
if normal_text:
|
|
if reasoning_state["open"]:
|
|
for ev in _close_reasoning_item():
|
|
yield ev
|
|
for tool_index in list(tool_call_states):
|
|
for ev in _close_tool_call_state(tool_index):
|
|
yield ev
|
|
if not message_state["open"]:
|
|
item_id = _open_message_item()
|
|
yield _send_event(
|
|
openai_responses_types.ResponseOutputItemAddedEvent(
|
|
type="response.output_item.added",
|
|
sequence_number=-1,
|
|
output_index=message_state["output_index"],
|
|
item=ResponseOutputMessage(
|
|
id=item_id,
|
|
type="message",
|
|
role="assistant",
|
|
content=[],
|
|
status="in_progress",
|
|
),
|
|
)
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseContentPartAddedEvent(
|
|
type="response.content_part.added",
|
|
sequence_number=-1,
|
|
output_index=message_state["output_index"],
|
|
item_id=message_state["item_id"],
|
|
content_index=0,
|
|
part=openai_responses_types.ResponseOutputText(
|
|
type="output_text",
|
|
text="",
|
|
annotations=[],
|
|
logprobs=None,
|
|
),
|
|
)
|
|
)
|
|
message_state["text"] += normal_text
|
|
yield _send_event(
|
|
openai_responses_types.ResponseTextDeltaEvent(
|
|
type="response.output_text.delta",
|
|
sequence_number=-1,
|
|
content_index=0,
|
|
output_index=message_state["output_index"],
|
|
item_id=message_state["item_id"],
|
|
delta=normal_text,
|
|
logprobs=[],
|
|
)
|
|
)
|
|
|
|
if not tool_calls:
|
|
continue
|
|
|
|
if reasoning_state["open"]:
|
|
for ev in _close_reasoning_item():
|
|
yield ev
|
|
if message_state["open"]:
|
|
for ev in _close_message_item():
|
|
yield ev
|
|
|
|
for call in tool_calls:
|
|
tool_index = call.tool_index
|
|
state = tool_call_states.get(tool_index)
|
|
if state is None or state.get("done"):
|
|
current_output_index += 1
|
|
item_id = f"fc_{random_uuid()[:8]}"
|
|
call_id = f"call_{random_uuid()[:24]}"
|
|
state = {
|
|
"item_id": item_id,
|
|
"call_id": call_id,
|
|
"output_index": current_output_index,
|
|
"name": call.name or "",
|
|
"arguments": "",
|
|
"added": False,
|
|
"done": False,
|
|
}
|
|
tool_call_states[tool_index] = state
|
|
if not state["added"]:
|
|
state["added"] = True
|
|
# Capture ``call.name`` before the ``added`` event so
|
|
# the name is set on the first emitted item.
|
|
if call.name and not state["name"]:
|
|
state["name"] = call.name
|
|
yield _send_event(
|
|
openai_responses_types.ResponseOutputItemAddedEvent(
|
|
type="response.output_item.added",
|
|
sequence_number=-1,
|
|
output_index=state["output_index"],
|
|
item=ResponseFunctionToolCall(
|
|
arguments="",
|
|
call_id=state["call_id"],
|
|
name=state["name"],
|
|
type="function_call",
|
|
id=state["item_id"],
|
|
status="in_progress",
|
|
),
|
|
)
|
|
)
|
|
if call.parameters:
|
|
state["arguments"] += call.parameters
|
|
yield _send_event(
|
|
openai_responses_types.ResponseFunctionCallArgumentsDeltaEvent(
|
|
type="response.function_call_arguments.delta",
|
|
sequence_number=-1,
|
|
item_id=state["item_id"],
|
|
output_index=state["output_index"],
|
|
delta=call.parameters,
|
|
)
|
|
)
|
|
except Exception:
|
|
logger.exception("Error while streaming /v1/responses")
|
|
failed = _sanitize_response_dict(
|
|
ResponsesResponse.from_request(
|
|
request,
|
|
sampling_params,
|
|
model_name=model_name,
|
|
created_time=created_time,
|
|
output=[],
|
|
status="failed",
|
|
usage=None,
|
|
).model_dump()
|
|
)
|
|
yield _send_event(
|
|
openai_responses_types.ResponseFailedEvent(
|
|
type="response.failed",
|
|
sequence_number=-1,
|
|
response=failed,
|
|
)
|
|
)
|
|
return
|
|
|
|
for ev in _close_reasoning_item():
|
|
yield ev
|
|
for ev in _close_message_item():
|
|
yield ev
|
|
for tool_index in list(tool_call_states):
|
|
for ev in _close_tool_call_state(tool_index):
|
|
yield ev
|
|
|
|
final_output_items = list(emitted_items)
|
|
|
|
usage = UsageInfo(
|
|
prompt_tokens=prompt_tokens,
|
|
completion_tokens=completion_tokens,
|
|
total_tokens=total_tokens_meta or (prompt_tokens + completion_tokens),
|
|
reasoning_tokens=reasoning_tokens_meta,
|
|
)
|
|
if self.enable_prompt_tokens_details and cached_tokens:
|
|
usage.prompt_tokens_details = PromptTokenUsageInfo(
|
|
cached_tokens=cached_tokens
|
|
)
|
|
request_metadata.final_usage_info = usage
|
|
|
|
final_response = ResponsesResponse.from_request(
|
|
request,
|
|
sampling_params,
|
|
model_name=model_name,
|
|
created_time=created_time,
|
|
output=final_output_items,
|
|
status="completed",
|
|
usage=usage,
|
|
)
|
|
if request.store:
|
|
async with self.response_store_lock:
|
|
stored = self.response_store.get(final_response.id)
|
|
if stored is None or stored.status != "cancelled":
|
|
self.response_store[final_response.id] = final_response
|
|
|
|
response_dict = _sanitize_response_dict(final_response.model_dump())
|
|
if response_dict.get("usage"):
|
|
usage_info = response_dict["usage"]
|
|
response_dict["usage"] = {
|
|
"input_tokens": usage_info.get("prompt_tokens", 0),
|
|
"input_tokens_details": {
|
|
"cached_tokens": cached_tokens,
|
|
},
|
|
"output_tokens": usage_info.get("completion_tokens", 0),
|
|
"output_tokens_details": {
|
|
"reasoning_tokens": reasoning_tokens_meta,
|
|
},
|
|
"total_tokens": usage_info.get("total_tokens", 0),
|
|
}
|
|
|
|
yield _send_event(
|
|
openai_responses_types.ResponseCompletedEvent(
|
|
type="response.completed",
|
|
sequence_number=-1,
|
|
response=response_dict,
|
|
)
|
|
)
|
|
|
|
async def _generate_with_builtin_tools(
|
|
self,
|
|
request_id: str,
|
|
request_prompt: Any,
|
|
adapted_request: GenerateReqInput,
|
|
sampling_params: Any,
|
|
context: ConversationContext,
|
|
raw_request: Optional[Request] = None,
|
|
priority: Optional[int] = None,
|
|
**kwargs,
|
|
) -> AsyncGenerator[Any, None]:
|
|
"""Generate with builtin tool support for harmony-based models."""
|
|
orig_priority = priority or 0
|
|
|
|
while True:
|
|
# Generate using SGLang's tokenizer manager
|
|
generator = self.tokenizer_manager.generate_request(
|
|
adapted_request, raw_request
|
|
)
|
|
|
|
async for res in generator:
|
|
context.append_output(res)
|
|
# NOTE(woosuk): The stop condition is handled by the engine.
|
|
yield context
|
|
|
|
if not context.need_builtin_tool_call():
|
|
# The model did not ask for a tool call, so we're done.
|
|
break
|
|
|
|
# Call the tool and update the context with the result.
|
|
tool_output = await context.call_tool()
|
|
context.append_output(tool_output)
|
|
|
|
# Prepare for the next generation turn
|
|
# Render the updated conversation for the next completion
|
|
prompt_token_ids = context.render_for_completion()
|
|
|
|
# Update the adapted request with new prompt
|
|
adapted_request = GenerateReqInput(
|
|
input_ids=prompt_token_ids,
|
|
sampling_params=sampling_params,
|
|
stream=adapted_request.stream,
|
|
rid=request_id,
|
|
session_id=adapted_request.session_id,
|
|
extra_key=adapted_request.extra_key,
|
|
return_logprob=adapted_request.return_logprob,
|
|
logprob_start_len=adapted_request.logprob_start_len,
|
|
top_logprobs_num=adapted_request.top_logprobs_num,
|
|
return_text_in_logprobs=adapted_request.return_text_in_logprobs,
|
|
return_hidden_states=adapted_request.return_hidden_states,
|
|
background=adapted_request.background,
|
|
)
|
|
|
|
# Update sampling params with reduced max_tokens
|
|
if hasattr(sampling_params, "max_new_tokens") or isinstance(
|
|
sampling_params, dict
|
|
):
|
|
context_len = getattr(
|
|
self.tokenizer_manager.model_config, "context_len", 4096
|
|
)
|
|
num_reserved_tokens = self.tokenizer_manager.num_reserved_tokens
|
|
remaining_tokens = (
|
|
context_len - len(prompt_token_ids) - num_reserved_tokens
|
|
)
|
|
|
|
if isinstance(sampling_params, dict):
|
|
sampling_params["max_new_tokens"] = max(remaining_tokens, 1)
|
|
else:
|
|
sampling_params.max_new_tokens = max(remaining_tokens, 1)
|
|
|
|
# Slightly reduce priority for subsequent tool calls
|
|
priority = orig_priority - 1
|