Merge origin/main: resolve conflicts in realtime_streaming + tests

- Keep both request_data and _violation_count in __init__
- Adapt violation handler to use main's error-JSON approach with
  realtime_violation_message as the message content
- Extract _make_transcript_event to module level (no nested functions)
- All 20 realtime streaming tests pass
This commit is contained in:
Ishaan Jaffer 2026-02-25 23:47:54 -08:00
commit 3e1e6897ea
11 changed files with 1399 additions and 197 deletions

View file

@ -43,6 +43,7 @@ class RealTimeStreaming:
provider_config: Optional[BaseRealtimeConfig] = None,
model: str = "",
user_api_key_dict: Optional[Any] = None,
request_data: Optional[Dict] = None,
):
self.websocket = websocket
self.backend_ws = backend_ws
@ -68,6 +69,7 @@ class RealTimeStreaming:
self.current_delta_type: Optional[ALL_DELTA_TYPES] = None
self.session_configuration_request: Optional[str] = None
self.user_api_key_dict = user_api_key_dict
self.request_data: Dict = request_data or {}
# Violation counter for end_session_after_n_fails support
self._violation_count: int = 0
@ -233,14 +235,40 @@ class RealTimeStreaming:
await self.backend_ws.send(message)
def _has_realtime_guardrails(self) -> bool:
"""Return True if any callback is registered for realtime_input_transcription."""
"""Return True if any callback is registered for realtime guardrail event types."""
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.types.guardrails import GuardrailEventHooks
_realtime_event_types = [
GuardrailEventHooks.realtime_input_transcription,
GuardrailEventHooks.pre_call,
GuardrailEventHooks.post_call,
]
return any(
isinstance(cb, CustomGuardrail)
and any(
cb.should_run_guardrail(
data=self.request_data,
event_type=et,
)
for et in _realtime_event_types
)
for cb in litellm.callbacks
)
def _has_audio_transcription_guardrails(self) -> bool:
"""Return True if any callback needs to run on audio transcriptions (VAD path).
When this returns True, we inject a session.update to disable the LLM's
auto-response so the guardrail can gate it first.
"""
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.types.guardrails import GuardrailEventHooks
return any(
isinstance(cb, CustomGuardrail)
and cb.should_run_guardrail(
data={},
data=self.request_data,
event_type=GuardrailEventHooks.realtime_input_transcription,
)
for cb in litellm.callbacks
@ -260,17 +288,25 @@ class RealTimeStreaming:
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.types.guardrails import GuardrailEventHooks
_realtime_event_types = [
GuardrailEventHooks.realtime_input_transcription,
GuardrailEventHooks.pre_call,
GuardrailEventHooks.post_call,
]
_check_data = {**self.request_data, "transcript": transcript}
_already_run: set = set()
for callback in litellm.callbacks:
if not isinstance(callback, CustomGuardrail):
continue
if (
callback.should_run_guardrail(
data={"transcript": transcript},
event_type=GuardrailEventHooks.realtime_input_transcription,
)
is not True
if id(callback) in _already_run:
continue
if not any(
callback.should_run_guardrail(data=_check_data, event_type=et)
for et in _realtime_event_types
):
continue
_already_run.add(id(callback))
try:
await callback.apply_guardrail(
inputs={"texts": [transcript], "images": []},
@ -297,21 +333,17 @@ class RealTimeStreaming:
safe_msg = str(e) or "I'm sorry, that request was blocked by the content filter."
# Use realtime_violation_message if configured; fall back to guardrail error text.
spoken_msg = getattr(callback, "realtime_violation_message", None) or safe_msg
error_msg = getattr(callback, "realtime_violation_message", None) or safe_msg
# Cancel any in-flight response before speaking the warning.
await self._send_to_backend(json.dumps({"type": "response.cancel"}))
# Ask the model to speak the violation message — TTS plays naturally in the client.
await self._send_to_backend(
# Return the error directly to the WebSocket consumer.
await self.websocket.send_text(
json.dumps(
{
"type": "response.create",
"response": {
"modalities": ["text", "audio"],
"instructions": (
f"Say exactly and only: \"{spoken_msg}\". "
"Do not add anything else."
),
"type": "error",
"error": {
"type": "guardrail_violation",
"message": error_msg,
"code": "content_policy_violation",
},
}
)
@ -370,25 +402,25 @@ class RealTimeStreaming:
if isinstance(transformed_response, list)
else [transformed_response]
)
for event in events:
## GUARDRAIL: inject create_response=false on session.created
if isinstance(event, dict) and event.get("type") == "session.created":
if self._has_realtime_guardrails():
await self._send_to_backend(
json.dumps(
{
"type": "session.update",
"session": {
"turn_detection": {
"type": "server_vad",
"create_response": False,
}
},
}
)
)
for event in events:
event_str = json.dumps(event)
## For audio/VAD guardrail path: forward session.created first, then inject.
if (
isinstance(event, dict)
and event.get("type") == "session.created"
and self._has_audio_transcription_guardrails()
):
self.store_message(event_str)
await self.websocket.send_text(event_str)
await self._send_to_backend(
json.dumps(
{
"type": "session.update",
"session": {"turn_detection": {"create_response": False}},
}
)
)
continue
## GUARDRAIL: run on transcription events in provider_config path too
if (
isinstance(event, dict)
@ -419,27 +451,26 @@ class RealTimeStreaming:
try:
event_obj = json.loads(raw_response)
if event_obj.get("type") == "session.created":
# If any realtime guardrails are registered, proactively
# set create_response=false so the LLM never auto-responds
# before our guardrail has a chance to run.
if self._has_realtime_guardrails():
await self._send_to_backend(
json.dumps(
{
"type": "session.update",
"session": {
"turn_detection": {
"type": "server_vad",
"create_response": False,
}
},
}
)
)
verbose_logger.debug(
"[realtime guardrail] injected create_response=false into session"
# For audio/VAD guardrail path: once the session is ready, tell the backend
# not to auto-respond after VAD detects end-of-speech. We send the
# session.created to the client FIRST so the client is always in sync, then
# inject the session.update so a potential error from the backend doesn't
# arrive before the client sees session.created.
if (
event_obj.get("type") == "session.created"
and self._has_audio_transcription_guardrails()
):
self.store_message(raw_response)
await self.websocket.send_text(raw_response)
await self._send_to_backend(
json.dumps(
{
"type": "session.update",
"session": {"turn_detection": {"create_response": False}},
}
)
)
return True
if (
event_obj.get("type")

View file

@ -6,13 +6,13 @@ This requires websockets, and is currently only supported on LiteLLM Proxy.
from typing import Any, Optional, cast
from litellm._logging import verbose_proxy_logger
from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
from ....litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
from ....litellm_core_utils.realtime_streaming import RealTimeStreaming
from ....llms.custom_httpx.http_handler import get_shared_realtime_ssl_context
from ..azure import AzureChatCompletion
from litellm._logging import verbose_proxy_logger
# BACKEND_WS_URL = "ws://localhost:8080/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01"
@ -77,6 +77,8 @@ class AzureOpenAIRealtime(AzureChatCompletion):
client: Optional[Any] = None,
timeout: Optional[float] = None,
realtime_protocol: Optional[str] = None,
user_api_key_dict: Optional[Any] = None,
litellm_metadata: Optional[dict] = None,
):
import websockets
from websockets.asyncio.client import ClientConnection
@ -101,7 +103,11 @@ class AzureOpenAIRealtime(AzureChatCompletion):
ssl=ssl_context,
) as backend_ws:
realtime_streaming = RealTimeStreaming(
websocket, cast(ClientConnection, backend_ws), logging_obj
websocket,
cast(ClientConnection, backend_ws),
logging_obj,
user_api_key_dict=user_api_key_dict,
request_data={"litellm_metadata": litellm_metadata or {}},
)
await realtime_streaming.bidirectional_forward()

View file

@ -99,6 +99,7 @@ class OpenAIRealtime(OpenAIChatCompletion):
timeout: Optional[float] = None,
query_params: Optional[RealtimeQueryParams] = None,
user_api_key_dict: Optional[Any] = None,
litellm_metadata: Optional[dict] = None,
**kwargs: Any,
):
import websockets
@ -142,6 +143,7 @@ class OpenAIRealtime(OpenAIChatCompletion):
cast(ClientConnection, backend_ws),
logging_obj,
user_api_key_dict=user_api_key_dict,
request_data={"litellm_metadata": litellm_metadata or {}},
)
await realtime_streaming.bidirectional_forward()

View file

@ -32,6 +32,15 @@ vertex_llm_base = VertexBase()
base_llm_http_handler = BaseLLMHTTPHandler()
def _build_litellm_metadata(kwargs: dict) -> dict:
"""Build the litellm_metadata dict for guardrail checking (internal only, not forwarded to provider)."""
metadata: dict = {**(kwargs.get("litellm_metadata") or {})}
guardrails = (kwargs.get("metadata") or {}).get("guardrails") or kwargs.get("guardrails") or []
if guardrails:
metadata["guardrails"] = guardrails
return metadata
@wrapper_client
async def _arealtime(
model: str,
@ -134,6 +143,8 @@ async def _arealtime(
timeout=timeout,
logging_obj=litellm_logging_obj,
realtime_protocol=realtime_protocol,
user_api_key_dict=kwargs.get("user_api_key_dict"),
litellm_metadata=_build_litellm_metadata(kwargs),
)
elif _custom_llm_provider == "openai":
api_base = (
@ -160,6 +171,7 @@ async def _arealtime(
timeout=timeout,
query_params=query_params,
user_api_key_dict=kwargs.get("user_api_key_dict"),
litellm_metadata=_build_litellm_metadata(kwargs),
)
elif _custom_llm_provider == "bedrock":
# Extract AWS parameters from kwargs
@ -217,6 +229,8 @@ async def _arealtime(
client=None,
timeout=timeout,
query_params=query_params,
user_api_key_dict=kwargs.get("user_api_key_dict"),
litellm_metadata=_build_litellm_metadata(kwargs),
)
elif _custom_llm_provider == "vertex_ai":
vertex_credentials = (

View file

@ -21,6 +21,16 @@ from litellm.types.llms.openai import (
)
def _make_transcript_event(text: str, item_id: str = "item_x") -> bytes:
return json.dumps(
{
"type": "conversation.item.input_audio_transcription.completed",
"transcript": text,
"item_id": item_id,
}
).encode()
def test_realtime_streaming_store_message():
# Setup
websocket = MagicMock()
@ -420,32 +430,32 @@ async def test_realtime_guardrail_blocks_prompt_injection():
streaming = RealTimeStreaming(client_ws, backend_ws, logging_obj)
await streaming.backend_to_client_send_messages()
# ASSERT 1: no bare response.create was sent to backend (injection blocked).
# The only response.create allowed is the warning one (has "instructions" field).
# ASSERT 1: no response.create was sent to backend (injection blocked).
sent_to_backend = [
json.loads(c.args[0])
for c in backend_ws.send.call_args_list
if c.args
]
bare_response_creates = [
response_creates = [
e for e in sent_to_backend
if e.get("type") == "response.create"
and "instructions" not in e.get("response", {})
]
assert len(bare_response_creates) == 0, (
f"Guardrail should prevent bare response.create for injected content, "
f"but got: {bare_response_creates}"
assert len(response_creates) == 0, (
f"Guardrail should prevent response.create for injected content, "
f"but got: {response_creates}"
)
# ASSERT 2: warning response.create was sent to backend (to speak the block message)
warning_creates = [
e for e in sent_to_backend
if e.get("type") == "response.create"
and "instructions" in e.get("response", {})
# ASSERT 2: error event was sent directly to the client WebSocket
sent_to_client = [
json.loads(c.args[0]) for c in client_ws.send_text.call_args_list
if c.args
]
assert len(warning_creates) > 0, (
f"Backend should receive a response.create with warning instructions, "
f"but got: {sent_to_backend}"
error_events = [e for e in sent_to_client if e.get("type") == "error"]
assert len(error_events) == 1, (
f"Expected one error event sent to client, got: {sent_to_client}"
)
assert error_events[0]["error"]["type"] == "guardrail_violation", (
f"Expected guardrail_violation error type, got: {error_events[0]}"
)
litellm.callbacks = [] # cleanup
@ -518,11 +528,91 @@ async def test_realtime_guardrail_allows_clean_transcript():
@pytest.mark.asyncio
async def test_realtime_session_created_injects_create_response_false():
async def test_realtime_text_input_guardrail_blocks_and_returns_error():
"""
Test that when session.created arrives from the backend and realtime guardrails
are registered, the proxy injects a session.update with create_response=False
so the LLM never auto-responds before the guardrail runs.
Test that when conversation.item.create arrives with text that triggers a guardrail,
the proxy blocks it (doesn't forward to backend) and returns an error event directly
to the client WebSocket.
"""
from fastapi import HTTPException
import litellm
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.types.guardrails import GuardrailEventHooks
class BlockingGuardrail(CustomGuardrail):
async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None):
texts = inputs.get("texts", [])
for text in texts:
if "@" in text:
raise HTTPException(
status_code=403,
detail={"error": "email address detected"},
)
return inputs
guardrail = BlockingGuardrail(
guardrail_name="email-blocker",
event_hook=GuardrailEventHooks.pre_call,
default_on=True,
)
litellm.callbacks = [guardrail]
client_ws = MagicMock()
client_ws.send_text = AsyncMock()
backend_ws = MagicMock()
backend_ws.send = AsyncMock()
backend_ws.recv = AsyncMock(side_effect=ConnectionClosed(None, None))
logging_obj = MagicMock()
logging_obj.pre_call = MagicMock()
streaming = RealTimeStreaming(client_ws, backend_ws, logging_obj)
item_create_msg = json.dumps({
"type": "conversation.item.create",
"item": {
"role": "user",
"content": [{"type": "input_text", "text": "My email is test@example.com"}],
},
})
# Simulate the client sending a conversation.item.create with an email
client_ws.receive_text = AsyncMock(
side_effect=[
item_create_msg,
Exception("connection closed"), # stop the loop
]
)
await streaming.client_ack_messages()
# ASSERT: error event was sent to client
assert client_ws.send_text.called, "Expected error to be sent to client websocket"
sent_texts = [json.loads(c.args[0]) for c in client_ws.send_text.call_args_list]
error_events = [e for e in sent_texts if e.get("type") == "error"]
assert len(error_events) == 1, f"Expected one error event, got: {sent_texts}"
assert error_events[0]["error"]["type"] == "guardrail_violation"
# ASSERT: blocked item was NOT forwarded to the backend
sent_to_backend = [c.args[0] for c in backend_ws.send.call_args_list if c.args]
forwarded_items = [
json.loads(m) for m in sent_to_backend
if isinstance(m, str) and json.loads(m).get("type") == "conversation.item.create"
]
assert len(forwarded_items) == 0, (
f"Blocked item should not be forwarded to backend, got: {forwarded_items}"
)
litellm.callbacks = [] # cleanup
@pytest.mark.asyncio
async def test_realtime_text_input_guardrail_uses_pre_call_mode():
"""
Test that _has_realtime_guardrails returns True for a guardrail configured with
pre_call mode (not just realtime_input_transcription).
"""
import litellm
from litellm.integrations.custom_guardrail import CustomGuardrail
@ -533,7 +623,46 @@ async def test_realtime_session_created_injects_create_response_false():
return inputs
guardrail = DummyGuardrail(
guardrail_name="dummy",
guardrail_name="pre-call-guardrail",
event_hook=GuardrailEventHooks.pre_call,
default_on=True,
)
litellm.callbacks = [guardrail]
client_ws = MagicMock()
backend_ws = MagicMock()
logging_obj = MagicMock()
streaming = RealTimeStreaming(client_ws, backend_ws, logging_obj)
assert streaming._has_realtime_guardrails() is True, (
"pre_call guardrail should be recognized as a realtime guardrail"
)
# pre_call guardrail should NOT trigger the audio/VAD session.update injection
assert streaming._has_audio_transcription_guardrails() is False, (
"pre_call guardrail should not trigger audio transcription guardrail path"
)
litellm.callbacks = [] # cleanup
@pytest.mark.asyncio
async def test_realtime_session_created_injects_session_update_for_audio_guardrail():
"""
Test that when an audio transcription guardrail is configured, a session.created
event from the backend triggers a session.update injection (create_response: false)
AFTER forwarding session.created to the client. This prevents the LLM from
auto-responding before the guardrail can run on the transcript.
"""
import litellm
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.types.guardrails import GuardrailEventHooks
class AudioGuardrail(CustomGuardrail):
async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None):
return inputs
guardrail = AudioGuardrail(
guardrail_name="audio-guardrail",
event_hook=GuardrailEventHooks.realtime_input_transcription,
default_on=True,
)
@ -542,34 +671,93 @@ async def test_realtime_session_created_injects_create_response_false():
client_ws = MagicMock()
client_ws.send_text = AsyncMock()
session_created_event = json.dumps({"type": "session.created"}).encode()
session_created_event = json.dumps(
{"type": "session.created", "session": {"id": "sess_abc"}}
).encode()
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[
session_created_event,
ConnectionClosed(None, None),
]
side_effect=[session_created_event, ConnectionClosed(None, None)]
)
backend_ws.send = AsyncMock()
logging_obj = MagicMock()
logging_obj.async_success_handler = AsyncMock()
logging_obj.success_handler = MagicMock()
streaming = RealTimeStreaming(client_ws, backend_ws, logging_obj)
await streaming.backend_to_client_send_messages()
# ASSERT: proxy injected session.update with create_response=False to backend
# session.created must be forwarded to the client
sent_to_client = [
json.loads(c.args[0]) for c in client_ws.send_text.call_args_list if c.args
]
session_created_events = [e for e in sent_to_client if e.get("type") == "session.created"]
assert len(session_created_events) == 1, (
f"session.created should be forwarded to client, got: {sent_to_client}"
)
# session.update must be sent to the backend AFTER session.created was forwarded
sent_to_backend = [
json.loads(c.args[0]) for c in backend_ws.send.call_args_list if c.args
]
session_updates = [e for e in sent_to_backend if e.get("type") == "session.update"]
assert len(session_updates) == 1, (
f"Expected proxy to inject session.update, got: {sent_to_backend}"
f"Expected one session.update injected to backend, got: {sent_to_backend}"
)
td = session_updates[0]["session"]["turn_detection"]
assert td["create_response"] is False, (
f"Expected create_response=False, got: {td}"
assert session_updates[0]["session"]["turn_detection"]["create_response"] is False
litellm.callbacks = [] # cleanup
@pytest.mark.asyncio
async def test_realtime_session_created_no_injection_for_pre_call_only():
"""
Test that when only a pre_call guardrail is configured (no audio transcription),
session.created does NOT trigger the session.update injection.
"""
import litellm
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.types.guardrails import GuardrailEventHooks
class PreCallGuardrail(CustomGuardrail):
async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None):
return inputs
guardrail = PreCallGuardrail(
guardrail_name="pre-call-only",
event_hook=GuardrailEventHooks.pre_call,
default_on=True,
)
litellm.callbacks = [guardrail]
client_ws = MagicMock()
client_ws.send_text = AsyncMock()
session_created_event = json.dumps(
{"type": "session.created", "session": {"id": "sess_xyz"}}
).encode()
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[session_created_event, ConnectionClosed(None, None)]
)
backend_ws.send = AsyncMock()
logging_obj = MagicMock()
logging_obj.async_success_handler = AsyncMock()
logging_obj.success_handler = MagicMock()
streaming = RealTimeStreaming(client_ws, backend_ws, logging_obj)
await streaming.backend_to_client_send_messages()
# No session.update should be injected
sent_to_backend = [
json.loads(c.args[0]) for c in backend_ws.send.call_args_list if c.args
]
session_updates = [e for e in sent_to_backend if e.get("type") == "session.update"]
assert len(session_updates) == 0, (
f"pre_call guardrail should NOT inject session.update, got: {sent_to_backend}"
)
litellm.callbacks = [] # cleanup
@ -581,6 +769,7 @@ async def test_end_session_after_n_fails_closes_connection():
Test that end_session_after_n_fails=2 closes the backend websocket after
the second guardrail violation in a session.
"""
class BadWordGuardrail(CustomGuardrail):
async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None):
for text in inputs.get("texts", []):
@ -599,18 +788,11 @@ async def test_end_session_after_n_fails_closes_connection():
client_ws = MagicMock()
client_ws.send_text = AsyncMock()
def make_transcript_event(text):
return json.dumps({
"type": "conversation.item.input_audio_transcription.completed",
"transcript": text,
"item_id": "item_x",
}).encode()
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[
make_transcript_event("this is blocked"), # violation 1 — warn
make_transcript_event("also blocked again"), # violation 2 — end session
_make_transcript_event("this is blocked"), # violation 1 — warn
_make_transcript_event("also blocked again"), # violation 2 — end session
ConnectionClosed(None, None),
]
)
@ -635,6 +817,7 @@ async def test_on_violation_end_session_closes_on_first_fail():
Test that on_violation='end_session' closes the session immediately on the
first violation, regardless of end_session_after_n_fails.
"""
class TopicGuardrail(CustomGuardrail):
async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None):
for text in inputs.get("texts", []):
@ -656,11 +839,7 @@ async def test_on_violation_end_session_closes_on_first_fail():
backend_ws = MagicMock()
backend_ws.recv = AsyncMock(
side_effect=[
json.dumps({
"type": "conversation.item.input_audio_transcription.completed",
"transcript": "What stock should I buy today?",
"item_id": "item_y",
}).encode(),
_make_transcript_event("What stock should I buy today?", item_id="item_y"),
ConnectionClosed(None, None),
]
)

View file

@ -90,7 +90,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@ -1772,7 +1771,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@ -1783,7 +1781,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@ -1793,14 +1790,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@ -1978,7 +1973,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@ -1992,7 +1986,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@ -2002,7 +1995,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@ -2326,7 +2318,7 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz",
"integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.1"
@ -3431,14 +3423,12 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.2.48",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz",
"integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@ -3480,7 +3470,6 @@
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
@ -4341,14 +4330,12 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@ -4362,7 +4349,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -4375,7 +4361,6 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
@ -4747,7 +4732,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -4773,7 +4757,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@ -4889,7 +4872,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -5013,7 +4995,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@ -5038,7 +5019,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@ -5114,7 +5094,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -5175,7 +5154,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@ -5589,14 +5567,12 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT"
},
"node_modules/doctrine": {
@ -6510,7 +6486,6 @@
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@ -6543,7 +6518,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@ -6581,7 +6555,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@ -6742,7 +6715,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@ -6893,7 +6865,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
@ -7391,7 +7362,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@ -7444,7 +7414,6 @@
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@ -7505,7 +7474,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -7551,7 +7519,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@ -7600,7 +7567,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@ -7877,7 +7843,6 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@ -8163,7 +8128,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@ -8176,7 +8140,6 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
@ -8491,7 +8454,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@ -8943,7 +8905,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@ -8957,7 +8918,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -9072,7 +9032,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
@ -9284,7 +9243,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -9303,7 +9261,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -9648,7 +9605,6 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
@ -9695,7 +9651,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@ -9708,7 +9663,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -9718,7 +9672,6 @@
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -9728,7 +9681,7 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.1"
@ -9747,7 +9700,7 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@ -9770,7 +9723,6 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -9799,7 +9751,6 @@
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
@ -9817,7 +9768,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -9843,7 +9793,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -9886,7 +9835,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -9912,7 +9860,6 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@ -9926,7 +9873,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/prelude-ls": {
@ -10040,7 +9986,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
@ -10829,7 +10774,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
@ -10839,7 +10783,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@ -10852,7 +10795,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -11117,7 +11059,6 @@
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
@ -11158,7 +11099,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@ -11214,7 +11154,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@ -11855,7 +11794,6 @@
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
@ -11891,7 +11829,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -11927,7 +11864,6 @@
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@ -11965,7 +11901,6 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@ -11982,7 +11917,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@ -12010,7 +11944,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
@ -12020,7 +11953,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
@ -12062,7 +11994,6 @@
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@ -12129,7 +12060,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@ -12217,7 +12147,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tsconfig-paths": {
@ -12334,7 +12263,7 @@
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
"integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -12536,7 +12465,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
@ -12990,7 +12918,7 @@
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@ -13056,21 +12984,6 @@
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
"integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}

View file

@ -0,0 +1,81 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { PrettyMessagesView } from "./PrettyMessagesView";
vi.mock("antd", async () => {
const actual = await vi.importActual<typeof import("antd")>("antd");
return {
...actual,
message: {
success: vi.fn(),
},
};
});
describe("PrettyMessagesView", () => {
it("should render the component for standard chat completions", () => {
const request = {
messages: [{ role: "user", content: "Hello" }],
};
const response = {
choices: [{ message: { role: "assistant", content: "Hi there!" } }],
};
render(<PrettyMessagesView request={request} response={response} />);
expect(screen.getByText("Hello")).toBeInTheDocument();
expect(screen.getByText("Hi there!")).toBeInTheDocument();
});
it("should render the realtime pretty view for realtime API responses", () => {
const request = {};
const response = {
results: [
{
type: "session.created",
session: {
id: "sess_123",
model: "gpt-4o-mini-realtime-preview",
voice: "alloy",
modalities: ["audio", "text"],
},
},
{
type: "response.done",
response: {
id: "resp_1",
status: "completed",
output: [
{
id: "item_1",
role: "assistant",
type: "message",
content: [{ type: "audio", transcript: "Hello from realtime!" }],
},
],
},
},
],
};
render(<PrettyMessagesView request={request} response={response} />);
expect(screen.getByText("Session")).toBeInTheDocument();
expect(screen.getByText("Hello from realtime!")).toBeInTheDocument();
const modelElements = screen.getAllByText("gpt-4o-mini-realtime-preview");
expect(modelElements.length).toBeGreaterThanOrEqual(1);
});
it("should render standard view when response has results but no realtime events", () => {
const request = {
messages: [{ role: "user", content: "Test" }],
};
const response = {
results: [{ type: "some.other.type" }],
choices: [{ message: { role: "assistant", content: "Reply" } }],
};
render(<PrettyMessagesView request={request} response={response} />);
expect(screen.getByText("Test")).toBeInTheDocument();
expect(screen.getByText("Reply")).toBeInTheDocument();
});
});

View file

@ -1,11 +1,13 @@
/**
* PrettyMessagesView - Datadog-style view with Input/Output cards
* Two main cards showing request and response with token counts and costs
* Two main cards showing request and response with token counts and costs.
* Detects realtime API responses and renders a specialized view.
*/
import { parseMessages } from './prettyMessagesUtils';
import { InputCard } from './InputCard';
import { OutputCard } from './OutputCard';
import { isRealtimeResponse, RealtimePrettyView } from './RealtimePrettyView';
interface PrettyMessagesViewProps {
request: any;
@ -19,6 +21,10 @@ interface PrettyMessagesViewProps {
}
export function PrettyMessagesView({ request, response, metrics }: PrettyMessagesViewProps) {
if (isRealtimeResponse(response)) {
return <RealtimePrettyView response={response} metrics={metrics} />;
}
const { requestMessages, responseMessage } = parseMessages(request, response);
return (

View file

@ -0,0 +1,392 @@
import React from "react";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { RealtimePrettyView, isRealtimeResponse } from "./RealtimePrettyView";
vi.mock("antd", async () => {
const actual = await vi.importActual<typeof import("antd")>("antd");
return {
...actual,
message: {
success: vi.fn(),
},
};
});
const sampleRealtimeResponse = {
usage: {
total_tokens: 587,
prompt_tokens: 294,
completion_tokens: 293,
},
results: [
{
type: "session.created",
session: {
id: "sess_DDNQlPKHjLsokSJPAOWY0",
model: "gpt-4o-mini-realtime-preview",
tools: [],
voice: "alloy",
modalities: ["audio", "text"],
temperature: 0.8,
tool_choice: "auto",
instructions: "You are a helpful assistant.",
turn_detection: {
type: "server_vad",
threshold: 0.5,
},
input_audio_format: "pcm16",
output_audio_format: "pcm16",
max_response_output_tokens: "inf",
},
event_id: "event_DDNQlB4VNUlpqTVIjBbm3",
},
{
type: "response.done",
event_id: "event_DDNQnagYJCZyZATdJCn0L",
response: {
id: "resp_DDNQnlXGHZJB46D5JhJ95",
usage: {
input_tokens: 116,
total_tokens: 162,
output_tokens: 46,
input_token_details: {
text_tokens: 116,
audio_tokens: 0,
},
output_token_details: {
text_tokens: 16,
audio_tokens: 30,
},
},
voice: "alloy",
object: "realtime.response",
output: [
{
id: "item_DDNQnz5uN1b8NEvPOPZOM",
role: "assistant",
type: "message",
status: "completed",
content: [
{
type: "audio",
transcript: "Hello! How's your day going?",
},
],
},
],
status: "completed",
conversation_id: "conv_DDNQlpNllPYhCCfXCtT8X",
max_output_tokens: "inf",
},
},
{
type: "response.done",
event_id: "event_DDNR0VmrRTVU69RxGC29U",
response: {
id: "resp_DDNQy6S4PBZxW4qsKq6Ah",
usage: {
input_tokens: 178,
total_tokens: 425,
output_tokens: 247,
},
voice: "alloy",
object: "realtime.response",
output: [
{
id: "item_DDNQywctWVnYmujg4FSTZ",
role: "assistant",
type: "message",
status: "completed",
content: [
{
type: "audio",
transcript:
"I'm here to help with information and general questions.",
},
],
},
],
status: "completed",
conversation_id: "conv_DDNQlpNllPYhCCfXCtT8X",
max_output_tokens: "inf",
},
},
],
};
describe("isRealtimeResponse", () => {
it("should return true for a valid realtime response with session.created", () => {
expect(isRealtimeResponse(sampleRealtimeResponse)).toBe(true);
});
it("should return true for response with only response.done events", () => {
const resp = {
results: [{ type: "response.done", response: { id: "r1" } }],
};
expect(isRealtimeResponse(resp)).toBe(true);
});
it("should return false for a standard chat completion response", () => {
const chatResponse = {
choices: [{ message: { role: "assistant", content: "Hello" } }],
};
expect(isRealtimeResponse(chatResponse)).toBe(false);
});
it("should return false for null/undefined", () => {
expect(isRealtimeResponse(null)).toBe(false);
expect(isRealtimeResponse(undefined)).toBe(false);
});
it("should return false for empty results array", () => {
expect(isRealtimeResponse({ results: [] })).toBe(false);
});
it("should return false for results with unrecognized event types", () => {
const resp = {
results: [{ type: "some.unknown.event" }],
};
expect(isRealtimeResponse(resp)).toBe(false);
});
});
describe("RealtimePrettyView", () => {
const mockWriteText = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(navigator, "clipboard", {
value: { writeText: mockWriteText },
writable: true,
configurable: true,
});
});
it("should render the component successfully", () => {
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
expect(screen.getByText("Session")).toBeInTheDocument();
});
it("should display the session model name", () => {
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
const modelElements = screen.getAllByText("gpt-4o-mini-realtime-preview");
expect(modelElements.length).toBeGreaterThanOrEqual(1);
});
it("should display the session voice tag", () => {
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
const voiceElements = screen.getAllByText("alloy");
expect(voiceElements.length).toBeGreaterThanOrEqual(1);
});
it("should display modality tags", () => {
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
expect(screen.getByText("audio")).toBeInTheDocument();
expect(screen.getByText("text")).toBeInTheDocument();
});
it("should display the turn count in session header", () => {
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
expect(screen.getByText("2 turns")).toBeInTheDocument();
});
it("should display singular 'turn' for a single response event", () => {
const singleTurnResponse = {
results: [
{
type: "session.created",
session: {
id: "sess_1",
model: "gpt-4o-mini-realtime-preview",
voice: "alloy",
modalities: ["audio"],
},
},
{
type: "response.done",
response: {
id: "r1",
status: "completed",
output: [
{
id: "item1",
role: "assistant",
type: "message",
content: [{ type: "audio", transcript: "Hi!" }],
},
],
},
},
],
};
render(<RealtimePrettyView response={singleTurnResponse} />);
expect(screen.getByText("1 turn")).toBeInTheDocument();
});
it("should display the turn count in the output section header", () => {
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
expect(screen.getByText("Turns: 2")).toBeInTheDocument();
});
it("should display the Output section header", () => {
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
expect(screen.getByText("Output")).toBeInTheDocument();
});
it("should display transcript text from response turns", () => {
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
expect(
screen.getByText("Hello! How's your day going?")
).toBeInTheDocument();
expect(
screen.getByText(
"I'm here to help with information and general questions."
)
).toBeInTheDocument();
});
it("should display completed status tags for response turns", () => {
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
const completedTags = screen.getAllByText("completed");
expect(completedTags.length).toBe(2);
});
it("should display token usage per turn", () => {
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
expect(screen.getByText("116 in / 46 out tokens")).toBeInTheDocument();
expect(screen.getByText("178 in / 247 out tokens")).toBeInTheDocument();
});
it("should expand session details when session header is clicked", async () => {
const user = userEvent.setup();
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
await user.click(screen.getByText("Session"));
await waitFor(() => {
expect(screen.getByText("Temperature")).toBeInTheDocument();
});
});
it("should display session instructions when expanded", async () => {
const user = userEvent.setup();
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
await user.click(screen.getByText("Session"));
await waitFor(() => {
expect(screen.getByText("Instructions")).toBeInTheDocument();
expect(
screen.getByText("You are a helpful assistant.")
).toBeInTheDocument();
});
});
it("should display session audio format when expanded", async () => {
const user = userEvent.setup();
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
await user.click(screen.getByText("Session"));
await waitFor(() => {
expect(screen.getByText("Input Audio Format")).toBeInTheDocument();
expect(screen.getAllByText("pcm16").length).toBeGreaterThanOrEqual(1);
});
});
it("should display ASSISTANT label for output messages", () => {
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
const assistantLabels = screen.getAllByText("ASSISTANT");
expect(assistantLabels.length).toBe(2);
});
it("should display fallback message when no recognized events exist", () => {
const emptyResponse = {
results: [{ type: "unknown.event" }],
};
render(<RealtimePrettyView response={emptyResponse} />);
expect(
screen.getByText("No recognized realtime events found")
).toBeInTheDocument();
});
it("should handle response with no output items gracefully", () => {
const noOutputResponse = {
results: [
{
type: "response.done",
response: {
id: "r1",
status: "completed",
output: [],
},
},
],
};
render(<RealtimePrettyView response={noOutputResponse} />);
expect(screen.getByText("completed")).toBeInTheDocument();
});
it("should display metrics tokens when provided", () => {
render(
<RealtimePrettyView
response={sampleRealtimeResponse}
metrics={{ completion_tokens: 500, output_cost: 0.005 }}
/>
);
expect(screen.getByText(/Tokens: 500/)).toBeInTheDocument();
expect(screen.getByText(/Cost: \$0\.005000/)).toBeInTheDocument();
});
it("should toggle output section collapse when header is clicked", async () => {
const user = userEvent.setup();
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
const transcript = screen.getByText("Hello! How's your day going?");
expect(transcript).toBeVisible();
const outputHeader = screen.getByText("Output").closest("div");
if (outputHeader) {
await user.click(outputHeader);
await waitFor(() => {
expect(transcript).not.toBeVisible();
});
}
});
it("should display token breakdown tags when input_token_details are present", async () => {
render(<RealtimePrettyView response={sampleRealtimeResponse} />);
expect(screen.getByText(/Text Tokens: 116/)).toBeInTheDocument();
});
it("should handle text content type in addition to audio", () => {
const textResponse = {
results: [
{
type: "response.done",
response: {
id: "r1",
status: "completed",
output: [
{
id: "item1",
role: "assistant",
type: "message",
content: [
{
type: "text",
text: "This is a text response",
},
],
},
],
},
},
],
};
render(<RealtimePrettyView response={textResponse} />);
expect(screen.getByText("This is a text response")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,570 @@
/**
* RealtimePrettyView - Structured pretty view for OpenAI Realtime API logs
* Displays session config, conversation turns, and token usage
* in a readable format instead of raw JSON.
*/
import { useState } from 'react';
import { Typography, Tag, Tooltip } from 'antd';
import {
SoundOutlined,
MessageOutlined,
SettingOutlined,
AudioOutlined,
DownOutlined,
UpOutlined,
} from '@ant-design/icons';
import { SectionHeader } from './SectionHeader';
const { Text } = Typography;
interface RealtimeEvent {
type: string;
event_id?: string;
session?: RealtimeSession;
response?: RealtimeResponse;
}
interface RealtimeSession {
id: string;
model: string;
voice?: string;
modalities?: string[];
temperature?: number;
tools?: any[];
instructions?: string;
turn_detection?: Record<string, any>;
input_audio_format?: string;
output_audio_format?: string;
max_response_output_tokens?: string | number;
[key: string]: any;
}
interface RealtimeResponse {
id: string;
status: string;
usage?: {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
input_token_details?: Record<string, any>;
output_token_details?: Record<string, any>;
};
output?: RealtimeOutputItem[];
modalities?: string[];
voice?: string;
conversation_id?: string;
[key: string]: any;
}
interface RealtimeOutputItem {
id: string;
role: string;
type: string;
status?: string;
content?: Array<{
type: string;
transcript?: string;
text?: string;
}>;
}
interface RealtimePrettyViewProps {
response: any;
metrics?: {
prompt_tokens?: number;
completion_tokens?: number;
input_cost?: number;
output_cost?: number;
};
}
export function isRealtimeResponse(response: any): boolean {
if (!response || !response.results || !Array.isArray(response.results) || response.results.length === 0) {
return false;
}
return response.results.some(
(r: any) =>
r.type === 'session.created' ||
r.type === 'session.updated' ||
r.type === 'response.done'
);
}
export function RealtimePrettyView({ response, metrics }: RealtimePrettyViewProps) {
const events: RealtimeEvent[] = response?.results || [];
const usage = response?.usage;
const sessionEvent = events.find(
(e) => e.type === 'session.created' || e.type === 'session.updated'
);
const responseEvents = events.filter((e) => e.type === 'response.done');
return (
<div>
{/* Session Configuration Card */}
{sessionEvent?.session && (
<SessionCard session={sessionEvent.session} turnCount={responseEvents.length} />
)}
{/* Conversation Turns */}
{responseEvents.length > 0 && (
<ConversationCard
responses={responseEvents.map((e) => e.response!).filter(Boolean)}
totalUsage={usage}
metrics={metrics}
/>
)}
{/* Fallback if no recognized events */}
{!sessionEvent && responseEvents.length === 0 && (
<div
style={{
border: '1px solid #f0f0f0',
borderRadius: 6,
padding: '16px',
color: '#8c8c8c',
fontStyle: 'italic',
fontSize: 13,
}}
>
No recognized realtime events found
</div>
)}
</div>
);
}
function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCount: number }) {
const [isCollapsed, setIsCollapsed] = useState(true);
return (
<div
style={{
border: '1px solid #f0f0f0',
borderRadius: 6,
marginBottom: 8,
overflow: 'hidden',
}}
>
<div
onClick={() => setIsCollapsed(!isCollapsed)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
borderBottom: isCollapsed ? 'none' : '1px solid #f0f0f0',
background: '#fafafa',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f5f5f5';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#fafafa';
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
{isCollapsed ? (
<DownOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
) : (
<UpOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SettingOutlined style={{ color: '#8c8c8c', fontSize: 14 }} />
<Text style={{ fontWeight: 500, fontSize: 14 }}>Session</Text>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{session.model}
</Text>
{turnCount > 0 && (
<Tag
color="purple"
style={{ margin: 0, fontWeight: 500 }}
>
{turnCount} {turnCount === 1 ? 'turn' : 'turns'}
</Tag>
)}
{session.voice && (
<Tag color="blue" style={{ margin: 0 }}>
<SoundOutlined /> {session.voice}
</Tag>
)}
{session.modalities && (
<div style={{ display: 'flex', gap: 4 }}>
{session.modalities.map((m) => (
<Tag key={m} style={{ margin: 0 }}>
{m === 'audio' ? <AudioOutlined /> : <MessageOutlined />} {m}
</Tag>
))}
</div>
)}
</div>
</div>
<div
style={{
maxHeight: isCollapsed ? '0px' : '10000px',
overflow: 'hidden',
transition: 'max-height 0.3s ease-out, opacity 0.3s ease-out',
opacity: isCollapsed ? 0 : 1,
}}
>
<div style={{ padding: '12px 16px' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px 24px',
fontSize: 13,
}}
>
<ConfigRow label="Model" value={session.model} />
<ConfigRow label="Voice" value={session.voice} />
<ConfigRow label="Temperature" value={session.temperature} />
<ConfigRow
label="Max Output Tokens"
value={session.max_response_output_tokens}
/>
<ConfigRow
label="Input Audio Format"
value={session.input_audio_format}
/>
<ConfigRow
label="Output Audio Format"
value={session.output_audio_format}
/>
{session.turn_detection && (
<ConfigRow
label="Turn Detection"
value={session.turn_detection.type}
/>
)}
{session.tools && session.tools.length > 0 && (
<ConfigRow
label="Tools"
value={`${session.tools.length} tool(s)`}
/>
)}
</div>
{session.instructions && (
<div style={{ marginTop: 12 }}>
<Text
type="secondary"
style={{
fontSize: 10,
letterSpacing: '0.5px',
textTransform: 'uppercase',
display: 'block',
marginBottom: 4,
}}
>
Instructions
</Text>
<div
style={{
fontSize: 12,
lineHeight: 1.6,
color: '#595959',
background: '#fafafa',
padding: '8px 12px',
borderRadius: 4,
border: '1px solid #f0f0f0',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 120,
overflowY: 'auto',
}}
>
{session.instructions}
</div>
</div>
)}
</div>
</div>
</div>
);
}
function ConversationCard({
responses,
totalUsage,
metrics,
}: {
responses: RealtimeResponse[];
totalUsage?: any;
metrics?: RealtimePrettyViewProps['metrics'];
}) {
const [isCollapsed, setIsCollapsed] = useState(false);
const totalTokens = totalUsage?.total_tokens;
const turnCount = responses.length;
const handleCopy = () => {
const transcripts = responses
.flatMap((r) =>
(r.output || []).flatMap((o) =>
(o.content || []).map(
(c) => `${o.role}: ${c.transcript || c.text || ''}`
)
)
)
.join('\n');
navigator.clipboard.writeText(transcripts);
};
return (
<div
style={{
border: '1px solid #f0f0f0',
borderRadius: 6,
overflow: 'hidden',
}}
>
<SectionHeader
type="output"
tokens={metrics?.completion_tokens ?? totalTokens}
cost={metrics?.output_cost}
onCopy={handleCopy}
isCollapsed={isCollapsed}
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
turnCount={turnCount}
/>
<div
style={{
maxHeight: isCollapsed ? '0px' : '10000px',
overflow: 'hidden',
transition: 'max-height 0.3s ease-out, opacity 0.3s ease-out',
opacity: isCollapsed ? 0 : 1,
}}
>
<div style={{ padding: '12px 16px' }}>
{responses.map((resp, idx) => (
<ResponseTurn key={resp.id || idx} response={resp} index={idx} />
))}
</div>
</div>
</div>
);
}
function ResponseTurn({
response,
index,
}: {
response: RealtimeResponse;
index: number;
}) {
const outputs = response.output || [];
const usage = response.usage;
return (
<div
style={{
marginBottom: 12,
paddingBottom: 12,
borderBottom: '1px solid #f5f5f5',
}}
>
{/* Turn header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 8,
}}
>
<Tag
color={response.status === 'completed' ? 'green' : 'orange'}
style={{ margin: 0 }}
>
{response.status || 'unknown'}
</Tag>
{usage && (
<Text type="secondary" style={{ fontSize: 11 }}>
{usage.input_tokens ?? 0} in / {usage.output_tokens ?? 0} out tokens
</Text>
)}
{response.conversation_id && (
<Tooltip title={response.conversation_id}>
<Text
type="secondary"
style={{ fontSize: 11, cursor: 'help' }}
>
conv: {response.conversation_id.slice(0, 12)}...
</Text>
</Tooltip>
)}
</div>
{/* Output messages / transcripts */}
{outputs.map((output, oIdx) => (
<OutputMessage key={output.id || oIdx} output={output} />
))}
{/* Token breakdown if available */}
{usage?.input_token_details && (
<TokenBreakdown
label="Input"
details={usage.input_token_details}
/>
)}
{usage?.output_token_details && (
<TokenBreakdown
label="Output"
details={usage.output_token_details}
/>
)}
</div>
);
}
function OutputMessage({ output }: { output: RealtimeOutputItem }) {
const contents = output.content || [];
const hasTranscripts = contents.some((c) => c.transcript || c.text);
if (!hasTranscripts) return null;
return (
<div style={{ marginBottom: 8 }}>
<Text
type="secondary"
style={{
fontSize: 10,
letterSpacing: '0.5px',
textTransform: 'uppercase',
display: 'block',
marginBottom: 3,
}}
>
{output.role?.toUpperCase() || 'ASSISTANT'}
</Text>
{contents.map((c, cIdx) => {
const text = c.transcript || c.text;
if (!text) return null;
return (
<div
key={cIdx}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
marginBottom: 4,
}}
>
{c.type === 'audio' && (
<AudioOutlined
style={{
color: '#8c8c8c',
fontSize: 12,
marginTop: 3,
flexShrink: 0,
}}
/>
)}
{c.type === 'text' && (
<MessageOutlined
style={{
color: '#8c8c8c',
fontSize: 12,
marginTop: 3,
flexShrink: 0,
}}
/>
)}
<div
style={{
fontSize: 13,
lineHeight: 1.7,
color: '#262626',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{text}
</div>
</div>
);
})}
</div>
);
}
function TokenBreakdown({
label,
details,
}: {
label: string;
details: Record<string, any>;
}) {
const entries = Object.entries(details).filter(
([, v]) =>
typeof v === 'number' ||
(typeof v === 'object' && v !== null)
);
if (entries.length === 0) return null;
return (
<div style={{ marginTop: 4 }}>
<Text
type="secondary"
style={{ fontSize: 10, letterSpacing: '0.5px', textTransform: 'uppercase' }}
>
{label} Token Breakdown
</Text>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
marginTop: 4,
}}
>
{entries.map(([key, value]) => {
if (typeof value === 'number') {
return (
<Tag key={key} style={{ margin: 0 }}>
{formatTokenLabel(key)}: {value.toLocaleString()}
</Tag>
);
}
return null;
})}
</div>
</div>
);
}
function ConfigRow({
label,
value,
}: {
label: string;
value: any;
}) {
if (value === undefined || value === null) return null;
return (
<div>
<Text type="secondary" style={{ fontSize: 11 }}>
{label}
</Text>
<div style={{ fontSize: 13, color: '#262626' }}>
{String(value)}
</div>
</div>
);
}
function formatTokenLabel(key: string): string {
return key
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}

View file

@ -19,9 +19,10 @@ interface SectionHeaderProps {
onCopy: () => void;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
turnCount?: number;
}
export function SectionHeader({ type, tokens, cost, onCopy, isCollapsed, onToggleCollapse }: SectionHeaderProps) {
export function SectionHeader({ type, tokens, cost, onCopy, isCollapsed, onToggleCollapse, turnCount }: SectionHeaderProps) {
return (
<div
onClick={onToggleCollapse}
@ -81,6 +82,13 @@ export function SectionHeader({ type, tokens, cost, onCopy, isCollapsed, onToggl
Cost: ${cost.toFixed(6)}
</Text>
)}
{/* Turn count */}
{turnCount !== undefined && turnCount > 0 && (
<Text type="secondary" style={{ fontSize: 12 }}>
Turns: {turnCount}
</Text>
)}
</div>
{/* Copy Button */}