Merge branch 'litellm_internal_staging' into litellm_gemini_36_flash_introductory_pricing

This commit is contained in:
mateo-berri 2026-08-17 14:46:26 -07:00
commit 5cf291a3ef
90 changed files with 4373 additions and 479 deletions

View file

@ -1,6 +1,6 @@
{
"reportAny": {
"limit": 22344
"limit": 22343
},
"reportArgumentType": {
"limit": 2578

View file

@ -119,4 +119,7 @@ spec:
{{- end }}
ttlSecondsAfterFinished: {{ .Values.migrationJob.ttlSecondsAfterFinished }}
backoffLimit: {{ .Values.migrationJob.backoffLimit }}
{{- with .Values.migrationJob.activeDeadlineSeconds }}
activeDeadlineSeconds: {{ . }}
{{- end }}
{{- end }}

View file

@ -314,3 +314,31 @@ tests:
operator: Equal
value: litellm-e2e
effect: NoSchedule
- it: bounds the Job with a deadline by default, so a blocked migration cannot stall the release forever
set:
migrationJob:
enabled: true
asserts:
- equal:
path: spec.activeDeadlineSeconds
value: 1800
- it: honours an operator-supplied deadline
set:
migrationJob:
enabled: true
activeDeadlineSeconds: 600
asserts:
- equal:
path: spec.activeDeadlineSeconds
value: 600
- it: omits the deadline entirely when it is nulled out, restoring the unbounded behaviour
set:
migrationJob:
enabled: true
activeDeadlineSeconds: null
asserts:
- notExists:
path: spec.activeDeadlineSeconds

View file

@ -427,6 +427,13 @@ migrationJob:
enabled: true # Enable or disable the schema migration Job
retries: 3 # Number of retries for the Job in case of failure
backoffLimit: 4 # Backoff limit for Job restarts
# Wall-clock budget for the whole Job, shared across every `backoffLimit`
# retry rather than granted per attempt. Without it a migration that blocks
# on the database never fails, and when the Helm hook is enabled the release
# waits on it forever: `helm upgrade` and any GitOps controller driving it
# stop reconciling the whole chart until someone deletes the Job by hand.
# Set to null to opt out and restore the unbounded behaviour.
activeDeadlineSeconds: 1800
disableSchemaUpdate: false # Skip schema migrations for specific environments. When True, the job will exit with code 0.
# Optional service account for the migration job.
# Only used when migrationJob.hooks.helm.enabled=true and serviceAccount.create=true.

View file

@ -21,6 +21,9 @@ metadata:
spec:
backoffLimit: {{ .Values.migrationJob.backoffLimit }}
ttlSecondsAfterFinished: {{ .Values.migrationJob.ttlSecondsAfterFinished }}
{{- with .Values.migrationJob.activeDeadlineSeconds }}
activeDeadlineSeconds: {{ . }}
{{- end }}
template:
metadata:
{{- /* The Job's selector is generated by the controller rather than

View file

@ -167,3 +167,24 @@ tests:
- equal:
path: spec.template.metadata.labels['app.kubernetes.io/component']
value: batch-migrations
- it: bounds the Job with a deadline by default, so a blocked migration cannot stall the release forever
asserts:
- equal:
path: spec.activeDeadlineSeconds
value: 1800
- it: honours an operator-supplied deadline
set:
migrationJob.activeDeadlineSeconds: 600
asserts:
- equal:
path: spec.activeDeadlineSeconds
value: 600
- it: omits the deadline entirely when it is nulled out, restoring the unbounded behaviour
set:
migrationJob.activeDeadlineSeconds: null
asserts:
- notExists:
path: spec.activeDeadlineSeconds

View file

@ -56,6 +56,15 @@ migrationJob:
enabled: true
backoffLimit: 4
ttlSecondsAfterFinished: 120
# Wall-clock budget for the whole Job, shared across every `backoffLimit`
# retry rather than granted per attempt. Without it a migration that blocks
# on the database never fails, and because this is a pre-upgrade hook the
# release waits on it forever: `helm upgrade` and any GitOps controller
# driving it stop reconciling the whole chart until someone deletes the Job
# by hand. A migration that has exhausted its retries is not going to
# succeed on the next one, so failing is strictly better than hanging.
# Set to null to opt out and restore the unbounded behaviour.
activeDeadlineSeconds: 1800
resources: {}
# ServiceAccount for the Job pod only.
#

View file

@ -1593,6 +1593,13 @@ DEFAULT_MCP_ACCESS_GROUP_NEGATIVE_CACHE_TTL: Final = 10
# in a single ``/{name1,name2,...}/mcp`` URL. Bounds the per-request DB / cache
# fan-out an authenticated caller can trigger by stuffing the path with tokens.
DEFAULT_MCP_NAMESPACE_CSV_MAX_TOKENS: Final = 16
# Ceilings on the cached auth registries; larger tables fall back to per-row lookups
# instead of holding an unbounded id set in every worker.
TAG_REGISTRY_MAX_SIZE: Final = 5000
END_USER_RESTRICTED_REGISTRY_MAX_SIZE: Final = 5000
# How long a failed registry load is remembered as "unusable", so a degraded Postgres
# is not re-scanned on every request on top of the per-id lookups it falls back to.
REGISTRY_ERROR_NEGATIVE_CACHE_TTL: Final = 30
# Sentry Scrubbing Configuration
SENTRY_DENYLIST: Final = [

View file

@ -734,6 +734,7 @@ class LiteLLMAnthropicMessagesAdapter:
"input_schema",
"description",
"cache_control",
"strict",
"type",
]
@ -763,6 +764,8 @@ class LiteLLMAnthropicMessagesAdapter:
function_chunk["parameters"] = tool["input_schema"]
if "description" in tool:
function_chunk["description"] = tool["description"]
if "strict" in tool:
function_chunk["strict"] = bool(tool["strict"])
for k, v in tool.items():
if k not in mapped_tool_params: # pass additional computer kwargs

View file

@ -3,7 +3,7 @@
import json
import traceback
from collections import deque
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Mapping
from typing import Any, Final
from litellm import verbose_logger
@ -68,6 +68,19 @@ class AnthropicResponsesStreamWrapper:
self._current_block_index += 1
return self._current_block_index
def _open_block(self, item_id: str | None, content_block: Mapping[str, Any]) -> int:
block_idx = self._next_block_index()
if item_id:
self._item_id_to_block_index[item_id] = block_idx
self._chunk_queue.append(
{
"type": "content_block_start",
"index": block_idx,
"content_block": content_block,
}
)
return block_idx
def _process_event(self, event: Any) -> None:
"""Convert one Responses API event into zero or more Anthropic chunks queued for emission."""
event_type = getattr(event, "type", None)
@ -93,47 +106,22 @@ class AnthropicResponsesStreamWrapper:
item_id = getattr(item, "id", None) or (item.get("id") if isinstance(item, dict) else None)
if item_type == "message":
block_idx = self._next_block_index()
if item_id:
self._item_id_to_block_index[item_id] = block_idx
self._chunk_queue.append(
{
"type": "content_block_start",
"index": block_idx,
"content_block": {"type": "text", "text": ""},
}
)
self._open_block(item_id, {"type": "text", "text": ""})
elif item_type == "function_call":
call_id: Final = (
getattr(item, "call_id", None) or (item.get("call_id") if isinstance(item, dict) else None) or ""
)
name = getattr(item, "name", None) or (item.get("name") if isinstance(item, dict) else None) or ""
block_idx = self._next_block_index()
if item_id:
self._item_id_to_block_index[item_id] = block_idx
self._pending_tool_ids[item_id] = call_id
self._chunk_queue.append(
self._open_block(
item_id,
{
"type": "content_block_start",
"index": block_idx,
"content_block": {
"type": "tool_use",
"id": call_id,
"name": name,
"input": {},
},
}
)
elif item_type == "reasoning":
block_idx = self._next_block_index()
if item_id:
self._item_id_to_block_index[item_id] = block_idx
self._chunk_queue.append(
{
"type": "content_block_start",
"index": block_idx,
"content_block": {"type": "thinking", "thinking": ""},
}
"type": "tool_use",
"id": call_id,
"name": name,
"input": {},
},
)
return
@ -146,16 +134,7 @@ class AnthropicResponsesStreamWrapper:
# Some providers (e.g. LMStudio) skip response.output_item.added,
# so no text block is open yet; synthesize content_block_start
# instead of emitting a delta with index -1
block_idx = self._next_block_index()
if item_id:
self._item_id_to_block_index[item_id] = block_idx
self._chunk_queue.append(
{
"type": "content_block_start",
"index": block_idx,
"content_block": {"type": "text", "text": ""},
}
)
block_idx = self._open_block(item_id, {"type": "text", "text": ""})
self._chunk_queue.append(
{
"type": "content_block_delta",
@ -169,11 +148,11 @@ class AnthropicResponsesStreamWrapper:
if event_type == "response.reasoning_summary_text.delta":
item_id = getattr(event, "item_id", None) or (event.get("item_id") if isinstance(event, dict) else None)
delta = getattr(event, "delta", "") or (event.get("delta", "") if isinstance(event, dict) else "")
block_idx = (
self._item_id_to_block_index.get(item_id, self._current_block_index)
if item_id
else self._current_block_index
)
block_idx = self._item_id_to_block_index.get(item_id, -1) if item_id else self._current_block_index
if block_idx < 0:
if not delta:
return
block_idx = self._open_block(item_id, {"type": "thinking", "thinking": ""})
self._chunk_queue.append(
{
"type": "content_block_delta",
@ -207,11 +186,9 @@ class AnthropicResponsesStreamWrapper:
item_id = (
getattr(item, "id", None) or (item.get("id") if isinstance(item, dict) else None) if item else None
)
block_idx = (
self._item_id_to_block_index.get(item_id, self._current_block_index)
if item_id
else self._current_block_index
)
block_idx = self._item_id_to_block_index.get(item_id, -1) if item_id else self._current_block_index
if block_idx < 0:
return
self._chunk_queue.append(
{
"type": "content_block_stop",

View file

@ -266,7 +266,13 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
if (isinstance(tool_type, str) and tool_type.startswith("web_search")) or tool_name == "web_search":
result.append({"type": "web_search_preview"})
continue
func_tool: dict[str, Any] = {"type": "function", "name": tool_name}
# Responses turns strict mode on when `strict` is omitted, silently rewriting
# `required` to every property. Anthropic tools are non-strict unless asked.
func_tool: dict[str, Any] = {
"type": "function",
"name": tool_name,
"strict": bool(tool_dict.get("strict")),
}
if "description" in tool_dict:
func_tool["description"] = tool_dict["description"]
if "input_schema" in tool_dict:

View file

@ -2416,7 +2416,7 @@ def _build_oauth_authorization_server_response(
_raise_unless_oauth2_discovery_server(mcp_server, mcp_server_name, "not an OAuth authorization server")
return {
"issuer": request_base_url, # point to your proxy
"issuer": f"{request_base_url}/{mcp_server_name}" if mcp_server_name else request_base_url,
"authorization_endpoint": authorization_endpoint,
"token_endpoint": token_endpoint,
"response_types_supported": ["code"],
@ -2464,7 +2464,14 @@ async def oauth_authorization_server_mcp(request: Request, mcp_server_name: str
# Alias for standard OpenID discovery
@router.get("/.well-known/openid-configuration")
async def openid_configuration(request: Request):
response = await oauth_authorization_server_mcp(request)
response: Final = await oauth_authorization_server_mcp(request)
if not isinstance(response, dict):
return response
request_base_url: Final = get_request_base_url(request)
# OIDC verifiers derive this URL from their configured issuer (the proxy origin),
# so keep the origin issuer here even when root resolution scoped the metadata.
unscoped_response: Final = {**response, "issuer": request_base_url}
# If MCPJWTSigner is active, augment the discovery doc with JWKS fields so
# MCP servers and gateways (e.g. AWS Bedrock AgentCore Gateway) can resolve
@ -2476,17 +2483,15 @@ async def openid_configuration(request: Request):
signer: Final = get_mcp_jwt_signer()
if signer is not None:
request_base_url: Final = get_request_base_url(request)
if isinstance(response, dict):
response = {
**response,
"jwks_uri": f"{request_base_url}/.well-known/jwks.json",
"id_token_signing_alg_values_supported": ["RS256"],
}
return {
**unscoped_response,
"jwks_uri": f"{request_base_url}/.well-known/jwks.json",
"id_token_signing_alg_values_supported": ["RS256"],
}
except ImportError:
pass
return response
return unscoped_response
@router.get("/.well-known/jwks.json")

View file

@ -13,7 +13,7 @@ import json
import os
import re
import time
from collections.abc import AsyncIterator, Callable, Sequence
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypedDict, cast
from urllib.parse import ParseResult, urlparse
@ -46,6 +46,9 @@ from litellm.constants import (
)
from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException
from litellm.experimental_mcp_client.client import MCPClient, MCPSigV4Auth
from litellm.integrations.custom_guardrail import (
_sync_guardrail_info_to_logging_obj, # pyright: ignore[reportPrivateUsage] - the same bridge @log_guardrail_information uses; reimplementing it here would fork the metadata-key logic
)
from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
@ -162,6 +165,7 @@ if TYPE_CHECKING:
from mcp.types import CreateMessageRequestParams
from litellm.caching.caching import InMemoryCache
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.types.mcp_server.mcp_toolset import MCPToolset
try:
@ -1233,6 +1237,35 @@ def _create_elicitation_callback():
return _elicitation_callback
def _record_mcp_guardrail_evaluations(
synthetic_llm_data: dict[str, Any], # mutable-ok: `_sync_guardrail_info_to_logging_obj` takes a concrete dict
litellm_logging_obj: "LiteLLMLoggingObj | None",
) -> None:
"""Bridge guardrail decision records off an MCP synthetic request onto the request's logger.
MCP guardrails run against a throwaway LLM-shaped dict from
``ProxyLogging._convert_mcp_to_llm_format``, so ``@log_guardrail_information``
files ``standard_logging_guardrail_information`` in that dict's metadata bucket,
which ``get_standard_logging_object_payload`` never reads. Native (non-unified)
guardrails receive no ``logging_obj`` kwarg, so the decorator cannot bridge on
their behalf; this calls the same helper it would have.
Only the decision records move. The synthetic request's messages and tool
arguments stay behind: they can carry end-user data, and the monitor needs none
of it.
"""
if litellm_logging_obj is None:
return
try:
_sync_guardrail_info_to_logging_obj(synthetic_llm_data, litellm_logging_obj)
except Exception as e: # noqa: BLE001 # callers run this from a `finally` on the block path
# The breadth is the point. Narrowing to the knowable AttributeError/TypeError
# would let an unexpected type escape that ``finally`` and replace the guardrail's
# block with a bookkeeping error.
verbose_logger.warning("Failed to record MCP guardrail evaluation for logging: %s", e)
class MCPServerManager:
_STDIO_ENV_TEMPLATE_PATTERN = re.compile(r"^\$\{(X-[^}]+)\}$")
@ -4573,6 +4606,7 @@ class MCPServerManager:
proxy_logging_obj: ProxyLogging | None,
server: MCPServer,
raw_headers: dict[str, str] | None = None,
litellm_logging_obj: "LiteLLMLoggingObj | None" = None,
) -> dict[str, Any]:
"""
Run pre-call checks and guardrail hooks for an MCP tool call.
@ -4582,6 +4616,10 @@ class MCPServerManager:
present. An absent logger must never be able to turn an authorization
decision into a no-op.
``litellm_logging_obj`` is the request's logger, and it is what lands a
``pre_mcp_call`` evaluation (or a block) on the spend-log row the Guardrails
Monitor counts. It stays optional so callers that do no logging are unchanged.
Returns a dict that may contain:
- "arguments": hook-modified tool arguments (only if changed)
- "extra_headers": headers injected by pre_mcp_call guardrail hooks
@ -4640,8 +4678,13 @@ class MCPServerManager:
# Create MCP request object for processing
mcp_request_obj: Final = proxy_logging_obj._create_mcp_request_object_from_kwargs(pre_hook_kwargs)
# Convert to LLM format for existing guardrail compatibility
# Convert to LLM format for existing guardrail compatibility.
# Unified guardrails read the seeded logger off the request dict and pass it
# into ``apply_guardrail``, so ``@log_guardrail_information`` bridges their
# evaluations itself; the ``finally`` below covers native guardrails, which
# never receive it. Same seeding the pass-through routes do.
synthetic_llm_data: Final = proxy_logging_obj._convert_mcp_to_llm_format(mcp_request_obj, pre_hook_kwargs)
synthetic_llm_data["litellm_logging_obj"] = litellm_logging_obj
try:
# Use standard pre_call_hook
@ -4666,6 +4709,12 @@ class MCPServerManager:
# Re-raise guardrail exceptions to properly fail the MCP call
verbose_logger.error("Guardrail blocked MCP tool call pre call: %s", e)
raise e
finally:
# ``finally`` rather than after the ``try``: a block raises straight out of
# here, and the failure spend-log row that "Total Blocked" counts is built
# from this logger further up the stack, so the record has to be attached
# before the exception leaves this frame.
_record_mcp_guardrail_evaluations(synthetic_llm_data, litellm_logging_obj)
return hook_result
@ -4677,8 +4726,14 @@ class MCPServerManager:
user_api_key_auth: UserAPIKeyAuth | None,
proxy_logging_obj: ProxyLogging,
start_time: datetime.datetime,
litellm_logging_obj: "LiteLLMLoggingObj | None" = None,
):
"""Create and return a during hook task for MCP tool calls."""
"""Create and return a during hook task for MCP tool calls.
``litellm_logging_obj`` is the request's logger; see ``pre_call_tool_check``.
The task is awaited before the tool call's success logging runs, so a
``during_mcp_call`` evaluation recorded on it is serialized with that call.
"""
from litellm.types.llms.base import HiddenParams
from litellm.types.mcp import MCPDuringCallRequestObject
@ -4697,15 +4752,23 @@ class MCPServerManager:
"user_api_key_auth": user_api_key_auth,
}
# Seeded for the same reason as in ``pre_call_tool_check``.
synthetic_llm_data: Final = proxy_logging_obj._convert_mcp_to_llm_format(request_obj, during_hook_kwargs)
synthetic_llm_data["litellm_logging_obj"] = litellm_logging_obj
return asyncio.create_task(
proxy_logging_obj.during_call_hook(
user_api_key_dict=user_api_key_auth,
data=synthetic_llm_data,
call_type=CallTypes.call_mcp_tool.value,
)
)
# Wrapped so the bridge runs inside the task: the caller only holds the task and
# gathers it later, so there is no other point that still sees a block here.
async def _run_during_call_hook() -> Mapping[str, Any] | None:
try:
return await proxy_logging_obj.during_call_hook(
user_api_key_dict=user_api_key_auth,
data=synthetic_llm_data,
call_type=CallTypes.call_mcp_tool.value,
)
finally:
_record_mcp_guardrail_evaluations(synthetic_llm_data, litellm_logging_obj)
return asyncio.create_task(_run_during_call_hook())
def _get_call_semaphore(self, mcp_server: MCPServer) -> asyncio.Semaphore | None:
limit: Final = mcp_server.max_concurrent_requests
@ -5234,6 +5297,7 @@ class MCPServerManager:
oauth2_headers: dict[str, str] | None = None,
raw_headers: dict[str, str] | None = None,
host_progress_callback: Callable | None = None,
litellm_logging_obj: "LiteLLMLoggingObj | None" = None,
) -> CallToolResult:
"""
Call a tool with the given name and arguments
@ -5246,6 +5310,9 @@ class MCPServerManager:
mcp_auth_header: MCP auth header (deprecated)
mcp_server_auth_headers: Optional dict of server-specific auth headers {server_alias: auth_value}
proxy_logging_obj: Optional ProxyLogging object for hook integration
litellm_logging_obj: Optional request logger the guardrail hooks record
their evaluations onto, so MCP guardrail activity reaches the
Guardrails Monitor. See ``pre_call_tool_check``
Returns:
@ -5276,6 +5343,7 @@ class MCPServerManager:
proxy_logging_obj=proxy_logging_obj,
server=mcp_server,
raw_headers=raw_headers,
litellm_logging_obj=litellm_logging_obj,
)
if "arguments" in hook_result:
arguments = hook_result["arguments"]
@ -5290,6 +5358,7 @@ class MCPServerManager:
user_api_key_auth=user_api_key_auth,
proxy_logging_obj=proxy_logging_obj,
start_time=start_time,
litellm_logging_obj=litellm_logging_obj,
)
tasks.append(during_hook_task)

View file

@ -2824,6 +2824,7 @@ if MCP_AVAILABLE:
proxy_logging_obj=proxy_logging_obj,
server=mcp_server,
raw_headers=raw_headers,
litellm_logging_obj=litellm_logging_obj,
)
# `pre_call_tool_check` may return guardrail-modified
# arguments; honor them on the local path too.
@ -2962,6 +2963,7 @@ if MCP_AVAILABLE:
proxy_logging_obj=proxy_logging_obj,
server=prefix_server,
raw_headers=raw_headers,
litellm_logging_obj=litellm_logging_obj,
)
if "arguments" in hook_result:
arguments = hook_result["arguments"] # pyright: ignore[reportAny] # hook returns untyped args
@ -3149,6 +3151,20 @@ if MCP_AVAILABLE:
traceback_str: Final = traceback.format_exc(limit=MAXIMUM_TRACEBACK_LINES_TO_LOG)
from litellm.proxy.proxy_server import proxy_logging_obj
# Ordering is load-bearing. ``_ProxyDBLogger.async_post_call_failure_hook``,
# reached below, writes the failure spend-log row from this logger's
# ``standard_logging_object``, which only exists once the failure handlers
# have run. Flush them first or the row lands with
# ``guardrail_information=None`` and a guardrail block is never counted.
#
# Not double-logged: both handlers gate on ``should_run_logging`` and then
# mark it, so the ``@client`` wrapper's own post-raise logging no-ops on this
# logger, same as ``_fire_mcp_tool_call_logging`` does for ``isError=True``.
if litellm_logging_obj is not None:
end_time: Final = datetime.now() # noqa: DTZ005 # naive to match `start_time`, which it is subtracted from
litellm_logging_obj.failure_handler(e, traceback_str, start_time, end_time)
await litellm_logging_obj.async_failure_handler(e, traceback_str, start_time, end_time)
if proxy_logging_obj and user_api_key_auth:
await proxy_logging_obj.post_call_failure_hook(
request_data=kwargs,
@ -3326,6 +3342,7 @@ if MCP_AVAILABLE:
raw_headers=raw_headers,
proxy_logging_obj=proxy_logging_obj,
host_progress_callback=host_progress_callback,
litellm_logging_obj=litellm_logging_obj,
)
verbose_logger.debug("CALL TOOL RESULT: %s", call_tool_result)
return call_tool_result

View file

@ -680,6 +680,7 @@ class LiteLLMRoutes(enum.Enum):
# permitted teams exactly like /spend/logs/ui — it belongs to the same
# access tier, not to customer management.
"/management/v1/spend_logs/end_users",
"/management/v1/spend_logs/users",
"/cost/estimate",
]
@ -872,12 +873,13 @@ class LiteLLMRoutes(enum.Enum):
# PROXY_ADMIN_VIEW_ONLY — the route gate must match).
"/customer/list",
"/customer/info",
# UI Logs page detail drawer (single + session) and the end-user filter
# facet. The list endpoint `/spend/logs/ui` is covered via
# UI Logs page detail drawer (single + session) and the filter facets.
# The list endpoint `/spend/logs/ui` is covered via
# spend_tracking_routes below.
"/spend/logs/ui/{logId}",
"/spend/logs/session/ui",
"/management/v1/spend_logs/end_users",
"/management/v1/spend_logs/users",
# Settings / observability read endpoints exposed in admin-only
# sidebar groups (Logging & Alerts, Admin Settings, Budgets,
# Invitations).

View file

@ -193,6 +193,9 @@ async def anthropic_response(
)
verbose_proxy_logger.exception("litellm.proxy.proxy_server.anthropic_response(): Exception occured - %s", e)
if isinstance(e, ProxyException):
raise
# Extract model_id from request metadata (same as success path)
litellm_metadata: Final = data.get("litellm_metadata", {}) or {}
model_info: Final = litellm_metadata.get("model_info", {}) or {}

View file

@ -13,7 +13,7 @@ import asyncio
import math
import re
import time
from collections.abc import Iterator, Mapping, Sequence
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, cast
@ -30,6 +30,9 @@ from litellm.constants import (
DEFAULT_IN_MEMORY_TTL,
DEFAULT_MAX_RECURSE_DEPTH,
EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE,
END_USER_RESTRICTED_REGISTRY_MAX_SIZE,
REGISTRY_ERROR_NEGATIVE_CACHE_TTL,
TAG_REGISTRY_MAX_SIZE,
)
from litellm.litellm_core_utils.dd_tracing import tracer
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
@ -74,9 +77,15 @@ from litellm.proxy.common_utils.http_parsing_utils import (
)
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
from litellm.proxy.common_utils.user_api_key_cache import (
END_USER_RESTRICTED_REGISTRY_OVERFLOW_SENTINEL,
TAG_REGISTRY_OVERFLOW_SENTINEL,
UserApiKeyCache,
end_user_cache_key,
end_user_restricted_registry_cache_key,
get_management_object_ttl,
object_permission_cache_key,
tag_cache_key,
tag_registry_cache_key,
)
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.proxy.guardrails.tool_name_extraction import (
@ -163,7 +172,7 @@ class _PrismaAuthTable(Protocol[RowT_co]):
async def find_many(
self,
*,
where: Mapping[str, object],
where: Mapping[str, object] | None = None,
include: Mapping[str, object] | None = None,
take: int | None = None,
) -> Sequence[RowT_co]: ...
@ -220,6 +229,16 @@ def _tag_table(repo: _PrismaTableHolder[_PrismaTagRow]) -> _PrismaAuthTable[_Pri
return repo.table
class _PrismaEndUserRow(Protocol):
user_id: str
def dict(self) -> Mapping[str, object]: ...
def _end_user_table(repo: _PrismaTableHolder[_PrismaEndUserRow]) -> _PrismaAuthTable[_PrismaEndUserRow]:
return repo.table
class _RawCacheRead(Protocol):
async def async_get_cache(self, *, key: str) -> object: ...
@ -1284,6 +1303,191 @@ async def _check_end_user_budget(
)
#: Columns whose non-null value makes an end-user row restrict something auth enforces. ``blocked``
#: is separate: it restricts when true rather than when merely set.
_RESTRICTED_COLUMNS: Final = ("budget_id", "allowed_model_region", "default_model", "object_permission_id")
def _column_is_set(column: str) -> Mapping[str, object]:
"""``column IS NOT NULL`` as a plain dict, which is the only shape prisma's builder accepts."""
return {column: {"not": None}} # mutable-ok: prisma's query builder isinstance-checks for dict
def _restricted_end_user_where() -> Mapping[str, object]:
"""Prisma filter selecting every end-user row that carries a restriction auth enforces."""
return {"OR": [{"blocked": True}, *map(_column_is_set, _RESTRICTED_COLUMNS)]} # mutable-ok: prisma needs dict/list
class _RegistryNotCached:
"""No cached registry answer, as distinct from the cached answer ``None`` (registry unusable)."""
_REGISTRY_NOT_CACHED: Final = _RegistryNotCached()
#: One lock per registry; module-level because the stampede to collapse is worker-wide.
_TAG_REGISTRY_LOAD_LOCK: Final = asyncio.Lock()
_END_USER_REGISTRY_LOAD_LOCK: Final = asyncio.Lock()
async def _cached_registry(
cache_key: str,
overflow_sentinel: str,
user_api_key_cache: UserApiKeyCache,
) -> frozenset[str] | None | _RegistryNotCached:
"""The cached registry answer, or ``_REGISTRY_NOT_CACHED`` when the caller has to query."""
cached: Final = await _raw_cache(user_api_key_cache).async_get_cache(key=cache_key)
if cached == overflow_sentinel:
return None
# Memory hands back the tuple that was written; Redis round-trips it through JSON as a list.
if isinstance(cached, (list, tuple)):
return frozenset(entry for entry in cached if isinstance(entry, str))
return _REGISTRY_NOT_CACHED
async def _cache_registry_answer(
cache_key: str,
value: tuple[str, ...] | str,
ttl: float,
user_api_key_cache: UserApiKeyCache,
) -> None:
"""Best-effort: a cache backend failure must not turn a registry load into a failed request."""
try:
await user_api_key_cache.async_set_cache(key=cache_key, value=value, ttl=ttl)
except Exception as e: # noqa: BLE001 # best-effort cache write: auth must survive a cache backend error
verbose_proxy_logger.warning("Failed to cache registry %s: %s", cache_key, e)
async def _fetch_and_cache_registry(
cache_key: str,
overflow_sentinel: str,
max_size: int,
fetch_ids: Callable[[], Awaitable[tuple[str, ...]]],
user_api_key_cache: UserApiKeyCache,
) -> frozenset[str] | None:
"""The registry as the database has it, cached whole, or ``None`` when it is unusable."""
try:
registry_ids: Final = await fetch_ids()
except Exception as e: # noqa: BLE001 # fail-safe: any registry load error must degrade to per-id lookups, never break auth
verbose_proxy_logger.warning(
"Registry %s could not be loaded from the database, so per-id lookups will run and the "
"registry query is suppressed for %ss: %s",
cache_key,
REGISTRY_ERROR_NEGATIVE_CACHE_TTL,
e,
)
await _cache_registry_answer(
cache_key=cache_key,
value=overflow_sentinel,
ttl=REGISTRY_ERROR_NEGATIVE_CACHE_TTL,
user_api_key_cache=user_api_key_cache,
)
return None
if len(registry_ids) > max_size:
await _cache_registry_answer(
cache_key=cache_key,
value=overflow_sentinel,
ttl=get_management_object_ttl(user_api_key_cache),
user_api_key_cache=user_api_key_cache,
)
return None
await _cache_registry_answer(
cache_key=cache_key,
value=registry_ids,
ttl=get_management_object_ttl(user_api_key_cache),
user_api_key_cache=user_api_key_cache,
)
return frozenset(registry_ids)
async def _load_bounded_registry(
cache_key: str,
overflow_sentinel: str,
max_size: int,
load_lock: asyncio.Lock,
fetch_ids: Callable[[], Awaitable[tuple[str, ...]]],
user_api_key_cache: UserApiKeyCache,
) -> frozenset[str] | None:
"""
A bounded id set under one cache key, so an id outside it costs no DB read.
``None`` = unusable (overflow or recent DB error): fall back to per-id lookups. An empty
frozenset is a real, cacheable answer. Loads are single-flighted to stop TTL-expiry stampedes.
"""
cached: Final = await _cached_registry(cache_key, overflow_sentinel, user_api_key_cache)
if not isinstance(cached, _RegistryNotCached):
return cached
async with load_lock:
# The request that held the lock has since cached an answer for everyone waiting on it.
cached_after_wait: Final = await _cached_registry(cache_key, overflow_sentinel, user_api_key_cache)
if not isinstance(cached_after_wait, _RegistryNotCached):
return cached_after_wait
return await _fetch_and_cache_registry(
cache_key=cache_key,
overflow_sentinel=overflow_sentinel,
max_size=max_size,
fetch_ids=fetch_ids,
user_api_key_cache=user_api_key_cache,
)
async def _load_end_user_restricted_registry(
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
) -> frozenset[str] | None:
"""The set of end-user ids whose ``LiteLLM_EndUserTable`` row carries a restriction."""
async def fetch_ids() -> tuple[str, ...]:
restricted_rows: Final = await _end_user_table(EndUserRepository(prisma_client)).find_many(
where=_restricted_end_user_where(),
take=END_USER_RESTRICTED_REGISTRY_MAX_SIZE + 1,
)
return tuple(row.user_id for row in restricted_rows)
return await _load_bounded_registry(
cache_key=end_user_restricted_registry_cache_key(),
overflow_sentinel=END_USER_RESTRICTED_REGISTRY_OVERFLOW_SENTINEL,
max_size=END_USER_RESTRICTED_REGISTRY_MAX_SIZE,
load_lock=_END_USER_REGISTRY_LOAD_LOCK,
fetch_ids=fetch_ids,
user_api_key_cache=user_api_key_cache,
)
async def _end_user_is_known_unrestricted(
end_user_id: str,
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
token_end_user_max_budget: float | None,
) -> bool:
"""
True when the cached registry proves the id restricts nothing, so its row need not be read.
Every field ``get_end_user_object`` callers consume (budget, spend under that budget, region,
default model, object permission, blocked) is part of the registry predicate, so an id outside
it is indistinguishable from one with no row at all. The skip is off whenever mere existence of
the row is meaningful: ``max_end_user_budget_id`` grafts a default budget onto any row that
exists, ``validate_end_user_id_in_db`` rejects ids that resolve to no row, and a token-supplied
``end_user_max_budget`` (a ``user_custom_auth`` callable can set one against an otherwise
unrestricted row) is enforced against the row's recorded spend.
"""
if (
litellm.max_end_user_budget_id is not None
or litellm.validate_end_user_id_in_db
or token_end_user_max_budget is not None
):
return False
registry: Final = await _load_end_user_restricted_registry(
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
return registry is not None and end_user_id not in registry
@log_db_metrics
async def get_end_user_object(
end_user_id: str | None,
@ -1292,6 +1496,7 @@ async def get_end_user_object(
route: str | None = "",
parent_otel_span: Span | None = None,
proxy_logging_obj: ProxyLogging | None = None,
token_end_user_max_budget: float | None = None,
) -> LiteLLM_EndUserTable | None:
"""
Returns end user object from database or cache.
@ -1306,6 +1511,9 @@ async def get_end_user_object(
route: The request route
parent_otel_span: Optional OpenTelemetry span for tracing
proxy_logging_obj: Optional proxy logging object
token_end_user_max_budget: ``valid_token.end_user_max_budget``, when the caller holds a
token. Budget enforcement reads the row's spend, so a row that restricts nothing on
its own must still be loaded when the token carries a budget for it.
Returns:
LiteLLM_EndUserTable if found, None otherwise
@ -1316,7 +1524,7 @@ async def get_end_user_object(
if end_user_id is None:
return None
_key: Final = f"end_user_id:{end_user_id}"
_key: Final = end_user_cache_key(end_user_id)
# Check cache first
cached_user_obj: Final = await user_api_key_cache.async_get_cache(
@ -1335,6 +1543,14 @@ async def get_end_user_object(
return return_obj
if await _end_user_is_known_unrestricted(
end_user_id=end_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
token_end_user_max_budget=token_end_user_max_budget,
):
return None
# Fetch from database
try:
response: Final = await _dictable_table(EndUserRepository(prisma_client)).find_unique(
@ -1358,9 +1574,10 @@ async def get_end_user_object(
# Save to cache
await user_api_key_cache.async_set_cache(
key=f"end_user_id:{end_user_id}",
key=_key,
value=_response,
model_type=LiteLLM_EndUserTable,
ttl=get_management_object_ttl(user_api_key_cache),
)
return _response
@ -1480,6 +1697,67 @@ async def _end_user_id_exists_in_db(
return False
async def _load_tag_registry(
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
) -> frozenset[str] | None:
"""The set of tag names that have a row in ``LiteLLM_TagTable``."""
async def fetch_ids() -> tuple[str, ...]:
registry_rows: Final = await _tag_table(TagRepository(prisma_client)).find_many(
take=TAG_REGISTRY_MAX_SIZE + 1,
)
return tuple(row.tag_name for row in registry_rows)
return await _load_bounded_registry(
cache_key=tag_registry_cache_key(),
overflow_sentinel=TAG_REGISTRY_OVERFLOW_SENTINEL,
max_size=TAG_REGISTRY_MAX_SIZE,
load_lock=_TAG_REGISTRY_LOAD_LOCK,
fetch_ids=fetch_ids,
user_api_key_cache=user_api_key_cache,
)
async def _fetch_uncached_tags(
uncached_tags: Sequence[str],
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
) -> tuple[tuple[str, LiteLLM_TagTable], ...]:
"""Rows for the tags a cache probe missed; names absent from the registry never reach the DB."""
if not uncached_tags:
return ()
registry: Final = await _load_tag_registry(
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
tags_to_fetch: Final = (
tuple(uncached_tags) if registry is None else tuple(tag for tag in uncached_tags if tag in registry)
)
if not tags_to_fetch:
return ()
try:
db_tags: Final = await _tag_table(TagRepository(prisma_client)).find_many(
where={"tag_name": {"in": list(tags_to_fetch)}},
include={"litellm_budget_table": True},
)
fetched: Final = tuple((db_tag.tag_name, LiteLLM_TagTable.model_validate(db_tag.dict())) for db_tag in db_tags)
for fetched_name, fetched_obj in fetched:
await user_api_key_cache.async_set_cache(
key=tag_cache_key(fetched_name),
value=fetched_obj,
model_type=LiteLLM_TagTable,
ttl=get_management_object_ttl(user_api_key_cache),
)
except Exception as e: # noqa: BLE001 # fail-safe: a tag fetch error must yield "no budget objects", never break auth
verbose_proxy_logger.debug("Error batch fetching tags from database: %s", e)
return ()
else:
return fetched
@log_db_metrics
async def get_tag_objects_batch(
tag_names: list[str],
@ -1492,8 +1770,9 @@ async def get_tag_objects_batch(
Batch fetch multiple tag objects from cache and db.
Optimizes for latency by:
1. Fetching all cached tags in parallel
2. Batch fetching uncached tags in one DB query
1. Serving already-cached tags without touching the DB
2. Skipping tags that no ``LiteLLM_TagTable`` row exists for, via the cached name registry
3. Batch fetching the remaining uncached tags in one DB query
Args:
tag_names: List of tag names to fetch
@ -1505,50 +1784,22 @@ async def get_tag_objects_batch(
Returns:
Dictionary mapping tag_name to LiteLLM_TagTable object
"""
if prisma_client is None:
if prisma_client is None or not tag_names:
return {}
if not tag_names:
return {}
tag_objects: Final = dict[str, LiteLLM_TagTable]()
uncached_tags: Final = list[str]()
# Try to get all tags from cache first
for tag_name in tag_names:
cache_key = f"tag:{tag_name}"
cached_tag = await user_api_key_cache.async_get_cache(
key=cache_key,
model_type=LiteLLM_TagTable,
probed: Final = [
(
tag_name,
await user_api_key_cache.async_get_cache(key=tag_cache_key(tag_name), model_type=LiteLLM_TagTable),
)
if cached_tag is not None:
tag_objects[tag_name] = cached_tag
else:
uncached_tags.append(tag_name)
# Batch fetch uncached tags from DB in one query
if uncached_tags:
try:
db_tags: Final = await _tag_table(TagRepository(prisma_client)).find_many(
where={"tag_name": {"in": uncached_tags}},
include={"litellm_budget_table": True},
)
# Cache and add to tag_objects
for db_tag in db_tags:
tag_name = db_tag.tag_name
cache_key = f"tag:{tag_name}"
_tag_obj = LiteLLM_TagTable.model_validate(db_tag.dict())
await user_api_key_cache.async_set_cache(
key=cache_key,
value=_tag_obj,
model_type=LiteLLM_TagTable,
)
tag_objects[tag_name] = _tag_obj
except Exception as e:
verbose_proxy_logger.debug("Error batch fetching tags from database: %s", e)
return tag_objects
for tag_name in tag_names
]
fetched: Final = await _fetch_uncached_tags(
uncached_tags=tuple(tag_name for tag_name, tag_obj in probed if tag_obj is None),
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
return {tag_name: tag_obj for tag_name, tag_obj in (*probed, *fetched) if tag_obj is not None}
@log_db_metrics
@ -4573,25 +4824,15 @@ async def delete_cached_project_object(
user_api_key_cache: UserApiKeyCache,
) -> None:
"""
Every endpoint that mutates litellm_projecttable must call this: get_project_object
serves auth cache-first with no freshness check, so without invalidation a stale
project (e.g. a pre-update empty model allowlist) keeps being enforced until the
TTL expires (LIT-3803). Best-effort on both steps: the DB write has already
committed, so a cache backend error must not fail the endpoint; the stale entry
then expires via TTL.
Every endpoint that mutates litellm_projecttable must call this, or a stale project (e.g. a
pre-update empty model allowlist) keeps being enforced until the TTL expires (LIT-3803).
"""
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import publish_auth_cache_invalidation
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast
cache_key: Final = _project_cache_key(project_id)
try:
await user_api_key_cache.async_delete_cache(key=cache_key)
except Exception as e: # noqa: BLE001 # best-effort eviction: any cache backend error must not fail the mutation
verbose_proxy_logger.warning(
"Failed to evict cached project entry %s; a stale project may be served until its TTL expires: %s",
cache_key,
e,
)
await publish_auth_cache_invalidation(cache_key=cache_key)
await evict_and_broadcast(
cache_keys=(_project_cache_key(project_id),),
user_api_key_cache=user_api_key_cache,
)
async def _organization_max_budget_check(

View file

@ -2307,6 +2307,7 @@ async def _run_centralized_common_checks(
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
route=route,
token_end_user_max_budget=user_api_key_auth_obj.end_user_max_budget,
),
)
)
@ -2841,6 +2842,7 @@ async def _lookup_end_user_and_apply_budget(
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
route=route,
token_end_user_max_budget=valid_token.end_user_max_budget,
)
if end_user_object is not None:
end_user_params = {

View file

@ -40,6 +40,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import (
update_batch_in_database,
validate_managed_id_requirement,
)
from litellm.proxy.route_llm_request import raise_if_required_body_param_missing
from litellm.proxy.utils import handle_exception_on_proxy, is_known_model
from litellm.repositories.table_repositories import ManagedFileRepository
from litellm.types.llms.openai import LiteLLMBatchCreateRequest
@ -140,6 +141,8 @@ async def create_batch(
)
data["metadata"] = sanitize_openai_provider_metadata(data.get("metadata"))
raise_if_required_body_param_missing(route_type="acreate_batch", data=data)
## check if model is a loadbalanced model
router_model: str | None = None
is_router_model = False

View file

@ -1,5 +1,6 @@
import asyncio
import json
from collections.abc import Sequence
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Final
@ -72,6 +73,27 @@ async def publish_auth_cache_invalidation(cache_key: str) -> None:
verbose_proxy_logger.warning("auth cache invalidation publish for %s failed: %s", cache_key, e)
async def evict_and_broadcast(cache_keys: Sequence[str], user_api_key_cache: "UserApiKeyCache") -> None:
"""
Drop cached management objects here and on every other worker.
Every endpoint that mutates a cached object must call this: auth serves those objects
cache-first with no freshness check, so a mutation that leaves the entry in place keeps the
stale object enforced until its TTL expires (LIT-3803). Best-effort on both steps: the DB write
has already committed, so a cache backend error must not fail the endpoint.
"""
for cache_key in cache_keys:
try:
await user_api_key_cache.async_delete_cache(key=cache_key)
except Exception as e: # noqa: BLE001 # best-effort eviction: any cache backend error must not fail the mutation
verbose_proxy_logger.warning(
"Failed to evict cached entry %s; a stale object may be served until its TTL expires: %s",
cache_key,
e,
)
await publish_auth_cache_invalidation(cache_key=cache_key)
class AuthCacheInvalidationSubscriber:
__slots__ = ("_redis_cache", "_task", "_user_api_key_cache")

View file

@ -28,6 +28,7 @@ from litellm.proxy.common_utils.timezone_utils import (
compute_budget_reset_at,
get_budget_reset_settings,
)
from litellm.proxy.common_utils.user_api_key_cache import tag_cache_key
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.repositories.organization_repository import OrganizationRepository
from litellm.repositories.prisma_protocols import ReadOnlyTable, SpendLinkedTable
@ -112,7 +113,7 @@ def _tag_counter_key(row: _TagRow) -> str:
def _tag_cache_keys(row: _TagRow) -> tuple[str, ...]:
return (f"tag:{row.tag_name}",)
return (tag_cache_key(row.tag_name),)
def _budget_link_where(

View file

@ -170,6 +170,36 @@ def object_permission_cache_key(object_permission_id: str) -> str:
return f"object_permission_id:{object_permission_id}"
#: Cached under ``tag_registry_cache_key`` when the table exceeds ``TAG_REGISTRY_MAX_SIZE``:
#: registry unusable, fall back to the per-tag lookup.
TAG_REGISTRY_OVERFLOW_SENTINEL: Final = "__tag_registry_overflow__"
def tag_cache_key(tag_name: str) -> str:
"""Cache key one tag row is stored under; shared so its five reader/writer modules cannot drift."""
return f"tag:{tag_name}"
def tag_registry_cache_key() -> str:
"""Cache key for the set of tag names that exist in ``LiteLLM_TagTable``."""
return "tag_registry"
#: Cached under ``end_user_restricted_registry_cache_key`` when the restricted set exceeds
#: ``END_USER_RESTRICTED_REGISTRY_MAX_SIZE``: registry unusable, fall back to the per-id fetch.
END_USER_RESTRICTED_REGISTRY_OVERFLOW_SENTINEL: Final = "__end_user_restricted_registry_overflow__"
def end_user_cache_key(end_user_id: str) -> str:
"""Cache key one end-user row is stored under; shared so auth and spend tracking cannot drift."""
return f"end_user_id:{end_user_id}"
def end_user_restricted_registry_cache_key() -> str:
"""Cache key for the set of end-user ids whose row carries a restriction auth enforces."""
return "end_user_restricted_registry"
def get_management_object_ttl(cache: DualCache) -> float:
"""
In-memory TTL for management-object cache writes (keys, teams, users, budgets, ...).

View file

@ -38,6 +38,8 @@ from litellm.proxy._types import (
CommonProxyErrors,
LitellmDataForBackendLLMCall,
LitellmUserRoles,
ProxyErrorTypes,
ProxyException,
SpecialHeaders,
TeamCallbackMetadata,
UserAPIKeyAuth,
@ -348,6 +350,36 @@ def reject_url_valued_destination(field: str, value: str) -> None:
)
_METADATA_JSON_TYPE_NAMES: Final[Mapping[type, str]] = MappingProxyType(
{bool: "a boolean", int: "an integer", float: "a number", str: "a string", list: "an array"}
)
def _invalid_metadata_type_error(field: str, value: object) -> ProxyException:
received_type: Final = _METADATA_JSON_TYPE_NAMES.get(type(value), f"a {type(value).__name__}")
return ProxyException(
message=f"Invalid type for '{field}': expected an object, but got {received_type} instead.",
type=ProxyErrorTypes.bad_request_error,
param=field,
code=400,
)
def _normalized_metadata_object(field: str, value: object) -> Mapping[str, Any]:
"""Return ``value`` as a metadata object or raise a 400 like OpenAI does.
A JSON string that parses to an object is accepted because multipart/form-data
and ``extra_body`` callers can only send metadata as a string. The caller pops
the raw value from the request body before validating so the failure-logging
hooks that inspect the body afterwards don't crash on it and mask the 400 as a 500.
"""
if isinstance(value, dict):
return value
if isinstance(value, str) and isinstance((parsed := safe_json_loads(value)), dict):
return parsed
raise _invalid_metadata_type_error(field=field, value=value)
def _strip_untrusted_request_header_controls(
headers: Any,
*,
@ -1572,6 +1604,13 @@ async def add_litellm_data_to_request(
continue
data.pop(_internal_key, None)
_reject_url_valued_destinations(data)
_raw_metadata_by_field: Final = {
_metadata_field: data.pop(_metadata_field)
for _metadata_field in ("metadata", "litellm_metadata")
if data.get(_metadata_field) is not None
}
for _metadata_field, _raw_metadata in _raw_metadata_by_field.items():
data[_metadata_field] = _normalized_metadata_object(_metadata_field, _raw_metadata)
# Strip spoofable auth metadata from user-supplied metadata dict
_user_metadata = data.get("metadata")
if isinstance(_user_metadata, dict):
@ -1711,29 +1750,10 @@ async def add_litellm_data_to_request(
verbose_proxy_logger.debug("receiving data: %s", data)
# Parse metadata if it's a string (e.g., from multipart/form-data)
if "metadata" in data and data["metadata"] is not None:
if isinstance(data["metadata"], str):
data["metadata"] = safe_json_loads(data["metadata"])
if not isinstance(data["metadata"], dict):
verbose_proxy_logger.warning(
"Failed to parse 'metadata' as JSON dict. Received value: %s", data["metadata"]
)
# requester_metadata is snapshotted AFTER the strip below so
# downstream consumers (e.g. PANW guardrail reading user_ip /
# profile_id) don't see attacker-injected admin slots preserved in
# the deepcopy.
# Parse litellm_metadata if it's a string (e.g., from multipart/form-data or extra_body)
if "litellm_metadata" in data and data["litellm_metadata"] is not None:
if isinstance(data["litellm_metadata"], str):
parsed_litellm_metadata: Final = safe_json_loads(data["litellm_metadata"])
if not isinstance(parsed_litellm_metadata, dict):
verbose_proxy_logger.warning(
"Failed to parse 'litellm_metadata' as JSON dict. Received value: %s", data["litellm_metadata"]
)
else:
data["litellm_metadata"] = parsed_litellm_metadata
# requester_metadata is snapshotted AFTER the strip below so
# downstream consumers (e.g. PANW guardrail reading user_ip /
# profile_id) don't see attacker-injected admin slots preserved in
# the deepcopy.
# Strip internal pipeline state and admin-injection slots from user input.
# Runs AFTER the string-to-dict parse above so JSON-string metadata (sent

View file

@ -29,6 +29,10 @@ from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.proxy._types import *
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.user_api_key_cache import (
end_user_cache_key,
end_user_restricted_registry_cache_key,
)
from litellm.proxy.management_endpoints.common_daily_activity import get_daily_activity
from litellm.proxy.management_endpoints.common_utils import validate_budget_duration
from litellm.proxy.management_helpers.object_permission_utils import (
@ -99,6 +103,25 @@ def _typed_table(repo: EndUserRepository | BudgetRepository) -> object:
router: Final = APIRouter()
async def _evict_end_user_cache_keys(cache_keys: Sequence[str]) -> None:
"""
Every endpoint that mutates an end-user row must call this, or a newly blocked or budgeted
customer keeps being served unrestricted until the TTL expires: auth reads end users
cache-first, and the cached restricted-id registry decides whether the row is read at all.
"""
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
evict_and_broadcast,
)
from litellm.proxy.proxy_server import user_api_key_cache
await evict_and_broadcast(cache_keys=cache_keys, user_api_key_cache=user_api_key_cache)
def _end_user_cache_keys(user_ids: Sequence[str]) -> tuple[str, ...]:
"""The per-id entries plus the registry, which any restriction change can move ids in or out of."""
return (*(end_user_cache_key(user_id) for user_id in user_ids), end_user_restricted_registry_cache_key())
def _to_customer_response(record: BaseModel) -> CustomerResponse:
"""Validate a raw end-user DB row into the typed customer response.
@ -152,6 +175,7 @@ async def block_user(data: BlockUsers):
},
)
records.append(record)
await _evict_end_user_cache_keys(_end_user_cache_keys(data.user_ids))
else:
raise HTTPException(
status_code=500,
@ -448,6 +472,8 @@ async def new_end_user(
include={"litellm_budget_table": True, "object_permission": True},
)
await _evict_end_user_cache_keys(_end_user_cache_keys((data.user_id,)))
return _to_customer_response(end_user_record)
except Exception as e:
verbose_proxy_logger.exception(
@ -691,6 +717,8 @@ async def update_end_user(
raise ValueError(f"Failed updating customer data. User ID does not exist passed user_id={data.user_id}")
verbose_proxy_logger.debug("received response from updating prisma client. response=%s", response)
await _evict_end_user_cache_keys(_end_user_cache_keys((data.user_id,)))
return _to_customer_response(response)
else:
raise ValueError(f"user_id is required, passed user_id = {data.user_id}")
@ -764,6 +792,9 @@ async def delete_end_user(
where={"user_id": {"in": data.user_ids}}
)
verbose_proxy_logger.debug("received response from updating prisma client. response=%s", response)
await _evict_end_user_cache_keys(_end_user_cache_keys(data.user_ids))
return DeleteCustomersResponse(
deleted_customers=response,
message="Successfully deleted customers with ids: " + str(data.user_ids),

View file

@ -1,7 +1,7 @@
"""`/management/v1/spend_logs` facets."""
from datetime import datetime, timezone
from typing import Annotated, Any, Final
from typing import Annotated, Any, Final, Literal
from fastapi import APIRouter, Depends, Query, Request
@ -35,7 +35,7 @@ def _as_utc(value: datetime) -> datetime:
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
async def _end_user_scope_clause(
async def _spend_log_scope_clause(
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
next_param_index: int,
@ -43,8 +43,8 @@ async def _end_user_scope_clause(
"""SQL predicate restricting the facet to spend logs this caller may read.
Returns ``(None, ())`` for a proxy admin. Mirrors the scoping ``/spend/logs/ui``
applies, so the dropdown can never offer an end user whose rows the caller
could not open.
applies, so a dropdown can never offer a value from a row the caller could
not open.
"""
from litellm.proxy.spend_tracking.spend_management_endpoints import (
_get_permitted_team_ids_for_spend_logs,
@ -77,6 +77,98 @@ async def _end_user_scope_clause(
return f"({' OR '.join(clauses)})", params
async def _list_spend_log_facet(
request: Request,
user_api_key_dict: UserAPIKeyAuth,
start_time: datetime,
end_time: datetime,
q: str | None,
page: int,
page_size: int,
column: Literal["end_user", "user"],
) -> FacetListResponse:
try:
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}database-not-connected",
title="Database not connected",
status=503,
detail=CommonProxyErrors.db_not_connected_error.value,
)
)
column_sql: Final = "end_user" if column == "end_user" else '"user"'
window_params: Final[tuple[Any, ...]] = (_as_utc(start_time), _as_utc(end_time))
search_params: Final[tuple[Any, ...]] = (f"%{escape_like(q)}%",) if q else ()
search_clause: Final = (f"{column_sql} ILIKE ${len(window_params) + 1} ESCAPE '\\'",) if q else ()
scope_clause, scope_params = await _spend_log_scope_clause(
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
next_param_index=len(window_params) + len(search_params) + 1,
)
where_parts: Final = (
(
"\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC')",
"\"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')",
f"{column_sql} IS NOT NULL",
f"{column_sql} != ''",
)
+ search_clause
+ ((scope_clause,) if scope_clause is not None else ())
)
# The inner LIMIT walks the startTime index newest first and bounds the
# rows DISTINCT can inspect. request_id makes the cut-off deterministic,
# and page_size + 1 reveals has_more without a COUNT(*).
params: Final = (
window_params
+ search_params
+ scope_params
+ (SPEND_LOGS_FACET_SCAN_CAP, page_size + 1, (page - 1) * page_size)
)
scan_idx: Final = len(params) - 2
facet_sql: Final = (
f"SELECT DISTINCT {column_sql} FROM ("
f" SELECT {column_sql}"
f' FROM "LiteLLM_SpendLogs"'
f" WHERE {' AND '.join(where_parts)}"
f' ORDER BY "startTime" DESC, request_id DESC'
f" LIMIT ${scan_idx}"
f") recent"
f" ORDER BY {column_sql} ASC"
f" LIMIT ${scan_idx + 1} OFFSET ${scan_idx + 2}"
)
rows: Final = await prisma_client.db.query_raw(facet_sql, *params)
values: Final[list[str]] = [row[column] for row in rows if row.get(column)]
has_more: Final = len(values) > page_size
return FacetListResponse(
data=values[:page_size],
meta=PageMeta(page=page, page_size=page_size, has_more=has_more),
links=build_page_links(request=request, page=page, has_more=has_more),
)
except ManagementProblem:
raise
except Exception as e:
verbose_proxy_logger.exception(
"litellm.proxy.management_endpoints.management_v1.spend_logs._list_spend_log_facet(): Exception occured - %s",
e,
)
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}internal-server-error",
title="Internal server error",
status=500,
detail=f"Failed to list spend log {column.replace('_', ' ')}s.",
)
)
@router.get(
"/spend_logs/end_users",
tags=["Budget & Spend Tracking"],
@ -116,85 +208,47 @@ async def list_spend_log_end_users(
--header 'Authorization: Bearer sk-1234'
```
"""
try:
from litellm.proxy.proxy_server import prisma_client
return await _list_spend_log_facet(
request=request,
user_api_key_dict=user_api_key_dict,
start_time=start_time,
end_time=end_time,
q=q,
page=page,
page_size=page_size,
column="end_user",
)
if prisma_client is None:
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}database-not-connected",
title="Database not connected",
status=503,
detail=CommonProxyErrors.db_not_connected_error.value,
)
)
window_params: Final[tuple[Any, ...]] = (_as_utc(start_time), _as_utc(end_time))
search_params: Final[tuple[Any, ...]] = (f"%{escape_like(q)}%",) if q else ()
search_clause: Final = (f"end_user ILIKE ${len(window_params) + 1} ESCAPE '\\'",) if q else ()
scope_clause, scope_params = await _end_user_scope_clause(
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
next_param_index=len(window_params) + len(search_params) + 1,
)
where_parts: Final = (
(
"\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC')",
"\"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')",
"end_user IS NOT NULL",
"end_user != ''",
)
+ search_clause
+ ((scope_clause,) if scope_clause is not None else ())
)
# The inner LIMIT is the safety bound: it walks the startTime index newest
# first and stops, so DISTINCT never runs over an unbounded row set.
# request_id breaks startTime ties so the cut-off row is deterministic and
# successive OFFSET pages agree on the set they are paging through.
# page_size + 1: one row beyond the page reveals has_more without a COUNT(*).
params: Final = (
window_params
+ search_params
+ scope_params
+ (SPEND_LOGS_FACET_SCAN_CAP, page_size + 1, (page - 1) * page_size)
)
scan_idx: Final = len(params) - 2
facet_sql: Final = (
f"SELECT DISTINCT end_user FROM ("
f" SELECT end_user"
f' FROM "LiteLLM_SpendLogs"'
f" WHERE {' AND '.join(where_parts)}"
f' ORDER BY "startTime" DESC, request_id DESC'
f" LIMIT ${scan_idx}"
f") recent"
f" ORDER BY end_user ASC"
f" LIMIT ${scan_idx + 1} OFFSET ${scan_idx + 2}"
)
rows: Final = await prisma_client.db.query_raw(facet_sql, *params)
end_users: Final[list[str]] = [row["end_user"] for row in rows if row.get("end_user")]
has_more: Final = len(end_users) > page_size
return FacetListResponse(
data=end_users[:page_size],
meta=PageMeta(page=page, page_size=page_size, has_more=has_more),
links=build_page_links(request=request, page=page, has_more=has_more),
)
except ManagementProblem:
raise
except Exception as e:
verbose_proxy_logger.exception(
"litellm.proxy.management_endpoints.management_v1.spend_logs.list_spend_log_end_users(): Exception occured - %s",
e,
)
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}internal-server-error",
title="Internal server error",
status=500,
detail="Failed to list spend log end users.",
)
)
@router.get(
"/spend_logs/users",
tags=["Budget & Spend Tracking"],
dependencies=[Depends(user_api_key_auth), Depends(reject_unknown_query_params)],
response_model=FacetListResponse,
)
async def list_spend_log_users(
request: Request,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
start_time: Annotated[
datetime,
Query(alias="filter[startTime][gte]", description="Window start (UTC when no offset is given)"),
],
end_time: Annotated[
datetime,
Query(alias="filter[startTime][lte]", description="Window end (UTC when no offset is given)"),
],
q: Annotated[str | None, Query(description="Case-insensitive partial match on the internal user id")] = None,
page: Annotated[int, Query(ge=1, description="Page number")] = 1,
page_size: Annotated[int, Query(ge=1, le=100, description="Page size")] = 50,
) -> FacetListResponse:
"""The distinct internal users appearing in spend logs the caller can read."""
return await _list_spend_log_facet(
request=request,
user_api_key_dict=user_api_key_dict,
start_time=start_time,
end_time=end_time,
q=q,
page=page,
page_size=page_size,
column="user",
)

View file

@ -21,6 +21,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import UserAPIKeyAuth, user_api_key_has_admin_view
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.user_api_key_cache import (
tag_cache_key,
tag_registry_cache_key,
)
from litellm.proxy.management_endpoints.common_daily_activity import (
SpendAnalyticsPaginatedResponse,
get_daily_activity,
@ -133,6 +137,20 @@ def _table(
return prisma_table
async def _evict_tag_cache_keys(cache_keys: Sequence[str]) -> None:
"""
Every endpoint that mutates a tag row must call this, or a deleted tag keeps its budget
enforced and a newly created one stays invisible to the cached name registry until the TTL
expires: auth reads tags cache-first, with no freshness check.
"""
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
evict_and_broadcast,
)
from litellm.proxy.proxy_server import user_api_key_cache
await evict_and_broadcast(cache_keys=cache_keys, user_api_key_cache=user_api_key_cache)
async def _get_internal_user_api_keys(
prisma_client: "PrismaClient",
user_api_key_dict: UserAPIKeyAuth,
@ -294,6 +312,8 @@ async def new_tag(
}
)
await _evict_tag_cache_keys((tag_cache_key(tag.name), tag_registry_cache_key()))
# Update models with new tag
if tag.models:
tasks: Final = []
@ -440,6 +460,8 @@ async def update_tag(
data=update_data,
)
await _evict_tag_cache_keys((tag_cache_key(tag.name),))
# Build response
tag_config: Final = TagConfig(
name=updated_tag_record.tag_name,
@ -689,6 +711,8 @@ async def delete_tag(
# Delete tag from database
await _table(TagRepository(prisma_client)).delete(where={"tag_name": data.name})
await _evict_tag_cache_keys((tag_cache_key(data.name), tag_registry_cache_key()))
return {"message": f"Tag {data.name} deleted successfully"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -916,6 +916,7 @@ class ProxyInitializationHelpers:
"path that can cause schema thrashing during rolling deploys where two "
"LiteLLM versions contend for the same DB. Default is the v1 resolver."
),
envvar="USE_V2_MIGRATION_RESOLVER",
)
@click.option(
"--reload",

View file

@ -358,7 +358,9 @@ from litellm.proxy.common_utils.timezone_utils import (
)
from litellm.proxy.common_utils.user_api_key_cache import (
UserApiKeyCache,
end_user_cache_key,
get_management_object_ttl,
tag_cache_key,
)
from litellm.proxy.config_resolvers import resolve_fields
from litellm.proxy.config_resolvers.alerting import (
@ -2780,7 +2782,7 @@ async def _increment_end_user_and_tag_spend_counters(
if end_user_id is not None:
await _init_and_increment_unreserved_spend_counter(
counter_key=f"spend:end_user:{end_user_id}",
source_cache_key=f"end_user_id:{end_user_id}",
source_cache_key=end_user_cache_key(end_user_id),
increment=response_cost,
reserved_counter_keys=reserved_counter_keys,
)
@ -2795,7 +2797,7 @@ async def _increment_end_user_and_tag_spend_counters(
seen_tags.add(tag_name)
await _init_and_increment_unreserved_spend_counter(
counter_key=f"spend:tag:{tag_name}",
source_cache_key=f"tag:{tag_name}",
source_cache_key=tag_cache_key(tag_name),
increment=response_cost,
reserved_counter_keys=reserved_counter_keys,
)
@ -3134,7 +3136,7 @@ async def update_cache(
if end_user_id is None or response_cost is None:
return
_id: Final = f"end_user_id:{end_user_id}"
_id: Final = end_user_cache_key(end_user_id)
try:
# Fetch the existing cost for the given user
cached_end_user: Final = await user_api_key_cache.async_get_cache(key=_id)
@ -3226,7 +3228,7 @@ async def update_cache(
if not tag_name or not isinstance(tag_name, str):
continue
cache_key = f"tag:{tag_name}"
cache_key = tag_cache_key(tag_name)
# Fetch the existing tag object from cache
cached_tag = await user_api_key_cache.async_get_cache(key=cache_key)
if cached_tag is None:
@ -10371,6 +10373,8 @@ async def moderations(
user_api_key_dict=user_api_key_dict, original_exception=e, request_data=data
)
verbose_proxy_logger.exception("litellm.proxy.proxy_server.moderations(): Exception occured - %s", e)
if isinstance(e, ProxyException):
raise
if isinstance(e, HTTPException):
raise ProxyException(
message=getattr(e, "message", str(e)),

View file

@ -6,7 +6,7 @@ import httpx
from fastapi import HTTPException, status
import litellm
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
from litellm.router_utils.common_utils import _is_proxy_admin_request
# Client-supplied params that make the router or the call path fabricate a
@ -141,6 +141,7 @@ ROUTE_ENDPOINT_MAPPING: Final = {
"aget_run": "/evals/{eval_id}/runs/{run_id}",
"acancel_run": "/evals/{eval_id}/runs/{run_id}/cancel",
"adelete_run": "/evals/{eval_id}/runs/{run_id}",
"acreate_batch": "/batches",
}
@ -152,27 +153,33 @@ class ProxyModelNotFoundError(HTTPException):
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
REQUIRED_BODY_PARAM_BY_ROUTE: Final[Mapping[str, str]] = {
"acompletion": "messages",
"aembedding": "input",
REQUIRED_BODY_PARAMS_BY_ROUTE: Final[Mapping[str, tuple[str, ...]]] = {
"acompletion": ("messages",),
"aembedding": ("input",),
"acreate_batch": ("input_file_id", "endpoint", "completion_window"),
}
class ProxyMissingRequiredParamError(HTTPException):
class ProxyMissingRequiredParamError(ProxyException):
def __init__(self, route: str, param: str):
detail: Final = {"error": f"{route}: Missing required parameter: '{param}'."}
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
self.type = "invalid_request_error"
self.param = param
super().__init__(
message=f"{route}: Missing required parameter: '{param}'.",
type="invalid_request_error",
param=param,
code=status.HTTP_400_BAD_REQUEST,
)
def raise_if_required_body_param_missing(route_type: str, data: Mapping[str, object]) -> None:
required_param: Final = REQUIRED_BODY_PARAM_BY_ROUTE.get(route_type)
if required_param is None or data.get(required_param) is not None:
missing_param: Final = next(
(param for param in REQUIRED_BODY_PARAMS_BY_ROUTE.get(route_type, ()) if data.get(param) is None),
None,
)
if missing_param is None:
return
raise ProxyMissingRequiredParamError(
route=ROUTE_ENDPOINT_MAPPING.get(route_type, route_type),
param=required_param,
param=missing_param,
)

View file

@ -24,6 +24,7 @@ from litellm.proxy._types import (
from litellm.proxy.auth.auth_utils import get_model_from_request
from litellm.proxy.auth.budget_throttle import should_throttle_budget_exceeded
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.common_utils.user_api_key_cache import end_user_cache_key, tag_cache_key
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.router import Router
@ -448,7 +449,7 @@ async def _get_end_user_budget_counter(
if end_user_id is None:
return None
source_cache_key: Final = f"end_user_id:{end_user_id}"
source_cache_key: Final = end_user_cache_key(end_user_id)
max_budget = _to_float(valid_token.end_user_max_budget)
fallback_spend = 0.0
if end_user_object is not None:
@ -502,7 +503,7 @@ async def _get_tag_budget_counters(
counters.append(
_BudgetCounter(
counter_key=f"spend:tag:{tag_name}",
source_cache_key=f"tag:{tag_name}",
source_cache_key=tag_cache_key(tag_name),
max_budget=max_budget,
fallback_spend=_to_float(_get_value(tag_object, "spend")) or 0.0,
entity_type="Tag",

View file

@ -2426,7 +2426,23 @@ async def ui_view_spend_logs(
user_api_key_dict=user_api_key_dict,
request_id=request_id,
)
permitted_team_ids: list[str] | None = None
user_scope_applies: Final = (
not is_request_id_lookup
and not is_admin_view
and team_id is None
and _can_user_view_spend_log(user_api_key_dict=user_api_key_dict)
)
permitted_team_ids: Final = (
await _get_permitted_team_ids_for_spend_logs_or_empty(
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
)
if user_scope_applies
else ()
)
explicit_user_requires_caller_scope: Final = (
user_scope_applies and not permitted_team_ids and user_id is not None
)
if not is_request_id_lookup and not is_admin_view:
if team_id is not None:
can_view_team: Final = await _can_team_member_view_log(
@ -2440,25 +2456,22 @@ async def ui_view_spend_logs(
detail={"error": f"Not authorized to view team spend for team_id={team_id}"},
)
where_conditions["team_id"] = team_id
where_conditions.pop("user", None)
else:
if _can_user_view_spend_log(user_api_key_dict=user_api_key_dict):
try:
permitted_team_ids = await _get_permitted_team_ids_for_spend_logs(
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
)
except Exception:
permitted_team_ids = []
if permitted_team_ids:
elif user_scope_applies:
if permitted_team_ids:
if user_id is None:
where_conditions.pop("user", None)
where_conditions["OR"] = [
{"user": user_api_key_dict.user_id},
{"team_id": {"in": permitted_team_ids}},
]
else:
where_conditions["OR"] = [
{"user": user_api_key_dict.user_id},
{"team_id": {"in": permitted_team_ids}},
]
else:
if user_id is None:
where_conditions["user"] = user_api_key_dict.user_id
where_conditions.pop("team_id", None)
else:
where_conditions["AND"] = where_conditions.get("AND", []) + [
{"user": user_api_key_dict.user_id}
]
where_conditions.pop("team_id", None)
# Calculate skip value for pagination
skip: Final = (page - 1) * page_size
@ -2502,12 +2515,16 @@ async def ui_view_spend_logs(
p += 1
# Multi-team OR filter: (user = $X OR team_id = ANY($Y))
if permitted_team_ids is not None and len(permitted_team_ids) > 0:
if permitted_team_ids:
or_clause: Final = f'("user" = ${p} OR team_id = ANY(${p + 1}::text[]))'
sql_params.append(user_api_key_dict.user_id)
sql_params.append(permitted_team_ids)
p += 2
sql_conditions.append(or_clause)
elif explicit_user_requires_caller_scope:
sql_conditions.append(f'"user" = ${p}')
sql_params.append(user_api_key_dict.user_id)
p += 1
if session_id is not None and isinstance(session_id, str):
like_escaped_session_id: Final = session_id.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
@ -4272,3 +4289,19 @@ async def _get_permitted_team_ids_for_spend_logs(
):
permitted.append(team_obj.team_id)
return permitted
async def _get_permitted_team_ids_for_spend_logs_or_empty(
prisma_client: PrismaClient,
user_api_key_dict: UserAPIKeyAuth,
) -> tuple[str, ...]:
"""Resolve permitted teams once, falling back to the caller's own-user scope."""
try:
return tuple(
await _get_permitted_team_ids_for_spend_logs(
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
)
)
except Exception:
return ()

View file

@ -806,6 +806,7 @@ class LiteLLM_Proxy_MCP_Handler:
oauth2_headers=oauth2_headers,
raw_headers=raw_headers,
proxy_logging_obj=proxy_logging_obj,
litellm_logging_obj=litellm_logging_obj,
)
if proxy_logging_obj:

View file

@ -3,7 +3,7 @@ from enum import Enum
from typing import Any, Final, Literal, TypeAlias
from pydantic import BaseModel, ConfigDict
from typing_extensions import NotRequired, Required, TypedDict
from typing_extensions import NotRequired, ReadOnly, Required, TypedDict
from .openai import (
ChatCompletionCachedContent,
@ -48,6 +48,7 @@ class AnthropicMessagesTool(TypedDict, total=False):
name: Required[str]
description: str
input_schema: AnthropicInputSchema | None
strict: ReadOnly[bool]
type: Literal["custom"]
cache_control: dict | ChatCompletionCachedContent | None
defer_loading: bool

View file

@ -201,7 +201,7 @@
"limit": 58
},
"SIM102": {
"limit": 319
"limit": 317
},
"SIM103": {
"limit": 119

View file

@ -0,0 +1,106 @@
"""Unit tests for the tool-search replay assertion in `http_probe`.
Markerless harness tests: they exercise probe plumbing over hand-built
`Result` values, not a product feature, so they run without a proxy and carry
no `e2e` marker.
The red paths are what these are for. A live cell only ever executes the green
one, so a broken diagnostic in the failure branch would sit undetected until
the day the provider actually rejects the history, which is the day the
diagnostic has to be right.
"""
from __future__ import annotations
from e2e_http import Result, Success, UnknownApiError
from models import (
AnthropicContentBlock,
AnthropicMessagesResponse,
AnthropicToolResultTurn,
ChatMessage,
)
from claude_code.http_probe import (
ToolSearchReplay,
_replay_history,
assert_tool_search_replay_shape,
)
_REJECTED: Result[AnthropicMessagesResponse] = UnknownApiError(
status_code=400,
body="server_tool_use blocks are not supported",
)
_ACCEPTED: Result[AnthropicMessagesResponse] = Success(
status_code=200,
data=AnthropicMessagesResponse(content=[AnthropicContentBlock(type="text", text="done")]),
)
def _replay(block_types: tuple[str, ...], second_turn: Result[AnthropicMessagesResponse]) -> ToolSearchReplay:
answer = AnthropicMessagesResponse(
content=[AnthropicContentBlock(type=block_type, id="srvtoolu_01") for block_type in block_types]
)
return ToolSearchReplay(
first_turn=Success(status_code=200, data=answer),
history=_replay_history(answer),
second_turn=second_turn,
)
def test_accepts_a_replayed_server_tool_pair() -> None:
replay = _replay(("text", "server_tool_use", "tool_search_tool_result"), _ACCEPTED)
assert assert_tool_search_replay_shape(replay) is None
def test_reports_the_status_when_the_replayed_history_is_rejected() -> None:
replay = _replay(("server_tool_use", "tool_search_tool_result"), _REJECTED)
error = assert_tool_search_replay_shape(replay)
assert error is not None
assert "status 400" in error
assert "server_tool_use" in error
def test_a_turn_truncated_before_the_result_block_is_not_a_pass() -> None:
replay = _replay(("server_tool_use",), _ACCEPTED)
error = assert_tool_search_replay_shape(replay)
assert error is not None
assert "tool_search_tool_result" in error
def test_a_history_with_no_server_tool_block_is_not_a_pass() -> None:
replay = _replay(("text",), _ACCEPTED)
error = assert_tool_search_replay_shape(replay)
assert error is not None
assert "server_tool_use" in error
def test_a_failed_first_turn_is_reported_as_the_first_turn() -> None:
replay = ToolSearchReplay(first_turn=_REJECTED, history=(), second_turn=None)
error = assert_tool_search_replay_shape(replay)
assert error is not None
assert error.startswith("first turn: ")
def test_a_pending_tool_use_is_answered_with_the_id_the_model_returned() -> None:
answer = AnthropicMessagesResponse(
content=[
AnthropicContentBlock(type="server_tool_use", id="srvtoolu_01"),
AnthropicContentBlock(type="tool_search_tool_result", id=None),
AnthropicContentBlock(type="tool_use", id="toolu_99"),
]
)
last_turn = _replay_history(answer)[-1]
assert isinstance(last_turn, AnthropicToolResultTurn)
assert [block.tool_use_id for block in last_turn.content] == ["toolu_99"]
def test_a_turn_with_no_pending_tool_use_gets_a_plain_follow_up() -> None:
answer = AnthropicMessagesResponse(
content=[
AnthropicContentBlock(type="server_tool_use", id="srvtoolu_01"),
AnthropicContentBlock(type="tool_search_tool_result"),
]
)
last_turn = _replay_history(answer)[-1]
assert isinstance(last_turn, ChatMessage)
assert last_turn.role == "user"

View file

@ -28,6 +28,7 @@ the upstream, or LiteLLM 500 on a transformation bug).
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from pydantic import BaseModel
@ -42,10 +43,14 @@ from e2e_http import (
ValidationError,
)
from models import (
AnthropicAssistantTurn,
AnthropicCustomTool,
AnthropicMessage,
AnthropicMessagesBody,
AnthropicMessagesResponse,
AnthropicTool,
AnthropicToolResultBlock,
AnthropicToolResultTurn,
AnthropicToolSearchTool,
ChatMessage,
CountTokensBody,
@ -132,6 +137,7 @@ def probe_tool_search(
client: ProxyClient,
api_key: str,
model: str,
max_tokens: int = 64,
rate_limiter: RateLimiter | None = None,
) -> Result[AnthropicMessagesResponse]:
"""POST to `/v1/messages` with a `tool_search_tool_regex_20251119` tool
@ -155,13 +161,115 @@ def probe_tool_search(
api_key,
AnthropicMessagesBody(
model=model,
max_tokens=64,
max_tokens=max_tokens,
messages=[ChatMessage(role="user", content=_TOOL_SEARCH_PROMPT)],
tools=list(_TOOL_SEARCH_TOOLS),
),
)
_TOOL_SEARCH_FOLLOW_UP = "Thanks. Now reply with the word 'done'."
_TOOL_RESULT_STUB = "3"
# A `server_tool_use` block and the `tool_search_tool_result` answering it are
# one indivisible pair: replaying the request without its result is malformed
# Anthropic and 400s on any provider. 64 output tokens is not enough room for
# both, so the turn we replay is generated with a budget that fits the whole
# discovery round trip.
_REPLAY_SOURCE_MAX_TOKENS = 1024
_REPLAYED_SERVER_BLOCKS = frozenset({"server_tool_use", "tool_search_tool_result"})
@dataclass(frozen=True, slots=True)
class ToolSearchReplay:
"""Both turns of the multi-turn probe plus the history the second turn
carried, so a failing cell can report which turn broke and what was on the
wire when it did."""
first_turn: Result[AnthropicMessagesResponse]
history: tuple[AnthropicMessage, ...]
second_turn: Result[AnthropicMessagesResponse] | None
def _replayed_server_block_types(history: tuple[AnthropicMessage, ...]) -> frozenset[str]:
return frozenset(
block.type
for turn in history
if isinstance(turn, AnthropicAssistantTurn)
for block in turn.content
if block.type in _REPLAYED_SERVER_BLOCKS
)
def _replay_history(answer: AnthropicMessagesResponse) -> tuple[AnthropicMessage, ...]:
"""Turn a real first-turn answer into a well-formed two-turn history.
Every client-side `tool_use` the model emitted gets a `tool_result` keyed on
the id the model actually returned; a turn with none gets a plain follow-up
instead. An unanswered `tool_use`, or a `tool_result` pointing at an invented
id, is malformed Anthropic and 400s on any provider, which would make this
probe measure our own request rather than the provider's handling of the
replayed server-tool blocks."""
blocks = tuple(answer.content or ())
pending = tuple(block.id for block in blocks if block.type == "tool_use" and block.id is not None)
reply: AnthropicMessage = (
AnthropicToolResultTurn(
content=[
AnthropicToolResultBlock(tool_use_id=tool_use_id, content=_TOOL_RESULT_STUB)
for tool_use_id in pending
]
)
if pending
else ChatMessage(role="user", content=_TOOL_SEARCH_FOLLOW_UP)
)
return (
ChatMessage(role="user", content=_TOOL_SEARCH_PROMPT),
AnthropicAssistantTurn(content=list(blocks)),
reply,
)
def probe_tool_search_multiturn(
*,
client: ProxyClient,
api_key: str,
model: str,
rate_limiter: RateLimiter | None = None,
) -> ToolSearchReplay:
"""Run `probe_tool_search`, then send the real assistant turn back as
history with the same tools still declared.
The first turn only proves the proxy attaches the tool-search beta header on
the way out. Nothing proves the provider accepts the `server_tool_use` and
`tool_search_tool_result` blocks it produced when they come back in
`messages`, which is every turn of a real Claude Code session after the
first."""
first_turn = probe_tool_search(
client=client,
api_key=api_key,
model=model,
max_tokens=_REPLAY_SOURCE_MAX_TOKENS,
rate_limiter=rate_limiter,
)
if not isinstance(first_turn, Success):
return ToolSearchReplay(first_turn=first_turn, history=(), second_turn=None)
history = _replay_history(first_turn.data)
_acquire(model, rate_limiter)
return ToolSearchReplay(
first_turn=first_turn,
history=history,
second_turn=client.messages(
api_key,
AnthropicMessagesBody(
model=model,
max_tokens=64,
messages=list(history),
tools=list(_TOOL_SEARCH_TOOLS),
),
),
)
def _failure_diagnostic[R: BaseModel](result: Result[R], route: str) -> str:
"""Map a non-success `Result` to a one-line diagnostic. The `status 429`
wording is load-bearing: the compat conftest classifies a rate-limited cell
@ -207,6 +315,40 @@ def assert_tool_search_shape(result: Result[AnthropicMessagesResponse]) -> str |
return _failure_diagnostic(result, "/v1/messages")
def assert_tool_search_replay_shape(replay: ToolSearchReplay) -> str | None:
"""Return None on success, else describe the first violation.
Acceptance criteria:
1. The first turn succeeded, on the same terms as `assert_tool_search_shape`.
2. That turn produced a complete `server_tool_use` / `tool_search_tool_result`
pair to replay. Without both the second turn carries either an ordinary
text history or a half-finished tool call, and the cell would report on
our own request rather than on the provider's handling of server-tool
blocks in history.
3. The provider accepted the history containing those blocks.
"""
first_error = assert_tool_search_shape(replay.first_turn)
if first_error is not None:
return f"first turn: {first_error}"
replayed = _replayed_server_block_types(replay.history)
missing = _REPLAYED_SERVER_BLOCKS - replayed
if missing:
return (
f"first turn returned no {' or '.join(sorted(missing))} block to replay, so the history "
"proves nothing about server-tool handling; a turn truncated at max_tokens looks like this"
)
if replay.second_turn is None:
return "second turn was never sent"
second_error = assert_tool_search_shape(replay.second_turn)
if second_error is not None:
return f"history replaying {sorted(replayed)} rejected: {second_error}"
return None
def assert_count_tokens_shape(result: Result[CountTokensResponse]) -> str | None:
"""Return None on success, or an error string describing the first violation.

View file

@ -1,12 +1,17 @@
"""tool_search x Bedrock (Invoke).
HTTP-probe row. Sends a single `/v1/messages` request whose `tools`
array includes a `tool_search_tool_regex_20251119` discovery tool, and
HTTP-probe row. Sends a `/v1/messages` request whose `tools` array
includes a `tool_search_tool_regex_20251119` discovery tool, and
asserts the proxy round-trips it to the upstream without a 400. This
verifies LiteLLM's tool-search beta-header translation
(`advanced-tool-use-2025-11-20` for Anthropic-shape providers,
`tool-search-tool-2025-10-19` for Vertex/Bedrock) survives end-to-end.
A second probe then replays that turn's answer as history, which is
what every turn of a real session after the first looks like: the
first turn only exercises the outbound header, and the blocks the
model sends back have to be accepted on the way in too.
The (feature, provider) for this cell is inferred from the file path by
`tests/e2e/claude_code/conftest.py`:
@ -47,8 +52,10 @@ import pytest
from claude_code._env import require_proxy_client
from claude_code.http_probe import (
assert_tool_search_replay_shape,
assert_tool_search_shape,
probe_tool_search,
probe_tool_search_multiturn,
)
@ -80,3 +87,31 @@ def test_tool_search_bedrock_invoke(compat_result):
if failures:
pytest.fail("; ".join(failures), pytrace=False)
@pytest.mark.covers("llm.messages.bedrock_invoke.tool_search_history.nonstream.works")
def test_tool_search_history_bedrock_invoke(compat_result):
"""Send the tool-search request, take the real assistant turn back, and
replay it as history with the tools still declared.
Every turn of a real Claude Code session after the first carries the
`server_tool_use` and `tool_search_tool_result` blocks the previous turn
produced. The single-turn probe above never sends them, so it cannot see a
provider or a transformation that accepts tool_search on the way out and
rejects the blocks it gets back."""
client, api_key = require_proxy_client(compat_result)
failures = []
for model in BEDROCK_INVOKE_MODELS:
replay = probe_tool_search_multiturn(client=client, api_key=api_key, model=model)
shape_error = assert_tool_search_replay_shape(replay)
if shape_error is not None:
error = f"[{model}] tool_search history replay failed: {shape_error}"
compat_result.add({"status": "fail", "error": error})
failures.append(error)
continue
compat_result.add({"status": "pass"})
if failures:
pytest.fail("; ".join(failures), pytrace=False)

View file

@ -8,7 +8,8 @@
# route : anthropic | azure_foundry | bedrock_converse | bedrock_invoke | vertex
# capability : basic | tool_use | vision | thinking | prompt_cache_5m | prompt_cache_1h
# | structured_output | pdf_input | long_context_1m
# | thinking_with_tool_use | tool_search | count_tokens | web_search
# | thinking_with_tool_use | tool_search | tool_search_history | count_tokens
# | web_search
# streaming : stream | nonstream
# ---- basic / non-streaming ----
@ -94,6 +95,7 @@
- {id: llm.messages.bedrock_converse.tool_search.nonstream.works, module: llm, tier: P1, subject_endpoint: messages, route: bedrock_converse, capability: tool_search, streaming: nonstream, assertions: [works], source: "claude_code compat matrix", rationale: "tool_search discovery tool over Bedrock Converse"}
- {id: llm.messages.bedrock_invoke.tool_search.nonstream.works, module: llm, tier: P1, subject_endpoint: messages, route: bedrock_invoke, capability: tool_search, streaming: nonstream, assertions: [works], source: "claude_code compat matrix", rationale: "tool_search discovery tool over Bedrock Invoke"}
- {id: llm.messages.vertex.tool_search.nonstream.works, module: llm, tier: P1, subject_endpoint: messages, route: vertex, capability: tool_search, streaming: nonstream, assertions: [works], source: "claude_code compat matrix", rationale: "tool_search discovery tool over Vertex AI"}
- {id: llm.messages.bedrock_invoke.tool_search_history.nonstream.works, module: llm, tier: P1, subject_endpoint: messages, route: bedrock_invoke, capability: tool_search_history, streaming: nonstream, assertions: [works], source: "claude_code compat matrix", rationale: "A real server_tool_use / tool_search_tool_result pair replayed as history over Bedrock Invoke"}
# ---- count_tokens ----
- {id: llm.messages.anthropic.count_tokens.nonstream.works, module: llm, tier: P1, subject_endpoint: messages, route: anthropic, capability: count_tokens, streaming: nonstream, assertions: [works], source: "claude_code compat matrix", rationale: "/v1/messages/count_tokens over Anthropic direct"}

View file

@ -76,6 +76,7 @@ LlmCapability = Literal[
"thinking",
"thinking_with_tool_use",
"tool_search",
"tool_search_history",
"tool_use",
"vision",
"web_search",

View file

@ -384,9 +384,45 @@ class AnthropicCustomTool(BaseModel):
type AnthropicTool = AnthropicToolSearchTool | AnthropicWebSearchTool | AnthropicCustomTool
class AnthropicContentBlock(BaseModel):
"""One block of a `content` array. Only the fields a test reads are
declared; `extra="allow"` keeps the rest (a `server_tool_use` block's
`input`, a `tool_search_tool_result` block's nested `content`) so an
assistant turn read off the wire can be replayed into history verbatim
instead of being silently flattened to its text."""
model_config = ConfigDict(extra="allow")
type: str | None = None
text: str | None = None
id: str | None = None
class AnthropicToolResultBlock(BaseModel):
"""The user-turn answer to a client-side `tool_use`. `tool_use_id` must be
the id the model actually emitted; an invented one is rejected by
Anthropic's own schema validator, which Bedrock inherits."""
type: Literal["tool_result"] = "tool_result"
tool_use_id: str
content: str
class AnthropicAssistantTurn(BaseModel):
role: Literal["assistant"] = "assistant"
content: list[AnthropicContentBlock]
class AnthropicToolResultTurn(BaseModel):
role: Literal["user"] = "user"
content: list[AnthropicToolResultBlock]
type AnthropicMessage = ChatMessage | AnthropicAssistantTurn | AnthropicToolResultTurn
class AnthropicMessagesBody(BaseModel):
model: str
messages: list[ChatMessage]
messages: list[AnthropicMessage]
max_tokens: int
stream: bool | None = None
tools: list[AnthropicTool] | None = None
@ -401,11 +437,6 @@ class CountTokensBody(BaseModel):
messages: list[ChatMessage]
class AnthropicContentBlock(BaseModel):
type: str | None = None
text: str | None = None
class AnthropicMessagesResponse(BaseModel):
"""A /v1/messages answer. `content` is the Anthropic-native passthrough
shape; `choices` is the OpenAI-normalized shape LiteLLM emits for some

View file

@ -3518,6 +3518,53 @@ def test_translate_anthropic_tools_to_openai_preserves_parameters_type():
assert new_tools[0]["type"] == "function"
def test_translate_anthropic_tools_to_openai_maps_strict_onto_function_not_parameters():
"""A tool-level `strict` lands on the OpenAI function, leaving the caller's `input_schema` untouched."""
adapter = LiteLLMAnthropicMessagesAdapter()
input_schema = {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
"additionalProperties": False,
}
tools = [{"type": "custom", "name": "get_weather", "strict": True, "input_schema": input_schema}]
new_tools, _ = adapter.translate_anthropic_tools_to_openai(tools=tools)
function = new_tools[0]["function"]
assert function["strict"] is True
assert "strict" not in function["parameters"]
assert input_schema == {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
"additionalProperties": False,
}
def test_translate_anthropic_tools_to_openai_omits_unset_strict():
"""Chat Completions already defaults to non-strict, so an unset `strict` stays unset."""
adapter = LiteLLMAnthropicMessagesAdapter()
tools = [
{
"type": "custom",
"name": "search",
"input_schema": {
"type": "object",
"properties": {"query": {"type": "string"}, "cursor": {"type": "string"}},
"required": ["query"],
},
}
]
new_tools, _ = adapter.translate_anthropic_tools_to_openai(tools=tools)
function = new_tools[0]["function"]
assert "strict" not in function
assert "strict" not in function["parameters"]
assert function["parameters"]["required"] == ["query"]
TOOL_RESULT_IMAGE_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
TOOL_RESULT_IMAGE_URL = "https://example.com/screenshot.png"

View file

@ -76,6 +76,78 @@ class TestProcessEventResponseCreatedGuard:
assert len(message_starts) == 1
class TestReasoningItemWithoutSummaryText:
"""Regression: a reasoning item whose summary never produces text must not
surface as a thinking content block.
OpenAI emits ``response.output_item.added`` with ``type: "reasoning"`` on
every reasoning turn, but only emits
``response.reasoning_summary_text.delta`` when a summary was requested and
the model actually produced one. Eagerly opening the block on
``output_item.added`` left ``{"type": "thinking", "thinking": ""}`` in the
assistant turn, which clients persist in their session transcript. Replaying
that transcript against an Anthropic model (what ``claude --resume`` does
once the resumed session falls back to the default Anthropic model) fails
with::
400 invalid_request_error - messages.2.content.0.thinking:
each thinking block must contain thinking
So the thinking block is opened on the first non-empty summary delta.
"""
@staticmethod
def _gpt_turn(reasoning_summary_deltas: list) -> list:
return [
{"type": "response.created"},
{"type": "response.output_item.added", "item": {"type": "reasoning", "id": "rs_1"}},
*(
{"type": "response.reasoning_summary_text.delta", "item_id": "rs_1", "delta": delta}
for delta in reasoning_summary_deltas
),
{"type": "response.output_item.done", "item": {"type": "reasoning", "id": "rs_1"}},
{"type": "response.output_item.added", "item": {"type": "message", "id": "msg_1"}},
{"type": "response.output_text.delta", "item_id": "msg_1", "delta": "Hello"},
{"type": "response.output_item.done", "item": {"type": "message", "id": "msg_1"}},
]
def test_reasoning_without_summary_emits_no_thinking_block(self):
chunks = _drain_async(self._gpt_turn(reasoning_summary_deltas=[]))
assert not [
c for c in chunks if c["type"] == "content_block_start" and c["content_block"]["type"] == "thinking"
]
assert [(c["type"], c.get("index")) for c in chunks[1:]] == [
("content_block_start", 0),
("content_block_delta", 0),
("content_block_stop", 0),
]
assert chunks[1]["content_block"] == {"type": "text", "text": ""}
def test_reasoning_with_only_empty_summary_deltas_emits_no_thinking_block(self):
chunks = _drain_async(self._gpt_turn(reasoning_summary_deltas=["", ""]))
assert not [c for c in chunks if c["type"] == "content_block_delta" and c["delta"]["type"] == "thinking_delta"]
assert not [
c for c in chunks if c["type"] == "content_block_start" and c["content_block"]["type"] == "thinking"
]
def test_reasoning_with_summary_text_still_emits_a_thinking_block(self):
chunks = _drain_async(self._gpt_turn(reasoning_summary_deltas=["Weigh", "ing options"]))
assert [(c["type"], c.get("index")) for c in chunks[1:]] == [
("content_block_start", 0),
("content_block_delta", 0),
("content_block_delta", 0),
("content_block_stop", 0),
("content_block_start", 1),
("content_block_delta", 1),
("content_block_stop", 1),
]
assert chunks[1]["content_block"] == {"type": "thinking", "thinking": ""}
assert "".join(c["delta"]["thinking"] for c in chunks[2:4]) == "Weighing options"
class TestProcessEventTextDeltaWithoutOutputItemAdded:
"""Streams that skip response.output_item.added (e.g. LMStudio) must still
open a text block before any delta and never emit index -1."""
@ -110,12 +182,13 @@ class TestProcessEventTextDeltaWithoutOutputItemAdded:
"type": "response.output_item.added",
"item": {"type": "reasoning", "id": "rs_1"},
},
{"type": "response.reasoning_summary_text.delta", "item_id": "rs_1", "delta": "hm"},
{"type": "response.output_text.delta", "item_id": "m1", "delta": "Hi"},
]
)
assert chunks[1]["type"] == "content_block_start"
assert chunks[1]["content_block"] == {"type": "text", "text": ""}
assert [c["index"] for c in chunks[1:]] == [1, 1]
assert chunks[2]["type"] == "content_block_start"
assert chunks[2]["content_block"] == {"type": "text", "text": ""}
assert [c["index"] for c in chunks[2:]] == [1, 1]
def test_process_event_registered_item_id_does_not_synthesize_start(self):
chunks = _process_all(

View file

@ -22,7 +22,10 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import TOOL_RESULT
from litellm.llms.anthropic.experimental_pass_through.responses_adapters.transformation import (
LiteLLMAnthropicToResponsesAPIAdapter,
)
from litellm.types.llms.anthropic import AnthropicMessagesRequest
from litellm.types.llms.anthropic import (
AllAnthropicToolsValues,
AnthropicMessagesRequest,
)
from litellm.types.llms.openai import ResponseAPIUsage
@ -606,6 +609,7 @@ class TestTranslateToolsToResponsesAPI:
{
"type": "function",
"name": "get_weather",
"strict": False,
"description": "Get current weather for a city.",
"parameters": {
"type": "object",
@ -615,6 +619,60 @@ class TestTranslateToolsToResponsesAPI:
}
]
def test_tool_with_optional_properties_stays_non_strict(self):
"""Regression: an unset Anthropic `strict` must not become the Responses strict default,
which would rewrite `required` to include every optional property."""
tools: List[AllAnthropicToolsValues] = [
{
"name": "search",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"cursor": {"type": "string"},
},
"required": ["query"],
"additionalProperties": False,
},
}
]
result = _ADAPTER.translate_tools_to_responses_api(tools)
assert result[0]["strict"] is False
assert result[0]["parameters"]["required"] == ["query"]
def test_tool_forwards_explicit_strict_true(self):
"""An explicit Anthropic `strict: True` still reaches Responses as True."""
tools: List[AllAnthropicToolsValues] = [
{
"name": "search",
"strict": True,
"input_schema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
"additionalProperties": False,
},
}
]
result = _ADAPTER.translate_tools_to_responses_api(tools)
assert result == [
{
"type": "function",
"name": "search",
"strict": True,
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
"additionalProperties": False,
},
}
]
def test_tool_without_description(self):
"""Tool without a description omits the description key."""
tools = [{"name": "ping", "input_schema": {"type": "object", "properties": {}}}]

View file

@ -8163,8 +8163,40 @@ async def test_bare_origin_discovery_resolves_single_server_not_aggregate():
)
# per-server, not aggregate: the single server's name is in the endpoints
assert "/test_oauth/authorize" in authorization_response["authorization_endpoint"]
assert authorization_response["issuer"] == "https://llm.example.com"
assert resource_response["authorization_servers"] == ["https://llm.example.com/test_oauth"]
expected_issuer = "https://llm.example.com/test_oauth"
assert authorization_response["issuer"] == expected_issuer
assert resource_response["authorization_servers"] == [expected_issuer]
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_openid_configuration_alias_keeps_origin_issuer_on_root_resolution():
"""OIDC verifiers derive /.well-known/openid-configuration from their configured issuer
(the proxy origin), so the alias must keep the origin issuer even when single-server
root resolution scopes the underlying authorization-server metadata."""
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
openid_configuration,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.registry.clear()
oauth2_server = _create_oauth2_server()
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://llm.example.com/"
mock_request.headers = {}
try:
response = await openid_configuration(mock_request)
assert isinstance(response, dict)
assert response["issuer"] == "https://llm.example.com"
assert "/test_oauth/authorize" in response["authorization_endpoint"]
finally:
global_mcp_server_manager.registry.clear()

View file

@ -0,0 +1,126 @@
"""Tests for guardrail-block recording in
``litellm.proxy._experimental.mcp_server.server.call_mcp_tool``.
A pre-call MCP guardrail block *raises* into ``call_mcp_tool``'s
``except Exception``. The failure spend-log row that the Guardrails Monitor's
"Total Blocked" counts is written by ``_ProxyDBLogger.async_post_call_failure_hook``
(reached via ``proxy_logging_obj.post_call_failure_hook``), which reads
``standard_logging_object`` off the request's logging obj -- and that only exists
once ``failure_handler`` / ``async_failure_handler`` have run. So the failure
handlers must run *before* ``post_call_failure_hook``, otherwise the row persists
with ``guardrail_information=None`` and the block is never counted. These tests
pin that ordering.
``call_mcp_tool`` is wrapped by ``@client`` (``litellm.utils.client``), which uses
``functools.wraps`` and therefore exposes the raw undecorated coroutine as
``__wrapped__``. The tests drive ``__wrapped__`` directly so the except-block
ordering is observed in isolation, without the wrapper's own post-raise logging
firing. Note that this means they do not exercise the wrapper's dedup path; that
dedup rests on ``should_run_logging("sync_failure")`` / ``("async_failure")``,
which has its own coverage in the logging tests.
``proxy_logging_obj`` is imported lazily inside the except block via
``from litellm.proxy.proxy_server import proxy_logging_obj``; the real
``proxy_server`` module is heavy, so a fake module is injected into ``sys.modules``
to satisfy that lazy import without loading it.
"""
import contextlib
import sys
import types
from unittest import mock
import pytest
from fastapi import HTTPException
from litellm.proxy._experimental.mcp_server import server
class _RecordingLoggingObj:
"""Stands in for ``LiteLLMLoggingObj``, recording the failure flush the fix
makes so the test can assert it happens before ``post_call_failure_hook``."""
def __init__(self, order: list) -> None:
self._order = order
self.failure_calls = 0
self.async_failure_calls = 0
def failure_handler(self, *_args, **_kwargs) -> None:
self.failure_calls += 1
self._order.append("failure_handler")
async def async_failure_handler(self, *_args, **_kwargs) -> None:
self.async_failure_calls += 1
self._order.append("async_failure_handler")
async def _call_block(logging_obj, order: list, *, user_api_key_auth=mock.sentinel.auth):
"""Drive ``call_mcp_tool`` into its except path via ``arguments=None``, which
raises ``HTTPException(400)`` before any server-manager call, and return once it
re-raises."""
async def _record_post_call_failure_hook(**_kwargs) -> None:
order.append("post_call_failure_hook")
proxy_logging_obj = mock.MagicMock()
proxy_logging_obj.post_call_failure_hook.side_effect = _record_post_call_failure_hook
fake_proxy_server = types.ModuleType("litellm.proxy.proxy_server")
fake_proxy_server.proxy_logging_obj = proxy_logging_obj # pyright: ignore[reportAttributeAccessIssue]
with mock.patch.dict(sys.modules, {"litellm.proxy.proxy_server": fake_proxy_server}):
with contextlib.suppress(HTTPException):
await server.call_mcp_tool.__wrapped__(
name="t",
arguments=None,
user_api_key_auth=user_api_key_auth,
litellm_logging_obj=logging_obj,
)
@pytest.mark.asyncio
async def test_block_flushes_failure_before_post_call_failure_hook():
order: list = []
await _call_block(_RecordingLoggingObj(order), order)
assert order == ["failure_handler", "async_failure_handler", "post_call_failure_hook"], order
@pytest.mark.asyncio
async def test_block_flushes_each_handler_exactly_once():
"""Each handler runs once, so the block yields exactly one counted row rather
than double-counting on the shared logging obj."""
order: list = []
obj = _RecordingLoggingObj(order)
await _call_block(obj, order)
assert (obj.failure_calls, obj.async_failure_calls) == (1, 1)
@pytest.mark.asyncio
async def test_block_flushes_failure_for_anonymous_calls():
"""With no ``user_api_key_auth`` the failure handlers still run, so OTel and the
other failure sinks see the block.
``post_call_failure_hook`` stays gated on auth, matching the pre-existing
contract: SpendLogs rows are attributable billing/audit records and the
downstream DB logger dereferences authenticated key, budget, and route data.
Counting anonymous MCP blocks needs a counter that does not live in SpendLogs,
which is a separate design change, not part of this fix.
"""
order: list = []
obj = _RecordingLoggingObj(order)
await _call_block(obj, order, user_api_key_auth=None)
assert order == ["failure_handler", "async_failure_handler"], order
@pytest.mark.asyncio
async def test_absent_logging_obj_still_calls_hook_and_skips_flush():
"""Without a logging obj the flush is skipped (no crash) but
``post_call_failure_hook`` still fires. Byte-equivalent to stock behavior for
that branch; its value is as a mutation-killer for the ``is not None`` guard."""
order: list = []
await _call_block(None, order)
assert order == ["post_call_failure_hook"], order

View file

@ -0,0 +1,301 @@
"""Tests for MCP guardrail evaluations reaching the Guardrails Monitor.
MCP tool calls run their guardrails against a throwaway LLM-shaped dict built by
``ProxyLogging._convert_mcp_to_llm_format``, not against the dict the tool call is
logged from. ``@log_guardrail_information`` therefore appends
``standard_logging_guardrail_information`` to that throwaway dict's metadata
bucket, where ``get_standard_logging_object_payload`` never sees it, so the
Guardrails Monitor reported zero evaluations and zero blocks for MCP traffic.
``pre_call_tool_check`` and ``_create_during_hook_task`` now take the request's
``litellm_logging_obj`` and bridge those records onto it. These tests pin both the
seeding (which unified guardrails consume off ``data["litellm_logging_obj"]``) and
the bridge (which native guardrails depend on), including on the block path.
"""
import asyncio
import datetime
from typing import Any
from unittest import mock
import pytest
from litellm.exceptions import GuardrailRaisedException
from litellm.proxy._experimental.mcp_server import mcp_server_manager as MOD
class _FakeLoggingObj:
"""Minimal stand-in for ``LiteLLMLoggingObj``.
``_sync_guardrail_info_to_logging_obj`` reads exactly these two attributes,
and the spend-log payload is built from ``litellm_params["metadata"]``, so a
real ``Logging`` instance would add setup cost without adding coverage.
"""
def __init__(self) -> None:
self.litellm_params: dict[str, Any] = {"metadata": {}}
self.model_call_details: dict[str, Any] = {"litellm_params": self.litellm_params}
@property
def recorded_guardrails(self) -> list:
return self.litellm_params["metadata"].get("standard_logging_guardrail_information", [])
def _bare_manager() -> MOD.MCPServerManager:
"""An ``MCPServerManager`` without running ``__init__``.
The authorization/validation helpers on the path are stubbed out so the test
reaches the guardrail hooks; they have their own coverage elsewhere.
"""
mgr = MOD.MCPServerManager.__new__(MOD.MCPServerManager)
mgr.check_allowed_or_banned_tools = lambda name, server: True
mgr.validate_allowed_params = lambda tool_name, arguments, server: None
async def _ok(*_args, **_kwargs) -> None:
return None
mgr.check_tool_permission_for_key_team = _ok
return mgr
def _fake_proxy_logging(capture: dict, *, guardrail_effect=None):
"""A ``proxy_logging_obj`` double whose hooks capture the data they receive.
``guardrail_effect`` stands in for a guardrail: it is handed the synthetic
request dict so it can append a guardrail record (and optionally raise, the
way a blocking guardrail does).
"""
plo = mock.MagicMock()
plo._create_mcp_request_object_from_kwargs.return_value = mock.MagicMock()
# Mirror the real conversion's metadata bucket so a test can prove it survives.
plo._convert_mcp_to_llm_format.side_effect = lambda *_a, **_k: {
"metadata": {"headers": {"x-forwarded-for": "1.2.3.4"}}
}
async def _hook(*, user_api_key_dict, data, call_type) -> None:
del user_api_key_dict # captured shape is what matters, not the auth double
capture["data"] = data
capture["call_type"] = call_type
if guardrail_effect is not None:
guardrail_effect(data)
plo.pre_call_hook.side_effect = _hook
plo.during_call_hook.side_effect = _hook
return plo
def _record_guardrail(status: str = "success"):
"""Write a guardrail record the way ``@log_guardrail_information`` does."""
def _effect(data: dict) -> None:
data.setdefault("metadata", {}).setdefault("standard_logging_guardrail_information", []).append(
{"guardrail_name": "test-guardrail", "guardrail_status": status}
)
return _effect
def _blocking_guardrail():
record = _record_guardrail(status="guardrail_intervened")
def _effect(data: dict) -> None:
record(data)
raise GuardrailRaisedException(guardrail_name="test-guardrail", message="blocked")
return _effect
async def _run_pre_call(mgr, plo, logging_obj) -> dict:
return await mgr.pre_call_tool_check(
name="t",
arguments={},
server_name="s",
user_api_key_auth=None,
proxy_logging_obj=plo,
server=mock.MagicMock(),
raw_headers={},
litellm_logging_obj=logging_obj,
)
@pytest.mark.asyncio
async def test_pre_call_seeds_request_logging_obj_for_unified_guardrails():
"""Unified guardrails read ``data["litellm_logging_obj"]`` and pass it into
``apply_guardrail``, whose ``@log_guardrail_information`` wrapper bridges the
evaluation onto that logger itself. Drop the seed and that path records
nothing."""
capture: dict = {}
logging_obj = _FakeLoggingObj()
await _run_pre_call(_bare_manager(), _fake_proxy_logging(capture), logging_obj)
assert capture["data"]["litellm_logging_obj"] is logging_obj
@pytest.mark.asyncio
async def test_pre_call_keeps_synthetic_request_headers_metadata():
"""The seed must not clobber the metadata bucket ``_convert_mcp_to_llm_format``
builds: guardrails such as ``MCPJWTSigner`` read ``metadata["headers"]`` off
it."""
capture: dict = {}
await _run_pre_call(_bare_manager(), _fake_proxy_logging(capture), _FakeLoggingObj())
assert capture["data"]["metadata"]["headers"] == {"x-forwarded-for": "1.2.3.4"}
@pytest.mark.asyncio
async def test_pre_call_bridges_allowed_evaluation_onto_request_logger():
"""An allowed ``pre_mcp_call`` evaluation must land on the request logger, which
is what the monitor's "Total Evaluations" counts."""
capture: dict = {}
logging_obj = _FakeLoggingObj()
plo = _fake_proxy_logging(capture, guardrail_effect=_record_guardrail())
await _run_pre_call(_bare_manager(), plo, logging_obj)
assert logging_obj.recorded_guardrails == [{"guardrail_name": "test-guardrail", "guardrail_status": "success"}]
@pytest.mark.asyncio
async def test_pre_call_bridges_blocked_evaluation_before_reraising():
"""A block raises straight out of ``pre_call_tool_check``, and the failure
spend-log row that "Total Blocked" counts is built from this logger further up
the stack. So the record has to be attached before the exception leaves the
frame -- hence the bridge lives in a ``finally``."""
capture: dict = {}
logging_obj = _FakeLoggingObj()
plo = _fake_proxy_logging(capture, guardrail_effect=_blocking_guardrail())
with pytest.raises(GuardrailRaisedException):
await _run_pre_call(_bare_manager(), plo, logging_obj)
assert logging_obj.recorded_guardrails == [
{"guardrail_name": "test-guardrail", "guardrail_status": "guardrail_intervened"}
]
@pytest.mark.asyncio
async def test_pre_call_without_logging_obj_is_unchanged():
"""Callers that thread no logger are unaffected: the seed is an explicit
``None`` (which every consumer reads via ``.get``) and nothing is bridged.
Guards against the bridge assuming a logger exists."""
capture: dict = {}
plo = _fake_proxy_logging(capture, guardrail_effect=_record_guardrail())
mgr = _bare_manager()
result = await mgr.pre_call_tool_check(
name="t",
arguments={},
server_name="s",
user_api_key_auth=None,
proxy_logging_obj=plo,
server=mock.MagicMock(),
raw_headers={},
)
assert result == {}
assert capture["data"]["litellm_logging_obj"] is None
@pytest.mark.asyncio
async def test_during_hook_seeds_and_bridges_onto_request_logger():
"""``during_mcp_call`` evaluations need the same treatment. The task is awaited
before the tool call's success logging runs, so the record is serialized with
that call."""
capture: dict = {}
logging_obj = _FakeLoggingObj()
plo = _fake_proxy_logging(capture, guardrail_effect=_record_guardrail())
await _bare_manager()._create_during_hook_task(
name="t",
arguments={},
server_name_from_prefix="s",
user_api_key_auth=None,
proxy_logging_obj=plo,
start_time=datetime.datetime(2026, 7, 14),
litellm_logging_obj=logging_obj,
)
assert capture["data"]["litellm_logging_obj"] is logging_obj
assert logging_obj.recorded_guardrails == [{"guardrail_name": "test-guardrail", "guardrail_status": "success"}]
@pytest.mark.asyncio
async def test_during_hook_bridges_even_when_hook_raises():
"""A during-call guardrail block must still be recorded before the task's
exception propagates to the ``asyncio.gather`` in ``call_tool``."""
capture: dict = {}
logging_obj = _FakeLoggingObj()
plo = _fake_proxy_logging(capture, guardrail_effect=_blocking_guardrail())
task = _bare_manager()._create_during_hook_task(
name="t",
arguments={},
server_name_from_prefix="s",
user_api_key_auth=None,
proxy_logging_obj=plo,
start_time=datetime.datetime(2026, 7, 14),
litellm_logging_obj=logging_obj,
)
with pytest.raises(GuardrailRaisedException):
await task
assert logging_obj.recorded_guardrails == [
{"guardrail_name": "test-guardrail", "guardrail_status": "guardrail_intervened"}
]
@pytest.mark.asyncio
async def test_bridge_failure_does_not_mask_a_guardrail_block():
"""Recording is best-effort bookkeeping. If the bridge itself raises, the guardrail's
block must still be what the caller sees, not a bookkeeping error.
The bridge is forced to fail by making the logger's ``model_call_details`` raise, and
the swallow is asserted (not just the surviving exception type) so the test cannot go
vacuous if a refactor stops the bridge from touching that attribute.
"""
capture: dict = {}
plo = _fake_proxy_logging(capture, guardrail_effect=_blocking_guardrail())
broken_logging_obj = mock.MagicMock()
type(broken_logging_obj).model_call_details = mock.PropertyMock(side_effect=RuntimeError("boom"))
with mock.patch.object(MOD.verbose_logger, "warning") as warn:
with pytest.raises(GuardrailRaisedException):
await _run_pre_call(_bare_manager(), plo, broken_logging_obj)
assert warn.call_count == 1, "the bridge did not actually fail, so this test proves nothing"
assert "boom" in str(warn.call_args)
@pytest.mark.asyncio
async def test_call_tool_threads_logging_obj_into_both_hooks():
"""``call_tool`` is the single entry point every MCP dispatch route funnels
through, so it must hand the logger to both guardrail hook sites."""
mgr = _bare_manager()
logging_obj = _FakeLoggingObj()
seen: dict = {}
async def _fake_pre_call_tool_check(**kwargs):
seen["pre_call"] = kwargs.get("litellm_logging_obj")
return {}
def _fake_during_hook_task(**kwargs):
seen["during_call"] = kwargs.get("litellm_logging_obj")
return asyncio.get_running_loop().create_future()
mgr.pre_call_tool_check = _fake_pre_call_tool_check
mgr._create_during_hook_task = _fake_during_hook_task
mgr._resolve_mcp_server_for_tool_call = lambda server_name, name: mock.MagicMock(spec_path=None)
mgr._resolve_oauth2_headers_for_tool_call = mock.AsyncMock(return_value=None)
mgr._call_regular_mcp_tool = mock.AsyncMock(return_value=mock.MagicMock())
with mock.patch.object(MOD, "_resolve_byok_mcp_auth_header", mock.AsyncMock(return_value=None)):
await mgr.call_tool(
server_name="s",
name="t",
arguments={},
proxy_logging_obj=mock.MagicMock(),
litellm_logging_obj=logging_obj,
)
assert seen == {"pre_call": logging_obj, "during_call": logging_obj}

View file

@ -125,6 +125,45 @@ class TestBlockedResponseUsage:
mock_logging.post_call_failure_hook.assert_awaited_once()
class TestProxyExceptionPassthrough:
@pytest.mark.asyncio
async def test_anthropic_response_reraises_proxy_exception_unwrapped(self):
"""A 400 ProxyException from request validation must surface as-is,
not be re-wrapped into a code-500 ProxyException."""
import litellm.proxy.anthropic_endpoints.endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy._types import ProxyErrorTypes, ProxyException
exc = ProxyException(
message="Invalid type for 'metadata': expected an object, but got a string instead.",
type=ProxyErrorTypes.bad_request_error,
param="metadata",
code=400,
)
with (
patch.object(ep, "_read_request_body", new=AsyncMock(return_value={})),
patch.object(
ep.ProxyBaseLLMRequestProcessing,
"base_process_llm_request",
new=AsyncMock(side_effect=exc),
),
patch.object(proxy_server, "proxy_logging_obj") as mock_logging,
):
mock_logging.post_call_failure_hook = AsyncMock()
with pytest.raises(ProxyException) as exc_info:
await ep.anthropic_response(
fastapi_response=MagicMock(),
request=MagicMock(),
user_api_key_dict=MagicMock(),
)
assert exc_info.value is exc
assert exc_info.value.code == "400"
assert exc_info.value.param == "metadata"
mock_logging.post_call_failure_hook.assert_awaited_once()
class TestEventLoggingBatchEndpoint:
"""Test the stubbed event logging batch endpoint"""

View file

@ -2,6 +2,7 @@ import asyncio
import json
import os
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
sys.path.insert(
@ -51,9 +52,22 @@ from litellm.proxy.auth.auth_checks import (
)
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.caching.redis_cache import RedisCache
from litellm.constants import DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL
from litellm.constants import (
DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL,
END_USER_RESTRICTED_REGISTRY_MAX_SIZE,
REGISTRY_ERROR_NEGATIVE_CACHE_TTL,
TAG_REGISTRY_MAX_SIZE,
)
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.common_utils.user_api_key_cache import (
END_USER_RESTRICTED_REGISTRY_OVERFLOW_SENTINEL,
TAG_REGISTRY_OVERFLOW_SENTINEL,
UserApiKeyCache,
end_user_cache_key,
end_user_restricted_registry_cache_key,
tag_cache_key,
tag_registry_cache_key,
)
from litellm.utils import get_utc_datetime
@ -2075,22 +2089,342 @@ async def test_get_tag_objects_batch():
assert tag_objects["uncached-2"].spend == 40.0
assert tag_objects["uncached-3"].spend == 50.0
# Verify DB was called ONCE with all 3 uncached tags
mock_prisma.db.litellm_tagtable.find_many.assert_called_once()
call_args = mock_prisma.db.litellm_tagtable.find_many.call_args
assert call_args.kwargs["where"]["tag_name"]["in"] == [
# Verify the DB saw exactly the registry query plus ONE batch query for all 3 uncached tags
assert mock_prisma.db.litellm_tagtable.find_many.call_count == 2
registry_call, batch_call = mock_prisma.db.litellm_tagtable.find_many.call_args_list
assert "where" not in registry_call.kwargs
assert batch_call.kwargs["where"]["tag_name"]["in"] == [
"uncached-1",
"uncached-2",
"uncached-3",
]
# Verify uncached tags were cached after fetching
assert mock_cache.async_set_cache.call_count == 3
# Verify uncached tags were cached after fetching, alongside the tag-name registry
cache_calls = mock_cache.async_set_cache.call_args_list
cached_keys = [call.kwargs["key"] for call in cache_calls]
assert "tag:uncached-1" in cached_keys
assert "tag:uncached-2" in cached_keys
assert "tag:uncached-3" in cached_keys
assert sorted(cached_keys) == [
"tag:uncached-1",
"tag:uncached-2",
"tag:uncached-3",
"tag_registry",
]
# Every write is TTL-bounded; an unbounded tag entry would outlive budget updates.
assert all("ttl" in call.kwargs for call in cache_calls)
class _TtlRecordingCache(UserApiKeyCache):
"""A real cache that also records the ttl each write carried, so tests can catch unbounded entries."""
def __init__(self):
super().__init__()
self.writes = []
async def async_set_cache(self, key, value, local_only: bool = False, **kwargs):
self.writes.append((key, kwargs.get("ttl")))
return await super().async_set_cache(key, value, local_only=local_only, **kwargs)
def _tag_registry_row(tag_name: str):
"""A row as the names-only registry query sees it: only ``tag_name`` is read off it."""
return SimpleNamespace(tag_name=tag_name)
def _tag_db_row(tag_name: str, max_budget=None):
row = MagicMock()
row.tag_name = tag_name
budget = None if max_budget is None else {"max_budget": max_budget}
row.dict = MagicMock(
return_value={
"tag_name": tag_name,
"spend": 0.0,
"models": [],
"litellm_budget_table": budget,
}
)
return row
def _registry_calls(find_many):
return [call for call in find_many.call_args_list if "where" not in call.kwargs]
def _batch_calls(find_many):
return [call for call in find_many.call_args_list if "where" in call.kwargs]
@pytest.mark.asyncio
async def test_get_tag_objects_batch_never_queries_db_for_unregistered_tags():
"""
Regression: a request tag with no LiteLLM_TagTable row must not cost a DB read per request.
Cost-attribution tags are free-form, so most carry no tag row. Before the cached name
registry, every request carrying one ran its own Postgres find_many, forever, which is what
saturated a customer's Prisma pool.
"""
from litellm.proxy.auth.auth_checks import get_tag_objects_batch
mock_prisma = MagicMock()
mock_prisma.db.litellm_tagtable.find_many = AsyncMock(
return_value=[_tag_registry_row("some-other-tag")]
)
cache = UserApiKeyCache()
first = await get_tag_objects_batch(
tag_names=["unregistered-tag"],
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
assert first == {}
# The only query is the names-only registry fetch; the tag itself is never looked up.
mock_prisma.db.litellm_tagtable.find_many.assert_called_once_with(
take=TAG_REGISTRY_MAX_SIZE + 1
)
second = await get_tag_objects_batch(
tag_names=["unregistered-tag"],
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
assert second == {}
assert mock_prisma.db.litellm_tagtable.find_many.call_count == 1
@pytest.mark.asyncio
async def test_get_tag_objects_batch_fetches_only_registered_uncached_tags():
"""Cached tags skip the DB, registered ones are batch-fetched, unregistered ones are dropped."""
from litellm.proxy.auth.auth_checks import get_tag_objects_batch
cache = UserApiKeyCache()
await cache.async_set_cache(
key=tag_cache_key("cached-tag"),
value=LiteLLM_TagTable(tag_name="cached-tag", spend=7.0, models=[]),
model_type=LiteLLM_TagTable,
)
async def fake_find_many(**kwargs):
if "where" not in kwargs:
return [_tag_registry_row("cached-tag"), _tag_registry_row("registered-tag")]
requested = kwargs["where"]["tag_name"]["in"]
return [_tag_db_row(name) for name in requested if name == "registered-tag"]
mock_prisma = MagicMock()
mock_prisma.db.litellm_tagtable.find_many = AsyncMock(side_effect=fake_find_many)
tag_objects = await get_tag_objects_batch(
tag_names=["cached-tag", "registered-tag", "unregistered-tag"],
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
assert sorted(tag_objects) == ["cached-tag", "registered-tag"]
assert tag_objects["cached-tag"].spend == 7.0
batch_calls = _batch_calls(mock_prisma.db.litellm_tagtable.find_many)
assert len(batch_calls) == 1
assert batch_calls[0].kwargs["where"]["tag_name"]["in"] == ["registered-tag"]
@pytest.mark.asyncio
async def test_get_tag_objects_batch_caches_empty_registry():
"""An empty tag table is a valid registry answer and must be cached, not re-queried."""
from litellm.proxy.auth.auth_checks import get_tag_objects_batch
mock_prisma = MagicMock()
mock_prisma.db.litellm_tagtable.find_many = AsyncMock(return_value=[])
cache = UserApiKeyCache()
assert (
await get_tag_objects_batch(
tag_names=["tag-a", "tag-b"],
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
== {}
)
# "No tags registered" is a cached answer, not a cache miss (which would be None).
cached_registry = await cache.async_get_cache(key=tag_registry_cache_key())
assert cached_registry is not None
assert tuple(cached_registry) == ()
assert (
await get_tag_objects_batch(
tag_names=["tag-a", "tag-b"],
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
== {}
)
assert mock_prisma.db.litellm_tagtable.find_many.call_count == 1
@pytest.mark.asyncio
async def test_get_tag_objects_batch_registry_db_error_negative_caches_and_keeps_per_tag_fetch():
"""
A degraded database must not be re-asked for the registry on every request.
Without the negative cache the failing scan re-runs per request on top of the per-tag fallback
it triggers, doubling load exactly when Postgres is least able to take it. Tag budgets keep
being enforced through the per-tag path throughout, and the registry is retried once the
negative entry expires.
"""
from litellm.proxy.auth.auth_checks import get_tag_objects_batch
async def fake_find_many(**kwargs):
if "where" not in kwargs:
raise Exception("registry query failed")
requested = kwargs["where"]["tag_name"]["in"]
return [_tag_db_row(name) for name in requested]
mock_prisma = MagicMock()
mock_prisma.db.litellm_tagtable.find_many = AsyncMock(side_effect=fake_find_many)
cache = _TtlRecordingCache()
first = await get_tag_objects_batch(
tag_names=["tag-a"],
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
assert list(first) == ["tag-a"]
assert await cache.async_get_cache(key=tag_registry_cache_key()) == TAG_REGISTRY_OVERFLOW_SENTINEL
assert (tag_registry_cache_key(), REGISTRY_ERROR_NEGATIVE_CACHE_TTL) in cache.writes
second = await get_tag_objects_batch(
tag_names=["tag-b"],
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
assert list(second) == ["tag-b"]
assert len(_registry_calls(mock_prisma.db.litellm_tagtable.find_many)) == 1
# The window closing (here: the entry expiring) puts the registry back in play.
await cache.async_delete_cache(key=tag_registry_cache_key())
third = await get_tag_objects_batch(
tag_names=["tag-c"],
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
assert list(third) == ["tag-c"]
assert len(_registry_calls(mock_prisma.db.litellm_tagtable.find_many)) == 2
@pytest.mark.asyncio
async def test_tag_registry_load_is_single_flighted_across_concurrent_requests():
"""
A cold registry under load must run one scan, not one per in-flight request.
The registry query is an unindexed table scan; a TTL expiry on a busy worker would otherwise
fan out into as many identical scans as there are concurrent requests.
"""
from litellm.proxy.auth.auth_checks import get_tag_objects_batch
async def fake_find_many(**kwargs):
if "where" not in kwargs:
await asyncio.sleep(0)
return [_tag_registry_row("registered-tag")]
return [_tag_db_row(name) for name in kwargs["where"]["tag_name"]["in"]]
mock_prisma = MagicMock()
mock_prisma.db.litellm_tagtable.find_many = AsyncMock(side_effect=fake_find_many)
cache = UserApiKeyCache()
results = await asyncio.gather(
*(
get_tag_objects_batch(
tag_names=["registered-tag"],
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
for _ in range(8)
)
)
assert all(list(result) == ["registered-tag"] for result in results)
assert len(_registry_calls(mock_prisma.db.litellm_tagtable.find_many)) == 1
@pytest.mark.asyncio
async def test_get_tag_objects_batch_oversized_registry_falls_back_and_stops_refetching():
"""Past the cap the registry is unusable: keep the old per-tag path, but stop rebuilding it."""
from litellm.proxy.auth.auth_checks import get_tag_objects_batch
oversized = [
_tag_registry_row(f"tag-{index}") for index in range(TAG_REGISTRY_MAX_SIZE + 1)
]
async def fake_find_many(**kwargs):
if "where" not in kwargs:
return oversized
return [_tag_db_row(name) for name in kwargs["where"]["tag_name"]["in"]]
mock_prisma = MagicMock()
mock_prisma.db.litellm_tagtable.find_many = AsyncMock(side_effect=fake_find_many)
cache = UserApiKeyCache()
first = await get_tag_objects_batch(
tag_names=["tag-a"],
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
assert list(first) == ["tag-a"]
assert (
await cache.async_get_cache(key=tag_registry_cache_key())
== TAG_REGISTRY_OVERFLOW_SENTINEL
)
second = await get_tag_objects_batch(
tag_names=["tag-b"],
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
assert list(second) == ["tag-b"]
find_many = mock_prisma.db.litellm_tagtable.find_many
assert len(_registry_calls(find_many)) == 1
assert [call.kwargs["where"]["tag_name"]["in"] for call in _batch_calls(find_many)] == [
["tag-a"],
["tag-b"],
]
@pytest.mark.asyncio
async def test_tag_max_budget_check_still_enforces_registered_tag_over_budget():
"""The registry filter must not swallow a real tag: an over-budget tag still raises."""
from litellm.proxy.utils import ProxyLogging
async def fake_find_many(**kwargs):
if "where" not in kwargs:
return [_tag_registry_row("paid-tag")]
return [
_tag_db_row(name, max_budget=1.0)
for name in kwargs["where"]["tag_name"]["in"]
]
mock_prisma = MagicMock()
mock_prisma.db.litellm_tagtable.find_many = AsyncMock(side_effect=fake_find_many)
async def mock_get_current_spend(
counter_key, fallback_spend, max_budget=None, **kwargs
):
if counter_key == "spend:tag:paid-tag":
return 1.5
return fallback_spend
with patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend):
with pytest.raises(litellm.BudgetExceededError) as exc_info:
await _tag_max_budget_check(
request_body={"metadata": {"tags": ["paid-tag", "unregistered-tag"]}},
prisma_client=mock_prisma,
user_api_key_cache=UserApiKeyCache(),
proxy_logging_obj=ProxyLogging(user_api_key_cache=None),
valid_token=UserAPIKeyAuth(token="test-token"),
)
assert exc_info.value.current_cost == 1.5
assert exc_info.value.entity_id == "paid-tag"
# The unregistered tag alongside it never reached the DB.
batch_calls = _batch_calls(mock_prisma.db.litellm_tagtable.find_many)
assert [call.kwargs["where"]["tag_name"]["in"] for call in batch_calls] == [["paid-tag"]]
@pytest.mark.asyncio
@ -5390,6 +5724,400 @@ async def test_get_end_user_object_db_fetch_returns_validated_end_user():
assert result.spend == 3.0
def _end_user_registry_row(user_id: str):
"""A row as the restricted-id registry query sees it: only ``user_id`` is read off it."""
return SimpleNamespace(user_id=user_id)
def _end_user_db_row(user_id: str, **fields):
row = MagicMock()
row.user_id = user_id
row.dict = lambda: {"user_id": user_id, "blocked": False, "spend": 0.0, **fields}
return row
_RESTRICTED_END_USER_WHERE = {
"OR": [
{"blocked": True},
{"budget_id": {"not": None}},
{"allowed_model_region": {"not": None}},
{"default_model": {"not": None}},
{"object_permission_id": {"not": None}},
]
}
@pytest.fixture
def end_user_registry_skip_enabled(monkeypatch):
"""Both bypass gates off: the default deployment, and the only state the registry skip runs in."""
monkeypatch.setattr(litellm, "max_end_user_budget_id", None)
monkeypatch.setattr(litellm, "validate_end_user_id_in_db", False)
@pytest.mark.asyncio
async def test_get_end_user_object_never_queries_db_for_unrestricted_end_users(
end_user_registry_skip_enabled,
):
"""
Regression: an end user carrying no restriction must not cost a DB read per request.
Spend tracking auto-creates a row for every distinct caller-supplied ``user`` id with every
restriction field null, so a high-cardinality deployment misses the per-pod cache on virtually
every request. Before the cached registry each miss ran its own Postgres find_unique, twice per
request, and under Prisma pool contention those queued for minutes inside user_api_key_auth.
"""
from litellm.proxy.auth.auth_checks import get_end_user_object
mock_prisma = MagicMock()
mock_prisma.db.litellm_endusertable.find_many = AsyncMock(return_value=[_end_user_registry_row("eu-blocked")])
mock_prisma.db.litellm_endusertable.find_unique = AsyncMock(return_value=_end_user_db_row("eu-anon-1"))
cache = UserApiKeyCache()
assert (
await get_end_user_object(
end_user_id="eu-anon-1",
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
is None
)
mock_prisma.db.litellm_endusertable.find_unique.assert_not_awaited()
mock_prisma.db.litellm_endusertable.find_many.assert_awaited_once()
registry_call = mock_prisma.db.litellm_endusertable.find_many.call_args
assert registry_call.kwargs["take"] == END_USER_RESTRICTED_REGISTRY_MAX_SIZE + 1
# Every field the callers of get_end_user_object consume has to be in this predicate, or an id
# the registry calls unrestricted would silently lose a restriction that is actually enforced.
assert registry_call.kwargs["where"] == _RESTRICTED_END_USER_WHERE
mock_prisma.db.litellm_endusertable.find_many.reset_mock()
assert (
await get_end_user_object(
end_user_id="eu-anon-2",
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
is None
)
# A second, different unknown id inside the TTL costs nothing: no rebuild, no row fetch.
mock_prisma.db.litellm_endusertable.find_many.assert_not_awaited()
mock_prisma.db.litellm_endusertable.find_unique.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_end_user_object_still_fetches_restricted_end_user(end_user_registry_skip_enabled):
"""An id in the registry keeps today's path: fetched, TTL-bounded in cache, then served cached."""
from litellm.proxy.auth.auth_checks import get_end_user_object
mock_prisma = MagicMock()
mock_prisma.db.litellm_endusertable.find_many = AsyncMock(return_value=[_end_user_registry_row("eu-blocked")])
mock_prisma.db.litellm_endusertable.find_unique = AsyncMock(
return_value=_end_user_db_row("eu-blocked", blocked=True)
)
cache = _TtlRecordingCache()
blocked = await get_end_user_object(
end_user_id="eu-blocked",
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
assert isinstance(blocked, LiteLLM_EndUserTable)
assert blocked.blocked is True
mock_prisma.db.litellm_endusertable.find_unique.assert_awaited_once()
# Without a ttl the Redis entry never expires, so a later unblock would never be picked up.
assert (end_user_cache_key("eu-blocked"), DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL) in cache.writes
mock_prisma.db.litellm_endusertable.find_unique.reset_mock()
again = await get_end_user_object(
end_user_id="eu-blocked",
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
assert again is not None and again.blocked is True
mock_prisma.db.litellm_endusertable.find_unique.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_end_user_object_caches_empty_restricted_registry(end_user_registry_skip_enabled):
"""No restricted end users at all is a valid answer and must be cached, not re-queried."""
from litellm.proxy.auth.auth_checks import get_end_user_object
mock_prisma = MagicMock()
mock_prisma.db.litellm_endusertable.find_many = AsyncMock(return_value=[])
mock_prisma.db.litellm_endusertable.find_unique = AsyncMock(return_value=_end_user_db_row("eu-anon-1"))
cache = UserApiKeyCache()
assert (
await get_end_user_object(
end_user_id="eu-anon-1",
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
is None
)
# "Nobody is restricted" is a cached answer, not a cache miss (which would read back as None).
cached_registry = await cache.async_get_cache(key=end_user_restricted_registry_cache_key())
assert cached_registry is not None
assert tuple(cached_registry) == ()
assert (
await get_end_user_object(
end_user_id="eu-anon-2",
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
is None
)
mock_prisma.db.litellm_endusertable.find_many.assert_awaited_once()
mock_prisma.db.litellm_endusertable.find_unique.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_end_user_object_registry_db_error_negative_caches_and_keeps_per_id_fetch(
end_user_registry_skip_enabled,
):
"""
A degraded database must not be re-asked for the registry on every request.
Restrictions keep being enforced through the per-id fetch, exactly as before the registry
existed, but the failing scan is suppressed for the negative-cache window instead of running
again on every request on top of that fetch. It is retried once the window closes.
"""
from litellm.proxy.auth.auth_checks import get_end_user_object
mock_prisma = MagicMock()
mock_prisma.db.litellm_endusertable.find_many = AsyncMock(side_effect=Exception("registry query failed"))
mock_prisma.db.litellm_endusertable.find_unique = AsyncMock(
side_effect=lambda **kwargs: _end_user_db_row(kwargs["where"]["user_id"], blocked=True)
)
cache = _TtlRecordingCache()
first = await get_end_user_object(
end_user_id="eu-blocked-1",
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
assert first is not None and first.blocked is True
assert (
await cache.async_get_cache(key=end_user_restricted_registry_cache_key())
== END_USER_RESTRICTED_REGISTRY_OVERFLOW_SENTINEL
)
assert (end_user_restricted_registry_cache_key(), REGISTRY_ERROR_NEGATIVE_CACHE_TTL) in cache.writes
second = await get_end_user_object(
end_user_id="eu-blocked-2",
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
assert second is not None and second.blocked is True
mock_prisma.db.litellm_endusertable.find_many.assert_awaited_once()
# The window closing (here: the entry expiring) puts the registry back in play.
await cache.async_delete_cache(key=end_user_restricted_registry_cache_key())
third = await get_end_user_object(
end_user_id="eu-blocked-3",
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
assert third is not None and third.blocked is True
assert mock_prisma.db.litellm_endusertable.find_many.await_count == 2
@pytest.mark.asyncio
async def test_registry_db_error_is_logged_at_warning(end_user_registry_skip_enabled):
"""
A registry that stops loading is a silent enforcement degradation, so seeing it must not
require debug logging: per-id lookups still enforce restrictions, but an operator has no other
signal that the database is failing the scan and that every request is paying for it.
"""
from litellm.proxy.auth.auth_checks import get_end_user_object
mock_prisma = MagicMock()
mock_prisma.db.litellm_endusertable.find_many = AsyncMock(side_effect=Exception("registry query failed"))
mock_prisma.db.litellm_endusertable.find_unique = AsyncMock(return_value=_end_user_db_row("eu-1", blocked=True))
with patch("litellm.proxy.auth.auth_checks.verbose_proxy_logger") as mock_logger:
await get_end_user_object(
end_user_id="eu-1",
prisma_client=mock_prisma,
user_api_key_cache=UserApiKeyCache(),
)
warnings = [_rendered_log_message(call) for call in mock_logger.warning.call_args_list]
assert any(
end_user_restricted_registry_cache_key() in message and "registry query failed" in message
for message in warnings
)
@pytest.mark.asyncio
async def test_end_user_registry_load_is_single_flighted_across_concurrent_requests(
end_user_registry_skip_enabled,
):
"""
A cold registry under load must run one scan, not one per in-flight request.
The registry query is an unindexed scan over the end-user table, which for the deployments this
exists for holds hundreds of thousands of rows; a TTL expiry on a busy worker would otherwise
fan it out across every concurrent request.
"""
from litellm.proxy.auth.auth_checks import get_end_user_object
async def fake_find_many(**kwargs):
await asyncio.sleep(0)
return [_end_user_registry_row("eu-blocked")]
mock_prisma = MagicMock()
mock_prisma.db.litellm_endusertable.find_many = AsyncMock(side_effect=fake_find_many)
mock_prisma.db.litellm_endusertable.find_unique = AsyncMock(return_value=_end_user_db_row("eu-anon-1"))
cache = UserApiKeyCache()
results = await asyncio.gather(
*(
get_end_user_object(
end_user_id="eu-anon-1",
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
for _ in range(8)
)
)
assert all(result is None for result in results)
mock_prisma.db.litellm_endusertable.find_many.assert_awaited_once()
mock_prisma.db.litellm_endusertable.find_unique.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_end_user_object_oversized_registry_falls_back_and_stops_refetching(
end_user_registry_skip_enabled,
):
"""Past the cap the registry is unusable: keep the per-id path, but stop rebuilding the set."""
from litellm.proxy.auth.auth_checks import get_end_user_object
oversized = [_end_user_registry_row(f"eu-{index}") for index in range(END_USER_RESTRICTED_REGISTRY_MAX_SIZE + 1)]
mock_prisma = MagicMock()
mock_prisma.db.litellm_endusertable.find_many = AsyncMock(return_value=oversized)
mock_prisma.db.litellm_endusertable.find_unique = AsyncMock(
side_effect=lambda **kwargs: _end_user_db_row(kwargs["where"]["user_id"], blocked=True)
)
cache = UserApiKeyCache()
first = await get_end_user_object(
end_user_id="eu-anon-1",
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
assert first is not None and first.blocked is True
assert (
await cache.async_get_cache(key=end_user_restricted_registry_cache_key())
== END_USER_RESTRICTED_REGISTRY_OVERFLOW_SENTINEL
)
second = await get_end_user_object(
end_user_id="eu-anon-2",
prisma_client=mock_prisma,
user_api_key_cache=cache,
)
assert second is not None and second.blocked is True
mock_prisma.db.litellm_endusertable.find_many.assert_awaited_once()
assert mock_prisma.db.litellm_endusertable.find_unique.await_count == 2
@pytest.mark.asyncio
async def test_get_end_user_object_default_budget_gate_keeps_fetching_unrestricted_end_users(monkeypatch):
"""
With ``max_end_user_budget_id`` set, an existing unrestricted row is not equivalent to a missing
one: the default budget is grafted onto whatever row exists and is then enforced, so the skip
has to stay off entirely.
"""
from litellm.proxy.auth.auth_checks import get_end_user_object
monkeypatch.setattr(litellm, "max_end_user_budget_id", "default-eu-budget")
monkeypatch.setattr(litellm, "validate_end_user_id_in_db", False)
budget_row = MagicMock()
budget_row.dict = lambda: {"budget_id": "default-eu-budget", "max_budget": 25.0}
mock_prisma = MagicMock()
mock_prisma.db.litellm_endusertable.find_many = AsyncMock(return_value=[])
mock_prisma.db.litellm_endusertable.find_unique = AsyncMock(return_value=_end_user_db_row("eu-anon-1"))
mock_prisma.db.litellm_budgettable.find_unique = AsyncMock(return_value=budget_row)
result = await get_end_user_object(
end_user_id="eu-anon-1",
prisma_client=mock_prisma,
user_api_key_cache=UserApiKeyCache(),
)
assert result is not None
assert result.litellm_budget_table is not None
assert result.litellm_budget_table.max_budget == 25.0
mock_prisma.db.litellm_endusertable.find_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_end_user_object_token_budget_gate_keeps_fetching_unrestricted_end_users(
end_user_registry_skip_enabled,
):
"""
A token-supplied end-user budget is enforced against the row's recorded spend, so the row has
to be loaded even though nothing on it is restricted.
A ``user_custom_auth`` callable can set ``end_user_max_budget`` on the returned token for an
end user whose row carries no budget of its own, which keeps it out of the registry.
"""
from litellm.proxy.auth.auth_checks import get_end_user_object
mock_prisma = MagicMock()
mock_prisma.db.litellm_endusertable.find_many = AsyncMock(return_value=[])
mock_prisma.db.litellm_endusertable.find_unique = AsyncMock(
return_value=_end_user_db_row("eu-anon-1", spend=100.0)
)
cache = UserApiKeyCache()
result = await get_end_user_object(
end_user_id="eu-anon-1",
prisma_client=mock_prisma,
user_api_key_cache=cache,
token_end_user_max_budget=50.0,
)
assert result is not None
assert result.spend == 100.0
mock_prisma.db.litellm_endusertable.find_unique.assert_awaited_once()
mock_prisma.db.litellm_endusertable.find_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_end_user_id_validation_gate_still_resolves_unrestricted_end_users(monkeypatch):
"""
With ``validate_end_user_id_in_db`` on, existence itself is the answer, so the skip stays off.
Skipping here would turn every unrestricted customer into an unknown id and drop it from the
request, which for a deployment with no default budget means the id silently stops being tracked.
"""
from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id
monkeypatch.setattr(litellm, "max_end_user_budget_id", None)
monkeypatch.setattr(litellm, "validate_end_user_id_in_db", True)
mock_prisma = MagicMock()
mock_prisma.db.litellm_endusertable.find_many = AsyncMock(return_value=[])
mock_prisma.db.litellm_endusertable.find_unique = AsyncMock(return_value=_end_user_db_row("eu-known-1"))
mock_prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_usertable.find_first = AsyncMock(return_value=None)
resolved = await resolve_and_validate_end_user_id(
raw_end_user_id="eu-known-1",
prisma_client=mock_prisma,
user_api_key_cache=UserApiKeyCache(),
)
assert resolved == "eu-known-1"
mock_prisma.db.litellm_endusertable.find_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_team_membership_db_fetch_returns_validated_membership():
from litellm.proxy._types import LiteLLM_TeamMembership

View file

@ -173,6 +173,55 @@ async def test_custom_auth_defers_end_user_budget_to_common_checks_when_enabled(
mock_check.assert_not_awaited()
@pytest.mark.asyncio
async def test_custom_auth_token_budget_still_loads_and_caches_unrestricted_end_user():
"""
A token-supplied end_user_max_budget must leave the end-user row in cache.
Custom auth can set that budget for a customer whose own row carries no budget, block, region
or permission, which keeps the row out of the cached restricted-id registry that lets auth skip
the read. The end-user spend counter seeds from this cache entry, so skipping the read would
cold-start the counter at 0 and under-count a customer who has already spent 100.
"""
from unittest.mock import MagicMock
from litellm.proxy.auth.user_api_key_auth import _lookup_end_user_and_apply_budget
from litellm.proxy.common_utils.user_api_key_cache import (
UserApiKeyCache,
end_user_cache_key,
)
end_user_row = MagicMock()
end_user_row.user_id = "customer-1"
end_user_row.dict = lambda: {
"user_id": "customer-1",
"blocked": False,
"spend": 100.0,
}
mock_prisma = MagicMock()
mock_prisma.db.litellm_endusertable.find_many = AsyncMock(return_value=[])
mock_prisma.db.litellm_endusertable.find_unique = AsyncMock(return_value=end_user_row)
cache = UserApiKeyCache()
_, end_user_object = await _lookup_end_user_and_apply_budget(
valid_token=UserAPIKeyAuth(
token="test_token",
end_user_id="customer-1",
end_user_max_budget=50.0,
),
route="/v1/chat/completions",
parent_otel_span=None,
prisma_client=mock_prisma,
user_api_key_cache=cache,
proxy_logging_obj=MagicMock(),
)
assert end_user_object is not None
assert end_user_object.spend == 100.0
assert await cache.async_get_cache(key=end_user_cache_key("customer-1")) is not None
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,

View file

@ -2,6 +2,7 @@ import asyncio
import json
import os
import sys
from contextlib import contextmanager
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock, MagicMock, patch
@ -3739,6 +3740,140 @@ async def test_centralized_common_checks_skipped_for_custom_auth_without_flag():
setattr(_proxy_server_mod, k, v)
def _unrestricted_end_user_prisma(spend: float):
"""Prisma stand-in where "customer-1" exists but restricts nothing: no row matches the
restricted-registry query, and the row itself carries only spend."""
end_user_row = MagicMock()
end_user_row.user_id = "customer-1"
end_user_row.dict = lambda: {"user_id": "customer-1", "blocked": False, "spend": spend}
mock_prisma = MagicMock()
mock_prisma.db.litellm_endusertable.find_many = AsyncMock(return_value=[])
mock_prisma.db.litellm_endusertable.find_unique = AsyncMock(return_value=end_user_row)
mock_prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
return mock_prisma
@contextmanager
def _custom_auth_end_user_world(mock_prisma):
"""The proxy globals a custom-auth deployment running the centralized gate reads, with cold
spend counters. Real caches, so the end user's spend reaches the counter the way it does in
production: through the cache entry get_end_user_object writes."""
import litellm.proxy.proxy_server as _proxy_server_mod
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.utils import ProxyLogging
key_cache = UserApiKeyCache()
attrs = {
**_proxy_attrs_for_centralized_checks(user_custom_auth=AsyncMock(), flag=True),
"prisma_client": mock_prisma,
"user_api_key_cache": key_cache,
"spend_counter_cache": DualCache(),
"proxy_logging_obj": ProxyLogging(user_api_key_cache=key_cache),
}
originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs}
try:
for k, v in attrs.items():
setattr(_proxy_server_mod, k, v)
yield
finally:
for k, v in originals.items():
setattr(_proxy_server_mod, k, v)
def _chat_request():
from fastapi import Request
from starlette.datastructures import URL
request = Request(scope={"type": "http"})
request._url = URL(url="/chat/completions")
return request
@pytest.mark.asyncio
async def test_centralized_checks_enforce_token_end_user_budget_against_row_spend():
"""
Regression: a token-supplied end-user budget must still be checked against the end user's
recorded spend.
A user_custom_auth callable can set end_user_max_budget on the token for an end user whose own
row carries no budget, which keeps that row out of the restricted-id registry. Auth must still
load it, because the reservation counter cold-starts from the spend on the loaded row; skipping
the load admits a customer who is already double their budget.
"""
mock_prisma = _unrestricted_end_user_prisma(spend=100.0)
token = UserAPIKeyAuth(
api_key="sk-test",
token="hashed-token",
user_id="u1",
end_user_id="customer-1",
end_user_max_budget=50.0,
)
with _custom_auth_end_user_world(mock_prisma):
with (
patch(
"litellm.proxy.auth.user_api_key_auth.common_checks",
new_callable=AsyncMock,
),
patch(
"litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost",
return_value=0.6,
),
pytest.raises(litellm.BudgetExceededError) as exc_info,
):
await _run_centralized_common_checks(
user_api_key_auth_obj=token,
request=_chat_request(),
request_data={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "hi"}],
},
route="/chat/completions",
)
assert exc_info.value.max_budget == 50.0
assert exc_info.value.current_cost == pytest.approx(100.6)
@pytest.mark.asyncio
async def test_centralized_checks_skip_end_user_lookup_without_a_token_budget():
"""The companion case: with no token budget an unrestricted end user costs zero row reads."""
mock_prisma = _unrestricted_end_user_prisma(spend=100.0)
token = UserAPIKeyAuth(
api_key="sk-test",
token="hashed-token",
user_id="u1",
end_user_id="customer-1",
)
with _custom_auth_end_user_world(mock_prisma):
with (
patch(
"litellm.proxy.auth.user_api_key_auth.common_checks",
new_callable=AsyncMock,
),
patch(
"litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost",
return_value=0.6,
),
):
await _run_centralized_common_checks(
user_api_key_auth_obj=token,
request=_chat_request(),
request_data={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "hi"}],
},
route="/chat/completions",
)
mock_prisma.db.litellm_endusertable.find_unique.assert_not_awaited()
@pytest.mark.asyncio
async def test_centralized_common_checks_runs_for_custom_auth_with_flag():
"""Custom-auth deployments that opt in via custom_auth_run_common_checks

View file

@ -758,6 +758,30 @@ async def test_create__model_encoded_beats_loadbalancing(harness):
harness.creds_resolver.assert_called_once_with(model_id="azure/gpt-4o")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"body, missing_param",
[
({"endpoint": "/v1/chat/completions", "completion_window": "24h"}, "input_file_id"),
({"input_file_id": "file-abc", "completion_window": "24h"}, "endpoint"),
({"input_file_id": "file-abc", "endpoint": "/v1/chat/completions"}, "completion_window"),
({}, "input_file_id"),
],
)
async def test_create__missing_required_param_is_400(harness, body, missing_param):
set_body(harness, body)
with pytest.raises(ProxyException) as exc_info:
await call_create(harness)
assert exc_info.value.code == "400"
assert exc_info.value.type == "invalid_request_error"
assert exc_info.value.param == missing_param
assert exc_info.value.message == f"/batches: Missing required parameter: '{missing_param}'."
harness.litellm_acreate.assert_not_called()
harness.router_acreate.assert_not_called()
# =========================================================================== #
# Team-level batch expiry enforcement (independent of routing).
# =========================================================================== #

View file

@ -1,5 +1,4 @@
from datetime import datetime, timezone
from typing import List
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -45,6 +44,7 @@ app.include_router(router)
client = TestClient(app)
END_USERS_PATH = f"{MANAGEMENT_V1_PREFIX}/spend_logs/end_users"
USERS_PATH = f"{MANAGEMENT_V1_PREFIX}/spend_logs/users"
WINDOW = "filter[startTime][gte]=2026-07-23T00:00:00Z&filter[startTime][lte]=2026-07-24T00:00:00Z"
@ -65,7 +65,7 @@ def as_proxy_admin():
app.dependency_overrides.clear()
def _mock_rows(mock_prisma_client, end_users: List[str]) -> AsyncMock:
def _mock_rows(mock_prisma_client, end_users: list[str]) -> AsyncMock:
query_raw = AsyncMock(return_value=[{"end_user": eu} for eu in end_users])
mock_prisma_client.db.query_raw = query_raw
return query_raw
@ -82,6 +82,11 @@ def _get(query: str = WINDOW):
return client.get(f"{END_USERS_PATH}{suffix}", headers={"Authorization": "Bearer k"})
def _get_users(query: str = WINDOW):
suffix = f"?{query}" if query else ""
return client.get(f"{USERS_PATH}{suffix}", headers={"Authorization": "Bearer k"})
def test_returns_the_control_plane_envelope(mock_prisma_client, as_proxy_admin):
"""`{data, meta, links}` is the contract; a bare list or a legacy `aliases` key is not."""
_mock_rows(mock_prisma_client, ["a", "b"])
@ -213,7 +218,7 @@ def test_requires_a_time_window(mock_prisma_client, as_proxy_admin, query):
def test_rejects_a_malformed_window_as_a_problem_document(mock_prisma_client, as_proxy_admin):
_mock_rows(mock_prisma_client, [])
response = _get(f"filter[startTime][gte]=yesterday&filter[startTime][lte]=2026-07-24T00:00:00Z")
response = _get("filter[startTime][gte]=yesterday&filter[startTime][lte]=2026-07-24T00:00:00Z")
assert response.status_code == 400
assert response.headers["content-type"].startswith("application/problem+json")
@ -400,6 +405,49 @@ def test_q_placeholder_precedes_the_scan_limit_and_offset(mock_prisma_client, as
assert query_raw.call_args.args[5:] == (11, 0)
def test_user_facet_reads_internal_users_from_spend_logs(mock_prisma_client, as_proxy_admin):
query_raw = AsyncMock(return_value=[{"user": "alice@example.com"}, {"user": "user-42"}])
mock_prisma_client.db.query_raw = query_raw
response = _get_users()
assert response.status_code == 200
assert response.json()["data"] == ["alice@example.com", "user-42"]
sql = query_raw.call_args.args[0]
assert 'SELECT DISTINCT "user"' in sql
assert '"user" IS NOT NULL' in sql
assert "end_user IS NOT NULL" not in sql
def test_user_facet_uses_the_same_team_scope_as_request_logs(mock_prisma_client):
query_raw = AsyncMock(return_value=[{"user": "member@example.com"}])
mock_prisma_client.db.query_raw = query_raw
original = _as_role(LitellmUserRoles.INTERNAL_USER, user_id="team-admin-1")
try:
with patch(
"litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs",
new=AsyncMock(return_value=["team-a"]),
):
response = _get_users()
finally:
app.dependency_overrides = original
assert response.status_code == 200
assert '("user" = $3 OR team_id = ANY($4::text[]))' in query_raw.call_args.args[0]
assert query_raw.call_args.args[3] == "team-admin-1"
assert query_raw.call_args.args[4] == ["team-a"]
def test_user_facet_searches_the_internal_user_value(mock_prisma_client, as_proxy_admin):
query_raw = AsyncMock(return_value=[])
mock_prisma_client.db.query_raw = query_raw
_get_users(f"{WINDOW}&q=alice%40example.com")
assert '"user" ILIKE $3 ESCAPE' in query_raw.call_args.args[0]
assert query_raw.call_args.args[3] == "%alice@example.com%"
@pytest.mark.parametrize(
"role",
[
@ -416,18 +464,19 @@ def test_is_reachable_by_every_role_that_can_open_the_logs_page(role):
"""
from litellm.proxy.auth.route_checks import RouteChecks
for allowed in (
LiteLLMRoutes.internal_user_routes.value,
LiteLLMRoutes.internal_user_view_only_routes.value,
):
assert ("/spend/logs/ui" in allowed) == (END_USERS_PATH in allowed)
for facet_path in (END_USERS_PATH, USERS_PATH):
for allowed in (
LiteLLMRoutes.internal_user_routes.value,
LiteLLMRoutes.internal_user_view_only_routes.value,
):
assert ("/spend/logs/ui" in allowed) == (facet_path in allowed)
if role in (LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY):
allowed_routes = (
LiteLLMRoutes.internal_user_routes.value
if role == LitellmUserRoles.INTERNAL_USER
else LiteLLMRoutes.internal_user_view_only_routes.value
)
assert RouteChecks.check_route_access(route=END_USERS_PATH, allowed_routes=allowed_routes)
else:
assert END_USERS_PATH in LiteLLMRoutes.admin_viewer_routes.value
if role in (LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY):
allowed_routes = (
LiteLLMRoutes.internal_user_routes.value
if role == LitellmUserRoles.INTERNAL_USER
else LiteLLMRoutes.internal_user_view_only_routes.value
)
assert RouteChecks.check_route_access(route=facet_path, allowed_routes=allowed_routes)
else:
assert facet_path in LiteLLMRoutes.admin_viewer_routes.value

View file

@ -1,3 +1,4 @@
from contextlib import contextmanager
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -815,3 +816,129 @@ def test_char_delete_body(mock_prisma_client, mock_user_api_key_auth):
"deleted_customers": 2,
"message": "Successfully deleted customers with ids: ['c1', 'c2']",
}
class _RecordingAuthCache:
"""Captures the keys an endpoint evicts, so tests assert on cache keys not mock plumbing."""
def __init__(self):
self.deleted: list[str] = []
async def async_delete_cache(self, key: str) -> None:
self.deleted.append(key)
@contextmanager
def _end_user_cache_doubles():
"""Swaps in the auth cache and the cross-worker publisher a customer mutation is expected to hit."""
recording_cache = _RecordingAuthCache()
mock_publish = AsyncMock()
with (
patch("litellm.proxy.proxy_server.user_api_key_cache", recording_cache),
patch(
"litellm.proxy.common_utils.auth_cache_invalidation_pubsub.publish_auth_cache_invalidation",
mock_publish,
),
):
yield recording_cache, mock_publish
def _published_keys(mock_publish) -> list[str]:
return [call.kwargs["cache_key"] for call in mock_publish.call_args_list]
def test_customer_new_invalidates_end_user_and_registry_caches(mock_prisma_client, mock_user_api_key_auth):
"""
A customer created on one worker must be visible to every worker's auth path immediately.
Auth serves end users cache-first, and the cached restricted-id registry is what decides whether
the row is read at all, so a create that leaves both entries stale means the new customer's
budget or block goes unenforced until the TTL expires.
"""
mock_prisma_client.db.litellm_endusertable.create = AsyncMock(return_value=_row(_FULL_DB_ROW))
with _end_user_cache_doubles() as (recording_cache, mock_publish):
response = client.post(
"/customer/new",
json={"user_id": "c1", "blocked": True},
headers={"Authorization": "Bearer k"},
)
assert response.status_code == 200, response.text
assert recording_cache.deleted == ["end_user_id:c1", "end_user_restricted_registry"]
assert _published_keys(mock_publish) == ["end_user_id:c1", "end_user_restricted_registry"]
def test_customer_update_invalidates_end_user_and_registry_caches(mock_prisma_client, mock_user_api_key_auth):
"""An update can add or drop a budget, block, region or permission, moving the id in the registry."""
mock_prisma_client.db.litellm_endusertable.find_first = AsyncMock(
return_value=_row({"user_id": "c1", "blocked": False})
)
mock_prisma_client.db.litellm_endusertable.update = AsyncMock(return_value=_row(_FULL_DB_ROW))
with _end_user_cache_doubles() as (recording_cache, mock_publish):
response = client.post(
"/customer/update",
json={"user_id": "c1", "budget_id": "b1"},
headers={"Authorization": "Bearer k"},
)
assert response.status_code == 200, response.text
assert recording_cache.deleted == ["end_user_id:c1", "end_user_restricted_registry"]
assert _published_keys(mock_publish) == ["end_user_id:c1", "end_user_restricted_registry"]
def test_customer_block_invalidates_end_user_and_registry_caches(mock_prisma_client, mock_user_api_key_auth):
"""Blocking is the one mutation that must take effect instantly; a stale registry keeps serving it."""
mock_prisma_client.db.litellm_endusertable.upsert = AsyncMock(
return_value=LiteLLM_EndUserTable(user_id="c1", blocked=True)
)
with _end_user_cache_doubles() as (recording_cache, mock_publish):
response = client.post(
"/customer/block",
json={"user_ids": ["c1", "c2"]},
headers={"Authorization": "Bearer k"},
)
assert response.status_code == 200, response.text
assert recording_cache.deleted == [
"end_user_id:c1",
"end_user_id:c2",
"end_user_restricted_registry",
]
assert _published_keys(mock_publish) == [
"end_user_id:c1",
"end_user_id:c2",
"end_user_restricted_registry",
]
def test_customer_delete_invalidates_end_user_and_registry_caches(mock_prisma_client, mock_user_api_key_auth):
"""Without this a deleted customer keeps its cached budget and block enforced until the TTL expires."""
mock_prisma_client.db.litellm_endusertable.find_many = AsyncMock(
return_value=[
LiteLLM_EndUserTable(user_id="c1", blocked=False),
LiteLLM_EndUserTable(user_id="c2", blocked=False),
]
)
mock_prisma_client.db.litellm_endusertable.delete_many = AsyncMock(return_value=2)
with _end_user_cache_doubles() as (recording_cache, mock_publish):
response = client.post(
"/customer/delete",
json={"user_ids": ["c1", "c2"]},
headers={"Authorization": "Bearer k"},
)
assert response.status_code == 200, response.text
assert recording_cache.deleted == [
"end_user_id:c1",
"end_user_id:c2",
"end_user_restricted_registry",
]
assert _published_keys(mock_publish) == [
"end_user_id:c1",
"end_user_id:c2",
"end_user_restricted_registry",
]

View file

@ -14,7 +14,8 @@ sys.path.insert(
0, os.path.abspath("../../../..")
) # Adds the parent directory to the system path
from unittest.mock import Mock, patch
from contextlib import contextmanager
from unittest.mock import AsyncMock, Mock, patch
import litellm
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
@ -275,6 +276,190 @@ async def test_delete_tag():
app.dependency_overrides.clear()
class _RecordingAuthCache:
"""Captures the keys an endpoint evicts, so tests assert on cache keys not mock plumbing."""
def __init__(self):
self.deleted: list[str] = []
async def async_delete_cache(self, key: str) -> None:
self.deleted.append(key)
@contextmanager
def _tag_cache_doubles():
"""Swaps in the auth cache and the cross-worker publisher a tag mutation is expected to hit."""
recording_cache = _RecordingAuthCache()
mock_publish = AsyncMock()
with (
patch("litellm.proxy.proxy_server.user_api_key_cache", recording_cache),
patch(
"litellm.proxy.common_utils.auth_cache_invalidation_pubsub.publish_auth_cache_invalidation",
mock_publish,
),
):
yield recording_cache, mock_publish
def _published_keys(mock_publish) -> list[str]:
return [call.kwargs["cache_key"] for call in mock_publish.call_args_list]
@pytest.mark.asyncio
async def test_new_tag_invalidates_tag_and_registry_caches():
"""
A tag created on one worker must be visible to every worker's auth path immediately.
Auth serves tags cache-first, and the cached tag-name registry is what decides whether a
request tag is looked up at all, so a create that leaves both entries stale means the new
tag's budget goes unenforced until the TTL expires.
"""
from datetime import datetime
from unittest.mock import AsyncMock, Mock
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_id="test-user-123",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
try:
with (
_tag_cache_doubles() as (recording_cache, mock_publish),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.llm_router"),
patch(
"litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id"
),
patch(
"litellm.proxy.management_endpoints.tag_management_endpoints.get_deployments_by_model"
) as mock_get_deployments,
):
mock_db = Mock()
mock_prisma.db = mock_db
mock_db.litellm_tagtable.find_unique = AsyncMock(return_value=None)
mock_db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[])
mock_get_deployments.return_value = []
created_tag = Mock()
created_tag.tag_name = "cache-tag"
created_tag.description = None
created_tag.models = []
created_tag.model_info = {}
created_tag.spend = 0.0
created_tag.budget_id = None
created_tag.created_at = datetime.now()
created_tag.updated_at = datetime.now()
created_tag.created_by = "test-user-123"
mock_db.litellm_tagtable.create = AsyncMock(return_value=created_tag)
response = client.post(
"/tag/new",
json={"name": "cache-tag"},
headers={"Authorization": "Bearer sk-1234"},
)
assert response.status_code == 200
assert recording_cache.deleted == ["tag:cache-tag", "tag_registry"]
assert _published_keys(mock_publish) == ["tag:cache-tag", "tag_registry"]
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_update_tag_invalidates_only_the_tag_cache():
"""An update can change the tag's budget but never the set of names, so the registry stands."""
from datetime import datetime
from unittest.mock import AsyncMock, Mock
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_id="test-user-123",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
try:
with (
_tag_cache_doubles() as (recording_cache, mock_publish),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch(
"litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id"
),
):
mock_db = Mock()
mock_prisma.db = mock_db
existing_tag = Mock()
existing_tag.tag_name = "cache-tag"
existing_tag.budget_id = None
mock_db.litellm_tagtable.find_unique = AsyncMock(return_value=existing_tag)
mock_db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[])
updated_tag = Mock()
updated_tag.tag_name = "cache-tag"
updated_tag.description = "updated"
updated_tag.models = []
updated_tag.model_info = {}
updated_tag.spend = 0.0
updated_tag.budget_id = None
updated_tag.created_at = datetime.now()
updated_tag.updated_at = datetime.now()
updated_tag.created_by = "test-user-123"
mock_db.litellm_tagtable.update = AsyncMock(return_value=updated_tag)
response = client.post(
"/tag/update",
json={"name": "cache-tag", "description": "updated"},
headers={"Authorization": "Bearer sk-1234"},
)
assert response.status_code == 200
assert recording_cache.deleted == ["tag:cache-tag"]
assert _published_keys(mock_publish) == ["tag:cache-tag"]
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_delete_tag_invalidates_tag_and_registry_caches():
"""Without this a deleted tag keeps its cached budget enforced until the TTL expires."""
from unittest.mock import AsyncMock, Mock
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_id="test-user-123",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
try:
with (
_tag_cache_doubles() as (recording_cache, mock_publish),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
):
mock_db = Mock()
mock_prisma.db = mock_db
existing_tag = Mock()
existing_tag.tag_name = "cache-tag"
mock_db.litellm_tagtable.find_unique = AsyncMock(return_value=existing_tag)
mock_db.litellm_tagtable.delete = AsyncMock(return_value=existing_tag)
response = client.post(
"/tag/delete",
json={"name": "cache-tag"},
headers={"Authorization": "Bearer sk-1234"},
)
assert response.status_code == 200
assert recording_cache.deleted == ["tag:cache-tag", "tag_registry"]
assert _published_keys(mock_publish) == ["tag:cache-tag", "tag_registry"]
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_list_tags_with_dynamic_tags():
"""

View file

@ -150,7 +150,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
return where
def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=None):
def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=None, query_observer=None):
"""
Create a MockPrismaClient for /spend/logs/ui endpoint tests.
@ -177,6 +177,8 @@ def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=No
return [{col: value, "_count": {col: n}} for value, n in tallied.items()]
async def query_raw(self, sql_query, *params):
if query_observer is not None:
query_observer(sql_query, params)
if "mcp_tool_call_count" in sql_query:
return []
filtered = filter_fn(_reconstruct_ui_where_from_sql(sql_query, params))
@ -1321,7 +1323,128 @@ async def test_ui_view_spend_logs_internal_user_scoped_without_user_id(
@pytest.mark.asyncio
async def test_ui_view_spend_logs_team_admin_can_view_team_spend(client, monkeypatch):
async def test_ui_view_spend_logs_explicit_user_filter_cannot_escape_own_scope(client, monkeypatch):
caller_log = {
"id": "log1",
"request_id": "req1",
"api_key": "sk-test-key",
"user": "caller@example.com",
"team_id": None,
"spend": 0.05,
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
}
observed_queries = []
def observe_query(sql_query, params):
if 'FROM "LiteLLM_SpendLogs"' in sql_query:
observed_queries.append((sql_query, params))
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma([caller_log], lambda _where: [], query_observer=observe_query),
)
monkeypatch.setattr(
"litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs",
AsyncMock(return_value=[]),
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, user_id="caller@example.com"
)
try:
start_date, end_date = _default_date_range()
response = client.get(
"/spend/logs/ui",
params={
"user_id": "someone-else@example.com",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
assert response.json()["data"] == []
page_sql, page_params = next((sql, params) for sql, params in observed_queries if "SELECT\n" in sql)
assert page_sql.count('"user" = $') == 2
assert page_params[2:4] == ("someone-else@example.com", "caller@example.com")
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_without_user_filter_includes_permitted_team_scope(client, monkeypatch):
caller_log = {
"id": "log1",
"request_id": "req1",
"api_key": "sk-test-key",
"user": "team-admin@example.com",
"team_id": None,
"spend": 0.05,
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
}
member_log = {**caller_log, "id": "log2", "request_id": "req2", "user": "member@example.com", "team_id": "team-9"}
outside_log = {
**caller_log,
"id": "log3",
"request_id": "req3",
"user": "outside@example.com",
"team_id": "outside-team",
}
def filter_by_scope(where):
if {"multi_team": True} in where.get("OR", []) and "user" not in where:
return [caller_log, member_log]
return [caller_log, member_log, outside_log]
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma([caller_log, member_log, outside_log], filter_by_scope),
)
monkeypatch.setattr(
"litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs",
AsyncMock(return_value=["team-9"]),
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, user_id="team-admin@example.com"
)
try:
start_date, end_date = _default_date_range()
response = client.get(
"/spend/logs/ui",
params={"start_date": start_date, "end_date": end_date},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
assert [row["request_id"] for row in response.json()["data"]] == ["req1", "req2"]
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_permitted_team_scope_falls_back_to_own_user_when_lookup_fails(monkeypatch):
monkeypatch.setattr(
"litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs",
AsyncMock(side_effect=RuntimeError("database unavailable")),
)
permitted_team_ids = await spend_management_endpoints._get_permitted_team_ids_for_spend_logs_or_empty(
prisma_client=MagicMock(),
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="caller@example.com",
),
)
assert permitted_team_ids == ()
@pytest.mark.asyncio
async def test_ui_view_spend_logs_team_admin_can_filter_team_spend_by_user(client, monkeypatch):
"""
Team admins should be able to view team-wide spend when team_id is provided.
"""
@ -1346,11 +1469,23 @@ async def test_ui_view_spend_logs_team_admin_can_view_team_spend(client, monkeyp
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
},
{
"id": "log3",
"request_id": "req3",
"api_key": "sk-test-key",
"user": "member3",
"team_id": "team_admin_team",
"spend": 0.15,
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
},
]
def filter_by_team(where):
if "team_id" in where and where["team_id"] == "team_admin_team":
if where.get("team_id") == "team_admin_team" and where.get("user") == "member1":
return [mock_spend_logs[0]]
if where.get("team_id") == "team_admin_team":
return [mock_spend_logs[0], mock_spend_logs[2]]
return mock_spend_logs
class TeamTable:
@ -1383,6 +1518,7 @@ async def test_ui_view_spend_logs_team_admin_can_view_team_spend(client, monkeyp
"/spend/logs/ui",
params={
"team_id": "team_admin_team",
"user_id": "member1",
"start_date": start_date,
"end_date": end_date,
},
@ -1398,6 +1534,66 @@ async def test_ui_view_spend_logs_team_admin_can_view_team_spend(client, monkeyp
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_user_filter_intersects_permitted_team_scope(client, monkeypatch):
member_log = {
"id": "log1",
"request_id": "req1",
"api_key": "sk-test-key",
"user": "member@example.com",
"team_id": "team-9",
"spend": 0.05,
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
}
other_team_log = {
**member_log,
"id": "log2",
"request_id": "req2",
"team_id": "team-outside-scope",
}
seen_where = []
def filter_by_user_and_scope(where):
seen_where.append(where)
if where.get("user") == "member@example.com" and {"multi_team": True} in where.get("OR", []):
return [member_log]
return [member_log, other_team_log]
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma([member_log, other_team_log], filter_by_user_and_scope),
)
monkeypatch.setattr(
"litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs",
AsyncMock(return_value=["team-9"]),
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, user_id="team-admin"
)
try:
start_date, end_date = _default_date_range()
response = client.get(
"/spend/logs/ui",
params={
"user_id": "member@example.com",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
assert [row["request_id"] for row in response.json()["data"]] == ["req1"]
assert any(
where.get("user") == "member@example.com" and {"multi_team": True} in where.get("OR", [])
for where in seen_where
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_pagination(client, monkeypatch):
mock_spend_logs = [

View file

@ -11,7 +11,7 @@ from pydantic import ValidationError as PydanticValidationError
from starlette.datastructures import Headers
import litellm
from litellm.proxy._types import AddTeamCallback, TeamCallbackMetadata, UserAPIKeyAuth
from litellm.proxy._types import AddTeamCallback, ProxyException, TeamCallbackMetadata, UserAPIKeyAuth
from litellm.proxy.litellm_pre_call_utils import (
KeyAndTeamLoggingSettings,
LiteLLMProxyRequestSetup,
@ -417,6 +417,91 @@ async def test_add_litellm_data_to_request_string_metadata_does_not_crash():
assert updated["metadata"].get("generation_name") == "test"
def _batches_request_mock() -> MagicMock:
request_mock = MagicMock(spec=Request)
request_mock.url = MagicMock()
request_mock.url.__str__.return_value = "http://localhost/v1/batches"
request_mock.url.path = "/v1/batches"
request_mock.method = "POST"
request_mock.query_params = {}
request_mock.headers = {"Content-Type": "application/json"}
request_mock.client = MagicMock()
request_mock.client.host = "127.0.0.1"
return request_mock
@pytest.mark.asyncio
@pytest.mark.parametrize(
"field,value,received_type",
[
("metadata", "abc", "a string"),
("litellm_metadata", "abc", "a string"),
("metadata", 42, "an integer"),
("litellm_metadata", [1, 2], "an array"),
("metadata", True, "a boolean"),
],
)
async def test_add_litellm_data_to_request_rejects_non_object_metadata(field, value, received_type):
"""Regression for https://github.com/BerriAI/litellm/issues/37147: a
non-object metadata was silently dropped with a 200, and a non-object
litellm_metadata crashed later with a 500 ('str' object has no attribute
'update'). Both must be a 400 naming the field, like OpenAI returns."""
data = {"input_file_id": "file-abc", "endpoint": "/v1/chat/completions", field: value}
with pytest.raises(ProxyException) as exc_info:
await add_litellm_data_to_request(
data=data,
request=_batches_request_mock(),
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert exc_info.value.code == "400"
assert exc_info.value.param == field
assert exc_info.value.message == f"Invalid type for '{field}': expected an object, but got {received_type} instead."
assert field not in data
@pytest.mark.asyncio
async def test_add_litellm_data_to_request_removes_every_invalid_metadata_field_before_raising():
"""When both fields are invalid, the raise for the first must not leave the
second invalid value in data, or failure-logging hooks that inspect the body
can crash on it and mask the 400 as a 500."""
data = {"input_file_id": "file-abc", "metadata": "abc", "litellm_metadata": "xyz"}
with pytest.raises(ProxyException) as exc_info:
await add_litellm_data_to_request(
data=data,
request=_batches_request_mock(),
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert exc_info.value.param == "metadata"
assert "metadata" not in data
assert "litellm_metadata" not in data
@pytest.mark.asyncio
async def test_add_litellm_data_to_request_parses_json_object_string_litellm_metadata():
data = {"input_file_id": "file-abc", "litellm_metadata": json.dumps({"cost_centre": "research"})}
updated = await add_litellm_data_to_request(
data=data,
request=_batches_request_mock(),
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert updated["litellm_metadata"]["cost_centre"] == "research"
@pytest.mark.asyncio
async def test_add_litellm_data_to_request_proxy_server_request_body_is_post_strip():
"""Regression: proxy_server_request['body'] used to be snapshotted before

View file

@ -1925,6 +1925,64 @@ class TestRunServerDbSetup:
assert exc_info.value.code == 1
mock_setup_database.assert_not_called()
@patch("subprocess.run")
@patch("atexit.register")
@patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database")
@patch("litellm.proxy.db.check_migration.check_prisma_schema_diff")
@patch("litellm.proxy.db.prisma_client.should_update_prisma_schema")
def test_v2_migration_resolver_opts_in_via_env_var(
self,
mock_should_update_schema,
mock_check_schema_diff,
mock_setup_database,
mock_atexit_register,
mock_subprocess_run,
):
"""USE_V2_MIGRATION_RESOLVER must select the v2 resolver.
The Helm migrations Job runs `python litellm/proxy/prisma_migration.py`,
which calls run_server with a fixed argv, so a deployment has no way to
pass --use_v2_migration_resolver and an env var is the only route in.
"""
from litellm.proxy.proxy_cli import run_server
mock_subprocess_run.return_value = MagicMock(returncode=0)
mock_should_update_schema.return_value = True
mock_setup_database.return_value = True
mock_proxy_module = MagicMock(
app=MagicMock(),
ProxyConfig=MagicMock(),
KeyManagementSettings=MagicMock(),
save_worker_config=MagicMock(),
)
clean_env = {
k: v
for k, v in os.environ.items()
if k not in ("DATABASE_URL", "DIRECT_URL")
}
clean_env["DATABASE_URL"] = "postgresql://test:test@localhost:5432/test"
clean_env["USE_V2_MIGRATION_RESOLVER"] = "true"
with (
patch.dict(os.environ, clean_env, clear=True),
patch.dict(
"sys.modules",
{
"proxy_server": mock_proxy_module,
"litellm.proxy.proxy_server": mock_proxy_module,
},
),
):
run_server.main(
["--local", "--skip_server_startup"], standalone_mode=False
)
mock_setup_database.assert_called_once_with(
use_migrate=True, use_v2_resolver=True
)
# --- Module-level helpers for worker startup hook tests ---

View file

@ -11064,3 +11064,37 @@ async def test_ptu_rollup_job_not_registered_without_opt_in(monkeypatch):
assert scheduler.get_job(PTU_ROLLUP_JOB_ID) is None
assert len(scheduler.get_jobs()) > 0
@pytest.mark.asyncio
async def test_moderations_reraises_proxy_exception_unwrapped():
"""A 400 ProxyException from request validation must surface as-is,
not be re-wrapped into a code-500 ProxyException."""
from litellm.proxy._types import ProxyErrorTypes, ProxyException
exc = ProxyException(
message="Invalid type for 'metadata': expected an object, but got a string instead.",
type=ProxyErrorTypes.bad_request_error,
param="metadata",
code=400,
)
request = MagicMock()
request.body = AsyncMock(return_value=b'{"input": "hi", "metadata": "abc"}')
with (
patch.object(proxy_server_module, "add_litellm_data_to_request", new=AsyncMock(side_effect=exc)),
patch.object(proxy_server_module, "proxy_logging_obj") as mock_logging,
):
mock_logging.post_call_failure_hook = AsyncMock()
with pytest.raises(ProxyException) as exc_info:
await proxy_server_module.moderations(
request=request,
fastapi_response=MagicMock(),
user_api_key_dict=MagicMock(),
)
assert exc_info.value is exc
assert exc_info.value.code == "400"
assert exc_info.value.param == "metadata"
mock_logging.post_call_failure_hook.assert_awaited_once()

View file

@ -1042,9 +1042,10 @@ async def test_route_request_override_enable_tag_filtering_beats_body_value():
[
("acompletion", "messages", "/chat/completions"),
("aembedding", "input", "/embeddings"),
("acreate_batch", "input_file_id", "/batches"),
],
)
@pytest.mark.parametrize("data_extra", [{}, {"messages": None, "input": None}])
@pytest.mark.parametrize("data_extra", [{}, {"messages": None, "input": None, "input_file_id": None}])
def test_raise_if_required_body_param_missing_rejects_missing_param(route_type, param, route, data_extra):
from litellm.proxy.route_llm_request import (
ProxyMissingRequiredParamError,
@ -1054,10 +1055,31 @@ def test_raise_if_required_body_param_missing_rejects_missing_param(route_type,
with pytest.raises(ProxyMissingRequiredParamError) as exc_info:
raise_if_required_body_param_missing(route_type=route_type, data={"model": "gpt-4o", **data_extra})
assert exc_info.value.status_code == 400
assert exc_info.value.code == "400"
assert exc_info.value.param == param
assert exc_info.value.type == "invalid_request_error"
assert exc_info.value.detail == {"error": f"{route}: Missing required parameter: '{param}'."}
assert exc_info.value.message == f"{route}: Missing required parameter: '{param}'."
@pytest.mark.parametrize(
"data, param",
[
({"endpoint": "/v1/chat/completions", "completion_window": "24h"}, "input_file_id"),
({"input_file_id": "file-abc", "completion_window": "24h"}, "endpoint"),
({"input_file_id": "file-abc", "endpoint": "/v1/chat/completions"}, "completion_window"),
({}, "input_file_id"),
],
)
def test_raise_if_required_body_param_missing_names_first_missing_batch_param(data, param):
from litellm.proxy.route_llm_request import (
ProxyMissingRequiredParamError,
raise_if_required_body_param_missing,
)
with pytest.raises(ProxyMissingRequiredParamError) as exc_info:
raise_if_required_body_param_missing(route_type="acreate_batch", data=data)
assert exc_info.value.param == param
@pytest.mark.parametrize(
@ -1069,6 +1091,10 @@ def test_raise_if_required_body_param_missing_rejects_missing_param(route_type,
("aembedding", {"model": "text-embedding-3-small", "input": "hi"}),
("arerank", {"model": "rerank-model"}),
("aimage_generation", {"model": "dall-e-3"}),
(
"acreate_batch",
{"input_file_id": "file-abc", "endpoint": "/v1/chat/completions", "completion_window": "24h"},
),
],
)
def test_raise_if_required_body_param_missing_allows_valid_requests(route_type, data):
@ -1088,7 +1114,7 @@ async def test_route_request_rejects_chat_completion_without_messages():
with pytest.raises(ProxyMissingRequiredParamError) as exc_info:
await route_request({"model": "gpt-4o"}, llm_router, None, "acompletion")
assert exc_info.value.status_code == 400
assert exc_info.value.code == "400"
assert exc_info.value.param == "messages"
llm_router.acompletion.assert_not_called()

View file

@ -452,6 +452,42 @@ async def test_execute_tool_calls_passes_litellm_call_id_and_trace_id_to_functio
assert captured.get("litellm_trace_id") == "tid"
@pytest.mark.asyncio
async def test_execute_tool_calls_threads_logging_obj_into_call_tool(monkeypatch):
"""The Responses-API MCP path must hand the request's litellm_logging_obj to
global_mcp_server_manager.call_tool, otherwise pre_call_tool_check /
_create_during_hook_task get None and no guardrail evaluation is bridged onto
the request logger, so MCP tool calls made through the Responses API report zero
guardrail evaluations in the monitor. Drop the litellm_logging_obj kwarg on the
call_tool invocation and this fails."""
_setup_proxy_logging(monkeypatch)
call_tool_mock = _setup_mcp_call_environment(monkeypatch)
sentinel_logging_obj = MagicMock()
sentinel_logging_obj.async_post_mcp_tool_call_hook = AsyncMock()
sentinel_logging_obj.async_success_handler = AsyncMock()
handler_module = importlib.import_module("litellm.responses.mcp.litellm_proxy_mcp_handler")
monkeypatch.setattr(
handler_module,
"function_setup",
lambda *_args, **_kwargs: (sentinel_logging_obj, None),
)
tool_name = "deepwiki-read_wiki_structure"
tool_calls = [{"id": "call-1", "function": {"name": tool_name, "arguments": "{}"}}]
await LiteLLM_Proxy_MCP_Handler._execute_tool_calls(
tool_server_map={tool_name: "deepwiki"},
tool_calls=tool_calls,
user_api_key_auth=None,
)
assert call_tool_mock.await_count == 1
assert call_tool_mock.await_args is not None
assert call_tool_mock.await_args.kwargs["litellm_logging_obj"] is sentinel_logging_obj
@pytest.mark.asyncio
async def test_get_mcp_tools_from_manager_enables_list_tools_logging(monkeypatch):
"""

View file

@ -30,7 +30,7 @@
"limit": 16713
},
"LIT011": {
"limit": 5591
"limit": 5590
},
"LIT012": {
"limit": 4519

View file

@ -2947,9 +2947,6 @@
"max-lines": {
"count": 1
},
"no-nested-ternary": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}

View file

@ -0,0 +1,40 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const useInfiniteQuery = vi.fn();
vi.mock("@/lib/http/api", () => ({ $api: { useInfiniteQuery: (...args: unknown[]) => useInfiniteQuery(...args) } }));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
import { useInfiniteSpendLogUsers } from "./useSpendLogUsers";
const WINDOW = { start_date: "2026-07-23 00:00:00", end_date: "2026-07-24 00:00:00" };
describe("useInfiniteSpendLogUsers", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token" });
});
it("calls the scoped spend-log user facet with the visible window", () => {
renderHook(() => useInfiniteSpendLogUsers(WINDOW, 25, "alice"));
const expectedQuery = {
"filter[startTime][gte]": "2026-07-23 00:00:00",
"filter[startTime][lte]": "2026-07-24 00:00:00",
page_size: 25,
q: "alice",
};
expect(useInfiniteQuery.mock.calls[0][1]).toBe("/management/v1/spend_logs/users");
expect(useInfiniteQuery.mock.calls[0][2].params.query).toEqual(expectedQuery);
});
it("omits q when the search box is empty", () => {
renderHook(() => useInfiniteSpendLogUsers(WINDOW, 50, ""));
expect(useInfiniteQuery.mock.calls[0][2].params.query).not.toHaveProperty("q");
});
});

View file

@ -0,0 +1,21 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { $api } from "@/lib/http/api";
import { nextPageFromLinks, type SpendLogsWindow } from "./useSpendLogEndUsers";
export const useInfiniteSpendLogUsers = (window: SpendLogsWindow, pageSize: number = 50, q?: string) => {
const { accessToken } = useAuthorized();
const query = {
"filter[startTime][gte]": window.start_date,
"filter[startTime][lte]": window.end_date,
page_size: pageSize,
...(q !== undefined && q !== "" ? { q } : {}),
};
const options = {
pageParamName: "page",
initialPageParam: 1,
getNextPageParam: nextPageFromLinks,
enabled: Boolean(accessToken),
};
return $api.useInfiniteQuery("get", "/management/v1/spend_logs/users", { params: { query } }, options);
};

View file

@ -24,7 +24,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import React, { type ReactNode, useMemo, useState } from "react";
import TeamMultiSelect from "@/components/common_components/team_multi_select";
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
import { UsageExportHeader } from "@/components/EntityUsageExport";
import { UsageExportHeader, type UsageFilterSelectProps } from "@/components/EntityUsageExport";
import type { EntityType } from "@/components/EntityUsageExport/types";
import {
agentDailyActivityCall,
@ -84,6 +84,7 @@ interface EntityUsageProps {
entityList: EntityList[] | null;
premiumUser: boolean;
dateValue: DateRangePickerValue;
filterSelectProps?: UsageFilterSelectProps;
}
const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
@ -107,6 +108,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
entityList,
userRole,
dateValue,
filterSelectProps,
}) => {
const { teams } = useTeams();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
@ -621,6 +623,9 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
{ key: "endpoints", label: "Endpoint Activity", content: <EndpointUsage userSpendData={spendData} /> },
];
const hasEntityFilterOptions = entityList !== null && entityList.length > 0;
const showEntityFilters = entityType !== "team" && (filterSelectProps !== undefined || hasEntityFilterOptions);
return (
<div style={{ width: "100%" }} className="relative">
{isFetchingMore && (
@ -679,7 +684,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
dateValue={dateValue}
entityType={entityType}
spendData={spendData}
showFilters={entityType !== "team" && entityList !== null && entityList.length > 0}
showFilters={showEntityFilters}
filterSlot={
entityType === "team" ? <TeamMultiSelect value={selectedTags} onChange={setSelectedTags} /> : undefined
}
@ -689,6 +694,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
onFiltersChange={setSelectedTags}
filterOptions={getAllTags() || undefined}
filterMode={entityType === "user" ? "single" : "multiple"}
filterSelectProps={filterSelectProps}
teams={teams || []}
/>
<Tabs defaultValue={tabs[0].key}>

View file

@ -47,7 +47,18 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({
}));
vi.mock("./EntityUsage/EntityUsage", () => ({
default: () => <div>Entity Usage</div>,
default: ({
entityType,
filterSelectProps,
}: {
entityType?: string;
filterSelectProps?: { onSearchChange?: (query: string) => void };
}) => (
<div>
Entity Usage
{entityType === "user" && filterSelectProps !== undefined && <span>Searchable user filter</span>}
</div>
),
EntityList: [],
}));
@ -77,6 +88,7 @@ vi.mock("./UsageViewSelect/UsageViewSelect", async () => {
React.createElement("option", { value: "customer" }, "Customer Usage"),
tagOption,
React.createElement("option", { value: "agent" }, "Agent Usage"),
React.createElement("option", { value: "user" }, "User Usage"),
React.createElement("option", { value: "user-agent-activity" }, "User Agent Activity"),
);
};
@ -744,6 +756,18 @@ describe("UsagePage", () => {
expect(mockUseInfiniteUsers).toHaveBeenCalledWith(50, undefined);
});
it("should reuse the searchable user filter in the user usage view", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: "user" } });
expect(await screen.findByText("Searchable user filter")).toBeInTheDocument();
});
it("should deduplicate users across pages", async () => {
mockUseInfiniteUsers.mockReturnValue({
data: {

View file

@ -28,7 +28,7 @@ import { formatNumberWithCommas } from "@/utils/dataUtils";
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
import CloudZeroExportModal from "@/components/cloudzero_export_modal";
import EntityUsageExportModal from "@/components/EntityUsageExport";
import EntityUsageExportModal, { type UsageFilterSelectProps } from "@/components/EntityUsageExport";
import { Team } from "@/components/key_team_helpers/key_list";
import {
gatewayDailyActivityCall,
@ -135,6 +135,14 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
return result;
}, [usersInfiniteData]);
const userFilterSelectProps: UsageFilterSelectProps = {
onSearchChange: setSettledUserSearch,
onLoadMore: () => void fetchNextUsersPage(),
hasNextPage: hasNextUsersPage,
isLoading: isLoadingUsers,
isFetchingNextPage: isFetchingNextUsersPage,
emptyText: "No users found",
};
// For admins: null means global view (all users), a string means filter by that user
// For non-admins: always set to their own user ID
const [selectedUserId, setSelectedUserId] = useState<string | null>(isAdmin ? null : userID || null);
@ -1043,6 +1051,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
userID={userID}
userRole={userRole}
entityList={userOptions.length > 0 ? userOptions : null}
filterSelectProps={userFilterSelectProps}
premiumUser={premiumUser}
dateValue={dateValue}
/>

View file

@ -148,12 +148,25 @@ describe("ViewUserDashboard", () => {
expect(screen.getByRole("textbox", { name: "Default setting" })).toHaveValue("unsaved change");
});
it("renders invite and bulk invite as toolbar actions alongside the other admin controls", async () => {
renderDashboard();
const inviteButton = await screen.findByRole("button", { name: /\+ invite user/i });
const bulkInviteButton = screen.getByRole("button", { name: /\+ bulk invite users/i });
const toolbar = screen.getByTestId("toggle-user-selection").parentElement;
expect(inviteButton.parentElement).toBe(toolbar);
expect(bulkInviteButton.parentElement).toBe(toolbar);
});
it("shows the users table without admin controls for non-proxy admins", async () => {
renderDashboard({ userRole: "Internal User" });
expect(await screen.findByText("test@example.com")).toBeInTheDocument();
expect(screen.queryByRole("tab")).not.toBeInTheDocument();
expect(screen.queryByTestId("toggle-user-selection")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /\+ invite user/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /\+ bulk invite users/i })).not.toBeInTheDocument();
});
it("keeps actions unavailable while the user list is loading", () => {

View file

@ -2,6 +2,7 @@ import { parseAsString, useQueryState } from "nuqs";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import BulkEditUserModal from "./BulkEditUsers";
import BulkCreateUsersButton from "@/components/bulk_create_users_button";
import { CreateUserButton } from "@/components/CreateUserButton";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@ -365,12 +366,11 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
{!userListQuery.isLoading && userID && accessToken && (
<>
{isProxyAdmin && (
<CreateUserButton
userID={userID}
accessToken={accessToken}
teams={teams}
possibleUIRoles={possibleUIRoles}
/>
<CreateUserButton userID={userID} accessToken={accessToken} possibleUIRoles={possibleUIRoles} />
)}
{isProxyAdmin && (
<BulkCreateUsersButton accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
)}
{isProxyAdmin && (

View file

@ -20,10 +20,6 @@ vi.mock("./networking", () => ({
getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost"),
}));
vi.mock("./bulk_create_users_button", () => ({
default: () => <div data-testid="bulk-create-users">Bulk Create Users</div>,
}));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: vi.fn().mockReturnValue({ data: [], isLoading: false }),
}));
@ -42,7 +38,6 @@ const createQueryClient = () =>
const defaultProps = {
userID: "123",
accessToken: "token",
teams: [],
possibleUIRoles: null as Record<string, Record<string, string>> | null,
};
@ -75,6 +70,14 @@ describe("CreateUserButton", () => {
});
});
it("should not render the bulk invite button", async () => {
renderWithProviders(<CreateUserButton {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
});
expect(screen.queryByRole("button", { name: /bulk invite users/i })).not.toBeInTheDocument();
});
it("should open the invite modal when invite user button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<CreateUserButton {...defaultProps} />);

View file

@ -1,22 +1,11 @@
import { InfoCircleOutlined, UserAddOutlined } from "@ant-design/icons";
import { InfoCircleOutlined } from "@ant-design/icons";
import { useQueryClient } from "@tanstack/react-query";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { Button } from "@/components/ui/button";
import { Accordion, AccordionBody, AccordionHeader, SelectItem, TextInput } from "@tremor/react";
import {
Alert,
Button,
Checkbox,
Form,
Input,
Modal,
Select,
Select as Select2,
Space,
Tooltip,
Typography,
} from "antd";
import { Alert, Checkbox, Form, Input, Modal, Select, Select as Select2, Space, Tooltip, Typography } from "antd";
import { UserPlus } from "lucide-react";
import React, { useEffect, useState } from "react";
import BulkCreateUsers from "./bulk_create_users_button";
import TeamDropdown from "./common_components/team_dropdown";
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
import NotificationsManager from "./molecules/notifications_manager";
@ -46,7 +35,6 @@ const generateUUID = (): string => {
interface CreateuserProps {
userID: string;
accessToken: string;
teams: any[] | null;
possibleUIRoles: null | Record<string, Record<string, string>>;
onUserCreated?: (userId: string) => void;
isEmbedded?: boolean;
@ -63,7 +51,6 @@ interface UISettings {
export const CreateUserButton: React.FC<CreateuserProps> = ({
userID,
accessToken,
teams,
possibleUIRoles,
onUserCreated,
isEmbedded = false,
@ -79,8 +66,6 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
const [baseUrl, setBaseUrl] = useState<string | null>(null);
const { data: organizations = [] } = useOrganizations();
// Derive teams from the user's organizations, falling back to the teams prop
useEffect(() => {
const fetchData = async () => {
try {
@ -237,7 +222,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
</Form.Item>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button htmlType="submit">Create User</Button>
<Button type="submit">Create User</Button>
</div>
</Form>
);
@ -245,11 +230,10 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
// Original return for standalone mode
return (
<div className="flex gap-2">
<Button type="primary" className="mb-0" onClick={() => setIsModalVisible(true)}>
<>
<Button type="button" onClick={() => setIsModalVisible(true)}>
+ Invite User
</Button>
<BulkCreateUsers accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
<Modal
title="Invite User"
open={isModalVisible}
@ -377,7 +361,8 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
</Accordion>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button type="primary" icon={<UserAddOutlined />} htmlType="submit">
<Button type="submit">
<UserPlus />
Invite User
</Button>
</div>
@ -391,6 +376,6 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
invitationLinkData={invitationLinkData}
/>
)}
</div>
</>
);
};

View file

@ -1,4 +1,4 @@
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import UsageExportHeader from "./UsageExportHeader";
@ -70,4 +70,29 @@ describe("UsageExportHeader", () => {
);
expect(screen.getByText("Team")).toBeInTheDocument();
});
it("should keep a searchable single filter usable when no options match", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
renderWithProviders(
<UsageExportHeader
{...defaultProps}
entityType="user"
showFilters
filterMode="single"
filterLabel="User"
filterPlaceholder="Select user to filter..."
filterOptions={[]}
filterSelectProps={{ onSearchChange, onLoadMore: vi.fn() }}
onFiltersChange={vi.fn()}
/>,
);
const userFilter = screen.getByRole("combobox");
await user.click(userFilter);
await user.type(userFilter, "alice");
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("alice"));
});
});

View file

@ -1,6 +1,7 @@
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import { Download } from "lucide-react";
import React, { useState } from "react";
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
import { Button } from "@/components/ui/button";
import {
Combobox,
@ -20,6 +21,16 @@ import EntityUsageExportModal from "./EntityUsageExportModal";
import type { EntitySpendData, EntityType } from "./types";
import type { Team } from "@/components/key_team_helpers/key_list";
export interface UsageFilterSelectProps {
onSearchChange: (query: string) => void;
onLoadMore: () => void;
hasNextPage?: boolean;
isLoading?: boolean;
isFetchingNextPage?: boolean;
emptyText?: string;
loadingText?: string;
}
interface UsageExportHeaderProps {
dateValue: DateRangePickerValue;
entityType: EntityType;
@ -32,6 +43,7 @@ interface UsageExportHeaderProps {
onFiltersChange?: (filters: string[]) => void;
filterOptions?: Array<{ label: string; value: string }>;
filterMode?: "multiple" | "single";
filterSelectProps?: UsageFilterSelectProps;
filterSlot?: React.ReactNode;
customTitle?: string;
compactLayout?: boolean;
@ -49,6 +61,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
onFiltersChange,
filterOptions = [],
filterMode = "multiple",
filterSelectProps,
filterSlot,
customTitle,
compactLayout = false,
@ -57,7 +70,8 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
const anchor = useComboboxAnchor();
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
const hasFilters = filterSlot != null || (showFilters && filterOptions.length > 0);
const hasBuiltInFilter = filterOptions.length > 0 || filterSelectProps !== undefined;
const hasFilters = filterSlot != null || (showFilters && hasBuiltInFilter);
const optionValues = filterOptions.map((option) => option.value);
const labelOf = (value: string) => filterOptions.find((option) => option.value === value)?.label ?? value;
@ -74,45 +88,59 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
</ComboboxContent>
);
const builtInFilter =
filterMode === "single" ? (
<Combobox
items={optionValues}
value={selectedFilters[0] ?? null}
onValueChange={(next: string | null) => onFiltersChange?.(next ? [next] : [])}
itemToStringLabel={labelOf}
>
<ComboboxInput
className="w-full"
placeholder={filterPlaceholder}
aria-label={filterPlaceholder}
showClear={selectedFilters.length > 0}
/>
{filterList}
</Combobox>
) : (
<Combobox
multiple
items={optionValues}
value={selectedFilters}
onValueChange={(next: string[]) => onFiltersChange?.(next)}
>
<ComboboxChips render={<div ref={anchor} />} className="w-full">
<ComboboxValue>
{(selected: string[]) =>
selected.map((value) => (
<ComboboxChip key={value} aria-label={labelOf(value)}>
{labelOf(value)}
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxChipsInput placeholder={filterPlaceholder} aria-label={filterPlaceholder} />
{selectedFilters.length > 0 && <ComboboxClear aria-label={`Clear ${filterLabel ?? "filters"}`} />}
</ComboboxChips>
{filterList}
</Combobox>
);
const searchableSingleFilter =
filterSelectProps !== undefined ? (
<PaginatedSearchSelect
options={filterOptions}
value={selectedFilters[0]}
onValueChange={(next) => onFiltersChange?.(next ? [next] : [])}
placeholder={filterPlaceholder}
{...filterSelectProps}
/>
) : undefined;
const singleFilter = searchableSingleFilter ?? (
<Combobox
items={optionValues}
value={selectedFilters[0] ?? null}
onValueChange={(next: string | null) => onFiltersChange?.(next ? [next] : [])}
itemToStringLabel={labelOf}
>
<ComboboxInput
className="w-full"
placeholder={filterPlaceholder}
aria-label={filterPlaceholder}
showClear={selectedFilters.length > 0}
/>
{filterList}
</Combobox>
);
const multiFilter = (
<Combobox
multiple
items={optionValues}
value={selectedFilters}
onValueChange={(next: string[]) => onFiltersChange?.(next)}
>
<ComboboxChips render={<div ref={anchor} />} className="w-full">
<ComboboxValue>
{(selected: string[]) =>
selected.map((value) => (
<ComboboxChip key={value} aria-label={labelOf(value)}>
{labelOf(value)}
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxChipsInput placeholder={filterPlaceholder} aria-label={filterPlaceholder} />
{selectedFilters.length > 0 && <ComboboxClear aria-label={`Clear ${filterLabel ?? "filters"}`} />}
</ComboboxChips>
{filterList}
</Combobox>
);
const builtInFilter = filterMode === "single" ? singleFilter : multiFilter;
return (
<>

View file

@ -1,3 +1,4 @@
export { default } from "./EntityUsageExportModal";
export { default as UsageExportHeader } from "./UsageExportHeader";
export type { UsageFilterSelectProps } from "./UsageExportHeader";
export * from "./types";

View file

@ -5,6 +5,7 @@ import { ColumnDef } from "@tanstack/react-table";
import { Popover, Typography } from "antd";
import { DataTableMultiSortHeader, DataTableSortHeader, type DataTableSortField } from "@/components/shared/DataTable";
import { inheritedBudgetGates } from "@/components/shared/InheritedBudgetHint";
import { Skeleton } from "@/components/ui/skeleton";
import {
DateCell,
@ -304,13 +305,14 @@ export const getKeyTableColumns = ({
size: 180,
enableSorting: true,
cell: ({ row }) => {
const teamId = row.original.team_id;
const team = allTeams.find((t) => t.team_id === teamId);
const team = allTeams.find((t) => t.team_id === row.original.team_id);
const orgId = row.original.organization_id || row.original.org_id || team?.organization_id;
const organization = organizations.find((o) => o.organization_id === orgId);
return (
<SpendBudgetCell
spend={row.original.spend}
maxBudget={row.original.max_budget}
teamMaxBudget={team?.max_budget ?? null}
inheritedGates={row.original.max_budget == null ? inheritedBudgetGates(team, organization) : []}
/>
);
},

View file

@ -1718,7 +1718,6 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
<CreateUserButton
userID={userID}
accessToken={accessToken}
teams={teams}
possibleUIRoles={possibleUIRoles}
onUserCreated={handleUserCreated}
isEmbedded={true}

View file

@ -0,0 +1,60 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { InheritedBudgetHint, inheritedBudgetGates } from "./InheritedBudgetHint";
const team = { team_id: "team-1", team_alias: "Platform", max_budget: 1200, budget_duration: "30d" };
const organization = {
organization_id: "org-1",
organization_alias: "Acme",
litellm_budget_table: { max_budget: 5000, budget_duration: null },
};
describe("inheritedBudgetGates", () => {
it("returns team then org gates when both have budgets", () => {
expect(inheritedBudgetGates(team, organization)).toEqual([
{ scope: "Team", alias: "Platform", maxBudget: 1200, budgetDuration: "30d" },
{ scope: "Organization", alias: "Acme", maxBudget: 5000, budgetDuration: null },
]);
});
it("skips a team or org whose max_budget is null", () => {
expect(inheritedBudgetGates({ ...team, max_budget: null }, organization)).toEqual([
{ scope: "Organization", alias: "Acme", maxBudget: 5000, budgetDuration: null },
]);
expect(inheritedBudgetGates(team, { ...organization, litellm_budget_table: { max_budget: null } })).toEqual([
{ scope: "Team", alias: "Platform", maxBudget: 1200, budgetDuration: "30d" },
]);
});
it("returns nothing when team and org are missing or budgetless", () => {
expect(inheritedBudgetGates(null, undefined)).toEqual([]);
expect(
inheritedBudgetGates({ ...team, max_budget: null }, { ...organization, litellm_budget_table: null }),
).toEqual([]);
});
it("falls back to ids when aliases are empty", () => {
expect(
inheritedBudgetGates({ ...team, team_alias: "" }, { ...organization, organization_alias: "" }).map(
(g) => g.alias,
),
).toEqual(["team-1", "org-1"]);
});
});
describe("InheritedBudgetHint", () => {
it("renders nothing without gates", () => {
const { container } = render(<InheritedBudgetHint gates={[]} />);
expect(container).toBeEmptyDOMElement();
});
it("shows each gate with its budget and duration on hover", async () => {
render(<InheritedBudgetHint gates={inheritedBudgetGates(team, organization)} />);
await userEvent.setup().hover(screen.getByLabelText("question-circle"));
expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("Team Platform: $1,200.00 / 30d");
expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("Organization Acme: $5,000.00");
expect(screen.getByTestId("inherited-budget-hint")).not.toHaveTextContent("Organization Acme: $5,000.00 /");
});
});

View file

@ -0,0 +1,67 @@
"use client";
import { Tooltip } from "@/components/atoms/Tooltip";
import type { Team } from "@/components/key_team_helpers/key_list";
import type { Organization } from "@/components/networking";
import { formatNumberWithCommas } from "@/utils/dataUtils";
export interface InheritedBudgetGate {
scope: "Team" | "Organization";
alias: string;
maxBudget: number;
budgetDuration: string | null;
}
type TeamBudgetSource = Pick<Team, "team_id" | "team_alias" | "max_budget" | "budget_duration">;
type OrganizationBudgetSource = Pick<Organization, "organization_id" | "organization_alias" | "litellm_budget_table">;
const teamGate = (team: TeamBudgetSource | null | undefined): InheritedBudgetGate | null =>
team && team.max_budget != null
? {
scope: "Team",
alias: team.team_alias || team.team_id,
maxBudget: team.max_budget,
budgetDuration: team.budget_duration ?? null,
}
: null;
const organizationGate = (organization: OrganizationBudgetSource | null | undefined): InheritedBudgetGate | null => {
const budgetTable: { max_budget?: number | null; budget_duration?: string | null } | null | undefined =
organization?.litellm_budget_table;
return organization && budgetTable?.max_budget != null
? {
scope: "Organization",
alias: organization.organization_alias || organization.organization_id,
maxBudget: budgetTable.max_budget,
budgetDuration: budgetTable.budget_duration ?? null,
}
: null;
};
export const inheritedBudgetGates = (
team: TeamBudgetSource | null | undefined,
organization: OrganizationBudgetSource | null | undefined,
): readonly InheritedBudgetGate[] => [teamGate(team), organizationGate(organization)].filter((gate) => gate !== null);
const formatGate = (gate: InheritedBudgetGate): string =>
`${gate.scope} ${gate.alias}: $${formatNumberWithCommas(gate.maxBudget, 2)}${gate.budgetDuration ? ` / ${gate.budgetDuration}` : ""}`;
interface InheritedBudgetHintProps {
gates: readonly InheritedBudgetGate[];
}
export function InheritedBudgetHint({ gates }: InheritedBudgetHintProps) {
if (gates.length === 0) return null;
return (
<Tooltip
content={
<div data-testid="inherited-budget-hint" className="flex flex-col gap-1">
<span>This key has no budget of its own, but its spend still counts toward:</span>
{gates.map((gate) => (
<span key={gate.scope}>{formatGate(gate)}</span>
))}
</div>
}
/>
);
}

View file

@ -53,9 +53,24 @@ describe("SpendBudgetCell", () => {
expect(indicator(container)?.className).toContain("bg-destructive");
});
it("falls back to the team budget and labels it", () => {
render(<SpendBudgetCell spend={10} maxBudget={null} teamMaxBudget={200} />);
expect(screen.getByText("of $200 (Team)")).toBeInTheDocument();
expect(screen.getByRole("meter")).toHaveAttribute("aria-valuemax", "200");
it("never meters key spend against an inherited team/org budget", () => {
const gates = [{ scope: "Team" as const, alias: "Team A", maxBudget: 200, budgetDuration: "30d" }];
render(<SpendBudgetCell spend={10} maxBudget={null} inheritedGates={gates} />);
expect(screen.getByText("· Unlimited")).toBeInTheDocument();
expect(screen.queryByText(/\(Team\)/)).not.toBeInTheDocument();
expect(screen.queryByRole("meter")).not.toBeInTheDocument();
expect(screen.getByLabelText("question-circle")).toBeInTheDocument();
});
it("shows no inherited-budget hint when there is nothing to inherit", () => {
render(<SpendBudgetCell spend={10} maxBudget={null} inheritedGates={[]} />);
expect(screen.queryByLabelText("question-circle")).not.toBeInTheDocument();
});
it("shows no inherited-budget hint when the key has its own budget", () => {
const gates = [{ scope: "Team" as const, alias: "Team A", maxBudget: 200, budgetDuration: null }];
render(<SpendBudgetCell spend={10} maxBudget={50} inheritedGates={gates} />);
expect(screen.getByText("of $50")).toBeInTheDocument();
expect(screen.queryByLabelText("question-circle")).not.toBeInTheDocument();
});
});

View file

@ -1,12 +1,13 @@
"use client";
import { InheritedBudgetHint, type InheritedBudgetGate } from "@/components/shared/InheritedBudgetHint";
import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter";
import { formatNumberWithCommas, getSpendString } from "@/utils/dataUtils";
interface SpendBudgetCellProps {
spend: number | null | undefined;
maxBudget: number | null | undefined;
teamMaxBudget?: number | null;
inheritedGates?: readonly InheritedBudgetGate[];
spendDecimals?: number;
budgetDecimals?: number;
}
@ -20,27 +21,24 @@ const meterTone = (pct: number): "default" | "warning" | "over" => {
export function SpendBudgetCell({
spend,
maxBudget,
teamMaxBudget,
inheritedGates = [],
spendDecimals = 4,
budgetDecimals = 0,
}: SpendBudgetCellProps) {
const spendValue = typeof spend === "number" && !Number.isNaN(spend) ? spend : 0;
const budget = maxBudget ?? teamMaxBudget ?? null;
const isTeamBudget = maxBudget == null && teamMaxBudget != null;
const budget = maxBudget ?? null;
const hasBudget = typeof budget === "number" && budget > 0;
const pct = hasBudget ? (spendValue / budget) * 100 : 0;
const spendText = spendValue > 0 ? getSpendString(spendValue, spendDecimals) : "$0.00";
const budgetLabel =
budget === null
? "· Unlimited"
: `of $${formatNumberWithCommas(budget, budgetDecimals)}${isTeamBudget ? " (Team)" : ""}`;
const budgetLabel = budget === null ? "· Unlimited" : `of $${formatNumberWithCommas(budget, budgetDecimals)}`;
return (
<div className="flex min-w-[130px] flex-col gap-1">
<div className="whitespace-nowrap text-xs">
<span className="font-medium tabular-nums text-foreground">{spendText}</span>{" "}
<span className="text-muted-foreground">{budgetLabel}</span>
{budget === null && <InheritedBudgetHint gates={inheritedGates} />}
</div>
{hasBudget && (
<Meter

View file

@ -1,10 +1,13 @@
import { renderWithProviders } from "../../../tests/test-utils";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { KeyResponse, Team } from "../key_team_helpers/key_list";
import KeyInfoView from "./key_info_view";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import type { Organization } from "../networking";
// IMPORTANT: do not mock `@/utils/dataUtils` here. We want to exercise the
// real `formatNumberWithCommas` so this test catches the LIT-2845 regression
@ -13,15 +16,12 @@ import useTeams from "@/app/(dashboard)/hooks/useTeams";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: () => ({ data: [] }),
}));
vi.mock("./key_edit_view", () => ({
KeyEditView: () => <div data-testid="key-edit-view-stub" />,
}));
vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ default: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ useOrganizations: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
useProjects: vi.fn().mockReturnValue({ data: [], isLoading: false }),
@ -130,10 +130,34 @@ const makeTeam = (overrides: Partial<Team>): Team => ({
...overrides,
});
const makeOrganization = (overrides: Partial<Organization>): Organization =>
({
organization_id: "org-1",
organization_alias: "Acme Org",
budget_id: "budget-1",
metadata: {},
models: [],
spend: 0,
model_spend: {},
created_at: "2026-01-01T00:00:00Z",
created_by: "admin",
updated_at: "2026-01-01T00:00:00Z",
updated_by: "admin",
litellm_budget_table: { max_budget: null, budget_duration: null },
teams: null,
users: null,
members: null,
...overrides,
}) as Organization;
const mockOrganizations = (organizations: Organization[]) =>
vi.mocked(useOrganizations).mockReturnValue({ data: organizations } as ReturnType<typeof useOrganizations>);
describe("KeyInfoView overview budget display (LIT-2845)", () => {
beforeEach(() => {
vi.mocked(useTeams).mockReturnValue({ teams: [], setTeams: vi.fn() });
vi.mocked(useAuthorized).mockReturnValue(baseAuthorized);
mockOrganizations([]);
});
it("renders a sub-dollar max_budget ($0.10) with 2-decimal precision in the overview Spend card", async () => {
@ -188,7 +212,7 @@ describe("KeyInfoView overview budget display (LIT-2845)", () => {
});
});
it("renders team budget with alias and duration when key has no own budget but team has one", async () => {
it("never pairs key spend with the team budget: shows Unlimited plus an inherited-budget hint", async () => {
vi.mocked(useTeams).mockReturnValue({
teams: [makeTeam({ team_id: "team-123", team_alias: "Test Budget", max_budget: 1200, budget_duration: "30d" })],
setTeams: vi.fn(),
@ -203,15 +227,20 @@ describe("KeyInfoView overview budget display (LIT-2845)", () => {
/>,
);
await waitFor(() => {
expect(screen.getByText(/of \$1,200\.00 \(Team: Test Budget \/ 30d\)/)).toBeInTheDocument();
expect(screen.getByText(/of Unlimited/)).toBeInTheDocument();
});
expect(screen.queryByText(/of \$1,200\.00/)).not.toBeInTheDocument();
expect(screen.queryByText(/\(Team: Test Budget/)).not.toBeInTheDocument();
await userEvent.setup().hover(screen.getByLabelText("question-circle"));
expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("Team Test Budget: $1,200.00 / 30d");
});
it("renders team budget without duration when team has no budget_duration", async () => {
it("lists the organization budget in the hint when the team's org has one", async () => {
vi.mocked(useTeams).mockReturnValue({
teams: [makeTeam({ team_id: "team-456", team_alias: "No Duration Team", max_budget: 500 })],
teams: [makeTeam({ team_id: "team-456", team_alias: "Org Team", organization_id: "org-1" })],
setTeams: vi.fn(),
});
mockOrganizations([makeOrganization({ litellm_budget_table: { max_budget: 5000, budget_duration: null } })]);
renderWithProviders(
<KeyInfoView
keyData={{ ...MOCK_KEY_DATA, max_budget: null, team_id: "team-456" } as unknown as KeyResponse}
@ -222,11 +251,14 @@ describe("KeyInfoView overview budget display (LIT-2845)", () => {
/>,
);
await waitFor(() => {
expect(screen.getByText(/of \$500\.00 \(Team: No Duration Team\)/)).toBeInTheDocument();
expect(screen.getByText(/of Unlimited/)).toBeInTheDocument();
});
await userEvent.setup().hover(screen.getByLabelText("question-circle"));
expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("Organization Acme Org: $5,000.00");
expect(screen.getByTestId("inherited-budget-hint")).not.toHaveTextContent("Team Org Team");
});
it("renders 'Unlimited' when key has no budget and team also has no budget", async () => {
it("renders 'Unlimited' with no hint when neither key, team, nor org has a budget", async () => {
vi.mocked(useTeams).mockReturnValue({
teams: [makeTeam({ team_id: "team-789", team_alias: "Free Team" })],
setTeams: vi.fn(),
@ -243,6 +275,27 @@ describe("KeyInfoView overview budget display (LIT-2845)", () => {
await waitFor(() => {
expect(screen.getByText(/of Unlimited/)).toBeInTheDocument();
});
expect(screen.queryByLabelText("question-circle")).not.toBeInTheDocument();
});
it("shows no hint when the key has its own budget even if the team has one", async () => {
vi.mocked(useTeams).mockReturnValue({
teams: [makeTeam({ team_id: "team-123", team_alias: "Test Budget", max_budget: 1200 })],
setTeams: vi.fn(),
});
renderWithProviders(
<KeyInfoView
keyData={{ ...MOCK_KEY_DATA, max_budget: 25, team_id: "team-123" } as unknown as KeyResponse}
onClose={() => {}}
keyId={"test-key-id"}
onKeyDataUpdate={() => {}}
teams={[]}
/>,
);
await waitFor(() => {
expect(screen.getByText(/of \$25\.00/)).toBeInTheDocument();
});
expect(screen.queryByLabelText("question-circle")).not.toBeInTheDocument();
});
});
@ -250,6 +303,7 @@ describe("KeyInfoView budget reset visibility", () => {
beforeEach(() => {
vi.mocked(useTeams).mockReturnValue({ teams: [], setTeams: vi.fn() });
vi.mocked(useAuthorized).mockReturnValue(baseAuthorized);
mockOrganizations([]);
});
const KEY_WITH_RESET = {

View file

@ -36,6 +36,7 @@ import { extractMcpEntitlement } from "../mcp_server_management/mcpEntitlement";
import ObjectPermissionsView from "../object_permissions_view";
import { RegenerateKeyModal } from "../organisms/RegenerateKeyModal";
import { parseErrorMessage } from "../shared/errorUtils";
import { InheritedBudgetHint, inheritedBudgetGates } from "../shared/InheritedBudgetHint";
import { KeyEditView } from "./key_edit_view";
interface KeyInfoViewProps {
@ -460,12 +461,9 @@ export default function KeyInfoView({
const orgId = currentKeyData.organization_id || currentKeyData.org_id || parentTeam?.organization_id || "";
const parentOrg = orgId ? organizations?.find((org) => org.organization_id === orgId) : null;
const budgetDisplay =
currentKeyData.max_budget !== null
? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}`
: parentTeam?.max_budget != null
? `$${formatNumberWithCommas(parentTeam.max_budget, 2)} (Team: ${parentTeam.team_alias || parentTeam.team_id}${parentTeam.budget_duration ? ` / ${parentTeam.budget_duration}` : ""})`
: "Unlimited";
const hasOwnBudget = currentKeyData.max_budget !== null;
const budgetDisplay = hasOwnBudget ? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}` : "Unlimited";
const inheritedGates = hasOwnBudget ? [] : inheritedBudgetGates(parentTeam, parentOrg);
return (
<div className="w-full h-full overflow-y-auto p-4">
@ -616,7 +614,10 @@ export default function KeyInfoView({
<p className="text-sm">Spend</p>
<div className="mt-2">
<h3 className="text-lg font-medium">${formatNumberWithCommas(currentKeyData.spend, 4)}</h3>
<p className="text-sm">of {budgetDisplay}</p>
<p className="text-sm">
of {budgetDisplay}
<InheritedBudgetHint gates={inheritedGates} />
</p>
{currentKeyData.budget_reset_at && (
<p className="text-sm">Resets {formatTimestamp(currentKeyData.budget_reset_at)}</p>
)}

View file

@ -14,11 +14,16 @@ vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useInfiniteModelInfo: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers", () => ({
useInfiniteSpendLogUsers: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({
useInfiniteSpendLogEndUsers: vi.fn(),
}));
import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers";
import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers";
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
@ -50,6 +55,9 @@ describe("RequestLogsFilters", () => {
vi.mocked(useInfiniteModelInfo).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteModelInfo>,
);
vi.mocked(useInfiniteSpendLogUsers).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteSpendLogUsers>,
);
vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteSpendLogEndUsers>,
);
@ -62,6 +70,7 @@ describe("RequestLogsFilters", () => {
"Team ID",
"Status",
"Key Alias",
"User ID",
"End User",
"Error Code",
"Error Message",
@ -74,6 +83,78 @@ describe("RequestLogsFilters", () => {
}
});
it("places User ID between Key Alias and End User", async () => {
renderFilters();
const labels = ["Key Alias", "User ID", "End User"].map((label) => screen.getByText(label));
expect(labels[0].compareDocumentPosition(labels[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(labels[1].compareDocumentPosition(labels[2]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("selects an internal user value from the caller's visible spend logs", async () => {
vi.mocked(useInfiniteSpendLogUsers).mockReturnValue({
...emptyInfiniteQuery,
data: {
pages: [
{
data: ["alice@example.com"],
meta: { page: 1, page_size: 50, has_more: false },
links: { self: "", next: null },
},
],
pageParams: [1],
},
} as unknown as ReturnType<typeof useInfiniteSpendLogUsers>);
const user = userEvent.setup();
const { set } = renderFilters();
await user.click(await screen.findByPlaceholderText("Search an internal user"));
await user.click(await screen.findByText("alice@example.com"));
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.USER_ID, "alice@example.com");
});
it("pushes the User ID picker query to the paginated user lookup", async () => {
const user = userEvent.setup();
renderFilters();
const input = await screen.findByPlaceholderText("Search an internal user");
await user.click(input);
await user.type(input, "alice@example.com");
await waitFor(() => expect(useInfiniteSpendLogUsers).toHaveBeenCalledWith(LOGS_WINDOW, 50, "alice@example.com"));
});
it("loads the next page when the User ID list is scrolled near the end", async () => {
const fetchNextPage = vi.fn();
vi.mocked(useInfiniteSpendLogUsers).mockReturnValue({
...emptyInfiniteQuery,
fetchNextPage,
hasNextPage: true,
data: {
pages: [
{
data: ["alice@example.com"],
meta: { page: 1, page_size: 50, has_more: true },
links: { self: "", next: "?page=2" },
},
],
pageParams: [1],
},
} as unknown as ReturnType<typeof useInfiniteSpendLogUsers>);
const user = userEvent.setup();
renderFilters();
await user.click(await screen.findByPlaceholderText("Search an internal user"));
const list = await screen.findByTestId("paginated-search-select-list");
Object.defineProperty(list, "scrollTop", { value: 90, configurable: true });
Object.defineProperty(list, "clientHeight", { value: 10, configurable: true });
Object.defineProperty(list, "scrollHeight", { value: 100, configurable: true });
list.dispatchEvent(new Event("scroll", { bubbles: true }));
await waitFor(() => expect(fetchNextPage).toHaveBeenCalled());
});
it("scopes the Key Alias lookup to the selected team", async () => {
renderFilters({ [LOG_FILTER_IDS.TEAM_ID]: "team-42" });

View file

@ -3,6 +3,7 @@
import { useMemo, useState } from "react";
import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers";
import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers";
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
import { DataTableFilterField } from "@/components/shared/DataTable";
@ -144,6 +145,51 @@ function ModelFilterField({ value, onChange }: { value: string; onChange: (value
);
}
function UserIdFilterField({
value,
onChange,
logsWindow,
}: {
value: string;
onChange: (value: string | undefined) => void;
logsWindow: LogsWindow;
}) {
const [search, setSearch] = useState("");
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteSpendLogUsers(
logsWindow,
PAGE_SIZE,
emptyToUndefined(search),
);
const options = useMemo<SearchSelectOption[]>(() => {
const seen = new Set<string>();
return (data?.pages ?? []).flatMap((page) =>
page.data.flatMap((userId) => {
if (!userId || seen.has(userId)) return [];
seen.add(userId);
return [{ label: userId, value: userId }];
}),
);
}, [data]);
return (
<DataTableFilterField label="User ID">
<PaginatedSearchSelect
options={options}
value={value}
onValueChange={(next) => onChange(emptyToUndefined(next))}
onSearchChange={setSearch}
onLoadMore={() => void fetchNextPage()}
hasNextPage={hasNextPage}
isLoading={isLoading}
isFetchingNextPage={isFetchingNextPage}
placeholder="Search an internal user"
emptyText="No users found"
/>
</DataTableFilterField>
);
}
function EndUserFilterField({
value,
onChange,
@ -279,6 +325,12 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF
teamId={valueOf(LOG_FILTER_IDS.TEAM_ID)}
/>
<UserIdFilterField
value={valueOf(LOG_FILTER_IDS.USER_ID)}
onChange={setter(LOG_FILTER_IDS.USER_ID)}
logsWindow={logsWindow}
/>
<EndUserFilterField
value={valueOf(LOG_FILTER_IDS.END_USER)}
onChange={setter(LOG_FILTER_IDS.END_USER)}

View file

@ -6,7 +6,6 @@ import moment from "moment";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AutoRouterModelGroupsProvider } from "@/components/shared/table_cells";
import { internalUserRoles } from "../../utils/roles";
import type { KeyResponse } from "../key_team_helpers/key_list";
import { keyInfoV1Call, uiSpendLogsCall } from "../networking";
import KeyInfoView from "../templates/key_info_view";
@ -73,15 +72,12 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
sessionStorage.setItem("isLiveTail", JSON.stringify(isLiveTail));
}, [isLiveTail]);
const filterByCurrentUser = internalUserRoles.includes(userRole);
const { logsQuery, filteredLogs, allTeams } = useLogFilterLogic({
accessToken,
token,
userRole,
userID,
columnFilters,
filterByCurrentUser,
activeTab: isActive ? "request logs" : "inactive",
isLiveTail,
startTime,

View file

@ -45,7 +45,6 @@ const defaultProps = {
userRole: "Admin" as string | null,
userID: "user-1" as string | null,
columnFilters: [] as ColumnFiltersState,
filterByCurrentUser: false,
activeTab: "request logs",
isLiveTail: false,
startTime: "2025-01-01T00:00:00",
@ -181,17 +180,16 @@ describe("useLogFilterLogic", () => {
});
});
describe("filterByCurrentUser", () => {
it("scopes to the current user when no explicit user filter is set", async () => {
renderFilterHook({ filterByCurrentUser: true });
describe("user scope", () => {
it("leaves an empty user filter for the backend to authorize", async () => {
renderFilterHook();
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCallParams()?.params).toMatchObject({ user_id: "user-1" });
expect(lastCallParams()?.params?.user_id).toBeUndefined();
});
it("lets an explicit user filter win over the current-user scope", async () => {
it("sends an explicit user filter for the backend to intersect with authorization", async () => {
renderFilterHook({
filterByCurrentUser: true,
columnFilters: [{ id: LOG_FILTER_IDS.USER_ID, value: "someone-else" }],
});

View file

@ -36,6 +36,7 @@ export const LOG_FILTER_LABELS: Record<string, string> = {
[LOG_FILTER_IDS.TEAM_ID]: "Team ID",
[LOG_FILTER_IDS.STATUS]: "Status",
[LOG_FILTER_IDS.KEY_ALIAS]: "Key Alias",
[LOG_FILTER_IDS.USER_ID]: "User ID",
[LOG_FILTER_IDS.END_USER]: "End User",
[LOG_FILTER_IDS.ERROR_CODE]: "Error Code",
[LOG_FILTER_IDS.ERROR_MESSAGE]: "Error Message",
@ -98,7 +99,6 @@ export function useLogFilterLogic({
userRole,
userID,
columnFilters,
filterByCurrentUser,
activeTab,
isLiveTail,
startTime,
@ -112,7 +112,6 @@ export function useLogFilterLogic({
userRole: string | null;
userID: string | null;
columnFilters: ColumnFiltersState;
filterByCurrentUser: boolean | null;
activeTab: string;
isLiveTail: boolean;
startTime: string;
@ -136,7 +135,6 @@ export function useLogFilterLogic({
endTime,
isCustomDate,
columnFilters,
filterByCurrentUser ? userID : null,
sortBy,
sortOrder,
],
@ -166,7 +164,7 @@ export function useLogFilterLogic({
team_id: getFilterValue(columnFilters, LOG_FILTER_IDS.TEAM_ID),
request_id: getFilterValue(columnFilters, LOG_FILTER_IDS.REQUEST_ID),
session_id: getFilterValue(columnFilters, LOG_FILTER_IDS.SESSION_ID),
user_id: userIdFilter ?? (filterByCurrentUser ? userID ?? undefined : undefined),
user_id: userIdFilter,
end_user: getFilterValue(columnFilters, LOG_FILTER_IDS.END_USER),
status_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.STATUS),
model_id: getFilterValue(columnFilters, LOG_FILTER_IDS.MODEL_ID),

View file

@ -7622,6 +7622,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/management/v1/spend_logs/users": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Spend Log Users
* @description The distinct internal users appearing in spend logs the caller can read.
*/
get: operations["list_spend_log_users_management_v1_spend_logs_users_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mcp": {
parameters: {
query?: never;
@ -45939,6 +45959,46 @@ export interface operations {
};
};
};
list_spend_log_users_management_v1_spend_logs_users_get: {
parameters: {
query: {
/** @description Window start (UTC when no offset is given) */
"filter[startTime][gte]": string;
/** @description Window end (UTC when no offset is given) */
"filter[startTime][lte]": string;
/** @description Case-insensitive partial match on the internal user id */
q?: string | null;
/** @description Page number */
page?: number;
/** @description Page size */
page_size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["FacetListResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
aggregate_mcp_route_mcp_get: {
parameters: {
query?: never;

8
uv.lock generated
View file

@ -10,7 +10,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-08-12T18:09:57.187615Z"
exclude-newer = "2026-08-14T18:59:56.524034Z"
exclude-newer-span = "P3D"
[manifest]
@ -9075,11 +9075,11 @@ wheels = [
[[package]]
name = "sqlparse"
version = "0.5.5"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
]
[[package]]