mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
commit
3e1e6897ea
11 changed files with 1399 additions and 197 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
)
|
||||
|
|
|
|||
97
ui/litellm-dashboard/package-lock.json
generated
97
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue