mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge pull request #39068 from BerriAI/litellm_spend_log_request_id_call_id
fix(spend_logs): store litellm_call_id and match it in request_id lookups
This commit is contained in:
commit
a978ad2227
19 changed files with 1205 additions and 167 deletions
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN IF NOT EXISTS "litellm_call_id" TEXT;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
-- CreateIndex (CONCURRENTLY)
|
||||
--
|
||||
-- Disclaimer:
|
||||
-- - CREATE INDEX CONCURRENTLY cannot run inside a transaction. This migration must stay a
|
||||
-- single statement so Prisma Migrate on PostgreSQL can apply it outside a transaction.
|
||||
-- - Builds are slower and use more I/O than a blocking CREATE INDEX; if the build is
|
||||
-- interrupted, Postgres may leave an INVALID index that must be dropped and recreated.
|
||||
-- - Do not edit this file after it has been applied to any database: Prisma checksums
|
||||
-- migrations; add a new migration instead.
|
||||
-- - Requires PostgreSQL that supports CONCURRENTLY with IF NOT EXISTS (use a new migration
|
||||
-- without IF NOT EXISTS if you must support older versions).
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_SpendLogs_litellm_call_id_idx" ON "LiteLLM_SpendLogs"("litellm_call_id");
|
||||
|
|
@ -659,12 +659,14 @@ model LiteLLM_SpendLogs {
|
|||
mcp_namespaced_tool_name String?
|
||||
agent_id String?
|
||||
proxy_server_request Json? @default("{}")
|
||||
litellm_call_id String?
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
updated_at DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
@@index([startTime])
|
||||
@@index([startTime, request_id])
|
||||
@@index([end_user])
|
||||
@@index([session_id])
|
||||
@@index([litellm_call_id])
|
||||
}
|
||||
|
||||
model LiteLLM_BudgetWindowSpend {
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ DEFAULT_MCP_SEMANTIC_FILTER_SIMILARITY_THRESHOLD: Final = float(
|
|||
os.getenv("DEFAULT_MCP_SEMANTIC_FILTER_SIMILARITY_THRESHOLD", 0.3)
|
||||
)
|
||||
MAX_MCP_SEMANTIC_FILTER_TOOLS_HEADER_LENGTH: Final = int(os.getenv("MAX_MCP_SEMANTIC_FILTER_TOOLS_HEADER_LENGTH", 150))
|
||||
MAX_LITELLM_CALL_ID_LENGTH: Final = 256
|
||||
MAX_GUARDRAIL_SCAN_METADATA_HEADER_LENGTH: Final = 2048
|
||||
|
||||
DEFAULT_AUTO_ROUTER_MAX_INPUT_CHARS: Final = 2000
|
||||
|
|
|
|||
|
|
@ -3882,6 +3882,7 @@ class SpendLogsPayload(TypedDict):
|
|||
session_id: str | None
|
||||
request_duration_ms: int | None
|
||||
status: Literal["success", "failure"]
|
||||
litellm_call_id: ReadOnly[str | None]
|
||||
|
||||
|
||||
class SpanAttributes(str, enum.Enum):
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from litellm.constants import (
|
|||
DEFAULT_MAX_RECURSE_DEPTH,
|
||||
LITELLM_DETAILED_TIMING,
|
||||
LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED,
|
||||
MAX_LITELLM_CALL_ID_LENGTH,
|
||||
MAX_PAYLOAD_SIZE_FOR_DEBUG_LOG,
|
||||
NON_INFERENCE_CALL_TYPES,
|
||||
RETURN_RAW_MODEL_NAME_METADATA_KEY,
|
||||
|
|
@ -217,6 +218,12 @@ def _withheld_provider_output(response: object) -> bool:
|
|||
return getattr(response, "has_buffered_provider_output", False) is True
|
||||
|
||||
|
||||
def resolve_litellm_call_id(client_call_id: str | None) -> str:
|
||||
if client_call_id is not None and 0 < len(client_call_id) <= MAX_LITELLM_CALL_ID_LENGTH:
|
||||
return client_call_id
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _should_return_raw_model_name(request_data: dict[str, object]) -> bool:
|
||||
return any(
|
||||
isinstance(metadata, dict) and metadata.get(RETURN_RAW_MODEL_NAME_METADATA_KEY) is True
|
||||
|
|
@ -1938,7 +1945,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
if alias_target is not None:
|
||||
self.data["model"] = alias_target
|
||||
|
||||
self.data["litellm_call_id"] = request.headers.get("x-litellm-call-id", str(uuid.uuid4()))
|
||||
self.data["litellm_call_id"] = resolve_litellm_call_id(request.headers.get("x-litellm-call-id"))
|
||||
DDSpanTagger.tag_call_id(self.data.get("litellm_call_id"))
|
||||
DDSpanTagger.tag_request(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
|
|
|
|||
|
|
@ -659,12 +659,14 @@ model LiteLLM_SpendLogs {
|
|||
mcp_namespaced_tool_name String?
|
||||
agent_id String?
|
||||
proxy_server_request Json? @default("{}")
|
||||
litellm_call_id String?
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
updated_at DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
@@index([startTime])
|
||||
@@index([startTime, request_id])
|
||||
@@index([end_user])
|
||||
@@index([session_id])
|
||||
@@index([litellm_call_id])
|
||||
}
|
||||
|
||||
model LiteLLM_BudgetWindowSpend {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import collections
|
|||
import json
|
||||
import os
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from itertools import groupby
|
||||
from types import MappingProxyType
|
||||
|
|
@ -17,6 +18,7 @@ from typing import (
|
|||
TypeAlias,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
cast, # noqa: TID251 # custom-logger and cold-storage payloads are untyped JSON
|
||||
)
|
||||
|
||||
import fastapi
|
||||
|
|
@ -75,6 +77,7 @@ _SPEND_LOG_LIST_COLUMNS: Final = """
|
|||
cache_hit, cache_key, request_tags, team_id,
|
||||
organization_id, end_user, requester_ip_address,
|
||||
session_id, status, mcp_namespaced_tool_name, agent_id,
|
||||
litellm_call_id,
|
||||
COALESCE(request_duration_ms,
|
||||
(EXTRACT(EPOCH FROM ("endTime" - "startTime")) * 1000)::INTEGER) AS request_duration_ms
|
||||
"""
|
||||
|
|
@ -91,9 +94,9 @@ class _SupportsModelDump(Protocol):
|
|||
def model_dump(self) -> Mapping[str, object]: ...
|
||||
|
||||
|
||||
class _SpendLogOwnershipRow(Protocol):
|
||||
user: str | None
|
||||
team_id: str | None
|
||||
class _SpendLogOwnerRow(TypedDict):
|
||||
user: ReadOnly[str | None]
|
||||
team_id: ReadOnly[str | None]
|
||||
|
||||
|
||||
class _ActivityRow(TypedDict):
|
||||
|
|
@ -330,12 +333,37 @@ async def _find_spend_logs(
|
|||
return rows
|
||||
|
||||
|
||||
async def _find_spend_log_row(prisma_client: PrismaClient, request_id: str) -> _SpendLogOwnershipRow | None:
|
||||
"""Read the single spend log row identified by ``request_id``."""
|
||||
return await _spend_logs_table(prisma_client).find_unique(
|
||||
where={"request_id": request_id},
|
||||
include=None,
|
||||
)
|
||||
class _RequestIdEquals(TypedDict):
|
||||
request_id: ReadOnly[str]
|
||||
|
||||
|
||||
class _LitellmCallIdEquals(TypedDict):
|
||||
litellm_call_id: ReadOnly[str]
|
||||
|
||||
|
||||
def _request_id_or_call_id_clause(request_id: str) -> tuple[_RequestIdEquals, _LitellmCallIdEquals]:
|
||||
request_id_clause: Final[_RequestIdEquals] = {"request_id": request_id}
|
||||
call_id_clause: Final[_LitellmCallIdEquals] = {"litellm_call_id": request_id}
|
||||
return (request_id_clause, call_id_clause)
|
||||
|
||||
|
||||
async def _find_spend_log_owners(prisma_client: PrismaClient, request_id: str) -> Sequence[_SpendLogOwnerRow]:
|
||||
"""Read the distinct ``(user, team_id)`` owner pairs across every spend log row
|
||||
identified by ``request_id`` or ``litellm_call_id``.
|
||||
|
||||
``litellm_call_id`` is populated from the client-settable ``x-litellm-call-id``
|
||||
request header, so it is not guaranteed unique to one tenant: any number of rows
|
||||
can match one id. The read is uncapped because a flood of another tenant's rows
|
||||
carrying the caller's id could otherwise push the caller's own owner pair past a
|
||||
row-sample cap and lock them out of their own lookup.
|
||||
"""
|
||||
sql_query: Final = """
|
||||
SELECT DISTINCT "user", team_id
|
||||
FROM "LiteLLM_SpendLogs"
|
||||
WHERE request_id = $1 OR litellm_call_id = $1
|
||||
"""
|
||||
owners: Final[Sequence[_SpendLogOwnerRow] | None] = await _query_raw_or_none(prisma_client, sql_query, request_id)
|
||||
return owners if owners is not None else ()
|
||||
|
||||
|
||||
async def _count_spend_logs(prisma_client: PrismaClient, where: Mapping[str, object]) -> int:
|
||||
|
|
@ -2599,10 +2627,11 @@ async def ui_view_spend_logs(
|
|||
if max_spend is not None:
|
||||
where_conditions["spend"]["lte"] = max_spend
|
||||
# A request_id lookup drops the date window, so a non-admin could otherwise
|
||||
# reach any single row by id; require they own it, mirroring the detail
|
||||
# endpoint. That ownership check fully authorizes the one row, so the
|
||||
# general scoping below is skipped for id lookups. Scoped to the UI route
|
||||
# so the public v2 contract is unchanged.
|
||||
# reach any single row by id; require they own one of the matches, mirroring
|
||||
# the detail endpoint, and keep the general scoping below so a colliding
|
||||
# foreign row is filtered out rather than served or allowed to deny the
|
||||
# caller their own row. Scoped to the UI route so the public v2 contract is
|
||||
# unchanged.
|
||||
if request_id is not None and not is_v2 and not is_admin_view:
|
||||
await _assert_user_can_view_request_id(
|
||||
prisma_client=prisma_client,
|
||||
|
|
@ -2610,10 +2639,9 @@ async def ui_view_spend_logs(
|
|||
request_id=request_id,
|
||||
)
|
||||
user_scope_applies: Final = (
|
||||
not is_request_id_lookup
|
||||
and not is_admin_view
|
||||
not is_admin_view
|
||||
and team_id is None
|
||||
and _can_user_view_spend_log(user_api_key_dict=user_api_key_dict)
|
||||
and (is_request_id_lookup or _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(
|
||||
|
|
@ -2626,7 +2654,7 @@ async def ui_view_spend_logs(
|
|||
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 not is_admin_view:
|
||||
if team_id is not None:
|
||||
can_view_team: Final = await _can_team_member_view_log(
|
||||
prisma_client=prisma_client,
|
||||
|
|
@ -2696,7 +2724,6 @@ async def ui_view_spend_logs(
|
|||
("team_id", "team_id"),
|
||||
('"user"', "user"),
|
||||
("api_key", "api_key"),
|
||||
("request_id", "request_id"),
|
||||
("model", "model"),
|
||||
("model_id", "model_id"),
|
||||
("model_group", "model_group"),
|
||||
|
|
@ -2708,6 +2735,13 @@ async def ui_view_spend_logs(
|
|||
sql_params.append(val)
|
||||
p += 1
|
||||
|
||||
request_id_filter: Final = where_conditions.get("request_id")
|
||||
exact_request_id_first: Final = f"(request_id = ${p}) DESC, " if isinstance(request_id_filter, str) else ""
|
||||
if isinstance(request_id_filter, str):
|
||||
sql_conditions.append(f"(request_id = ${p} OR litellm_call_id = ${p})")
|
||||
sql_params.append(request_id_filter)
|
||||
p += 1
|
||||
|
||||
# Multi-team OR filter: (user = $X OR team_id = ANY($Y))
|
||||
if permitted_team_ids:
|
||||
or_clause: Final = f'("user" = ${p} OR team_id = ANY(${p + 1}::text[]))'
|
||||
|
|
@ -2837,7 +2871,7 @@ async def ui_view_spend_logs(
|
|||
WHERE {joined_conditions}
|
||||
ORDER BY {_SESSION_GROUP_KEY_SQL}, call_type IN {_MCP_CALL_TYPES_SQL}, "startTime" DESC
|
||||
) AS session_representatives
|
||||
ORDER BY {_order_expr} {_sql_dir}{_nulls_clause}, request_id
|
||||
ORDER BY {exact_request_id_first}{_order_expr} {_sql_dir}{_nulls_clause}, request_id
|
||||
LIMIT ${p} OFFSET ${p + 1}
|
||||
"""
|
||||
if session_grouping
|
||||
|
|
@ -2846,7 +2880,7 @@ async def ui_view_spend_logs(
|
|||
{_SPEND_LOG_LIST_COLUMNS}
|
||||
FROM "LiteLLM_SpendLogs"
|
||||
WHERE {joined_conditions}
|
||||
ORDER BY {_order_expr} {_sql_dir}{_nulls_clause}
|
||||
ORDER BY {exact_request_id_first}{_order_expr} {_sql_dir}{_nulls_clause}
|
||||
LIMIT ${p} OFFSET ${p + 1}
|
||||
"""
|
||||
)
|
||||
|
|
@ -2854,6 +2888,14 @@ async def ui_view_spend_logs(
|
|||
|
||||
data: Final = await prisma_client.db.query_raw(sql_query, *sql_params)
|
||||
|
||||
if request_id is not None and not is_v2 and not is_admin_view:
|
||||
await _assert_user_owns_fetched_spend_rows(
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
rows=data,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
_hydrate_spend_log_metadata(data)
|
||||
|
||||
# Calculate total pages
|
||||
|
|
@ -3104,7 +3146,7 @@ def _hydrate_spend_log_metadata(rows: Sequence[Mapping[str, object]]) -> None:
|
|||
|
||||
|
||||
def _cold_storage_object_key_from_metadata(
|
||||
metadata: str | dict | None,
|
||||
metadata: str | Mapping[str, object] | None,
|
||||
) -> str | None:
|
||||
if isinstance(metadata, str):
|
||||
try:
|
||||
|
|
@ -3209,7 +3251,8 @@ async def ui_view_request_response_for_request_id(
|
|||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if not _is_admin_view_safe(user_api_key_dict=user_api_key_dict):
|
||||
caller_is_admin: Final = _is_admin_view_safe(user_api_key_dict=user_api_key_dict)
|
||||
if not caller_is_admin:
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
|
|
@ -3234,38 +3277,45 @@ async def ui_view_request_response_for_request_id(
|
|||
if end_date is not None:
|
||||
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
|
||||
|
||||
spend_log_row: Final = (
|
||||
None
|
||||
if prisma_client is None
|
||||
else await _resolve_spend_log_payload_row(
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
request_id=request_id,
|
||||
caller_is_admin=caller_is_admin,
|
||||
)
|
||||
)
|
||||
stored_request_id: Final = _stored_request_id(spend_log_row, request_id)
|
||||
|
||||
for custom_logger in custom_loggers:
|
||||
payload = await custom_logger.get_request_response_payload(
|
||||
request_id=request_id,
|
||||
request_id=stored_request_id,
|
||||
start_time_utc=start_date_obj,
|
||||
end_time_utc=end_date_obj,
|
||||
)
|
||||
if payload is not None:
|
||||
if not caller_is_admin and prisma_client is not None:
|
||||
await _assert_user_owns_cold_storage_payload(
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
payload=cast(Mapping[str, object], payload), # cast-ok: custom-logger payload is untyped
|
||||
request_id=request_id,
|
||||
)
|
||||
return payload
|
||||
|
||||
if spend_log_row is None:
|
||||
return None
|
||||
|
||||
# Fallback: the list endpoint omits the heavy columns for performance, so
|
||||
# serve them here. When prompts were offloaded to cold storage the DB holds
|
||||
# only placeholders, so _resolve_request_response_payload fetches the real
|
||||
# payload from the configured cold storage backend by object key.
|
||||
if prisma_client is not None:
|
||||
from litellm.proxy.spend_tracking.cold_storage_handler import (
|
||||
ColdStorageHandler,
|
||||
)
|
||||
from litellm.proxy.spend_tracking.cold_storage_handler import ColdStorageHandler
|
||||
|
||||
sql_query: Final = """
|
||||
SELECT messages, response, proxy_server_request, metadata
|
||||
FROM "LiteLLM_SpendLogs"
|
||||
WHERE request_id = $1
|
||||
LIMIT 1
|
||||
"""
|
||||
db_result: Final[Sequence[Mapping[str, object]] | None] = await _query_raw_or_none(
|
||||
prisma_client, sql_query, request_id
|
||||
)
|
||||
if db_result and len(db_result) > 0:
|
||||
resolved = await _resolve_request_response_payload(db_result[0], cold_storage_handler=ColdStorageHandler())
|
||||
return resolved._asdict()
|
||||
|
||||
return None
|
||||
resolved: Final = await _resolve_request_response_payload(spend_log_row, cold_storage_handler=ColdStorageHandler())
|
||||
return resolved._asdict()
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
@ -3391,7 +3441,7 @@ async def view_spend_logs(
|
|||
if api_key is not None and isinstance(api_key, str):
|
||||
filter_query["api_key"] = summary_api_key
|
||||
if request_id is not None and isinstance(request_id, str):
|
||||
filter_query["request_id"] = request_id
|
||||
filter_query["OR"] = _request_id_or_call_id_clause(request_id)
|
||||
if user_id is not None and isinstance(user_id, str):
|
||||
filter_query["user"] = user_id
|
||||
|
||||
|
|
@ -3439,7 +3489,7 @@ async def view_spend_logs(
|
|||
return [*summary_items, *padding]
|
||||
|
||||
else:
|
||||
scoped_filter: Final[dict[str, str]] = {}
|
||||
scoped_filter: Final[dict[str, object]] = {}
|
||||
if api_key is not None and isinstance(api_key, str):
|
||||
if api_key.startswith("sk-"):
|
||||
hashed_token = prisma_client.hash_token(token=api_key)
|
||||
|
|
@ -3447,7 +3497,7 @@ async def view_spend_logs(
|
|||
hashed_token = api_key
|
||||
scoped_filter["api_key"] = hashed_token
|
||||
if request_id is not None and isinstance(request_id, str):
|
||||
scoped_filter["request_id"] = request_id
|
||||
scoped_filter["OR"] = _request_id_or_call_id_clause(request_id)
|
||||
if user_id is not None and isinstance(user_id, str):
|
||||
scoped_filter["user"] = user_id
|
||||
|
||||
|
|
@ -4676,39 +4726,190 @@ def _can_user_view_spend_log(user_api_key_dict: UserAPIKeyAuth) -> bool:
|
|||
)
|
||||
|
||||
|
||||
async def _user_can_view_spend_log_owner(
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
owner_user: str | None,
|
||||
owner_team_id: str | None,
|
||||
) -> bool:
|
||||
if owner_user is not None and owner_user == user_api_key_dict.user_id:
|
||||
return True
|
||||
if owner_team_id:
|
||||
return await _can_team_member_view_log(
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
team_id=owner_team_id,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _spend_log_forbidden(request_id: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"error": f"Not authorized to view spend log for request_id={request_id}"},
|
||||
)
|
||||
|
||||
|
||||
async def _assert_user_can_view_request_id(
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
request_id: str,
|
||||
) -> None:
|
||||
"""
|
||||
Verify the requesting non-admin user is allowed to view this spend-log row.
|
||||
Allowed when the log belongs to the user directly, or to one of their
|
||||
permitted teams (admin or ``/spend/logs`` permission).
|
||||
Raises HTTP 403 if not, including when no spend-log row exists for the
|
||||
request_id (e.g. it was pruned by retention), so a missing row can't be
|
||||
used to read a payload out of cold storage via the detail endpoint.
|
||||
Verify the requesting non-admin user is allowed to view at least one spend-log
|
||||
row identified by ``request_id`` or ``litellm_call_id``. The latter is
|
||||
client-settable, so an id lookup can match rows across different tenants; the
|
||||
data queries scope a non-admin's results to rows they own directly or via a
|
||||
permitted team, so a colliding foreign row can neither be served nor deny the
|
||||
caller their own. Raises HTTP 403 when none of the matching rows is theirs to
|
||||
view, including when no row exists at all (e.g. it was pruned by retention),
|
||||
so a missing row can't be used to read a payload out of cold storage via the
|
||||
detail endpoint.
|
||||
"""
|
||||
row: Final = await _find_spend_log_row(prisma_client, request_id)
|
||||
owners: Final = await _find_spend_log_owners(prisma_client, request_id)
|
||||
for owner in owners:
|
||||
if await _user_can_view_spend_log_owner(prisma_client, user_api_key_dict, owner["user"], owner["team_id"]):
|
||||
return
|
||||
raise _spend_log_forbidden(request_id)
|
||||
|
||||
if row is not None and row.user is not None and row.user == user_api_key_dict.user_id:
|
||||
return
|
||||
|
||||
if row is not None and row.team_id:
|
||||
can_view: Final = await _can_team_member_view_log(
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SpendLogViewer:
|
||||
user_id: str | None
|
||||
team_ids: tuple[str, ...]
|
||||
|
||||
|
||||
async def _spend_log_viewer(prisma_client: PrismaClient, user_api_key_dict: UserAPIKeyAuth) -> _SpendLogViewer:
|
||||
return _SpendLogViewer(
|
||||
user_id=user_api_key_dict.user_id,
|
||||
team_ids=await _get_permitted_team_ids_for_spend_logs_or_empty(
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
team_id=row.team_id,
|
||||
)
|
||||
if can_view:
|
||||
return
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"error": f"Not authorized to view spend log for request_id={request_id}"},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _viewer_scope_clause(viewer: _SpendLogViewer | None) -> tuple[str, tuple[object, ...]]:
|
||||
match viewer:
|
||||
case None:
|
||||
return ("", ())
|
||||
case _SpendLogViewer(user_id=user_id, team_ids=()):
|
||||
return (' AND "user" = $2', (user_id,))
|
||||
case _SpendLogViewer(user_id=user_id, team_ids=team_ids):
|
||||
return (' AND ("user" = $2 OR team_id = ANY($3::text[]))', (user_id, team_ids))
|
||||
|
||||
|
||||
def _spend_log_payload_query(request_id: str, viewer: _SpendLogViewer | None) -> tuple[str, tuple[object, ...]]:
|
||||
"""
|
||||
Fetch the one row an id lookup resolves to, preferring the exact ``request_id``
|
||||
match over rows that merely carry the id as their client-set ``litellm_call_id``.
|
||||
A non-admin viewer only ever gets rows they own or rows of a team they may view.
|
||||
"""
|
||||
scope, scope_params = _viewer_scope_clause(viewer)
|
||||
return (
|
||||
f"""
|
||||
SELECT request_id, messages, response, proxy_server_request, metadata, "user", team_id
|
||||
FROM "LiteLLM_SpendLogs"
|
||||
WHERE (request_id = $1 OR litellm_call_id = $1){scope}
|
||||
ORDER BY (request_id = $1) DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(request_id, *scope_params),
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_spend_log_payload_row(
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
request_id: str,
|
||||
caller_is_admin: bool,
|
||||
) -> Mapping[str, object] | None:
|
||||
"""
|
||||
Resolve an id lookup to the caller's own spend-log row before any payload
|
||||
store is consulted. Cold storage is keyed by the provider ``request_id``, so
|
||||
asking it for the raw lookup id could hand back another tenant's payload when
|
||||
that id is only the caller's ``litellm_call_id``; the row's stored
|
||||
``request_id`` is the key that names the caller's own request.
|
||||
"""
|
||||
viewer: Final = None if caller_is_admin else await _spend_log_viewer(prisma_client, user_api_key_dict)
|
||||
sql_query, sql_params = _spend_log_payload_query(request_id, viewer)
|
||||
rows: Final[Sequence[Mapping[str, object]] | None] = await _query_raw_or_none(prisma_client, sql_query, *sql_params)
|
||||
if not rows:
|
||||
return None
|
||||
if not caller_is_admin:
|
||||
await _assert_user_owns_fetched_spend_rows(
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
rows=rows,
|
||||
request_id=request_id,
|
||||
)
|
||||
return rows[0]
|
||||
|
||||
|
||||
def _stored_request_id(row: Mapping[str, object] | None, lookup_id: str) -> str:
|
||||
stored: Final = None if row is None else row.get("request_id")
|
||||
return stored if isinstance(stored, str) else lookup_id
|
||||
|
||||
|
||||
def _fetched_row_owner(row: Mapping[str, object]) -> tuple[str | None, str | None]:
|
||||
user: Final = row.get("user")
|
||||
team_id: Final = row.get("team_id")
|
||||
return (
|
||||
user if isinstance(user, str) else None,
|
||||
team_id if isinstance(team_id, str) else None,
|
||||
)
|
||||
|
||||
|
||||
async def _assert_user_owns_fetched_spend_rows(
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
rows: Sequence[Mapping[str, object]],
|
||||
request_id: str,
|
||||
) -> None:
|
||||
"""
|
||||
Re-verify ownership on the rows an id lookup actually fetched.
|
||||
``_assert_user_can_view_request_id`` and the data query read the table at
|
||||
different moments, so a foreign row inserted between them could otherwise be
|
||||
returned even though the pre-check passed. Checking the fetched rows
|
||||
themselves means no interleaving can return another tenant's row.
|
||||
"""
|
||||
for user, team_id in frozenset(_fetched_row_owner(row) for row in rows):
|
||||
if not await _user_can_view_spend_log_owner(prisma_client, user_api_key_dict, user, team_id):
|
||||
raise _spend_log_forbidden(request_id)
|
||||
|
||||
|
||||
def _cold_storage_payload_owner(payload: Mapping[str, object]) -> tuple[str | None, str | None]:
|
||||
metadata: Final = payload.get("metadata")
|
||||
if not isinstance(metadata, Mapping):
|
||||
return (None, None)
|
||||
owner: Final = cast(Mapping[str, object], metadata) # cast-ok: cold-storage JSON is untyped
|
||||
user: Final = owner.get("user_api_key_user_id")
|
||||
team_id: Final = owner.get("user_api_key_team_id")
|
||||
return (
|
||||
user if isinstance(user, str) else None,
|
||||
team_id if isinstance(team_id, str) else None,
|
||||
)
|
||||
|
||||
|
||||
async def _assert_user_owns_cold_storage_payload(
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
payload: Mapping[str, object],
|
||||
request_id: str,
|
||||
) -> None:
|
||||
"""
|
||||
Authorize a cold-storage payload against the owner recorded inside it.
|
||||
The custom logger reads the payload straight from cold storage, written
|
||||
independently of the spend-log table and able to outlive its row, so a
|
||||
request_id lookup could otherwise hand back another tenant's stored payload
|
||||
when no row exists for the pre-check to catch. Verifying the payload's own
|
||||
owner closes that gap, and a payload that records no owner fails closed.
|
||||
"""
|
||||
owner_user, owner_team_id = _cold_storage_payload_owner(payload)
|
||||
if not await _user_can_view_spend_log_owner(prisma_client, user_api_key_dict, owner_user, owner_team_id):
|
||||
raise _spend_log_forbidden(request_id)
|
||||
|
||||
|
||||
async def _get_permitted_team_ids_for_spend_logs(
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
|
|
|
|||
|
|
@ -621,6 +621,7 @@ def get_logging_payload(kwargs, response_obj, start_time, end_time) -> SpendLogs
|
|||
status=_get_status_for_spend_log(
|
||||
metadata=metadata,
|
||||
),
|
||||
litellm_call_id=litellm_call_id,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
|
|
|
|||
|
|
@ -659,12 +659,14 @@ model LiteLLM_SpendLogs {
|
|||
mcp_namespaced_tool_name String?
|
||||
agent_id String?
|
||||
proxy_server_request Json? @default("{}")
|
||||
litellm_call_id String?
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
updated_at DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
@@index([startTime])
|
||||
@@index([startTime, request_id])
|
||||
@@index([end_user])
|
||||
@@index([session_id])
|
||||
@@index([litellm_call_id])
|
||||
}
|
||||
|
||||
model LiteLLM_BudgetWindowSpend {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ verbose_logger.setLevel(logging.DEBUG)
|
|||
|
||||
ignored_keys = [
|
||||
"request_id",
|
||||
"litellm_call_id",
|
||||
"metadata.litellm_call_id",
|
||||
"session_id",
|
||||
"startTime",
|
||||
|
|
|
|||
|
|
@ -124,7 +124,10 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
|
|||
sess = re.fullmatch(r"session_id LIKE \$(\d+)", cond)
|
||||
status = re.fullmatch(r"status = \$(\d+)", cond)
|
||||
api_key_not_in = re.fullmatch(r"api_key NOT IN \(\$(\d+), \$(\d+)\)", cond)
|
||||
if gte:
|
||||
req_or_call = re.fullmatch(r"\(request_id = \$(\d+) OR litellm_call_id = \$\1\)", cond)
|
||||
if req_or_call:
|
||||
where["request_id_or_call_id"] = params[int(req_or_call.group(1)) - 1]
|
||||
elif gte:
|
||||
date_bounds["gte"] = _iso(params[int(gte.group(1)) - 1])
|
||||
elif lte:
|
||||
date_bounds["lte"] = _iso(params[int(lte.group(1)) - 1])
|
||||
|
|
@ -213,6 +216,8 @@ def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=No
|
|||
query_observer(sql_query, params)
|
||||
if "mcp_tool_call_count" in sql_query:
|
||||
return []
|
||||
if 'SELECT DISTINCT "user", team_id' in sql_query:
|
||||
return _emulate_spend_log_owner_lookup(mock_spend_logs, sql_query, params)
|
||||
filtered = filter_fn(_reconstruct_ui_where_from_sql(sql_query, params))
|
||||
total = len(filtered)
|
||||
if "COUNT(*)" in sql_query:
|
||||
|
|
@ -220,7 +225,13 @@ def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=No
|
|||
return [{"total_count": min(total, cap_plus_one)}]
|
||||
page_size = params[-2] if len(params) >= 2 else 50
|
||||
skip = params[-1] if len(params) >= 1 else 0
|
||||
return [row for row in filtered[skip : skip + page_size]]
|
||||
exact_first = re.search(r"ORDER BY \(request_id = \$(\d+)\) DESC", sql_query)
|
||||
ordered = (
|
||||
sorted(filtered, key=lambda row: row["request_id"] == params[int(exact_first.group(1)) - 1], reverse=True)
|
||||
if exact_first
|
||||
else filtered
|
||||
)
|
||||
return [row for row in ordered[skip : skip + page_size]]
|
||||
|
||||
class MockPrismaClient:
|
||||
def __init__(self):
|
||||
|
|
@ -424,37 +435,173 @@ def test_can_user_view_spend_log_false_for_other_roles():
|
|||
assert spend_management_endpoints._can_user_view_spend_log(auth) is False
|
||||
|
||||
|
||||
def _emulate_spend_log_owner_lookup(rows, sql_query, params):
|
||||
"""Emulate the ownership lookup SQL over an in-memory spend-log corpus,
|
||||
honoring DISTINCT and any literal LIMIT the query carries so a capped or
|
||||
non-distinct query produces the truncated result it would in Postgres."""
|
||||
lookup_id = params[0]
|
||||
matches = [
|
||||
{"user": row.get("user"), "team_id": row.get("team_id")}
|
||||
for row in rows
|
||||
if lookup_id in (row.get("request_id"), row.get("litellm_call_id"))
|
||||
]
|
||||
if "DISTINCT" in sql_query:
|
||||
deduped = []
|
||||
for match in matches:
|
||||
if match not in deduped:
|
||||
deduped.append(match)
|
||||
matches = deduped
|
||||
limit = re.search(r"LIMIT\s+(\d+)", sql_query, re.IGNORECASE)
|
||||
if limit is not None:
|
||||
matches = matches[: int(limit.group(1))]
|
||||
return matches
|
||||
|
||||
|
||||
def _make_owner_lookup_prisma(rows):
|
||||
class MockDB:
|
||||
async def query_raw(self, sql_query, *params):
|
||||
return _emulate_spend_log_owner_lookup(rows, sql_query, params)
|
||||
|
||||
class MockPrisma:
|
||||
def __init__(self):
|
||||
self.db = MockDB()
|
||||
|
||||
return MockPrisma()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assert_user_can_view_request_id_rejects_both_users_none():
|
||||
"""
|
||||
API keys with user_id=None must not be treated as owning a log whose user
|
||||
field is None (avoid None == None bypass).
|
||||
"""
|
||||
|
||||
class MockRow:
|
||||
user = None
|
||||
team_id = None
|
||||
|
||||
class MockSpendLogs:
|
||||
async def find_unique(self, where, include=None):
|
||||
return MockRow()
|
||||
|
||||
class MockDB:
|
||||
def __init__(self):
|
||||
self.litellm_spendlogs = MockSpendLogs()
|
||||
|
||||
class MockPrisma:
|
||||
def __init__(self):
|
||||
self.db = MockDB()
|
||||
prisma = _make_owner_lookup_prisma(
|
||||
[{"request_id": "req-none-user", "litellm_call_id": None, "user": None, "team_id": None}]
|
||||
)
|
||||
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id=None)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await spend_management_endpoints._assert_user_can_view_request_id(
|
||||
MockPrisma(), auth, "req-none-user"
|
||||
prisma, auth, "req-none-user"
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assert_user_can_view_request_id_rejects_when_no_match_is_owned():
|
||||
"""An id whose every matching row belongs to other tenants is refused outright,
|
||||
so the relaxed date window of an id lookup cannot reach a foreign row."""
|
||||
prisma = _make_owner_lookup_prisma(
|
||||
[
|
||||
{"request_id": "foreign-request", "litellm_call_id": "shared-id", "user": "tenant_a", "team_id": None},
|
||||
{"request_id": "shared-id", "litellm_call_id": "other-call-id", "user": "tenant_b", "team_id": None},
|
||||
]
|
||||
)
|
||||
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="caller")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await spend_management_endpoints._assert_user_can_view_request_id(prisma, auth, "shared-id")
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assert_user_can_view_request_id_allows_owner_despite_foreign_collision():
|
||||
"""
|
||||
litellm_call_id comes from the client-settable x-litellm-call-id header, so
|
||||
another tenant can mint a row whose call id equals the caller's request_id.
|
||||
That collision must not lock the caller out of their own row: the pre-check
|
||||
passes once one match is theirs, and the scoped data queries keep the foreign
|
||||
row out of the result. Regression for the every-match-must-be-owned rule that
|
||||
let any tenant deny another's lookup by reusing their id.
|
||||
"""
|
||||
prisma = _make_owner_lookup_prisma(
|
||||
[
|
||||
{
|
||||
"request_id": "attacker-own-request",
|
||||
"litellm_call_id": "victim-request-id",
|
||||
"user": "attacker",
|
||||
"team_id": None,
|
||||
},
|
||||
{
|
||||
"request_id": "victim-request-id",
|
||||
"litellm_call_id": "victim-call-id",
|
||||
"user": "victim",
|
||||
"team_id": None,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="victim")
|
||||
result = await spend_management_endpoints._assert_user_can_view_request_id(prisma, auth, "victim-request-id")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assert_user_can_view_request_id_finds_owner_past_any_row_cap():
|
||||
"""
|
||||
An attacker can mint hundreds of rows carrying the victim's request_id as
|
||||
their litellm_call_id, so a capped or sampled ownership read could exhaust
|
||||
its cap on attacker-owned rows and never see the victim's own row, locking
|
||||
the victim out of their lookup. The ownership read must consider every
|
||||
matching row's owner no matter how many rows match. Regression for the
|
||||
find_many(take=100) sample the first fix used.
|
||||
"""
|
||||
rows = [
|
||||
{
|
||||
"request_id": f"attacker-request-{i}",
|
||||
"litellm_call_id": "victim-request-id",
|
||||
"user": "attacker",
|
||||
"team_id": None,
|
||||
}
|
||||
for i in range(150)
|
||||
]
|
||||
rows.append(
|
||||
{
|
||||
"request_id": "victim-request-id",
|
||||
"litellm_call_id": "victim-call-id",
|
||||
"user": "victim",
|
||||
"team_id": None,
|
||||
}
|
||||
)
|
||||
prisma = _make_owner_lookup_prisma(rows)
|
||||
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="victim")
|
||||
result = await spend_management_endpoints._assert_user_can_view_request_id(prisma, auth, "victim-request-id")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assert_user_can_view_request_id_allows_when_every_match_is_owned():
|
||||
"""The same ambiguous id matching more than one row is fine when every match
|
||||
belongs to the caller (e.g. two of the caller's own requests happen to share
|
||||
a request_id/litellm_call_id pairing); only a foreign match should block it."""
|
||||
prisma = _make_owner_lookup_prisma(
|
||||
[
|
||||
{
|
||||
"request_id": "shared-request-id",
|
||||
"litellm_call_id": "caller-call-a",
|
||||
"user": "caller",
|
||||
"team_id": None,
|
||||
},
|
||||
{
|
||||
"request_id": "caller-request-b",
|
||||
"litellm_call_id": "shared-request-id",
|
||||
"user": "caller",
|
||||
"team_id": None,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="caller")
|
||||
result = await spend_management_endpoints._assert_user_can_view_request_id(
|
||||
prisma, auth, "shared-request-id"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assert_user_can_view_request_id_rejects_missing_row():
|
||||
"""
|
||||
|
|
@ -462,24 +609,11 @@ async def test_assert_user_can_view_request_id_rejects_missing_row():
|
|||
authorize reading the payload from cold storage; a missing row is not
|
||||
the same as an owned row.
|
||||
"""
|
||||
|
||||
class MockSpendLogs:
|
||||
async def find_unique(self, where, include=None):
|
||||
return None
|
||||
|
||||
class MockDB:
|
||||
def __init__(self):
|
||||
self.litellm_spendlogs = MockSpendLogs()
|
||||
|
||||
class MockPrisma:
|
||||
def __init__(self):
|
||||
self.db = MockDB()
|
||||
prisma = _make_owner_lookup_prisma([])
|
||||
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await spend_management_endpoints._assert_user_can_view_request_id(
|
||||
MockPrisma(), auth, "req-missing-row"
|
||||
)
|
||||
await spend_management_endpoints._assert_user_can_view_request_id(prisma, auth, "req-missing-row")
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
|
|
@ -507,6 +641,7 @@ def test_ui_view_request_response_forbids_non_admin_without_db(client, monkeypat
|
|||
|
||||
ignored_keys = [
|
||||
"request_id",
|
||||
"litellm_call_id",
|
||||
"metadata.litellm_call_id",
|
||||
"session_id",
|
||||
"startTime",
|
||||
|
|
@ -2216,7 +2351,10 @@ async def test_ui_view_spend_logs_request_id_lookup_ignores_date_window(
|
|||
def filter_fn(where):
|
||||
captured["where"] = where
|
||||
rows = _filter_logs_by_date_range(mock_spend_logs, where)
|
||||
if where.get("request_id"):
|
||||
rid_either = where.get("request_id_or_call_id")
|
||||
if rid_either:
|
||||
rows = [r for r in rows if rid_either in (r["request_id"], r.get("litellm_call_id"))]
|
||||
elif where.get("request_id"):
|
||||
rows = [r for r in rows if r["request_id"] == where["request_id"]]
|
||||
return rows
|
||||
|
||||
|
|
@ -2246,9 +2384,82 @@ async def test_ui_view_spend_logs_request_id_lookup_ignores_date_window(
|
|||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["data"][0]["request_id"] == "req-old"
|
||||
# Query dropped the time window and scoped solely by the primary key.
|
||||
# Query dropped the time window and scoped solely by the id lookup.
|
||||
assert "startTime" not in captured["where"]
|
||||
assert captured["where"]["request_id"] == "req-old"
|
||||
assert captured["where"]["request_id_or_call_id"] == "req-old"
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_request_id_lookup_matches_litellm_call_id(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""
|
||||
LIT-6302: success rows are keyed by the upstream provider response id, so a
|
||||
lookup with the x-litellm-call-id response header value found nothing. The id
|
||||
lookup now matches request_id OR litellm_call_id, resolving the header value.
|
||||
"""
|
||||
today = datetime.datetime.now(timezone.utc)
|
||||
mock_spend_logs = [
|
||||
{
|
||||
"id": "log_provider_keyed",
|
||||
"request_id": "chatcmpl-9ZKMURhVYSi9D6r6PJ9vLcayIK0Vm",
|
||||
"litellm_call_id": "b980eea9-5cd9-4099-93cd-8291e46c76fd",
|
||||
"api_key": "sk-test-key",
|
||||
"user": "test_user_1",
|
||||
"team_id": "team1",
|
||||
"spend": 0.05,
|
||||
"startTime": today.isoformat(),
|
||||
"model": "gpt-4",
|
||||
},
|
||||
{
|
||||
"id": "log_other",
|
||||
"request_id": "chatcmpl-other",
|
||||
"litellm_call_id": "11111111-2222-3333-4444-555555555555",
|
||||
"api_key": "sk-test-key",
|
||||
"user": "test_user_1",
|
||||
"team_id": "team1",
|
||||
"spend": 0.01,
|
||||
"startTime": today.isoformat(),
|
||||
"model": "gpt-4",
|
||||
},
|
||||
]
|
||||
|
||||
def filter_fn(where):
|
||||
rid_either = where.get("request_id_or_call_id")
|
||||
if rid_either:
|
||||
return [
|
||||
r
|
||||
for r in mock_spend_logs
|
||||
if rid_either in (r["request_id"], r.get("litellm_call_id"))
|
||||
]
|
||||
if where.get("request_id"):
|
||||
return [
|
||||
r for r in mock_spend_logs if r["request_id"] == where["request_id"]
|
||||
]
|
||||
return list(mock_spend_logs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn),
|
||||
)
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs/ui",
|
||||
params={"request_id": "b980eea9-5cd9-4099-93cd-8291e46c76fd"},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert (
|
||||
data["data"][0]["request_id"] == "chatcmpl-9ZKMURhVYSi9D6r6PJ9vLcayIK0Vm"
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
|
@ -2303,23 +2514,18 @@ async def test_ui_view_spend_logs_request_id_blocks_non_owner(client, monkeypatc
|
|||
"""A non-admin looking up a request_id they do not own is rejected (403), so
|
||||
the relaxed date window cannot read another tenant's log by id."""
|
||||
|
||||
class _ForeignRow:
|
||||
user = "other_user"
|
||||
team_id = None
|
||||
prisma = _make_owner_lookup_prisma(
|
||||
[
|
||||
{
|
||||
"request_id": "foreign-req",
|
||||
"litellm_call_id": None,
|
||||
"user": "other_user",
|
||||
"team_id": None,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
class _SpendLogs:
|
||||
async def find_unique(self, where, include=None):
|
||||
return _ForeignRow()
|
||||
|
||||
class _DB:
|
||||
def __init__(self):
|
||||
self.litellm_spendlogs = _SpendLogs()
|
||||
|
||||
class _Prisma:
|
||||
def __init__(self):
|
||||
self.db = _DB()
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", _Prisma())
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1"
|
||||
)
|
||||
|
|
@ -2335,13 +2541,437 @@ async def test_ui_view_spend_logs_request_id_blocks_non_owner(client, monkeypatc
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_request_id_owner_scoped_by_id_only(
|
||||
async def test_ui_view_spend_logs_request_id_collision_serves_only_callers_rows(client, monkeypatch):
|
||||
"""Two tenants share one id: the attacker minted a row whose client-set
|
||||
litellm_call_id equals the victim's request_id. Each side's lookup of that id
|
||||
returns only their own row, so the collision neither leaks the other tenant's
|
||||
row nor denies the victim theirs (Veria: identifier collision could deny access)."""
|
||||
now_iso = datetime.datetime.now(timezone.utc).isoformat()
|
||||
corpus = [
|
||||
{
|
||||
"id": "log_attacker",
|
||||
"request_id": "attacker-req",
|
||||
"litellm_call_id": "victim-req",
|
||||
"api_key": "sk-attacker-key",
|
||||
"user": "attacker_user",
|
||||
"team_id": None,
|
||||
"spend": 0.05,
|
||||
"startTime": now_iso,
|
||||
"model": "gpt-4",
|
||||
},
|
||||
{
|
||||
"id": "log_victim",
|
||||
"request_id": "victim-req",
|
||||
"litellm_call_id": "victim-call-id",
|
||||
"api_key": "sk-victim-key",
|
||||
"user": "victim_user",
|
||||
"team_id": None,
|
||||
"spend": 0.07,
|
||||
"startTime": now_iso,
|
||||
"model": "gpt-4",
|
||||
},
|
||||
]
|
||||
|
||||
def filter_fn(where):
|
||||
rid_either = where.get("request_id_or_call_id")
|
||||
rows = [r for r in corpus if rid_either in (r["request_id"], r["litellm_call_id"])]
|
||||
return [r for r in rows if where.get("user") is None or r["user"] == where["user"]]
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", make_ui_spend_logs_mock_prisma(corpus, filter_fn))
|
||||
try:
|
||||
for caller, own_request_id, other in (
|
||||
("victim_user", "victim-req", "attacker_user"),
|
||||
("attacker_user", "attacker-req", "victim_user"),
|
||||
):
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda caller=caller: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id=caller
|
||||
)
|
||||
response = client.get(
|
||||
"/spend/logs/ui",
|
||||
params={"request_id": "victim-req"},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["data"][0]["request_id"] == own_request_id
|
||||
assert other not in response.text
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_id_lookup_scopes_every_non_admin_role(client, monkeypatch):
|
||||
"""An org admin reaches /spend/logs/ui without the internal-user row scope. An
|
||||
id lookup still fetches only rows they own, so another tenant's row carrying
|
||||
that id as its client-set litellm_call_id neither leaks nor turns the
|
||||
org admin's own lookup into a 403 (Bugbot: non-internal id lookup 403s on collision)."""
|
||||
now_iso = datetime.datetime.now(timezone.utc).isoformat()
|
||||
corpus = [
|
||||
{
|
||||
"id": "log_attacker",
|
||||
"request_id": "attacker-req",
|
||||
"litellm_call_id": "victim-req",
|
||||
"api_key": "sk-attacker-key",
|
||||
"user": "attacker_user",
|
||||
"team_id": None,
|
||||
"spend": 0.05,
|
||||
"startTime": now_iso,
|
||||
"model": "gpt-4",
|
||||
},
|
||||
{
|
||||
"id": "log_victim",
|
||||
"request_id": "victim-req",
|
||||
"litellm_call_id": "victim-call-id",
|
||||
"api_key": "sk-victim-key",
|
||||
"user": "victim_user",
|
||||
"team_id": None,
|
||||
"spend": 0.07,
|
||||
"startTime": now_iso,
|
||||
"model": "gpt-4",
|
||||
},
|
||||
]
|
||||
|
||||
def filter_fn(where):
|
||||
rid_either = where.get("request_id_or_call_id")
|
||||
rows = [r for r in corpus if rid_either in (r["request_id"], r["litellm_call_id"])]
|
||||
return [r for r in rows if where.get("user") is None or r["user"] == where["user"]]
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", make_ui_spend_logs_mock_prisma(corpus, filter_fn))
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.ORG_ADMIN, user_id="victim_user"
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs/ui",
|
||||
params={"request_id": "victim-req"},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert [row["request_id"] for row in data["data"]] == ["victim-req"]
|
||||
assert "attacker_user" not in response.text
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_request_id_rejects_foreign_row_inserted_after_owner_check(client, monkeypatch):
|
||||
"""The SQL scope keeps foreign rows out of an id lookup; this backstop covers a
|
||||
row the scope did not filter (the mock ignores it on purpose). The rows actually
|
||||
fetched are ownership-checked again, so the lookup answers 403 instead of serving
|
||||
the other tenant's row."""
|
||||
now_iso = datetime.datetime.now(timezone.utc).isoformat()
|
||||
owned_row = {
|
||||
"id": "log_owned",
|
||||
"request_id": "attacker-req",
|
||||
"litellm_call_id": "shared-id",
|
||||
"api_key": "sk-test-key",
|
||||
"user": "user_1",
|
||||
"team_id": None,
|
||||
"spend": 0.05,
|
||||
"startTime": now_iso,
|
||||
"model": "gpt-4",
|
||||
}
|
||||
foreign_row = {
|
||||
"id": "log_foreign",
|
||||
"request_id": "shared-id",
|
||||
"litellm_call_id": None,
|
||||
"api_key": "sk-victim-key",
|
||||
"user": "victim_user",
|
||||
"team_id": None,
|
||||
"spend": 0.07,
|
||||
"startTime": now_iso,
|
||||
"model": "gpt-4",
|
||||
}
|
||||
|
||||
mock_prisma = make_ui_spend_logs_mock_prisma([owned_row], lambda where: [owned_row, foreign_row])
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1"
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs/ui",
|
||||
params={"request_id": "shared-id"},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "victim_user" not in response.text
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
def _make_payload_lookup_prisma(rows):
|
||||
"""Emulate the detail endpoint's SQL over an in-memory corpus: the owner
|
||||
pre-check, the caller scope on ``"user"`` and permitted teams, and the
|
||||
exact-request_id-first ordering with LIMIT 1."""
|
||||
|
||||
class MockDB:
|
||||
async def query_raw(self, sql_query, *params):
|
||||
if 'SELECT DISTINCT "user", team_id' in sql_query:
|
||||
return _emulate_spend_log_owner_lookup(rows, sql_query, params)
|
||||
lookup_id = params[0]
|
||||
matches = [r for r in rows if lookup_id in (r["request_id"], r["litellm_call_id"])]
|
||||
if '"user" = $2' in sql_query:
|
||||
team_ids = params[2] if "ANY($3::text[])" in sql_query else ()
|
||||
matches = [r for r in matches if r["user"] == params[1] or r["team_id"] in team_ids]
|
||||
if "ORDER BY (request_id = $1) DESC" in sql_query:
|
||||
matches = sorted(matches, key=lambda r: r["request_id"] == lookup_id, reverse=True)
|
||||
return matches[:1]
|
||||
|
||||
class MockPrisma:
|
||||
def __init__(self):
|
||||
self.db = MockDB()
|
||||
|
||||
return MockPrisma()
|
||||
|
||||
|
||||
def _payload_row(request_id, litellm_call_id, user, prompt):
|
||||
return {
|
||||
"request_id": request_id,
|
||||
"litellm_call_id": litellm_call_id,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"response": {"id": request_id},
|
||||
"proxy_server_request": None,
|
||||
"metadata": None,
|
||||
"user": user,
|
||||
"team_id": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_request_response_collision_serves_callers_own_row(client, monkeypatch):
|
||||
"""The attacker's row carries the victim's request_id as its client-set call id
|
||||
and was written first. Each tenant's detail lookup of that id serves only their
|
||||
own payload, and an admin's lookup resolves the exact request_id match rather
|
||||
than whichever colliding row the database happens to return first."""
|
||||
prisma = _make_payload_lookup_prisma(
|
||||
[
|
||||
_payload_row("attacker-req", "victim-req", "attacker_user", "attacker prompt"),
|
||||
_payload_row("victim-req", "victim-call-id", "victim_user", "victim prompt"),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma)
|
||||
try:
|
||||
for role, user_id, own_prompt, other_prompt in (
|
||||
(LitellmUserRoles.INTERNAL_USER, "victim_user", "victim prompt", "attacker prompt"),
|
||||
(LitellmUserRoles.INTERNAL_USER, "attacker_user", "attacker prompt", "victim prompt"),
|
||||
(LitellmUserRoles.PROXY_ADMIN, "admin", "victim prompt", "attacker prompt"),
|
||||
):
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda role=role, user_id=user_id: UserAPIKeyAuth(
|
||||
user_role=role, user_id=user_id
|
||||
)
|
||||
response = client.get("/spend/logs/ui/victim-req", headers={"Authorization": "Bearer sk-test"})
|
||||
assert response.status_code == 200, response.text
|
||||
assert own_prompt in response.text
|
||||
assert other_prompt not in response.text
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_request_response_rejects_foreign_row_inserted_after_owner_check(client, monkeypatch):
|
||||
"""Backstop behind the SQL scope on the detail endpoint (the mock ignores the
|
||||
scope on purpose): the payload row fetched by id is itself ownership-checked, so
|
||||
a foreign row the scope did not filter cannot have its payload served."""
|
||||
|
||||
class MockDB:
|
||||
async def query_raw(self, sql_query, *params):
|
||||
if 'SELECT DISTINCT "user", team_id' in sql_query:
|
||||
return [{"user": "user_1", "team_id": None}]
|
||||
return [
|
||||
{
|
||||
"messages": [{"role": "user", "content": "victim prompt"}],
|
||||
"response": {"id": "resp-1"},
|
||||
"proxy_server_request": None,
|
||||
"metadata": None,
|
||||
"user": "victim_user",
|
||||
"team_id": None,
|
||||
}
|
||||
]
|
||||
|
||||
class MockPrisma:
|
||||
def __init__(self):
|
||||
self.db = MockDB()
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MockPrisma())
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1"
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs/ui/shared-id",
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "victim prompt" not in response.text
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_request_response_custom_logger_denies_foreign_payload_owner(client, monkeypatch):
|
||||
"""The custom-logger payload comes straight from cold storage, written independently
|
||||
of the spend-log table and able to outlive its row. When an id lookup matches no row,
|
||||
the DB owner pre-check has nothing to verify, so the payload is authorized against the
|
||||
owner recorded inside it. A foreign tenant's stored payload is denied even though no
|
||||
spend-log row exists for the pre-check to catch."""
|
||||
|
||||
class MockDB:
|
||||
async def query_raw(self, sql_query, *params):
|
||||
return []
|
||||
|
||||
class MockPrisma:
|
||||
def __init__(self):
|
||||
self.db = MockDB()
|
||||
|
||||
class ColdStorageLogger:
|
||||
async def get_request_response_payload(self, request_id, start_time_utc, end_time_utc):
|
||||
return {
|
||||
"messages": [{"role": "user", "content": "victim prompt"}],
|
||||
"response": {"id": "r"},
|
||||
"metadata": {"user_api_key_user_id": "victim_user", "user_api_key_team_id": None},
|
||||
}
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MockPrisma())
|
||||
monkeypatch.setattr(
|
||||
litellm.logging_callback_manager,
|
||||
"get_active_additional_logging_utils_from_custom_logger",
|
||||
lambda: [ColdStorageLogger()],
|
||||
)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1"
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs/ui/shared-id",
|
||||
params={"start_date": "2026-01-01 00:00:00"},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "victim prompt" not in response.text
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_request_response_custom_logger_is_keyed_by_callers_own_request_id(client, monkeypatch):
|
||||
"""Cold storage is keyed by the provider request_id. The caller's row carries the
|
||||
lookup id only as its client-set litellm_call_id while another tenant's row owns
|
||||
that id as its request_id. The custom logger is asked for the caller's own stored
|
||||
request_id, so the caller gets their payload rather than a 403 from the foreign
|
||||
payload's owner check, and the foreign payload is never fetched."""
|
||||
prisma = _make_payload_lookup_prisma(
|
||||
[
|
||||
_payload_row("shared-id", "other-call-id", "other_user", "other tenant prompt"),
|
||||
_payload_row("caller-req", "shared-id", "caller_user", "caller prompt"),
|
||||
]
|
||||
)
|
||||
cold_storage = {
|
||||
"shared-id": {
|
||||
"messages": [{"role": "user", "content": "other tenant prompt"}],
|
||||
"response": {"id": "shared-id"},
|
||||
"metadata": {"user_api_key_user_id": "other_user", "user_api_key_team_id": None},
|
||||
},
|
||||
"caller-req": {
|
||||
"messages": [{"role": "user", "content": "caller prompt"}],
|
||||
"response": {"id": "caller-req"},
|
||||
"metadata": {"user_api_key_user_id": "caller_user", "user_api_key_team_id": None},
|
||||
},
|
||||
}
|
||||
requested_ids = []
|
||||
|
||||
class ColdStorageLogger:
|
||||
async def get_request_response_payload(self, request_id, start_time_utc, end_time_utc):
|
||||
requested_ids.append(request_id)
|
||||
return cold_storage.get(request_id)
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma)
|
||||
monkeypatch.setattr(
|
||||
litellm.logging_callback_manager,
|
||||
"get_active_additional_logging_utils_from_custom_logger",
|
||||
lambda: [ColdStorageLogger()],
|
||||
)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id="caller_user"
|
||||
)
|
||||
try:
|
||||
response = client.get("/spend/logs/ui/shared-id", headers={"Authorization": "Bearer sk-test"})
|
||||
assert response.status_code == 200, response.text
|
||||
assert "caller prompt" in response.text
|
||||
assert "other tenant prompt" not in response.text
|
||||
assert requested_ids == ["caller-req"]
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("group_by_session", [False, True])
|
||||
async def test_ui_view_spend_logs_id_lookup_lists_exact_request_id_row_first(client, monkeypatch, group_by_session):
|
||||
"""The dashboard's deep link fetches a single row for ``?log_id=``. When a newer
|
||||
row carries that id as its client-set litellm_call_id, the row whose request_id
|
||||
is the id still comes first, so the link opens the request it names. The
|
||||
session-grouped page orders its representatives the same way."""
|
||||
today = datetime.datetime.now(timezone.utc)
|
||||
corpus = [
|
||||
{
|
||||
"id": "log_colliding",
|
||||
"request_id": "colliding-req",
|
||||
"litellm_call_id": "victim-req",
|
||||
"api_key": "sk-test-key",
|
||||
"user": "other_user",
|
||||
"team_id": None,
|
||||
"spend": 0.01,
|
||||
"startTime": today.isoformat(),
|
||||
"model": "gpt-4",
|
||||
},
|
||||
{
|
||||
"id": "log_victim",
|
||||
"request_id": "victim-req",
|
||||
"litellm_call_id": "victim-call-id",
|
||||
"api_key": "sk-test-key",
|
||||
"user": "victim_user",
|
||||
"team_id": None,
|
||||
"spend": 0.02,
|
||||
"startTime": (today - datetime.timedelta(minutes=5)).isoformat(),
|
||||
"model": "gpt-4",
|
||||
},
|
||||
]
|
||||
|
||||
def filter_fn(where):
|
||||
rows = _filter_logs_by_date_range(corpus, where)
|
||||
rid_either = where.get("request_id_or_call_id")
|
||||
if rid_either:
|
||||
return [r for r in rows if rid_either in (r["request_id"], r["litellm_call_id"])]
|
||||
return rows
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", make_ui_spend_logs_mock_prisma(corpus, filter_fn))
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs/ui",
|
||||
params={"request_id": "victim-req", "page_size": 1, "group_by_session": str(group_by_session).lower()},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["total"] == 2
|
||||
assert [row["request_id"] for row in data["data"]] == ["victim-req"]
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_request_id_owner_lookup_drops_window_keeps_scope(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""A non-admin owner looking up their own request_id resolves across all time.
|
||||
The ownership check authorizes the single row, so the query drops both the date
|
||||
window and the general user/team scoping and filters by the primary key alone;
|
||||
without that skip an internal user would have a `user`/`OR` clause added."""
|
||||
"""A non-admin owner looking up their own request_id resolves across all time:
|
||||
the query drops the date window the dashboard sends, while the caller's own-user
|
||||
scope stays on the id lookup so a colliding foreign row can never be served."""
|
||||
today = datetime.datetime.now(timezone.utc)
|
||||
mock_spend_logs = [
|
||||
{
|
||||
|
|
@ -2361,20 +2991,14 @@ async def test_ui_view_spend_logs_request_id_owner_scoped_by_id_only(
|
|||
def filter_fn(where):
|
||||
captured["where"] = where
|
||||
rows = _filter_logs_by_date_range(mock_spend_logs, where)
|
||||
if where.get("request_id"):
|
||||
rid_either = where.get("request_id_or_call_id")
|
||||
if rid_either:
|
||||
rows = [r for r in rows if rid_either in (r["request_id"], r.get("litellm_call_id"))]
|
||||
elif where.get("request_id"):
|
||||
rows = [r for r in rows if r["request_id"] == where["request_id"]]
|
||||
return rows
|
||||
|
||||
mock_prisma = make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn)
|
||||
|
||||
class _OwnedRow:
|
||||
user = "user_1"
|
||||
team_id = "team1"
|
||||
|
||||
async def _find_unique(where, include=None):
|
||||
return _OwnedRow()
|
||||
|
||||
mock_prisma.db.find_unique = _find_unique
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
|
||||
# A 5-day window that EXCLUDES the 90-day-old log, as the dashboard sends.
|
||||
|
|
@ -2399,9 +3023,8 @@ async def test_ui_view_spend_logs_request_id_owner_scoped_by_id_only(
|
|||
assert data["total"] == 1
|
||||
assert data["data"][0]["request_id"] == "req-old"
|
||||
assert "startTime" not in captured["where"]
|
||||
assert captured["where"]["request_id"] == "req-old"
|
||||
assert "user" not in captured["where"]
|
||||
assert "OR" not in captured["where"]
|
||||
assert captured["where"]["request_id_or_call_id"] == "req-old"
|
||||
assert captured["where"]["user"] == "user_1"
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
|
@ -5095,7 +5718,7 @@ async def test_view_spend_logs_internal_user_combines_user_with_request_id(
|
|||
where = mock_client.db.captured_where
|
||||
assert where is not None
|
||||
assert where["user"] == "internal-user-2"
|
||||
assert where["request_id"] == "req-abc"
|
||||
assert where["OR"] == ({"request_id": "req-abc"}, {"litellm_call_id": "req-abc"})
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
|
@ -5122,7 +5745,7 @@ async def test_view_spend_logs_non_date_range_combines_user_with_request_id(
|
|||
where = mock_client.db.captured_where
|
||||
assert where is not None
|
||||
assert where["user"] == "internal-user-3"
|
||||
assert where["request_id"] == "req-xyz"
|
||||
assert where["OR"] == ({"request_id": "req-xyz"}, {"litellm_call_id": "req-xyz"})
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
|
@ -7052,25 +7675,19 @@ async def test_ui_view_spend_logs_search_returns_flat_rows_when_grouping_by_sess
|
|||
|
||||
|
||||
def _fake_prisma_with_owned_spend_log(owner_user_id, messages_json, response_json):
|
||||
class _Row:
|
||||
user = owner_user_id
|
||||
team_id = None
|
||||
|
||||
class _SpendLogs:
|
||||
async def find_unique(self, where, include=None):
|
||||
return _Row()
|
||||
|
||||
class _DB:
|
||||
def __init__(self):
|
||||
self.litellm_spendlogs = _SpendLogs()
|
||||
|
||||
async def query_raw(self, _sql, *_args):
|
||||
async def query_raw(self, sql, *_args):
|
||||
if 'SELECT DISTINCT "user", team_id' in sql:
|
||||
return [{"user": owner_user_id, "team_id": None}]
|
||||
return [
|
||||
{
|
||||
"request_id": "req-owned-by-user-a",
|
||||
"messages": messages_json,
|
||||
"response": response_json,
|
||||
"proxy_server_request": "{}",
|
||||
"metadata": "{}",
|
||||
"user": owner_user_id,
|
||||
"team_id": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -7127,8 +7744,8 @@ def test_ui_view_request_response_internal_user_non_owner_forbidden(client, monk
|
|||
"""
|
||||
A different internal_user requesting someone else's row is forbidden;
|
||||
guards against _assert_user_can_view_request_id being skipped in the
|
||||
detail-drawer handler. Also proves the handler stops before it ever asks
|
||||
a custom logger or the DB for the payload.
|
||||
detail-drawer handler. Also proves the handler stops at the owner lookup,
|
||||
before it ever asks a custom logger or the DB for the payload.
|
||||
"""
|
||||
messages_json = json.dumps([{"role": "user", "content": "hi"}])
|
||||
response_json = json.dumps({"choices": [{"message": {"content": "hello"}}]})
|
||||
|
|
@ -7160,7 +7777,7 @@ def test_ui_view_request_response_internal_user_non_owner_forbidden(client, monk
|
|||
)
|
||||
assert response.status_code == 403
|
||||
assert custom_logger.requested_ids == []
|
||||
assert query_raw_calls == []
|
||||
assert [args[0] for args, _kwargs in query_raw_calls if "messages" in args[0]] == []
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
|
@ -7171,24 +7788,27 @@ def test_ui_view_request_response_internal_user_missing_row_forbidden(client, mo
|
|||
request_id with no spend-log row (e.g. pruned by retention) must be
|
||||
denied before the handler ever consults a custom logger, otherwise a
|
||||
non-admin who guesses/obtains a request_id could read another tenant's
|
||||
payload out of cold storage. Fails if `if row is None: return` is
|
||||
reintroduced.
|
||||
payload out of cold storage, and an existing payload that is not theirs
|
||||
would still confirm the id exists. Even a payload recorded as the caller's
|
||||
own is never fetched once the row is gone. Fails if an empty owner lookup
|
||||
is allowed to fall through to the loggers.
|
||||
"""
|
||||
|
||||
class _SpendLogs:
|
||||
async def find_unique(self, where, include=None):
|
||||
return None
|
||||
|
||||
class _DB:
|
||||
def __init__(self):
|
||||
self.litellm_spendlogs = _SpendLogs()
|
||||
async def query_raw(self, _sql, *_args):
|
||||
return []
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
fake_prisma = SimpleNamespace(db=_DB())
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", fake_prisma)
|
||||
|
||||
custom_logger = _RecordingAdditionalLoggingUtils({"messages": "should-not-be-returned"})
|
||||
custom_logger = _RecordingAdditionalLoggingUtils(
|
||||
{
|
||||
"messages": "should-not-be-returned",
|
||||
"metadata": {"user_api_key_user_id": "user_a", "user_api_key_team_id": None},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
litellm.logging_callback_manager,
|
||||
"get_active_additional_logging_utils_from_custom_logger",
|
||||
|
|
|
|||
|
|
@ -1329,6 +1329,33 @@ def test_get_logging_payload_includes_agent_id_from_kwargs():
|
|||
assert payload["agent_id"] == test_agent_id, f"Expected agent_id '{test_agent_id}', got '{payload.get('agent_id')}'"
|
||||
|
||||
|
||||
def test_get_logging_payload_populates_litellm_call_id_alongside_provider_request_id():
|
||||
"""
|
||||
LIT-6302: request_id stays the provider response id, so clients holding the
|
||||
x-litellm-call-id header value could never find their row. The payload now
|
||||
also carries litellm_call_id as its own column for lookups by either id.
|
||||
"""
|
||||
call_id = "b980eea9-5cd9-4099-93cd-8291e46c76fd"
|
||||
|
||||
payload = get_logging_payload(
|
||||
kwargs={
|
||||
"model": "gpt-4o-mini",
|
||||
"litellm_call_id": call_id,
|
||||
"litellm_params": {"metadata": {"user_api_key": "test-key"}},
|
||||
},
|
||||
response_obj=litellm.ModelResponse(
|
||||
id="chatcmpl-provider-id",
|
||||
choices=[],
|
||||
usage=litellm.Usage(prompt_tokens=1, completion_tokens=1, total_tokens=2),
|
||||
),
|
||||
start_time=datetime.datetime.now(timezone.utc),
|
||||
end_time=datetime.datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
assert payload["request_id"] == "chatcmpl-provider-id"
|
||||
assert payload["litellm_call_id"] == call_id
|
||||
|
||||
|
||||
@patch("litellm.proxy.proxy_server.master_key", None)
|
||||
@patch("litellm.proxy.proxy_server.general_settings", {})
|
||||
def test_get_logging_payload_includes_overhead_in_spend_logs_metadata():
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
|
|||
|
||||
import litellm
|
||||
from litellm._uuid import uuid
|
||||
from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY
|
||||
from litellm.constants import MAX_LITELLM_CALL_ID_LENGTH, RETURN_RAW_MODEL_NAME_METADATA_KEY
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.opentelemetry import UserAPIKeyAuth
|
||||
from litellm.proxy.common_request_processing import (
|
||||
|
|
@ -30,6 +30,7 @@ from litellm.proxy.common_request_processing import (
|
|||
_has_attribute_error_in_chain,
|
||||
_is_azure_model_router_request,
|
||||
open_sse_before_first_byte,
|
||||
resolve_litellm_call_id,
|
||||
ttft_keepalive_interval,
|
||||
_override_openai_response_model,
|
||||
_parse_event_data_for_error,
|
||||
|
|
@ -8060,6 +8061,19 @@ def test_log_llm_api_exception_traceback_only_for_unexpected_errors(exc, expect_
|
|||
assert (records[0].exc_info is not None) is expect_traceback
|
||||
|
||||
|
||||
class TestResolveLitellmCallId:
|
||||
def test_client_call_id_within_the_bound_is_kept(self):
|
||||
assert resolve_litellm_call_id("req-abc-123") == "req-abc-123"
|
||||
at_bound: Final = "y" * MAX_LITELLM_CALL_ID_LENGTH
|
||||
assert resolve_litellm_call_id(at_bound) == at_bound
|
||||
|
||||
@pytest.mark.parametrize("client_call_id", [None, "", "x" * (MAX_LITELLM_CALL_ID_LENGTH + 1), "z" * 3000])
|
||||
def test_missing_empty_or_oversized_client_call_id_gets_a_generated_uuid(self, client_call_id):
|
||||
resolved: Final = resolve_litellm_call_id(client_call_id)
|
||||
assert resolved != client_call_id
|
||||
assert uuid.UUID(resolved).version == 4
|
||||
|
||||
|
||||
class _FailureHookRecorder:
|
||||
"""Stands in for ProxyLogging.post_call_failure_hook, recording what the detached-failure closure hands it."""
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
|
||||
import type { LogEntry as SpendLogEntry } from "@/components/view_logs/columns";
|
||||
import { LogViewer } from "./LogViewer";
|
||||
|
||||
vi.mock("@/components/networking", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/components/networking")>();
|
||||
return { ...actual, uiSpendLogsCall: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("@/components/view_logs/LogDetailsDrawer", () => ({
|
||||
LogDetailsDrawer: function LogDetailsDrawerMock({
|
||||
open,
|
||||
logEntry,
|
||||
}: {
|
||||
open: boolean;
|
||||
logEntry?: { request_id: string } | null;
|
||||
}) {
|
||||
return (
|
||||
<div data-testid="log-details-drawer" data-log-id={logEntry?.request_id ?? ""}>
|
||||
{open ? "open" : "closed"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import { uiSpendLogsCall } from "@/components/networking";
|
||||
|
||||
const spendLog = (overrides: Partial<SpendLogEntry>): SpendLogEntry => ({
|
||||
request_id: "req-1",
|
||||
api_key: "key-1",
|
||||
team_id: "team-1",
|
||||
model: "gpt-4o",
|
||||
model_id: "model-1",
|
||||
call_type: "acompletion",
|
||||
spend: 0.01,
|
||||
total_tokens: 10,
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 5,
|
||||
startTime: "2026-09-02T09:50:13Z",
|
||||
endTime: "2026-09-02T09:50:14Z",
|
||||
cache_hit: "false",
|
||||
messages: [],
|
||||
response: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const guardrailLog = {
|
||||
id: "provider-victim",
|
||||
timestamp: "2026-09-02 09:50:13",
|
||||
action: "passed" as const,
|
||||
input_snippet: "victim prompt",
|
||||
};
|
||||
|
||||
describe("GuardrailsMonitor LogViewer drawer", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(uiSpendLogsCall).mockReset();
|
||||
testQueryClient.clear();
|
||||
});
|
||||
|
||||
it("opens the row whose request_id is the clicked log id even when a newer row carries that id as its call id", async () => {
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue({
|
||||
data: [
|
||||
spendLog({ request_id: "provider-attacker", litellm_call_id: "provider-victim" }),
|
||||
spendLog({ request_id: "provider-victim", litellm_call_id: "call-victim" }),
|
||||
],
|
||||
total: 2,
|
||||
});
|
||||
|
||||
renderWithProviders(<LogViewer logs={[guardrailLog]} accessToken="sk-test" />);
|
||||
await userEvent.click(screen.getByText("victim prompt"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("log-details-drawer")).toHaveAttribute("data-log-id", "provider-victim");
|
||||
});
|
||||
expect(vi.mocked(uiSpendLogsCall)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ params: { request_id: "provider-victim" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the first returned row when none carries the clicked id as its request_id", async () => {
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue({
|
||||
data: [spendLog({ request_id: "provider-other", litellm_call_id: "provider-victim" })],
|
||||
total: 1,
|
||||
});
|
||||
|
||||
renderWithProviders(<LogViewer logs={[guardrailLog]} accessToken="sk-test" />);
|
||||
await userEvent.click(screen.getByText("victim prompt"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("log-details-drawer")).toHaveAttribute("data-log-id", "provider-other");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -92,7 +92,8 @@ export function LogViewer({
|
|||
enabled: Boolean(accessToken && selectedRequestId && drawerOpen),
|
||||
});
|
||||
|
||||
const selectedLog: ViewLogsLogEntry | null = fullLogResponse?.data?.[0] ?? null;
|
||||
const selectedLog: ViewLogsLogEntry | null =
|
||||
fullLogResponse?.data?.find((log) => log.request_id === selectedRequestId) ?? fullLogResponse?.data?.[0] ?? null;
|
||||
|
||||
const handleLogClick = (log: LogEntry) => {
|
||||
setSelectedRequestId(log.id);
|
||||
|
|
|
|||
|
|
@ -577,6 +577,49 @@ describe("RequestLogsPanel", () => {
|
|||
expect(byIdCall.params?.group_by_session).toBeUndefined();
|
||||
});
|
||||
|
||||
it("opens the drawer when ?log_id= is the log's litellm_call_id rather than its request_id", async () => {
|
||||
respondWith([logEntry({ request_id: "chatcmpl-provider", litellm_call_id: "call-1" })]);
|
||||
renderPanel("?log_id=call-1");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(drawer()).toHaveTextContent("open");
|
||||
});
|
||||
expect(drawer()).toHaveAttribute("data-log-id", "chatcmpl-provider");
|
||||
});
|
||||
|
||||
it("fetches by litellm_call_id and opens the drawer when that log is not in the loaded page", async () => {
|
||||
vi.mocked(uiSpendLogsCall).mockImplementation(async ({ params }) =>
|
||||
params?.request_id === "call-old"
|
||||
? {
|
||||
data: [logEntry({ request_id: "chatcmpl-old", litellm_call_id: "call-old" })],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 1,
|
||||
total_pages: 1,
|
||||
}
|
||||
: { data: [], total: 0, page: 1, page_size: 50, total_pages: 0 },
|
||||
);
|
||||
renderPanel("?log_id=call-old");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(drawer()).toHaveTextContent("open");
|
||||
});
|
||||
expect(drawer()).toHaveAttribute("data-log-id", "chatcmpl-old");
|
||||
});
|
||||
|
||||
it("opens the exact request_id row when another log in the page carries that id as its litellm_call_id", async () => {
|
||||
respondWith([
|
||||
logEntry({ request_id: "chatcmpl-other", litellm_call_id: "victim-req" }),
|
||||
logEntry({ request_id: "victim-req", litellm_call_id: "victim-call" }),
|
||||
]);
|
||||
renderPanel("?log_id=victim-req");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(drawer()).toHaveTextContent("open");
|
||||
});
|
||||
expect(drawer()).toHaveAttribute("data-log-id", "victim-req");
|
||||
});
|
||||
|
||||
it("closing the drawer removes ?log_id= from the URL and closes the drawer", async () => {
|
||||
const user = userEvent.setup();
|
||||
respondWith([logEntry({ request_id: "req-1" })]);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ import { RequestLogsTable } from "./RequestLogsTable";
|
|||
|
||||
const PAGE_SIZE = DEFAULT_PAGE_SIZE_OPTIONS[0];
|
||||
const DEFAULT_INTERVAL = { value: 24, unit: "hours" };
|
||||
const matchesLogId = (log: LogEntry, logId: string) => log.request_id === logId || log.litellm_call_id === logId;
|
||||
const findLogById = (logs: readonly LogEntry[], logId: string): LogEntry | null =>
|
||||
logs.find((log) => log.request_id === logId) ?? logs.find((log) => log.litellm_call_id === logId) ?? null;
|
||||
|
||||
interface RequestLogsPanelProps {
|
||||
accessToken: string;
|
||||
|
|
@ -141,9 +144,9 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
|
|||
page_size: 1,
|
||||
params: { request_id: urlLogId },
|
||||
});
|
||||
return response.data.find((log) => log.request_id === urlLogId) ?? null;
|
||||
return findLogById(response.data, urlLogId);
|
||||
},
|
||||
enabled: urlLogId !== null && selectedLog?.request_id !== urlLogId,
|
||||
enabled: urlLogId !== null && !(selectedLog !== null && matchesLogId(selectedLog, urlLogId)),
|
||||
staleTime: Infinity,
|
||||
};
|
||||
|
||||
|
|
@ -151,8 +154,8 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
|
|||
|
||||
const displayLog = useMemo<LogEntry | null>(() => {
|
||||
if (urlLogId === null) return null;
|
||||
if (selectedLog?.request_id === urlLogId) return selectedLog;
|
||||
return filteredLogs.data.find((log) => log.request_id === urlLogId) ?? urlLog ?? null;
|
||||
if (selectedLog !== null && matchesLogId(selectedLog, urlLogId)) return selectedLog;
|
||||
return findLogById(filteredLogs.data, urlLogId) ?? urlLog ?? null;
|
||||
}, [urlLogId, selectedLog, filteredLogs.data, urlLog]);
|
||||
|
||||
const displaySessionId = useMemo<string | null>(() => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type LogsSortField = keyof typeof LOGS_SORT_FIELD_MAP;
|
|||
|
||||
export type LogEntry = {
|
||||
request_id: string;
|
||||
litellm_call_id?: string | null;
|
||||
api_key: string;
|
||||
team_id: string;
|
||||
model: string;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue