litellm/tests/unit/responses/test_responses_websocket_all_providers.py
yuneng-jiang a11a93f44a
test: move tests/test_litellm core utils, routing, responses, caching and rust_bridge into tests/unit (#43199)
* ci: run the unit_selection.sh shard files on every event instead of only fork pull requests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: rename fork-flag to unit-flag now that it applies on every event

* test: move tests/test_litellm root and small trees into tests/unit

Pure renames, no content changes. Follow-up commits in this PR fix
references, merge the three files that already existed in tests/unit,
keep live-provider tests in tests/test_litellm and wire CI.

* test: carry tests/test_litellm conftest isolation into tests/unit

Callback lists, routing fallbacks, cached HTTP clients, logger state, AWS,
proxy-URL and keychain env, and session-end client cleanup now reset for
unit tests too. The environment isolation owns its MonkeyPatch so a test's
own monkeypatch is undone before the model-cost teardown runs.

* test: merge, split and prune the moved root and small-tree tests

Merge batches/test_batch_utils.py and the chat_completions and messages
dispatch tests into the files that already existed in tests/unit. Keep
the live Gemini interactions tests, the async image-fetch format test and
the OpenAI embedding scorer test in tests/test_litellm since they need
real network or keys. Put test_router.py under tests/unit/test_router so
the existing package no longer shadows it. Delete eight tests the audit
found superseded by stronger ones kept in this move.

* ci: run the moved root and small-tree tests under their legacy flags

Add the misc and responses-caching-types flags to unit_selection.sh and
CircleCI, extend enterprise-routing and mcp-integration, and point the
legacy GHA shards, Makefile, redis-compat workflow, merge smoke manifest
and change classifier at the new paths.

* test: make the new tests/unit directories packages

tests/unit/test_package_layout.py requires every directory to carry an
__init__.py, and without one the moved and retained
test_litellm_responses_bridge.py modules collide on import.

* test: scope the unit socket block to tests/unit in shared sessions

The GHA shards collect the legacy test-path and the unit selection in one
pytest session. The unit conftest's loopback-only block leaked into legacy
modules that reach the network at import. The legacy conftest now lifts the
restriction at collect and setup time, and the unit conftest re-applies it
when collecting its own modules.

* test: move tests/test_litellm/llms into tests/unit/llms

Rename-only. Moves the provider tests and the fine-tuning fixtures they
load, mirroring the old paths. Follow-up commits merge, split and wire them.

* test: merge, split and prune the moved llms tests

Merges the Databricks chat transformation tests into the existing unit
file, keeps the tests that need real keys or the network in
tests/test_litellm, deletes the audited tests a stronger unit test
already covers, and points imports at tests.unit.llms.

* ci: run the moved llms tests under their legacy flags

The Vertex AI and All Other Providers shards keep their legacy test-path
for the retained files and add the llm-vertex-ai and llm-other-providers
unit selections. CircleCI gets matching unit jobs.

* test: make the tests/unit/llms directories packages

Adds __init__.py to the moved dirs and drops the legacy ones whose
directories no longer hold tests.

* test: drop script runners and path hacks the llms split left dangling

The __main__ runners in the split openai_like files and the Databricks e2e
runner called tests that now live in the other half of the split or were
deleted. The retained legacy halves also no longer need sys.path edits.

* test: give the shard-script tests their own GITHUB_OUTPUT

They only passed where the runner set it. The CircleCI unit job's env
allowlist drops it, so the script's redirect failed there.

* test: point the router and module-deletion checks at tests/unit

router_code_coverage and code_qa_check_tests only searched tests/test_litellm,
so the moved router tests no longer counted. The two silent-experiment tests
the audit deleted were the only direct callers of those methods; they are
replaced with tests that assert the forwarded shadow request and the
recursion guard.

* test: move tests/test_litellm integrations and secret_managers into tests/unit

Rename-only. Mirrors the old paths, including the directory conftests
and the prompt and JSON fixtures. Follow-up commits prune and wire them.

* test: prune and repoint the moved integrations tests

Deletes the 7 audited tests a stronger test in the same tree already
covers, imports the TLS sink helpers from their new conftest path, and
restores os.environ after each integrations test. Some presets write
OTEL_EXPORTER_OTLP_HEADERS straight into os.environ, and without the
legacy tree's test ordering that header leaked into the AgentOps tests.

* ci: run the moved integrations tests under their legacy flag

The integrations GHA shard and a new CircleCI job run the integrations
unit selection. secret_managers joins the misc selection.

* docs: point integrations and secret_managers references at tests/unit

* test: make the moved integrations directories packages

* test: keep the Databricks manual e2e runner and fix the SageMaker Nova run path

The Databricks e2e file is a manual script whose main() calls the tests
that were pruned, so pruning them broke the documented run. It is back to
its main version. The SageMaker Nova docstring now points at the file's
real location in tests/local_testing.

* test: move tests/test_litellm core utils, routing, responses, caching and rust_bridge into tests/unit

Rename-only. Mirrors the old paths, including fixtures, the stubtest config
and the native-route wheel script. Two files that collide with existing unit
files are merged in a follow-up commit.

* test: merge, prune and repoint the moved core, routing, responses, caching and rust_bridge tests

Merges the two files that collided with existing unit files, folding the
legacy extra case into test_is_chat_completion_cached_dict, and deletes the
9 audited tests a stronger test in the same file already covers.

Keeps what needs the network in tests/test_litellm: test_tokenizers pulls a
tokenizer from the Hugging Face hub, and the gpt2 and r50k_base tokenizer
cases download their BPE files. The unit core_utils conftest points
TIKTOKEN_CACHE_DIR at litellm's bundled encodings so the rest never depend on
import order to stay offline, and FakeSecretVault moves to a shared module
so both trees can build it.

* ci: run the moved core, routing, responses, caching and rust_bridge tests under their flags

core_utils gets a core-utils flag and CircleCI job, and its GHA shard keeps
the legacy path for the retained network tests. router_utils and
router_strategy join enterprise-routing, responses joins
responses-caching-types (minus responses/mcp, which mcp-integration owns),
caching joins caching-local and rust_bridge joins misc. The redis-compat,
test-rust, stubtest and merge-smoke paths follow the move.

* docs: point the Rust crate references at tests/unit

* test: make the moved core, routing and rust_bridge directories packages

* test: keep the no-loop DualCache batch_get_cache regression test

It runs the sync path outside any event loop, which the inside-loop test
cannot, so a change that picks the Redis client by loop state would only
show up there.

* test: keep the job's UNIT_FLAG out of the shard-script tests

* fix(url_utils): block 192.0.0.0/24 on every Python patch release

* test: move the new budget limiter tests into tests/unit/router_strategy

* test: move the new sentry scrubbing tests into tests/unit/litellm_core_utils

* test: move the new zerobus tests into tests/unit/integrations

* test: make tests/unit/integrations/zerobus a package

* test: load litellm's own tiktoken cache setup once instead of resetting it per test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 17:10:13 -07:00

3255 lines
118 KiB
Python

"""
Unit tests to verify that all providers support Responses API WebSocket mode.
Tests that:
1. All providers with ResponsesAPIConfig support websocket mode
2. Providers with native websocket support use direct connection
3. Providers without native websocket support use ManagedResponsesWebSocketHandler
"""
import json
from unittest.mock import MagicMock
import pytest
from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig
from litellm.llms.chatgpt.responses.transformation import ChatGPTResponsesAPIConfig
from litellm.llms.databricks.responses.transformation import (
DatabricksResponsesAPIConfig,
)
from litellm.llms.fireworks_ai.responses.transformation import (
FireworksAIResponsesAPIConfig,
)
from litellm.llms.github_copilot.responses.transformation import (
GithubCopilotResponsesAPIConfig,
)
from litellm.llms.hosted_vllm.responses.transformation import (
HostedVLLMResponsesAPIConfig,
)
from litellm.llms.litellm_proxy.responses.transformation import (
LiteLLMProxyResponsesAPIConfig,
)
from litellm.llms.manus.responses.transformation import ManusResponsesAPIConfig
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.llms.openrouter.responses.transformation import (
OpenRouterResponsesAPIConfig,
)
from litellm.llms.perplexity.responses.transformation import PerplexityResponsesConfig
from litellm.llms.volcengine.responses.transformation import (
VolcEngineResponsesAPIConfig,
)
from litellm.llms.xai.responses.transformation import XAIResponsesAPIConfig
class TestResponsesAPIWebSocketSupport:
"""Test that all providers have websocket support configured correctly"""
def test_openai_supports_native_websocket(self):
"""OpenAI should support native websocket"""
config = OpenAIResponsesAPIConfig()
assert (
config.supports_native_websocket() is True
), "OpenAI should support native websocket"
def test_azure_supports_native_websocket(self):
"""Azure should support native websocket"""
config = AzureOpenAIResponsesAPIConfig()
assert (
config.supports_native_websocket() is True
), "Azure should support native websocket"
def test_azure_websocket_url_uses_v1_path(self):
"""Azure WebSocket URL must use /openai/v1/responses (no api-version)"""
config = AzureOpenAIResponsesAPIConfig()
url = config.get_websocket_url(
api_base="https://myresource.cognitiveservices.azure.com",
litellm_params={"api_version": "2025-04-01-preview"},
)
assert url == "wss://myresource.cognitiveservices.azure.com/openai/v1/responses"
assert "api-version" not in url
def test_azure_websocket_url_strips_existing_path(self):
"""api_base that already contains /openai/responses must be cleaned"""
config = AzureOpenAIResponsesAPIConfig()
url = config.get_websocket_url(
api_base="https://myresource.cognitiveservices.azure.com/openai/responses",
litellm_params={},
)
assert url == "wss://myresource.cognitiveservices.azure.com/openai/v1/responses"
def test_azure_websocket_url_strips_query_params(self):
config = AzureOpenAIResponsesAPIConfig()
url = config.get_websocket_url(
api_base="https://myresource.cognitiveservices.azure.com/openai/responses?api-version=2024-05-01-preview",
litellm_params={},
)
assert url == "wss://myresource.cognitiveservices.azure.com/openai/v1/responses"
def test_azure_websocket_url_requires_api_base(self):
config = AzureOpenAIResponsesAPIConfig()
with pytest.raises(ValueError, match='api_base is required for Azure WebSocket'):
config.get_websocket_url(api_base=None, litellm_params={})
def test_azure_model_not_in_websocket_url(self):
"""Azure sends the model in the body, so it must not be appended to the URL"""
assert AzureOpenAIResponsesAPIConfig().model_in_websocket_url() is False
def test_openai_default_websocket_url_converts_scheme(self):
"""The base get_websocket_url default converts the HTTP endpoint to wss://"""
config = OpenAIResponsesAPIConfig()
url = config.get_websocket_url(
api_base="https://api.openai.com/v1", litellm_params={}
)
assert url == "wss://api.openai.com/v1/responses"
def test_openai_model_in_websocket_url_default(self):
assert OpenAIResponsesAPIConfig().model_in_websocket_url() is True
def test_fireworks_ai_uses_managed_websocket(self):
"""Fireworks AI should use managed websocket handler"""
assert (
FireworksAIResponsesAPIConfig().supports_native_websocket() is False
), "Fireworks AI should use managed websocket handler"
def test_xai_uses_managed_websocket(self):
"""XAI should use managed websocket handler"""
config = XAIResponsesAPIConfig()
assert (
config.supports_native_websocket() is False
), "XAI should use managed websocket handler"
def test_github_copilot_uses_managed_websocket(self):
"""GitHub Copilot should use managed websocket handler"""
config = GithubCopilotResponsesAPIConfig()
assert (
config.supports_native_websocket() is False
), "GitHub Copilot should use managed websocket handler"
def test_chatgpt_uses_managed_websocket(self):
"""ChatGPT should use managed websocket handler"""
config = ChatGPTResponsesAPIConfig()
assert (
config.supports_native_websocket() is False
), "ChatGPT should use managed websocket handler"
def test_litellm_proxy_uses_managed_websocket(self):
"""LiteLLM Proxy should use managed websocket handler"""
config = LiteLLMProxyResponsesAPIConfig()
assert (
config.supports_native_websocket() is False
), "LiteLLM Proxy should use managed websocket handler"
def test_volcengine_uses_managed_websocket(self):
"""VolcEngine should use managed websocket handler"""
config = VolcEngineResponsesAPIConfig()
assert (
config.supports_native_websocket() is False
), "VolcEngine should use managed websocket handler"
def test_manus_uses_managed_websocket(self):
"""Manus should use managed websocket handler"""
config = ManusResponsesAPIConfig()
assert (
config.supports_native_websocket() is False
), "Manus should use managed websocket handler"
def test_perplexity_uses_managed_websocket(self):
"""Perplexity should use managed websocket handler"""
config = PerplexityResponsesConfig()
assert (
config.supports_native_websocket() is False
), "Perplexity should use managed websocket handler"
def test_databricks_uses_managed_websocket(self):
"""Databricks should use managed websocket handler"""
config = DatabricksResponsesAPIConfig()
assert (
config.supports_native_websocket() is False
), "Databricks should use managed websocket handler"
def test_openrouter_uses_managed_websocket(self):
"""OpenRouter should use managed websocket handler"""
config = OpenRouterResponsesAPIConfig()
assert (
config.supports_native_websocket() is False
), "OpenRouter should use managed websocket handler"
def test_hosted_vllm_uses_managed_websocket(self):
"""Hosted vLLM should use managed websocket handler"""
config = HostedVLLMResponsesAPIConfig()
assert (
config.supports_native_websocket() is False
), "Hosted vLLM should use managed websocket handler"
class TestManagedWebSocketHandlerIntegration:
"""Test that ManagedResponsesWebSocketHandler is properly integrated"""
@pytest.mark.asyncio
async def test_managed_handler_instantiation(self):
"""Test that ManagedResponsesWebSocketHandler can be instantiated"""
from unittest.mock import MagicMock
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
mock_websocket = MagicMock()
mock_logging_obj = Logging(
model="test-model",
messages=[],
stream=True,
call_type="aresponses",
start_time=0,
litellm_call_id="test-id",
function_id="test-func",
)
handler = ManagedResponsesWebSocketHandler(
websocket=mock_websocket,
model="test-model",
logging_obj=mock_logging_obj,
user_api_key_dict=None,
litellm_metadata={},
api_key="test-key",
api_base="https://api.example.com",
timeout=30.0,
custom_llm_provider="test_provider",
)
assert handler.model == "test-model"
assert handler.api_key == "test-key"
assert handler.api_base == "https://api.example.com"
assert handler.timeout == 30.0
assert handler.custom_llm_provider == "test_provider"
@pytest.mark.asyncio
async def test_frame_alias_resolves_to_connection_model(self, monkeypatch):
"""
A response.create frame that repeats the public model alias must reach
litellm.aresponses with the router-resolved deployment model, not the
raw alias (which fails in get_llm_provider). Regression for codex
WebSocket sessions against managed providers like bedrock_mantle.
"""
import json
from unittest.mock import AsyncMock, MagicMock
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
captured: dict = {}
async def fake_aresponses(*args, **kwargs):
captured["model"] = kwargs.get("model")
async def _empty():
return
yield
return _empty()
monkeypatch.setattr(litellm, "aresponses", fake_aresponses)
mock_websocket = MagicMock()
mock_websocket.send_text = AsyncMock()
handler = ManagedResponsesWebSocketHandler(
websocket=mock_websocket,
model="bedrock_mantle/openai.gpt-5.5",
logging_obj=Logging(
model="bedrock_mantle/openai.gpt-5.5",
messages=[],
stream=True,
call_type="aresponses",
start_time=0,
litellm_call_id="test-id",
function_id="test-func",
),
litellm_metadata={"model_group": "gpt-5.5-mantle"},
)
frame = json.dumps(
{
"type": "response.create",
"model": "gpt-5.5-mantle",
"input": [],
}
)
await handler._process_response_create(frame)
assert captured["model"] == "bedrock_mantle/openai.gpt-5.5"
@pytest.mark.asyncio
async def test_warmup_frame_skips_provider_and_sends_synthetic_ack(
self, monkeypatch
):
"""
A generate=false warmup frame (codex prewarm) carries empty input that
managed HTTP providers reject. It must not call the provider, and should
emit synthetic response.created/completed events so Codex can proceed.
"""
import json
from unittest.mock import AsyncMock, MagicMock
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
called = False
async def fail_aresponses(*args, **kwargs):
nonlocal called
called = True
raise AssertionError("provider must not be called for a warmup frame")
monkeypatch.setattr(litellm, "aresponses", fail_aresponses)
mock_websocket = MagicMock()
mock_websocket.send_text = AsyncMock()
handler = ManagedResponsesWebSocketHandler(
websocket=mock_websocket,
model="bedrock_mantle/openai.gpt-5.5",
logging_obj=Logging(
model="bedrock_mantle/openai.gpt-5.5",
messages=[],
stream=True,
call_type="aresponses",
start_time=0,
litellm_call_id="test-id",
function_id="test-func",
),
litellm_metadata={"model_group": "gpt-5.5-mantle"},
)
frame = json.dumps(
{
"type": "response.create",
"model": "gpt-5.5-mantle",
"generate": False,
"input": [],
}
)
await handler._process_response_create(frame)
assert called is False
assert mock_websocket.send_text.call_count == 2
events = [
json.loads(call.args[0]) for call in mock_websocket.send_text.call_args_list
]
assert events[0]["type"] == "response.created"
assert events[0]["response"]["status"] == "in_progress"
assert events[1]["type"] == "response.completed"
assert events[1]["response"]["status"] == "completed"
assert events[1]["response"]["output"] == []
assert events[1]["response"]["model"] == "gpt-5.5-mantle"
@pytest.mark.asyncio
async def test_warmup_previous_response_id_not_forwarded_to_provider(
self, monkeypatch
):
import json
from unittest.mock import AsyncMock, MagicMock
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
captured: dict = {}
async def fake_aresponses(*args, **kwargs):
captured.update(kwargs)
async def _empty():
return
yield
return _empty()
monkeypatch.setattr(litellm, "aresponses", fake_aresponses)
mock_websocket = MagicMock()
mock_websocket.send_text = AsyncMock()
handler = ManagedResponsesWebSocketHandler(
websocket=mock_websocket,
model="bedrock_mantle/openai.gpt-5.5",
logging_obj=Logging(
model="bedrock_mantle/openai.gpt-5.5",
messages=[],
stream=True,
call_type="aresponses",
start_time=0,
litellm_call_id="test-id",
function_id="test-func",
),
litellm_metadata={"model_group": "gpt-5.5-mantle"},
)
await handler._process_response_create(
json.dumps(
{
"type": "response.create",
"model": "gpt-5.5-mantle",
"generate": False,
"input": [],
}
)
)
warmup_id = json.loads(mock_websocket.send_text.call_args_list[1].args[0])[
"response"
]["id"]
await handler._process_response_create(
json.dumps(
{
"type": "response.create",
"model": "gpt-5.5-mantle",
"previous_response_id": warmup_id,
"input": [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Hi"}],
}
],
}
)
)
assert "previous_response_id" not in captured
class TestChunkTransformation:
"""Test chunk serialization and transformation for WebSocket streaming"""
def test_serialize_chunk_with_dict(self):
"""Test serialization of dict chunks"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {
"type": "response.created",
"response": {"id": "resp_456", "status": "in_progress"},
}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.created" in serialized
assert "resp_456" in serialized
def test_serialize_chunk_handles_invalid_json(self):
"""Test that chunks with circular references are handled"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
# Create object with circular reference
obj = {"a": 1}
obj["self"] = obj # type: ignore
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(obj)
assert serialized is None
def test_extract_output_messages_with_text_content(self):
"""Test extraction of output messages with text content"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
completed_event = {
"type": "response.completed",
"response": {
"id": "resp_123",
"output": [
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Hello world"}],
}
],
},
}
messages = ManagedResponsesWebSocketHandler._extract_output_messages(
completed_event
)
assert len(messages) == 1
assert messages[0]["type"] == "message"
assert messages[0]["role"] == "assistant"
assert messages[0]["content"][0]["text"] == "Hello world"
def test_extract_output_messages_with_multiple_content_parts(self):
"""Test extraction with multiple content parts"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
completed_event = {
"type": "response.completed",
"response": {
"id": "resp_123",
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{"type": "output_text", "text": "Part 1. "},
{"type": "output_text", "text": "Part 2."},
],
}
],
},
}
messages = ManagedResponsesWebSocketHandler._extract_output_messages(
completed_event
)
assert len(messages) == 1
assert messages[0]["content"][0]["text"] == "Part 1. Part 2."
def test_extract_output_messages_with_function_calls(self):
"""Test that function calls are preserved"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
completed_event = {
"type": "response.completed",
"response": {
"id": "resp_123",
"output": [
{
"type": "function_call",
"id": "call_123",
"name": "get_weather",
"arguments": '{"location": "Paris"}',
}
],
},
}
messages = ManagedResponsesWebSocketHandler._extract_output_messages(
completed_event
)
assert len(messages) == 1
assert messages[0]["type"] == "function_call"
assert messages[0]["id"] == "call_123"
assert messages[0]["name"] == "get_weather"
def test_extract_output_messages_filters_empty_text(self):
"""Test that messages with empty text are filtered out"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
completed_event = {
"type": "response.completed",
"response": {
"id": "resp_123",
"output": [
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": ""}],
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Valid text"}],
},
],
},
}
messages = ManagedResponsesWebSocketHandler._extract_output_messages(
completed_event
)
assert len(messages) == 1
assert messages[0]["content"][0]["text"] == "Valid text"
def test_extract_output_messages_handles_non_dict_items(self):
"""Test that non-dict items are skipped"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
completed_event = {
"type": "response.completed",
"response": {
"id": "resp_123",
"output": [
"invalid_string",
None,
123,
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Valid"}],
},
],
},
}
messages = ManagedResponsesWebSocketHandler._extract_output_messages(
completed_event
)
assert len(messages) == 1
assert messages[0]["content"][0]["text"] == "Valid"
def test_input_to_messages_with_string(self):
"""Test conversion of string input to messages"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
messages = ManagedResponsesWebSocketHandler._input_to_messages("Hello world")
assert len(messages) == 1
assert messages[0]["type"] == "message"
assert messages[0]["role"] == "user"
assert messages[0]["content"][0]["type"] == "input_text"
assert messages[0]["content"][0]["text"] == "Hello world"
def test_input_to_messages_with_list(self):
"""Test conversion of list input to messages"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
input_list = [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Question"}],
}
]
messages = ManagedResponsesWebSocketHandler._input_to_messages(input_list)
assert len(messages) == 1
assert messages[0]["type"] == "message"
assert messages[0]["content"][0]["text"] == "Question"
def test_input_to_messages_filters_non_dict_items(self):
"""Test that non-dict items in list input are filtered"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
input_list = [
"invalid_string",
None,
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Valid"}],
},
]
messages = ManagedResponsesWebSocketHandler._input_to_messages(input_list)
assert len(messages) == 1
assert messages[0]["content"][0]["text"] == "Valid"
def test_input_to_messages_handles_empty_input(self):
"""Test that empty input returns empty list"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
assert ManagedResponsesWebSocketHandler._input_to_messages(None) == []
assert ManagedResponsesWebSocketHandler._input_to_messages([]) == []
assert ManagedResponsesWebSocketHandler._input_to_messages({}) == []
class TestUpdateProxyRequest:
"""Regression tests for ManagedResponsesWebSocketHandler._update_proxy_request.
The managed WebSocket path calls ``litellm.aresponses(model=..., **call_kwargs)``.
``litellm_params`` is not a Responses API request field, so passing it as a
top-level kwarg leaks it into the provider request body and providers that
forbid extra inputs (e.g. Anthropic) reject the call with
``litellm_params: Extra inputs are not permitted``. The request-tracking data
must ride along as ``proxy_server_request`` instead, which litellm consumes
internally and never forwards to the provider.
"""
def test_does_not_inject_litellm_params_kwarg(self):
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
call_kwargs = {
"input": "hello",
"store": True,
"litellm_metadata": {
"proxy_server_request": {"headers": {}, "body": {}},
},
}
ManagedResponsesWebSocketHandler._update_proxy_request(
call_kwargs, "anthropic/claude-sonnet-4-5"
)
assert "litellm_params" not in call_kwargs
assert call_kwargs["proxy_server_request"]["body"]["model"] == (
"anthropic/claude-sonnet-4-5"
)
assert call_kwargs["proxy_server_request"]["body"]["input"] == "hello"
def test_proxy_server_request_matches_metadata(self):
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
call_kwargs = {
"input": "hi",
"litellm_metadata": {"proxy_server_request": {"body": {}}},
}
ManagedResponsesWebSocketHandler._update_proxy_request(call_kwargs, "gpt-4o")
assert (
call_kwargs["proxy_server_request"]
== call_kwargs["litellm_metadata"]["proxy_server_request"]
)
class TestWebSocketEventTypes:
"""Test that all WebSocket event types are properly handled with dict-based chunks"""
def test_serialize_response_created_event_dict(self):
"""Test serialization of response.created event as dict"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {
"type": "response.created",
"response_id": "resp_123",
"response": {
"id": "resp_123",
"object": "response",
"status": "in_progress",
"created_at": 1234567890,
},
}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.created" in serialized
assert "resp_123" in serialized
def test_serialize_response_in_progress_event_dict(self):
"""Test serialization of response.in_progress event as dict"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {"type": "response.in_progress", "response_id": "resp_123"}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.in_progress" in serialized
def test_serialize_output_item_added_event_dict(self):
"""Test serialization of response.output_item.added event as dict"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {
"type": "response.output_item.added",
"response_id": "resp_123",
"item_id": "msg_456",
"output_index": 0,
"item": {"type": "message", "role": "assistant"},
}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.output_item.added" in serialized
assert "msg_456" in serialized
def test_serialize_output_text_delta_event_dict(self):
"""Test serialization of response.output_text.delta event as dict"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {
"type": "response.output_text.delta",
"response_id": "resp_123",
"item_id": "msg_456",
"output_index": 0,
"content_index": 0,
"delta": "Hello",
}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.output_text.delta" in serialized
assert "Hello" in serialized
def test_serialize_output_text_done_event_dict(self):
"""Test serialization of response.output_text.done event as dict"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {
"type": "response.output_text.done",
"response_id": "resp_123",
"item_id": "msg_456",
"output_index": 0,
"content_index": 0,
"text": "Hello world",
}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.output_text.done" in serialized
assert "Hello world" in serialized
def test_serialize_content_part_done_event_dict(self):
"""Test serialization of response.content_part.done event as dict"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {
"type": "response.content_part.done",
"response_id": "resp_123",
"item_id": "msg_456",
"output_index": 0,
"content_index": 0,
"part": {"type": "output_text", "text": "Complete text"},
}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.content_part.done" in serialized
def test_serialize_output_item_done_event_dict(self):
"""Test serialization of response.output_item.done event as dict"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {
"type": "response.output_item.done",
"response_id": "resp_123",
"item_id": "msg_456",
"output_index": 0,
"item": {"type": "message", "role": "assistant", "status": "completed"},
}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.output_item.done" in serialized
assert "msg_456" in serialized
def test_serialize_response_completed_event_dict(self):
"""Test serialization of response.completed event as dict"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {
"type": "response.completed",
"response_id": "resp_123",
"response": {
"id": "resp_123",
"status": "completed",
"output": [
{
"type": "message",
"content": [{"type": "output_text", "text": "Done"}],
}
],
},
}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.completed" in serialized
assert "resp_123" in serialized
def test_serialize_response_failed_event_dict(self):
"""Test serialization of response.failed event as dict"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {
"type": "response.failed",
"response_id": "resp_123",
"response": {
"id": "resp_123",
"status": "failed",
"status_details": {"error": {"message": "Rate limit exceeded"}},
},
}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.failed" in serialized
assert "Rate limit exceeded" in serialized
def test_serialize_response_incomplete_event_dict(self):
"""Test serialization of response.incomplete event as dict"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {
"type": "response.incomplete",
"response_id": "resp_123",
"response": {
"id": "resp_123",
"status": "incomplete",
"status_details": {"reason": "max_output_tokens"},
},
}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.incomplete" in serialized
assert "max_output_tokens" in serialized
class TestMultiTurnSessionHistory:
"""Test multi-turn conversation handling via session history"""
def test_extract_output_messages_preserves_multiple_messages(self):
"""Test that multiple output messages are all preserved"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
completed_event = {
"type": "response.completed",
"response": {
"id": "resp_123",
"output": [
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "First message"}],
},
{
"type": "function_call",
"id": "call_123",
"name": "get_weather",
"arguments": "{}",
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Second message"}],
},
],
},
}
messages = ManagedResponsesWebSocketHandler._extract_output_messages(
completed_event
)
assert len(messages) == 3
assert messages[0]["content"][0]["text"] == "First message"
assert messages[1]["type"] == "function_call"
assert messages[2]["content"][0]["text"] == "Second message"
def test_input_to_messages_with_mixed_content_types(self):
"""Test input conversion with mixed content types"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
input_list = [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "Question"},
{"type": "input_image", "image_url": "https://example.com/img.png"},
],
}
]
messages = ManagedResponsesWebSocketHandler._input_to_messages(input_list)
assert len(messages) == 1
assert len(messages[0]["content"]) == 2
assert messages[0]["content"][0]["type"] == "input_text"
assert messages[0]["content"][1]["type"] == "input_image"
def test_extract_output_messages_with_mixed_text_types(self):
"""Test that both 'output_text' and 'text' types are extracted"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
completed_event = {
"type": "response.completed",
"response": {
"id": "resp_123",
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{"type": "output_text", "text": "Part 1"},
{"type": "text", "text": "Part 2"},
],
}
],
},
}
messages = ManagedResponsesWebSocketHandler._extract_output_messages(
completed_event
)
assert len(messages) == 1
assert messages[0]["content"][0]["text"] == "Part 1Part 2"
def test_extract_response_id_from_completed_event(self):
"""Test extraction of response ID from completed event"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
completed_event = {
"type": "response.completed",
"response": {"id": "resp_abc123", "status": "completed"},
}
response_id = ManagedResponsesWebSocketHandler._extract_response_id(
completed_event
)
assert response_id == "resp_abc123"
def test_extract_response_id_handles_missing_response(self):
"""Test that missing response dict returns None"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
completed_event = {"type": "response.completed"}
response_id = ManagedResponsesWebSocketHandler._extract_response_id(
completed_event
)
assert response_id is None
class TestWebSocketErrorHandling:
"""Test error handling in WebSocket mode"""
@pytest.mark.asyncio
async def test_managed_handler_handles_invalid_json(self):
"""Test that invalid JSON in response.create is handled gracefully"""
from unittest.mock import AsyncMock, MagicMock
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
mock_websocket = MagicMock()
mock_websocket.send_text = AsyncMock()
mock_websocket.recv = AsyncMock(return_value="invalid json {{{")
mock_logging_obj = Logging(
model="test-model",
messages=[],
stream=True,
call_type="aresponses",
start_time=0,
litellm_call_id="test-id",
function_id="test-func",
)
handler = ManagedResponsesWebSocketHandler(
websocket=mock_websocket,
model="test-model",
logging_obj=mock_logging_obj,
)
# Process invalid JSON
await handler._process_response_create("invalid json {{{")
# Should have sent an error event
mock_websocket.send_text.assert_called_once()
error_event = mock_websocket.send_text.call_args[0][0]
assert "error" in error_event
assert "Invalid JSON" in error_event
class TestWebSocketProjectQuotaEnforcement:
"""VERIA regression: the connection-level pre-call hook only runs once,
but a WebSocket connection accepts many response.create frames. Every
frame must be checked against any registered project ITPM/OTPM quota
callback, not just the first one."""
@pytest.mark.asyncio
async def test_managed_handler_blocks_frame_rejected_by_quota_callback(self, monkeypatch):
from unittest.mock import AsyncMock, MagicMock
import litellm
from litellm.exceptions import RateLimitError
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
aresponses_called = False
async def fake_aresponses(*args, **kwargs):
nonlocal aresponses_called
aresponses_called = True
monkeypatch.setattr(litellm, "aresponses", fake_aresponses)
quota_callback = MagicMock()
quota_callback.enforce_project_io_token_quota_for_frame = AsyncMock(
side_effect=RateLimitError(message="project OTPM exceeded", llm_provider="", model="")
)
mock_websocket = MagicMock()
mock_websocket.send_text = AsyncMock()
mock_logging_obj = Logging(
model="test-model",
messages=[],
stream=True,
call_type="aresponses",
start_time=0,
litellm_call_id="test-id",
function_id="test-func",
)
handler = ManagedResponsesWebSocketHandler(
websocket=mock_websocket,
model="test-model",
logging_obj=mock_logging_obj,
quota_callbacks=[quota_callback],
)
await handler._process_response_create(json.dumps({"type": "response.create", "input": "hi"}))
quota_callback.enforce_project_io_token_quota_for_frame.assert_awaited_once()
assert aresponses_called is False
mock_websocket.send_text.assert_called_once()
error_event = mock_websocket.send_text.call_args[0][0]
assert "rate_limit_exceeded" in error_event
@pytest.mark.asyncio
async def test_managed_handler_forwards_frame_allowed_by_quota_callback(self, monkeypatch):
from unittest.mock import AsyncMock, MagicMock
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
aresponses_called = False
async def fake_aresponses(*args, **kwargs):
nonlocal aresponses_called
aresponses_called = True
async def _empty():
return
yield
return _empty()
monkeypatch.setattr(litellm, "aresponses", fake_aresponses)
quota_callback = MagicMock()
quota_callback.enforce_project_io_token_quota_for_frame = AsyncMock(return_value=None)
mock_websocket = MagicMock()
mock_websocket.send_text = AsyncMock()
mock_logging_obj = Logging(
model="test-model",
messages=[],
stream=True,
call_type="aresponses",
start_time=0,
litellm_call_id="test-id",
function_id="test-func",
)
handler = ManagedResponsesWebSocketHandler(
websocket=mock_websocket,
model="test-model",
logging_obj=mock_logging_obj,
quota_callbacks=[quota_callback],
)
await handler._process_response_create(json.dumps({"type": "response.create", "input": "hi"}))
quota_callback.enforce_project_io_token_quota_for_frame.assert_awaited_once()
assert aresponses_called is True
@pytest.mark.asyncio
async def test_native_handler_blocks_frame_rejected_by_quota_callback(self):
from unittest.mock import AsyncMock, MagicMock
from litellm.exceptions import RateLimitError
from litellm.responses.streaming_iterator import ResponsesWebSocketStreaming
quota_callback = MagicMock()
quota_callback.enforce_project_io_token_quota_for_frame = AsyncMock(
side_effect=RateLimitError(message="project OTPM exceeded", llm_provider="", model="")
)
mock_backend_ws = MagicMock()
mock_backend_ws.send = AsyncMock()
mock_websocket = MagicMock()
mock_websocket.send_text = AsyncMock()
handler = ResponsesWebSocketStreaming(
websocket=mock_websocket,
backend_ws=mock_backend_ws,
logging_obj=MagicMock(),
authorized_model="gpt-4o",
quota_callbacks=[quota_callback],
)
allowed = await handler._enforce_or_reject_frame(
json.dumps({"type": "response.create", "input": "hi"})
)
assert allowed is False
mock_backend_ws.send.assert_not_called()
mock_websocket.send_text.assert_called_once()
assert "rate_limit_exceeded" in mock_websocket.send_text.call_args[0][0]
@pytest.mark.asyncio
async def test_native_handler_forwards_frame_allowed_by_quota_callback(self):
from unittest.mock import AsyncMock, MagicMock
from litellm.responses.streaming_iterator import ResponsesWebSocketStreaming
quota_callback = MagicMock()
quota_callback.enforce_project_io_token_quota_for_frame = AsyncMock(return_value=None)
handler = ResponsesWebSocketStreaming(
websocket=MagicMock(),
backend_ws=MagicMock(),
logging_obj=MagicMock(),
authorized_model="gpt-4o",
quota_callbacks=[quota_callback],
)
allowed = await handler._enforce_or_reject_frame(
json.dumps({"type": "response.create", "input": "hi"})
)
assert allowed is True
quota_callback.enforce_project_io_token_quota_for_frame.assert_awaited_once()
def _deployment_defaults():
from types import MappingProxyType
from litellm.types.responses.streaming_websocket import ResponsesWebSocketRequestDefaults
return ResponsesWebSocketRequestDefaults(
fill_missing=MappingProxyType({"reasoning": {"effort": "high"}, "service_tier": "priority"}),
overrides=MappingProxyType({"provider_default": "configured"}),
)
class TestNativeWebSocketDeploymentDefaults:
"""The native relay merges deployment litellm_params into every response.create like HTTP does."""
def test_builder_maps_router_kwargs_like_the_http_path(self):
from litellm.responses.main import _build_responses_websocket_request_defaults
defaults = _build_responses_websocket_request_defaults(
{
"model": "gpt-5-pro",
"reasoning_effort": "high",
"service_tier": "priority",
"extra_body": {"provider_default": "configured"},
"temperature": None,
"timeout": 600,
"max_retries": 2,
"caching": False,
"custom_llm_provider": "openai",
"litellm_metadata": {"user_api_key": "hashed"},
"user_api_key_dict": MagicMock(),
"litellm_logging_obj": MagicMock(),
"websocket": MagicMock(),
}
)
assert dict(defaults.fill_missing) == {"reasoning": {"effort": "high"}, "service_tier": "priority"}
assert dict(defaults.overrides) == {"provider_default": "configured"}
def test_builder_keeps_explicit_reasoning_over_reasoning_effort(self):
from litellm.responses.main import _build_responses_websocket_request_defaults
defaults = _build_responses_websocket_request_defaults(
{"model": "gpt-5-pro", "reasoning": {"effort": "low"}, "reasoning_effort": "high"}
)
assert dict(defaults.fill_missing) == {"reasoning": {"effort": "low"}}
assert dict(defaults.overrides) == {}
def test_builder_copies_dict_valued_reasoning_effort_like_the_http_path(self):
from litellm.responses.main import _build_responses_websocket_request_defaults
defaults = _build_responses_websocket_request_defaults(
{"model": "gpt-5-pro", "reasoning_effort": {"effort": "xhigh", "summary": "auto"}}
)
assert dict(defaults.fill_missing) == {"reasoning": {"effort": "xhigh", "summary": "auto"}}
@pytest.mark.parametrize("reasoning_effort", [5, ["low"], "hgih"])
def test_builder_forwards_non_enum_reasoning_effort_like_the_http_path(
self, reasoning_effort: int | list[str] | str
):
from litellm.responses.main import _build_responses_websocket_request_defaults
defaults = _build_responses_websocket_request_defaults({"model": "gpt-5-pro", "reasoning_effort": reasoning_effort})
assert dict(defaults.fill_missing) == {"reasoning": {"effort": reasoning_effort}}
@pytest.mark.asyncio
async def test_extra_body_type_key_never_replaces_the_frame_type(self):
from types import MappingProxyType
from litellm.types.responses.streaming_websocket import ResponsesWebSocketRequestDefaults
handler = _make_streaming(
authorized_model="gpt-5-pro",
request_defaults=ResponsesWebSocketRequestDefaults(
fill_missing=MappingProxyType({}),
overrides=MappingProxyType({"type": "session.update", "provider_default": "configured"}),
),
)
forwarded = json.loads(
await handler._mask_response_create(
json.dumps({"type": "response.create", "model": "gpt-5-pro", "input": "hi"})
)
)
assert forwarded == {
"type": "response.create",
"model": "gpt-5-pro",
"input": "hi",
"provider_default": "configured",
}
@pytest.mark.asyncio
async def test_flat_frame_gets_defaults_client_keys_win_extra_body_overrides(self):
handler = _make_streaming(authorized_model="gpt-5-pro", request_defaults=_deployment_defaults())
forwarded = json.loads(
await handler._mask_response_create(
json.dumps(
{
"type": "response.create",
"model": "gpt-5-pro",
"input": "Say hello",
"service_tier": "default",
"provider_default": "client",
}
)
)
)
assert forwarded == {
"type": "response.create",
"model": "gpt-5-pro",
"input": "Say hello",
"service_tier": "default",
"provider_default": "configured",
"reasoning": {"effort": "high"},
}
@pytest.mark.asyncio
async def test_nested_response_frame_gets_defaults_inside_response(self):
handler = _make_streaming(authorized_model="gpt-5-pro", request_defaults=_deployment_defaults())
forwarded = json.loads(
await handler._mask_response_create(
json.dumps({"type": "response.create", "response": {"model": "gpt-5-pro", "input": "hi"}})
)
)
assert forwarded == {
"type": "response.create",
"response": {
"model": "gpt-5-pro",
"input": "hi",
"reasoning": {"effort": "high"},
"service_tier": "priority",
"provider_default": "configured",
},
}
@pytest.mark.asyncio
async def test_frames_that_need_nothing_pass_through_untouched(self):
handler = _make_streaming(authorized_model="gpt-5-pro", request_defaults=_deployment_defaults())
cancel_frame = json.dumps({"type": "response.cancel"})
complete_frame = json.dumps(
{
"type": "response.create",
"model": "gpt-5-pro",
"input": "hi",
"reasoning": {"effort": "high"},
"service_tier": "priority",
"provider_default": "configured",
}
)
assert await handler._mask_response_create(cancel_frame) is cancel_frame
assert await handler._mask_response_create(complete_frame) is complete_frame
@pytest.mark.asyncio
async def test_handler_applies_defaults_to_the_first_frame_sent_upstream(self):
import asyncio
from unittest.mock import AsyncMock, patch
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
class FakeBackend:
def __init__(self):
self.sent = []
async def send(self, message):
self.sent.append(message)
async def recv(self, decode=False):
raise RuntimeError("backend closed")
async def close(self):
pass
backend = FakeBackend()
class FakeConnect:
def __init__(self, url, **kwargs):
pass
async def __aenter__(self):
return backend
async def __aexit__(self, *args):
pass
mock_config = MagicMock(spec=OpenAIResponsesAPIConfig)
mock_config.supports_native_websocket.return_value = True
mock_config.model_in_websocket_url.return_value = True
mock_config.get_websocket_url.return_value = "wss://api.openai.com/v1/responses"
mock_config.validate_environment.return_value = {}
mock_logging = MagicMock()
mock_logging.pre_call = MagicMock()
mock_logging.dispatch_success_handlers = AsyncMock()
client_ws = MagicMock()
client_ws.receive_text = AsyncMock(side_effect=RuntimeError("client closed"))
client_ws.send_text = AsyncMock()
client_ws.close = AsyncMock()
with patch("websockets.connect", FakeConnect):
await BaseLLMHTTPHandler().async_responses_websocket(
model="gpt-5-pro",
websocket=client_ws,
logging_obj=mock_logging,
responses_api_provider_config=mock_config,
api_key="sk-test",
first_message=json.dumps({"type": "response.create", "model": "gpt-5-pro", "input": "Say hello"}),
request_defaults=_deployment_defaults(),
)
await asyncio.sleep(0)
assert [json.loads(frame) for frame in backend.sent] == [
{
"type": "response.create",
"model": "gpt-5-pro",
"input": "Say hello",
"reasoning": {"effort": "high"},
"service_tier": "priority",
"provider_default": "configured",
}
]
@pytest.mark.asyncio
async def test_aresponses_websocket_builds_defaults_from_deployment_kwargs(self, monkeypatch):
import importlib
from unittest.mock import AsyncMock
responses_main = importlib.import_module("litellm.responses.main")
stub = MagicMock()
stub.async_responses_websocket = AsyncMock()
monkeypatch.setattr(responses_main, "base_llm_http_handler", stub)
await responses_main._aresponses_websocket.__wrapped__(
model="openai/gpt-5-pro",
websocket=MagicMock(),
api_key="sk-test",
litellm_logging_obj=MagicMock(),
reasoning_effort="high",
service_tier="priority",
extra_body={"provider_default": "configured"},
)
request_defaults = stub.async_responses_websocket.call_args.kwargs["request_defaults"]
assert dict(request_defaults.fill_missing) == {"reasoning": {"effort": "high"}, "service_tier": "priority"}
assert dict(request_defaults.overrides) == {"provider_default": "configured"}
@pytest.mark.asyncio
async def test_aresponses_websocket_keeps_first_frame_routing_hints_out_of_the_defaults(
self, monkeypatch: pytest.MonkeyPatch
):
import importlib
from unittest.mock import AsyncMock
responses_main = importlib.import_module("litellm.responses.main")
stub = MagicMock()
stub.async_responses_websocket = AsyncMock()
monkeypatch.setattr(responses_main, "base_llm_http_handler", stub)
await responses_main._aresponses_websocket.__wrapped__(
model="openai/gpt-5-pro",
websocket=MagicMock(),
api_key="sk-test",
litellm_logging_obj=MagicMock(),
reasoning_effort="high",
input=[{"id": "encitem_abc", "type": "reasoning", "encrypted_content": "litellm_enc:abc"}],
previous_response_id="resp_first_turn",
)
call_kwargs = stub.async_responses_websocket.call_args.kwargs
assert dict(call_kwargs["request_defaults"].fill_missing) == {"reasoning": {"effort": "high"}}
assert "input" not in call_kwargs
assert "previous_response_id" not in call_kwargs
class TestNativeWebSocketGuardrails:
@pytest.mark.asyncio
async def test_response_create_injects_authorized_model(self):
import json
from unittest.mock import MagicMock
from litellm.responses.streaming_iterator import ResponsesWebSocketStreaming
handler = ResponsesWebSocketStreaming(
websocket=MagicMock(),
backend_ws=MagicMock(),
logging_obj=MagicMock(),
authorized_model="authorized-deployment",
)
flat_message = await handler._mask_response_create(
json.dumps({"type": "response.create", "input": "hi"})
)
nested_message = await handler._mask_response_create(
json.dumps({"type": "response.create", "response": {"input": "hi"}})
)
assert json.loads(flat_message)["model"] == "authorized-deployment"
assert (
json.loads(nested_message)["response"]["model"] == "authorized-deployment"
)
@pytest.mark.asyncio
async def test_completed_event_with_null_response_passes_through(self):
from unittest.mock import MagicMock
from litellm.responses.streaming_iterator import ResponsesWebSocketStreaming
class Guardrail:
def get_presidio_settings_from_request_data(self, request_data):
return None
def _unmask_pii_text(self, text, pii_tokens):
return text
event = '{"type":"response.completed","response":null}'
guardrail = Guardrail()
handler = ResponsesWebSocketStreaming(
websocket=MagicMock(),
backend_ws=MagicMock(),
logging_obj=MagicMock(),
request_data={"metadata": {"pii_tokens": {"<TOKEN_1>": "secret"}}},
guardrail_callbacks=[guardrail],
output_guardrail_callbacks=[guardrail],
)
assert handler._unmask_response_event(event) == event
assert await handler._mask_response_completed(event) == event
@pytest.mark.asyncio
async def test_output_masking_suppresses_delta_without_calling_presidio(self):
import json
from unittest.mock import AsyncMock, MagicMock
import websockets.exceptions
from litellm.responses.streaming_iterator import ResponsesWebSocketStreaming
class RecordingGuardrail:
def __init__(self):
self.check_pii_calls = []
def get_presidio_settings_from_request_data(self, request_data):
return None
def _unmask_pii_text(self, text, pii_tokens):
return text
async def check_pii(
self, text, output_parse_pii, presidio_config, request_data
):
self.check_pii_calls.append(text)
return text
class FakeBackendWS:
def __init__(self, events):
self._events = list(events)
async def recv(self, decode=False):
if self._events:
return self._events.pop(0)
raise websockets.exceptions.ConnectionClosed(None, None)
guardrail = RecordingGuardrail()
client_ws = MagicMock()
client_ws.send_text = AsyncMock()
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
delta_event = json.dumps(
{"type": "response.output_text.delta", "delta": "alice@example.com"}
)
completed_event = json.dumps(
{
"type": "response.completed",
"response": {
"output": [
{
"content": [
{"type": "output_text", "text": "alice@example.com"}
]
}
]
},
}
)
handler = ResponsesWebSocketStreaming(
websocket=client_ws,
backend_ws=FakeBackendWS([delta_event, completed_event]),
logging_obj=logging_obj,
output_guardrail_callbacks=[guardrail],
)
await handler.backend_to_client()
# The delta event must be suppressed without ever invoking Presidio,
# so check_pii is called exactly once (for the completed event only).
assert guardrail.check_pii_calls == ["alice@example.com"]
client_ws.send_text.assert_called_once()
sent_payload = client_ws.send_text.call_args[0][0]
assert json.loads(sent_payload)["type"] == "response.completed"
@pytest.mark.asyncio
async def test_output_masking_suppresses_text_bearing_done_events(self):
import json
from unittest.mock import AsyncMock, MagicMock
import websockets.exceptions
from litellm.responses.streaming_iterator import ResponsesWebSocketStreaming
class MaskingGuardrail:
def __init__(self):
self.check_pii_calls = []
def get_presidio_settings_from_request_data(self, request_data):
return None
def _unmask_pii_text(self, text, pii_tokens):
return text
async def check_pii(
self, text, output_parse_pii, presidio_config, request_data
):
self.check_pii_calls.append(text)
return text.replace("alice@example.com", "<EMAIL_ADDRESS>")
class FakeBackendWS:
def __init__(self, events):
self._events = list(events)
async def recv(self, decode=False):
if self._events:
return self._events.pop(0)
raise websockets.exceptions.ConnectionClosed(None, None)
guardrail = MaskingGuardrail()
client_ws = MagicMock()
client_ws.send_text = AsyncMock()
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
done_events = [
json.dumps(
{"type": "response.output_text.done", "text": "alice@example.com"}
),
json.dumps(
{
"type": "response.content_part.done",
"part": {"type": "output_text", "text": "alice@example.com"},
}
),
json.dumps(
{
"type": "response.output_item.done",
"item": {
"type": "message",
"content": [
{"type": "output_text", "text": "alice@example.com"}
],
},
}
),
]
completed_event = json.dumps(
{
"type": "response.completed",
"response": {
"output": [
{
"content": [
{"type": "output_text", "text": "alice@example.com"}
]
}
]
},
}
)
handler = ResponsesWebSocketStreaming(
websocket=client_ws,
backend_ws=FakeBackendWS(done_events + [completed_event]),
logging_obj=logging_obj,
output_guardrail_callbacks=[guardrail],
)
await handler.backend_to_client()
# Text-bearing done events carry the full output before response.completed
# arrives; they must be suppressed so unmasked PII never reaches the
# client, and Presidio is only invoked for response.completed.
assert guardrail.check_pii_calls == ["alice@example.com"]
client_ws.send_text.assert_called_once()
sent_payload = client_ws.send_text.call_args[0][0]
assert json.loads(sent_payload)["type"] == "response.completed"
assert "alice@example.com" not in sent_payload
assert "<EMAIL_ADDRESS>" in sent_payload
class _FakeWSGuardrail:
"""Presidio-like guardrail double for the WebSocket masking hooks.
``check_pii`` replaces each known PII string with its token. When
``output_parse_pii`` is True (input masking) the token->original map is
persisted into ``request_data["metadata"]["pii_tokens"]`` so the response
path can reverse it. ``_unmask_pii_text`` performs that reversal.
"""
def __init__(self, mask_map=None):
self.mask_map = mask_map or {"alice@example.com": "<EMAIL_ADDRESS_1>"}
self.output_parse_pii = True
self.apply_to_output = True
def get_presidio_settings_from_request_data(self, request_data):
return None
async def check_pii(self, text, output_parse_pii, presidio_config, request_data):
masked = text
tokens = {}
for original, token in self.mask_map.items():
if original in masked:
masked = masked.replace(original, token)
tokens[token] = original
if output_parse_pii and tokens:
metadata = request_data.setdefault("metadata", {})
metadata.setdefault("pii_tokens", {}).update(tokens)
return masked
def _unmask_pii_text(self, text, pii_tokens):
for token, original in pii_tokens.items():
text = text.replace(token, original)
return text
def _make_streaming(**kwargs):
from unittest.mock import MagicMock
from litellm.responses.streaming_iterator import ResponsesWebSocketStreaming
kwargs.setdefault("websocket", MagicMock())
kwargs.setdefault("backend_ws", MagicMock())
kwargs.setdefault("logging_obj", MagicMock())
return ResponsesWebSocketStreaming(**kwargs)
class TestNativeWebSocketGuardrailMasking:
"""Exercises the input/output PII masking hooks on ResponsesWebSocketStreaming."""
@pytest.mark.asyncio
async def test_mask_response_create_flat_string_input(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(
request_data={},
guardrail_callbacks=[guardrail],
authorized_model="auth-model",
)
masked = await handler._mask_response_create(
json.dumps(
{"type": "response.create", "input": "email alice@example.com now"}
)
)
obj = json.loads(masked)
assert obj["model"] == "auth-model"
assert obj["input"] == "email <EMAIL_ADDRESS_1> now"
assert handler.request_data["metadata"]["pii_tokens"] == {
"<EMAIL_ADDRESS_1>": "alice@example.com"
}
@pytest.mark.asyncio
async def test_mask_response_create_list_content_string(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(request_data={}, guardrail_callbacks=[guardrail])
masked = await handler._mask_response_create(
json.dumps(
{
"type": "response.create",
"input": [
{
"type": "message",
"role": "user",
"content": "ping alice@example.com",
}
],
}
)
)
obj = json.loads(masked)
assert obj["input"][0]["content"] == "ping <EMAIL_ADDRESS_1>"
@pytest.mark.asyncio
async def test_mask_response_create_input_text_blocks(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(request_data={}, guardrail_callbacks=[guardrail])
masked = await handler._mask_response_create(
json.dumps(
{
"type": "response.create",
"input": [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "alice@example.com"},
{"type": "input_image", "image_url": "http://x"},
],
}
],
}
)
)
obj = json.loads(masked)
blocks = obj["input"][0]["content"]
assert blocks[0]["text"] == "<EMAIL_ADDRESS_1>"
assert blocks[1]["image_url"] == "http://x"
@pytest.mark.asyncio
async def test_mask_response_create_function_call_output_string(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(request_data={}, guardrail_callbacks=[guardrail])
masked = await handler._mask_response_create(
json.dumps(
{
"type": "response.create",
"input": [
{
"type": "function_call_output",
"call_id": "call_1",
"output": "tool returned alice@example.com",
}
],
}
)
)
obj = json.loads(masked)
assert obj["input"][0]["output"] == "tool returned <EMAIL_ADDRESS_1>"
assert handler.request_data["metadata"]["pii_tokens"] == {
"<EMAIL_ADDRESS_1>": "alice@example.com"
}
@pytest.mark.asyncio
async def test_mask_response_create_function_call_output_blocks(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(request_data={}, guardrail_callbacks=[guardrail])
masked = await handler._mask_response_create(
json.dumps(
{
"type": "response.create",
"input": [
{
"type": "function_call_output",
"call_id": "call_1",
"output": [
{"type": "output_text", "text": "alice@example.com"},
{"type": "input_image", "image_url": "http://x"},
],
}
],
}
)
)
obj = json.loads(masked)
blocks = obj["input"][0]["output"]
assert blocks[0]["text"] == "<EMAIL_ADDRESS_1>"
assert blocks[1]["image_url"] == "http://x"
@pytest.mark.asyncio
async def test_mask_response_create_nested_shape(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(
request_data={},
guardrail_callbacks=[guardrail],
authorized_model="auth-model",
)
masked = await handler._mask_response_create(
json.dumps(
{
"type": "response.create",
"response": {"input": "alice@example.com", "model": "spoofed"},
}
)
)
obj = json.loads(masked)
assert obj["response"]["model"] == "auth-model"
assert obj["response"]["input"] == "<EMAIL_ADDRESS_1>"
@pytest.mark.asyncio
async def test_mask_response_create_flat_instructions(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(request_data={}, guardrail_callbacks=[guardrail])
masked = await handler._mask_response_create(
json.dumps(
{
"type": "response.create",
"input": "hi",
"instructions": "reply to alice@example.com",
}
)
)
obj = json.loads(masked)
assert obj["instructions"] == "reply to <EMAIL_ADDRESS_1>"
assert handler.request_data["metadata"]["pii_tokens"] == {
"<EMAIL_ADDRESS_1>": "alice@example.com"
}
@pytest.mark.asyncio
async def test_mask_response_create_nested_instructions(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(request_data={}, guardrail_callbacks=[guardrail])
masked = await handler._mask_response_create(
json.dumps(
{
"type": "response.create",
"response": {
"input": "hi",
"instructions": "email alice@example.com",
},
}
)
)
obj = json.loads(masked)
assert obj["response"]["instructions"] == "email <EMAIL_ADDRESS_1>"
assert handler.request_data["metadata"]["pii_tokens"] == {
"<EMAIL_ADDRESS_1>": "alice@example.com"
}
@pytest.mark.asyncio
async def test_mask_response_create_non_create_unchanged(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(
request_data={},
guardrail_callbacks=[guardrail],
authorized_model="auth-model",
)
message = json.dumps({"type": "response.cancel", "input": "alice@example.com"})
assert await handler._mask_response_create(message) == message
@pytest.mark.asyncio
async def test_mask_response_create_invalid_json_unchanged(self):
handler = _make_streaming(
request_data={}, guardrail_callbacks=[_FakeWSGuardrail()]
)
assert await handler._mask_response_create("not json {{{") == "not json {{{"
@pytest.mark.asyncio
async def test_mask_response_create_model_only_without_guardrails(self):
handler = _make_streaming(request_data={}, authorized_model="auth-model")
masked = await handler._mask_response_create(
json.dumps({"type": "response.create", "input": "alice@example.com"})
)
obj = json.loads(masked)
assert obj["model"] == "auth-model"
assert obj["input"] == "alice@example.com"
@pytest.mark.asyncio
async def test_mask_response_create_no_op_without_model_or_guardrails(self):
handler = _make_streaming(request_data={})
message = json.dumps({"type": "response.create", "input": "alice@example.com"})
assert await handler._mask_response_create(message) == message
@pytest.mark.asyncio
async def test_mask_response_create_list_with_non_dict_item(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(request_data={}, guardrail_callbacks=[guardrail])
masked = await handler._mask_response_create(
json.dumps(
{
"type": "response.create",
"input": [
"not-a-dict",
{
"type": "message",
"role": "user",
"content": "alice@example.com",
},
],
}
)
)
obj = json.loads(masked)
assert obj["input"][0] == "not-a-dict"
assert obj["input"][1]["content"] == "<EMAIL_ADDRESS_1>"
def test_enforce_authorized_model_no_authorized_model(self):
handler = _make_streaming(request_data={})
assert handler._enforce_authorized_model({"model": "anything"}) is False
def test_enforce_authorized_model_nested_with_top_level_model(self):
handler = _make_streaming(request_data={}, authorized_model="auth-model")
msg = {"response": {"model": "spoofed"}, "model": "also-spoofed"}
assert handler._enforce_authorized_model(msg) is True
assert msg["response"]["model"] == "auth-model"
assert msg["model"] == "auth-model"
@pytest.mark.asyncio
async def test_unmask_response_event_completed(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(
request_data={
"metadata": {"pii_tokens": {"<EMAIL_ADDRESS_1>": "alice@example.com"}}
},
guardrail_callbacks=[guardrail],
)
event = json.dumps(
{
"type": "response.completed",
"response": {
"output": [
{
"content": [
{"type": "output_text", "text": "to <EMAIL_ADDRESS_1>"}
]
}
]
},
}
)
unmasked = json.loads(handler._unmask_response_event(event))
assert (
unmasked["response"]["output"][0]["content"][0]["text"]
== "to alice@example.com"
)
@pytest.mark.asyncio
async def test_unmask_response_event_delta(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(
request_data={
"metadata": {"pii_tokens": {"<EMAIL_ADDRESS_1>": "alice@example.com"}}
},
guardrail_callbacks=[guardrail],
)
event = json.dumps(
{"type": "response.output_text.delta", "delta": "<EMAIL_ADDRESS_1>"}
)
unmasked = json.loads(handler._unmask_response_event(event))
assert unmasked["delta"] == "alice@example.com"
def test_unmask_response_event_no_tokens_unchanged(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(request_data={}, guardrail_callbacks=[guardrail])
event = json.dumps(
{"type": "response.output_text.delta", "delta": "<EMAIL_ADDRESS_1>"}
)
assert handler._unmask_response_event(event) == event
def test_unmask_response_event_no_guardrails_unchanged(self):
handler = _make_streaming(
request_data={"metadata": {"pii_tokens": {"<EMAIL_ADDRESS_1>": "x"}}}
)
event = json.dumps({"type": "response.completed", "response": {}})
assert handler._unmask_response_event(event) == event
def test_unmask_response_event_invalid_json_unchanged(self):
handler = _make_streaming(
request_data={"metadata": {"pii_tokens": {"<EMAIL_ADDRESS_1>": "x"}}},
guardrail_callbacks=[_FakeWSGuardrail()],
)
assert handler._unmask_response_event("not json {{{") == "not json {{{"
def test_unmask_response_event_non_dict_response_unchanged(self):
handler = _make_streaming(
request_data={"metadata": {"pii_tokens": {"<EMAIL_ADDRESS_1>": "x"}}},
guardrail_callbacks=[_FakeWSGuardrail()],
)
event = json.dumps({"type": "response.completed", "response": ["bad-shape"]})
assert handler._unmask_response_event(event) == event
def test_unmask_response_event_malformed_output_items_unchanged(self):
handler = _make_streaming(
request_data={
"metadata": {"pii_tokens": {"<EMAIL_ADDRESS_1>": "alice@example.com"}}
},
guardrail_callbacks=[_FakeWSGuardrail()],
)
event = json.dumps(
{
"type": "response.completed",
"response": {
"output": [
"not-a-dict",
{"content": "not-a-list"},
{"content": ["not-a-dict-block"]},
]
},
}
)
assert handler._unmask_response_event(event) == event
def test_unmask_response_event_other_event_type_unchanged(self):
handler = _make_streaming(
request_data={"metadata": {"pii_tokens": {"<EMAIL_ADDRESS_1>": "x"}}},
guardrail_callbacks=[_FakeWSGuardrail()],
)
event = json.dumps(
{"type": "response.in_progress", "delta": "<EMAIL_ADDRESS_1>"}
)
assert handler._unmask_response_event(event) == event
@pytest.mark.asyncio
async def test_mask_response_completed_event(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(
request_data={}, output_guardrail_callbacks=[guardrail]
)
event = json.dumps(
{
"type": "response.completed",
"response": {
"output": [
{
"content": [
{
"type": "output_text",
"text": "contact alice@example.com",
}
]
}
]
},
}
)
masked = json.loads(await handler._mask_response_completed(event))
assert (
masked["response"]["output"][0]["content"][0]["text"]
== "contact <EMAIL_ADDRESS_1>"
)
@pytest.mark.asyncio
async def test_mask_response_completed_masks_function_call_arguments(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(
request_data={}, output_guardrail_callbacks=[guardrail]
)
event = json.dumps(
{
"type": "response.completed",
"response": {
"output": [
{
"type": "function_call",
"name": "send_email",
"arguments": '{"to": "alice@example.com"}',
}
]
},
}
)
masked = json.loads(await handler._mask_response_completed(event))
assert (
masked["response"]["output"][0]["arguments"]
== '{"to": "<EMAIL_ADDRESS_1>"}'
)
@pytest.mark.asyncio
async def test_mask_response_completed_masks_reasoning_summary(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(
request_data={}, output_guardrail_callbacks=[guardrail]
)
event = json.dumps(
{
"type": "response.completed",
"response": {
"output": [
{
"type": "reasoning",
"summary": [
{
"type": "summary_text",
"text": "user is alice@example.com",
}
],
}
]
},
}
)
masked = json.loads(await handler._mask_response_completed(event))
assert (
masked["response"]["output"][0]["summary"][0]["text"]
== "user is <EMAIL_ADDRESS_1>"
)
@pytest.mark.asyncio
async def test_mask_response_completed_delta_unchanged(self):
guardrail = _FakeWSGuardrail()
handler = _make_streaming(
request_data={}, output_guardrail_callbacks=[guardrail]
)
event = json.dumps(
{"type": "response.output_text.delta", "delta": "alice@example.com"}
)
assert await handler._mask_response_completed(event) == event
@pytest.mark.asyncio
async def test_mask_response_completed_no_guardrails_unchanged(self):
handler = _make_streaming(request_data={})
event = json.dumps(
{"type": "response.output_text.delta", "delta": "alice@example.com"}
)
assert await handler._mask_response_completed(event) == event
@pytest.mark.asyncio
async def test_mask_response_completed_invalid_json_unchanged(self):
handler = _make_streaming(
request_data={}, output_guardrail_callbacks=[_FakeWSGuardrail()]
)
assert await handler._mask_response_completed("not json {{{") == "not json {{{"
@pytest.mark.asyncio
async def test_mask_response_completed_malformed_unchanged(self):
handler = _make_streaming(
request_data={}, output_guardrail_callbacks=[_FakeWSGuardrail()]
)
event = json.dumps(
{
"type": "response.completed",
"response": {
"output": [
"not-a-dict",
{"content": "not-a-list"},
{"content": ["not-a-dict-block"]},
]
},
}
)
assert await handler._mask_response_completed(event) == event
@pytest.mark.asyncio
async def test_mask_response_completed_non_dict_response_unchanged(self):
handler = _make_streaming(
request_data={}, output_guardrail_callbacks=[_FakeWSGuardrail()]
)
event = json.dumps({"type": "response.completed", "response": ["bad"]})
assert await handler._mask_response_completed(event) == event
@pytest.mark.asyncio
async def test_client_to_backend_masks_and_enforces_model(self):
from unittest.mock import AsyncMock
guardrail = _FakeWSGuardrail()
backend_ws = MagicMock()
backend_ws.send = AsyncMock()
websocket = MagicMock()
websocket.receive_text = AsyncMock(
side_effect=[
json.dumps(
{"type": "response.create", "input": "ping alice@example.com"}
),
Exception("stop"),
]
)
handler = _make_streaming(
websocket=websocket,
backend_ws=backend_ws,
request_data={},
first_message=json.dumps(
{"type": "response.create", "input": "alice@example.com"}
),
guardrail_callbacks=[guardrail],
authorized_model="auth-model",
)
await handler.client_to_backend()
assert backend_ws.send.await_count == 2
first_sent = json.loads(backend_ws.send.await_args_list[0][0][0])
assert first_sent["model"] == "auth-model"
assert first_sent["input"] == "<EMAIL_ADDRESS_1>"
second_sent = json.loads(backend_ws.send.await_args_list[1][0][0])
assert second_sent["model"] == "auth-model"
assert second_sent["input"] == "ping <EMAIL_ADDRESS_1>"
assert handler.request_data["metadata"]["pii_tokens"] == {
"<EMAIL_ADDRESS_1>": "alice@example.com"
}
@pytest.mark.asyncio
async def test_backend_to_client_suppresses_deltas_and_masks_completed(self):
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
guardrail = _FakeWSGuardrail()
websocket = MagicMock()
websocket.send_text = AsyncMock()
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[
json.dumps(
{"type": "response.output_text.delta", "delta": "alice@example.com"}
),
json.dumps(
{
"type": "response.completed",
"response": {
"output": [
{
"content": [
{
"type": "output_text",
"text": "contact alice@example.com",
}
]
}
]
},
}
),
Exception("stop"),
]
)
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
handler = _make_streaming(
websocket=websocket,
backend_ws=backend_ws,
logging_obj=logging_obj,
request_data={},
output_guardrail_callbacks=[guardrail],
)
await handler.backend_to_client()
websocket.send_text.assert_awaited_once()
forwarded = json.loads(websocket.send_text.await_args[0][0])
assert forwarded["type"] == "response.completed"
assert (
forwarded["response"]["output"][0]["content"][0]["text"]
== "contact <EMAIL_ADDRESS_1>"
)
@pytest.mark.asyncio
async def test_backend_to_client_suppresses_function_call_arguments_done(self):
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
guardrail = _FakeWSGuardrail()
websocket = MagicMock()
websocket.send_text = AsyncMock()
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[
json.dumps(
{
"type": "response.function_call_arguments.done",
"arguments": '{"to": "alice@example.com"}',
}
),
json.dumps(
{
"type": "response.completed",
"response": {
"output": [
{
"type": "function_call",
"name": "send_email",
"arguments": '{"to": "alice@example.com"}',
}
]
},
}
),
Exception("stop"),
]
)
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
handler = _make_streaming(
websocket=websocket,
backend_ws=backend_ws,
logging_obj=logging_obj,
request_data={},
output_guardrail_callbacks=[guardrail],
)
await handler.backend_to_client()
# The unmasked function-call arguments must never reach the client; only
# the masked response.completed is forwarded.
websocket.send_text.assert_awaited_once()
sent_payload = websocket.send_text.await_args[0][0]
forwarded = json.loads(sent_payload)
assert forwarded["type"] == "response.completed"
assert (
forwarded["response"]["output"][0]["arguments"]
== '{"to": "<EMAIL_ADDRESS_1>"}'
)
assert "alice@example.com" not in sent_payload
@pytest.mark.asyncio
async def test_backend_to_client_suppresses_reasoning_summary_text_done(self):
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
guardrail = _FakeWSGuardrail()
websocket = MagicMock()
websocket.send_text = AsyncMock()
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[
json.dumps(
{
"type": "response.reasoning_summary_text.done",
"text": "contact alice@example.com",
}
),
json.dumps(
{
"type": "response.completed",
"response": {
"output": [
{
"content": [
{
"type": "output_text",
"text": "done",
}
]
}
]
},
}
),
Exception("stop"),
]
)
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
handler = _make_streaming(
websocket=websocket,
backend_ws=backend_ws,
logging_obj=logging_obj,
request_data={},
output_guardrail_callbacks=[guardrail],
)
await handler.backend_to_client()
# The reasoning-summary done event carries the full reasoning text before
# response.completed arrives; it must be suppressed so unmasked PII never
# reaches the client.
websocket.send_text.assert_awaited_once()
sent_payload = websocket.send_text.await_args[0][0]
assert json.loads(sent_payload)["type"] == "response.completed"
assert "alice@example.com" not in sent_payload
@pytest.mark.asyncio
async def test_backend_to_client_suppresses_reasoning_summary_part_done(self):
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
guardrail = _FakeWSGuardrail()
websocket = MagicMock()
websocket.send_text = AsyncMock()
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[
json.dumps(
{
"type": "response.reasoning_summary_part.done",
"part": {
"type": "summary_text",
"text": "user is alice@example.com",
},
}
),
json.dumps(
{
"type": "response.completed",
"response": {
"output": [
{
"type": "reasoning",
"summary": [
{
"type": "summary_text",
"text": "user is alice@example.com",
}
],
}
]
},
}
),
Exception("stop"),
]
)
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
handler = _make_streaming(
websocket=websocket,
backend_ws=backend_ws,
logging_obj=logging_obj,
request_data={},
output_guardrail_callbacks=[guardrail],
)
await handler.backend_to_client()
# The reasoning-summary part-done event carries the full reasoning text
# before response.completed arrives; it must be suppressed, and the
# reasoning summary in response.completed must itself be masked.
websocket.send_text.assert_awaited_once()
sent_payload = websocket.send_text.await_args[0][0]
forwarded = json.loads(sent_payload)
assert forwarded["type"] == "response.completed"
assert (
forwarded["response"]["output"][0]["summary"][0]["text"]
== "user is <EMAIL_ADDRESS_1>"
)
assert "alice@example.com" not in sent_payload
class TestWebSocketChunkTypes:
"""Test handling of different chunk types from streaming responses"""
def test_serialize_function_call_chunk(self):
"""Test serialization of function call chunks"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {
"type": "response.function_call.added",
"response_id": "resp_123",
"item_id": "call_456",
"output_index": 0,
"call_id": "call_456",
"name": "get_weather",
"arguments": "",
}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.function_call.added" in serialized
assert "get_weather" in serialized
def test_serialize_function_call_arguments_delta(self):
"""Test serialization of function call arguments delta"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {
"type": "response.function_call_arguments.delta",
"response_id": "resp_123",
"item_id": "call_456",
"output_index": 0,
"call_id": "call_456",
"delta": '{"location"',
}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.function_call_arguments.delta" in serialized
assert "location" in serialized
def test_serialize_function_call_arguments_done(self):
"""Test serialization of function call arguments done"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {
"type": "response.function_call_arguments.done",
"response_id": "resp_123",
"item_id": "call_456",
"output_index": 0,
"call_id": "call_456",
"arguments": '{"location": "Paris"}',
}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.function_call_arguments.done" in serialized
assert "Paris" in serialized
def test_serialize_reasoning_content_delta(self):
"""Test serialization of reasoning content delta"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {
"type": "response.reasoning_content.delta",
"response_id": "resp_123",
"item_id": "msg_456",
"output_index": 0,
"content_index": 0,
"delta": "Thinking step 1...",
}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.reasoning_content.delta" in serialized
assert "Thinking step 1" in serialized
def test_serialize_reasoning_content_done(self):
"""Test serialization of reasoning content done"""
from litellm.responses.streaming_iterator import (
ManagedResponsesWebSocketHandler,
)
chunk = {
"type": "response.reasoning_content.done",
"response_id": "resp_123",
"item_id": "msg_456",
"output_index": 0,
"content_index": 0,
"reasoning_content": "Complete reasoning...",
}
serialized = ManagedResponsesWebSocketHandler._serialize_chunk(chunk)
assert serialized is not None
assert "response.reasoning_content.done" in serialized
assert "Complete reasoning" in serialized
class TestNativeWebSocketUrlConstruction:
"""Test that native WebSocket URLs include the model query parameter.
These tests mock websockets.connect so they exercise the actual URL-building
code inside BaseLLMHTTPHandler.async_responses_websocket rather than
reimplementing the logic themselves.
"""
@pytest.mark.asyncio
async def test_openai_ws_url_includes_model(self):
"""Handler must pass ?model= in the URL to the backend WebSocket."""
from unittest.mock import AsyncMock, MagicMock, patch
captured_urls = []
class FakeConnect:
def __init__(self, url, **kwargs):
captured_urls.append(url)
async def __aenter__(self):
raise Exception("stop")
async def __aexit__(self, *args):
pass
mock_config = MagicMock(spec=OpenAIResponsesAPIConfig)
mock_config.supports_native_websocket.return_value = True
mock_config.get_websocket_url.return_value = "wss://api.openai.com/v1/responses"
mock_config.validate_environment.return_value = {}
mock_logging = MagicMock()
mock_logging.pre_call = MagicMock()
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
handler = BaseLLMHTTPHandler()
mock_ws = MagicMock()
mock_ws.close = AsyncMock()
with patch("websockets.connect", FakeConnect):
await handler.async_responses_websocket(
model="gpt-4o-mini",
websocket=mock_ws,
logging_obj=mock_logging,
responses_api_provider_config=mock_config,
api_key="sk-test",
)
assert len(captured_urls) == 1
from urllib.parse import parse_qs, urlparse
qs = parse_qs(urlparse(captured_urls[0]).query)
assert qs.get("model") == [
"gpt-4o-mini"
], f"Expected model in URL, got: {captured_urls[0]}"
@pytest.mark.asyncio
async def test_ws_url_preserves_existing_params_and_adds_model(self):
"""When api_base already has query params, model is added alongside them."""
from unittest.mock import AsyncMock, MagicMock, patch
captured_urls = []
class FakeConnect:
def __init__(self, url, **kwargs):
captured_urls.append(url)
async def __aenter__(self):
raise Exception("stop")
async def __aexit__(self, *args):
pass
mock_config = MagicMock(spec=OpenAIResponsesAPIConfig)
mock_config.supports_native_websocket.return_value = True
mock_config.get_websocket_url.return_value = (
"wss://custom.example.com/v1/responses?api-version=2024-05-01"
)
mock_config.validate_environment.return_value = {}
mock_logging = MagicMock()
mock_logging.pre_call = MagicMock()
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
handler = BaseLLMHTTPHandler()
mock_ws = MagicMock()
mock_ws.close = AsyncMock()
with patch("websockets.connect", FakeConnect):
await handler.async_responses_websocket(
model="gpt-4o",
websocket=mock_ws,
logging_obj=mock_logging,
responses_api_provider_config=mock_config,
api_key="sk-test",
)
assert len(captured_urls) == 1
from urllib.parse import parse_qs, urlparse
qs = parse_qs(urlparse(captured_urls[0]).query)
assert qs.get("model") == [
"gpt-4o"
], f"model missing from URL: {captured_urls[0]}"
assert qs.get("api-version") == [
"2024-05-01"
], f"existing param lost: {captured_urls[0]}"
@pytest.mark.asyncio
async def test_ws_passes_litellm_params_to_get_websocket_url(self):
"""Deployment api_version must reach get_websocket_url (Azure WS URL)."""
from unittest.mock import AsyncMock, MagicMock, patch
mock_config = MagicMock(spec=OpenAIResponsesAPIConfig)
mock_config.supports_native_websocket.return_value = True
mock_config.get_websocket_url.return_value = (
"wss://example.openai.azure.com/openai/v1/responses"
)
mock_config.validate_environment.return_value = {}
mock_logging = MagicMock()
mock_logging.pre_call = MagicMock()
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
handler = BaseLLMHTTPHandler()
mock_ws = MagicMock()
mock_ws.close = AsyncMock()
class FakeConnect:
def __init__(self, url, **kwargs):
pass
async def __aenter__(self):
raise Exception("stop")
async def __aexit__(self, *args):
pass
with patch("websockets.connect", FakeConnect):
await handler.async_responses_websocket(
model="gpt-5.3-codex",
websocket=mock_ws,
logging_obj=mock_logging,
responses_api_provider_config=mock_config,
api_key="sk-test",
api_base="https://example.openai.azure.com",
api_version="2025-04-01-preview",
)
mock_config.get_websocket_url.assert_called_once()
_, call_kwargs = mock_config.get_websocket_url.call_args
assert call_kwargs["litellm_params"]["api_version"] == "2025-04-01-preview"
_AFFINITY_METADATA = {
"model_info": {"id": "dep-1"},
"encrypted_content_affinity_enabled": True,
}
def _wrapped_reasoning_item():
from litellm.responses.utils import ResponsesAPIRequestUtils
return {
"type": "reasoning",
"id": ResponsesAPIRequestUtils._build_encrypted_item_id("dep-1", "rs_orig"),
"encrypted_content": ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id("gAAAA-blob", "dep-1"),
"summary": [],
}
class TestNativeWebSocketEncryptedContentAffinity:
@pytest.mark.asyncio
@pytest.mark.parametrize("nested", [False, True])
async def test_client_to_backend_restores_wrapped_ids(self, nested: bool):
from unittest.mock import AsyncMock
from litellm.responses.utils import ResponsesAPIRequestUtils
wrapped_previous = ResponsesAPIRequestUtils._build_responses_api_response_id(
custom_llm_provider="openai", model_id="dep-1", response_id="resp_orig"
)
payload = {
"input": [_wrapped_reasoning_item(), {"type": "message", "role": "user", "content": "hi"}],
"previous_response_id": wrapped_previous,
}
frame = {"type": "response.create", "response": payload} if nested else {"type": "response.create", **payload}
backend_ws = MagicMock()
backend_ws.send = AsyncMock()
websocket = MagicMock()
websocket.receive_text = AsyncMock(side_effect=[json.dumps(frame), Exception("stop")])
handler = _make_streaming(websocket=websocket, backend_ws=backend_ws, request_data={})
await handler.client_to_backend()
sent = json.loads(backend_ws.send.await_args_list[0][0][0])
body = sent["response"] if nested else sent
assert body["input"][0]["id"] == "rs_orig"
assert body["input"][0]["encrypted_content"] == "gAAAA-blob"
assert body["input"][1] == {"type": "message", "role": "user", "content": "hi"}
assert body["previous_response_id"] == "resp_orig"
@pytest.mark.asyncio
async def test_client_to_backend_leaves_unwrapped_frames_untouched(self):
from unittest.mock import AsyncMock
frame = json.dumps({"type": "response.create", "input": "hello", "previous_response_id": "resp_raw"})
backend_ws = MagicMock()
backend_ws.send = AsyncMock()
websocket = MagicMock()
websocket.receive_text = AsyncMock(side_effect=[frame, Exception("stop")])
handler = _make_streaming(websocket=websocket, backend_ws=backend_ws, request_data={})
await handler.client_to_backend()
assert backend_ws.send.await_args_list[0][0][0] == frame
@pytest.mark.asyncio
async def test_backend_to_client_wraps_ids_when_affinity_is_enabled(self):
import asyncio
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
from litellm.responses.utils import ResponsesAPIRequestUtils
reasoning_item = {"type": "reasoning", "id": "rs_1", "encrypted_content": "gAAAA-blob", "summary": []}
websocket = MagicMock()
websocket.send_text = AsyncMock()
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[
json.dumps({"type": "response.output_item.done", "output_index": 0, "item": dict(reasoning_item)}),
json.dumps(
{
"type": "response.completed",
"response": {"id": "resp_1", "output": [dict(reasoning_item)], "usage": {"total_tokens": 3}},
}
),
Exception("stop"),
]
)
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
handler = _make_streaming(
websocket=websocket,
backend_ws=backend_ws,
logging_obj=logging_obj,
request_data={"litellm_metadata": dict(_AFFINITY_METADATA)},
custom_llm_provider="openai",
)
await handler.backend_to_client()
wrapped_content = ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id("gAAAA-blob", "dep-1")
item_done = json.loads(websocket.send_text.await_args_list[0][0][0])
assert item_done["item"]["encrypted_content"] == wrapped_content
completed = json.loads(websocket.send_text.await_args_list[1][0][0])
assert completed["response"]["id"] == ResponsesAPIRequestUtils._build_responses_api_response_id(
custom_llm_provider="openai", model_id="dep-1", response_id="resp_1"
)
assert completed["response"]["output"][0]["id"] == ResponsesAPIRequestUtils._build_encrypted_item_id(
"dep-1", "rs_1"
)
assert completed["response"]["output"][0]["encrypted_content"] == wrapped_content
await asyncio.sleep(0)
logged = logging_obj.dispatch_success_handlers.await_args[0][0]
assert logged[0]["response"]["id"] == completed["response"]["id"]
@pytest.mark.asyncio
async def test_backend_to_client_wraps_only_response_id_without_affinity(self):
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
from litellm.responses.utils import ResponsesAPIRequestUtils
reasoning_item = {"type": "reasoning", "id": "rs_1", "encrypted_content": "gAAAA-blob", "summary": []}
websocket = MagicMock()
websocket.send_text = AsyncMock()
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[
json.dumps({"type": "response.output_item.done", "output_index": 0, "item": dict(reasoning_item)}),
json.dumps({"type": "response.completed", "response": {"id": "resp_1", "output": [dict(reasoning_item)]}}),
Exception("stop"),
]
)
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
handler = _make_streaming(
websocket=websocket,
backend_ws=backend_ws,
logging_obj=logging_obj,
request_data={"litellm_metadata": {"model_info": {"id": "dep-1"}}},
custom_llm_provider="openai",
)
await handler.backend_to_client()
item_done = json.loads(websocket.send_text.await_args_list[0][0][0])
assert item_done["item"] == reasoning_item
completed = json.loads(websocket.send_text.await_args_list[1][0][0])
assert completed["response"]["id"] == ResponsesAPIRequestUtils._build_responses_api_response_id(
custom_llm_provider="openai", model_id="dep-1", response_id="resp_1"
)
assert completed["response"]["output"][0] == reasoning_item
@pytest.mark.asyncio
@pytest.mark.parametrize(
"failure_frame, expected_status",
[
(
{
"type": "error",
"error": {
"type": "invalid_request_error",
"code": "invalid_encrypted_content",
"message": "The encrypted content for item rs_1 could not be verified.",
},
},
400,
),
(
{
"type": "response.failed",
"response": {
"id": "resp_1",
"status": "failed",
"error": {"code": "server_error", "message": "upstream blew up"},
},
},
500,
),
],
)
async def test_backend_to_client_books_failure_frames_as_failures(
self, failure_frame: dict[str, object], expected_status: int
):
import asyncio
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
websocket = MagicMock()
websocket.send_text = AsyncMock()
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[
json.dumps({"type": "response.created", "response": {"id": "resp_1", "status": "in_progress"}}),
json.dumps(failure_frame),
Exception("stop"),
]
)
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
logging_obj.dispatch_failure_handlers = AsyncMock()
logging_obj._response_cost_calculator = MagicMock(return_value=0.0)
handler = _make_streaming(
websocket=websocket,
backend_ws=backend_ws,
logging_obj=logging_obj,
request_data={},
authorized_model="gpt-5.6",
custom_llm_provider="openai",
)
await handler.backend_to_client()
await asyncio.sleep(0)
logging_obj.dispatch_success_handlers.assert_not_awaited()
logging_obj.dispatch_failure_handlers.assert_awaited_once()
exception = logging_obj.dispatch_failure_handlers.await_args[0][0]
assert exception.status_code == expected_status
assert failure_frame.get("error", failure_frame.get("response", {}).get("error"))["message"] in str(exception)
@pytest.mark.asyncio
async def test_backend_to_client_bills_completed_turns_before_a_failure(self):
import asyncio
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
websocket = MagicMock()
websocket.send_text = AsyncMock()
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[
json.dumps(
{
"type": "response.completed",
"response": {
"id": "resp_1",
"status": "completed",
"output": [],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
},
}
),
json.dumps({"type": "error", "error": {"type": "invalid_request_error", "message": "bad turn"}}),
Exception("stop"),
]
)
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
logging_obj.dispatch_failure_handlers = AsyncMock()
logging_obj._response_cost_calculator = MagicMock(return_value=0.01)
handler = _make_streaming(websocket=websocket, backend_ws=backend_ws, logging_obj=logging_obj, request_data={})
await handler.backend_to_client()
await asyncio.sleep(0)
logging_obj.record_partial_usage_for_failure.assert_called_once()
usage, response_cost = logging_obj.record_partial_usage_for_failure.call_args[0]
assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (10, 5, 15)
assert response_cost == 0.01
logging_obj.dispatch_success_handlers.assert_not_awaited()
logging_obj.dispatch_failure_handlers.assert_awaited_once()
@pytest.mark.asyncio
async def test_bidirectional_forward_returns_the_provider_failure(self):
import asyncio
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
backend_drained = asyncio.Event()
backend_events = [
json.dumps({"type": "response.created", "response": {"id": "resp_1", "status": "in_progress"}}),
json.dumps(
{
"type": "error",
"status": 400,
"error": {
"type": "invalid_request_error",
"code": "invalid_encrypted_content",
"message": "could not be verified",
},
}
),
]
async def recv(decode=False):
if backend_events:
return backend_events.pop(0)
backend_drained.set()
raise Exception("stop")
async def receive_text():
await backend_drained.wait()
raise Exception("client gone")
websocket = MagicMock()
websocket.send_text = AsyncMock()
websocket.receive_text = receive_text
backend_ws = MagicMock()
backend_ws.recv = recv
backend_ws.send = AsyncMock()
backend_ws.close = AsyncMock()
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
logging_obj.dispatch_failure_handlers = AsyncMock()
logging_obj._response_cost_calculator = MagicMock(return_value=0.0)
handler = _make_streaming(
websocket=websocket,
backend_ws=backend_ws,
logging_obj=logging_obj,
request_data={},
authorized_model="gpt-5.6",
custom_llm_provider="openai",
)
failure = await handler.bidirectional_forward()
assert isinstance(failure, Exception)
assert failure.status_code == 400
assert "could not be verified" in str(failure)
@pytest.mark.asyncio
async def test_bidirectional_forward_returns_none_after_a_completed_turn(self):
import asyncio
from unittest.mock import AsyncMock
import websockets.exceptions # noqa: F401 (lazy submodule must be importable)
backend_drained = asyncio.Event()
backend_events = [
json.dumps(
{
"type": "response.completed",
"response": {
"id": "resp_1",
"status": "completed",
"output": [],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
},
}
),
]
async def recv(decode=False):
if backend_events:
return backend_events.pop(0)
backend_drained.set()
raise Exception("stop")
async def receive_text():
await backend_drained.wait()
raise Exception("client gone")
websocket = MagicMock()
websocket.send_text = AsyncMock()
websocket.receive_text = receive_text
backend_ws = MagicMock()
backend_ws.recv = recv
backend_ws.send = AsyncMock()
backend_ws.close = AsyncMock()
logging_obj = MagicMock()
logging_obj.dispatch_success_handlers = AsyncMock()
logging_obj.dispatch_failure_handlers = AsyncMock()
handler = _make_streaming(
websocket=websocket,
backend_ws=backend_ws,
logging_obj=logging_obj,
request_data={},
authorized_model="gpt-5.6",
custom_llm_provider="openai",
)
assert await handler.bidirectional_forward() is None