Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
417 changes: 417 additions & 0 deletions tests/entrypoints/openai/responses/test_serving_stateless.py

Large diffs are not rendered by default.

238 changes: 238 additions & 0 deletions tests/entrypoints/openai/responses/test_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project

"""Unit tests for the stateless Responses API state carrier (state.py)."""

import importlib
import os

import pytest

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _reset_signing_key():
"""Force state.py to re-derive the signing key on next call."""
import vllm.entrypoints.openai.responses.state as state_mod

state_mod._SIGNING_KEY = None


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture(autouse=True)
def isolated_key(monkeypatch):
"""Each test gets a fresh, deterministic signing key."""
monkeypatch.setenv(
"VLLM_RESPONSES_STATE_SIGNING_KEY", "ab" * 32 # 64 hex chars = 32 bytes
)
_reset_signing_key()
yield
_reset_signing_key()


# ---------------------------------------------------------------------------
# Import after env is patched (in case module was already imported)
# ---------------------------------------------------------------------------


@pytest.fixture()
def state():
import vllm.entrypoints.openai.responses.state as m

return m


# ---------------------------------------------------------------------------
# Round-trip tests
# ---------------------------------------------------------------------------


def test_roundtrip_plain_dicts(state):
messages = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
]
blob = state.serialize_state(messages)
recovered = state.deserialize_state(blob)
assert recovered == messages


def test_roundtrip_empty_list(state):
blob = state.serialize_state([])
recovered = state.deserialize_state(blob)
assert recovered == []


def test_roundtrip_nested_structure(state):
messages = [
{
"role": "user",
"content": [{"type": "text", "text": "What is 2+2?"}],
},
{"role": "assistant", "content": "4", "extra": {"key": [1, 2, 3]}},
]
blob = state.serialize_state(messages)
recovered = state.deserialize_state(blob)
assert recovered == messages


def test_roundtrip_pydantic_model(state):
"""Objects with model_dump() should serialize transparently."""

class FakeModel:
def model_dump(self):
return {"author": {"role": "user"}, "content": "hi"}

messages = [FakeModel()]
blob = state.serialize_state(messages)
recovered = state.deserialize_state(blob)
# After JSON round-trip, FakeModel becomes a plain dict
assert recovered == [{"author": {"role": "user"}, "content": "hi"}]


# ---------------------------------------------------------------------------
# is_state_carrier
# ---------------------------------------------------------------------------


def test_is_state_carrier_true(state):
blob = state.serialize_state([{"role": "user", "content": "hi"}])

class FakeItem:
encrypted_content = blob

assert state.is_state_carrier(FakeItem())


def test_is_state_carrier_false_external(state):
"""Real encrypted_content from external models should not be detected."""

class FakeItem:
encrypted_content = "some-opaque-blob-from-openai"

assert not state.is_state_carrier(FakeItem())


def test_is_state_carrier_false_no_field(state):
class FakeItem:
pass

assert not state.is_state_carrier(FakeItem())


def test_is_state_carrier_false_none(state):
class FakeItem:
encrypted_content = None

assert not state.is_state_carrier(FakeItem())


# ---------------------------------------------------------------------------
# deserialize_state — non-carrier inputs
# ---------------------------------------------------------------------------


def test_deserialize_returns_none_for_non_carrier(state):
assert state.deserialize_state("some-random-opaque-string") is None


def test_deserialize_returns_none_for_empty_string(state):
assert state.deserialize_state("") is None


# ---------------------------------------------------------------------------
# HMAC tamper detection
# ---------------------------------------------------------------------------


def test_tampered_payload_raises(state):
blob = state.serialize_state([{"role": "user", "content": "original"}])
# Corrupt the payload part (index 2 when split on ':')
parts = blob.split(":", 3)
assert len(parts) == 4
parts[2] = parts[2][:-4] + "XXXX" # corrupt the base64 payload
tampered = ":".join(parts)
with pytest.raises(ValueError, match="HMAC verification failed"):
state.deserialize_state(tampered)


def test_tampered_sig_raises(state):
blob = state.serialize_state([{"role": "user", "content": "hello"}])
parts = blob.split(":", 3)
parts[3] = "0" * 64 # replace HMAC with zeros
tampered = ":".join(parts)
with pytest.raises(ValueError, match="HMAC verification failed"):
state.deserialize_state(tampered)


def test_malformed_carrier_raises(state):
malformed = "vllm:1:onlythreeparts"
with pytest.raises(ValueError, match="Malformed vLLM state carrier"):
state.deserialize_state(malformed)


# ---------------------------------------------------------------------------
# Cross-key incompatibility
# ---------------------------------------------------------------------------


def test_different_keys_are_incompatible(monkeypatch):
"""A blob signed with key A must not validate with key B."""
import vllm.entrypoints.openai.responses.state as state_mod

monkeypatch.setenv("VLLM_RESPONSES_STATE_SIGNING_KEY", "aa" * 32)
state_mod._SIGNING_KEY = None
blob = state_mod.serialize_state([{"role": "user", "content": "secret"}])

# Switch to a different key
monkeypatch.setenv("VLLM_RESPONSES_STATE_SIGNING_KEY", "bb" * 32)
state_mod._SIGNING_KEY = None

with pytest.raises(ValueError, match="HMAC verification failed"):
state_mod.deserialize_state(blob)


# ---------------------------------------------------------------------------
# Random-key warning (no env var)
# ---------------------------------------------------------------------------


def test_no_env_var_generates_random_key(monkeypatch):
"""Without the env var, a random 32-byte key is generated.

The warning is emitted via vLLM's logger (visible in test output) but is
not capturable via capsys/caplog since vLLM writes to sys.__stdout__ directly.
"""
import vllm.entrypoints.openai.responses.state as state_mod

monkeypatch.delenv("VLLM_RESPONSES_STATE_SIGNING_KEY", raising=False)
state_mod._SIGNING_KEY = None

key = state_mod._get_signing_key()

assert key is not None
assert len(key) == 32
# A second call returns the same cached key (warning only fires once)
key2 = state_mod._get_signing_key()
assert key == key2


# ---------------------------------------------------------------------------
# Invalid hex key
# ---------------------------------------------------------------------------


def test_invalid_hex_key_raises(monkeypatch):
import vllm.entrypoints.openai.responses.state as state_mod

monkeypatch.setenv("VLLM_RESPONSES_STATE_SIGNING_KEY", "not-valid-hex!!")
state_mod._SIGNING_KEY = None

with pytest.raises(ValueError, match="valid hex string"):
state_mod._get_signing_key()
41 changes: 40 additions & 1 deletion vllm/entrypoints/openai/responses/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# Adapted from
# https://github.com/lm-sys/FastChat/blob/168ccc29d3f7edc50823016105c024fe2282732a/fastchat/protocol/openai_api_protocol.py
import time
from typing import Any, Literal, TypeAlias
from typing import TYPE_CHECKING, Any, Literal, TypeAlias

import torch
from openai.types.responses import (
Expand Down Expand Up @@ -233,6 +233,13 @@ class ResponsesRequest(OpenAIBaseModel):
# this cannot be used in conjunction with previous_response_id
# TODO: consider supporting non harmony messages as well
previous_input_messages: list[OpenAIHarmonyMessage | dict] | None = None
# Accept full previous response for stateless multi-turn (RFC #26934 + @grs).
# The client stores the full response object and passes it back on the next
# turn instead of a previous_response_id. vLLM extracts the Harmony message
# history from the encrypted_content state carrier embedded in the response
# output, so no server-side store is required.
# Cannot be set together with previous_response_id.
previous_response: "ResponsesResponse | None" = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this field one of the standard fields in the Open Response API?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, previous_response is a vLLM extension — the standard OpenAI API only has previous_response_id (a string ID that requires server-side storage).

This extension is needed because previous_response_id depends on vLLM's in-process store (the response_store / msg_store / event_store dicts marked # HACK / # FIXME), which leaks memory (#34738), loses state on restart, and is incompatible with production multi-node deployments (RFC #26934, raised by @qandrew at Meta).

The approach follows @grs's proposal in #26934: the client sends back the full response object; vLLM extracts the signed state from the existing encrypted_content field on a ReasoningItem — so the wire-format response itself uses zero new fields.

This is consistent with vLLM's existing extension policy (RFC #32850) — the Responses API already ships non-standard request fields (top_k, request_id, priority, etc.), and the Open Responses spec explicitly allows implementation extensions.

Mutual exclusion with previous_response_id is enforced via a Pydantic model_validator.

structured_outputs: StructuredOutputsParams | None = Field(
default=None,
description="Additional kwargs for structured outputs",
Expand Down Expand Up @@ -378,6 +385,33 @@ def is_include_output_logprobs(self) -> bool:
and "message.output_text.logprobs" in self.include
)

@model_validator(mode="after")
def validate_previous_response_xor_id(self) -> "ResponsesRequest":
if self.previous_response_id and self.previous_response is not None:
raise ValueError(
"Cannot set both 'previous_response_id' and 'previous_response'. "
"Use 'previous_response_id' for store-backed multi-turn, or "
"'previous_response' (with include=['reasoning.encrypted_content'] "
"and store=false) for stateless multi-turn."
)
if self.previous_response is not None:
if self.background:
# background mode requires store=True to retrieve the response
# later, but the stateless path forces store=False. Raise
# explicitly rather than silently producing an unretrievable
# background response.
raise ValueError(
"'background' mode cannot be used with 'previous_response'. "
"Stateless multi-turn (previous_response + "
"include=['reasoning.encrypted_content']) does not support "
"background responses."
)
if self.store:
# Stateless path: store is meaningless (and would cause
# confusion). Mirror the silent-disable in create_responses.
self.store = False
return self

@model_validator(mode="before")
@classmethod
def validate_background(cls, data):
Expand Down Expand Up @@ -648,3 +682,8 @@ class ResponseInProgressEvent(OpenAIResponseInProgressEvent):
| ResponseMcpCallInProgressEvent
| ResponseMcpCallCompletedEvent
)

# Resolve forward reference: ResponsesRequest.previous_response -> ResponsesResponse
# Both classes are defined in this module; model_rebuild() is needed because
# ResponsesResponse did not exist when ResponsesRequest was first evaluated.
ResponsesRequest.model_rebuild()
Loading
Loading