mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
Merge remote-tracking branch 'origin' into litellm_paginated_key_alias
This commit is contained in:
commit
947586b62d
80 changed files with 4097 additions and 616 deletions
|
|
@ -796,6 +796,7 @@ router_settings:
|
|||
| PYROSCOPE_SERVER_ADDRESS | Pyroscope server URL to send profiles to. Required when LITELLM_ENABLE_PYROSCOPE is true. No default.
|
||||
| PYROSCOPE_SAMPLE_RATE | Optional. Sample rate for Pyroscope profiling (integer). No default; when unset, the pyroscope-io library default is used.
|
||||
| LITELLM_MASTER_KEY | Master key for proxy authentication
|
||||
| LITELLM_MAX_ITERATIONS_TTL | TTL in seconds for session iteration counters used by the max-iterations limiter. Default is 3600 (1 hour)
|
||||
| LITELLM_MODE | Operating mode for LiteLLM (e.g., production, development)
|
||||
| LITELLM_NON_ROOT | Flag to run LiteLLM in non-root mode for enhanced security in Docker containers
|
||||
| LITELLM_RATE_LIMIT_WINDOW_SIZE | Rate limit window size for LiteLLM. Default is 60
|
||||
|
|
@ -991,6 +992,7 @@ router_settings:
|
|||
| TOGETHER_AI_EMBEDDING_150_M | Size parameter for Together AI 150M embedding model. Default is 150
|
||||
| TOGETHER_AI_EMBEDDING_350_M | Size parameter for Together AI 350M embedding model. Default is 350
|
||||
| TOOL_CHOICE_OBJECT_TOKEN_COUNT | Token count for tool choice objects. Default is 4
|
||||
| TOOL_POLICY_CACHE_TTL_SECONDS | TTL in seconds for caching tool policy guardrail results. Default is 60
|
||||
| UI_LOGO_PATH | Path to the logo image used in the UI
|
||||
| UI_PASSWORD | Password for accessing the UI
|
||||
| UI_USERNAME | Username for accessing the UI
|
||||
|
|
|
|||
|
|
@ -136,78 +136,137 @@ class ArizePhoenixLogger(OpenTelemetry): # type: ignore
|
|||
|
||||
return None
|
||||
|
||||
def _get_phoenix_context(self, kwargs):
|
||||
"""
|
||||
Build a trace context for Phoenix's dedicated TracerProvider.
|
||||
|
||||
The base ``_get_span_context`` returns parent spans from the global
|
||||
TracerProvider (the ``otel`` callback). Those spans live on a
|
||||
*different* TracerProvider, so they won't appear in Phoenix — using
|
||||
them as parents just creates broken links.
|
||||
|
||||
Instead we:
|
||||
1. Honour an incoming ``traceparent`` HTTP header (distributed tracing).
|
||||
2. In proxy mode, create our *own* parent span on Phoenix's tracer
|
||||
so the hierarchy is visible end-to-end inside Phoenix.
|
||||
3. In SDK (non-proxy) mode, just return (None, None) for a root span.
|
||||
"""
|
||||
from opentelemetry import trace
|
||||
|
||||
litellm_params = kwargs.get("litellm_params", {}) or {}
|
||||
proxy_server_request = litellm_params.get("proxy_server_request", {}) or {}
|
||||
headers = proxy_server_request.get("headers", {}) or {}
|
||||
|
||||
# Propagate distributed trace context if the caller sent a traceparent
|
||||
traceparent_ctx = (
|
||||
self.get_traceparent_from_header(headers=headers)
|
||||
if headers.get("traceparent")
|
||||
else None
|
||||
)
|
||||
|
||||
is_proxy_mode = bool(proxy_server_request)
|
||||
|
||||
if is_proxy_mode:
|
||||
# Create a parent span on Phoenix's own tracer so both parent
|
||||
# and child are exported to Phoenix.
|
||||
start_time_val = kwargs.get("start_time", kwargs.get("api_call_start_time"))
|
||||
parent_span = self.tracer.start_span(
|
||||
name="litellm_proxy_request",
|
||||
start_time=self._to_ns(start_time_val) if start_time_val is not None else None,
|
||||
context=traceparent_ctx,
|
||||
kind=self.span_kind.SERVER,
|
||||
)
|
||||
ctx = trace.set_span_in_context(parent_span)
|
||||
return ctx, parent_span
|
||||
|
||||
# SDK mode — no parent span needed
|
||||
return traceparent_ctx, None
|
||||
|
||||
def _handle_success(self, kwargs, response_obj, start_time, end_time):
|
||||
"""
|
||||
Override to prevent creating duplicate litellm_request spans when a proxy parent span exists.
|
||||
|
||||
ArizePhoenixLogger should reuse the proxy parent span instead of creating a new litellm_request span,
|
||||
to maintain a shallow span hierarchy as expected by Arize Phoenix.
|
||||
Override to always create spans on ArizePhoenixLogger's dedicated TracerProvider.
|
||||
|
||||
The base class's ``_get_span_context`` would find the parent span created by
|
||||
the ``otel`` callback on the *global* TracerProvider. That span is invisible
|
||||
in Phoenix (different exporter pipeline), so we ignore it and build our own
|
||||
hierarchy via ``_get_phoenix_context``.
|
||||
"""
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
from litellm.secret_managers.main import get_secret_bool
|
||||
from litellm.integrations.opentelemetry import LITELLM_PROXY_REQUEST_SPAN_NAME
|
||||
|
||||
|
||||
verbose_logger.debug(
|
||||
"ArizePhoenixLogger: Logging kwargs: %s, OTEL config settings=%s",
|
||||
kwargs,
|
||||
self.config,
|
||||
)
|
||||
ctx, parent_span = self._get_span_context(kwargs)
|
||||
|
||||
# ArizePhoenixLogger NEVER creates a litellm_request span when a proxy parent span exists
|
||||
# This is different from the base OpenTelemetry behavior which respects USE_OTEL_LITELLM_REQUEST_SPAN
|
||||
should_create_primary_span = parent_span is None or (
|
||||
parent_span.name != LITELLM_PROXY_REQUEST_SPAN_NAME
|
||||
and get_secret_bool("USE_OTEL_LITELLM_REQUEST_SPAN")
|
||||
ctx, parent_span = self._get_phoenix_context(kwargs)
|
||||
|
||||
# Create litellm_request span (child of our parent when in proxy mode)
|
||||
span = self.tracer.start_span(
|
||||
name=self._get_span_name(kwargs),
|
||||
start_time=self._to_ns(start_time),
|
||||
context=ctx,
|
||||
)
|
||||
span.set_status(Status(StatusCode.OK))
|
||||
self.set_attributes(span, kwargs, response_obj)
|
||||
|
||||
if should_create_primary_span:
|
||||
# Create a new litellm_request span
|
||||
span = self._start_primary_span(
|
||||
kwargs, response_obj, start_time, end_time, ctx
|
||||
)
|
||||
# Raw-request sub-span (if enabled) - child of litellm_request span
|
||||
self._maybe_log_raw_request(
|
||||
kwargs, response_obj, start_time, end_time, span
|
||||
)
|
||||
# Ensure proxy-request parent span is annotated with the actual operation kind
|
||||
if (
|
||||
parent_span is not None
|
||||
and parent_span.name == LITELLM_PROXY_REQUEST_SPAN_NAME
|
||||
):
|
||||
self.set_attributes(parent_span, kwargs, response_obj)
|
||||
else:
|
||||
# Do not create primary span (keep hierarchy shallow when parent exists)
|
||||
span = None
|
||||
# Only set attributes if the span is still recording (not closed)
|
||||
# Note: parent_span is guaranteed to be not None here
|
||||
if parent_span.is_recording():
|
||||
parent_span.set_status(Status(StatusCode.OK))
|
||||
self.set_attributes(parent_span, kwargs, response_obj)
|
||||
# Raw-request as direct child of parent_span
|
||||
self._maybe_log_raw_request(
|
||||
kwargs, response_obj, start_time, end_time, parent_span
|
||||
)
|
||||
# Raw-request sub-span (if enabled) — must be created before
|
||||
# ending the parent span so the hierarchy is valid.
|
||||
self._maybe_log_raw_request(
|
||||
kwargs, response_obj, start_time, end_time, span
|
||||
)
|
||||
span.end(end_time=self._to_ns(end_time))
|
||||
|
||||
# 3. Guardrail span
|
||||
# Guardrail span
|
||||
self._create_guardrail_span(kwargs=kwargs, context=ctx)
|
||||
|
||||
# 4. Metrics & cost recording
|
||||
# Annotate and close our proxy parent span
|
||||
if parent_span is not None:
|
||||
parent_span.set_status(Status(StatusCode.OK))
|
||||
self.set_attributes(parent_span, kwargs, response_obj)
|
||||
parent_span.end(end_time=self._to_ns(end_time))
|
||||
|
||||
# Metrics & cost recording
|
||||
self._record_metrics(kwargs, response_obj, start_time, end_time)
|
||||
|
||||
# 5. Semantic logs.
|
||||
# Semantic logs
|
||||
if self.config.enable_events:
|
||||
log_span = span if span is not None else parent_span
|
||||
if log_span is not None:
|
||||
self._emit_semantic_logs(kwargs, response_obj, log_span)
|
||||
self._emit_semantic_logs(kwargs, response_obj, span)
|
||||
|
||||
# 6. Do NOT end parent span - it should be managed by its creator
|
||||
# External spans (from Langfuse, user code, HTTP headers, global context) must not be closed by LiteLLM
|
||||
# However, proxy-created spans should be closed here
|
||||
if (
|
||||
parent_span is not None
|
||||
and parent_span.name == LITELLM_PROXY_REQUEST_SPAN_NAME
|
||||
):
|
||||
def _handle_failure(self, kwargs, response_obj, start_time, end_time):
|
||||
"""
|
||||
Override to always create failure spans on ArizePhoenixLogger's dedicated
|
||||
TracerProvider. Mirrors ``_handle_success`` but sets ERROR status.
|
||||
"""
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
|
||||
verbose_logger.debug(
|
||||
"ArizePhoenixLogger: Failure - Logging kwargs: %s, OTEL config settings=%s",
|
||||
kwargs,
|
||||
self.config,
|
||||
)
|
||||
|
||||
ctx, parent_span = self._get_phoenix_context(kwargs)
|
||||
|
||||
# Create litellm_request span (child of our parent when in proxy mode)
|
||||
span = self.tracer.start_span(
|
||||
name=self._get_span_name(kwargs),
|
||||
start_time=self._to_ns(start_time),
|
||||
context=ctx,
|
||||
)
|
||||
span.set_status(Status(StatusCode.ERROR))
|
||||
self.set_attributes(span, kwargs, response_obj)
|
||||
self._record_exception_on_span(span=span, kwargs=kwargs)
|
||||
span.end(end_time=self._to_ns(end_time))
|
||||
|
||||
# Guardrail span
|
||||
self._create_guardrail_span(kwargs=kwargs, context=ctx)
|
||||
|
||||
# Annotate and close our proxy parent span
|
||||
if parent_span is not None:
|
||||
parent_span.set_status(Status(StatusCode.ERROR))
|
||||
self.set_attributes(parent_span, kwargs, response_obj)
|
||||
self._record_exception_on_span(span=parent_span, kwargs=kwargs)
|
||||
parent_span.end(end_time=self._to_ns(end_time))
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -92,6 +92,9 @@ class CustomGuardrail(CustomLogger):
|
|||
mask_request_content: bool = False,
|
||||
mask_response_content: bool = False,
|
||||
violation_message_template: Optional[str] = None,
|
||||
end_session_after_n_fails: Optional[int] = None,
|
||||
on_violation: Optional[str] = None,
|
||||
realtime_violation_message: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
|
|
@ -104,6 +107,9 @@ class CustomGuardrail(CustomLogger):
|
|||
default_on: If True, the guardrail will be run by default on all requests
|
||||
mask_request_content: If True, the guardrail will mask the request content
|
||||
mask_response_content: If True, the guardrail will mask the response content
|
||||
end_session_after_n_fails: For /v1/realtime sessions, end the session after this many violations
|
||||
on_violation: For /v1/realtime sessions, 'warn' or 'end_session'
|
||||
realtime_violation_message: Message the bot speaks aloud when a /v1/realtime guardrail fires
|
||||
"""
|
||||
self.guardrail_name = guardrail_name
|
||||
self.supported_event_hooks = supported_event_hooks
|
||||
|
|
@ -114,6 +120,9 @@ class CustomGuardrail(CustomLogger):
|
|||
self.mask_request_content: bool = mask_request_content
|
||||
self.mask_response_content: bool = mask_response_content
|
||||
self.violation_message_template: Optional[str] = violation_message_template
|
||||
self.end_session_after_n_fails: Optional[int] = end_session_after_n_fails
|
||||
self.on_violation: Optional[str] = on_violation
|
||||
self.realtime_violation_message: Optional[str] = realtime_violation_message
|
||||
|
||||
if supported_event_hooks:
|
||||
## validate event_hook is in supported_event_hooks
|
||||
|
|
|
|||
|
|
@ -299,12 +299,54 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
f"WebSearchInterception: Detected {len(tool_calls)} WebSearch tool call(s), executing agentic loop"
|
||||
)
|
||||
|
||||
# Return tools dict with tool calls
|
||||
# Extract thinking blocks from response content.
|
||||
# When extended thinking is enabled, the model response includes
|
||||
# thinking/redacted_thinking blocks that must be preserved and
|
||||
# prepended to the follow-up assistant message.
|
||||
thinking_blocks: List[Dict] = []
|
||||
if isinstance(response, dict):
|
||||
content = response.get("content", [])
|
||||
else:
|
||||
content = getattr(response, "content", []) or []
|
||||
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
block_type = block.get("type")
|
||||
else:
|
||||
block_type = getattr(block, "type", None)
|
||||
|
||||
if block_type in ("thinking", "redacted_thinking"):
|
||||
if isinstance(block, dict):
|
||||
thinking_blocks.append(block)
|
||||
else:
|
||||
# Convert object to dict using getattr, matching the
|
||||
# pattern in _detect_from_non_streaming_response
|
||||
thinking_block_dict: Dict = {"type": block_type}
|
||||
if block_type == "thinking":
|
||||
thinking_block_dict["thinking"] = getattr(
|
||||
block, "thinking", ""
|
||||
)
|
||||
thinking_block_dict["signature"] = getattr(
|
||||
block, "signature", ""
|
||||
)
|
||||
else: # redacted_thinking
|
||||
thinking_block_dict["data"] = getattr(
|
||||
block, "data", ""
|
||||
)
|
||||
thinking_blocks.append(thinking_block_dict)
|
||||
|
||||
if thinking_blocks:
|
||||
verbose_logger.debug(
|
||||
f"WebSearchInterception: Extracted {len(thinking_blocks)} thinking block(s) from response"
|
||||
)
|
||||
|
||||
# Return tools dict with tool calls and thinking blocks
|
||||
tools_dict = {
|
||||
"tool_calls": tool_calls,
|
||||
"tool_type": "websearch",
|
||||
"provider": custom_llm_provider,
|
||||
"response_format": "anthropic",
|
||||
"thinking_blocks": thinking_blocks,
|
||||
}
|
||||
return True, tools_dict
|
||||
|
||||
|
|
@ -387,6 +429,7 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
"""
|
||||
|
||||
tool_calls = tools["tool_calls"]
|
||||
thinking_blocks = tools.get("thinking_blocks", [])
|
||||
|
||||
verbose_logger.debug(
|
||||
f"WebSearchInterception: Executing agentic loop for {len(tool_calls)} search(es)"
|
||||
|
|
@ -396,6 +439,7 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
model=model,
|
||||
messages=messages,
|
||||
tool_calls=tool_calls,
|
||||
thinking_blocks=thinking_blocks,
|
||||
anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
|
||||
logging_obj=logging_obj,
|
||||
stream=stream,
|
||||
|
|
@ -442,6 +486,7 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
model: str,
|
||||
messages: List[Dict],
|
||||
tool_calls: List[Dict],
|
||||
thinking_blocks: List[Dict],
|
||||
anthropic_messages_optional_request_params: Dict,
|
||||
logging_obj: Any,
|
||||
stream: bool,
|
||||
|
|
@ -495,6 +540,7 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
assistant_message, user_message = WebSearchTransformation.transform_response(
|
||||
tool_calls=tool_calls,
|
||||
search_results=final_search_results,
|
||||
thinking_blocks=thinking_blocks,
|
||||
)
|
||||
|
||||
# Make follow-up request with search results
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ WebSearch Tool Transformation
|
|||
Transforms between Anthropic/OpenAI tool_use format and LiteLLM search format.
|
||||
"""
|
||||
import json
|
||||
from typing import Any, Dict, List, Tuple, Union
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import LITELLM_WEB_SEARCH_TOOL_NAME
|
||||
|
|
@ -224,6 +224,7 @@ class WebSearchTransformation:
|
|||
tool_calls: List[Dict],
|
||||
search_results: List[str],
|
||||
response_format: str = "anthropic",
|
||||
thinking_blocks: Optional[List[Dict]] = None,
|
||||
) -> Tuple[Dict, Union[Dict, List[Dict]]]:
|
||||
"""
|
||||
Transform LiteLLM search results to Anthropic/OpenAI tool_result format.
|
||||
|
|
@ -235,6 +236,10 @@ class WebSearchTransformation:
|
|||
tool_calls: List of tool_use/tool_calls dicts from transform_request
|
||||
search_results: List of search result strings (one per tool_call)
|
||||
response_format: Response format - "anthropic" or "openai" (default: "anthropic")
|
||||
thinking_blocks: Optional list of thinking/redacted_thinking blocks
|
||||
from the model's response. When present, prepended to the
|
||||
assistant message content (required by Anthropic API when
|
||||
thinking is enabled).
|
||||
|
||||
Returns:
|
||||
(assistant_message, user_or_tool_messages):
|
||||
|
|
@ -247,19 +252,29 @@ class WebSearchTransformation:
|
|||
)
|
||||
else:
|
||||
return WebSearchTransformation._transform_response_anthropic(
|
||||
tool_calls, search_results
|
||||
tool_calls, search_results, thinking_blocks=thinking_blocks
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _transform_response_anthropic(
|
||||
tool_calls: List[Dict],
|
||||
search_results: List[str],
|
||||
thinking_blocks: Optional[List[Dict]] = None,
|
||||
) -> Tuple[Dict, Dict]:
|
||||
"""Transform to Anthropic format (single user message with tool_result blocks)"""
|
||||
# Build assistant message with tool_use blocks
|
||||
assistant_message = {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
# Build assistant message content
|
||||
assistant_content: List[Dict] = []
|
||||
|
||||
# Prepend thinking blocks if present.
|
||||
# When extended thinking is enabled, Anthropic requires the assistant
|
||||
# message to start with thinking/redacted_thinking blocks before any
|
||||
# tool_use blocks. Same pattern as anthropic_messages_pt in factory.py.
|
||||
if thinking_blocks:
|
||||
assistant_content.extend(thinking_blocks)
|
||||
|
||||
# Add tool_use blocks
|
||||
assistant_content.extend(
|
||||
[
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": tc["id"],
|
||||
|
|
@ -267,7 +282,12 @@ class WebSearchTransformation:
|
|||
"input": tc["input"],
|
||||
}
|
||||
for tc in tool_calls
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
assistant_message = {
|
||||
"role": "assistant",
|
||||
"content": assistant_content,
|
||||
}
|
||||
|
||||
# Build user message with tool_result blocks
|
||||
|
|
|
|||
|
|
@ -3834,6 +3834,12 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
|||
)
|
||||
)
|
||||
_in_memory_loggers.append(otel_logger)
|
||||
|
||||
# Auto-initialize Arize Phoenix if Phoenix env vars are configured
|
||||
# This allows users to get nested traces in both OTEL and Phoenix
|
||||
# by only specifying "otel" in callbacks
|
||||
_maybe_auto_initialize_arize_phoenix(_in_memory_loggers)
|
||||
|
||||
return otel_logger # type: ignore
|
||||
|
||||
elif logging_integration == "galileo":
|
||||
|
|
@ -3887,7 +3893,8 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
|||
headers=f"Authorization={os.getenv('LOGFIRE_TOKEN')}",
|
||||
)
|
||||
for callback in _in_memory_loggers:
|
||||
if isinstance(callback, OpenTelemetry):
|
||||
# Use exact type check to avoid matching ArizePhoenixLogger (subclass)
|
||||
if type(callback) is OpenTelemetry:
|
||||
return callback # type: ignore
|
||||
_otel_logger = OpenTelemetry(config=otel_config)
|
||||
_in_memory_loggers.append(_otel_logger)
|
||||
|
|
@ -4147,6 +4154,57 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
|||
return None
|
||||
|
||||
|
||||
def _maybe_auto_initialize_arize_phoenix(_in_memory_loggers: list) -> None:
|
||||
"""
|
||||
Auto-initialize ArizePhoenixLogger when Phoenix env vars are detected.
|
||||
|
||||
Called during ``otel`` callback setup so that users get nested traces in
|
||||
both their OTEL collector *and* Arize Phoenix by only listing ``"otel"``
|
||||
in ``callbacks``. If no Phoenix env vars are set, this is a no-op.
|
||||
"""
|
||||
phoenix_env_vars = (
|
||||
"PHOENIX_API_KEY",
|
||||
"PHOENIX_COLLECTOR_HTTP_ENDPOINT",
|
||||
"PHOENIX_COLLECTOR_ENDPOINT",
|
||||
)
|
||||
if not any(os.environ.get(v) for v in phoenix_env_vars):
|
||||
return
|
||||
|
||||
# Already registered — nothing to do
|
||||
if any(
|
||||
isinstance(cb, ArizePhoenixLogger) and cb.callback_name == "arize_phoenix"
|
||||
for cb in _in_memory_loggers
|
||||
):
|
||||
return
|
||||
|
||||
try:
|
||||
from litellm.integrations.opentelemetry import OpenTelemetryConfig
|
||||
|
||||
arize_phoenix_config = ArizePhoenixLogger.get_arize_phoenix_config()
|
||||
otel_config = OpenTelemetryConfig(
|
||||
exporter=arize_phoenix_config.protocol,
|
||||
endpoint=arize_phoenix_config.endpoint,
|
||||
headers=arize_phoenix_config.otlp_auth_headers,
|
||||
)
|
||||
phoenix_logger = ArizePhoenixLogger(
|
||||
config=otel_config, callback_name="arize_phoenix"
|
||||
)
|
||||
_in_memory_loggers.append(phoenix_logger)
|
||||
|
||||
# Register as a litellm callback so it receives success/failure events
|
||||
litellm.logging_callback_manager.add_litellm_callback(phoenix_logger)
|
||||
|
||||
verbose_logger.info(
|
||||
"Auto-initialized Arize Phoenix logger alongside otel "
|
||||
"(endpoint=%s)",
|
||||
arize_phoenix_config.endpoint,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.warning(
|
||||
"Failed to auto-initialize Arize Phoenix logger: %s", str(e)
|
||||
)
|
||||
|
||||
|
||||
def get_custom_logger_compatible_class( # noqa: PLR0915
|
||||
logging_integration: _custom_logger_compatible_callbacks_literal,
|
||||
) -> Optional[CustomLogger]:
|
||||
|
|
@ -4249,7 +4307,8 @@ def get_custom_logger_compatible_class( # noqa: PLR0915
|
|||
from litellm.integrations.opentelemetry import OpenTelemetry
|
||||
|
||||
for callback in _in_memory_loggers:
|
||||
if isinstance(callback, OpenTelemetry):
|
||||
# Use exact type check to avoid matching ArizePhoenixLogger (subclass)
|
||||
if type(callback) is OpenTelemetry:
|
||||
return callback
|
||||
elif logging_integration == "arize":
|
||||
if "ARIZE_API_KEY" not in os.environ:
|
||||
|
|
@ -4266,7 +4325,8 @@ def get_custom_logger_compatible_class( # noqa: PLR0915
|
|||
from litellm.integrations.opentelemetry import OpenTelemetry
|
||||
|
||||
for callback in _in_memory_loggers:
|
||||
if isinstance(callback, OpenTelemetry):
|
||||
# Use exact type check to avoid matching ArizePhoenixLogger (subclass)
|
||||
if type(callback) is OpenTelemetry:
|
||||
return callback # type: ignore
|
||||
|
||||
elif logging_integration == "dynamic_rate_limiter":
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import asyncio
|
||||
import concurrent.futures
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -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,9 @@ 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
|
||||
|
||||
def _should_store_message(
|
||||
self,
|
||||
|
|
@ -88,7 +92,7 @@ class RealTimeStreaming:
|
|||
message_obj = message
|
||||
else:
|
||||
message_obj = json.loads(message)
|
||||
self._collect_tool_calls_from_response_done(message_obj)
|
||||
self._collect_tool_calls_from_response_done(cast(dict, message_obj))
|
||||
try:
|
||||
if (
|
||||
not isinstance(message, dict)
|
||||
|
|
@ -155,7 +159,7 @@ class RealTimeStreaming:
|
|||
event_type
|
||||
== "conversation.item.input_audio_transcription.completed"
|
||||
):
|
||||
transcript = event_obj.get("transcript", "")
|
||||
transcript = cast(str, event_obj.get("transcript", ""))
|
||||
if transcript:
|
||||
self.input_messages.append(
|
||||
{"role": "user", "content": transcript}
|
||||
|
|
@ -170,7 +174,7 @@ class RealTimeStreaming:
|
|||
try:
|
||||
if event_obj.get("type") != "response.done":
|
||||
return
|
||||
response = event_obj.get("response", {})
|
||||
response = cast(Dict[str, Any], event_obj.get("response", {}))
|
||||
for item in response.get("output", []):
|
||||
if item.get("type") == "function_call":
|
||||
self.tool_calls.append(
|
||||
|
|
@ -231,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
|
||||
|
|
@ -258,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": []},
|
||||
|
|
@ -293,26 +331,42 @@ class RealTimeStreaming:
|
|||
safe_msg = str(detail)
|
||||
else:
|
||||
safe_msg = str(e) or "I'm sorry, that request was blocked by the content filter."
|
||||
# Cancel any in-flight response before speaking the warning.
|
||||
# This handles the race where create_response fired before we could intercept.
|
||||
await self._send_to_backend(json.dumps({"type": "response.cancel"}))
|
||||
# Ask the model to speak the warning — TTS audio plays naturally in the client
|
||||
await self._send_to_backend(
|
||||
|
||||
# Use realtime_violation_message if configured; fall back to guardrail error text.
|
||||
error_msg = getattr(callback, "realtime_violation_message", None) or safe_msg
|
||||
|
||||
# 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: \"{safe_msg}\". "
|
||||
"Do not add anything else."
|
||||
),
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "guardrail_violation",
|
||||
"message": error_msg,
|
||||
"code": "content_policy_violation",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
self._violation_count += 1
|
||||
end_session_after: Optional[int] = getattr(
|
||||
callback, "end_session_after_n_fails", None
|
||||
)
|
||||
should_end = getattr(callback, "on_violation", None) == "end_session" or (
|
||||
end_session_after is not None
|
||||
and self._violation_count >= end_session_after
|
||||
)
|
||||
if should_end:
|
||||
verbose_logger.warning(
|
||||
"[realtime guardrail] ending session after violation %d",
|
||||
self._violation_count,
|
||||
)
|
||||
await self.backend_ws.close()
|
||||
|
||||
verbose_logger.warning(
|
||||
"[realtime guardrail] BLOCKED transcript: %r",
|
||||
"[realtime guardrail] BLOCKED transcript (violation %d): %r",
|
||||
self._violation_count,
|
||||
transcript[:80],
|
||||
)
|
||||
return True
|
||||
|
|
@ -348,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)
|
||||
|
|
@ -374,11 +428,11 @@ class RealTimeStreaming:
|
|||
== "conversation.item.input_audio_transcription.completed"
|
||||
):
|
||||
transcript = event.get("transcript", "")
|
||||
self._collect_user_input_from_backend_event(event)
|
||||
self._collect_user_input_from_backend_event(cast(dict, event))
|
||||
self.store_message(event_str)
|
||||
await self.websocket.send_text(event_str)
|
||||
blocked = await self.run_realtime_guardrails(
|
||||
transcript, item_id=event.get("item_id")
|
||||
cast(str, transcript), item_id=cast(Optional[str], event.get("item_id"))
|
||||
)
|
||||
if not blocked:
|
||||
await self._send_to_backend(
|
||||
|
|
@ -397,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")
|
||||
|
|
|
|||
|
|
@ -1106,19 +1106,19 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
# extract usage
|
||||
usage: Usage = getattr(response, "usage")
|
||||
uncached_input_tokens = usage.prompt_tokens or 0
|
||||
cached_tokens = 0
|
||||
if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details:
|
||||
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
|
||||
uncached_input_tokens -= cached_tokens
|
||||
|
||||
|
||||
anthropic_usage = AnthropicUsage(
|
||||
input_tokens=uncached_input_tokens,
|
||||
output_tokens=usage.completion_tokens or 0,
|
||||
)
|
||||
# Add cache tokens if available (for prompt caching support)
|
||||
if hasattr(usage, "_cache_creation_input_tokens") and usage._cache_creation_input_tokens > 0:
|
||||
anthropic_usage["cache_creation_input_tokens"] = usage._cache_creation_input_tokens
|
||||
if hasattr(usage, "_cache_read_input_tokens") and usage._cache_read_input_tokens > 0:
|
||||
anthropic_usage["cache_read_input_tokens"] = usage._cache_read_input_tokens
|
||||
if cached_tokens > 0:
|
||||
anthropic_usage["cache_read_input_tokens"] = cached_tokens
|
||||
|
||||
translated_obj = AnthropicMessagesResponse(
|
||||
id=response.id,
|
||||
|
|
@ -1271,19 +1271,19 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
litellm_usage_chunk = None
|
||||
if litellm_usage_chunk is not None:
|
||||
uncached_input_tokens = litellm_usage_chunk.prompt_tokens or 0
|
||||
cached_tokens = 0
|
||||
if hasattr(litellm_usage_chunk, "prompt_tokens_details") and litellm_usage_chunk.prompt_tokens_details:
|
||||
cached_tokens = getattr(litellm_usage_chunk.prompt_tokens_details, "cached_tokens", 0) or 0
|
||||
uncached_input_tokens -= cached_tokens
|
||||
|
||||
|
||||
usage_delta = UsageDelta(
|
||||
input_tokens=uncached_input_tokens,
|
||||
output_tokens=litellm_usage_chunk.completion_tokens or 0,
|
||||
)
|
||||
# Add cache tokens if available (for prompt caching support)
|
||||
if hasattr(litellm_usage_chunk, "_cache_creation_input_tokens") and litellm_usage_chunk._cache_creation_input_tokens > 0:
|
||||
usage_delta["cache_creation_input_tokens"] = litellm_usage_chunk._cache_creation_input_tokens
|
||||
if hasattr(litellm_usage_chunk, "_cache_read_input_tokens") and litellm_usage_chunk._cache_read_input_tokens > 0:
|
||||
usage_delta["cache_read_input_tokens"] = litellm_usage_chunk._cache_read_input_tokens
|
||||
if cached_tokens > 0:
|
||||
usage_delta["cache_read_input_tokens"] = cached_tokens
|
||||
else:
|
||||
usage_delta = UsageDelta(input_tokens=0, output_tokens=0)
|
||||
return MessageBlockDelta(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from typing import Any, List, Optional
|
|||
|
||||
import httpx
|
||||
|
||||
from litellm.types.utils import Usage
|
||||
from litellm.llms.bedrock.chat.invoke_transformations.amazon_qwen3_transformation import (
|
||||
AmazonQwen3Config,
|
||||
)
|
||||
|
|
@ -19,7 +18,7 @@ from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation
|
|||
LiteLLMLoggingObj,
|
||||
)
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.utils import ModelResponse
|
||||
from litellm.types.utils import ModelResponse, Usage
|
||||
|
||||
|
||||
class AmazonQwen2Config(AmazonQwen3Config):
|
||||
|
|
@ -80,10 +79,14 @@ class AmazonQwen2Config(AmazonQwen3Config):
|
|||
# Set usage information if available in response
|
||||
if "usage" in response_data:
|
||||
usage_data = response_data["usage"]
|
||||
model_response.usage = Usage(
|
||||
prompt_tokens=usage_data.get("prompt_tokens", 0),
|
||||
completion_tokens=usage_data.get("completion_tokens", 0),
|
||||
total_tokens=usage_data.get("total_tokens", 0),
|
||||
setattr(
|
||||
model_response,
|
||||
"usage",
|
||||
Usage(
|
||||
prompt_tokens=usage_data.get("prompt_tokens", 0),
|
||||
completion_tokens=usage_data.get("completion_tokens", 0),
|
||||
total_tokens=usage_data.get("total_tokens", 0),
|
||||
),
|
||||
)
|
||||
|
||||
return model_response
|
||||
|
|
|
|||
|
|
@ -10,14 +10,13 @@ from typing import Any, List, Optional
|
|||
|
||||
import httpx
|
||||
|
||||
from litellm.types.utils import Usage
|
||||
from litellm.llms.base_llm.chat.transformation import BaseConfig
|
||||
from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation import (
|
||||
AmazonInvokeConfig,
|
||||
LiteLLMLoggingObj,
|
||||
)
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.utils import ModelResponse
|
||||
from litellm.types.utils import ModelResponse, Usage
|
||||
|
||||
|
||||
class AmazonQwen3Config(AmazonInvokeConfig, BaseConfig):
|
||||
|
|
@ -202,10 +201,14 @@ class AmazonQwen3Config(AmazonInvokeConfig, BaseConfig):
|
|||
# Set usage information if available in response
|
||||
if "usage" in response_data:
|
||||
usage_data = response_data["usage"]
|
||||
model_response.usage = Usage(
|
||||
prompt_tokens=usage_data.get("prompt_tokens", 0),
|
||||
completion_tokens=usage_data.get("completion_tokens", 0),
|
||||
total_tokens=usage_data.get("total_tokens", 0),
|
||||
setattr(
|
||||
model_response,
|
||||
"usage",
|
||||
Usage(
|
||||
prompt_tokens=usage_data.get("prompt_tokens", 0),
|
||||
completion_tokens=usage_data.get("completion_tokens", 0),
|
||||
total_tokens=usage_data.get("total_tokens", 0),
|
||||
),
|
||||
)
|
||||
|
||||
return model_response
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -269,6 +269,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
|
|||
"logprobs",
|
||||
"top_logprobs",
|
||||
"modalities",
|
||||
"audio",
|
||||
"parallel_tool_calls",
|
||||
"web_search_options",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -119,6 +119,12 @@ class VertexAIVideoConfig(BaseVideoConfig, VertexBase):
|
|||
# Map input_reference to image (will be processed in transform_video_create_request)
|
||||
if "input_reference" in video_create_optional_params:
|
||||
mapped_params["image"] = video_create_optional_params["input_reference"]
|
||||
elif "image" in video_create_optional_params:
|
||||
mapped_params["image"] = video_create_optional_params["image"]
|
||||
|
||||
# Pass through a provider-specific parameters block if provided directly
|
||||
if "parameters" in video_create_optional_params:
|
||||
mapped_params["parameters"] = video_create_optional_params["parameters"]
|
||||
|
||||
# Map size to aspectRatio
|
||||
if "size" in video_create_optional_params:
|
||||
|
|
@ -263,23 +269,49 @@ class VertexAIVideoConfig(BaseVideoConfig, VertexBase):
|
|||
instance_dict: Dict[str, Any] = {"prompt": prompt}
|
||||
params_copy = video_create_optional_request_params.copy()
|
||||
|
||||
|
||||
# Check if user wants to provide full instance dict
|
||||
if "instances" in params_copy and isinstance(params_copy["instances"], dict):
|
||||
# Replace/merge with user-provided instance
|
||||
instance_dict.update(params_copy["instances"])
|
||||
params_copy.pop("instances")
|
||||
elif "image" in params_copy and params_copy["image"] is not None:
|
||||
image_data = _convert_image_to_vertex_format(params_copy["image"])
|
||||
image = params_copy["image"]
|
||||
if isinstance(image, dict):
|
||||
# Already in Vertex format e.g. {"gcsUri": "gs://..."} or
|
||||
# {"bytesBase64Encoded": "...", "mimeType": "..."}
|
||||
image_data = image
|
||||
elif isinstance(image, str) and image.startswith("gs://"):
|
||||
# Bare GCS URI — Vertex AI accepts gcsUri natively, no download needed
|
||||
image_data = {"gcsUri": image}
|
||||
elif isinstance(image, str):
|
||||
raise ValueError(
|
||||
f"Unsupported image value '{image}'. "
|
||||
"Provide a GCS URI (gs://...), a dict with 'gcsUri' or "
|
||||
"'bytesBase64Encoded'/'mimeType', or a binary file-like object."
|
||||
)
|
||||
else:
|
||||
# File-like object — encode to base64
|
||||
image_data = _convert_image_to_vertex_format(image)
|
||||
instance_dict["image"] = image_data
|
||||
params_copy.pop("image")
|
||||
|
||||
# Extract a nested "parameters" block that map_openai_params may have placed
|
||||
# inside params_copy (e.g. from provider-specific pass-through). Merging it
|
||||
# flat prevents the double-nesting bug:
|
||||
# {"parameters": {"parameters": {...}}} ← wrong
|
||||
# {"parameters": {...}} ← correct
|
||||
nested_params = params_copy.pop("parameters", None)
|
||||
vertex_params: Dict[str, Any] = {}
|
||||
if isinstance(nested_params, dict):
|
||||
vertex_params.update(nested_params)
|
||||
vertex_params.update(params_copy)
|
||||
|
||||
# Build request data directly (TypedDict doesn't have model_dump)
|
||||
request_data: Dict[str, Any] = {"instances": [instance_dict]}
|
||||
|
||||
# Only add parameters if there are any
|
||||
if params_copy:
|
||||
request_data["parameters"] = params_copy
|
||||
if vertex_params:
|
||||
request_data["parameters"] = vertex_params
|
||||
|
||||
# Append :predictLongRunning endpoint to api_base
|
||||
url = f"{api_base}:predictLongRunning"
|
||||
|
|
|
|||
|
|
@ -4680,12 +4680,16 @@ def embedding( # noqa: PLR0915
|
|||
if dynamic_api_key is not None:
|
||||
api_key = dynamic_api_key
|
||||
|
||||
allowed_openai_params: Optional[List[str]] = kwargs.get(
|
||||
"allowed_openai_params", None
|
||||
)
|
||||
optional_params = get_optional_params_embeddings(
|
||||
model=model,
|
||||
user=user,
|
||||
dimensions=dimensions,
|
||||
encoding_format=encoding_format,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
allowed_openai_params=allowed_openai_params,
|
||||
**non_default_params,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -6222,13 +6222,13 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"azure_ai/mistral-small-2503": {
|
||||
"input_cost_per_token": 1e-06,
|
||||
"input_cost_per_token": 1e-07,
|
||||
"litellm_provider": "azure_ai",
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-06,
|
||||
"output_cost_per_token": 3e-07,
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
|
|
@ -37847,4 +37847,4 @@
|
|||
"notes": "DuckDuckGo Instant Answer API is free and does not require an API key."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from typing import Dict, List, Optional, Set, Tuple, cast
|
||||
|
||||
from fastapi import HTTPException
|
||||
from starlette.datastructures import Headers
|
||||
|
|
@ -539,7 +539,7 @@ class MCPRequestHandler:
|
|||
allowed_tools = team_tools
|
||||
else:
|
||||
# No team restrictions → use key restrictions
|
||||
allowed_tools = key_tools
|
||||
allowed_tools = cast(List[str], key_tools)
|
||||
|
||||
# Intersect with agent's tool permissions if agent_id is set
|
||||
if user_api_key_auth.agent_id:
|
||||
|
|
|
|||
|
|
@ -756,14 +756,30 @@ class MCPServerManager:
|
|||
|
||||
Returns server_ids unchanged when client_ip is None (no filtering).
|
||||
"""
|
||||
filtered, _ = self.filter_server_ids_by_ip_with_info(server_ids, client_ip)
|
||||
return filtered
|
||||
|
||||
def filter_server_ids_by_ip_with_info(
|
||||
self, server_ids: List[str], client_ip: Optional[str]
|
||||
) -> Tuple[List[str], int]:
|
||||
"""
|
||||
Filter server IDs by client IP — external callers only see public servers.
|
||||
|
||||
Returns (filtered_ids, ip_blocked_count) where ip_blocked_count is the number
|
||||
of servers that were blocked because the client IP is not allowed to access them.
|
||||
Returns server_ids unchanged (with 0 blocked) when client_ip is None.
|
||||
"""
|
||||
if client_ip is None:
|
||||
return server_ids
|
||||
return [
|
||||
sid
|
||||
for sid in server_ids
|
||||
if (s := self.get_mcp_server_by_id(sid)) is not None
|
||||
and self._is_server_accessible_from_ip(s, client_ip)
|
||||
]
|
||||
return server_ids, 0
|
||||
allowed = []
|
||||
blocked = 0
|
||||
for sid in server_ids:
|
||||
s = self.get_mcp_server_by_id(sid)
|
||||
if s is not None and self._is_server_accessible_from_ip(s, client_ip):
|
||||
allowed.append(sid)
|
||||
elif s is not None:
|
||||
blocked += 1
|
||||
return allowed, blocked
|
||||
|
||||
async def get_tools_for_server(self, server_id: str) -> List[MCPTool]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ from litellm.proxy._experimental.mcp_server.ui_session_utils import (
|
|||
)
|
||||
from litellm.proxy._experimental.mcp_server.utils import merge_mcp_headers
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers
|
||||
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers
|
||||
from litellm.types.mcp import MCPAuth
|
||||
from litellm.types.utils import CallTypes
|
||||
|
||||
|
|
@ -283,8 +283,10 @@ if MCP_AVAILABLE:
|
|||
)
|
||||
allowed_server_ids_set.update(servers)
|
||||
|
||||
allowed_server_ids = global_mcp_server_manager.filter_server_ids_by_ip(
|
||||
list(allowed_server_ids_set), _rest_client_ip
|
||||
allowed_server_ids, _ip_blocked_count = (
|
||||
global_mcp_server_manager.filter_server_ids_by_ip_with_info(
|
||||
list(allowed_server_ids_set), _rest_client_ip
|
||||
)
|
||||
)
|
||||
|
||||
list_tools_result = []
|
||||
|
|
@ -293,6 +295,26 @@ if MCP_AVAILABLE:
|
|||
# If server_id is specified, only query that specific server
|
||||
if server_id:
|
||||
if server_id not in allowed_server_ids:
|
||||
_server = global_mcp_server_manager.get_mcp_server_by_id(server_id)
|
||||
if (
|
||||
_server is not None
|
||||
and _rest_client_ip is not None
|
||||
and not global_mcp_server_manager._is_server_accessible_from_ip(
|
||||
_server, _rest_client_ip
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "ip_filtering",
|
||||
"message": (
|
||||
f"MCP server '{server_id}' is not accessible from your IP address "
|
||||
f"({_rest_client_ip}). This server is restricted to internal "
|
||||
"networks only. To make it externally accessible, set "
|
||||
"'available_on_public_internet: true' in the server configuration."
|
||||
),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
|
|
@ -330,6 +352,19 @@ if MCP_AVAILABLE:
|
|||
}
|
||||
else:
|
||||
if not allowed_server_ids:
|
||||
if _ip_blocked_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "ip_filtering",
|
||||
"message": (
|
||||
f"No MCP tools are available for your IP address ({_rest_client_ip}). "
|
||||
f"{_ip_blocked_count} server(s) are restricted to internal networks only. "
|
||||
"To make servers externally accessible, set "
|
||||
"'available_on_public_internet: true' in the server configuration."
|
||||
),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
|
|
|
|||
|
|
@ -771,8 +771,8 @@ if MCP_AVAILABLE:
|
|||
user_api_key_auth
|
||||
)
|
||||
)
|
||||
allowed_mcp_server_ids = (
|
||||
global_mcp_server_manager.filter_server_ids_by_ip(
|
||||
allowed_mcp_server_ids, _ip_blocked = (
|
||||
global_mcp_server_manager.filter_server_ids_by_ip_with_info(
|
||||
allowed_mcp_server_ids, client_ip
|
||||
)
|
||||
)
|
||||
|
|
@ -780,6 +780,16 @@ if MCP_AVAILABLE:
|
|||
"MCP IP filter: client_ip=%s, allowed_server_ids=%s",
|
||||
client_ip, allowed_mcp_server_ids,
|
||||
)
|
||||
if _ip_blocked > 0:
|
||||
verbose_logger.debug(
|
||||
"MCP IP filtering: %d server(s) are not accessible from client IP %s "
|
||||
"because they are restricted to internal networks. "
|
||||
"No tools from those servers will be returned. "
|
||||
"To expose a server externally, set 'available_on_public_internet: true' "
|
||||
"in its configuration.",
|
||||
_ip_blocked,
|
||||
client_ip,
|
||||
)
|
||||
allowed_mcp_servers: List[MCPServer] = []
|
||||
for allowed_mcp_server_id in allowed_mcp_server_ids:
|
||||
mcp_server = global_mcp_server_manager.get_mcp_server_by_id(
|
||||
|
|
|
|||
|
|
@ -1,40 +1,59 @@
|
|||
import enum
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import (TYPE_CHECKING, Any, Callable, Dict, List, Literal,
|
||||
Optional, Union)
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Union
|
||||
|
||||
import httpx
|
||||
from pydantic import (BaseModel, ConfigDict, Field, Json, field_validator,
|
||||
model_validator)
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
Json,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from typing_extensions import Required, TypedDict
|
||||
|
||||
from litellm._uuid import uuid
|
||||
from litellm.types.integrations.slack_alerting import AlertType
|
||||
from litellm.types.llms.openai import (AllMessageValues, OpenAIFileObject,
|
||||
ResponsesAPIResponse)
|
||||
from litellm.types.mcp import (MCPAuth, MCPAuthType, MCPCredentials,
|
||||
MCPTransport, MCPTransportType)
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
OpenAIFileObject,
|
||||
ResponsesAPIResponse,
|
||||
)
|
||||
from litellm.types.mcp import (
|
||||
MCPAuthType,
|
||||
MCPCredentials,
|
||||
MCPTransport,
|
||||
MCPTransportType,
|
||||
)
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPInfo
|
||||
from litellm.types.router import RouterErrors, UpdateRouterConfig
|
||||
from litellm.types.secret_managers.main import KeyManagementSystem
|
||||
from litellm.types.utils import (CallTypes, CostBreakdown, EmbeddingResponse,
|
||||
GenericBudgetConfigType, ImageResponse,
|
||||
LiteLLMBatch, LiteLLMFineTuningJob,
|
||||
LiteLLMPydanticObjectBase, ModelResponse,
|
||||
ProviderField, StandardCallbackDynamicParams,
|
||||
StandardLoggingGuardrailInformation,
|
||||
StandardLoggingMCPToolCall,
|
||||
StandardLoggingModelInformation,
|
||||
StandardLoggingPayloadErrorInformation,
|
||||
StandardLoggingPayloadStatus,
|
||||
StandardLoggingVectorStoreRequest,
|
||||
StandardPassThroughResponseObject,
|
||||
TextCompletionResponse)
|
||||
from litellm.types.utils import (
|
||||
CallTypes,
|
||||
CostBreakdown,
|
||||
EmbeddingResponse,
|
||||
GenericBudgetConfigType,
|
||||
ImageResponse,
|
||||
LiteLLMBatch,
|
||||
LiteLLMFineTuningJob,
|
||||
LiteLLMPydanticObjectBase,
|
||||
ModelResponse,
|
||||
ProviderField,
|
||||
StandardCallbackDynamicParams,
|
||||
StandardLoggingGuardrailInformation,
|
||||
StandardLoggingMCPToolCall,
|
||||
StandardLoggingModelInformation,
|
||||
StandardLoggingPayloadErrorInformation,
|
||||
StandardLoggingPayloadStatus,
|
||||
StandardLoggingVectorStoreRequest,
|
||||
StandardPassThroughResponseObject,
|
||||
TextCompletionResponse,
|
||||
)
|
||||
from litellm.types.videos.main import VideoObject
|
||||
|
||||
from .types_utils.utils import (get_instance_fn,
|
||||
validate_custom_validate_return_type)
|
||||
from .types_utils.utils import get_instance_fn, validate_custom_validate_return_type
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.trace import Span as _Span
|
||||
|
|
@ -1087,7 +1106,9 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
|
|||
raise ValueError("args is required for stdio transport")
|
||||
elif transport in [MCPTransport.http, MCPTransport.sse]:
|
||||
if not values.get("url") and not values.get("spec_path"):
|
||||
raise ValueError("url or spec_path is required for HTTP/SSE transport")
|
||||
raise ValueError(
|
||||
"url or spec_path is required for HTTP/SSE transport"
|
||||
)
|
||||
return values
|
||||
|
||||
@model_validator(mode="before")
|
||||
|
|
@ -1139,7 +1160,9 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
|
|||
raise ValueError("args is required for stdio transport")
|
||||
elif transport in [MCPTransport.http, MCPTransport.sse]:
|
||||
if not values.get("url") and not values.get("spec_path"):
|
||||
raise ValueError("url or spec_path is required for HTTP/SSE transport")
|
||||
raise ValueError(
|
||||
"url or spec_path is required for HTTP/SSE transport"
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
|
|
@ -1390,12 +1413,12 @@ class NewCustomerRequest(BudgetNewRequest):
|
|||
blocked: bool = False # allow/disallow requests for this end-user
|
||||
budget_id: Optional[str] = None # give either a budget_id or max_budget
|
||||
spend: Optional[float] = None
|
||||
allowed_model_region: Optional[AllowedModelRegion] = (
|
||||
None # require all user requests to use models in this specific region
|
||||
)
|
||||
default_model: Optional[str] = (
|
||||
None # if no equivalent model in allowed region - default all requests to this model
|
||||
)
|
||||
allowed_model_region: Optional[
|
||||
AllowedModelRegion
|
||||
] = None # require all user requests to use models in this specific region
|
||||
default_model: Optional[
|
||||
str
|
||||
] = None # if no equivalent model in allowed region - default all requests to this model
|
||||
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
|
|
@ -1418,12 +1441,12 @@ class UpdateCustomerRequest(LiteLLMPydanticObjectBase):
|
|||
blocked: bool = False # allow/disallow requests for this end-user
|
||||
max_budget: Optional[float] = None
|
||||
budget_id: Optional[str] = None # give either a budget_id or max_budget
|
||||
allowed_model_region: Optional[AllowedModelRegion] = (
|
||||
None # require all user requests to use models in this specific region
|
||||
)
|
||||
default_model: Optional[str] = (
|
||||
None # if no equivalent model in allowed region - default all requests to this model
|
||||
)
|
||||
allowed_model_region: Optional[
|
||||
AllowedModelRegion
|
||||
] = None # require all user requests to use models in this specific region
|
||||
default_model: Optional[
|
||||
str
|
||||
] = None # if no equivalent model in allowed region - default all requests to this model
|
||||
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
|
||||
|
||||
|
||||
|
|
@ -2249,6 +2272,7 @@ class LiteLLM_VerificationTokenView(LiteLLM_VerificationToken):
|
|||
end_user_tpm_limit: Optional[int] = None
|
||||
end_user_rpm_limit: Optional[int] = None
|
||||
end_user_max_budget: Optional[float] = None
|
||||
end_user_model_max_budget: Optional[dict] = None
|
||||
|
||||
# Organization Params
|
||||
organization_max_budget: Optional[float] = None
|
||||
|
|
@ -2349,8 +2373,7 @@ class UserAPIKeyAuth(
|
|||
|
||||
This is used to track number of requests/spend for health check calls.
|
||||
"""
|
||||
from litellm.constants import \
|
||||
LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
|
||||
from litellm.constants import LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
|
||||
|
||||
return cls(
|
||||
api_key=LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME,
|
||||
|
|
@ -2382,8 +2405,7 @@ class UserAPIKeyAuth(
|
|||
|
||||
This is used to track actions performed by automated system jobs.
|
||||
"""
|
||||
from litellm.constants import \
|
||||
LITELLM_INTERNAL_JOBS_SERVICE_ACCOUNT_NAME
|
||||
from litellm.constants import LITELLM_INTERNAL_JOBS_SERVICE_ACCOUNT_NAME
|
||||
|
||||
return cls(
|
||||
api_key=LITELLM_INTERNAL_JOBS_SERVICE_ACCOUNT_NAME,
|
||||
|
|
@ -2774,8 +2796,7 @@ class LiteLLM_AuditLogs(LiteLLMPydanticObjectBase):
|
|||
|
||||
@model_validator(mode="after")
|
||||
def mask_api_keys(self):
|
||||
from litellm.litellm_core_utils.sensitive_data_masker import \
|
||||
SensitiveDataMasker
|
||||
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
|
||||
|
||||
masker = SensitiveDataMasker(sensitive_patterns={"key"})
|
||||
|
||||
|
|
@ -3040,7 +3061,9 @@ class SpendLogsMetadata(TypedDict):
|
|||
str
|
||||
] # S3/GCS object key for cold storage retrieval
|
||||
litellm_overhead_time_ms: Optional[float] # LiteLLM overhead time in milliseconds
|
||||
attempted_retries: Optional[int] # Number of retries attempted (0 = first attempt succeeded)
|
||||
attempted_retries: Optional[
|
||||
int
|
||||
] # Number of retries attempted (0 = first attempt succeeded)
|
||||
max_retries: Optional[int] # Max retries configured for this request
|
||||
cost_breakdown: Optional[
|
||||
CostBreakdown
|
||||
|
|
@ -4101,10 +4124,10 @@ class SpendUpdateQueueItem(TypedDict, total=False):
|
|||
|
||||
class ToolDiscoveryQueueItem(TypedDict, total=False):
|
||||
tool_name: str
|
||||
origin: Optional[str] # MCP server name or "user_defined"
|
||||
origin: Optional[str] # MCP server name or "user_defined"
|
||||
created_by: Optional[str]
|
||||
key_hash: Optional[str] # hash of virtual key that triggered discovery
|
||||
team_id: Optional[str] # team that triggered discovery
|
||||
key_hash: Optional[str] # hash of virtual key that triggered discovery
|
||||
team_id: Optional[str] # team that triggered discovery
|
||||
key_alias: Optional[str] # human-readable key alias
|
||||
|
||||
|
||||
|
|
@ -4128,6 +4151,7 @@ class LiteLLM_ManagedObjectTable(LiteLLMPydanticObjectBase):
|
|||
|
||||
class LiteLLM_ManagedVectorStoreTable(LiteLLMPydanticObjectBase):
|
||||
"""Table for managing vector stores with target_model_names support."""
|
||||
|
||||
unified_resource_id: str
|
||||
resource_object: Optional[Any] = None # VectorStoreCreateResponse
|
||||
model_mappings: Dict[str, str]
|
||||
|
|
|
|||
|
|
@ -183,6 +183,9 @@ def _apply_budget_limits_to_end_user_params(
|
|||
if budget_info.max_budget is not None:
|
||||
end_user_params["end_user_max_budget"] = budget_info.max_budget
|
||||
|
||||
if budget_info.model_max_budget is not None:
|
||||
end_user_params["end_user_model_max_budget"] = budget_info.model_max_budget
|
||||
|
||||
verbose_proxy_logger.debug(f"Applied budget limits to end user {end_user_id}")
|
||||
|
||||
|
||||
|
|
@ -241,9 +244,20 @@ def update_valid_token_with_end_user_params(
|
|||
valid_token: UserAPIKeyAuth, end_user_params: dict
|
||||
) -> UserAPIKeyAuth:
|
||||
valid_token.end_user_id = end_user_params.get("end_user_id")
|
||||
valid_token.end_user_tpm_limit = end_user_params.get("end_user_tpm_limit")
|
||||
valid_token.end_user_rpm_limit = end_user_params.get("end_user_rpm_limit")
|
||||
valid_token.allowed_model_region = end_user_params.get("allowed_model_region")
|
||||
# Only overwrite token fields when the DB-derived value is not None.
|
||||
# This prevents DB lookups (where the budget table has no value set)
|
||||
# from silently clearing values that a custom auth function may have
|
||||
# already set on the token.
|
||||
if end_user_params.get("end_user_tpm_limit") is not None:
|
||||
valid_token.end_user_tpm_limit = end_user_params["end_user_tpm_limit"]
|
||||
if end_user_params.get("end_user_rpm_limit") is not None:
|
||||
valid_token.end_user_rpm_limit = end_user_params["end_user_rpm_limit"]
|
||||
if end_user_params.get("allowed_model_region") is not None:
|
||||
valid_token.allowed_model_region = end_user_params["allowed_model_region"]
|
||||
if end_user_params.get("end_user_model_max_budget") is not None:
|
||||
valid_token.end_user_model_max_budget = end_user_params[
|
||||
"end_user_model_max_budget"
|
||||
]
|
||||
return valid_token
|
||||
|
||||
|
||||
|
|
@ -498,13 +512,29 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
|||
request=request, api_key=api_key, user_custom_auth=user_custom_auth
|
||||
)
|
||||
if response is not None and isinstance(response, UserAPIKeyAuth):
|
||||
return UserAPIKeyAuth.model_validate(response)
|
||||
validated = UserAPIKeyAuth.model_validate(response)
|
||||
validated = await _run_post_custom_auth_checks(
|
||||
valid_token=validated,
|
||||
request=request,
|
||||
request_data=request_data,
|
||||
route=route,
|
||||
parent_otel_span=parent_otel_span,
|
||||
)
|
||||
return validated
|
||||
elif response is not None and isinstance(response, str):
|
||||
api_key = response
|
||||
custom_auth_api_key = True
|
||||
elif user_custom_auth is not None:
|
||||
response = await user_custom_auth(request=request, api_key=api_key) # type: ignore
|
||||
return UserAPIKeyAuth.model_validate(response)
|
||||
validated = UserAPIKeyAuth.model_validate(response)
|
||||
validated = await _run_post_custom_auth_checks(
|
||||
valid_token=validated,
|
||||
request=request,
|
||||
request_data=request_data,
|
||||
route=route,
|
||||
parent_otel_span=parent_otel_span,
|
||||
)
|
||||
return validated
|
||||
|
||||
### LITELLM-DEFINED AUTH FUNCTION ###
|
||||
#### IF JWT ####
|
||||
|
|
@ -1210,6 +1240,21 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
|||
model=current_model,
|
||||
)
|
||||
|
||||
# Check 5b. End-user model max budget
|
||||
end_user_mmb = valid_token.end_user_model_max_budget
|
||||
if (
|
||||
end_user_mmb is not None
|
||||
and isinstance(end_user_mmb, dict)
|
||||
and len(end_user_mmb) > 0
|
||||
and current_model is not None
|
||||
and valid_token.end_user_id is not None
|
||||
):
|
||||
await model_max_budget_limiter.is_end_user_within_model_budget(
|
||||
end_user_id=valid_token.end_user_id,
|
||||
end_user_model_max_budget=end_user_mmb,
|
||||
model=current_model,
|
||||
)
|
||||
|
||||
# Check 6: Additional Common Checks across jwt + key auth
|
||||
if valid_token.team_id is not None:
|
||||
try:
|
||||
|
|
@ -1501,3 +1546,218 @@ def _update_key_budget_with_temp_budget_increase(
|
|||
temp_budget_increase = _get_temp_budget_increase(valid_token) or 0.0
|
||||
valid_token.max_budget = valid_token.max_budget + temp_budget_increase
|
||||
return valid_token
|
||||
|
||||
|
||||
async def _lookup_end_user_and_apply_budget(
|
||||
valid_token: UserAPIKeyAuth,
|
||||
route: str,
|
||||
parent_otel_span: Optional[Span],
|
||||
prisma_client,
|
||||
user_api_key_cache,
|
||||
proxy_logging_obj,
|
||||
):
|
||||
"""Look up end_user from DB and apply budget limits to valid_token."""
|
||||
end_user_object = None
|
||||
try:
|
||||
end_user_object = await get_end_user_object(
|
||||
end_user_id=valid_token.end_user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
route=route,
|
||||
)
|
||||
if end_user_object is not None:
|
||||
end_user_params = {
|
||||
"end_user_id": valid_token.end_user_id,
|
||||
"allowed_model_region": end_user_object.allowed_model_region,
|
||||
}
|
||||
if end_user_object.litellm_budget_table is not None:
|
||||
_apply_budget_limits_to_end_user_params(
|
||||
end_user_params=end_user_params,
|
||||
budget_info=end_user_object.litellm_budget_table,
|
||||
end_user_id=valid_token.end_user_id,
|
||||
)
|
||||
valid_token = update_valid_token_with_end_user_params(
|
||||
valid_token=valid_token, end_user_params=end_user_params
|
||||
)
|
||||
elif litellm.max_end_user_budget_id is not None:
|
||||
from litellm.proxy.auth.auth_checks import get_default_end_user_budget
|
||||
|
||||
default_budget = await get_default_end_user_budget(
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=parent_otel_span,
|
||||
)
|
||||
if default_budget is not None:
|
||||
end_user_params = {"end_user_id": valid_token.end_user_id}
|
||||
_apply_budget_limits_to_end_user_params(
|
||||
end_user_params=end_user_params,
|
||||
budget_info=default_budget,
|
||||
end_user_id=valid_token.end_user_id,
|
||||
)
|
||||
valid_token = update_valid_token_with_end_user_params(
|
||||
valid_token=valid_token, end_user_params=end_user_params
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, litellm.BudgetExceededError):
|
||||
raise e
|
||||
verbose_proxy_logger.debug(f"Unable to find user in db. Error - {str(e)}")
|
||||
return valid_token, end_user_object
|
||||
|
||||
|
||||
async def _run_post_custom_auth_checks(
|
||||
valid_token: UserAPIKeyAuth,
|
||||
request: Request,
|
||||
request_data: dict,
|
||||
route: str,
|
||||
parent_otel_span: Optional[Span],
|
||||
) -> UserAPIKeyAuth:
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
user_api_key_cache,
|
||||
proxy_logging_obj,
|
||||
general_settings,
|
||||
llm_router,
|
||||
model_max_budget_limiter,
|
||||
)
|
||||
|
||||
# 1. Look up end_user object from DB if end_user_id is set
|
||||
end_user_object = None
|
||||
if valid_token.end_user_id is not None:
|
||||
valid_token, end_user_object = await _lookup_end_user_and_apply_budget(
|
||||
valid_token=valid_token,
|
||||
route=route,
|
||||
parent_otel_span=parent_otel_span,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
# 2. Check token expiry
|
||||
if valid_token.expires is not None:
|
||||
current_time = datetime.now(timezone.utc)
|
||||
if isinstance(valid_token.expires, datetime):
|
||||
expiry_time = valid_token.expires
|
||||
else:
|
||||
expiry_time = datetime.fromisoformat(valid_token.expires)
|
||||
if (
|
||||
expiry_time.tzinfo is None
|
||||
or expiry_time.tzinfo.utcoffset(expiry_time) is None
|
||||
):
|
||||
expiry_time = expiry_time.replace(tzinfo=timezone.utc)
|
||||
if expiry_time < current_time:
|
||||
raise ProxyException(
|
||||
message=f"Authentication Error - Expired Key. Key Expiry time {expiry_time} and current time {current_time}",
|
||||
type=ProxyErrorTypes.expired_key,
|
||||
code=400,
|
||||
param=abbreviate_api_key(api_key=valid_token.token)
|
||||
if valid_token.token
|
||||
else "",
|
||||
)
|
||||
|
||||
current_model = request_data.get("model", None)
|
||||
|
||||
# 3. Check key-level model_max_budget
|
||||
max_budget_per_model = valid_token.model_max_budget
|
||||
if (
|
||||
max_budget_per_model is not None
|
||||
and isinstance(max_budget_per_model, dict)
|
||||
and len(max_budget_per_model) > 0
|
||||
and current_model is not None
|
||||
and valid_token.token is not None
|
||||
):
|
||||
await model_max_budget_limiter.is_key_within_model_budget(
|
||||
user_api_key_dict=valid_token,
|
||||
model=current_model,
|
||||
)
|
||||
|
||||
# 4. Check end-user model_max_budget
|
||||
end_user_mmb = valid_token.end_user_model_max_budget
|
||||
if (
|
||||
end_user_mmb is not None
|
||||
and isinstance(end_user_mmb, dict)
|
||||
and len(end_user_mmb) > 0
|
||||
and current_model is not None
|
||||
and valid_token.end_user_id is not None
|
||||
):
|
||||
await model_max_budget_limiter.is_end_user_within_model_budget(
|
||||
end_user_id=valid_token.end_user_id,
|
||||
end_user_model_max_budget=end_user_mmb,
|
||||
model=current_model,
|
||||
)
|
||||
|
||||
# 5. Look up user object if user_id is set
|
||||
user_object = None
|
||||
if valid_token.user_id is not None:
|
||||
try:
|
||||
user_object = await get_user_object(
|
||||
user_id=valid_token.user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
user_id_upsert=False,
|
||||
parent_otel_span=parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
except Exception:
|
||||
# If user_role is PROXY_ADMIN on the token, create a synthetic user object
|
||||
# so that admin route checks pass for custom auth
|
||||
if valid_token.user_role == LitellmUserRoles.PROXY_ADMIN:
|
||||
user_object = LiteLLM_UserTable(
|
||||
user_id=valid_token.user_id,
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
spend=0.0,
|
||||
)
|
||||
|
||||
# 6. Run common checks
|
||||
if valid_token.team_id is not None:
|
||||
try:
|
||||
_team_obj = await get_team_object(
|
||||
team_id=valid_token.team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
except HTTPException:
|
||||
_team_obj = LiteLLM_TeamTableCachedObj(
|
||||
team_id=valid_token.team_id,
|
||||
max_budget=valid_token.team_max_budget,
|
||||
soft_budget=valid_token.team_soft_budget,
|
||||
spend=valid_token.team_spend,
|
||||
tpm_limit=valid_token.team_tpm_limit,
|
||||
rpm_limit=valid_token.team_rpm_limit,
|
||||
blocked=valid_token.team_blocked,
|
||||
models=valid_token.team_models,
|
||||
metadata=valid_token.team_metadata,
|
||||
object_permission_id=valid_token.team_object_permission_id,
|
||||
)
|
||||
else:
|
||||
_team_obj = None
|
||||
|
||||
_project_obj = None
|
||||
if valid_token.project_id is not None:
|
||||
_project_obj = await get_project_object(
|
||||
project_id=valid_token.project_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
_ = await common_checks(
|
||||
request=request,
|
||||
request_body=request_data,
|
||||
team_object=_team_obj,
|
||||
user_object=user_object,
|
||||
end_user_object=end_user_object,
|
||||
general_settings=general_settings,
|
||||
global_proxy_spend=None,
|
||||
route=route,
|
||||
llm_router=llm_router,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
valid_token=valid_token,
|
||||
skip_budget_checks=False,
|
||||
project_object=_project_obj,
|
||||
)
|
||||
|
||||
return valid_token
|
||||
|
|
|
|||
|
|
@ -143,7 +143,8 @@ def _safe_get_request_headers(request: Optional[Request]) -> dict:
|
|||
"""
|
||||
if request is None:
|
||||
return {}
|
||||
cached = getattr(request.state, "_cached_headers", None)
|
||||
state = getattr(request, "state", None)
|
||||
cached = getattr(state, "_cached_headers", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
|
|
@ -154,7 +155,8 @@ def _safe_get_request_headers(request: Optional[Request]) -> dict:
|
|||
)
|
||||
headers = {}
|
||||
try:
|
||||
request.state._cached_headers = headers
|
||||
if state is not None:
|
||||
state._cached_headers = headers
|
||||
except Exception:
|
||||
pass # request.state may not be available in all contexts
|
||||
return headers
|
||||
|
|
|
|||
|
|
@ -312,7 +312,7 @@ class DBSpendUpdateWriter:
|
|||
prisma_client: Optional[PrismaClient],
|
||||
user_api_key_cache: DualCache,
|
||||
litellm_proxy_budget_name: Optional[str],
|
||||
payload_copy: dict,
|
||||
payload_copy: SpendLogsPayload,
|
||||
request_tags: Optional[Any],
|
||||
):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from datetime import datetime
|
|||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncGenerator,
|
||||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
|
|
@ -26,6 +25,7 @@ from fastapi import HTTPException
|
|||
from litellm.integrations.custom_guardrail import (
|
||||
CustomGuardrail,
|
||||
ModifyResponseException,
|
||||
log_guardrail_information,
|
||||
)
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
|
||||
|
|
@ -37,7 +37,6 @@ from litellm.types.utils import (
|
|||
GenericGuardrailAPIInputs,
|
||||
GuardrailStatus,
|
||||
GuardrailTracingDetail,
|
||||
ModelResponseStream,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -538,6 +537,7 @@ class BlockCodeExecutionGuardrail(CustomGuardrail):
|
|||
detection_info={"language": language},
|
||||
)
|
||||
|
||||
@log_guardrail_information
|
||||
async def apply_guardrail(
|
||||
self,
|
||||
inputs: GenericGuardrailAPIInputs,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import litellm
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import \
|
||||
ContentFilterGuardrail
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
|
||||
ContentFilterGuardrail,
|
||||
)
|
||||
from litellm.types.guardrails import SupportedGuardrailIntegrations
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -46,6 +47,9 @@ def initialize_guardrail(
|
|||
competitor_intent_config=getattr(
|
||||
litellm_params, "competitor_intent_config", None
|
||||
),
|
||||
end_session_after_n_fails=getattr(litellm_params, "end_session_after_n_fails", None),
|
||||
on_violation=getattr(litellm_params, "on_violation", None),
|
||||
realtime_violation_message=getattr(litellm_params, "realtime_violation_message", None),
|
||||
)
|
||||
|
||||
litellm.logging_callback_manager.add_litellm_callback(content_filter_guardrail)
|
||||
|
|
|
|||
|
|
@ -69,10 +69,12 @@ always_block_keywords:
|
|||
severity: "high"
|
||||
|
||||
exceptions:
|
||||
- "explainability"
|
||||
- "improve explainability"
|
||||
- "add explainability"
|
||||
- "interpretability"
|
||||
- "model card"
|
||||
- "audit trail"
|
||||
- "with audit trail"
|
||||
- "add audit trail"
|
||||
- "explain what"
|
||||
- "explain how"
|
||||
- "what is"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from litellm.types.utils import (
|
|||
)
|
||||
|
||||
VIRTUAL_KEY_SPEND_CACHE_KEY_PREFIX = "virtual_key_spend"
|
||||
END_USER_SPEND_CACHE_KEY_PREFIX = "end_user_model_spend"
|
||||
|
||||
|
||||
class _PROXY_VirtualKeyModelMaxBudgetLimiter(RouterBudgetLimiting):
|
||||
|
|
@ -83,6 +84,81 @@ class _PROXY_VirtualKeyModelMaxBudgetLimiter(RouterBudgetLimiting):
|
|||
|
||||
return True
|
||||
|
||||
async def is_end_user_within_model_budget(
|
||||
self,
|
||||
end_user_id: str,
|
||||
end_user_model_max_budget: dict,
|
||||
model: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if the end_user is within the model budget
|
||||
|
||||
Raises:
|
||||
BudgetExceededError: If the end_user has exceeded the model budget
|
||||
"""
|
||||
internal_model_max_budget: GenericBudgetConfigType = {}
|
||||
|
||||
for _model, _budget_info in end_user_model_max_budget.items():
|
||||
internal_model_max_budget[_model] = BudgetConfig(**_budget_info)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
"end_user internal_model_max_budget %s",
|
||||
json.dumps(internal_model_max_budget, indent=4, default=str),
|
||||
)
|
||||
|
||||
# check if current model is in internal_model_max_budget
|
||||
_current_model_budget_info = self._get_request_model_budget_config(
|
||||
model=model, internal_model_max_budget=internal_model_max_budget
|
||||
)
|
||||
if _current_model_budget_info is None:
|
||||
verbose_proxy_logger.debug(
|
||||
f"Model {model} not found in end_user_model_max_budget"
|
||||
)
|
||||
return True
|
||||
|
||||
# check if current model is within budget
|
||||
if (
|
||||
_current_model_budget_info.max_budget
|
||||
and _current_model_budget_info.max_budget > 0
|
||||
):
|
||||
_current_spend = await self._get_end_user_spend_for_model(
|
||||
end_user_id=end_user_id,
|
||||
model=model,
|
||||
key_budget_config=_current_model_budget_info,
|
||||
)
|
||||
if (
|
||||
_current_spend is not None
|
||||
and _current_model_budget_info.max_budget is not None
|
||||
and _current_spend > _current_model_budget_info.max_budget
|
||||
):
|
||||
raise litellm.BudgetExceededError(
|
||||
message=f"LiteLLM End User: {end_user_id}, exceeded budget for model={model}",
|
||||
current_cost=_current_spend,
|
||||
max_budget=_current_model_budget_info.max_budget,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def _get_end_user_spend_for_model(
|
||||
self,
|
||||
end_user_id: str,
|
||||
model: str,
|
||||
key_budget_config: BudgetConfig,
|
||||
) -> Optional[float]:
|
||||
# 1. model: directly look up `model`
|
||||
end_user_model_spend_cache_key = f"{END_USER_SPEND_CACHE_KEY_PREFIX}:{end_user_id}:{model}:{key_budget_config.budget_duration}"
|
||||
_current_spend = await self.dual_cache.async_get_cache(
|
||||
key=end_user_model_spend_cache_key,
|
||||
)
|
||||
|
||||
if _current_spend is None:
|
||||
# 2. If 1, does not exist, check if passed as {custom_llm_provider}/model
|
||||
end_user_model_spend_cache_key = f"{END_USER_SPEND_CACHE_KEY_PREFIX}:{end_user_id}:{self._get_model_without_custom_llm_provider(model)}:{key_budget_config.budget_duration}"
|
||||
_current_spend = await self.dual_cache.async_get_cache(
|
||||
key=end_user_model_spend_cache_key,
|
||||
)
|
||||
return _current_spend
|
||||
|
||||
async def _get_virtual_key_spend_for_model(
|
||||
self,
|
||||
user_api_key_hash: Optional[str],
|
||||
|
|
@ -163,46 +239,77 @@ class _PROXY_VirtualKeyModelMaxBudgetLimiter(RouterBudgetLimiting):
|
|||
user_api_key_model_max_budget: Optional[dict] = _metadata.get(
|
||||
"user_api_key_model_max_budget", None
|
||||
)
|
||||
user_api_key_end_user_model_max_budget: Optional[dict] = _metadata.get(
|
||||
"user_api_key_end_user_model_max_budget", None
|
||||
)
|
||||
if (
|
||||
user_api_key_model_max_budget is None
|
||||
or len(user_api_key_model_max_budget) == 0
|
||||
) and (
|
||||
user_api_key_end_user_model_max_budget is None
|
||||
or len(user_api_key_end_user_model_max_budget) == 0
|
||||
):
|
||||
verbose_proxy_logger.debug(
|
||||
"Not running _PROXY_VirtualKeyModelMaxBudgetLimiter.async_log_success_event because user_api_key_model_max_budget is None or empty. `user_api_key_model_max_budget`=%s",
|
||||
user_api_key_model_max_budget,
|
||||
"Not running _PROXY_VirtualKeyModelMaxBudgetLimiter.async_log_success_event because user_api_key_model_max_budget and user_api_key_end_user_model_max_budget are None or empty."
|
||||
)
|
||||
return
|
||||
|
||||
response_cost: float = standard_logging_payload.get("response_cost", 0)
|
||||
model = standard_logging_payload.get("model")
|
||||
virtual_key = standard_logging_payload.get("metadata", {}).get(
|
||||
"user_api_key_hash"
|
||||
)
|
||||
end_user_id = standard_logging_payload.get(
|
||||
"end_user"
|
||||
) or standard_logging_payload.get("metadata", {}).get(
|
||||
"user_api_key_end_user_id"
|
||||
)
|
||||
|
||||
if virtual_key is None or model is None:
|
||||
if model is None:
|
||||
return
|
||||
|
||||
# Resolve per-model budget config (same logic as is_key_within_model_budget)
|
||||
internal_model_max_budget: GenericBudgetConfigType = {}
|
||||
for _model, _budget_info in user_api_key_model_max_budget.items():
|
||||
internal_model_max_budget[_model] = BudgetConfig(**_budget_info)
|
||||
key_budget_config = self._get_request_model_budget_config(
|
||||
model=model, internal_model_max_budget=internal_model_max_budget
|
||||
)
|
||||
if key_budget_config is None or not key_budget_config.budget_duration:
|
||||
verbose_proxy_logger.debug(
|
||||
"Not incrementing model spend: no budget config or budget_duration for model=%s",
|
||||
model,
|
||||
if (
|
||||
virtual_key is not None
|
||||
and user_api_key_model_max_budget is not None
|
||||
and len(user_api_key_model_max_budget) > 0
|
||||
):
|
||||
internal_model_max_budget: GenericBudgetConfigType = {}
|
||||
for _model, _budget_info in user_api_key_model_max_budget.items():
|
||||
internal_model_max_budget[_model] = BudgetConfig(**_budget_info)
|
||||
key_budget_config = self._get_request_model_budget_config(
|
||||
model=model, internal_model_max_budget=internal_model_max_budget
|
||||
)
|
||||
return
|
||||
if key_budget_config is not None and key_budget_config.budget_duration:
|
||||
virtual_spend_key = f"{VIRTUAL_KEY_SPEND_CACHE_KEY_PREFIX}:{virtual_key}:{model}:{key_budget_config.budget_duration}"
|
||||
virtual_start_time_key = f"virtual_key_budget_start_time:{virtual_key}"
|
||||
await self._increment_spend_for_key(
|
||||
budget_config=key_budget_config,
|
||||
spend_key=virtual_spend_key,
|
||||
start_time_key=virtual_start_time_key,
|
||||
response_cost=response_cost,
|
||||
)
|
||||
|
||||
if (
|
||||
end_user_id is not None
|
||||
and user_api_key_end_user_model_max_budget is not None
|
||||
and len(user_api_key_end_user_model_max_budget) > 0
|
||||
):
|
||||
internal_model_max_budget: GenericBudgetConfigType = {}
|
||||
for _model, _budget_info in user_api_key_end_user_model_max_budget.items():
|
||||
internal_model_max_budget[_model] = BudgetConfig(**_budget_info)
|
||||
key_budget_config = self._get_request_model_budget_config(
|
||||
model=model, internal_model_max_budget=internal_model_max_budget
|
||||
)
|
||||
if key_budget_config is not None and key_budget_config.budget_duration:
|
||||
end_user_spend_key = f"{END_USER_SPEND_CACHE_KEY_PREFIX}:{end_user_id}:{model}:{key_budget_config.budget_duration}"
|
||||
end_user_start_time_key = f"end_user_budget_start_time:{end_user_id}"
|
||||
await self._increment_spend_for_key(
|
||||
budget_config=key_budget_config,
|
||||
spend_key=end_user_spend_key,
|
||||
start_time_key=end_user_start_time_key,
|
||||
response_cost=response_cost,
|
||||
)
|
||||
|
||||
virtual_spend_key = f"{VIRTUAL_KEY_SPEND_CACHE_KEY_PREFIX}:{virtual_key}:{model}:{key_budget_config.budget_duration}"
|
||||
virtual_start_time_key = f"virtual_key_budget_start_time:{virtual_key}"
|
||||
await self._increment_spend_for_key(
|
||||
budget_config=key_budget_config,
|
||||
spend_key=virtual_spend_key,
|
||||
start_time_key=virtual_start_time_key,
|
||||
response_cost=response_cost,
|
||||
)
|
||||
verbose_proxy_logger.debug(
|
||||
"current state of in memory cache %s",
|
||||
json.dumps(
|
||||
|
|
|
|||
|
|
@ -1047,6 +1047,9 @@ async def add_litellm_data_to_request( # noqa: PLR0915
|
|||
data[_metadata_variable_name][
|
||||
"user_api_key_model_max_budget"
|
||||
] = user_api_key_dict.model_max_budget
|
||||
data[_metadata_variable_name][
|
||||
"user_api_key_end_user_model_max_budget"
|
||||
] = user_api_key_dict.end_user_model_max_budget
|
||||
|
||||
# User spend, budget - used by prometheus.py
|
||||
# Follow same pattern as team and API key budgets
|
||||
|
|
|
|||
|
|
@ -364,7 +364,8 @@ async def new_user(
|
|||
- model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
|
||||
- model_tpm_limit: Optional[float] - Model-specific tpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
|
||||
- spend: Optional[float] - Amount spent by user. Default is 0. Will be updated by proxy whenever user is used. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d"), months ("1mo").
|
||||
- team_id: Optional[str] - [DEPRECATED PARAM] The team id of the user. Default is None.
|
||||
- agent_id: Optional[str] - The agent id associated with the user.
|
||||
- team_id: Optional[str] - [DEPRECATED PARAM] The team id of the user. Default is None.
|
||||
- duration: Optional[str] - Duration for the key auto-created on `/user/new`. Default is None.
|
||||
- key_alias: Optional[str] - Alias for the key auto-created on `/user/new`. Default is None.
|
||||
- sso_user_id: Optional[str] - The id of the user in the SSO provider.
|
||||
|
|
@ -1075,7 +1076,8 @@ async def user_update(
|
|||
- model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
|
||||
- model_tpm_limit: Optional[float] - Model-specific tpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
|
||||
- spend: Optional[float] - Amount spent by user. Default is 0. Will be updated by proxy whenever user is used. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d"), months ("1mo").
|
||||
- team_id: Optional[str] - [DEPRECATED PARAM] The team id of the user. Default is None.
|
||||
- agent_id: Optional[str] - The agent id associated with the user.
|
||||
- team_id: Optional[str] - [DEPRECATED PARAM] The team id of the user. Default is None.
|
||||
- duration: Optional[str] - [NOT IMPLEMENTED].
|
||||
- key_alias: Optional[str] - [NOT IMPLEMENTED].
|
||||
- object_permission: Optional[LiteLLM_ObjectPermissionBase] - internal user-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"]}. IF null or {} then no object permission.
|
||||
|
|
|
|||
|
|
@ -1070,6 +1070,7 @@ async def generate_key_fn(
|
|||
- key: Optional[str] - User defined key value. If not set, a 16-digit unique sk-key is created for you.
|
||||
- team_id: Optional[str] - The team id of the key
|
||||
- user_id: Optional[str] - The user id of the key
|
||||
- agent_id: Optional[str] - The agent id associated with the key.
|
||||
- organization_id: Optional[str] - The organization id of the key. If not set, and team_id is set, the organization id will be the same as the team id. If conflict, an error will be raised.
|
||||
- project_id: Optional[str] - The project id of the key. When set, models and max_budget are validated against the project's limits.
|
||||
- budget_id: Optional[str] - The budget id associated with the key. Created by calling `/budget/new`.
|
||||
|
|
@ -1749,6 +1750,7 @@ async def update_key_fn(
|
|||
- key_alias: Optional[str] - User-friendly key alias
|
||||
- user_id: Optional[str] - User ID associated with key
|
||||
- team_id: Optional[str] - Team ID associated with key
|
||||
- agent_id: Optional[str] - The agent id associated with the key.
|
||||
- budget_id: Optional[str] - The budget id associated with the key. Created by calling `/budget/new`.
|
||||
- models: Optional[list] - Model_name's a user is allowed to call
|
||||
- tags: Optional[List[str]] - Tags for organizing keys (Enterprise only)
|
||||
|
|
@ -4107,13 +4109,23 @@ async def list_keys(
|
|||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
@management_endpoint_wrapper
|
||||
async def key_aliases() -> Dict[str, List[str]]:
|
||||
async def key_aliases(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
size: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
search: Optional[str] = Query(
|
||||
None, description="Search key aliases (case-insensitive partial match)"
|
||||
),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Lists all key aliases
|
||||
Lists key aliases with pagination and optional search.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"aliases": List[str]
|
||||
"aliases": List[str],
|
||||
"total_count": int,
|
||||
"current_page": int,
|
||||
"total_pages": int,
|
||||
"size": int,
|
||||
}
|
||||
"""
|
||||
try:
|
||||
|
|
@ -4125,36 +4137,55 @@ async def key_aliases() -> Dict[str, List[str]]:
|
|||
verbose_proxy_logger.error("Database not connected")
|
||||
raise Exception("Database not connected")
|
||||
|
||||
where: Dict[str, Any] = {}
|
||||
try:
|
||||
where.update(_get_condition_to_filter_out_ui_session_tokens())
|
||||
except NameError:
|
||||
# Helper may not exist in some builds; ignore if missing
|
||||
pass
|
||||
# Build a parameterized WHERE clause to avoid loading full rows into
|
||||
# memory. Raw SQL is used because the Prisma client wrapper does not
|
||||
# support column-level SELECT projection on find_many.
|
||||
#
|
||||
# $1 is always UI_SESSION_TOKEN_TEAM_ID (filters out UI session tokens).
|
||||
query_params: List[Any] = [UI_SESSION_TOKEN_TEAM_ID]
|
||||
where_parts = [
|
||||
"key_alias IS NOT NULL",
|
||||
"key_alias != ''",
|
||||
"(team_id IS NULL OR team_id != $1)",
|
||||
]
|
||||
if search:
|
||||
query_params.append(f"%{search}%")
|
||||
where_parts.append(f"key_alias ILIKE ${len(query_params)}")
|
||||
|
||||
rows = await prisma_client.db.litellm_verificationtoken.find_many(
|
||||
where=where,
|
||||
order=[{"key_alias": "asc"}],
|
||||
where_sql = " AND ".join(where_parts)
|
||||
|
||||
count_sql = (
|
||||
f'SELECT COUNT(*) AS count FROM "LiteLLM_VerificationToken" WHERE {where_sql}'
|
||||
)
|
||||
count_rows = await prisma_client.db.query_raw(count_sql, *query_params)
|
||||
total_count = int(count_rows[0]["count"]) if count_rows else 0
|
||||
|
||||
aliases_params = query_params + [size, (page - 1) * size]
|
||||
limit_idx = len(aliases_params) - 1
|
||||
offset_idx = len(aliases_params)
|
||||
aliases_sql = (
|
||||
f"SELECT key_alias"
|
||||
f' FROM "LiteLLM_VerificationToken"'
|
||||
f" WHERE {where_sql}"
|
||||
f" ORDER BY key_alias ASC"
|
||||
f" LIMIT ${limit_idx} OFFSET ${offset_idx}"
|
||||
)
|
||||
alias_rows = await prisma_client.db.query_raw(aliases_sql, *aliases_params)
|
||||
aliases: List[str] = [row["key_alias"] for row in alias_rows if row.get("key_alias")]
|
||||
|
||||
total_pages = -(-total_count // size) if total_count > 0 else 0
|
||||
verbose_proxy_logger.debug(
|
||||
f"key_aliases: page={page}, size={size}, search={search!r}, "
|
||||
f"total_count={total_count}, total_pages={total_pages}"
|
||||
)
|
||||
|
||||
seen = set()
|
||||
aliases: List[str] = []
|
||||
for row in rows:
|
||||
alias = getattr(row, "key_alias", None)
|
||||
if alias is None and isinstance(row, dict):
|
||||
alias = row.get("key_alias")
|
||||
|
||||
if not alias:
|
||||
continue
|
||||
|
||||
alias_str = str(alias).strip()
|
||||
if alias_str and alias_str not in seen:
|
||||
seen.add(alias_str)
|
||||
aliases.append(alias_str)
|
||||
|
||||
verbose_proxy_logger.debug(f"Returning {len(aliases)} key aliases")
|
||||
|
||||
return {"aliases": aliases}
|
||||
return {
|
||||
"aliases": aliases,
|
||||
"total_count": total_count,
|
||||
"current_page": page,
|
||||
"total_pages": total_pages,
|
||||
"size": size,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error in key_aliases: {e}")
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ usage/spend data by querying the aggregated daily activity endpoints.
|
|||
|
||||
import json
|
||||
from datetime import date
|
||||
from typing import Any, AsyncIterator, Callable, Dict, List, Literal, Optional
|
||||
from typing import Any, AsyncIterator, Callable, Dict, List, Literal, Optional, cast
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
|
@ -14,8 +16,6 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
|
|||
SpendAnalyticsPaginatedResponse,
|
||||
)
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -492,17 +492,17 @@ async def _process_tool_call(
|
|||
"tool_label": handler["label"],
|
||||
"arguments": fn_args,
|
||||
}
|
||||
yield _sse({**tool_event_base, "status": "running"})
|
||||
yield _sse(cast(SSEToolCallEvent, {**tool_event_base, "status": "running"}))
|
||||
|
||||
try:
|
||||
tool_result = await _execute_tool_call(
|
||||
handler, fn_name, fn_args, user_id, is_admin
|
||||
)
|
||||
yield _sse({**tool_event_base, "status": "complete"})
|
||||
yield _sse(cast(SSEToolCallEvent, {**tool_event_base, "status": "complete"}))
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error("Tool %s failed: %s", fn_name, e)
|
||||
tool_result = f"Error fetching {handler['label']}. Please try again."
|
||||
yield _sse({**tool_event_base, "status": "error"})
|
||||
yield _sse(cast(SSEToolCallEvent, {**tool_event_base, "status": "error"}))
|
||||
|
||||
chat_messages.append(
|
||||
{"role": "tool", "tool_call_id": tc.id, "content": tool_result}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from datetime import datetime as dt
|
||||
|
|
@ -10,7 +11,10 @@ from pydantic import BaseModel
|
|||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import MAX_STRING_LENGTH_PROMPT_IN_DB, REDACTED_BY_LITELM_STRING
|
||||
from litellm.constants import (
|
||||
MAX_STRING_LENGTH_PROMPT_IN_DB as DEFAULT_MAX_STRING_LENGTH_PROMPT_IN_DB,
|
||||
)
|
||||
from litellm.constants import REDACTED_BY_LITELM_STRING
|
||||
from litellm.litellm_core_utils.core_helpers import (
|
||||
get_litellm_metadata_from_kwargs,
|
||||
reconstruct_model_name,
|
||||
|
|
@ -30,6 +34,20 @@ from litellm.types.utils import (
|
|||
from litellm.utils import get_end_user_id_for_cost_tracking
|
||||
|
||||
|
||||
def _get_max_string_length_prompt_in_db() -> int:
|
||||
"""
|
||||
Resolve prompt truncation threshold at runtime so values loaded later via
|
||||
proxy config environment_variables are honored.
|
||||
"""
|
||||
max_length_str = os.getenv("MAX_STRING_LENGTH_PROMPT_IN_DB")
|
||||
if max_length_str is None:
|
||||
return DEFAULT_MAX_STRING_LENGTH_PROMPT_IN_DB
|
||||
try:
|
||||
return int(max_length_str)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_MAX_STRING_LENGTH_PROMPT_IN_DB
|
||||
|
||||
|
||||
def _is_master_key(api_key: str, _master_key: Optional[str]) -> bool:
|
||||
if _master_key is None:
|
||||
return False
|
||||
|
|
@ -609,6 +627,7 @@ def _get_messages_for_spend_logs_payload(
|
|||
def _sanitize_request_body_for_spend_logs_payload(
|
||||
request_body: dict,
|
||||
visited: Optional[set] = None,
|
||||
max_string_length_prompt_in_db: Optional[int] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Recursively sanitize request body to prevent logging large base64 strings or other large values.
|
||||
|
|
@ -618,6 +637,8 @@ def _sanitize_request_body_for_spend_logs_payload(
|
|||
|
||||
if visited is None:
|
||||
visited = set()
|
||||
if max_string_length_prompt_in_db is None:
|
||||
max_string_length_prompt_in_db = _get_max_string_length_prompt_in_db()
|
||||
|
||||
# Get the object's memory address to track visited objects
|
||||
obj_id = id(request_body)
|
||||
|
|
@ -627,27 +648,29 @@ def _sanitize_request_body_for_spend_logs_payload(
|
|||
|
||||
def _sanitize_value(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return _sanitize_request_body_for_spend_logs_payload(value, visited)
|
||||
return _sanitize_request_body_for_spend_logs_payload(
|
||||
value, visited, max_string_length_prompt_in_db
|
||||
)
|
||||
elif isinstance(value, list):
|
||||
return [_sanitize_value(item) for item in value]
|
||||
elif isinstance(value, str):
|
||||
if len(value) > MAX_STRING_LENGTH_PROMPT_IN_DB:
|
||||
if len(value) > max_string_length_prompt_in_db:
|
||||
# Keep 35% from beginning and 65% from end (end is usually more important)
|
||||
# This split ensures we keep more context from the end of conversations
|
||||
start_ratio = 0.35
|
||||
end_ratio = 0.65
|
||||
|
||||
# Calculate character distribution
|
||||
start_chars = int(MAX_STRING_LENGTH_PROMPT_IN_DB * start_ratio)
|
||||
end_chars = int(MAX_STRING_LENGTH_PROMPT_IN_DB * end_ratio)
|
||||
start_chars = int(max_string_length_prompt_in_db * start_ratio)
|
||||
end_chars = int(max_string_length_prompt_in_db * end_ratio)
|
||||
|
||||
# Ensure we don't exceed the total limit
|
||||
total_keep = start_chars + end_chars
|
||||
if total_keep > MAX_STRING_LENGTH_PROMPT_IN_DB:
|
||||
end_chars = MAX_STRING_LENGTH_PROMPT_IN_DB - start_chars
|
||||
if total_keep > max_string_length_prompt_in_db:
|
||||
end_chars = max_string_length_prompt_in_db - start_chars
|
||||
|
||||
# If the string length is less than what we want to keep, just truncate normally
|
||||
if len(value) <= MAX_STRING_LENGTH_PROMPT_IN_DB:
|
||||
if len(value) <= max_string_length_prompt_in_db:
|
||||
return value
|
||||
|
||||
# Calculate how many characters are being skipped
|
||||
|
|
|
|||
|
|
@ -32,8 +32,17 @@ 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(
|
||||
async def _arealtime( # noqa: PLR0915
|
||||
model: str,
|
||||
websocket: Any, # fastapi websocket
|
||||
api_base: Optional[str] = None,
|
||||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -7053,7 +7053,7 @@ class Router:
|
|||
user_model_info = deployment.get("model_info") or {}
|
||||
|
||||
if model_info is not None:
|
||||
model_info.update(user_model_info)
|
||||
model_info.update(cast(ModelInfo, user_model_info))
|
||||
|
||||
return model_info
|
||||
|
||||
|
|
|
|||
|
|
@ -649,6 +649,21 @@ class BaseLitellmParams(
|
|||
description="Custom message when a guardrail blocks an action. Supports placeholders like {tool_name}, {rule_id}, and {default_message}.",
|
||||
)
|
||||
|
||||
################## Realtime API params ################
|
||||
########################################################
|
||||
end_session_after_n_fails: Optional[int] = Field(
|
||||
default=None,
|
||||
description="For /v1/realtime sessions: automatically close the session after this many guardrail violations.",
|
||||
)
|
||||
on_violation: Optional[Literal["warn", "end_session"]] = Field(
|
||||
default=None,
|
||||
description="For /v1/realtime sessions: 'warn' speaks the violation message and continues; 'end_session' speaks the message and closes the connection.",
|
||||
)
|
||||
realtime_violation_message: Optional[str] = Field(
|
||||
default=None,
|
||||
description="The message the bot speaks aloud when a /v1/realtime guardrail fires. Falls back to violation_message_template if not set.",
|
||||
)
|
||||
|
||||
# Model Armor params
|
||||
template_id: Optional[str] = Field(
|
||||
default=None, description="The ID of your Model Armor template"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from dataclasses import dataclass
|
|||
from enum import Enum
|
||||
from typing import Any, Dict, List, Literal, Optional, Tuple
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing_extensions import Annotated
|
||||
|
||||
import litellm
|
||||
|
|
@ -721,6 +721,13 @@ class UserAPIKeyLabelValues(BaseModel):
|
|||
Optional[str], Field(..., alias=UserAPIKeyLabelNames.STREAM.value)
|
||||
] = None
|
||||
|
||||
@field_validator("stream", mode="before")
|
||||
@classmethod
|
||||
def coerce_stream_to_str(cls, v: Any) -> Optional[str]:
|
||||
if v is None:
|
||||
return None
|
||||
return str(v)
|
||||
|
||||
|
||||
class PrometheusMetricsConfig(BaseModel):
|
||||
"""Configuration for filtering Prometheus metrics"""
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
from typing import Any, Dict, List, Literal, Optional
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from litellm.types.utils import FileTypes
|
||||
|
||||
|
||||
|
|
@ -72,6 +73,8 @@ class VideoCreateOptionalRequestParams(TypedDict, total=False):
|
|||
Params here: https://platform.openai.com/docs/api-reference/videos/create
|
||||
"""
|
||||
input_reference: Optional[FileTypes] # File reference for input image
|
||||
image: Optional[Any] # Image for image-to-video; dict with gcsUri/bytesBase64Encoded, or file-like object
|
||||
parameters: Optional[Dict[str, Any]] # Provider-specific parameters block passed directly to the API
|
||||
model: Optional[str]
|
||||
seconds: Optional[str]
|
||||
size: Optional[str]
|
||||
|
|
|
|||
|
|
@ -3117,6 +3117,7 @@ def get_optional_params_embeddings( # noqa: PLR0915
|
|||
custom_llm_provider="",
|
||||
drop_params: Optional[bool] = None,
|
||||
additional_drop_params: Optional[List[str]] = None,
|
||||
allowed_openai_params: Optional[List[str]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
# Lazy load get_supported_openai_params
|
||||
|
|
@ -3131,6 +3132,7 @@ def get_optional_params_embeddings( # noqa: PLR0915
|
|||
|
||||
drop_params = passed_params.pop("drop_params", None)
|
||||
additional_drop_params = passed_params.pop("additional_drop_params", None)
|
||||
allowed_openai_params = passed_params.pop("allowed_openai_params", None) or []
|
||||
# Remove function objects from passed_params to avoid JSON serialization errors
|
||||
passed_params.pop("get_supported_openai_params", None)
|
||||
|
||||
|
|
@ -3188,11 +3190,11 @@ def get_optional_params_embeddings( # noqa: PLR0915
|
|||
## raise exception if non-default value passed for non-openai/azure embedding calls
|
||||
elif custom_llm_provider == "openai":
|
||||
# 'dimensions` is only supported in `text-embedding-3` and later models
|
||||
|
||||
if (
|
||||
model is not None
|
||||
and "text-embedding-3" not in model
|
||||
and "dimensions" in non_default_params.keys()
|
||||
and "dimensions" not in (allowed_openai_params or [])
|
||||
):
|
||||
raise UnsupportedParamsError(
|
||||
status_code=500,
|
||||
|
|
|
|||
|
|
@ -6222,13 +6222,13 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"azure_ai/mistral-small-2503": {
|
||||
"input_cost_per_token": 1e-06,
|
||||
"input_cost_per_token": 1e-07,
|
||||
"litellm_provider": "azure_ai",
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-06,
|
||||
"output_cost_per_token": 3e-07,
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
|
|
@ -37847,4 +37847,4 @@
|
|||
"notes": "DuckDuckGo Instant Answer API is free and does not require an API key."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "litellm"
|
||||
version = "1.81.15"
|
||||
version = "1.81.16"
|
||||
description = "Library to easily interface with LLM API providers"
|
||||
authors = ["BerriAI"]
|
||||
license = "MIT"
|
||||
|
|
@ -183,7 +183,7 @@ requires = ["poetry-core", "wheel"]
|
|||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.commitizen]
|
||||
version = "1.81.15"
|
||||
version = "1.81.16"
|
||||
version_files = [
|
||||
"pyproject.toml:^version"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ google-cloud-aiplatform: >=1.47.0 # Unknown license
|
|||
mcp: >=1.5.0 # Unknown license
|
||||
google-generativeai: >=0.5.0 # Unknown license
|
||||
async_generator: >=1.10.0 # Unknown license
|
||||
wheel: >=0.40.0 # MIT License - https://github.com/pypa/wheel/blob/main/LICENSE.txt
|
||||
langfuse: >=2.45.0 # Unknown license
|
||||
prometheus_client: >=0.20.0 # Unknown license
|
||||
ddtrace: >=2.19.0 # Unknown license
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ from litellm.proxy.guardrails.guardrail_hooks.presidio import _OPTIONAL_Presidio
|
|||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.types.utils import StandardLoggingPayload, StandardLoggingGuardrailInformation
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.caching.caching import DualCache
|
||||
from typing import Optional
|
||||
|
||||
|
||||
|
|
@ -64,9 +66,13 @@ async def test_standard_logging_payload_includes_guardrail_information():
|
|||
|
||||
# Create mock response objects
|
||||
mock_analyze_resp = MagicMock()
|
||||
mock_analyze_resp.status = 200
|
||||
mock_analyze_resp.content_type = "application/json"
|
||||
mock_analyze_resp.json = AsyncMock(return_value=mock_analyze_response)
|
||||
|
||||
|
||||
mock_anonymize_resp = MagicMock()
|
||||
mock_anonymize_resp.status = 200
|
||||
mock_anonymize_resp.content_type = "application/json"
|
||||
mock_anonymize_resp.json = AsyncMock(return_value=mock_anonymize_response)
|
||||
|
||||
# Mock the aiohttp ClientSession with global call tracking
|
||||
|
|
@ -85,7 +91,7 @@ async def test_standard_logging_payload_includes_guardrail_information():
|
|||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
def post(self, url, json=None):
|
||||
def post(self, url, json=None, **kwargs):
|
||||
class MockResponse:
|
||||
def __init__(self, response_obj):
|
||||
self.response_obj = response_obj
|
||||
|
|
@ -116,8 +122,8 @@ async def test_standard_logging_payload_includes_guardrail_information():
|
|||
|
||||
with patch("aiohttp.ClientSession", MockClientSession):
|
||||
await presidio_guard.async_pre_call_hook(
|
||||
user_api_key_dict={},
|
||||
cache=None,
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
cache=DualCache(),
|
||||
data=request_data,
|
||||
call_type="acompletion"
|
||||
)
|
||||
|
|
@ -136,11 +142,11 @@ async def test_standard_logging_payload_includes_guardrail_information():
|
|||
assert len(test_custom_logger.standard_logging_payload["guardrail_information"]) > 0
|
||||
|
||||
guardrail_info = test_custom_logger.standard_logging_payload["guardrail_information"][0]
|
||||
assert guardrail_info["guardrail_name"] == "presidio_guard"
|
||||
assert guardrail_info["guardrail_mode"] == GuardrailEventHooks.pre_call
|
||||
assert guardrail_info.get("guardrail_name") == "presidio_guard"
|
||||
assert guardrail_info.get("guardrail_mode") == GuardrailEventHooks.pre_call
|
||||
|
||||
# assert that the guardrail_response is a response from presidio analyze
|
||||
presidio_response = guardrail_info["guardrail_response"]
|
||||
presidio_response = guardrail_info.get("guardrail_response")
|
||||
assert isinstance(presidio_response, list)
|
||||
for response_item in presidio_response:
|
||||
assert "analysis_explanation" in response_item
|
||||
|
|
@ -150,12 +156,14 @@ async def test_standard_logging_payload_includes_guardrail_information():
|
|||
assert "entity_type" in response_item
|
||||
|
||||
# assert that the duration is not None
|
||||
assert guardrail_info["duration"] is not None
|
||||
assert guardrail_info["duration"] > 0
|
||||
duration = guardrail_info.get("duration")
|
||||
assert duration is not None
|
||||
assert duration > 0
|
||||
|
||||
# assert that we get the count of masked entities
|
||||
assert guardrail_info["masked_entity_count"] is not None
|
||||
assert guardrail_info["masked_entity_count"]["PHONE_NUMBER"] == 1
|
||||
masked_entity_count = guardrail_info.get("masked_entity_count")
|
||||
assert masked_entity_count is not None
|
||||
assert masked_entity_count["PHONE_NUMBER"] == 1
|
||||
|
||||
|
||||
|
||||
|
|
@ -201,8 +209,8 @@ async def test_langfuse_trace_includes_guardrail_information():
|
|||
"metadata": {},
|
||||
}
|
||||
await presidio_guard.async_pre_call_hook(
|
||||
user_api_key_dict={},
|
||||
cache=None,
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
cache=DualCache(),
|
||||
data=request_data,
|
||||
call_type="acompletion"
|
||||
)
|
||||
|
|
@ -310,7 +318,7 @@ async def test_bedrock_guardrail_status_blocked():
|
|||
try:
|
||||
await bedrock_guard.async_pre_call_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
cache=None,
|
||||
cache=DualCache(),
|
||||
data=request_data,
|
||||
call_type="completion"
|
||||
)
|
||||
|
|
@ -331,8 +339,8 @@ async def test_bedrock_guardrail_status_blocked():
|
|||
|
||||
# Verify guardrail information fields (guardrail_information is now a list)
|
||||
guardrail_info = test_custom_logger.standard_logging_payload["guardrail_information"][0]
|
||||
assert guardrail_info["guardrail_status"] == "guardrail_intervened"
|
||||
assert guardrail_info["guardrail_provider"] == "bedrock"
|
||||
assert guardrail_info.get("guardrail_status") == "guardrail_intervened"
|
||||
assert guardrail_info.get("guardrail_provider") == "bedrock"
|
||||
|
||||
# Verify the new typed status fields
|
||||
# guardrail_status should be "guardrail_intervened" when content is blocked
|
||||
|
|
@ -395,7 +403,7 @@ async def test_bedrock_guardrail_status_success():
|
|||
with patch.object(bedrock_guard, 'should_run_guardrail', return_value=True):
|
||||
await bedrock_guard.async_pre_call_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
cache=None,
|
||||
cache=DualCache(),
|
||||
data=request_data,
|
||||
call_type="completion"
|
||||
)
|
||||
|
|
@ -411,8 +419,8 @@ async def test_bedrock_guardrail_status_success():
|
|||
assert len(test_custom_logger.standard_logging_payload["guardrail_information"]) > 0
|
||||
|
||||
guardrail_info = test_custom_logger.standard_logging_payload["guardrail_information"][0]
|
||||
assert guardrail_info["guardrail_status"] == "success"
|
||||
assert guardrail_info["guardrail_provider"] == "bedrock"
|
||||
assert guardrail_info.get("guardrail_status") == "success"
|
||||
assert guardrail_info.get("guardrail_provider") == "bedrock"
|
||||
|
||||
# Check status fields
|
||||
status_fields = test_custom_logger.standard_logging_payload.get("status_fields", {})
|
||||
|
|
@ -469,7 +477,7 @@ async def test_bedrock_guardrail_status_failure():
|
|||
try:
|
||||
await bedrock_guard.async_pre_call_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
cache=None,
|
||||
cache=DualCache(),
|
||||
data=request_data,
|
||||
call_type="completion"
|
||||
)
|
||||
|
|
@ -488,8 +496,8 @@ async def test_bedrock_guardrail_status_failure():
|
|||
assert len(test_custom_logger.standard_logging_payload["guardrail_information"]) > 0
|
||||
|
||||
guardrail_info = test_custom_logger.standard_logging_payload["guardrail_information"][0]
|
||||
assert guardrail_info["guardrail_status"] == "guardrail_failed_to_respond"
|
||||
assert guardrail_info["guardrail_provider"] == "bedrock"
|
||||
assert guardrail_info.get("guardrail_status") == "guardrail_failed_to_respond"
|
||||
assert guardrail_info.get("guardrail_provider") == "bedrock"
|
||||
|
||||
# Check status fields
|
||||
status_fields = test_custom_logger.standard_logging_payload.get("status_fields", {})
|
||||
|
|
@ -554,7 +562,7 @@ async def test_noma_guardrail_status_blocked():
|
|||
try:
|
||||
await noma_guard.async_pre_call_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
cache=None,
|
||||
cache=DualCache(),
|
||||
data=request_data,
|
||||
call_type="completion"
|
||||
)
|
||||
|
|
@ -572,8 +580,8 @@ async def test_noma_guardrail_status_blocked():
|
|||
assert len(test_custom_logger.standard_logging_payload["guardrail_information"]) > 0
|
||||
|
||||
guardrail_info = test_custom_logger.standard_logging_payload["guardrail_information"][0]
|
||||
assert guardrail_info["guardrail_status"] == "guardrail_intervened"
|
||||
assert guardrail_info["guardrail_provider"] == "noma"
|
||||
assert guardrail_info.get("guardrail_status") == "guardrail_intervened"
|
||||
assert guardrail_info.get("guardrail_provider") == "noma"
|
||||
|
||||
# Check status fields
|
||||
status_fields = test_custom_logger.standard_logging_payload.get("status_fields", {})
|
||||
|
|
@ -632,7 +640,7 @@ async def test_noma_guardrail_status_success():
|
|||
with patch.object(noma_guard, 'should_run_guardrail', return_value=True):
|
||||
await noma_guard.async_pre_call_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
cache=None,
|
||||
cache=DualCache(),
|
||||
data=request_data,
|
||||
call_type="completion"
|
||||
)
|
||||
|
|
@ -648,8 +656,8 @@ async def test_noma_guardrail_status_success():
|
|||
assert len(test_custom_logger.standard_logging_payload["guardrail_information"]) > 0
|
||||
|
||||
guardrail_info = test_custom_logger.standard_logging_payload["guardrail_information"][0]
|
||||
assert guardrail_info["guardrail_status"] == "success"
|
||||
assert guardrail_info["guardrail_provider"] == "noma"
|
||||
assert guardrail_info.get("guardrail_status") == "success"
|
||||
assert guardrail_info.get("guardrail_provider") == "noma"
|
||||
|
||||
# Check status fields
|
||||
status_fields = test_custom_logger.standard_logging_payload.get("status_fields", {})
|
||||
|
|
@ -679,8 +687,8 @@ def test_guardrail_status_fields_computation():
|
|||
guardrail_information=intervened_info,
|
||||
error_str=None
|
||||
)
|
||||
assert status_fields_intervened["llm_api_status"] == "success"
|
||||
assert status_fields_intervened["guardrail_status"] == "guardrail_intervened"
|
||||
assert status_fields_intervened.get("llm_api_status") == "success"
|
||||
assert status_fields_intervened.get("guardrail_status") == "guardrail_intervened"
|
||||
|
||||
# Test legacy blocked status (for backward compatibility)
|
||||
blocked_info = [{"guardrail_status": "blocked"}]
|
||||
|
|
@ -689,8 +697,8 @@ def test_guardrail_status_fields_computation():
|
|||
guardrail_information=blocked_info,
|
||||
error_str=None
|
||||
)
|
||||
assert status_fields_blocked["llm_api_status"] == "success"
|
||||
assert status_fields_blocked["guardrail_status"] == "guardrail_intervened"
|
||||
assert status_fields_blocked.get("llm_api_status") == "success"
|
||||
assert status_fields_blocked.get("guardrail_status") == "guardrail_intervened"
|
||||
|
||||
# Test success status
|
||||
success_info = [{"guardrail_status": "success"}]
|
||||
|
|
@ -699,8 +707,8 @@ def test_guardrail_status_fields_computation():
|
|||
guardrail_information=success_info,
|
||||
error_str=None
|
||||
)
|
||||
assert status_fields_success["llm_api_status"] == "success"
|
||||
assert status_fields_success["guardrail_status"] == "success"
|
||||
assert status_fields_success.get("llm_api_status") == "success"
|
||||
assert status_fields_success.get("guardrail_status") == "success"
|
||||
|
||||
# Test guardrail_failed_to_respond status
|
||||
failed_info = [{"guardrail_status": "guardrail_failed_to_respond"}]
|
||||
|
|
@ -709,8 +717,8 @@ def test_guardrail_status_fields_computation():
|
|||
guardrail_information=failed_info,
|
||||
error_str=None
|
||||
)
|
||||
assert status_fields_failed["llm_api_status"] == "failure"
|
||||
assert status_fields_failed["guardrail_status"] == "guardrail_failed_to_respond"
|
||||
assert status_fields_failed.get("llm_api_status") == "failure"
|
||||
assert status_fields_failed.get("guardrail_status") == "guardrail_failed_to_respond"
|
||||
|
||||
# Test legacy failure status (for backward compatibility)
|
||||
failure_info = [{"guardrail_status": "failure"}]
|
||||
|
|
@ -719,8 +727,8 @@ def test_guardrail_status_fields_computation():
|
|||
guardrail_information=failure_info,
|
||||
error_str=None
|
||||
)
|
||||
assert status_fields_failure["llm_api_status"] == "failure"
|
||||
assert status_fields_failure["guardrail_status"] == "guardrail_failed_to_respond"
|
||||
assert status_fields_failure.get("llm_api_status") == "failure"
|
||||
assert status_fields_failure.get("guardrail_status") == "guardrail_failed_to_respond"
|
||||
|
||||
# Test no guardrail run
|
||||
no_guardrail = None
|
||||
|
|
@ -729,5 +737,5 @@ def test_guardrail_status_fields_computation():
|
|||
guardrail_information=no_guardrail,
|
||||
error_str=None
|
||||
)
|
||||
assert status_fields_no_guardrail["llm_api_status"] == "success"
|
||||
assert status_fields_no_guardrail["guardrail_status"] == "not_run"
|
||||
assert status_fields_no_guardrail.get("llm_api_status") == "success"
|
||||
assert status_fields_no_guardrail.get("guardrail_status") == "not_run"
|
||||
|
|
@ -497,7 +497,7 @@ async def test_perform_health_check_filters_by_model_id():
|
|||
|
||||
captured_list = []
|
||||
|
||||
async def mock_perform_health_check(m_list, details=True):
|
||||
async def mock_perform_health_check(m_list, details=True, **kwargs):
|
||||
captured_list.append(m_list)
|
||||
return [{"model": "gpt-4", "api_key": m_list[0]["litellm_params"]["api_key"]}], []
|
||||
|
||||
|
|
|
|||
|
|
@ -69,3 +69,40 @@ def test_bedrock_embed_v2_with_drop_params():
|
|||
)
|
||||
print(f"received optional_params: {optional_params}")
|
||||
assert optional_params == {"dimensions": 512, "embeddingTypes": ["binary"]}
|
||||
|
||||
|
||||
def test_openai_non_text_embedding_3_with_allowed_openai_params():
|
||||
"""
|
||||
Test that `dimensions` is allowed for non-text-embedding-3 OpenAI models
|
||||
when `allowed_openai_params=["dimensions"]` is passed. Without this flag,
|
||||
an UnsupportedParamsError would be raised.
|
||||
"""
|
||||
model, custom_llm_provider, _, _ = get_llm_provider(
|
||||
model="openai/nvidia/llama-3.2-nv-embedqa-1b-v2"
|
||||
)
|
||||
optional_params = get_optional_params_embeddings(
|
||||
model=model,
|
||||
dimensions=1024,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
allowed_openai_params=["dimensions"],
|
||||
)
|
||||
print(f"received optional_params: {optional_params}")
|
||||
assert optional_params.get("dimensions") == 1024
|
||||
|
||||
|
||||
def test_openai_non_text_embedding_3_without_allowed_openai_params_raises():
|
||||
"""
|
||||
Test that passing `dimensions` to a non-text-embedding-3 OpenAI model
|
||||
without `allowed_openai_params` still raises UnsupportedParamsError.
|
||||
"""
|
||||
from litellm.exceptions import UnsupportedParamsError
|
||||
|
||||
model, custom_llm_provider, _, _ = get_llm_provider(
|
||||
model="openai/nvidia/llama-3.2-nv-embedqa-1b-v2"
|
||||
)
|
||||
with pytest.raises(UnsupportedParamsError):
|
||||
get_optional_params_embeddings(
|
||||
model=model,
|
||||
dimensions=1024,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"model": "BAAI/bge-small-en-v1.5",
|
||||
"input": ["Hello from litellm!"]
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ ignored_keys = [
|
|||
"endTime",
|
||||
"completionStartTime",
|
||||
"endTime",
|
||||
"request_duration_ms",
|
||||
"metadata.model_map_information",
|
||||
"metadata.usage_object",
|
||||
"metadata.cold_storage_object_key",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
sys.path.insert(0, os.path.abspath("../.."))
|
||||
|
|
@ -14,6 +16,7 @@ sys.path.insert(0, os.path.abspath("../.."))
|
|||
import litellm
|
||||
from litellm import completion
|
||||
from litellm.caching import InMemoryCache
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
|
||||
litellm.num_retries = 3
|
||||
litellm.success_callback = ["langfuse"]
|
||||
|
|
@ -445,6 +448,57 @@ class TestLangfuseLogging:
|
|||
setup["mock_post"], "completion_with_vertex_call.json", setup["trace_id"]
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_logging_vllm_embedding(self, mock_setup):
|
||||
"""
|
||||
Test that the request sent to the vllm embedding endpoint is correct.
|
||||
|
||||
Verifies the request body matches the expected JSON fixture,
|
||||
including that the hosted_vllm/ prefix is stripped from the model name
|
||||
and that no unexpected fields (e.g. encoding_format) are included.
|
||||
"""
|
||||
setup = mock_setup
|
||||
|
||||
vllm_response_data = {
|
||||
"object": "list",
|
||||
"data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2, 0.3]}],
|
||||
"model": "BAAI/bge-small-en-v1.5",
|
||||
"usage": {"prompt_tokens": 10, "total_tokens": 10},
|
||||
}
|
||||
mock_vllm_response = httpx.Response(
|
||||
status_code=200,
|
||||
json=vllm_response_data,
|
||||
)
|
||||
|
||||
mock_async_client = AsyncHTTPHandler()
|
||||
mock_async_client.post = AsyncMock(return_value=mock_vllm_response)
|
||||
|
||||
with patch("httpx.Client.post", setup["mock_post"]):
|
||||
await litellm.aembedding(
|
||||
model="hosted_vllm/BAAI/bge-small-en-v1.5",
|
||||
input=["Hello from litellm!"],
|
||||
api_base="http://my-fake-vllm.com/v1",
|
||||
metadata={"trace_id": setup["trace_id"]},
|
||||
client=mock_async_client,
|
||||
)
|
||||
|
||||
# Verify the request sent to vllm matches the expected JSON fixture
|
||||
assert mock_async_client.post.call_count == 1
|
||||
actual_vllm_request = mock_async_client.post.call_args.kwargs["json"]
|
||||
|
||||
pwd = os.path.dirname(os.path.realpath(__file__))
|
||||
expected_body_path = os.path.join(
|
||||
pwd, "langfuse_expected_request_body", "embedding_with_vllm.json"
|
||||
)
|
||||
with open(expected_body_path, "r") as f:
|
||||
expected_vllm_request = json.load(f)
|
||||
|
||||
assert actual_vllm_request == expected_vllm_request, (
|
||||
f"vllm request body mismatch:\n"
|
||||
f"actual: {json.dumps(actual_vllm_request, indent=2)}\n"
|
||||
f"expected: {json.dumps(expected_vllm_request, indent=2)}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_logging_with_router(self, mock_setup):
|
||||
"""Test Langfuse logging with router"""
|
||||
|
|
|
|||
|
|
@ -258,57 +258,61 @@ def validate_redacted_message_span_attributes(span):
|
|||
pass
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arize_phoenix_adds_openinference_kind_and_avoids_duplicate_litellm_spans():
|
||||
async def test_arize_phoenix_creates_nested_spans_on_dedicated_provider():
|
||||
"""
|
||||
Ensure Arize Phoenix spans include OpenInference span kind and do not create
|
||||
a duplicate litellm_request span when a proxy parent span is already active.
|
||||
ArizePhoenixLogger creates its own dedicated TracerProvider so it can
|
||||
coexist with the generic ``otel`` callback. In proxy mode it creates a
|
||||
``litellm_proxy_request`` parent span and a ``litellm_request`` child span
|
||||
on its *own* provider — completely independent of the global provider.
|
||||
|
||||
This test verifies:
|
||||
1. Phoenix creates both parent and child spans on its dedicated exporter.
|
||||
2. The spans form a proper parent-child hierarchy (same trace ID).
|
||||
3. A raw_gen_ai_request sub-span is also produced.
|
||||
"""
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
|
||||
exporter.clear()
|
||||
phoenix_exporter = InMemorySpanExporter()
|
||||
|
||||
litellm.logging_callback_manager._reset_all_callbacks()
|
||||
|
||||
# Set up a global TracerProvider so we can create valid spans
|
||||
# This simulates the proxy server's TracerProvider
|
||||
global_provider = TracerProvider()
|
||||
global_provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
trace.set_tracer_provider(global_provider)
|
||||
# ArizePhoenixLogger builds its own TracerProvider internally.
|
||||
# We pass our in-memory exporter so we can inspect spans.
|
||||
phoenix_logger = ArizePhoenixLogger(
|
||||
config=OpenTelemetryConfig(exporter=phoenix_exporter),
|
||||
callback_name="arize_phoenix",
|
||||
)
|
||||
|
||||
otel_logger = ArizePhoenixLogger(config=OpenTelemetryConfig(exporter=exporter))
|
||||
litellm.callbacks = [otel_logger]
|
||||
litellm.callbacks = [phoenix_logger]
|
||||
litellm.success_callback = []
|
||||
litellm.failure_callback = []
|
||||
|
||||
tracer = trace.get_tracer(LITELLM_TRACER_NAME)
|
||||
parent_span = tracer.start_span(LITELLM_PROXY_REQUEST_SPAN_NAME)
|
||||
# Simulate a proxy request by injecting proxy_server_request as a top-level kwarg.
|
||||
# This triggers ArizePhoenixLogger._get_phoenix_context to create its own parent span.
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
mock_response="pong",
|
||||
proxy_server_request={"url": "/chat/completions", "method": "POST", "headers": {}},
|
||||
)
|
||||
|
||||
# Keep parent span active; OpenTelemetry logger will attach attributes and end it.
|
||||
with trace.use_span(parent_span, end_on_exit=False):
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
mock_response="pong",
|
||||
)
|
||||
|
||||
# Flush span processing
|
||||
# Flush async span processing
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if parent_span.is_recording():
|
||||
parent_span.end()
|
||||
spans = phoenix_exporter.get_finished_spans()
|
||||
span_names = [s.name for s in spans]
|
||||
|
||||
spans = exporter.get_finished_spans()
|
||||
# Phoenix creates its own span names on its dedicated TracerProvider:
|
||||
# - "litellm_proxy_request" (parent) — created by _get_phoenix_context
|
||||
# - "litellm_request" (child) — the LLM call span
|
||||
# - "raw_gen_ai_request" — raw request sub-span
|
||||
assert "litellm_proxy_request" in span_names, f"Expected proxy parent span, got: {span_names}"
|
||||
assert LITELLM_REQUEST_SPAN_NAME in span_names, f"Expected request child span, got: {span_names}"
|
||||
assert RAW_REQUEST_SPAN_NAME in span_names, f"Expected raw request span, got: {span_names}"
|
||||
|
||||
span_names = [span.name for span in spans]
|
||||
assert LITELLM_REQUEST_SPAN_NAME not in span_names
|
||||
assert span_names.count(LITELLM_PROXY_REQUEST_SPAN_NAME) == 1
|
||||
assert span_names.count(RAW_REQUEST_SPAN_NAME) == 1
|
||||
# All spans should share the same trace ID (proper hierarchy)
|
||||
trace_ids = {s.context.trace_id for s in spans}
|
||||
assert len(trace_ids) == 1, f"Expected single trace, got {len(trace_ids)} traces"
|
||||
|
||||
# All spans should belong to the same trace (parent + raw child)
|
||||
assert len({span.context.trace_id for span in spans}) == 1
|
||||
assert len(spans) == 2
|
||||
|
||||
proxy_span = next(span for span in spans if span.name == LITELLM_PROXY_REQUEST_SPAN_NAME)
|
||||
assert proxy_span.attributes.get(OISpanAttributes.OPENINFERENCE_SPAN_KIND) == OpenInferenceSpanKindValues.LLM.value
|
||||
|
||||
exporter.clear()
|
||||
phoenix_exporter.clear()
|
||||
|
|
|
|||
|
|
@ -234,3 +234,62 @@ class TestProxyMcpSimpleConnections:
|
|||
)
|
||||
assert stdio_result == "5"
|
||||
assert streamable_result == "9"
|
||||
|
||||
|
||||
class TestProxyMcpStatelessBehavior:
|
||||
"""
|
||||
Verify that the LiteLLM MCP proxy operates in stateless mode.
|
||||
|
||||
When StreamableHTTPSessionManager is configured with stateless=True,
|
||||
independent clients must be able to connect, list tools, and call tools
|
||||
without sharing or inheriting session state from other clients.
|
||||
|
||||
With stateless=False this fails because the server tracks sessions and
|
||||
expects clients to supply an mcp-session-id header obtained from a
|
||||
prior handshake — breaking clients that don't manage session IDs.
|
||||
|
||||
Regression test for https://github.com/BerriAI/litellm/issues/20242
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_independent_clients_no_shared_session(
|
||||
self, proxy_server_url: str
|
||||
) -> None:
|
||||
"""Two independent clients connect and operate without sharing session state."""
|
||||
async with asyncio.timeout(30):
|
||||
# --- Client A: connect, initialize, call tool ---
|
||||
async with streamablehttp_client(
|
||||
url=f"{proxy_server_url}/mcp",
|
||||
headers={
|
||||
"Authorization": PROXY_AUTHORIZATION_HEADER,
|
||||
"x-mcp-servers": "math_stdio",
|
||||
},
|
||||
) as (read_a, write_a, _get_sid_a):
|
||||
async with ClientSession(read_a, write_a) as session_a:
|
||||
await session_a.initialize()
|
||||
result_a = await session_a.call_tool(
|
||||
"add", arguments={"a": 10, "b": 20}
|
||||
)
|
||||
assert result_a.content
|
||||
text_a = getattr(result_a.content[0], "text", None)
|
||||
assert text_a == "30"
|
||||
|
||||
# --- Client B: completely independent connection ---
|
||||
async with streamablehttp_client(
|
||||
url=f"{proxy_server_url}/mcp",
|
||||
headers={
|
||||
"Authorization": PROXY_AUTHORIZATION_HEADER,
|
||||
"x-mcp-servers": "math_stdio",
|
||||
},
|
||||
) as (read_b, write_b, _get_sid_b):
|
||||
async with ClientSession(read_b, write_b) as session_b:
|
||||
await session_b.initialize()
|
||||
tools = await session_b.list_tools()
|
||||
assert any(t.name.endswith("add") for t in tools.tools)
|
||||
result_b = await session_b.call_tool(
|
||||
"add", arguments={"a": 100, "b": 200}
|
||||
)
|
||||
assert result_b.content
|
||||
text_b = getattr(result_b.content[0], "text", None)
|
||||
assert text_b == "300"
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ def mock_request():
|
|||
self.request_body = request_body or {}
|
||||
# Add url attribute that the actual code expects
|
||||
self.url = "http://localhost:8000/test"
|
||||
# Add state attribute that FastAPI requests have
|
||||
self.state = type("State", (), {})()
|
||||
|
||||
async def body(self) -> bytes:
|
||||
return bytes(json.dumps(self.request_body), "utf-8")
|
||||
|
|
|
|||
|
|
@ -3668,9 +3668,10 @@ async def test_list_keys(prisma_client):
|
|||
async def test_key_aliases(prisma_client):
|
||||
"""
|
||||
Test the key_aliases function:
|
||||
- Returns a list
|
||||
- Returns a paginated response
|
||||
- Includes alias from a newly created key
|
||||
- Aliases are unique and sorted
|
||||
- Aliases are sorted
|
||||
- Pagination and search params work correctly
|
||||
"""
|
||||
import asyncio
|
||||
import uuid
|
||||
|
|
@ -3682,10 +3683,16 @@ async def test_key_aliases(prisma_client):
|
|||
setattr(litellm.proxy.proxy_server, "master_key", "sk-1234")
|
||||
await litellm.proxy.proxy_server.prisma_client.connect()
|
||||
|
||||
# Basic call
|
||||
response = await key_aliases()
|
||||
# Basic call - check pagination response shape
|
||||
response = await key_aliases(page=1, size=50)
|
||||
assert "aliases" in response
|
||||
assert isinstance(response["aliases"], list)
|
||||
assert "total_count" in response
|
||||
assert "current_page" in response
|
||||
assert "total_pages" in response
|
||||
assert "size" in response
|
||||
assert response["current_page"] == 1
|
||||
assert response["size"] == 50
|
||||
|
||||
# Create a new user (and key) with a unique alias
|
||||
unique_id = str(uuid.uuid4())
|
||||
|
|
@ -3704,17 +3711,22 @@ async def test_key_aliases(prisma_client):
|
|||
# Allow async DB writes to settle
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Call again and validate
|
||||
response_after = await key_aliases()
|
||||
# Call again and validate alias is present
|
||||
response_after = await key_aliases(page=1, size=50)
|
||||
aliases = response_after["aliases"]
|
||||
|
||||
# Contains the new alias
|
||||
assert test_alias in aliases
|
||||
|
||||
# Unique & sorted (endpoint dedupes and orders ascending)
|
||||
assert len(aliases) == len(set(aliases))
|
||||
assert aliases == sorted(aliases)
|
||||
|
||||
# Search by partial alias
|
||||
partial = test_alias[:10]
|
||||
search_response = await key_aliases(page=1, size=50, search=partial)
|
||||
assert test_alias in search_response["aliases"]
|
||||
|
||||
# Search with no match
|
||||
no_match_response = await key_aliases(page=1, size=50, search="__no_match_xyz__")
|
||||
assert len(no_match_response["aliases"]) == 0
|
||||
assert no_match_response["total_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires reliable external DB connection (prisma).")
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -158,3 +158,109 @@ async def test_async_log_success_event_uses_per_model_budget_duration(budget_lim
|
|||
f"{VIRTUAL_KEY_SPEND_CACHE_KEY_PREFIX}:{virtual_key}:{model}:{budget_duration}"
|
||||
)
|
||||
assert call_kwargs["response_cost"] == 0.05
|
||||
|
||||
|
||||
# Test is_end_user_within_model_budget
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_end_user_within_model_budget(budget_limiter):
|
||||
# Test when model is within budget
|
||||
with patch.object(
|
||||
budget_limiter, "_get_end_user_spend_for_model", return_value=50.0
|
||||
):
|
||||
assert (
|
||||
await budget_limiter.is_end_user_within_model_budget(
|
||||
"test-user",
|
||||
{"gpt-4": {"budget_limit": 100.0, "time_period": "1d"}},
|
||||
"gpt-4",
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
# Test when model exceeds budget
|
||||
with patch.object(
|
||||
budget_limiter, "_get_end_user_spend_for_model", return_value=150.0
|
||||
):
|
||||
with pytest.raises(litellm.BudgetExceededError):
|
||||
await budget_limiter.is_end_user_within_model_budget(
|
||||
"test-user",
|
||||
{"gpt-4": {"budget_limit": 100.0, "time_period": "1d"}},
|
||||
"gpt-4",
|
||||
)
|
||||
|
||||
# Test model not in budget config
|
||||
assert (
|
||||
await budget_limiter.is_end_user_within_model_budget(
|
||||
"test-user",
|
||||
{"gpt-4": {"budget_limit": 100.0, "time_period": "1d"}},
|
||||
"non-existent",
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
# Test _get_end_user_spend_for_model
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_end_user_spend_for_model(budget_limiter):
|
||||
budget_config = GenericBudgetInfo(budget_limit=100.0, time_period="1d")
|
||||
|
||||
# Mock cache get
|
||||
with patch.object(budget_limiter.dual_cache, "async_get_cache", return_value=50.0):
|
||||
spend = await budget_limiter._get_end_user_spend_for_model(
|
||||
end_user_id="test-user", model="gpt-4", key_budget_config=budget_config
|
||||
)
|
||||
assert spend == 50.0
|
||||
|
||||
# Test with provider prefix
|
||||
spend = await budget_limiter._get_end_user_spend_for_model(
|
||||
end_user_id="test-user",
|
||||
model="openai/gpt-4",
|
||||
key_budget_config=budget_config,
|
||||
)
|
||||
assert spend == 50.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_log_success_event_uses_end_user_model_budget_duration(
|
||||
budget_limiter,
|
||||
):
|
||||
"""
|
||||
async_log_success_event must use the per-model budget_duration for the end user cache key
|
||||
"""
|
||||
from litellm.proxy.hooks.model_max_budget_limiter import (
|
||||
END_USER_SPEND_CACHE_KEY_PREFIX,
|
||||
)
|
||||
|
||||
end_user_id = "test-user"
|
||||
model = "gpt-4"
|
||||
budget_duration = "1d"
|
||||
user_api_key_end_user_model_max_budget = {
|
||||
model: {"budget_limit": 100.0, "time_period": budget_duration},
|
||||
}
|
||||
kwargs = {
|
||||
"standard_logging_object": {
|
||||
"response_cost": 0.05,
|
||||
"model": model,
|
||||
"end_user": end_user_id,
|
||||
"metadata": {"user_api_key_end_user_id": end_user_id},
|
||||
},
|
||||
"litellm_params": {
|
||||
"metadata": {
|
||||
"user_api_key_end_user_model_max_budget": user_api_key_end_user_model_max_budget
|
||||
},
|
||||
},
|
||||
}
|
||||
with patch.object(
|
||||
budget_limiter,
|
||||
"_increment_spend_for_key",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_increment:
|
||||
await budget_limiter.async_log_success_event(
|
||||
kwargs, response_obj=None, start_time=None, end_time=None
|
||||
)
|
||||
mock_increment.assert_awaited_once()
|
||||
call_kwargs = mock_increment.call_args.kwargs
|
||||
spend_key = call_kwargs["spend_key"]
|
||||
assert spend_key == (
|
||||
f"{END_USER_SPEND_CACHE_KEY_PREFIX}:{end_user_id}:{model}:{budget_duration}"
|
||||
)
|
||||
assert call_kwargs["response_cost"] == 0.05
|
||||
|
|
|
|||
|
|
@ -6,8 +6,11 @@ Covers the three root-cause fixes:
|
|||
1. ArizePhoenixLogger / ArizeLogger create *dedicated* TracerProviders.
|
||||
2. The ``otel`` dedup check does NOT match Arize subclasses.
|
||||
3. Arize loggers do NOT overwrite ``proxy_server.open_telemetry_logger``.
|
||||
4. Phoenix creates nested spans (parent + child) in proxy mode.
|
||||
5. Auto-initialization of Phoenix when env vars are detected.
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
|
@ -165,5 +168,46 @@ class TestProxyLoggerNotOverwritten(unittest.TestCase):
|
|||
assert proxy_server.open_telemetry_logger is None
|
||||
|
||||
|
||||
class TestPhoenixAutoInitWithOtelOnly(unittest.TestCase):
|
||||
"""When only 'otel' is configured but Phoenix env vars are set,
|
||||
ArizePhoenixLogger should be auto-initialized and receive spans."""
|
||||
|
||||
def setUp(self):
|
||||
"""Save original callbacks to restore after each test."""
|
||||
import litellm
|
||||
self._original_callbacks = litellm.callbacks[:]
|
||||
|
||||
def tearDown(self):
|
||||
"""Restore original callbacks to prevent global state leakage."""
|
||||
import litellm
|
||||
litellm.callbacks = self._original_callbacks
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
"PHOENIX_COLLECTOR_HTTP_ENDPOINT": "http://localhost:6006/v1/traces",
|
||||
}, clear=False)
|
||||
def test_auto_init_creates_phoenix_logger(self):
|
||||
from litellm.integrations.arize.arize_phoenix import ArizePhoenixLogger
|
||||
from litellm.litellm_core_utils.litellm_logging import _maybe_auto_initialize_arize_phoenix
|
||||
|
||||
_in_memory_loggers = []
|
||||
_maybe_auto_initialize_arize_phoenix(_in_memory_loggers)
|
||||
|
||||
phoenix_loggers = [cb for cb in _in_memory_loggers if isinstance(cb, ArizePhoenixLogger)]
|
||||
assert len(phoenix_loggers) == 1, "Phoenix logger should be auto-initialized when env vars are set"
|
||||
|
||||
def test_no_auto_init_without_env_vars(self):
|
||||
from litellm.integrations.arize.arize_phoenix import ArizePhoenixLogger
|
||||
from litellm.litellm_core_utils.litellm_logging import _maybe_auto_initialize_arize_phoenix
|
||||
|
||||
env_keys = ["PHOENIX_API_KEY", "PHOENIX_COLLECTOR_HTTP_ENDPOINT", "PHOENIX_COLLECTOR_ENDPOINT"]
|
||||
with patch.dict(os.environ, {k: "" for k in env_keys}, clear=False):
|
||||
for k in env_keys:
|
||||
os.environ.pop(k, None)
|
||||
_in_memory_loggers = []
|
||||
_maybe_auto_initialize_arize_phoenix(_in_memory_loggers)
|
||||
phoenix_loggers = [cb for cb in _in_memory_loggers if isinstance(cb, ArizePhoenixLogger)]
|
||||
assert len(phoenix_loggers) == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,327 @@
|
|||
"""
|
||||
Unit tests for WebSearch Interception with Extended Thinking
|
||||
|
||||
Tests that the websearch interception agentic loop correctly handles
|
||||
thinking/redacted_thinking blocks when extended thinking is enabled.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.websearch_interception.handler import (
|
||||
WebSearchInterceptionLogger,
|
||||
)
|
||||
from litellm.integrations.websearch_interception.transformation import (
|
||||
WebSearchTransformation,
|
||||
)
|
||||
|
||||
|
||||
class TestTransformResponseWithThinking:
|
||||
"""Tests for _transform_response_anthropic with thinking blocks."""
|
||||
|
||||
def test_thinking_blocks_prepended_to_assistant_message(self):
|
||||
"""Test that thinking blocks are prepended before tool_use blocks."""
|
||||
tool_calls = [
|
||||
{
|
||||
"id": "toolu_01",
|
||||
"type": "tool_use",
|
||||
"name": "litellm_web_search",
|
||||
"input": {"query": "latest news"},
|
||||
}
|
||||
]
|
||||
search_results = [
|
||||
"Title: News\nURL: https://example.com\nSnippet: Latest news"
|
||||
]
|
||||
thinking_blocks = [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "Let me search for that.",
|
||||
"signature": "sig123",
|
||||
},
|
||||
{"type": "redacted_thinking", "data": "abc123"},
|
||||
]
|
||||
|
||||
assistant_msg, user_msg = (
|
||||
WebSearchTransformation._transform_response_anthropic(
|
||||
tool_calls=tool_calls,
|
||||
search_results=search_results,
|
||||
thinking_blocks=thinking_blocks,
|
||||
)
|
||||
)
|
||||
|
||||
# Verify thinking blocks come first
|
||||
content = assistant_msg["content"]
|
||||
assert len(content) == 3 # 2 thinking + 1 tool_use
|
||||
assert content[0]["type"] == "thinking"
|
||||
assert content[0]["thinking"] == "Let me search for that."
|
||||
assert content[1]["type"] == "redacted_thinking"
|
||||
assert content[1]["data"] == "abc123"
|
||||
assert content[2]["type"] == "tool_use"
|
||||
assert content[2]["id"] == "toolu_01"
|
||||
|
||||
def test_no_thinking_blocks_backward_compat(self):
|
||||
"""Test that transform works without thinking blocks (backward compat)."""
|
||||
tool_calls = [
|
||||
{
|
||||
"id": "toolu_01",
|
||||
"type": "tool_use",
|
||||
"name": "litellm_web_search",
|
||||
"input": {"query": "test"},
|
||||
}
|
||||
]
|
||||
search_results = ["Search result text"]
|
||||
|
||||
# No thinking_blocks param (default None)
|
||||
assistant_msg, _ = (
|
||||
WebSearchTransformation._transform_response_anthropic(
|
||||
tool_calls=tool_calls,
|
||||
search_results=search_results,
|
||||
)
|
||||
)
|
||||
|
||||
content = assistant_msg["content"]
|
||||
assert len(content) == 1
|
||||
assert content[0]["type"] == "tool_use"
|
||||
|
||||
def test_empty_thinking_blocks_list(self):
|
||||
"""Test that an empty thinking_blocks list behaves like None."""
|
||||
tool_calls = [
|
||||
{
|
||||
"id": "toolu_01",
|
||||
"type": "tool_use",
|
||||
"name": "litellm_web_search",
|
||||
"input": {"query": "test"},
|
||||
}
|
||||
]
|
||||
search_results = ["Search result text"]
|
||||
|
||||
assistant_msg, _ = (
|
||||
WebSearchTransformation._transform_response_anthropic(
|
||||
tool_calls=tool_calls,
|
||||
search_results=search_results,
|
||||
thinking_blocks=[],
|
||||
)
|
||||
)
|
||||
|
||||
content = assistant_msg["content"]
|
||||
assert len(content) == 1
|
||||
assert content[0]["type"] == "tool_use"
|
||||
|
||||
def test_transform_response_passes_thinking_to_anthropic(self):
|
||||
"""Test that transform_response routes thinking_blocks correctly."""
|
||||
tool_calls = [
|
||||
{
|
||||
"id": "toolu_01",
|
||||
"type": "tool_use",
|
||||
"name": "litellm_web_search",
|
||||
"input": {"query": "test"},
|
||||
}
|
||||
]
|
||||
search_results = ["Search result"]
|
||||
thinking_blocks = [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "Reasoning here.",
|
||||
"signature": "sig",
|
||||
},
|
||||
]
|
||||
|
||||
assistant_msg, _ = WebSearchTransformation.transform_response(
|
||||
tool_calls=tool_calls,
|
||||
search_results=search_results,
|
||||
response_format="anthropic",
|
||||
thinking_blocks=thinking_blocks,
|
||||
)
|
||||
|
||||
content = assistant_msg["content"]
|
||||
assert content[0]["type"] == "thinking"
|
||||
assert content[1]["type"] == "tool_use"
|
||||
|
||||
def test_transform_response_openai_ignores_thinking(self):
|
||||
"""Test that OpenAI format is unaffected by thinking_blocks param."""
|
||||
tool_calls = [
|
||||
{
|
||||
"id": "call_01",
|
||||
"type": "function",
|
||||
"name": "litellm_web_search",
|
||||
"function": {
|
||||
"name": "litellm_web_search",
|
||||
"arguments": {"query": "test"},
|
||||
},
|
||||
"input": {"query": "test"},
|
||||
}
|
||||
]
|
||||
search_results = ["Search result"]
|
||||
thinking_blocks = [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "Should not appear.",
|
||||
"signature": "sig",
|
||||
},
|
||||
]
|
||||
|
||||
assistant_msg, _ = WebSearchTransformation.transform_response(
|
||||
tool_calls=tool_calls,
|
||||
search_results=search_results,
|
||||
response_format="openai",
|
||||
thinking_blocks=thinking_blocks,
|
||||
)
|
||||
|
||||
# OpenAI format uses tool_calls key, not content — thinking is irrelevant
|
||||
assert "tool_calls" in assistant_msg
|
||||
assert "content" not in assistant_msg
|
||||
|
||||
|
||||
class TestAgenticLoopThinkingExtraction:
|
||||
"""Tests for thinking block extraction in async_should_run_agentic_loop."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extracts_thinking_blocks_from_dict_response(self):
|
||||
"""Test extraction of thinking blocks from dict-style response."""
|
||||
logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"])
|
||||
|
||||
response = {
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "Let me think...",
|
||||
"signature": "sig1",
|
||||
},
|
||||
{"type": "redacted_thinking", "data": "redacted_data"},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01",
|
||||
"name": "litellm_web_search",
|
||||
"input": {"query": "latest news"},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
should_run, tools_dict = await logger.async_should_run_agentic_loop(
|
||||
response=response,
|
||||
model="bedrock/claude",
|
||||
messages=[],
|
||||
tools=[{"name": "WebSearch"}],
|
||||
stream=False,
|
||||
custom_llm_provider="bedrock",
|
||||
kwargs={},
|
||||
)
|
||||
|
||||
assert should_run is True
|
||||
assert len(tools_dict["tool_calls"]) == 1
|
||||
assert len(tools_dict["thinking_blocks"]) == 2
|
||||
assert tools_dict["thinking_blocks"][0]["type"] == "thinking"
|
||||
assert tools_dict["thinking_blocks"][0]["thinking"] == "Let me think..."
|
||||
assert tools_dict["thinking_blocks"][1]["type"] == "redacted_thinking"
|
||||
assert tools_dict["thinking_blocks"][1]["data"] == "redacted_data"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extracts_thinking_blocks_from_object_response(self):
|
||||
"""Test extraction of thinking blocks from non-dict response objects.
|
||||
|
||||
In practice, the Anthropic pass-through always returns plain dicts
|
||||
(TypedDict(**raw_json) produces a dict). This test covers the safety
|
||||
branch for non-dict response objects.
|
||||
"""
|
||||
logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"])
|
||||
|
||||
# Simulate object-style response blocks
|
||||
thinking_block = Mock()
|
||||
thinking_block.type = "thinking"
|
||||
thinking_block.thinking = "Reasoning..."
|
||||
thinking_block.signature = "sig"
|
||||
|
||||
redacted_block = Mock()
|
||||
redacted_block.type = "redacted_thinking"
|
||||
redacted_block.data = "abc"
|
||||
|
||||
tool_block = Mock()
|
||||
tool_block.type = "tool_use"
|
||||
tool_block.name = "litellm_web_search"
|
||||
tool_block.id = "toolu_01"
|
||||
tool_block.input = {"query": "test"}
|
||||
|
||||
response = Mock()
|
||||
response.content = [thinking_block, redacted_block, tool_block]
|
||||
|
||||
should_run, tools_dict = await logger.async_should_run_agentic_loop(
|
||||
response=response,
|
||||
model="bedrock/claude",
|
||||
messages=[],
|
||||
tools=[{"name": "WebSearch"}],
|
||||
stream=False,
|
||||
custom_llm_provider="bedrock",
|
||||
kwargs={},
|
||||
)
|
||||
|
||||
assert should_run is True
|
||||
assert len(tools_dict["thinking_blocks"]) == 2
|
||||
# Verify getattr-based conversion produced correct dicts
|
||||
assert tools_dict["thinking_blocks"][0] == {
|
||||
"type": "thinking",
|
||||
"thinking": "Reasoning...",
|
||||
"signature": "sig",
|
||||
}
|
||||
assert tools_dict["thinking_blocks"][1] == {
|
||||
"type": "redacted_thinking",
|
||||
"data": "abc",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_thinking_blocks_when_thinking_disabled(self):
|
||||
"""Test that thinking_blocks is empty when response has no thinking."""
|
||||
logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"])
|
||||
|
||||
response = {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01",
|
||||
"name": "litellm_web_search",
|
||||
"input": {"query": "test"},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
should_run, tools_dict = await logger.async_should_run_agentic_loop(
|
||||
response=response,
|
||||
model="bedrock/claude",
|
||||
messages=[],
|
||||
tools=[{"name": "WebSearch"}],
|
||||
stream=False,
|
||||
custom_llm_provider="bedrock",
|
||||
kwargs={},
|
||||
)
|
||||
|
||||
assert should_run is True
|
||||
assert tools_dict["thinking_blocks"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thinking_blocks_not_extracted_when_no_tool_calls(self):
|
||||
"""Test that no extraction happens when no websearch tool calls found."""
|
||||
logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"])
|
||||
|
||||
response = {
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "Just thinking...",
|
||||
"signature": "sig",
|
||||
},
|
||||
{"type": "text", "text": "Here is my response."},
|
||||
]
|
||||
}
|
||||
|
||||
should_run, tools_dict = await logger.async_should_run_agentic_loop(
|
||||
response=response,
|
||||
model="bedrock/claude",
|
||||
messages=[],
|
||||
tools=[{"name": "WebSearch"}],
|
||||
stream=False,
|
||||
custom_llm_provider="bedrock",
|
||||
kwargs={},
|
||||
)
|
||||
|
||||
assert should_run is False
|
||||
assert tools_dict == {}
|
||||
|
|
@ -6,17 +6,31 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
import pytest
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
import litellm
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../..")
|
||||
) # Adds the parent directory to the system path
|
||||
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.litellm_core_utils.realtime_streaming import RealTimeStreaming
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.llms.openai import (
|
||||
OpenAIRealtimeStreamResponseBaseObject,
|
||||
OpenAIRealtimeStreamSessionEvents,
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
|
|
@ -416,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
|
||||
|
|
@ -514,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
|
||||
|
|
@ -529,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,
|
||||
)
|
||||
|
|
@ -538,16 +671,133 @@ 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)]
|
||||
)
|
||||
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()
|
||||
|
||||
# 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 one session.update injected to backend, got: {sent_to_backend}"
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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", []):
|
||||
if "blocked" in text.lower():
|
||||
raise ValueError("Content blocked by guardrail.")
|
||||
return inputs
|
||||
|
||||
guardrail = BadWordGuardrail(
|
||||
guardrail_name="bad_word_guard",
|
||||
event_hook=GuardrailEventHooks.realtime_input_transcription,
|
||||
default_on=True,
|
||||
end_session_after_n_fails=2,
|
||||
)
|
||||
litellm.callbacks = [guardrail]
|
||||
|
||||
client_ws = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
|
||||
backend_ws = MagicMock()
|
||||
backend_ws.recv = AsyncMock(
|
||||
side_effect=[
|
||||
session_created_event,
|
||||
_make_transcript_event("this is blocked"), # violation 1 — warn
|
||||
_make_transcript_event("also blocked again"), # violation 2 — end session
|
||||
ConnectionClosed(None, None),
|
||||
]
|
||||
)
|
||||
backend_ws.send = AsyncMock()
|
||||
backend_ws.close = AsyncMock()
|
||||
|
||||
logging_obj = MagicMock()
|
||||
logging_obj.async_success_handler = AsyncMock()
|
||||
|
|
@ -555,17 +805,54 @@ async def test_realtime_session_created_injects_create_response_false():
|
|||
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
|
||||
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}"
|
||||
)
|
||||
td = session_updates[0]["session"]["turn_detection"]
|
||||
assert td["create_response"] is False, (
|
||||
f"Expected create_response=False, got: {td}"
|
||||
)
|
||||
assert backend_ws.close.called, "Expected backend_ws.close() to be called after 2 violations"
|
||||
assert streaming._violation_count == 2
|
||||
|
||||
litellm.callbacks = [] # cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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", []):
|
||||
if "stock" in text.lower():
|
||||
raise ValueError("Topic not allowed: financial advice.")
|
||||
return inputs
|
||||
|
||||
guardrail = TopicGuardrail(
|
||||
guardrail_name="topic_guard",
|
||||
event_hook=GuardrailEventHooks.realtime_input_transcription,
|
||||
default_on=True,
|
||||
on_violation="end_session",
|
||||
)
|
||||
litellm.callbacks = [guardrail]
|
||||
|
||||
client_ws = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
|
||||
backend_ws = MagicMock()
|
||||
backend_ws.recv = AsyncMock(
|
||||
side_effect=[
|
||||
_make_transcript_event("What stock should I buy today?", item_id="item_y"),
|
||||
ConnectionClosed(None, None),
|
||||
]
|
||||
)
|
||||
backend_ws.send = AsyncMock()
|
||||
backend_ws.close = 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 backend_ws.close.called, "Expected session to close immediately with on_violation=end_session"
|
||||
assert streaming._violation_count == 1
|
||||
|
||||
litellm.callbacks = [] # cleanup
|
||||
|
|
|
|||
|
|
@ -1813,6 +1813,51 @@ def test_translate_openai_response_to_anthropic_input_tokens_no_cache():
|
|||
assert anthropic_response["usage"]["output_tokens"] == 50
|
||||
|
||||
|
||||
def test_translate_openai_response_to_anthropic_cache_tokens_from_prompt_tokens_details():
|
||||
"""
|
||||
OpenAI/Azure providers set prompt_tokens_details.cached_tokens but not
|
||||
_cache_read_input_tokens. The adapter should populate cache_read_input_tokens
|
||||
from prompt_tokens_details.cached_tokens directly.
|
||||
"""
|
||||
from litellm.types.utils import PromptTokensDetailsWrapper
|
||||
|
||||
# OpenAI-style usage: only prompt_tokens_details, no cache_read_input_tokens kwarg
|
||||
usage = Usage(
|
||||
prompt_tokens=100,
|
||||
completion_tokens=50,
|
||||
total_tokens=150,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
cached_tokens=30
|
||||
),
|
||||
)
|
||||
|
||||
response = ModelResponse(
|
||||
id="test-id",
|
||||
choices=[
|
||||
Choices(
|
||||
index=0,
|
||||
finish_reason="stop",
|
||||
message=Message(
|
||||
role="assistant",
|
||||
content="Test response",
|
||||
),
|
||||
)
|
||||
],
|
||||
model="gpt-4o-2024-08-06",
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
anthropic_response = adapter.translate_openai_response_to_anthropic(
|
||||
response=response,
|
||||
tool_name_mapping=None,
|
||||
)
|
||||
|
||||
assert anthropic_response["usage"]["input_tokens"] == 70
|
||||
assert anthropic_response["usage"]["output_tokens"] == 50
|
||||
assert anthropic_response["usage"]["cache_read_input_tokens"] == 30
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Web Search Tool Transformation Tests
|
||||
# =====================================================================
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
"""
|
||||
Tests for Vertex AI (Veo) video generation transformation.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
import base64
|
||||
import pytest
|
||||
|
||||
from litellm.llms.vertex_ai.videos.transformation import (
|
||||
VertexAIVideoConfig,
|
||||
_convert_image_to_vertex_format,
|
||||
)
|
||||
from litellm.types.videos.main import VideoObject
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.videos.main import VideoObject
|
||||
|
||||
|
||||
class TestVertexAIVideoConfig:
|
||||
|
|
@ -548,3 +549,170 @@ class TestConvertImageToVertexFormat:
|
|||
decoded = base64.b64decode(result["bytesBase64Encoded"])
|
||||
assert decoded == fake_image_data
|
||||
|
||||
|
||||
class TestImageAndParametersPassthrough:
|
||||
"""
|
||||
Tests that image (gcsUri / bare gs:// / file-like) and a pre-built
|
||||
parameters dict are correctly forwarded through map_openai_params and
|
||||
transform_video_create_request.
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
self.config = VertexAIVideoConfig()
|
||||
self.api_base = (
|
||||
"https://us-central1-aiplatform.googleapis.com/v1/projects/"
|
||||
"test-project/locations/us-central1/publishers/google/models/veo-002"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# map_openai_params #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_map_openai_params_passes_image_dict(self):
|
||||
"""image dict (gcsUri format) is forwarded as-is."""
|
||||
image = {"gcsUri": "gs://my-bucket/boardwalk.jpg"}
|
||||
mapped = self.config.map_openai_params(
|
||||
video_create_optional_params={"image": image},
|
||||
model="veo-002",
|
||||
drop_params=False,
|
||||
)
|
||||
assert mapped["image"] == image
|
||||
|
||||
def test_map_openai_params_passes_parameters_dict(self):
|
||||
"""A pre-built parameters dict is forwarded as-is."""
|
||||
params = {"sampleCount": 1, "videoLengthSeconds": 5, "aspectRatio": "16:9"}
|
||||
mapped = self.config.map_openai_params(
|
||||
video_create_optional_params={"parameters": params},
|
||||
model="veo-002",
|
||||
drop_params=False,
|
||||
)
|
||||
assert mapped["parameters"] == params
|
||||
|
||||
def test_map_openai_params_input_reference_takes_priority_over_image(self):
|
||||
"""input_reference wins over a directly passed image key."""
|
||||
mock_file = Mock()
|
||||
image_dict = {"gcsUri": "gs://my-bucket/other.jpg"}
|
||||
mapped = self.config.map_openai_params(
|
||||
video_create_optional_params={
|
||||
"input_reference": mock_file,
|
||||
"image": image_dict,
|
||||
},
|
||||
model="veo-002",
|
||||
drop_params=False,
|
||||
)
|
||||
assert mapped["image"] is mock_file
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# transform_video_create_request – image forms #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_transform_request_image_gcs_uri_dict(self):
|
||||
"""image passed as {"gcsUri": "gs://..."} is placed in instances as-is."""
|
||||
image = {"gcsUri": "gs://my-bucket/boardwalk.jpg"}
|
||||
data, _, url = self.config.transform_video_create_request(
|
||||
model="veo-002",
|
||||
prompt="Cinematic drone shot",
|
||||
api_base=self.api_base,
|
||||
video_create_optional_request_params={"image": image},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert data["instances"][0]["image"] == image
|
||||
assert url.endswith(":predictLongRunning")
|
||||
|
||||
def test_transform_request_image_bare_gs_uri_string(self):
|
||||
"""A bare gs:// string is wrapped in {"gcsUri": ...} without downloading."""
|
||||
gs_uri = "gs://my-bucket/boardwalk.jpg"
|
||||
data, _, _ = self.config.transform_video_create_request(
|
||||
model="veo-002",
|
||||
prompt="Cinematic drone shot",
|
||||
api_base=self.api_base,
|
||||
video_create_optional_request_params={"image": gs_uri},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert data["instances"][0]["image"] == {"gcsUri": gs_uri}
|
||||
|
||||
def test_transform_request_image_bytes_base64_dict(self):
|
||||
"""image already in bytesBase64Encoded format is passed through unchanged."""
|
||||
image = {"bytesBase64Encoded": "abc123", "mimeType": "image/jpeg"}
|
||||
data, _, _ = self.config.transform_video_create_request(
|
||||
model="veo-002",
|
||||
prompt="Cinematic drone shot",
|
||||
api_base=self.api_base,
|
||||
video_create_optional_request_params={"image": image},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert data["instances"][0]["image"] == image
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# transform_video_create_request – parameters dict #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_transform_request_parameters_dict_not_double_nested(self):
|
||||
"""A pre-built parameters dict becomes request_data["parameters"] directly."""
|
||||
params = {"sampleCount": 1, "videoLengthSeconds": 5, "aspectRatio": "16:9"}
|
||||
data, _, _ = self.config.transform_video_create_request(
|
||||
model="veo-002",
|
||||
prompt="Cinematic drone shot",
|
||||
api_base=self.api_base,
|
||||
video_create_optional_request_params={"parameters": params},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert data["parameters"] == params
|
||||
# Must NOT be double-nested
|
||||
assert "parameters" not in data["parameters"]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Full user scenario #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_transform_request_full_user_scenario(self):
|
||||
"""
|
||||
Reproduces the exact user request:
|
||||
image: {"gcsUri": "gs://your-bucket-name/path/to/boardwalk.jpg"}
|
||||
parameters: {"sampleCount": 1, "videoLengthSeconds": 5,
|
||||
"aspectRatio": "16:9", "storageUri": "gs://test/outputs/"}
|
||||
"""
|
||||
image = {"gcsUri": "gs://your-bucket-name/path/to/boardwalk.jpg"}
|
||||
parameters = {
|
||||
"sampleCount": 1,
|
||||
"videoLengthSeconds": 5,
|
||||
"aspectRatio": "16:9",
|
||||
"storageUri": "gs://test/outputs/",
|
||||
}
|
||||
|
||||
# Simulate the full pipeline: map_openai_params → transform_video_create_request
|
||||
mapped = self.config.map_openai_params(
|
||||
video_create_optional_params={"image": image, "parameters": parameters},
|
||||
model="veo-3.1-generate-preview",
|
||||
drop_params=False,
|
||||
)
|
||||
|
||||
data, _, url = self.config.transform_video_create_request(
|
||||
model="veo-3.1-generate-preview",
|
||||
prompt="Cinematic drone shot moving forward along the beach boardwalk",
|
||||
api_base=self.api_base,
|
||||
video_create_optional_request_params=mapped,
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
# instances contains prompt + image
|
||||
assert len(data["instances"]) == 1
|
||||
instance = data["instances"][0]
|
||||
assert instance["prompt"] == "Cinematic drone shot moving forward along the beach boardwalk"
|
||||
assert instance["image"] == image
|
||||
|
||||
# parameters block is correct and not double-nested
|
||||
assert data["parameters"] == parameters
|
||||
assert "parameters" not in data["parameters"]
|
||||
|
||||
assert url.endswith(":predictLongRunning")
|
||||
|
||||
|
|
|
|||
|
|
@ -486,7 +486,7 @@ async def test_get_tools_from_mcp_servers_continues_when_one_server_fails():
|
|||
working_server if server_id == "working_server" else failing_server
|
||||
)
|
||||
# Mock filter_server_ids_by_ip to return server_ids unchanged (no IP filtering)
|
||||
mock_manager.filter_server_ids_by_ip = lambda server_ids, client_ip: server_ids
|
||||
mock_manager.filter_server_ids_by_ip_with_info = lambda server_ids, client_ip: (server_ids, 0)
|
||||
|
||||
async def mock_get_tools_from_server(
|
||||
server,
|
||||
|
|
@ -588,7 +588,7 @@ async def test_get_tools_from_mcp_servers_handles_all_servers_failing():
|
|||
failing_server1 if server_id == "failing_server1" else failing_server2
|
||||
)
|
||||
# Mock filter_server_ids_by_ip to return server_ids unchanged (no IP filtering)
|
||||
mock_manager.filter_server_ids_by_ip = lambda server_ids, client_ip: server_ids
|
||||
mock_manager.filter_server_ids_by_ip_with_info = lambda server_ids, client_ip: (server_ids, 0)
|
||||
|
||||
async def mock_get_tools_from_server(
|
||||
server,
|
||||
|
|
@ -1035,7 +1035,7 @@ async def test_list_tools_single_server_unprefixed_names():
|
|||
mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["server1"])
|
||||
mock_manager.get_mcp_server_by_id = MagicMock(return_value=server)
|
||||
# Mock filter_server_ids_by_ip to return server_ids unchanged (no IP filtering)
|
||||
mock_manager.filter_server_ids_by_ip = lambda server_ids, client_ip: server_ids
|
||||
mock_manager.filter_server_ids_by_ip_with_info = lambda server_ids, client_ip: (server_ids, 0)
|
||||
|
||||
async def mock_get_tools_from_server(
|
||||
server,
|
||||
|
|
@ -1113,7 +1113,7 @@ async def test_list_tools_multiple_servers_prefixed_names():
|
|||
server1 if server_id == "server1" else server2
|
||||
)
|
||||
# Mock filter_server_ids_by_ip to return server_ids unchanged (no IP filtering)
|
||||
mock_manager.filter_server_ids_by_ip = lambda server_ids, client_ip: server_ids
|
||||
mock_manager.filter_server_ids_by_ip_with_info = lambda server_ids, client_ip: (server_ids, 0)
|
||||
|
||||
async def mock_get_tools_from_server(
|
||||
server,
|
||||
|
|
@ -1364,7 +1364,7 @@ async def test_list_tools_filters_by_key_team_permissions():
|
|||
mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["server1"])
|
||||
mock_manager.get_mcp_server_by_id = lambda server_id: server
|
||||
# Mock filter_server_ids_by_ip to return server_ids unchanged (no IP filtering)
|
||||
mock_manager.filter_server_ids_by_ip = lambda server_ids, client_ip: server_ids
|
||||
mock_manager.filter_server_ids_by_ip_with_info = lambda server_ids, client_ip: (server_ids, 0)
|
||||
|
||||
async def mock_get_tools_from_server(
|
||||
server,
|
||||
|
|
@ -1471,7 +1471,7 @@ async def test_list_tools_with_team_tool_permissions_inheritance():
|
|||
mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["server1"])
|
||||
mock_manager.get_mcp_server_by_id = lambda server_id: server
|
||||
# Mock filter_server_ids_by_ip to return server_ids unchanged (no IP filtering)
|
||||
mock_manager.filter_server_ids_by_ip = lambda server_ids, client_ip: server_ids
|
||||
mock_manager.filter_server_ids_by_ip_with_info = lambda server_ids, client_ip: (server_ids, 0)
|
||||
|
||||
async def mock_get_tools_from_server(
|
||||
server,
|
||||
|
|
@ -1563,7 +1563,7 @@ async def test_list_tools_with_no_tool_permissions_shows_all():
|
|||
mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["server1"])
|
||||
mock_manager.get_mcp_server_by_id = lambda server_id: server
|
||||
# Mock filter_server_ids_by_ip to return server_ids unchanged (no IP filtering)
|
||||
mock_manager.filter_server_ids_by_ip = lambda server_ids, client_ip: server_ids
|
||||
mock_manager.filter_server_ids_by_ip_with_info = lambda server_ids, client_ip: (server_ids, 0)
|
||||
|
||||
async def mock_get_tools_from_server(
|
||||
server,
|
||||
|
|
@ -1658,7 +1658,7 @@ async def test_list_tools_strips_prefix_when_matching_permissions():
|
|||
mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["gitmcp_server"])
|
||||
mock_manager.get_mcp_server_by_id = MagicMock(return_value=server)
|
||||
# Mock filter_server_ids_by_ip to return server_ids unchanged (no IP filtering)
|
||||
mock_manager.filter_server_ids_by_ip = lambda server_ids, client_ip: server_ids
|
||||
mock_manager.filter_server_ids_by_ip_with_info = lambda server_ids, client_ip: (server_ids, 0)
|
||||
|
||||
async def mock_get_tools_from_server(
|
||||
server,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
import litellm
|
||||
from litellm.proxy.auth.user_api_key_auth import (
|
||||
_run_post_custom_auth_checks,
|
||||
update_valid_token_with_end_user_params,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_auth_run_post_custom_auth_checks_without_end_user_id():
|
||||
# Test backwards compatibility
|
||||
valid_token = UserAPIKeyAuth(token="test_token")
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.common_checks", new_callable=AsyncMock
|
||||
) as mock_common:
|
||||
mock_common.return_value = True
|
||||
result = await _run_post_custom_auth_checks(
|
||||
valid_token=valid_token,
|
||||
request=None,
|
||||
request_data={},
|
||||
route="/v1/chat/completions",
|
||||
parent_otel_span=None,
|
||||
)
|
||||
assert result.token == "test_token"
|
||||
assert getattr(result, "end_user_id", None) is None
|
||||
mock_common.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_auth_run_post_custom_auth_checks_with_end_user_budget_exceeded():
|
||||
valid_token = UserAPIKeyAuth(
|
||||
token="test_token",
|
||||
end_user_id="test_user",
|
||||
end_user_model_max_budget={
|
||||
"gpt-4": {"budget_limit": 10.0, "time_period": "1d"}
|
||||
},
|
||||
)
|
||||
request_data = {"model": "gpt-4"}
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.common_checks", new_callable=AsyncMock
|
||||
):
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.model_max_budget_limiter.is_end_user_within_model_budget",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_budget_check:
|
||||
mock_budget_check.side_effect = litellm.BudgetExceededError(
|
||||
message="Exceeded budget", current_cost=20.0, max_budget=10.0
|
||||
)
|
||||
|
||||
with pytest.raises(litellm.BudgetExceededError):
|
||||
await _run_post_custom_auth_checks(
|
||||
valid_token=valid_token,
|
||||
request=None,
|
||||
request_data=request_data,
|
||||
route="/v1/chat/completions",
|
||||
parent_otel_span=None,
|
||||
)
|
||||
mock_budget_check.assert_awaited_once()
|
||||
|
||||
|
||||
def test_update_valid_token_does_not_override_custom_auth_values_with_none():
|
||||
"""
|
||||
Greptile feedback: if custom auth sets end_user_model_max_budget on the token,
|
||||
but the DB end_user has no model_max_budget in their budget table, the DB lookup
|
||||
should NOT clear the custom-auth-provided value.
|
||||
"""
|
||||
custom_auth_budget = {"gpt-4": {"budget_limit": 5.0, "time_period": "1d"}}
|
||||
valid_token = UserAPIKeyAuth(
|
||||
token="test_token",
|
||||
end_user_id="user_1",
|
||||
end_user_tpm_limit=100,
|
||||
end_user_rpm_limit=50,
|
||||
end_user_model_max_budget=custom_auth_budget,
|
||||
)
|
||||
|
||||
# Simulate DB lookup that found the end_user but budget table has no limits set
|
||||
end_user_params = {
|
||||
"end_user_id": "user_1",
|
||||
"allowed_model_region": None,
|
||||
# No tpm_limit, rpm_limit, or model_max_budget from DB
|
||||
}
|
||||
|
||||
result = update_valid_token_with_end_user_params(valid_token, end_user_params)
|
||||
|
||||
# Custom-auth-provided values should be preserved, not cleared to None
|
||||
assert result.end_user_tpm_limit == 100
|
||||
assert result.end_user_rpm_limit == 50
|
||||
assert result.end_user_model_max_budget == custom_auth_budget
|
||||
assert result.end_user_id == "user_1"
|
||||
|
||||
|
||||
def test_update_valid_token_db_values_override_custom_auth_when_set():
|
||||
"""
|
||||
When the DB budget table has explicit values, they should override
|
||||
whatever the custom auth function set (DB is source of truth).
|
||||
"""
|
||||
valid_token = UserAPIKeyAuth(
|
||||
token="test_token",
|
||||
end_user_id="user_1",
|
||||
end_user_tpm_limit=100,
|
||||
end_user_model_max_budget={"gpt-4": {"budget_limit": 5.0, "time_period": "1d"}},
|
||||
)
|
||||
|
||||
db_budget = {"gpt-4": {"budget_limit": 20.0, "time_period": "1d"}}
|
||||
end_user_params = {
|
||||
"end_user_id": "user_1",
|
||||
"end_user_tpm_limit": 500,
|
||||
"end_user_model_max_budget": db_budget,
|
||||
}
|
||||
|
||||
result = update_valid_token_with_end_user_params(valid_token, end_user_params)
|
||||
|
||||
# DB values should win
|
||||
assert result.end_user_tpm_limit == 500
|
||||
assert result.end_user_model_max_budget == db_budget
|
||||
|
|
@ -91,3 +91,58 @@ class TestMCPServerIPFiltering:
|
|||
|
||||
result = manager.filter_server_ids_by_ip(["priv"], client_ip=None)
|
||||
assert result == ["priv"]
|
||||
|
||||
|
||||
class TestFilterServerIdsByIpWithInfo:
|
||||
"""Tests that filter_server_ids_by_ip_with_info returns accurate block counts."""
|
||||
|
||||
@patch("litellm.public_mcp_servers", [])
|
||||
@patch("litellm.proxy.proxy_server.general_settings", {})
|
||||
def test_external_ip_reports_blocked_count(self):
|
||||
pub = _make_server("pub", available_on_public_internet=True)
|
||||
priv = _make_server("priv", available_on_public_internet=False)
|
||||
manager = _make_manager([pub, priv])
|
||||
|
||||
allowed, blocked = manager.filter_server_ids_by_ip_with_info(
|
||||
["pub", "priv"], client_ip="8.8.8.8"
|
||||
)
|
||||
assert allowed == ["pub"]
|
||||
assert blocked == 1
|
||||
|
||||
@patch("litellm.public_mcp_servers", [])
|
||||
@patch("litellm.proxy.proxy_server.general_settings", {})
|
||||
def test_internal_ip_reports_zero_blocked(self):
|
||||
pub = _make_server("pub", available_on_public_internet=True)
|
||||
priv = _make_server("priv", available_on_public_internet=False)
|
||||
manager = _make_manager([pub, priv])
|
||||
|
||||
allowed, blocked = manager.filter_server_ids_by_ip_with_info(
|
||||
["pub", "priv"], client_ip="192.168.1.1"
|
||||
)
|
||||
assert allowed == ["pub", "priv"]
|
||||
assert blocked == 0
|
||||
|
||||
@patch("litellm.public_mcp_servers", [])
|
||||
@patch("litellm.proxy.proxy_server.general_settings", {})
|
||||
def test_no_ip_returns_all_with_zero_blocked(self):
|
||||
priv = _make_server("priv", available_on_public_internet=False)
|
||||
manager = _make_manager([priv])
|
||||
|
||||
allowed, blocked = manager.filter_server_ids_by_ip_with_info(
|
||||
["priv"], client_ip=None
|
||||
)
|
||||
assert allowed == ["priv"]
|
||||
assert blocked == 0
|
||||
|
||||
@patch("litellm.public_mcp_servers", [])
|
||||
@patch("litellm.proxy.proxy_server.general_settings", {})
|
||||
def test_all_private_external_ip_reports_all_blocked(self):
|
||||
priv1 = _make_server("priv1", available_on_public_internet=False)
|
||||
priv2 = _make_server("priv2", available_on_public_internet=False)
|
||||
manager = _make_manager([priv1, priv2])
|
||||
|
||||
allowed, blocked = manager.filter_server_ids_by_ip_with_info(
|
||||
["priv1", "priv2"], client_ip="1.2.3.4"
|
||||
)
|
||||
assert allowed == []
|
||||
assert blocked == 2
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ async def test_form_data_parsing():
|
|||
mock_request.form = AsyncMock(return_value=test_data)
|
||||
mock_request.headers = {"content-type": "application/x-www-form-urlencoded"}
|
||||
mock_request.scope = {}
|
||||
mock_request.state._cached_headers = None
|
||||
|
||||
# Parse the form data
|
||||
result = await _read_request_body(mock_request)
|
||||
|
|
@ -123,6 +124,7 @@ async def test_form_data_with_json_metadata():
|
|||
mock_request.form = AsyncMock(return_value=test_data)
|
||||
mock_request.headers = {"content-type": "multipart/form-data"}
|
||||
mock_request.scope = {}
|
||||
mock_request.state._cached_headers = None
|
||||
|
||||
# Parse the form data
|
||||
result = await _read_request_body(mock_request)
|
||||
|
|
@ -163,6 +165,7 @@ async def test_form_data_with_invalid_json_metadata():
|
|||
mock_request.form = AsyncMock(return_value=test_data)
|
||||
mock_request.headers = {"content-type": "multipart/form-data"}
|
||||
mock_request.scope = {}
|
||||
mock_request.state._cached_headers = None
|
||||
|
||||
# Should raise JSONDecodeError when trying to parse invalid JSON metadata
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
|
|
@ -189,6 +192,7 @@ async def test_form_data_without_metadata():
|
|||
mock_request.form = AsyncMock(return_value=test_data)
|
||||
mock_request.headers = {"content-type": "application/x-www-form-urlencoded"}
|
||||
mock_request.scope = {}
|
||||
mock_request.state._cached_headers = None
|
||||
|
||||
# Parse the form data
|
||||
result = await _read_request_body(mock_request)
|
||||
|
|
@ -219,6 +223,7 @@ async def test_form_data_with_empty_metadata():
|
|||
mock_request.form = AsyncMock(return_value=test_data)
|
||||
mock_request.headers = {"content-type": "multipart/form-data"}
|
||||
mock_request.scope = {}
|
||||
mock_request.state._cached_headers = None
|
||||
|
||||
# Parse the form data
|
||||
result = await _read_request_body(mock_request)
|
||||
|
|
@ -256,6 +261,7 @@ async def test_form_data_with_dict_metadata():
|
|||
mock_request.form = AsyncMock(return_value=test_data)
|
||||
mock_request.headers = {"content-type": "multipart/form-data"}
|
||||
mock_request.scope = {}
|
||||
mock_request.state._cached_headers = None
|
||||
|
||||
# Parse the form data
|
||||
result = await _read_request_body(mock_request)
|
||||
|
|
@ -286,6 +292,7 @@ async def test_form_data_with_none_metadata():
|
|||
mock_request.form = AsyncMock(return_value=test_data)
|
||||
mock_request.headers = {"content-type": "multipart/form-data"}
|
||||
mock_request.scope = {}
|
||||
mock_request.state._cached_headers = None
|
||||
|
||||
# Parse the form data
|
||||
result = await _read_request_body(mock_request)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ sys.path.insert(
|
|||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.guardrails.guardrail_endpoints import (
|
||||
CreateGuardrailRequest,
|
||||
PatchGuardrailRequest,
|
||||
|
|
@ -25,6 +26,8 @@ from litellm.proxy.guardrails.guardrail_endpoints import (
|
|||
patch_guardrail,
|
||||
update_guardrail,
|
||||
)
|
||||
|
||||
MOCK_ADMIN_USER = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
from litellm.proxy.guardrails.guardrail_registry import (
|
||||
IN_MEMORY_GUARDRAIL_HANDLER,
|
||||
InMemoryGuardrailHandler,
|
||||
|
|
@ -700,15 +703,15 @@ async def test_create_guardrail_endpoint(
|
|||
# Run the test
|
||||
if expected_exception:
|
||||
with pytest.raises(expected_exception) as exc_info:
|
||||
await create_guardrail(MOCK_CREATE_REQUEST)
|
||||
|
||||
await create_guardrail(MOCK_CREATE_REQUEST, user_api_key_dict=MOCK_ADMIN_USER)
|
||||
|
||||
if scenario == "database_failure":
|
||||
assert "Database error" in str(exc_info.value.detail)
|
||||
elif scenario == "no_prisma_client":
|
||||
assert "Prisma client not initialized" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
else:
|
||||
result = await create_guardrail(MOCK_CREATE_REQUEST)
|
||||
result = await create_guardrail(MOCK_CREATE_REQUEST, user_api_key_dict=MOCK_ADMIN_USER)
|
||||
|
||||
assert result["guardrail_id"] == expected_result
|
||||
assert result["guardrail_name"] == "Test DB Guardrail"
|
||||
|
|
@ -789,15 +792,15 @@ async def test_update_guardrail_endpoint(
|
|||
# Run the test
|
||||
if expected_exception:
|
||||
with pytest.raises(expected_exception) as exc_info:
|
||||
await update_guardrail("test-guardrail-id", MOCK_UPDATE_REQUEST)
|
||||
|
||||
await update_guardrail("test-guardrail-id", MOCK_UPDATE_REQUEST, user_api_key_dict=MOCK_ADMIN_USER)
|
||||
|
||||
if scenario == "database_failure":
|
||||
assert "Database error" in str(exc_info.value.detail)
|
||||
elif scenario == "no_prisma_client":
|
||||
assert "Prisma client not initialized" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
else:
|
||||
result = await update_guardrail("test-guardrail-id", MOCK_UPDATE_REQUEST)
|
||||
result = await update_guardrail("test-guardrail-id", MOCK_UPDATE_REQUEST, user_api_key_dict=MOCK_ADMIN_USER)
|
||||
|
||||
assert result["guardrail_id"] == expected_result
|
||||
assert result["guardrail_name"] == "Test DB Guardrail"
|
||||
|
|
@ -883,15 +886,15 @@ async def test_patch_guardrail_endpoint(
|
|||
# Run the test
|
||||
if expected_exception:
|
||||
with pytest.raises(expected_exception) as exc_info:
|
||||
await patch_guardrail("test-guardrail-id", MOCK_PATCH_REQUEST)
|
||||
|
||||
await patch_guardrail("test-guardrail-id", MOCK_PATCH_REQUEST, user_api_key_dict=MOCK_ADMIN_USER)
|
||||
|
||||
if scenario == "database_failure":
|
||||
assert "Database error" in str(exc_info.value.detail)
|
||||
elif scenario == "no_prisma_client":
|
||||
assert "Prisma client not initialized" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
else:
|
||||
result = await patch_guardrail("test-guardrail-id", MOCK_PATCH_REQUEST)
|
||||
result = await patch_guardrail("test-guardrail-id", MOCK_PATCH_REQUEST, user_api_key_dict=MOCK_ADMIN_USER)
|
||||
|
||||
assert result["guardrail_id"] == expected_result
|
||||
assert result["guardrail_name"] == "Test DB Guardrail"
|
||||
|
|
@ -947,9 +950,9 @@ async def test_delete_guardrail_endpoint(
|
|||
|
||||
if expected_exception:
|
||||
with pytest.raises(expected_exception):
|
||||
await delete_guardrail(guardrail_id=expected_result)
|
||||
await delete_guardrail(guardrail_id=expected_result, user_api_key_dict=MOCK_ADMIN_USER)
|
||||
else:
|
||||
result = await delete_guardrail(guardrail_id=expected_result)
|
||||
result = await delete_guardrail(guardrail_id=expected_result, user_api_key_dict=MOCK_ADMIN_USER)
|
||||
|
||||
assert result == MOCK_DB_GUARDRAIL
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ from litellm.proxy.management_endpoints.key_management_endpoints import (
|
|||
check_team_key_model_specific_limits,
|
||||
delete_verification_tokens,
|
||||
generate_key_helper_fn,
|
||||
key_aliases,
|
||||
list_keys,
|
||||
prepare_key_update_data,
|
||||
reset_key_spend_fn,
|
||||
|
|
@ -6194,15 +6195,14 @@ async def test_generate_key_helper_fn_agent_id():
|
|||
)
|
||||
mock_prisma_client.insert_data = mock_insert
|
||||
|
||||
with patch.object(km, "prisma_client", mock_prisma_client):
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client):
|
||||
await generate_key_helper_fn(
|
||||
request_type="key",
|
||||
agent_id="test-agent-456",
|
||||
key_alias="test-agent-key",
|
||||
models=[],
|
||||
table_name="key",
|
||||
)
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client):
|
||||
await generate_key_helper_fn(
|
||||
request_type="key",
|
||||
agent_id="test-agent-456",
|
||||
key_alias="test-agent-key",
|
||||
models=[],
|
||||
table_name="key",
|
||||
)
|
||||
|
||||
assert mock_insert.called, "insert_data was never called"
|
||||
# insert_data is called as insert_data(data=key_data, ...)
|
||||
|
|
@ -6211,3 +6211,97 @@ async def test_generate_key_helper_fn_agent_id():
|
|||
assert key_data.get("agent_id") == "test-agent-456", (
|
||||
f"Expected agent_id='test-agent-456' in key_data, got: {key_data.get('agent_id')}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_aliases_response_shape():
|
||||
"""Test that key_aliases returns the correct paginated response shape."""
|
||||
mock_prisma_client = AsyncMock()
|
||||
mock_prisma_client.db.query_raw = AsyncMock(
|
||||
side_effect=[
|
||||
[{"count": 2}],
|
||||
[{"key_alias": "alias-alpha"}, {"key_alias": "alias-beta"}],
|
||||
]
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client):
|
||||
result = await key_aliases(page=1, size=50, search=None)
|
||||
|
||||
assert result["aliases"] == ["alias-alpha", "alias-beta"]
|
||||
assert result["total_count"] == 2
|
||||
assert result["current_page"] == 1
|
||||
assert result["total_pages"] == 1
|
||||
assert result["size"] == 50
|
||||
|
||||
# Both SQL calls must filter out null/empty aliases
|
||||
count_sql = mock_prisma_client.db.query_raw.call_args_list[0].args[0]
|
||||
aliases_sql = mock_prisma_client.db.query_raw.call_args_list[1].args[0]
|
||||
assert "key_alias IS NOT NULL" in count_sql
|
||||
assert "key_alias IS NOT NULL" in aliases_sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_aliases_pagination_skip_take():
|
||||
"""Test that LIMIT and OFFSET are correctly derived from page and size."""
|
||||
mock_prisma_client = AsyncMock()
|
||||
mock_prisma_client.db.query_raw = AsyncMock(
|
||||
side_effect=[
|
||||
[{"count": 120}],
|
||||
[],
|
||||
]
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client):
|
||||
result = await key_aliases(page=3, size=25, search=None)
|
||||
|
||||
assert result["current_page"] == 3
|
||||
assert result["size"] == 25
|
||||
assert result["total_count"] == 120
|
||||
assert result["total_pages"] == 5 # ceil(120 / 25)
|
||||
|
||||
# aliases query params: [UI_SESSION_TOKEN_TEAM_ID, size=25, offset=50]
|
||||
aliases_call_args = mock_prisma_client.db.query_raw.call_args_list[1].args
|
||||
assert aliases_call_args[-2] == 25 # LIMIT = size
|
||||
assert aliases_call_args[-1] == 50 # OFFSET = (3 - 1) * 25
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_aliases_search_filter():
|
||||
"""Test that the search param adds a case-insensitive ILIKE condition."""
|
||||
mock_prisma_client = AsyncMock()
|
||||
mock_prisma_client.db.query_raw = AsyncMock(
|
||||
side_effect=[
|
||||
[{"count": 0}],
|
||||
[],
|
||||
]
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client):
|
||||
await key_aliases(page=1, size=50, search="my-key")
|
||||
|
||||
count_call = mock_prisma_client.db.query_raw.call_args_list[0]
|
||||
count_sql = count_call.args[0]
|
||||
count_params = count_call.args[1:]
|
||||
|
||||
assert "ILIKE" in count_sql
|
||||
assert "%my-key%" in count_params
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_aliases_no_search_omits_ilike_filter():
|
||||
"""Test that without a search term no ILIKE condition is added."""
|
||||
mock_prisma_client = AsyncMock()
|
||||
mock_prisma_client.db.query_raw = AsyncMock(
|
||||
side_effect=[
|
||||
[{"count": 0}],
|
||||
[],
|
||||
]
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client):
|
||||
await key_aliases(page=1, size=50, search=None)
|
||||
|
||||
count_sql = mock_prisma_client.db.query_raw.call_args_list[0].args[0]
|
||||
assert "ILIKE" not in count_sql
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -224,6 +224,7 @@ class TestVertexAIPassThroughHandler:
|
|||
|
||||
# Mock request
|
||||
mock_request = Mock()
|
||||
mock_request.state = None # Prevent Mock from returning a truthy _cached_headers
|
||||
mock_request.method = "POST"
|
||||
mock_request.headers = {
|
||||
"Authorization": "Bearer test-creds",
|
||||
|
|
@ -323,6 +324,7 @@ class TestVertexAIPassThroughHandler:
|
|||
|
||||
# Mock request
|
||||
mock_request = Mock()
|
||||
mock_request.state = None # Prevent Mock from returning a truthy _cached_headers
|
||||
mock_request.method = "POST"
|
||||
mock_request.headers = {
|
||||
"Authorization": "Bearer test-creds",
|
||||
|
|
@ -905,6 +907,7 @@ class TestVertexAIDiscoveryPassThroughHandler:
|
|||
|
||||
# Mock request
|
||||
mock_request = Mock()
|
||||
mock_request.state = None # Prevent Mock from returning a truthy _cached_headers
|
||||
mock_request.method = "POST"
|
||||
mock_request.headers = {
|
||||
"Authorization": "Bearer test-key",
|
||||
|
|
@ -1479,10 +1482,11 @@ class TestForwardHeaders:
|
|||
|
||||
# Create a mock request with custom headers
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.state = None # Prevent MagicMock from returning a truthy _cached_headers
|
||||
mock_request.method = "POST"
|
||||
mock_request.url = MagicMock()
|
||||
mock_request.url.path = "/test/endpoint"
|
||||
|
||||
|
||||
# User headers that should be forwarded
|
||||
user_headers = {
|
||||
"x-custom-header": "custom-value",
|
||||
|
|
|
|||
|
|
@ -253,6 +253,9 @@ async def test_vertex_passthrough_forwards_anthropic_beta_header():
|
|||
"content-length": "1234", # Should be removed
|
||||
"host": "localhost:4000", # Should be removed
|
||||
})
|
||||
# Prevent MagicMock from auto-creating a truthy _cached_headers attribute,
|
||||
# which would short-circuit _safe_get_request_headers before reading .headers
|
||||
mock_request.state._cached_headers = None
|
||||
|
||||
# Create mock vertex credentials
|
||||
mock_vertex_credentials = MagicMock()
|
||||
|
|
|
|||
|
|
@ -1650,58 +1650,64 @@ async def test_global_spend_keys_endpoint_limit_validation(client, monkeypatch):
|
|||
# Create a simple mock for prisma client with empty response
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
mock_query_raw = MagicMock()
|
||||
mock_query_raw.return_value = asyncio.Future()
|
||||
mock_query_raw.return_value.set_result([])
|
||||
mock_query_raw = AsyncMock(return_value=[])
|
||||
mock_db.query_raw = mock_query_raw
|
||||
mock_prisma_client.db = mock_db
|
||||
# Apply the mock to the prisma_client module
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
# Call the endpoint without specifying a limit
|
||||
no_limit_response = client.get("/global/spend/keys")
|
||||
assert no_limit_response.status_code == 200
|
||||
mock_query_raw.assert_called_once_with('SELECT * FROM "Last30dKeysBySpend";')
|
||||
# Reset the mock for the next test
|
||||
mock_query_raw.reset_mock()
|
||||
# Test with valid input
|
||||
normal_limit = "10"
|
||||
good_input_response = client.get(f"/global/spend/keys?limit={normal_limit}")
|
||||
assert good_input_response.status_code == 200
|
||||
# Verify the mock was called with the correct parameters
|
||||
mock_query_raw.assert_called_once_with(
|
||||
'SELECT * FROM "Last30dKeysBySpend" LIMIT $1 ;', 10
|
||||
# Override auth to bypass API key validation
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
|
||||
)
|
||||
# Reset the mock for the next test
|
||||
mock_query_raw.reset_mock()
|
||||
# Test with SQL injection payload
|
||||
sql_injection_limit = "10; DROP TABLE spend_logs; --"
|
||||
response = client.get(f"/global/spend/keys?limit={sql_injection_limit}")
|
||||
# Verify the response is a validation error (422)
|
||||
assert response.status_code == 422
|
||||
# Verify the mock was not called with the SQL injection payload
|
||||
# This confirms that the validation happens before the database query
|
||||
mock_query_raw.assert_not_called()
|
||||
# Reset the mock for the next test
|
||||
mock_query_raw.reset_mock()
|
||||
# Test with non-numeric input
|
||||
non_numeric_limit = "abc"
|
||||
response = client.get(f"/global/spend/keys?limit={non_numeric_limit}")
|
||||
assert response.status_code == 422
|
||||
mock_query_raw.assert_not_called()
|
||||
mock_query_raw.reset_mock()
|
||||
# Test with negative number
|
||||
negative_limit = "-5"
|
||||
response = client.get(f"/global/spend/keys?limit={negative_limit}")
|
||||
assert response.status_code == 422
|
||||
mock_query_raw.assert_not_called()
|
||||
mock_query_raw.reset_mock()
|
||||
# Test with zero
|
||||
zero_limit = "0"
|
||||
response = client.get(f"/global/spend/keys?limit={zero_limit}")
|
||||
assert response.status_code == 422
|
||||
mock_query_raw.assert_not_called()
|
||||
mock_query_raw.reset_mock()
|
||||
|
||||
try:
|
||||
# Call the endpoint without specifying a limit
|
||||
no_limit_response = client.get("/global/spend/keys")
|
||||
assert no_limit_response.status_code == 200
|
||||
mock_query_raw.assert_called_once_with('SELECT * FROM "Last30dKeysBySpend";')
|
||||
# Reset the mock for the next test
|
||||
mock_query_raw.reset_mock()
|
||||
# Test with valid input
|
||||
normal_limit = "10"
|
||||
good_input_response = client.get(f"/global/spend/keys?limit={normal_limit}")
|
||||
assert good_input_response.status_code == 200
|
||||
# Verify the mock was called with the correct parameters
|
||||
mock_query_raw.assert_called_once_with(
|
||||
'SELECT * FROM "Last30dKeysBySpend" LIMIT $1 ;', 10
|
||||
)
|
||||
# Reset the mock for the next test
|
||||
mock_query_raw.reset_mock()
|
||||
# Test with SQL injection payload
|
||||
sql_injection_limit = "10; DROP TABLE spend_logs; --"
|
||||
response = client.get(f"/global/spend/keys?limit={sql_injection_limit}")
|
||||
# Verify the response is a validation error (422)
|
||||
assert response.status_code == 422
|
||||
# Verify the mock was not called with the SQL injection payload
|
||||
# This confirms that the validation happens before the database query
|
||||
mock_query_raw.assert_not_called()
|
||||
# Reset the mock for the next test
|
||||
mock_query_raw.reset_mock()
|
||||
# Test with non-numeric input
|
||||
non_numeric_limit = "abc"
|
||||
response = client.get(f"/global/spend/keys?limit={non_numeric_limit}")
|
||||
assert response.status_code == 422
|
||||
mock_query_raw.assert_not_called()
|
||||
mock_query_raw.reset_mock()
|
||||
# Test with negative number
|
||||
negative_limit = "-5"
|
||||
response = client.get(f"/global/spend/keys?limit={negative_limit}")
|
||||
assert response.status_code == 422
|
||||
mock_query_raw.assert_not_called()
|
||||
mock_query_raw.reset_mock()
|
||||
# Test with zero
|
||||
zero_limit = "0"
|
||||
response = client.get(f"/global/spend/keys?limit={zero_limit}")
|
||||
assert response.status_code == 422
|
||||
mock_query_raw.assert_not_called()
|
||||
mock_query_raw.reset_mock()
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -161,6 +161,21 @@ def test_sanitize_request_body_for_spend_logs_payload_mixed_types():
|
|||
assert len(sanitized["nested"]["dict"]["key"]) == expected_length
|
||||
|
||||
|
||||
def test_sanitize_request_body_for_spend_logs_payload_uses_runtime_env_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
from litellm.constants import MAX_STRING_LENGTH_PROMPT_IN_DB
|
||||
|
||||
override_max = max(MAX_STRING_LENGTH_PROMPT_IN_DB + 1000, 6000)
|
||||
test_string = "a" * (MAX_STRING_LENGTH_PROMPT_IN_DB + 500)
|
||||
|
||||
# Simulate config-loaded env var being set after module import.
|
||||
monkeypatch.setenv("MAX_STRING_LENGTH_PROMPT_IN_DB", str(override_max))
|
||||
|
||||
sanitized = _sanitize_request_body_for_spend_logs_payload({"text": test_string})
|
||||
assert sanitized["text"] == test_string
|
||||
|
||||
|
||||
def test_sanitize_request_body_for_spend_logs_payload_circular_reference():
|
||||
# Create a circular reference
|
||||
a: dict[str, Any] = {}
|
||||
|
|
@ -1349,4 +1364,3 @@ def test_get_logging_payload_includes_request_duration_ms():
|
|||
)
|
||||
|
||||
assert payload["request_duration_ms"] == 3000
|
||||
|
||||
|
|
|
|||
|
|
@ -480,7 +480,7 @@ async def test_perform_health_check_and_save_passes_model_id_to_perform_health_c
|
|||
healthy = [{"model": "gpt-4"}]
|
||||
unhealthy = []
|
||||
|
||||
async def mock_perform_health_check(model_list, model=None, cli_model=None, details=True, model_id=None):
|
||||
async def mock_perform_health_check(model_list, model=None, cli_model=None, details=True, model_id=None, max_concurrency=None):
|
||||
return healthy, unhealthy
|
||||
|
||||
with patch(
|
||||
|
|
|
|||
|
|
@ -242,7 +242,7 @@ def test_full_output_structure_non_streaming():
|
|||
)
|
||||
result = model_dump_with_preserved_fields(response, exclude_unset=True)
|
||||
|
||||
# Top-level keys
|
||||
# Top-level keys (usage is None when not explicitly set and excluded by exclude_unset=True)
|
||||
assert set(result.keys()) == {
|
||||
"id",
|
||||
"choices",
|
||||
|
|
@ -250,7 +250,6 @@ def test_full_output_structure_non_streaming():
|
|||
"model",
|
||||
"object",
|
||||
"system_fingerprint",
|
||||
"usage",
|
||||
}
|
||||
assert result["object"] == "chat.completion"
|
||||
assert result["model"] == "gpt-4.1"
|
||||
|
|
@ -270,12 +269,6 @@ def test_full_output_structure_non_streaming():
|
|||
assert msg["content"] == "Hello!"
|
||||
assert msg["role"] == "assistant"
|
||||
|
||||
# Usage structure
|
||||
usage = result["usage"]
|
||||
assert "prompt_tokens" in usage
|
||||
assert "completion_tokens" in usage
|
||||
assert "total_tokens" in usage
|
||||
|
||||
|
||||
def test_full_output_structure_tool_calls():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -331,7 +331,8 @@ class TestProxyInitializationHelpers:
|
|||
|
||||
@patch("uvicorn.run")
|
||||
@patch("builtins.print")
|
||||
def test_max_requests_before_restart_flag(self, mock_print, mock_uvicorn_run):
|
||||
@patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database")
|
||||
def test_max_requests_before_restart_flag(self, mock_setup_db, mock_print, mock_uvicorn_run):
|
||||
"""Test that the max_requests_before_restart flag is passed to uvicorn as limit_max_requests"""
|
||||
from click.testing import CliRunner
|
||||
|
||||
|
|
@ -344,7 +345,10 @@ class TestProxyInitializationHelpers:
|
|||
mock_key_mgmt = MagicMock()
|
||||
mock_save_worker_config = MagicMock()
|
||||
|
||||
clean_env = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL")}
|
||||
with patch.dict(
|
||||
os.environ, clean_env, clear=True,
|
||||
), patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"proxy_server": MagicMock(
|
||||
|
|
@ -367,7 +371,7 @@ class TestProxyInitializationHelpers:
|
|||
run_server, ["--local", "--max_requests_before_restart", "123"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}"
|
||||
mock_uvicorn_run.assert_called_once()
|
||||
|
||||
# Check that uvicorn.run was called with limit_max_requests parameter
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from litellm.proxy.health_check_utils.shared_health_check_manager import SharedHealthCheckManager
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.health_check_utils.shared_health_check_manager import (
|
||||
SharedHealthCheckManager,
|
||||
)
|
||||
|
||||
|
||||
class TestSharedHealthCheckManager:
|
||||
|
|
@ -272,7 +275,7 @@ class TestSharedHealthCheckManager:
|
|||
)
|
||||
|
||||
# Should call perform_health_check and cache results
|
||||
mock_perform.assert_called_once_with(model_list=model_list, details=True)
|
||||
mock_perform.assert_called_once_with(model_list=model_list, details=True, max_concurrency=None)
|
||||
assert healthy == expected_healthy
|
||||
assert unhealthy == expected_unhealthy
|
||||
|
||||
|
|
@ -329,7 +332,7 @@ class TestSharedHealthCheckManager:
|
|||
|
||||
# Should fall back to local health check
|
||||
mock_sleep.assert_called_once_with(2)
|
||||
mock_perform.assert_called_once_with(model_list=model_list, details=True)
|
||||
mock_perform.assert_called_once_with(model_list=model_list, details=True, max_concurrency=None)
|
||||
assert healthy == expected_healthy
|
||||
assert unhealthy == expected_unhealthy
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from unittest.mock import Mock
|
|||
|
||||
import pytest
|
||||
|
||||
from litellm import Router
|
||||
from litellm.router_utils.common_utils import (
|
||||
_deployment_supports_web_search,
|
||||
filter_team_based_models,
|
||||
|
|
@ -340,3 +341,22 @@ class TestFilterWebSearchDeployments:
|
|||
result = filter_web_search_deployments(deployment, request_kwargs)
|
||||
# Should return the dict unchanged, not filter it
|
||||
assert result == deployment
|
||||
|
||||
|
||||
def test_invalidate_model_group_info_cache():
|
||||
"""Test that _invalidate_model_group_info_cache clears the LRU cache."""
|
||||
router = Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-4",
|
||||
"litellm_params": {"model": "gpt-4", "api_key": "fake-key"},
|
||||
}
|
||||
]
|
||||
)
|
||||
# Populate the cache
|
||||
router._cached_get_model_group_info("gpt-4")
|
||||
assert router._cached_get_model_group_info.cache_info().currsize > 0
|
||||
|
||||
# Invalidate and verify cache is cleared
|
||||
router._invalidate_model_group_info_cache()
|
||||
assert router._cached_get_model_group_info.cache_info().currsize == 0
|
||||
|
|
|
|||
82
ui/litellm-dashboard/package-lock.json
generated
82
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"
|
||||
|
|
|
|||
|
|
@ -118,6 +118,14 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
|
|||
const [pendingCategorySelection, setPendingCategorySelection] = useState<string>("");
|
||||
const [competitorIntentEnabled, setCompetitorIntentEnabled] = useState(false);
|
||||
const [competitorIntentConfig, setCompetitorIntentConfig] = useState<any>(null);
|
||||
|
||||
// Endpoint Settings state (step 5)
|
||||
const [selectedEndpointType, setSelectedEndpointType] = useState<string>("");
|
||||
const [endSessionAfterNFails, setEndSessionAfterNFails] = useState<number | undefined>(undefined);
|
||||
const [onViolation, setOnViolation] = useState<"warn" | "end_session">("warn");
|
||||
const [realtimeViolationMessage, setRealtimeViolationMessage] = useState<string>("");
|
||||
const [endpointSettingsOpen, setEndpointSettingsOpen] = useState<boolean>(false);
|
||||
|
||||
const [toolPermissionConfig, setToolPermissionConfig] = useState<ToolPermissionConfig>({
|
||||
rules: [],
|
||||
default_action: "deny",
|
||||
|
|
@ -361,6 +369,11 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
|
|||
on_disallowed_action: "block",
|
||||
violation_message_template: "",
|
||||
});
|
||||
setSelectedEndpointType("");
|
||||
setEndSessionAfterNFails(undefined);
|
||||
setOnViolation("warn");
|
||||
setRealtimeViolationMessage("");
|
||||
setEndpointSettingsOpen(false);
|
||||
setCurrentStep(0);
|
||||
};
|
||||
|
||||
|
|
@ -504,6 +517,19 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
|
|||
}
|
||||
}
|
||||
|
||||
// Endpoint Settings (realtime) — content filter only
|
||||
if (shouldRenderContentFilterConfigSettings(values.provider)) {
|
||||
if (endSessionAfterNFails !== undefined && endSessionAfterNFails > 0) {
|
||||
guardrailData.litellm_params.end_session_after_n_fails = endSessionAfterNFails;
|
||||
}
|
||||
if (onViolation && selectedEndpointType === "realtime") {
|
||||
guardrailData.litellm_params.on_violation = onViolation;
|
||||
}
|
||||
if (realtimeViolationMessage.trim()) {
|
||||
guardrailData.litellm_params.realtime_violation_message = realtimeViolationMessage.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/******************************
|
||||
* Add provider-specific params
|
||||
* ----------------------------------
|
||||
|
|
@ -841,13 +867,15 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
|
|||
return renderContentFilterConfiguration("keywords");
|
||||
}
|
||||
return null;
|
||||
case 4:
|
||||
return renderEndpointSettings();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepButtons = () => {
|
||||
const totalSteps = shouldRenderContentFilterConfigSettings(selectedProvider) ? 4 : 2;
|
||||
const totalSteps = shouldRenderContentFilterConfigSettings(selectedProvider) ? 5 : 2;
|
||||
const isLastStep = currentStep === totalSteps - 1;
|
||||
const isCategoriesStep = shouldRenderContentFilterConfigSettings(selectedProvider) && currentStep === 1;
|
||||
const hasPendingCategory = pendingCategorySelection !== "";
|
||||
|
|
@ -888,13 +916,137 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
|
|||
);
|
||||
};
|
||||
|
||||
const renderEndpointSettings = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Configure settings for a specific call type. Most guardrails don't need this — skip it
|
||||
unless you're using a specific endpoint like <code>/v1/realtime</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Call type</label>
|
||||
<Select
|
||||
placeholder="Select a call type"
|
||||
value={selectedEndpointType || undefined}
|
||||
onChange={(v) => {
|
||||
setSelectedEndpointType(v);
|
||||
setEndpointSettingsOpen(false);
|
||||
}}
|
||||
style={{ width: 260 }}
|
||||
allowClear
|
||||
options={[{ value: "realtime", label: "/v1/realtime" }]}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">More call types coming soon.</p>
|
||||
</div>
|
||||
|
||||
{selectedEndpointType === "realtime" && (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEndpointSettingsOpen((o) => !o)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 text-sm font-medium text-gray-700"
|
||||
>
|
||||
<span>/v1/realtime settings</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-500 transition-transform ${endpointSettingsOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{endpointSettingsOpen && (
|
||||
<div className="space-y-5 px-4 py-4 border-t border-gray-200">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
End session after X violations
|
||||
</label>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
Automatically close the session after this many guardrail violations. Leave
|
||||
empty to never auto-close.
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="e.g. 3"
|
||||
value={endSessionAfterNFails ?? ""}
|
||||
onChange={(e) =>
|
||||
setEndSessionAfterNFails(
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||
)
|
||||
}
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
On violation
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{(["warn", "end_session"] as const).map((opt) => (
|
||||
<label key={opt} className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="on_violation"
|
||||
value={opt}
|
||||
checked={onViolation === opt}
|
||||
onChange={() => setOnViolation(opt)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-800">
|
||||
{opt === "warn" ? "Warn" : "End session"}
|
||||
</span>
|
||||
<p className="text-xs text-gray-400 m-0">
|
||||
{opt === "warn"
|
||||
? "Bot speaks the message, session continues"
|
||||
: "Bot speaks the message, connection closes immediately"}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Message the user hears
|
||||
</label>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
What the bot says aloud when this guardrail fires. Falls back to the default
|
||||
violation message if empty.
|
||||
</p>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="e.g. I'm not able to continue this conversation. Please contact us at 1-800-774-2678."
|
||||
value={realtimeViolationMessage}
|
||||
onChange={(e) => setRealtimeViolationMessage(e.target.value)}
|
||||
className="border border-gray-300 rounded px-3 py-2 text-sm w-full resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStepConfigs = () => {
|
||||
if (shouldRenderContentFilterConfigSettings(selectedProvider)) {
|
||||
return [
|
||||
{ title: "Basic Info", optional: false },
|
||||
{ title: "Default Categories", optional: false },
|
||||
{ title: "Topics", optional: false },
|
||||
{ title: "Patterns", optional: false },
|
||||
{ title: "Keywords", optional: false },
|
||||
{ title: "Endpoint Settings (Optional)", optional: true },
|
||||
];
|
||||
}
|
||||
if (shouldRenderPIIConfigSettings(selectedProvider)) {
|
||||
|
|
|
|||
|
|
@ -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