[BugFix] Optimize turn-based /v1/realtime auto truncation (#8566)
Signed-off-by: Nick Cao <ncao@redhat.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
1 parent
f3743ebdb6
commit
744c202d86
4 files changed
+601
-138
No files matched your search
@@ -3,6 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
@@ -12,6 +13,8 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from openai.types import realtime as types
|
||||
from vllm.exceptions import VLLMValidationError
|
||||
from vllm.sampling_params import RequestOutputKind, SamplingParams
|
||||
|
||||
from tests.helpers.serving_chat import build_serving_chat
|
||||
@@ -20,7 +23,10 @@ from vllm_omni.entrypoints.openai.realtime.connection import (
|
||||
OpenAIFullDuplexConnection,
|
||||
_ResolvedResponse,
|
||||
)
|
||||
from vllm_omni.entrypoints.openai.realtime.session import ActiveResponse
|
||||
from vllm_omni.entrypoints.openai.realtime.session import (
|
||||
ActiveResponse,
|
||||
AudioFullDuplexSessionState,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -31,9 +37,13 @@ class _FakeRenderer:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.raw_prompt_lengths: list[int] = []
|
||||
self.message_counts: list[int] = []
|
||||
self.skip_mm_cache: list[bool] = []
|
||||
self.tokenization_params: list[Any] = []
|
||||
self.engine_inputs: list[dict[str, Any]] = []
|
||||
self.conversations: list[list[dict[str, Any]]] = []
|
||||
self.token_counts: dict[int, int] = {}
|
||||
self.errors: dict[int, VLLMValidationError] = {}
|
||||
|
||||
async def render_chat_async(
|
||||
self,
|
||||
@@ -41,12 +51,24 @@ class _FakeRenderer:
|
||||
_chat_params: Any,
|
||||
tok_params: Any,
|
||||
):
|
||||
message_count = len(conversations[0])
|
||||
self.message_counts.append(message_count)
|
||||
self.conversations.append(conversations[0])
|
||||
raw_prompt_length = 2 * len(conversations[0])
|
||||
self.raw_prompt_lengths.append(raw_prompt_length)
|
||||
self.tokenization_params.append(tok_params)
|
||||
error = self.errors.get(message_count)
|
||||
if error is not None:
|
||||
raise error
|
||||
# Model-side expansion makes the rendered prompt much longer than its text tokens.
|
||||
engine_input = {"prompt_token_ids": [0] * (10 + 20 * raw_prompt_length)}
|
||||
token_count = self.token_counts.get(
|
||||
message_count,
|
||||
10 + 20 * raw_prompt_length,
|
||||
)
|
||||
engine_input = {
|
||||
"prompt_token_ids": [0] * token_count,
|
||||
"message_count": message_count,
|
||||
}
|
||||
self.engine_inputs.append(engine_input)
|
||||
return conversations, [engine_input]
|
||||
|
||||
@@ -70,9 +92,8 @@ def _make_connection(
|
||||
model_config = SimpleNamespace(max_model_len=max_model_len, multimodal_config=None)
|
||||
|
||||
async def preprocess_chat(request: Any, messages: list[dict[str, Any]], **kwargs: Any):
|
||||
tok_params = kwargs.get("tok_params")
|
||||
if tok_params is None:
|
||||
tok_params = request.build_tok_params(model_config)
|
||||
tok_params = request.build_tok_params(model_config)
|
||||
renderer.skip_mm_cache.append(bool(kwargs.get("skip_mm_cache", False)))
|
||||
return await renderer.render_chat_async(
|
||||
[messages],
|
||||
kwargs.get("default_template_kwargs"),
|
||||
@@ -97,22 +118,79 @@ def _make_connection(
|
||||
return connection, renderer
|
||||
|
||||
|
||||
def _message_item(
|
||||
item_id: str,
|
||||
text: str | None = None,
|
||||
) -> types.RealtimeConversationItemUserMessage:
|
||||
return types.RealtimeConversationItemUserMessage(
|
||||
id=item_id,
|
||||
type="message",
|
||||
role="user",
|
||||
content=[{"type": "input_text", "text": text or item_id}],
|
||||
)
|
||||
|
||||
|
||||
def _audio_item(item_id: str, audio: bytes) -> types.RealtimeConversationItemUserMessage:
|
||||
return types.RealtimeConversationItemUserMessage(
|
||||
id=item_id,
|
||||
type="message",
|
||||
role="user",
|
||||
content=[
|
||||
{
|
||||
"type": "input_audio",
|
||||
"audio": base64.b64encode(audio).decode("ascii"),
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _assistant_item(item_id: str, text: str) -> types.RealtimeConversationItemAssistantMessage:
|
||||
return types.RealtimeConversationItemAssistantMessage(
|
||||
id=item_id,
|
||||
type="message",
|
||||
role="assistant",
|
||||
status="completed",
|
||||
content=[{"type": "output_text", "text": text}],
|
||||
)
|
||||
|
||||
|
||||
def _websocket_events(websocket: _FakeWebSocket) -> list[dict[str, Any]]:
|
||||
return [json.loads(message) for message in websocket.messages]
|
||||
|
||||
|
||||
def _prompt_texts(conversation: list[dict[str, Any]]) -> list[str]:
|
||||
texts: list[str] = []
|
||||
for message in conversation:
|
||||
content = message["content"]
|
||||
if isinstance(content, str):
|
||||
texts.append(content)
|
||||
continue
|
||||
texts.extend(part["text"] for part in content if part.get("type") == "text")
|
||||
return texts
|
||||
|
||||
|
||||
async def _create_response(
|
||||
connection: OpenAIFullDuplexConnection,
|
||||
) -> list[dict[str, Any]]:
|
||||
submitted: list[dict[str, Any]] = []
|
||||
|
||||
async def run_response(_response_id: str, _response: Any, engine_input: dict[str, Any]) -> None:
|
||||
submitted.append(engine_input)
|
||||
connection.session.active_response = None
|
||||
|
||||
connection._run_response = run_response
|
||||
await connection._handle_response_create(SimpleNamespace(event_id="evt_create", response=None))
|
||||
if connection._response_task is not None:
|
||||
await connection._response_task
|
||||
return submitted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preflight_truncates_by_rendered_token_count() -> None:
|
||||
connection, renderer = _make_connection()
|
||||
|
||||
first = SimpleNamespace(
|
||||
id="first",
|
||||
type="message",
|
||||
role="user",
|
||||
content=[SimpleNamespace(type="input_text", text="first")],
|
||||
)
|
||||
second = SimpleNamespace(
|
||||
id="second",
|
||||
type="message",
|
||||
role="user",
|
||||
content=[SimpleNamespace(type="input_text", text="second")],
|
||||
)
|
||||
first = _message_item("first")
|
||||
second = _message_item("second")
|
||||
items = [first, second]
|
||||
response = _ResolvedResponse(
|
||||
input=items,
|
||||
@@ -124,16 +202,405 @@ async def test_preflight_truncates_by_rendered_token_count() -> None:
|
||||
metadata=None,
|
||||
)
|
||||
|
||||
engine_input = await connection._prepare_engine_input_with_auto_truncation(response)
|
||||
prompt_items = await connection._truncate_prompt_items(response)
|
||||
engine_input = await connection._build_full_prompt(
|
||||
tools=response.tools,
|
||||
instructions=response.instructions,
|
||||
items=prompt_items,
|
||||
)
|
||||
|
||||
assert renderer.raw_prompt_lengths == [4, 2, 2]
|
||||
assert [len(prompt["prompt_token_ids"]) for prompt in renderer.engine_inputs] == [90, 50, 50]
|
||||
assert all(params.max_total_tokens is None for params in renderer.tokenization_params)
|
||||
assert all(params.max_total_tokens == 100 for params in renderer.tokenization_params)
|
||||
assert all(params.max_output_tokens == 0 for params in renderer.tokenization_params)
|
||||
assert items == [second]
|
||||
assert items == [first, second]
|
||||
assert prompt_items == [second]
|
||||
assert engine_input is renderer.engine_inputs[-1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persistent_model_context_cursor_reuses_truncated_window() -> None:
|
||||
connection, renderer = _make_connection()
|
||||
first = _message_item("first")
|
||||
second = _message_item("second")
|
||||
connection.session.insert_item(first)
|
||||
connection.session.insert_item(second)
|
||||
response = _ResolvedResponse(
|
||||
input=None,
|
||||
instructions=None,
|
||||
modalities=["text"],
|
||||
max_output_tokens="inf",
|
||||
tools=None,
|
||||
tool_choice="none",
|
||||
metadata=None,
|
||||
)
|
||||
|
||||
prompt_items = await connection._truncate_prompt_items(response)
|
||||
await connection._build_full_prompt(
|
||||
tools=response.tools,
|
||||
instructions=response.instructions,
|
||||
items=prompt_items,
|
||||
)
|
||||
connection.session.commit_model_context_items(prompt_items)
|
||||
renderer.raw_prompt_lengths.clear()
|
||||
|
||||
prompt_items = await connection._truncate_prompt_items(response)
|
||||
|
||||
assert renderer.raw_prompt_lengths == [2]
|
||||
assert prompt_items == [second]
|
||||
assert connection.session.model_context_first_item_id == "second"
|
||||
assert not connection.session.model_context_cursor_at_end
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_model_context_allows_a_later_appended_item() -> None:
|
||||
connection, renderer = _make_connection(max_model_len=20)
|
||||
response = _ResolvedResponse(
|
||||
input=None,
|
||||
instructions=None,
|
||||
modalities=["text"],
|
||||
max_output_tokens="inf",
|
||||
tools=None,
|
||||
tool_choice="none",
|
||||
metadata=None,
|
||||
)
|
||||
|
||||
prompt_items = await connection._truncate_prompt_items(response)
|
||||
connection.session.commit_model_context_items(prompt_items)
|
||||
connection.session.insert_item(_message_item("appended"))
|
||||
|
||||
assert renderer.raw_prompt_lengths == [0]
|
||||
assert prompt_items == []
|
||||
assert [item.id for item in connection.session.model_context_items()] == ["appended"]
|
||||
assert connection.session.model_context_first_item_id == "appended"
|
||||
assert not connection.session.model_context_cursor_at_end
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncation_to_empty_selects_no_items_when_overhead_fits() -> None:
|
||||
connection, renderer = _make_connection(max_model_len=20)
|
||||
connection.session.insert_item(_message_item("item"))
|
||||
response = _ResolvedResponse(
|
||||
input=None,
|
||||
instructions=None,
|
||||
modalities=["text"],
|
||||
max_output_tokens="inf",
|
||||
tools=None,
|
||||
tool_choice="none",
|
||||
metadata=None,
|
||||
)
|
||||
|
||||
prompt_items = await connection._truncate_prompt_items(response)
|
||||
connection.session.commit_model_context_items(prompt_items)
|
||||
connection.session.insert_item(_message_item("appended"))
|
||||
|
||||
assert renderer.raw_prompt_lengths == [2, 0]
|
||||
assert prompt_items == []
|
||||
assert [item.id for item in connection.session.model_context_items()] == ["appended"]
|
||||
|
||||
|
||||
def test_model_context_cursor_moves_only_forward() -> None:
|
||||
session = AudioFullDuplexSessionState()
|
||||
|
||||
session.insert_item(_message_item("first"))
|
||||
session.insert_item(_message_item("second"))
|
||||
session.insert_item(_message_item("third"))
|
||||
assert session.model_context_first_item_id == "first"
|
||||
assert session.model_context_items() == session.items
|
||||
|
||||
session.commit_model_context_items(session.items[1:])
|
||||
session.insert_item(_message_item("before-second"), previous_item_id="first")
|
||||
assert session.model_context_first_item_id == "second"
|
||||
assert [item.id for item in session.model_context_items()] == ["second", "third"]
|
||||
|
||||
session.insert_item(_message_item("after-second"), previous_item_id="second")
|
||||
assert session.model_context_first_item_id == "second"
|
||||
assert [item.id for item in session.model_context_items()] == [
|
||||
"second",
|
||||
"after-second",
|
||||
"third",
|
||||
]
|
||||
|
||||
session.remove_item("second")
|
||||
assert session.model_context_first_item_id == "after-second"
|
||||
assert [item.id for item in session.model_context_items()] == ["after-second", "third"]
|
||||
|
||||
session.commit_model_context_items([])
|
||||
session.insert_item(_message_item("fourth"))
|
||||
session.insert_item(_message_item("root"), previous_item_id="root")
|
||||
assert session.model_context_first_item_id == "fourth"
|
||||
assert not session.model_context_cursor_at_end
|
||||
assert [item.id for item in session.model_context_items()] == ["fourth"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_truncation_bisects_the_model_context_cursor() -> None:
|
||||
connection, renderer = _make_connection()
|
||||
items = [
|
||||
SimpleNamespace(
|
||||
id=f"item_{index}",
|
||||
type="message",
|
||||
role="user",
|
||||
content=[SimpleNamespace(type="input_text", text=str(index))],
|
||||
)
|
||||
for index in range(8)
|
||||
]
|
||||
response = _ResolvedResponse(
|
||||
input=items,
|
||||
instructions=None,
|
||||
modalities=["text"],
|
||||
max_output_tokens="inf",
|
||||
tools=None,
|
||||
tool_choice="none",
|
||||
metadata=None,
|
||||
)
|
||||
|
||||
prompt_items = await connection._truncate_prompt_items(response)
|
||||
await connection._build_full_prompt(
|
||||
tools=response.tools,
|
||||
instructions=response.instructions,
|
||||
items=prompt_items,
|
||||
)
|
||||
|
||||
assert renderer.raw_prompt_lengths == [16, 8, 4, 2, 2]
|
||||
assert prompt_items == [items[-1]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_auto_truncation_end_to_end() -> None:
|
||||
websocket = _FakeWebSocket()
|
||||
connection, renderer = _make_connection(websocket=websocket)
|
||||
audio = b"\x01\x00\x02\x00\x03\x00\x04\x00"
|
||||
items: list[Any] = [_message_item(f"item_{index}") for index in range(7)]
|
||||
items.append(_audio_item("item_audio", audio))
|
||||
for item in items:
|
||||
connection.session.insert_item(item)
|
||||
|
||||
submitted = await _create_response(connection)
|
||||
|
||||
assert len(submitted) == 1
|
||||
assert submitted[0]["message_count"] == 1
|
||||
assert renderer.message_counts == [8, 4, 2, 1, 1]
|
||||
assert renderer.skip_mm_cache == [True, True, True, True, False]
|
||||
assert connection.session.model_context_first_item_id == "item_audio"
|
||||
assert not connection.session.model_context_cursor_at_end
|
||||
assert len(connection.session.items) == 8
|
||||
|
||||
events = _websocket_events(websocket)
|
||||
assert not [event for event in events if event["type"] == "conversation.item.deleted"]
|
||||
assert [event for event in events if event["type"] == "response.created"]
|
||||
|
||||
await connection._handle_item_retrieve(
|
||||
types.ConversationItemRetrieveEvent(
|
||||
type="conversation.item.retrieve",
|
||||
event_id="evt_retrieve",
|
||||
item_id="item_0",
|
||||
)
|
||||
)
|
||||
retrieved = _websocket_events(websocket)[-1]
|
||||
assert retrieved["type"] == "conversation.item.retrieved"
|
||||
assert retrieved["item"]["id"] == "item_0"
|
||||
|
||||
final_conversation = renderer.conversations[-1]
|
||||
assert len(final_conversation) == 1
|
||||
audio_content = final_conversation[0]["content"][0]["input_audio"]
|
||||
with wave.open(io.BytesIO(base64.b64decode(audio_content["data"]))) as wav:
|
||||
assert wav.readframes(wav.getnframes()) == audio
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("truncation", "max_output_tokens", "expected_texts"),
|
||||
[
|
||||
("auto", "inf", ["item_1", "item_2", "item_3"]),
|
||||
("disabled", "inf", None),
|
||||
(
|
||||
types.RealtimeTruncationRetentionRatio(
|
||||
type="retention_ratio",
|
||||
retention_ratio=0.5,
|
||||
token_limits={"post_instructions": 70},
|
||||
),
|
||||
"inf",
|
||||
["item_3"],
|
||||
),
|
||||
(
|
||||
types.RealtimeTruncationRetentionRatio(
|
||||
type="retention_ratio",
|
||||
retention_ratio=0.3,
|
||||
),
|
||||
"inf",
|
||||
["item_3"],
|
||||
),
|
||||
("auto", 25, ["item_3"]),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_truncation_configurations(
|
||||
truncation: Any,
|
||||
max_output_tokens: Any,
|
||||
expected_texts: list[str] | None,
|
||||
) -> None:
|
||||
websocket = _FakeWebSocket()
|
||||
connection, renderer = _make_connection(websocket=websocket)
|
||||
connection.session.config.truncation = truncation
|
||||
connection.session.config.max_output_tokens = max_output_tokens
|
||||
renderer.token_counts = {0: 10, 1: 30, 2: 45, 3: 50, 4: 110}
|
||||
for index in range(4):
|
||||
connection.session.insert_item(_message_item(f"item_{index}"))
|
||||
|
||||
submitted = await _create_response(connection)
|
||||
|
||||
if expected_texts is None:
|
||||
assert submitted == []
|
||||
assert connection._response_task is None
|
||||
assert "exceeds the model's input token limit" in websocket.messages[-1]
|
||||
return
|
||||
|
||||
assert len(submitted) == 1
|
||||
assert _prompt_texts(renderer.conversations[-1]) == expected_texts
|
||||
assert connection.session.model_context_first_item_id == f"item_{4 - len(expected_texts)}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_truncation_render_errors() -> None:
|
||||
websocket = _FakeWebSocket()
|
||||
connection, renderer = _make_connection(websocket=websocket)
|
||||
renderer.token_counts = {0: 10}
|
||||
renderer.errors = {
|
||||
2: VLLMValidationError("Prompt is too long", parameter="input_tokens"),
|
||||
1: VLLMValidationError("Prompt is too long", parameter="input_tokens"),
|
||||
}
|
||||
connection.session.insert_item(_message_item("first"))
|
||||
connection.session.insert_item(_message_item("second"))
|
||||
|
||||
submitted = await _create_response(connection)
|
||||
|
||||
assert len(submitted) == 1
|
||||
assert submitted[0]["message_count"] == 0
|
||||
assert renderer.message_counts == [2, 1, 0, 0]
|
||||
assert renderer.skip_mm_cache == [True, True, True, False]
|
||||
assert connection.session.model_context_cursor_at_end
|
||||
|
||||
websocket = _FakeWebSocket()
|
||||
connection, renderer = _make_connection(websocket=websocket)
|
||||
renderer.errors = {
|
||||
2: VLLMValidationError("Tool rendering failed", parameter="tools"),
|
||||
}
|
||||
connection.session.insert_item(_message_item("first"))
|
||||
connection.session.insert_item(_message_item("second"))
|
||||
|
||||
submitted = await _create_response(connection)
|
||||
|
||||
assert submitted == []
|
||||
assert connection._response_task is None
|
||||
assert renderer.message_counts == [2]
|
||||
assert "Tool rendering failed" in websocket.messages[-1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_truncation_survives_history_mutations() -> None:
|
||||
websocket = _FakeWebSocket()
|
||||
connection, renderer = _make_connection(websocket=websocket)
|
||||
renderer.token_counts = {0: 10, 1: 30, 2: 45, 3: 50, 4: 110}
|
||||
|
||||
async def add_item(item: Any, previous_item_id: str | None = None) -> None:
|
||||
await connection._handle_item_create(
|
||||
types.ConversationItemCreateEvent(
|
||||
type="conversation.item.create",
|
||||
event_id=f"evt_create_{item.id}",
|
||||
item=item,
|
||||
previous_item_id=previous_item_id,
|
||||
)
|
||||
)
|
||||
|
||||
await add_item(_message_item("item_0"))
|
||||
await add_item(_message_item("item_1"))
|
||||
await add_item(_assistant_item("assistant", "assistant text"))
|
||||
await add_item(_message_item("item_3"))
|
||||
|
||||
submitted = await _create_response(connection)
|
||||
assert len(submitted) == 1
|
||||
assert _prompt_texts(renderer.conversations[-1]) == [
|
||||
"item_1",
|
||||
"assistant text",
|
||||
"item_3",
|
||||
]
|
||||
assert connection.session.model_context_first_item_id == "item_1"
|
||||
|
||||
renderer.message_counts.clear()
|
||||
renderer.skip_mm_cache.clear()
|
||||
await add_item(_message_item("before"), previous_item_id="item_0")
|
||||
await add_item(_message_item("after"), previous_item_id="item_3")
|
||||
assert connection.session.model_context_first_item_id == "item_1"
|
||||
|
||||
await connection._handle_item_truncate(
|
||||
types.ConversationItemTruncateEvent(
|
||||
type="conversation.item.truncate",
|
||||
event_id="evt_truncate",
|
||||
item_id="assistant",
|
||||
content_index=0,
|
||||
audio_end_ms=0,
|
||||
)
|
||||
)
|
||||
truncated_part = connection.session.find_item("assistant").content[0]
|
||||
assert truncated_part.type == "output_audio"
|
||||
assert truncated_part.transcript == ""
|
||||
|
||||
await connection._handle_item_delete(
|
||||
types.ConversationItemDeleteEvent(
|
||||
type="conversation.item.delete",
|
||||
event_id="evt_delete",
|
||||
item_id="item_1",
|
||||
)
|
||||
)
|
||||
assert connection.session.model_context_first_item_id == "assistant"
|
||||
|
||||
active_item = _assistant_item("active", "active text")
|
||||
connection.session.insert_item(active_item)
|
||||
active = ActiveResponse(response_id="resp_active", request_id="req_active")
|
||||
active.item_id = active_item.id
|
||||
connection.session.active_response = active
|
||||
cancellation_cleanup: list[str] = []
|
||||
|
||||
async def active_response_task() -> None:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
connection.session.remove_item(active_item.id)
|
||||
connection.session.active_response = None
|
||||
cancellation_cleanup.append(active_item.id)
|
||||
raise
|
||||
|
||||
connection._response_task = asyncio.create_task(active_response_task())
|
||||
await asyncio.sleep(0)
|
||||
aborted_requests: list[str] = []
|
||||
|
||||
async def abort(request_id: str) -> None:
|
||||
aborted_requests.append(request_id)
|
||||
|
||||
connection.engine.abort = abort
|
||||
renderer.message_counts.clear()
|
||||
renderer.skip_mm_cache.clear()
|
||||
|
||||
submitted = await _create_response(connection)
|
||||
|
||||
assert aborted_requests == ["req_active"]
|
||||
assert cancellation_cleanup == ["active"]
|
||||
assert renderer.message_counts == [3, 2, 2]
|
||||
assert renderer.skip_mm_cache == [True, True, False]
|
||||
assert len(submitted) == 1
|
||||
assert _prompt_texts(renderer.conversations[-1]) == ["item_3", "after"]
|
||||
assert [item.id for item in connection.session.items] == [
|
||||
"item_0",
|
||||
"before",
|
||||
"assistant",
|
||||
"item_3",
|
||||
"after",
|
||||
]
|
||||
assert connection.session.model_context_first_item_id == "assistant"
|
||||
assert connection.session.active_response is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.asyncio
|
||||
async def test_audio_prompt_uses_standard_chat_audio_content() -> None:
|
||||
audio = b"\x00\x00\x00\x40"
|
||||
|
||||
@@ -9,7 +9,7 @@ import io
|
||||
import json
|
||||
import warnings
|
||||
import wave
|
||||
from dataclasses import dataclass, replace
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
@@ -57,13 +57,17 @@ AUTO_TRUNCATION_TRIGGER_RATIO = 0.8
|
||||
AUTO_TRUNCATION_TARGET_RATIO = 0.5
|
||||
|
||||
|
||||
def _is_prompt_limit_error(exc: VLLMValidationError) -> bool:
|
||||
return exc.parameter in {"input_text", "input_tokens"}
|
||||
|
||||
|
||||
class _UnsupportedAudioFormatError(ValueError):
|
||||
"""The Realtime endpoint only accepts mono PCM16 audio at 24 kHz."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ResolvedResponse:
|
||||
input: list[Any] | None
|
||||
input: list[types.ConversationItem] | None
|
||||
instructions: str | None
|
||||
modalities: list[str]
|
||||
max_output_tokens: int | str
|
||||
@@ -434,24 +438,31 @@ class OpenAIFullDuplexConnection:
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
async def _prepare_engine_input_with_auto_truncation(
|
||||
self,
|
||||
response: _ResolvedResponse,
|
||||
*,
|
||||
preflight_only: bool = False,
|
||||
) -> EngineInput | None:
|
||||
async def _truncate_prompt_items(self, response: _ResolvedResponse) -> list[types.ConversationItem] | None:
|
||||
s = self.session
|
||||
max_model_len = getattr(self.engine.model_config, "max_model_len", None)
|
||||
persistent = response.input is None
|
||||
items: list[Any] = s.items if response.input is None else response.input
|
||||
staged_deletions: list[Any] = []
|
||||
if max_model_len is None:
|
||||
return await self._build_full_prompt(
|
||||
tools=response.tools,
|
||||
instructions=response.instructions,
|
||||
items=items,
|
||||
skip_mm_cache=preflight_only,
|
||||
)
|
||||
max_model_len = self.engine.model_config.max_model_len
|
||||
if response.input is None:
|
||||
items = s.model_context_items()
|
||||
else:
|
||||
items = response.input
|
||||
|
||||
async def probe_prompt(
|
||||
current_items: list[types.ConversationItem],
|
||||
) -> tuple[EngineInput | None, int]:
|
||||
# Budget checks are speculative and may render repeatedly while
|
||||
# truncating, so keep them off the shared sender cache.
|
||||
try:
|
||||
prompt = await self._build_full_prompt(
|
||||
tools=response.tools,
|
||||
instructions=response.instructions,
|
||||
items=current_items,
|
||||
skip_mm_cache=True,
|
||||
)
|
||||
except VLLMValidationError as exc:
|
||||
if not _is_prompt_limit_error(exc):
|
||||
raise
|
||||
return None, max_model_len + 1
|
||||
return prompt, len(prompt["prompt_token_ids"])
|
||||
|
||||
truncation = s.config.truncation or "auto"
|
||||
ratio = 1.0
|
||||
@@ -477,24 +488,9 @@ class OpenAIFullDuplexConnection:
|
||||
trigger = limit
|
||||
target = limit
|
||||
|
||||
# Budget checks are speculative and may render repeatedly while
|
||||
# truncating, so keep them off the shared sender cache. Once accepted,
|
||||
# render again through the normal cache path for engine submission.
|
||||
engine_input = await self._build_full_prompt(
|
||||
tools=response.tools,
|
||||
instructions=response.instructions,
|
||||
items=items,
|
||||
skip_mm_cache=True,
|
||||
)
|
||||
total = len(engine_input["prompt_token_ids"])
|
||||
engine_input, total = await probe_prompt(items)
|
||||
if total <= trigger:
|
||||
if preflight_only:
|
||||
return engine_input
|
||||
return await self._build_full_prompt(
|
||||
tools=response.tools,
|
||||
instructions=response.instructions,
|
||||
items=items,
|
||||
)
|
||||
return items
|
||||
if mode == "disabled":
|
||||
logger.warning(
|
||||
"[realtime] token budget exceeded (%d/%d) and truncation is disabled -- rejecting response.create",
|
||||
@@ -503,68 +499,30 @@ class OpenAIFullDuplexConnection:
|
||||
)
|
||||
return None
|
||||
|
||||
if persistent:
|
||||
# Preflight truncation is speculative. Work on a snapshot so a
|
||||
# rejected response.create does not delete items from session history.
|
||||
items = list(items)
|
||||
all_items = items
|
||||
if not all_items:
|
||||
return None if total > limit else all_items
|
||||
|
||||
idx = 0
|
||||
while total > target and idx < len(items):
|
||||
item = items[idx]
|
||||
if getattr(item, "role", None) == "system":
|
||||
idx += 1
|
||||
continue
|
||||
if persistent and item.id is not None and s.item_in_progress.get(item.id, False):
|
||||
idx += 1
|
||||
continue
|
||||
low = 1
|
||||
high = len(all_items)
|
||||
feasible_index: int | None = None
|
||||
while low < high:
|
||||
middle = (low + high) // 2
|
||||
items = all_items[middle:]
|
||||
engine_input, total = await probe_prompt(items)
|
||||
if total <= target:
|
||||
high = middle
|
||||
feasible_index = middle
|
||||
else:
|
||||
low = middle + 1
|
||||
|
||||
remove_indexes = [idx]
|
||||
if item.type in ("function_call", "function_call_output"):
|
||||
call_id = getattr(item, "call_id", None)
|
||||
pair_idx = next(
|
||||
(
|
||||
other_idx
|
||||
for other_idx, other in enumerate(items)
|
||||
if other_idx != idx and getattr(other, "call_id", None) == call_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if pair_idx is not None:
|
||||
remove_indexes.append(pair_idx)
|
||||
|
||||
for remove_idx in sorted(remove_indexes, reverse=True):
|
||||
removed = items[remove_idx]
|
||||
if persistent:
|
||||
staged_deletions.append(removed)
|
||||
del items[remove_idx]
|
||||
engine_input = await self._build_full_prompt(
|
||||
tools=response.tools,
|
||||
instructions=response.instructions,
|
||||
items=items,
|
||||
skip_mm_cache=True,
|
||||
)
|
||||
total = len(engine_input["prompt_token_ids"])
|
||||
if feasible_index != low:
|
||||
items = all_items[low:]
|
||||
engine_input, total = await probe_prompt(items)
|
||||
|
||||
if total > limit:
|
||||
return None
|
||||
if preflight_only:
|
||||
return engine_input
|
||||
engine_input = await self._build_full_prompt(
|
||||
tools=response.tools,
|
||||
instructions=response.instructions,
|
||||
items=items,
|
||||
)
|
||||
for removed in staged_deletions:
|
||||
if removed.id is None or s.remove_item(removed.id) is None:
|
||||
continue
|
||||
await self._send_event(
|
||||
types.ConversationItemDeletedEvent(
|
||||
event_id=_gen_id("evt"),
|
||||
type="conversation.item.deleted",
|
||||
item_id=removed.id,
|
||||
)
|
||||
)
|
||||
return engine_input
|
||||
return items
|
||||
|
||||
async def _handle_response_create(self, event: types.ResponseCreateEvent):
|
||||
s = self.session
|
||||
@@ -585,14 +543,11 @@ class OpenAIFullDuplexConnection:
|
||||
|
||||
had_active_response = s.active_response is not None
|
||||
try:
|
||||
engine_input = await self._prepare_engine_input_with_auto_truncation(
|
||||
response,
|
||||
preflight_only=had_active_response,
|
||||
)
|
||||
preflight_items = await self._truncate_prompt_items(response)
|
||||
except VLLMValidationError as exc:
|
||||
await self._send_error(str(exc), "invalid_request_error", event_id=event.event_id)
|
||||
return
|
||||
if engine_input is None:
|
||||
if preflight_items is None:
|
||||
await self._send_error(
|
||||
"The response input exceeds the model's input token limit",
|
||||
"invalid_request_error",
|
||||
@@ -604,11 +559,11 @@ class OpenAIFullDuplexConnection:
|
||||
if s.active_response is not None:
|
||||
await self._cancel_active_response()
|
||||
try:
|
||||
engine_input = await self._prepare_engine_input_with_auto_truncation(response)
|
||||
preflight_items = await self._truncate_prompt_items(response)
|
||||
except VLLMValidationError as exc:
|
||||
await self._send_error(str(exc), "invalid_request_error", event_id=event.event_id)
|
||||
return
|
||||
if engine_input is None:
|
||||
if preflight_items is None:
|
||||
await self._send_error(
|
||||
"The response input exceeds the model's input token limit",
|
||||
"invalid_request_error",
|
||||
@@ -616,6 +571,18 @@ class OpenAIFullDuplexConnection:
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
engine_input = await self._build_full_prompt(
|
||||
tools=response.tools,
|
||||
instructions=response.instructions,
|
||||
items=preflight_items,
|
||||
)
|
||||
except VLLMValidationError as exc:
|
||||
await self._send_error(str(exc), "invalid_request_error", event_id=event.event_id)
|
||||
return
|
||||
if response.input is None:
|
||||
s.commit_model_context_items(preflight_items)
|
||||
|
||||
response_id = _gen_id("resp")
|
||||
await self._send_event(
|
||||
types.ResponseCreatedEvent(
|
||||
@@ -1609,8 +1576,8 @@ class OpenAIFullDuplexConnection:
|
||||
return text
|
||||
return ""
|
||||
|
||||
def _resolve_response_input(self, items: list[Any]) -> list[Any]:
|
||||
resolved = []
|
||||
def _resolve_response_input(self, items: list[Any]) -> list[types.ConversationItem]:
|
||||
resolved: list[types.ConversationItem] = []
|
||||
for item in items:
|
||||
if getattr(item, "type", None) != "item_reference":
|
||||
self._validate_input_item(item)
|
||||
@@ -1641,7 +1608,7 @@ class OpenAIFullDuplexConnection:
|
||||
tools: list | None = None,
|
||||
*,
|
||||
instructions: str | None = None,
|
||||
items: list | None = None,
|
||||
items: list[types.ConversationItem] | None = None,
|
||||
skip_mm_cache: bool = False,
|
||||
) -> EngineInput:
|
||||
"""Render the effective conversation through normal chat preprocessing."""
|
||||
@@ -1706,10 +1673,6 @@ class OpenAIFullDuplexConnection:
|
||||
messages.append({"role": "system", "content": text})
|
||||
|
||||
request = ChatCompletionRequest(model=self.model_name, messages=messages)
|
||||
tok_params = replace(
|
||||
request.build_tok_params(self.engine.model_config),
|
||||
max_total_tokens=None,
|
||||
)
|
||||
_, (engine_input,) = await chat_handler._preprocess_chat(
|
||||
request,
|
||||
messages,
|
||||
@@ -1717,7 +1680,6 @@ class OpenAIFullDuplexConnection:
|
||||
default_template_content_format=chat_handler.chat_template_content_format,
|
||||
default_template_kwargs=chat_handler._effective_chat_template_kwargs(request),
|
||||
tool_dicts=converted_tools,
|
||||
tok_params=tok_params,
|
||||
skip_mm_cache=skip_mm_cache,
|
||||
)
|
||||
if engine_input.get("prompt_token_ids") is None:
|
||||
|
||||
@@ -87,7 +87,15 @@ class AudioFullDuplexSessionState:
|
||||
config: types.RealtimeSessionCreateRequest = field(default_factory=_default_config)
|
||||
|
||||
conversation_id: str = field(default_factory=lambda: _gen_id("conv"))
|
||||
items: list[Any] = field(default_factory=list)
|
||||
items: list[types.ConversationItem] = field(default_factory=list)
|
||||
# Valid model-context cursor states are:
|
||||
# - (None, False): include from the first retained item.
|
||||
# - (first item ID, False): include from that item onward.
|
||||
# - (None, True): the cursor is past the end and the model context is
|
||||
# empty; a later append moves the cursor to the appended item.
|
||||
# The combination (item ID, True) is invalid.
|
||||
model_context_first_item_id: str | None = None
|
||||
model_context_cursor_at_end: bool = False
|
||||
|
||||
item_duration_ms: dict[str, float] = field(default_factory=dict)
|
||||
item_token_ids: dict[str, list[int]] = field(default_factory=dict)
|
||||
@@ -103,17 +111,17 @@ class AudioFullDuplexSessionState:
|
||||
return i
|
||||
return None
|
||||
|
||||
def find_item(self, item_id: str) -> Any | None:
|
||||
def find_item(self, item_id: str) -> types.ConversationItem | None:
|
||||
idx = self.find_item_index(item_id)
|
||||
return self.items[idx] if idx is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _jsonable_item(item: Any) -> Any:
|
||||
def _jsonable_item(item: types.ConversationItem) -> Any:
|
||||
if hasattr(item, "model_dump"):
|
||||
return item.model_dump(mode="json", exclude_none=True)
|
||||
return item
|
||||
|
||||
def _history_size(self, items: list[Any] | None = None) -> int:
|
||||
def _history_size(self, items: list[types.ConversationItem] | None = None) -> int:
|
||||
history = self.items if items is None else items
|
||||
serialized = json.dumps(
|
||||
[self._jsonable_item(item) for item in history],
|
||||
@@ -122,10 +130,25 @@ class AudioFullDuplexSessionState:
|
||||
)
|
||||
return len(serialized.encode("utf-8"))
|
||||
|
||||
def _ensure_history_capacity(self, items: list[Any]) -> None:
|
||||
def _ensure_history_capacity(self, items: list[types.ConversationItem]) -> None:
|
||||
if self._history_size(items) > MAX_HISTORY_BYTES:
|
||||
raise HistoryLimitError(f"Conversation history exceeds the {MAX_HISTORY_BYTES} byte limit")
|
||||
|
||||
def _model_context_start_index(self) -> int:
|
||||
if self.model_context_cursor_at_end:
|
||||
return len(self.items)
|
||||
if self.model_context_first_item_id is None:
|
||||
return 0
|
||||
index = self.find_item_index(self.model_context_first_item_id)
|
||||
return len(self.items) if index is None else index
|
||||
|
||||
def model_context_items(self) -> list[types.ConversationItem]:
|
||||
return self.items[self._model_context_start_index() :]
|
||||
|
||||
def commit_model_context_items(self, items: list[types.ConversationItem]) -> None:
|
||||
self.model_context_first_item_id = items[0].id if items else None
|
||||
self.model_context_cursor_at_end = not items
|
||||
|
||||
def insert_item(self, item: Any, previous_item_id: str | None = None) -> int:
|
||||
if item.id is not None:
|
||||
existing_idx = self.find_item_index(item.id)
|
||||
@@ -153,6 +176,13 @@ class AudioFullDuplexSessionState:
|
||||
candidate.insert(pos, item)
|
||||
self._ensure_history_capacity(candidate)
|
||||
self.items.insert(pos, item)
|
||||
# A past-the-end cursor only advances for an item appended after it.
|
||||
# Inserting before the end leaves the cursor past every included item.
|
||||
if self.model_context_cursor_at_end and pos == len(self.items) - 1:
|
||||
self.model_context_first_item_id = item.id
|
||||
self.model_context_cursor_at_end = False
|
||||
elif not self.model_context_cursor_at_end and self.model_context_first_item_id is None:
|
||||
self.model_context_first_item_id = item.id
|
||||
return pos
|
||||
|
||||
def replace_item(self, item: Any) -> int:
|
||||
@@ -177,11 +207,18 @@ class AudioFullDuplexSessionState:
|
||||
self.item_in_progress.pop(item_id, None)
|
||||
self.pending_truncations_ms.pop(item_id, None)
|
||||
|
||||
def remove_item(self, item_id: str) -> Any | None:
|
||||
def remove_item(self, item_id: str) -> types.ConversationItem | None:
|
||||
idx = self.find_item_index(item_id)
|
||||
if idx is None:
|
||||
return None
|
||||
cursor_index = self._model_context_start_index()
|
||||
item = self.items.pop(idx)
|
||||
if idx == cursor_index and not self.model_context_cursor_at_end:
|
||||
if idx < len(self.items):
|
||||
self.model_context_first_item_id = self.items[idx].id
|
||||
else:
|
||||
self.model_context_first_item_id = None
|
||||
self.model_context_cursor_at_end = True
|
||||
if item.id:
|
||||
self._clear_item_metadata(item.id)
|
||||
return item
|
||||
@@ -105,7 +105,6 @@ from vllm.parser.mistral import MistralToolCall
|
||||
from vllm.reasoning import ReasoningParser
|
||||
from vllm.renderers import BaseRenderer, merge_kwargs
|
||||
from vllm.renderers.inputs import TokPrompt
|
||||
from vllm.renderers.params import TokenizeParams
|
||||
from vllm.sampling_params import RequestOutputKind, SamplingParams
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
from vllm.tokenizers import TokenizerLike as AnyTokenizer
|
||||
@@ -977,7 +976,6 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
|
||||
continue_final_message: bool = False,
|
||||
documents: list[dict[str, str]] | None = None,
|
||||
add_special_tokens: bool = False,
|
||||
tok_params: TokenizeParams | None = None,
|
||||
skip_mm_cache: bool = False,
|
||||
) -> tuple[list[ConversationMessage], list[TokPrompt]]:
|
||||
if renderer is None:
|
||||
@@ -997,8 +995,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
|
||||
),
|
||||
)
|
||||
|
||||
if tok_params is None:
|
||||
tok_params = request.build_tok_params(self.model_config)
|
||||
tok_params = request.build_tok_params(self.model_config)
|
||||
mm_config = self.model_config.multimodal_config
|
||||
chat_params = request.build_chat_params(
|
||||
default_template,
|
||||
|
||||
Reference in new issue
Block a user