merge: resolve conflict with litellm_internal_staging (ruff budget ceilings)

This commit is contained in:
Yucheng Zhu 2026-07-27 16:51:24 -07:00
commit 1042b56d2f
121 changed files with 7164 additions and 3239 deletions

View file

@ -104,6 +104,7 @@ jobs:
- name: Check basedpyright budget (delta vs base)
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
NODE_OPTIONS: --max-old-space-size=12288
run: |
(uv run --no-sync basedpyright --outputjson || true) | uv run --no-sync python scripts/type_check_gate.py --base "$BASE_SHA"

View file

@ -1,9 +1,9 @@
{
"reportAny": {
"limit": 37484
"limit": 34906
},
"reportArgumentType": {
"limit": 2704
"limit": 2701
},
"reportAssignmentType": {
"limit": 330
@ -12,7 +12,7 @@
"limit": 516
},
"reportCallIssue": {
"limit": 124
"limit": 123
},
"reportConstantRedefinition": {
"limit": 59
@ -24,7 +24,7 @@
"limit": 42
},
"reportExplicitAny": {
"limit": 10389
"limit": 10230
},
"reportFunctionMemberAccess": {
"limit": 11
@ -54,10 +54,10 @@
"limit": 0
},
"reportMissingParameterType": {
"limit": 5900
"limit": 5893
},
"reportMissingTypeArgument": {
"limit": 15903
"limit": 15886
},
"reportMissingTypeStubs": {
"limit": 41
@ -99,31 +99,31 @@
"limit": 0
},
"reportUnknownArgumentType": {
"limit": 45894
"limit": 45870
},
"reportUnknownLambdaType": {
"limit": 113
},
"reportUnknownMemberType": {
"limit": 40539
"limit": 40525
},
"reportUnknownParameterType": {
"limit": 20403
"limit": 20384
},
"reportUnknownVariableType": {
"limit": 32141
"limit": 32099
},
"reportUnnecessaryCast": {
"limit": 177
},
"reportUnnecessaryComparison": {
"limit": 1025
"limit": 1023
},
"reportUnnecessaryContains": {
"limit": 7
},
"reportUnnecessaryIsInstance": {
"limit": 1209
"limit": 1206
},
"reportUntypedBaseClass": {
"limit": 165

View file

@ -0,0 +1,46 @@
-- One-shot backfill of the LiteLLM_DailyToolSpend rollup from the per-request
-- LiteLLM_SpendLogToolIndex x LiteLLM_SpendLogs tables.
--
-- This is an opt-in, manual operation. New deployments do not need it: the
-- rollup is written at request time from the moment the release is deployed.
-- Run it only if you want the Cost Optimization "Spend by tool" card to show
-- history from before the deploy, and only once.
--
-- IMPORTANT caveats before running:
--
-- 1. Pre-deploy index rows may include tools that were merely DECLARED in a
-- request body but never invoked (the release this ships with stops
-- recording those). For agentic clients that declare many tools per
-- request, backfilled history attributes each request's full spend to
-- every declared tool, overstating per-tool spend. Post-deploy rows do not
-- have this problem. If your traffic is mostly such clients, consider not
-- backfilling.
--
-- 2. Coverage is bounded by spend-log retention: rows older than
-- maximum_spend_logs_retention_period are already gone.
--
-- 3. Replace the cutover timestamp below with the time you deployed the
-- release, so backfilled per-request rows cannot double-count on top of
-- rollup rows the new writer already created. ON CONFLICT DO NOTHING is a
-- second guard for (date, tool_name) buckets the writer already touched:
-- such buckets keep the writer's numbers and skip the backfill's.
--
-- Usage:
-- psql "$DATABASE_URL" -v cutover="'2026-07-25T00:00:00Z'" -f db_scripts/backfill_daily_tool_spend.sql
SET TIME ZONE 'UTC';
INSERT INTO "LiteLLM_DailyToolSpend" (date, tool_name, spend, total_tokens, request_count, created_at, updated_at)
SELECT
to_char(ti.start_time, 'YYYY-MM-DD') AS date,
ti.tool_name,
COALESCE(SUM(sl.spend), 0) AS spend,
COALESCE(SUM(sl.total_tokens), 0) AS total_tokens,
COUNT(*) AS request_count,
now() AS created_at,
now() AS updated_at
FROM "LiteLLM_SpendLogToolIndex" ti
JOIN "LiteLLM_SpendLogs" sl ON sl.request_id = ti.request_id
WHERE ti.start_time < :cutover::timestamptz
GROUP BY 1, 2
ON CONFLICT (date, tool_name) DO NOTHING;

View file

@ -11,7 +11,8 @@ Endpoints for /project operations
#### PROJECT MANAGEMENT ####
import json
from typing import List, Optional, Union
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING
from fastapi import APIRouter, Depends, HTTPException, Request
@ -25,15 +26,24 @@ from litellm.proxy.management_helpers.utils import (
)
from litellm.proxy.utils import PrismaClient, handle_exception_on_proxy
if TYPE_CHECKING:
from prisma import models as prisma_models
from prisma.actions import LiteLLM_TeamTableActions
router = APIRouter()
def _team_table(prisma_client: PrismaClient) -> "LiteLLM_TeamTableActions[prisma_models.LiteLLM_TeamTable]":
team_table: LiteLLM_TeamTableActions[prisma_models.LiteLLM_TeamTable] = prisma_client.db.litellm_teamtable
return team_table
async def _check_user_permission_for_project(
user_api_key_dict: UserAPIKeyAuth,
team_id: Optional[str],
team_id: str | None,
prisma_client: PrismaClient,
require_admin: bool = False,
team_object: Optional[LiteLLM_TeamTable] = None,
team_object: LiteLLM_TeamTable | None = None,
) -> bool:
"""
Check if user has permission to manage a project.
@ -57,9 +67,7 @@ async def _check_user_permission_for_project(
team = team_object
if team is None:
team = await prisma_client.db.litellm_teamtable.find_unique(
where={"team_id": team_id}
)
team = await _team_table(prisma_client).find_unique(where={"team_id": team_id})
if team and team.admins:
return user_api_key_dict.user_id in team.admins
@ -70,9 +78,9 @@ async def _check_user_permission_for_project(
async def _validate_team_exists(
team_id: str,
prisma_client: PrismaClient,
):
) -> "prisma_models.LiteLLM_TeamTable":
"""Validate that a team exists. Returns the team row."""
team = await prisma_client.db.litellm_teamtable.find_unique(
team = await _team_table(prisma_client).find_unique(
where={"team_id": team_id},
)
@ -89,7 +97,7 @@ async def _validate_team_exists(
def _check_team_project_limits(
team_object: LiteLLM_TeamTable,
data: Union[NewProjectRequest, UpdateProjectRequest],
data: NewProjectRequest | UpdateProjectRequest,
) -> None:
"""
Check that project limits respect its parent Team's limits.
@ -108,16 +116,12 @@ def _check_team_project_limits(
if data.max_budget is not None and data.max_budget < 0:
raise HTTPException(
status_code=400,
detail={
"error": f"max_budget cannot be negative. Received: {data.max_budget}"
},
detail={"error": f"max_budget cannot be negative. Received: {data.max_budget}"},
)
if data.soft_budget is not None and data.soft_budget < 0:
raise HTTPException(
status_code=400,
detail={
"error": f"soft_budget cannot be negative. Received: {data.soft_budget}"
},
detail={"error": f"soft_budget cannot be negative. Received: {data.soft_budget}"},
)
# --- soft_budget < max_budget ---
@ -131,7 +135,7 @@ def _check_team_project_limits(
)
# --- Validate project models are a subset of team models ---
project_models = getattr(data, "models", None)
project_models = data.models
team_models = team_object.models or []
if project_models and len(team_models) > 0:
# If team has 'all-proxy-models', skip validation as it allows all models
@ -148,11 +152,7 @@ def _check_team_project_limits(
# --- Validate project max_budget <= team max_budget ---
# Team stores budget fields directly (max_budget, tpm_limit, rpm_limit)
# unlike Project which uses a separate LiteLLM_BudgetTable relation
if (
data.max_budget is not None
and team_object.max_budget is not None
and data.max_budget > team_object.max_budget
):
if data.max_budget is not None and team_object.max_budget is not None and data.max_budget > team_object.max_budget:
raise HTTPException(
status_code=400,
detail={
@ -161,11 +161,7 @@ def _check_team_project_limits(
)
# --- Validate project tpm_limit <= team tpm_limit ---
if (
data.tpm_limit is not None
and team_object.tpm_limit is not None
and data.tpm_limit > team_object.tpm_limit
):
if data.tpm_limit is not None and team_object.tpm_limit is not None and data.tpm_limit > team_object.tpm_limit:
raise HTTPException(
status_code=400,
detail={
@ -174,11 +170,7 @@ def _check_team_project_limits(
)
# --- Validate project rpm_limit <= team rpm_limit ---
if (
data.rpm_limit is not None
and team_object.rpm_limit is not None
and data.rpm_limit > team_object.rpm_limit
):
if data.rpm_limit is not None and team_object.rpm_limit is not None and data.rpm_limit > team_object.rpm_limit:
raise HTTPException(
status_code=400,
detail={
@ -189,19 +181,19 @@ def _check_team_project_limits(
async def _create_budget_for_project(
data: NewProjectRequest,
user_id: Optional[str],
user_id: str | None,
litellm_proxy_admin_name: str,
prisma_client: PrismaClient,
) -> str:
"""Create a budget for the project and return budget_id."""
budget_params = LiteLLM_BudgetTable.model_fields.keys()
_json_data = data.json(exclude_none=True)
_json_data: Mapping[str, object] = data.json(exclude_none=True)
_budget_data = {k: v for k, v in _json_data.items() if k in budget_params}
budget_row = LiteLLM_BudgetTable(**_budget_data)
budget_row = LiteLLM_BudgetTable.model_validate(_budget_data)
new_budget = prisma_client.jsonify_object(budget_row.json(exclude_none=True))
_budget = await prisma_client.db.litellm_budgettable.create(
_budget: prisma_models.LiteLLM_BudgetTable = await prisma_client.db.litellm_budgettable.create(
data={
**new_budget,
"created_by": user_id or litellm_proxy_admin_name,
@ -214,8 +206,8 @@ async def _create_budget_for_project(
async def _set_project_object_permission(
data: NewProjectRequest,
prisma_client: Optional[PrismaClient],
) -> Optional[str]:
prisma_client: PrismaClient | None,
) -> str | None:
"""
Creates the LiteLLM_ObjectPermissionTable record for the project.
Returns the object_permission_id if created, otherwise None.
@ -224,7 +216,7 @@ async def _set_project_object_permission(
return None
if data.object_permission is not None:
created_object_permission = (
created_object_permission: prisma_models.LiteLLM_ObjectPermissionTable = (
await prisma_client.db.litellm_objectpermissiontable.create(
data=data.object_permission.model_dump(exclude_none=True),
)
@ -344,8 +336,7 @@ async def new_project(
raise HTTPException(
status_code=403,
detail={
"error": "Only premium users can add tags to projects. "
+ CommonProxyErrors.not_premium_user.value
"error": "Only premium users can add tags to projects. " + CommonProxyErrors.not_premium_user.value
},
)
@ -353,8 +344,7 @@ async def new_project(
raise HTTPException(
status_code=403,
detail={
"error": "Project management is an enterprise feature. "
+ CommonProxyErrors.not_premium_user.value
"error": "Project management is an enterprise feature. " + CommonProxyErrors.not_premium_user.value
},
)
@ -375,13 +365,11 @@ async def new_project(
)
# Validate team exists and get team object with budget
team_object = await _validate_team_exists(
team_id=data.team_id, prisma_client=prisma_client
)
team_object = await _validate_team_exists(team_id=data.team_id, prisma_client=prisma_client)
# Validate project limits against team limits
_check_team_project_limits(
team_object=LiteLLM_TeamTable(**team_object.model_dump()),
team_object=LiteLLM_TeamTable.model_validate(team_object.model_dump()),
data=data,
)
@ -391,7 +379,7 @@ async def new_project(
user_api_key_dict=user_api_key_dict,
team_id=data.team_id,
prisma_client=prisma_client,
team_object=LiteLLM_TeamTable(**team_object.model_dump()),
team_object=LiteLLM_TeamTable.model_validate(team_object.model_dump()),
)
if not has_permission:
@ -449,17 +437,13 @@ async def new_project(
value=getattr(data, field),
)
new_project_row = prisma_client.jsonify_object(
project_row.json(exclude_none=True)
)
new_project_row = prisma_client.jsonify_object(project_row.json(exclude_none=True))
# Remove budget fields (following organization_endpoints.py pattern)
new_project_row = _remove_budget_fields_from_project_data(new_project_row)
verbose_proxy_logger.info(
f"new_project_row: {json.dumps(new_project_row, indent=2)}"
)
response = await prisma_client.db.litellm_projecttable.create(
verbose_proxy_logger.info(f"new_project_row: {json.dumps(new_project_row, indent=2)}")
response: prisma_models.LiteLLM_ProjectTable = await prisma_client.db.litellm_projecttable.create(
data={
**new_project_row, # type: ignore
},
@ -469,9 +453,7 @@ async def new_project(
return response
except Exception as e:
verbose_proxy_logger.exception(
"litellm.proxy.management_endpoints.project_endpoints.new_project(): Exception occured - {}".format(
str(e)
)
"litellm.proxy.management_endpoints.project_endpoints.new_project(): Exception occured - {}".format(str(e))
)
raise handle_exception_on_proxy(e)
@ -539,8 +521,7 @@ async def update_project(
raise HTTPException(
status_code=403,
detail={
"error": "Only premium users can add tags to projects. "
+ CommonProxyErrors.not_premium_user.value
"error": "Only premium users can add tags to projects. " + CommonProxyErrors.not_premium_user.value
},
)
@ -548,8 +529,7 @@ async def update_project(
raise HTTPException(
status_code=403,
detail={
"error": "Project management is an enterprise feature. "
+ CommonProxyErrors.not_premium_user.value
"error": "Project management is an enterprise feature. " + CommonProxyErrors.not_premium_user.value
},
)
@ -576,9 +556,9 @@ async def update_project(
)
# Fetch existing project
existing_project = await prisma_client.db.litellm_projecttable.find_unique(
where={"project_id": data.project_id}
)
existing_project: (
prisma_models.LiteLLM_ProjectTable | None
) = await prisma_client.db.litellm_projecttable.find_unique(where={"project_id": data.project_id})
if existing_project is None:
raise ProxyException(
@ -595,9 +575,7 @@ async def update_project(
target_team_id = data.team_id or existing_project.team_id
target_team_obj = None
if target_team_id is not None:
target_team_obj = await _validate_team_exists(
team_id=target_team_id, prisma_client=prisma_client
)
target_team_obj = await _validate_team_exists(team_id=target_team_id, prisma_client=prisma_client)
has_permission = await _check_user_permission_for_project(
user_api_key_dict=user_api_key_dict,
@ -620,32 +598,26 @@ async def update_project(
team_id=data.team_id,
prisma_client=prisma_client,
team_object=(
LiteLLM_TeamTable(**target_team_obj.model_dump())
if target_team_obj
else None
LiteLLM_TeamTable.model_validate(target_team_obj.model_dump()) if target_team_obj else None
),
)
if not can_assign_to_target:
raise HTTPException(
status_code=403,
detail={
"error": "Cannot reassign project to a team you are not an admin of"
},
detail={"error": "Cannot reassign project to a team you are not an admin of"},
)
# Validate project limits against team limits
if target_team_obj is not None:
_check_team_project_limits(
team_object=LiteLLM_TeamTable(**target_team_obj.model_dump()),
team_object=LiteLLM_TeamTable.model_validate(target_team_obj.model_dump()),
data=data,
)
# Prepare update data
update_data = data.json(exclude_none=True, exclude={"project_id"})
update_data = prisma_client.jsonify_object(update_data)
update_data["updated_by"] = (
user_api_key_dict.user_id or litellm_proxy_admin_name
)
update_data["updated_by"] = user_api_key_dict.user_id or litellm_proxy_admin_name
# Handle budget updates
budget_fields = LiteLLM_BudgetTable.model_fields.keys()
@ -671,21 +643,17 @@ async def update_project(
if existing_project.object_permission_id:
# Update existing permission
await prisma_client.db.litellm_objectpermissiontable.update(
where={
"object_permission_id": existing_project.object_permission_id
},
where={"object_permission_id": existing_project.object_permission_id},
data=object_permission_data,
)
else:
# Create new permission
created_permission = (
created_permission: prisma_models.LiteLLM_ObjectPermissionTable = (
await prisma_client.db.litellm_objectpermissiontable.create(
data=object_permission_data,
)
)
update_data["object_permission_id"] = (
created_permission.object_permission_id
)
update_data["object_permission_id"] = created_permission.object_permission_id
# Handle metadata fields
for field in LiteLLM_ManagementEndpoint_MetadataFields:
@ -698,7 +666,7 @@ async def update_project(
update_data = _remove_budget_fields_from_project_data(update_data)
# Update project
updated_project = await prisma_client.db.litellm_projecttable.update(
updated_project: prisma_models.LiteLLM_ProjectTable | None = await prisma_client.db.litellm_projecttable.update(
where={"project_id": data.project_id},
data=update_data,
include={"litellm_budget_table": True, "object_permission": True},
@ -718,7 +686,7 @@ async def update_project(
"/project/delete",
tags=["project management"],
dependencies=[Depends(user_api_key_auth)],
response_model=List[LiteLLM_ProjectTable],
response_model=list[LiteLLM_ProjectTable],
)
@management_endpoint_wrapper
async def delete_project(
@ -749,8 +717,7 @@ async def delete_project(
raise HTTPException(
status_code=403,
detail={
"error": "Project management is an enterprise feature. "
+ CommonProxyErrors.not_premium_user.value
"error": "Project management is an enterprise feature. " + CommonProxyErrors.not_premium_user.value
},
)
@ -778,9 +745,7 @@ async def delete_project(
for project_id in data.project_ids:
# Check if project exists
existing_project = await prisma_client.db.litellm_projecttable.find_unique(
where={"project_id": project_id}
)
existing_project = await prisma_client.db.litellm_projecttable.find_unique(where={"project_id": project_id})
if existing_project is None:
raise ProxyException(
@ -791,11 +756,9 @@ async def delete_project(
)
# Check if there are any keys associated with this project
associated_keys = (
await prisma_client.db.litellm_verificationtoken.find_many(
where={"project_id": project_id}
)
)
associated_keys: Sequence[
prisma_models.LiteLLM_VerificationToken
] = await prisma_client.db.litellm_verificationtoken.find_many(where={"project_id": project_id})
if len(associated_keys) > 0:
raise ProxyException(
@ -806,9 +769,9 @@ async def delete_project(
)
# Delete the project
deleted_project = await prisma_client.db.litellm_projecttable.delete(
where={"project_id": project_id}
)
deleted_project: (
prisma_models.LiteLLM_ProjectTable | None
) = await prisma_client.db.litellm_projecttable.delete(where={"project_id": project_id})
deleted_projects.append(deleted_project)
@ -854,7 +817,7 @@ async def project_info(
)
# Fetch project
project = await prisma_client.db.litellm_projecttable.find_unique(
project: prisma_models.LiteLLM_ProjectTable | None = await prisma_client.db.litellm_projecttable.find_unique(
where={"project_id": project_id},
include={"litellm_budget_table": True, "object_permission": True},
)
@ -872,17 +835,11 @@ async def project_info(
is_team_member = False
if project.team_id and user_api_key_dict.user_id:
team = await prisma_client.db.litellm_teamtable.find_unique(
where={"team_id": project.team_id}
)
team = await _team_table(prisma_client).find_unique(where={"team_id": project.team_id})
if team:
caller_user_id = user_api_key_dict.user_id
for m in team.members_with_roles or []:
m_user_id = (
m.get("user_id")
if isinstance(m, dict)
else getattr(m, "user_id", None)
)
m_user_id = m.get("user_id") if isinstance(m, dict) else getattr(m, "user_id", None)
if m_user_id == caller_user_id:
is_team_member = True
break
@ -896,9 +853,7 @@ async def project_info(
return project
except Exception as e:
verbose_proxy_logger.exception(
"litellm.proxy.management_endpoints.project_endpoints.project_info(): Exception occured - {}".format(
str(e)
)
"litellm.proxy.management_endpoints.project_endpoints.project_info(): Exception occured - {}".format(str(e))
)
raise handle_exception_on_proxy(e)
@ -907,7 +862,7 @@ async def project_info(
"/project/list",
tags=["project management"],
dependencies=[Depends(user_api_key_auth)],
response_model=List[LiteLLM_ProjectTable],
response_model=list[LiteLLM_ProjectTable],
)
async def list_projects(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
@ -932,21 +887,19 @@ async def list_projects(
# If proxy admin, get all projects
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
projects = await prisma_client.db.litellm_projecttable.find_many(
projects: Sequence[
prisma_models.LiteLLM_ProjectTable
] = await prisma_client.db.litellm_projecttable.find_many(
include={"litellm_budget_table": True, "object_permission": True}
)
else:
# Look up the user's team memberships via the reverse-index on
# LiteLLM_UserTable.teams (maintained by team_member_add alongside
# members_with_roles). This avoids a full scan of all team rows.
user_record = await prisma_client.db.litellm_usertable.find_unique(
user_record: prisma_models.LiteLLM_UserTable | None = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_api_key_dict.user_id},
)
user_team_ids = (
user_record.teams
if user_record is not None and user_record.teams
else []
)
user_team_ids: Sequence[str] = user_record.teams if user_record is not None and user_record.teams else []
projects = await prisma_client.db.litellm_projecttable.find_many(
where={"team_id": {"in": user_team_ids}},

View file

@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "LiteLLM_DailyToolSpend" (
"date" TEXT NOT NULL,
"tool_name" TEXT NOT NULL,
"spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
"total_tokens" BIGINT NOT NULL DEFAULT 0,
"request_count" BIGINT NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LiteLLM_DailyToolSpend_pkey" PRIMARY KEY ("date","tool_name")
);

View file

@ -1102,6 +1102,19 @@ model LiteLLM_SpendLogToolIndex {
@@index([start_time])
}
// Daily tool spend rollup (one row per tool per day) the Cost Optimization card reads this, never SpendLogs
model LiteLLM_DailyToolSpend {
date String
tool_name String
spend Float @default(0.0)
total_tokens BigInt @default(0)
request_count BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@id([date, tool_name])
}
// Prompt table for storing prompt configurations
model LiteLLM_PromptTable {
id String @id @default(uuid())

View file

@ -209,7 +209,15 @@ class ResponsesToCompletionBridgeHandler:
json_mode=kwargs.get("json_mode"),
)
elif isinstance(result, ModelResponse):
return result
if not stream:
return result
return self._completed_response_as_stream(
response=result,
model=model,
custom_llm_provider=custom_llm_provider,
logging_obj=logging_obj,
json_mode=kwargs.get("json_mode"),
)
elif not stream:
responses_api_response = self._collect_response_from_stream(result)
return self.transformation_handler.transform_response(
@ -299,7 +307,15 @@ class ResponsesToCompletionBridgeHandler:
json_mode=kwargs.get("json_mode"),
)
elif isinstance(result, ModelResponse):
return result
if not stream:
return result
return self._completed_response_as_stream(
response=result,
model=model,
custom_llm_provider=custom_llm_provider,
logging_obj=logging_obj,
json_mode=kwargs.get("json_mode"),
)
elif not stream:
responses_api_response = await self._collect_response_from_stream_async(result)
return self.transformation_handler.transform_response(
@ -331,6 +347,25 @@ class ResponsesToCompletionBridgeHandler:
)
return self._apply_post_stream_processing(streamwrapper, model, custom_llm_provider)
def _completed_response_as_stream(
self,
response: "ModelResponse",
model: str,
custom_llm_provider: str,
logging_obj: "LiteLLMLoggingObj",
json_mode: bool | None,
) -> "CustomStreamWrapper":
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
from litellm.llms.base_llm.base_model_iterator import MockResponseIterator
streamwrapper = CustomStreamWrapper(
completion_stream=MockResponseIterator(model_response=response, json_mode=json_mode),
model=model,
custom_llm_provider=custom_llm_provider,
logging_obj=logging_obj,
)
return self._apply_post_stream_processing(streamwrapper, model, custom_llm_provider)
@staticmethod
def _apply_post_stream_processing(
stream: "CustomStreamWrapper",

View file

@ -1077,6 +1077,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
def __init__(self, streaming_response, sync_stream: bool, json_mode: Optional[bool] = False):
super().__init__(streaming_response, sync_stream, json_mode)
self._chat_completion_id: str | None = None
def _handle_string_chunk(
self, str_line: Union[str, "BaseModel"]
@ -1384,4 +1385,13 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
ModelResponseStream: OpenAI-formatted streaming chunk
"""
verbose_logger.debug(f"Chat provider: transform_streaming_response called with chunk: {chunk}")
return OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(chunk)
return self._with_stream_scoped_id(
OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(chunk)
)
def _with_stream_scoped_id(self, chunk: "ModelResponseStream") -> "ModelResponseStream":
if self._chat_completion_id is None:
self._chat_completion_id = chunk.id
else:
chunk.id = self._chat_completion_id
return chunk

View file

@ -1462,7 +1462,7 @@ SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES = int(os.getenv("SPEND_LOG_CLEA
SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS = float(
os.getenv("SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS", 0.5)
)
TOOL_SPEND_MAX_WINDOW_DAYS = 30
TOOL_SPEND_TOP_TOOLS = 100
SPEND_LOG_PARTITION_INTERVAL = os.getenv("SPEND_LOG_PARTITION_INTERVAL", "day")
SPEND_LOG_PARTITION_PRECREATE_AHEAD = int(os.getenv("SPEND_LOG_PARTITION_PRECREATE_AHEAD", 7))
SPEND_LOG_QUEUE_SIZE_THRESHOLD = int(os.getenv("SPEND_LOG_QUEUE_SIZE_THRESHOLD", 100))

View file

@ -133,6 +133,15 @@ class LangsmithLogger(CustomBatchLogger):
"dotted_order": metadata.get("dotted_order", None),
}
def _redact_metadata(self, metadata: dict) -> dict:
# helper is shallow; also scrub nested requester_metadata since
# LangSmith forwards the whole dict into the run
redacted = redact_user_api_key_info(metadata=dict(metadata))
nested = redacted.get("requester_metadata")
if isinstance(nested, dict):
redacted["requester_metadata"] = redact_user_api_key_info(metadata=nested)
return redacted
def _build_extra_metadata(self, metadata: Dict):
extra_metadata = dict(metadata)
requester_metadata = extra_metadata.get("requester_metadata")
@ -141,13 +150,7 @@ class LangsmithLogger(CustomBatchLogger):
if key in requester_metadata and key not in extra_metadata:
extra_metadata[key] = requester_metadata[key]
# helper is shallow; also scrub nested requester_metadata since
# LangSmith forwards the whole dict into `extra`
extra_metadata = redact_user_api_key_info(metadata=extra_metadata)
nested = extra_metadata.get("requester_metadata")
if isinstance(nested, dict):
extra_metadata["requester_metadata"] = redact_user_api_key_info(metadata=nested)
return extra_metadata
return self._redact_metadata(extra_metadata)
def _build_outputs_with_usage(self, payload: StandardLoggingPayload) -> Dict[str, Any]:
response = payload["response"]
@ -200,12 +203,13 @@ class LangsmithLogger(CustomBatchLogger):
metadata = payload["metadata"]
extra_metadata = self._build_extra_metadata(dict(metadata))
inputs = {**payload, "metadata": self._redact_metadata(dict(metadata))}
outputs = self._build_outputs_with_usage(payload)
data = {
"name": fields["run_name"],
"run_type": "llm",
"inputs": payload,
"inputs": inputs,
"outputs": outputs,
"session_name": fields["project_name"],
"start_time": payload["startTime"],

View file

@ -16,6 +16,7 @@ from typing import (
Dict,
List,
Literal,
Mapping,
Optional,
Sequence,
Tuple,
@ -1449,6 +1450,8 @@ class PrometheusLogger(CustomLogger):
prompt_details = usage_object.get("prompt_tokens_details") or {}
completion_details = usage_object.get("completion_tokens_details") or {}
cache_creation_detail_tokens = PrometheusLogger._resolve_cache_write_tokens(prompt_details)
detail_metrics: List[Tuple[Any, DEFINED_PROMETHEUS_METRICS, Any]] = [
(
self.litellm_input_cached_tokens_metric,
@ -1458,7 +1461,7 @@ class PrometheusLogger(CustomLogger):
(
self.litellm_input_cache_creation_tokens_metric,
"litellm_input_cache_creation_tokens_metric",
(prompt_details.get("cache_creation_tokens") if isinstance(prompt_details, dict) else None),
cache_creation_detail_tokens,
),
(
self.litellm_input_audio_tokens_metric,
@ -1597,27 +1600,12 @@ class PrometheusLogger(CustomLogger):
)
# Provider prompt caching metrics are independent of LiteLLM cache_hit.
provider_cache_read_tokens = 0
provider_cache_creation_tokens = 0
usage_obj = (standard_logging_payload.get("metadata", {}) or {}).get("usage_object")
if isinstance(usage_obj, dict):
# Prefer explicit provider cache fields when available.
_read = usage_obj.get("cache_read_input_tokens")
_write = usage_obj.get("cache_creation_input_tokens")
if isinstance(_read, int):
provider_cache_read_tokens = _read
if isinstance(_write, int):
provider_cache_creation_tokens = _write
# Fallback to prompt_tokens_details.cached_tokens (common normalization point).
# Only fallback when the explicit field is genuinely absent (None).
if _read is None:
prompt_details = usage_obj.get("prompt_tokens_details")
if isinstance(prompt_details, dict):
cached_tokens = prompt_details.get("cached_tokens")
if isinstance(cached_tokens, int):
provider_cache_read_tokens = cached_tokens
(
provider_cache_read_tokens,
provider_cache_creation_tokens,
) = PrometheusLogger._resolve_provider_cache_tokens(usage_obj)
if provider_cache_read_tokens > 0:
PrometheusLogger._inc_labeled_counter(
@ -1639,6 +1627,40 @@ class PrometheusLogger(CustomLogger):
amount=float(provider_cache_creation_tokens),
)
@staticmethod
def _resolve_provider_cache_tokens(usage_obj: Mapping[str, object]) -> tuple[int, int]:
# Prefer explicit provider cache fields when available.
_read = usage_obj.get("cache_read_input_tokens")
_write = usage_obj.get("cache_creation_input_tokens")
provider_cache_read_tokens = _read if isinstance(_read, int) else 0
provider_cache_creation_tokens = _write if isinstance(_write, int) else 0
# Fallback to prompt_tokens_details (common normalization point).
# Only fallback when the explicit field is genuinely absent (None).
prompt_details = usage_obj.get("prompt_tokens_details")
if _read is None and isinstance(prompt_details, dict):
cached_tokens = prompt_details.get("cached_tokens")
if isinstance(cached_tokens, int):
provider_cache_read_tokens = cached_tokens
if _write is None:
write_tokens = PrometheusLogger._resolve_cache_write_tokens(prompt_details)
if write_tokens is not None:
provider_cache_creation_tokens = write_tokens
return provider_cache_read_tokens, provider_cache_creation_tokens
@staticmethod
def _resolve_cache_write_tokens(prompt_details: object) -> int | None:
if not isinstance(prompt_details, dict):
return None
for key in ("cache_write_tokens", "cache_creation_tokens"):
value = prompt_details.get(key)
if isinstance(value, int) and not isinstance(value, bool):
return value
return None
def _increment_mcp_tool_call_metrics(
self,
standard_logging_payload: StandardLoggingPayload,

View file

@ -5593,18 +5593,6 @@ def scrub_sensitive_keys_in_metadata(litellm_params: Optional[dict]):
litellm_params["_langfuse_masking_function"] = masking_fn
litellm_params["metadata"] = metadata
## check user_api_key_metadata for sensitive logging keys
cleaned_user_api_key_metadata = {}
if "user_api_key_metadata" in metadata and isinstance(metadata["user_api_key_metadata"], dict):
for k, v in metadata["user_api_key_metadata"].items():
if k == "logging": # prevent logging user logging keys
cleaned_user_api_key_metadata[k] = "scrubbed_by_litellm_for_sensitive_keys"
else:
cleaned_user_api_key_metadata[k] = v
metadata["user_api_key_metadata"] = cleaned_user_api_key_metadata
litellm_params["metadata"] = metadata
return litellm_params

View file

@ -6,6 +6,7 @@ import mimetypes
import re
import xml.etree.ElementTree as ET
from enum import Enum
from collections.abc import Mapping
from typing import Any, Dict, List, Optional, Set, Tuple, TypedDict, Union, cast, overload
from jinja2.sandbox import ImmutableSandboxedEnvironment
@ -5350,7 +5351,9 @@ def prompt_factory(
def get_attribute_or_key(tool_or_function, attribute, default=None):
if hasattr(tool_or_function, attribute):
return getattr(tool_or_function, attribute)
return tool_or_function.get(attribute, default)
if isinstance(tool_or_function, Mapping):
return tool_or_function.get(attribute, default)
return default
class NormalizedToolCall(TypedDict):
@ -5379,14 +5382,18 @@ def _parse_tool_call_arguments(raw: Any, tool_name: Optional[str], context: str)
return parsed if isinstance(parsed, dict) else {}
def _tool_calls_from_chat_completion_response(response: Any) -> list[NormalizedToolCall]:
def _tool_calls_from_chat_completion_response(
response: Any, include_all_choices: bool = False
) -> list[NormalizedToolCall]:
choices = get_attribute_or_key(response, "choices", None)
if not (isinstance(choices, list) and choices):
return []
message = get_attribute_or_key(choices[0], "message", None)
tool_calls = get_attribute_or_key(message, "tool_calls", None) if message else None
if not isinstance(tool_calls, list):
return []
tool_calls: list[Any] = []
for choice in choices if include_all_choices else choices[:1]:
message = get_attribute_or_key(choice, "message", None)
choice_tool_calls = get_attribute_or_key(message, "tool_calls", None) if message else None
if isinstance(choice_tool_calls, list):
tool_calls.extend(choice_tool_calls)
result: list[NormalizedToolCall] = []
for tc in tool_calls:
fn = get_attribute_or_key(tc, "function", None)
@ -5449,7 +5456,7 @@ def _tool_calls_from_anthropic_messages_response(response: Any) -> list[Normaliz
return result
def get_tool_calls_from_response(response: Any) -> list[NormalizedToolCall]:
def get_tool_calls_from_response(response: Any, include_all_choices: bool = False) -> list[NormalizedToolCall]:
"""
Extract tool/function calls from a response object into a normalized
``{"id", "name", "arguments"}`` shape, regardless of which API surface
@ -5457,11 +5464,20 @@ def get_tool_calls_from_response(response: Any) -> list[NormalizedToolCall]:
the Responses API (``output`` items of type ``function_call``), or the
Anthropic Messages API (``content`` blocks of type ``tool_use``).
``include_all_choices`` decides the chat-completions scope: the default
reads only ``choices[0]``, which is what consumers that act on THE reply
(e.g. guardrails rebuilding the primary assistant message) want; usage
accounting passes True because every choice of an ``n>1`` request costs
money and its tool calls really ran. The other surfaces have a single
output, so the flag has no effect on them.
Callers that only care about a specific tool should filter the result by
``name`` themselves -- this returns every tool call found.
"""
chat_tool_calls = _tool_calls_from_chat_completion_response(response, include_all_choices=include_all_choices)
if chat_tool_calls:
return chat_tool_calls
for extractor in (
_tool_calls_from_chat_completion_response,
_tool_calls_from_responses_api_response,
_tool_calls_from_anthropic_messages_response,
):

View file

@ -2,7 +2,8 @@
Azure Batches API Handler
"""
from typing import Any, Coroutine, Optional, Union, cast
from collections.abc import Coroutine
from typing import cast
import httpx
from openai import AsyncOpenAI, OpenAI
@ -33,32 +34,30 @@ class AzureBatchesAPI(BaseAzureLLM):
async def acreate_batch(
self,
create_batch_data: CreateBatchRequest,
azure_client: Union[AsyncAzureOpenAI, AsyncOpenAI],
azure_client: AsyncAzureOpenAI | AsyncOpenAI,
) -> LiteLLMBatch:
response = await azure_client.batches.create(**create_batch_data) # type: ignore[arg-type]
return LiteLLMBatch(**response.model_dump())
return LiteLLMBatch.model_validate(response.model_dump())
def create_batch(
self,
_is_async: bool,
create_batch_data: CreateBatchRequest,
api_key: Optional[str],
api_base: Optional[str],
api_version: Optional[str],
timeout: Union[float, httpx.Timeout],
max_retries: Optional[int],
client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = None,
litellm_params: Optional[dict] = None,
) -> Union[LiteLLMBatch, Coroutine[Any, Any, LiteLLMBatch]]:
azure_client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = (
self.get_azure_openai_client(
api_key=api_key,
api_base=api_base,
api_version=api_version,
client=client,
_is_async=_is_async,
litellm_params=litellm_params or {},
)
api_key: str | None,
api_base: str | None,
api_version: str | None,
timeout: float | httpx.Timeout,
max_retries: int | None,
client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = None,
litellm_params: dict | None = None,
) -> LiteLLMBatch | Coroutine[object, object, LiteLLMBatch]:
azure_client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = self.get_azure_openai_client(
api_key=api_key,
api_base=api_base,
api_version=api_version,
client=client,
_is_async=_is_async,
litellm_params=litellm_params or {},
)
if azure_client is None:
raise ValueError(
@ -73,38 +72,36 @@ class AzureBatchesAPI(BaseAzureLLM):
return self.acreate_batch( # type: ignore
create_batch_data=create_batch_data, azure_client=azure_client
)
response = cast(Union[AzureOpenAI, OpenAI], azure_client).batches.create(**create_batch_data) # type: ignore[arg-type]
return LiteLLMBatch(**response.model_dump())
response = cast(AzureOpenAI | OpenAI, azure_client).batches.create(**create_batch_data) # type: ignore[arg-type]
return LiteLLMBatch.model_validate(response.model_dump())
async def aretrieve_batch(
self,
retrieve_batch_data: RetrieveBatchRequest,
client: Union[AsyncAzureOpenAI, AsyncOpenAI],
client: AsyncAzureOpenAI | AsyncOpenAI,
) -> LiteLLMBatch:
response = await client.batches.retrieve(**retrieve_batch_data) # type: ignore[arg-type]
return LiteLLMBatch(**response.model_dump())
return LiteLLMBatch.model_validate(response.model_dump())
def retrieve_batch(
self,
_is_async: bool,
retrieve_batch_data: RetrieveBatchRequest,
api_key: Optional[str],
api_base: Optional[str],
api_version: Optional[str],
timeout: Union[float, httpx.Timeout],
max_retries: Optional[int],
client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = None,
litellm_params: Optional[dict] = None,
api_key: str | None,
api_base: str | None,
api_version: str | None,
timeout: float | httpx.Timeout,
max_retries: int | None,
client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = None,
litellm_params: dict | None = None,
):
azure_client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = (
self.get_azure_openai_client(
api_key=api_key,
api_base=api_base,
api_version=api_version,
client=client,
_is_async=_is_async,
litellm_params=litellm_params or {},
)
azure_client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = self.get_azure_openai_client(
api_key=api_key,
api_base=api_base,
api_version=api_version,
client=client,
_is_async=_is_async,
litellm_params=litellm_params or {},
)
if azure_client is None:
raise ValueError(
@ -119,38 +116,36 @@ class AzureBatchesAPI(BaseAzureLLM):
return self.aretrieve_batch( # type: ignore
retrieve_batch_data=retrieve_batch_data, client=azure_client
)
response = cast(Union[AzureOpenAI, OpenAI], azure_client).batches.retrieve(**retrieve_batch_data)
return LiteLLMBatch(**response.model_dump())
response = cast(AzureOpenAI | OpenAI, azure_client).batches.retrieve(**retrieve_batch_data)
return LiteLLMBatch.model_validate(response.model_dump())
async def acancel_batch(
self,
cancel_batch_data: CancelBatchRequest,
client: Union[AsyncAzureOpenAI, AsyncOpenAI],
client: AsyncAzureOpenAI | AsyncOpenAI,
) -> LiteLLMBatch:
response = await client.batches.cancel(**cancel_batch_data)
return LiteLLMBatch(**response.model_dump())
return LiteLLMBatch.model_validate(response.model_dump())
def cancel_batch(
self,
_is_async: bool,
cancel_batch_data: CancelBatchRequest,
api_key: Optional[str],
api_base: Optional[str],
api_version: Optional[str],
timeout: Union[float, httpx.Timeout],
max_retries: Optional[int],
client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = None,
litellm_params: Optional[dict] = None,
api_key: str | None,
api_base: str | None,
api_version: str | None,
timeout: float | httpx.Timeout,
max_retries: int | None,
client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = None,
litellm_params: dict | None = None,
):
azure_client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = (
self.get_azure_openai_client(
api_key=api_key,
api_base=api_base,
api_version=api_version,
client=client,
_is_async=_is_async,
litellm_params=litellm_params or {},
)
azure_client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = self.get_azure_openai_client(
api_key=api_key,
api_base=api_base,
api_version=api_version,
client=client,
_is_async=_is_async,
litellm_params=litellm_params or {},
)
if azure_client is None:
raise ValueError(
@ -172,13 +167,13 @@ class AzureBatchesAPI(BaseAzureLLM):
"Azure client is not an instance of AzureOpenAI or OpenAI. Make sure you passed a sync client."
)
response = azure_client.batches.cancel(**cancel_batch_data)
return LiteLLMBatch(**response.model_dump())
return LiteLLMBatch.model_validate(response.model_dump())
async def alist_batches(
self,
client: Union[AsyncAzureOpenAI, AsyncOpenAI],
after: Optional[str] = None,
limit: Optional[int] = None,
client: AsyncAzureOpenAI | AsyncOpenAI,
after: str | None = None,
limit: int | None = None,
):
response = await client.batches.list(after=after, limit=limit) # type: ignore
return response
@ -186,25 +181,23 @@ class AzureBatchesAPI(BaseAzureLLM):
def list_batches(
self,
_is_async: bool,
api_key: Optional[str],
api_base: Optional[str],
api_version: Optional[str],
timeout: Union[float, httpx.Timeout],
max_retries: Optional[int],
after: Optional[str] = None,
limit: Optional[int] = None,
client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = None,
litellm_params: Optional[dict] = None,
api_key: str | None,
api_base: str | None,
api_version: str | None,
timeout: float | httpx.Timeout,
max_retries: int | None,
after: str | None = None,
limit: int | None = None,
client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = None,
litellm_params: dict | None = None,
):
azure_client: Optional[Union[AzureOpenAI, AsyncAzureOpenAI, OpenAI, AsyncOpenAI]] = (
self.get_azure_openai_client(
api_key=api_key,
api_base=api_base,
api_version=api_version,
client=client,
_is_async=_is_async,
litellm_params=litellm_params or {},
)
azure_client: AzureOpenAI | AsyncAzureOpenAI | OpenAI | AsyncOpenAI | None = self.get_azure_openai_client(
api_key=api_key,
api_base=api_base,
api_version=api_version,
client=client,
_is_async=_is_async,
litellm_params=litellm_params or {},
)
if azure_client is None:
raise ValueError(

View file

@ -4,8 +4,6 @@ Azure AI Anthropic CountTokens API transformation logic.
Extends the base Anthropic CountTokens transformation with Azure authentication.
"""
from typing import Any, Dict, Optional
from litellm.constants import ANTHROPIC_TOKEN_COUNTING_BETA_VERSION
from litellm.llms.anthropic.count_tokens.transformation import (
AnthropicCountTokensConfig,
@ -25,8 +23,8 @@ class AzureAIAnthropicCountTokensConfig(AnthropicCountTokensConfig):
def get_required_headers(
self,
api_key: str,
litellm_params: Optional[Dict[str, Any]] = None,
) -> Dict[str, str]:
litellm_params: dict[str, object] | None = None,
) -> dict[str, str]:
"""
Get the required headers for the Azure AI Anthropic CountTokens API.
@ -53,7 +51,7 @@ class AzureAIAnthropicCountTokensConfig(AnthropicCountTokensConfig):
if "api_key" not in litellm_params:
litellm_params["api_key"] = api_key
litellm_params_obj = GenericLiteLLMParams(**litellm_params)
litellm_params_obj = GenericLiteLLMParams.model_validate(litellm_params)
# Get Azure auth headers (api-key or Authorization)
azure_headers = BaseAzureLLM._base_validate_azure_environment(headers={}, litellm_params=litellm_params_obj)

View file

@ -2,7 +2,7 @@
OpenAI Evals API configuration and transformations
"""
from typing import Any, Dict, Optional, Tuple
from collections.abc import Mapping
import httpx
@ -31,6 +31,10 @@ from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
def _parsed_response_json(raw_response: httpx.Response) -> Mapping[str, object]:
return raw_response.json()
class OpenAIEvalsConfig(BaseEvalsAPIConfig):
"""OpenAI-specific Evals API configuration"""
@ -38,7 +42,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.OPENAI
def validate_environment(self, headers: dict, litellm_params: Optional[GenericLiteLLMParams]) -> dict:
def validate_environment(self, headers: dict, litellm_params: GenericLiteLLMParams | None) -> dict:
"""Add OpenAI-specific headers"""
import litellm
from litellm.secret_managers.main import get_secret_str
@ -61,9 +65,9 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
def get_complete_url(
self,
api_base: Optional[str],
api_base: str | None,
endpoint: str,
eval_id: Optional[str] = None,
eval_id: str | None = None,
) -> str:
"""Get complete URL for OpenAI Evals API"""
if api_base is None:
@ -79,7 +83,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
create_request: CreateEvalRequest,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Dict:
) -> dict:
"""Transform create eval request for OpenAI"""
verbose_logger.debug("Transforming create eval request: %s", create_request)
@ -94,17 +98,17 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
logging_obj: LiteLLMLoggingObj,
) -> Eval:
"""Transform OpenAI response to Eval object"""
response_json = raw_response.json()
response_json = _parsed_response_json(raw_response)
verbose_logger.debug("Transforming create eval response: %s", response_json)
return Eval(**response_json)
return Eval.model_validate(response_json)
def transform_list_evals_request(
self,
list_params: ListEvalsParams,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Tuple[str, Dict]:
) -> tuple[str, dict]:
"""Transform list evals request for OpenAI"""
api_base = "https://api.openai.com"
if litellm_params and litellm_params.api_base:
@ -113,7 +117,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
url = self.get_complete_url(api_base=api_base, endpoint="evals")
# Build query parameters
query_params: Dict[str, Any] = {}
query_params: dict[str, object] = {}
if "limit" in list_params and list_params["limit"]:
query_params["limit"] = list_params["limit"]
if "after" in list_params and list_params["after"]:
@ -138,10 +142,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
logging_obj: LiteLLMLoggingObj,
) -> ListEvalsResponse:
"""Transform OpenAI response to ListEvalsResponse"""
response_json = raw_response.json()
response_json = _parsed_response_json(raw_response)
verbose_logger.debug("Transforming list evals response: %s", response_json)
return ListEvalsResponse(**response_json)
return ListEvalsResponse.model_validate(response_json)
def transform_get_eval_request(
self,
@ -149,7 +153,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
api_base: str,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Tuple[str, Dict]:
) -> tuple[str, dict]:
"""Transform get eval request for OpenAI"""
url = self.get_complete_url(api_base=api_base, endpoint="evals", eval_id=eval_id)
@ -163,10 +167,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
logging_obj: LiteLLMLoggingObj,
) -> Eval:
"""Transform OpenAI response to Eval object"""
response_json = raw_response.json()
response_json = _parsed_response_json(raw_response)
verbose_logger.debug("Transforming get eval response: %s", response_json)
return Eval(**response_json)
return Eval.model_validate(response_json)
def transform_update_eval_request(
self,
@ -175,7 +179,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
api_base: str,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Tuple[str, Dict, Dict]:
) -> tuple[str, dict, dict]:
"""Transform update eval request for OpenAI"""
url = self.get_complete_url(api_base=api_base, endpoint="evals", eval_id=eval_id)
@ -192,10 +196,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
logging_obj: LiteLLMLoggingObj,
) -> Eval:
"""Transform OpenAI response to Eval object"""
response_json = raw_response.json()
response_json = _parsed_response_json(raw_response)
verbose_logger.debug("Transforming update eval response: %s", response_json)
return Eval(**response_json)
return Eval.model_validate(response_json)
def transform_delete_eval_request(
self,
@ -203,7 +207,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
api_base: str,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Tuple[str, Dict]:
) -> tuple[str, dict]:
"""Transform delete eval request for OpenAI"""
url = self.get_complete_url(api_base=api_base, endpoint="evals", eval_id=eval_id)
@ -217,10 +221,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
logging_obj: LiteLLMLoggingObj,
) -> DeleteEvalResponse:
"""Transform OpenAI response to DeleteEvalResponse"""
response_json = raw_response.json()
response_json = _parsed_response_json(raw_response)
verbose_logger.debug("Transforming delete eval response: %s", response_json)
return DeleteEvalResponse(**response_json)
return DeleteEvalResponse.model_validate(response_json)
def transform_cancel_eval_request(
self,
@ -228,12 +232,12 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
api_base: str,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Tuple[str, Dict, Dict]:
) -> tuple[str, dict, dict]:
"""Transform cancel eval request for OpenAI"""
url = f"{self.get_complete_url(api_base=api_base, endpoint='evals', eval_id=eval_id)}/cancel"
# Empty body for cancel request
request_body: Dict[str, Any] = {}
request_body: dict[str, object] = {}
verbose_logger.debug("Cancel eval request - URL: %s", url)
@ -245,10 +249,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
logging_obj: LiteLLMLoggingObj,
) -> CancelEvalResponse:
"""Transform OpenAI response to CancelEvalResponse"""
response_json = raw_response.json()
response_json = _parsed_response_json(raw_response)
verbose_logger.debug("Transforming cancel eval response: %s", response_json)
return CancelEvalResponse(**response_json)
return CancelEvalResponse.model_validate(response_json)
# Run API Transformations
def transform_create_run_request(
@ -257,7 +261,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
create_request: CreateRunRequest,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Tuple[str, Dict]:
) -> tuple[str, dict]:
"""Transform create run request for OpenAI"""
api_base = "https://api.openai.com"
if litellm_params and litellm_params.api_base:
@ -279,10 +283,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
logging_obj: LiteLLMLoggingObj,
) -> Run:
"""Transform OpenAI response to Run object"""
response_json = raw_response.json()
response_json = _parsed_response_json(raw_response)
verbose_logger.debug("Transforming create run response: %s", response_json)
return Run(**response_json)
return Run.model_validate(response_json)
def transform_list_runs_request(
self,
@ -290,7 +294,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
list_params: ListRunsParams,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Tuple[str, Dict]:
) -> tuple[str, dict]:
"""Transform list runs request for OpenAI"""
api_base = "https://api.openai.com"
if litellm_params and litellm_params.api_base:
@ -300,7 +304,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
url = f"{api_base}/v1/evals/{encoded_eval_id}/runs"
# Build query parameters
query_params: Dict[str, Any] = {}
query_params: dict[str, object] = {}
if "limit" in list_params and list_params["limit"]:
query_params["limit"] = list_params["limit"]
if "after" in list_params and list_params["after"]:
@ -323,10 +327,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
logging_obj: LiteLLMLoggingObj,
) -> ListRunsResponse:
"""Transform OpenAI response to ListRunsResponse"""
response_json = raw_response.json()
response_json = _parsed_response_json(raw_response)
verbose_logger.debug("Transforming list runs response: %s", response_json)
return ListRunsResponse(**response_json)
return ListRunsResponse.model_validate(response_json)
def transform_get_run_request(
self,
@ -335,7 +339,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
api_base: str,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Tuple[str, Dict]:
) -> tuple[str, dict]:
"""Transform get run request for OpenAI"""
encoded_eval_id = encode_url_path_segment(eval_id, field_name="eval_id")
encoded_run_id = encode_url_path_segment(run_id, field_name="run_id")
@ -351,10 +355,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
logging_obj: LiteLLMLoggingObj,
) -> Run:
"""Transform OpenAI response to Run object"""
response_json = raw_response.json()
response_json = _parsed_response_json(raw_response)
verbose_logger.debug("Transforming get run response: %s", response_json)
return Run(**response_json)
return Run.model_validate(response_json)
def transform_cancel_run_request(
self,
@ -363,14 +367,14 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
api_base: str,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Tuple[str, Dict, Dict]:
) -> tuple[str, dict, dict]:
"""Transform cancel run request for OpenAI"""
encoded_eval_id = encode_url_path_segment(eval_id, field_name="eval_id")
encoded_run_id = encode_url_path_segment(run_id, field_name="run_id")
url = f"{api_base}/v1/evals/{encoded_eval_id}/runs/{encoded_run_id}/cancel"
# Empty body for cancel request
request_body: Dict[str, Any] = {}
request_body: dict[str, object] = {}
verbose_logger.debug("Cancel run request - URL: %s", url)
@ -382,10 +386,10 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
logging_obj: LiteLLMLoggingObj,
) -> CancelRunResponse:
"""Transform OpenAI response to CancelRunResponse"""
response_json = raw_response.json()
response_json = _parsed_response_json(raw_response)
verbose_logger.debug("Transforming cancel run response: %s", response_json)
return CancelRunResponse(**response_json)
return CancelRunResponse.model_validate(response_json)
def transform_delete_run_request(
self,
@ -394,14 +398,14 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
api_base: str,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Tuple[str, Dict, Dict]:
) -> tuple[str, dict, dict]:
"""Transform delete run request for OpenAI"""
encoded_eval_id = encode_url_path_segment(eval_id, field_name="eval_id")
encoded_run_id = encode_url_path_segment(run_id, field_name="run_id")
url = f"{api_base}/v1/evals/{encoded_eval_id}/runs/{encoded_run_id}"
# Empty body for delete request
request_body: Dict[str, Any] = {}
request_body: dict[str, object] = {}
verbose_logger.debug("Delete run request - URL: %s", url)
@ -413,7 +417,7 @@ class OpenAIEvalsConfig(BaseEvalsAPIConfig):
logging_obj: LiteLLMLoggingObj,
) -> RunDeleteResponse:
"""Transform OpenAI response to RunDeleteResponse"""
response_json = raw_response.json()
response_json = _parsed_response_json(raw_response)
verbose_logger.debug("Transforming delete run response: %s", response_json)
return RunDeleteResponse(**response_json)
return RunDeleteResponse.model_validate(response_json)

View file

@ -1,11 +1,9 @@
from collections.abc import Callable, Mapping, Sequence
from typing import (
TYPE_CHECKING,
Any,
Dict,
List,
Literal,
Optional,
Tuple,
Protocol,
Union,
get_args,
get_origin,
@ -17,10 +15,10 @@ from pydantic import fields as pyd_fields
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.core_helpers import process_response_headers
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import (
_safe_convert_created_field,
)
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import (
@ -47,8 +45,15 @@ else:
LiteLLMLoggingObj = Any
class _EventModelClass(Protocol):
@property
def model_fields(self) -> Mapping[str, pyd_fields.FieldInfo]: ...
def model_validate(self, obj: Mapping[str, object]) -> ResponsesAPIStreamingResponse: ...
class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
_SUPPORTED_OPTIONAL_PARAMS: List[str] = [
_SUPPORTED_OPTIONAL_PARAMS: list[str] = [
# Doc-listed knobs
"instructions",
"max_output_tokens",
@ -89,9 +94,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
supported.remove("metadata")
return supported
def get_error_class(
self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers]
) -> VolcEngineError:
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> VolcEngineError:
typed_headers: httpx.Headers = headers if isinstance(headers, httpx.Headers) else httpx.Headers(headers or {})
return VolcEngineError(
status_code=status_code,
@ -99,14 +102,14 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
headers=typed_headers,
)
def validate_environment(self, headers: dict, model: str, litellm_params: Optional[GenericLiteLLMParams]) -> dict:
def validate_environment(self, headers: dict, model: str, litellm_params: GenericLiteLLMParams | None) -> dict:
"""
Build auth headers for Volcengine Responses API.
"""
if litellm_params is None:
litellm_params = GenericLiteLLMParams()
elif isinstance(litellm_params, dict):
litellm_params = GenericLiteLLMParams(**litellm_params)
litellm_params = GenericLiteLLMParams.model_validate(litellm_params)
api_key = (
litellm_params.api_key
@ -122,7 +125,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
def get_complete_url(
self,
api_base: Optional[str],
api_base: str | None,
litellm_params: dict,
) -> str:
"""
@ -149,7 +152,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
response_api_optional_params: ResponsesAPIOptionalRequestParams,
model: str,
drop_params: bool,
) -> Dict:
) -> dict:
"""
Volcengine Responses API aligns with OpenAI parameters.
Remove parameters not supported by the public docs.
@ -173,11 +176,11 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
def transform_responses_api_request(
self,
model: str,
input: Union[str, ResponseInputParam],
response_api_optional_request_params: Dict,
input: str | ResponseInputParam,
response_api_optional_request_params: dict,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Dict:
) -> dict:
"""
Volcengine rejects any undocumented fields (including extra_body). Fail fast
with clear errors and re-filter with the documented whitelist before delegating
@ -210,7 +213,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
def transform_streaming_response(
self,
model: str,
parsed_chunk: dict,
parsed_chunk: Mapping[str, object],
logging_obj: LiteLLMLoggingObj,
) -> ResponsesAPIStreamingResponse:
"""
@ -222,18 +225,19 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
if isinstance(chunk, dict):
resp = chunk.get("response")
if isinstance(resp, dict) and "output" not in resp:
resp_items: Mapping[str, object] = resp
patched_chunk = dict(chunk)
patched_resp = dict(resp)
patched_resp = dict(resp_items)
patched_resp["output"] = []
patched_chunk["response"] = patched_resp
chunk = patched_chunk
event_type = str(chunk.get("type")) if isinstance(chunk, dict) else None
event_pydantic_model = OpenAIResponsesAPIConfig.get_event_model_class(event_type=event_type)
event_pydantic_model: _EventModelClass = OpenAIResponsesAPIConfig.get_event_model_class(event_type=event_type)
patched_chunk = self._fill_missing_fields(chunk, event_pydantic_model)
return event_pydantic_model(**patched_chunk)
return event_pydantic_model.model_validate(patched_chunk)
def transform_response_api_response(
self,
@ -246,7 +250,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
original_response=raw_response.text,
additional_args={"complete_input_dict": {}},
)
raw_response_json = raw_response.json()
raw_response_json = self._parsed_response_body(raw_response)
if "created_at" in raw_response_json:
raw_response_json["created_at"] = _safe_convert_created_field(raw_response_json["created_at"])
except Exception:
@ -256,10 +260,11 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
processed_headers = process_response_headers(raw_response_headers)
try:
response = ResponsesAPIResponse(**raw_response_json)
response = ResponsesAPIResponse.model_validate(raw_response_json)
except Exception:
verbose_logger.debug("Volcengine Responses API: falling back to model_construct for response parsing.")
response = ResponsesAPIResponse.model_construct(**raw_response_json)
construct_response: Callable[..., ResponsesAPIResponse] = ResponsesAPIResponse.model_construct
response = construct_response(**raw_response_json)
response._hidden_params["additional_headers"] = processed_headers
response._hidden_params["headers"] = raw_response_headers
@ -274,10 +279,10 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
api_base: str,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Tuple[str, Dict]:
) -> tuple[str, dict]:
encoded_response_id = encode_url_path_segment(response_id, field_name="response_id")
url = f"{api_base}/{encoded_response_id}"
data: Dict = {}
data: dict = {}
return url, data
def transform_delete_response_api_response(
@ -286,16 +291,17 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
logging_obj: LiteLLMLoggingObj,
) -> DeleteResponseResult:
try:
raw_response_json = raw_response.json()
raw_response_json = self._parsed_response_body(raw_response)
except Exception:
raise VolcEngineError(message=raw_response.text, status_code=raw_response.status_code)
try:
return DeleteResponseResult(**raw_response_json)
return DeleteResponseResult.model_validate(raw_response_json)
except Exception:
verbose_logger.debug(
"Volcengine Responses API: falling back to model_construct for delete response parsing."
)
return DeleteResponseResult.model_construct(**raw_response_json)
construct_delete_result: Callable[..., DeleteResponseResult] = DeleteResponseResult.model_construct
return construct_delete_result(**raw_response_json)
#########################################################
########## GET RESPONSE API TRANSFORMATION ###############
@ -306,10 +312,10 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
api_base: str,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Tuple[str, Dict]:
) -> tuple[str, dict]:
encoded_response_id = encode_url_path_segment(response_id, field_name="response_id")
url = f"{api_base}/{encoded_response_id}"
data: Dict = {}
data: dict = {}
return url, data
def transform_get_response_api_response(
@ -318,14 +324,14 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
logging_obj: LiteLLMLoggingObj,
) -> ResponsesAPIResponse:
try:
raw_response_json = raw_response.json()
raw_response_json = self._parsed_response_body(raw_response)
except Exception:
raise VolcEngineError(message=raw_response.text, status_code=raw_response.status_code)
raw_response_headers = dict(raw_response.headers)
processed_headers = process_response_headers(raw_response_headers)
response = ResponsesAPIResponse(**raw_response_json)
response = ResponsesAPIResponse.model_validate(raw_response_json)
response._hidden_params["additional_headers"] = processed_headers
response._hidden_params["headers"] = raw_response_headers
return response
@ -339,15 +345,15 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
api_base: str,
litellm_params: GenericLiteLLMParams,
headers: dict,
after: Optional[str] = None,
before: Optional[str] = None,
include: Optional[List[str]] = None,
after: str | None = None,
before: str | None = None,
include: list[str] | None = None,
limit: int = 20,
order: Literal["asc", "desc"] = "desc",
) -> Tuple[str, Dict]:
) -> tuple[str, dict]:
encoded_response_id = encode_url_path_segment(response_id, field_name="response_id")
url = f"{api_base}/{encoded_response_id}/input_items"
params: Dict[str, Any] = {}
params: dict[str, str | int] = {}
if after is not None:
params["after"] = after
if before is not None:
@ -364,9 +370,9 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
) -> Dict:
) -> dict:
try:
return raw_response.json()
return self._parsed_response_body(raw_response)
except Exception:
raise VolcEngineError(message=raw_response.text, status_code=raw_response.status_code)
@ -379,10 +385,10 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
api_base: str,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Tuple[str, Dict]:
) -> tuple[str, dict]:
encoded_response_id = encode_url_path_segment(response_id, field_name="response_id")
url = f"{api_base}/{encoded_response_id}/cancel"
data: Dict = {}
data: dict = {}
return url, data
def transform_cancel_response_api_response(
@ -391,23 +397,23 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
logging_obj: LiteLLMLoggingObj,
) -> ResponsesAPIResponse:
try:
raw_response_json = raw_response.json()
raw_response_json = self._parsed_response_body(raw_response)
except Exception:
raise VolcEngineError(message=raw_response.text, status_code=raw_response.status_code)
raw_response_headers = dict(raw_response.headers)
processed_headers = process_response_headers(raw_response_headers)
response = ResponsesAPIResponse(**raw_response_json)
response = ResponsesAPIResponse.model_validate(raw_response_json)
response._hidden_params["additional_headers"] = processed_headers
response._hidden_params["headers"] = raw_response_headers
return response
def should_fake_stream(
self,
model: Optional[str],
stream: Optional[bool],
custom_llm_provider: Optional[str] = None,
model: str | None,
stream: bool | None,
custom_llm_provider: str | None = None,
) -> bool:
"""
Volcengine Responses API supports native streaming; never fall back to fake stream.
@ -415,7 +421,24 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
return False
@staticmethod
def _fill_missing_fields(chunk: Any, event_model: Any) -> Dict[str, Any]:
def _parsed_response_body(raw_response: httpx.Response) -> dict[str, object]:
return raw_response.json()
@staticmethod
def _annotation_origin(annotation: object) -> object:
return get_origin(annotation)
@staticmethod
def _annotation_args(annotation: object) -> tuple[object, ...]:
return get_args(annotation)
@staticmethod
def _field_annotation(field: pyd_fields.FieldInfo) -> object:
annotation: object = field.annotation
return annotation
@staticmethod
def _fill_missing_fields(chunk: Mapping[str, object], event_model: object | None) -> Mapping[str, object]:
"""
Heuristically fill missing required fields with safe defaults based on the
event model's field annotations. This keeps parsing tolerant of providers that
@ -424,31 +447,37 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
if not isinstance(chunk, dict) or event_model is None:
return chunk
patched: Dict[str, Any] = dict(chunk)
fields_map = getattr(event_model, "model_fields", {}) or {}
patched = dict(chunk)
fields_map: Mapping[str, pyd_fields.FieldInfo] = getattr(event_model, "model_fields", {}) or {}
for name, field in fields_map.items():
if name in patched:
patched[name] = VolcEngineResponsesAPIConfig._maybe_fill_nested(patched[name], field.annotation)
patched[name] = VolcEngineResponsesAPIConfig._maybe_fill_nested(
patched[name], VolcEngineResponsesAPIConfig._field_annotation(field)
)
continue
# Explicit default or factory
if field.default is not pyd_fields.PydanticUndefined and field.default is not None:
patched[name] = field.default
field_default: object = field.default
if field_default is not pyd_fields.PydanticUndefined and field_default is not None:
patched[name] = field_default
continue
if field.default_factory is not None and field.default_factory is not pyd_fields.PydanticUndefined:
patched[name] = field.default_factory()
default_factory: Callable[..., object] | None = field.default_factory
if default_factory is not None and default_factory is not pyd_fields.PydanticUndefined:
patched[name] = default_factory()
continue
# Heuristic defaults for missing required fields
patched[name] = VolcEngineResponsesAPIConfig._default_for_annotation(field.annotation)
patched[name] = VolcEngineResponsesAPIConfig._default_for_annotation(
VolcEngineResponsesAPIConfig._field_annotation(field)
)
return patched
@staticmethod
def _default_for_annotation(annotation: Any) -> Any:
origin = get_origin(annotation)
args = get_args(annotation)
def _default_for_annotation(annotation: object) -> object:
origin = VolcEngineResponsesAPIConfig._annotation_origin(annotation)
args = VolcEngineResponsesAPIConfig._annotation_args(annotation)
if annotation is int:
return 0
@ -456,7 +485,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
return []
if origin is Union:
# Prefer empty list when any option is a list
if any((arg is list or get_origin(arg) is list) for arg in args):
if any((arg is list or VolcEngineResponsesAPIConfig._annotation_origin(arg) is list) for arg in args):
return []
if type(None) in args:
return None
@ -467,53 +496,51 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
return None
@staticmethod
def _maybe_fill_nested(value: Any, annotation: Any) -> Any:
def _maybe_fill_nested(value: object, annotation: object) -> object:
"""
Recursively fill nested dict/list structures based on the annotated model.
"""
model_cls = VolcEngineResponsesAPIConfig._pick_model_class(annotation, value)
args = get_args(annotation)
args = VolcEngineResponsesAPIConfig._annotation_args(annotation)
if isinstance(value, dict) and model_cls is not None:
return VolcEngineResponsesAPIConfig._fill_missing_fields(value, model_cls)
nested_items: Mapping[str, object] = value
return VolcEngineResponsesAPIConfig._fill_missing_fields(nested_items, model_cls)
if isinstance(value, list):
# Attempt to fill list elements if we know the element annotation
elem_ann: Any = args[0] if args else None
elem_ann: object = args[0] if args else None
if elem_ann is not None:
return [VolcEngineResponsesAPIConfig._maybe_fill_nested(v, elem_ann) for v in value]
nested_elements: Sequence[object] = value
return [VolcEngineResponsesAPIConfig._maybe_fill_nested(v, elem_ann) for v in nested_elements]
return value
@staticmethod
def _pick_model_class(annotation: Any, value: Any) -> Optional[Any]:
def _pick_model_class(annotation: object, value: object) -> object | None:
"""
Choose the best-matching Pydantic model class for a nested dict.
"""
candidates: List[Any] = []
origin = get_origin(annotation)
if hasattr(annotation, "model_fields"):
candidates.append(annotation)
if origin is Union:
for arg in get_args(annotation):
if hasattr(arg, "model_fields"):
candidates.append(arg)
origin = VolcEngineResponsesAPIConfig._annotation_origin(annotation)
union_args = VolcEngineResponsesAPIConfig._annotation_args(annotation) if origin is Union else ()
candidates = tuple(candidate for candidate in (annotation, *union_args) if hasattr(candidate, "model_fields"))
if not candidates:
return None
# Try to match by literal "type" field when available
if isinstance(value, dict):
v_type = value.get("type")
value_items: Mapping[str, object] = value
v_type = value_items.get("type")
for candidate in candidates:
try:
type_field = candidate.model_fields.get("type")
candidate_fields: Mapping[str, pyd_fields.FieldInfo] = getattr(candidate, "model_fields")
type_field = candidate_fields.get("type")
if type_field is None:
continue
literal_ann = type_field.annotation
if get_origin(literal_ann) is Literal:
literal_values = get_args(literal_ann)
literal_ann = VolcEngineResponsesAPIConfig._field_annotation(type_field)
if VolcEngineResponsesAPIConfig._annotation_origin(literal_ann) is Literal:
literal_values = VolcEngineResponsesAPIConfig._annotation_args(literal_ann)
if v_type in literal_values:
return candidate
except Exception:

View file

@ -7,9 +7,10 @@ import base64
import mimetypes
import os
import re
from collections.abc import Callable, Coroutine, Mapping
from dataclasses import dataclass
from io import IOBase
from typing import Any, Callable, Coroutine, Union, cast
from typing import Any, cast
import httpx
@ -42,7 +43,7 @@ class _PreparedOCRRequest:
provider_config: BaseOCRConfig
optional_params: dict[str, object]
litellm_params: dict[str, object]
effective_timeout: Union[float, httpx.Timeout]
effective_timeout: float | httpx.Timeout
litellm_logging_obj: LiteLLMLoggingObj
@ -63,13 +64,13 @@ _RUST_OCR_PROVIDERS = {
def _prepare_ocr_request(
model: str,
document: dict[str, Any],
document: Mapping[str, object],
api_key: str | None,
api_base: str | None,
timeout: Union[float, httpx.Timeout] | None,
timeout: float | httpx.Timeout | None,
custom_llm_provider: str | None,
extra_headers: dict[str, Any] | None,
kwargs: dict[str, Any],
extra_headers: dict[str, object] | None,
kwargs: dict[str, object],
) -> _PreparedOCRRequest:
litellm_logging_obj = cast(LiteLLMLoggingObj, kwargs.pop("litellm_logging_obj"))
litellm_call_id = cast(str | None, kwargs.get("litellm_call_id", None))
@ -120,7 +121,7 @@ def _prepare_ocr_request(
verbose_logger.debug(f"OCR call - model: {model}, provider: {custom_llm_provider}")
litellm_params = GenericLiteLLMParams(**kwargs)
litellm_params = GenericLiteLLMParams.model_validate(kwargs)
supported_params = ocr_provider_config.get_supported_ocr_params(model=model)
non_default_params = {}
@ -155,7 +156,7 @@ def _prepare_ocr_request(
api_key=api_key,
api_base=api_base,
custom_llm_provider=custom_llm_provider,
extra_headers=cast(dict[str, object] | None, extra_headers),
extra_headers=extra_headers,
provider_config=ocr_provider_config,
optional_params=cast(dict[str, object], optional_params),
litellm_params=dict(litellm_params),
@ -305,13 +306,13 @@ async def _run_rust_aocr(
@client
async def aocr(
model: str,
document: dict[str, Any],
document: Mapping[str, object],
api_key: str | None = None,
api_base: str | None = None,
timeout: Union[float, httpx.Timeout] | None = None,
timeout: float | httpx.Timeout | None = None,
custom_llm_provider: str | None = None,
extra_headers: dict[str, Any] | None = None,
**kwargs,
extra_headers: dict[str, object] | None = None,
**kwargs: object,
) -> OCRResponse:
"""
Async OCR function.
@ -567,14 +568,14 @@ def convert_file_document_to_url_document(document: dict[str, Any]) -> dict[str,
@client
def ocr(
model: str,
document: dict[str, Any],
document: Mapping[str, object],
api_key: str | None = None,
api_base: str | None = None,
timeout: Union[float, httpx.Timeout] | None = None,
timeout: float | httpx.Timeout | None = None,
custom_llm_provider: str | None = None,
extra_headers: dict[str, Any] | None = None,
**kwargs,
) -> Union[OCRResponse, Coroutine[Any, Any, OCRResponse]]:
extra_headers: dict[str, object] | None = None,
**kwargs: object,
) -> OCRResponse | Coroutine[object, object, OCRResponse]:
"""
Synchronous OCR function.

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@ collaborators acquire their globals per call, mirroring v1's lazy-import pattern
from __future__ import annotations
import asyncio
from collections.abc import Callable
from collections.abc import Callable, Mapping
from typing import TYPE_CHECKING
from litellm._logging import verbose_logger
@ -54,7 +54,7 @@ ServerLookup = Callable[[str], "MCPServer | None"]
StoreBuilder = Callable[[ServerLookup], tuple[InvalidatableOAuthTokenStore, bool]]
async def _read_credential(user_id: str, server_id: str) -> dict[str, object] | None:
async def _read_credential(user_id: str, server_id: str) -> Mapping[str, object] | None:
from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415
get_user_oauth_credential,
)

View file

@ -10,14 +10,14 @@ injected, so the DB/decoding plumbing stays testable and out of this seam.
from __future__ import annotations
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Mapping
from datetime import datetime, timezone
from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import (
OAuthToken,
)
CredentialReader = Callable[[str, str], Awaitable["dict[str, object] | None"]]
CredentialReader = Callable[[str, str], Awaitable["Mapping[str, object] | None"]]
def _iso_to_epoch(expires_at: str) -> float | None:
@ -39,7 +39,7 @@ def _to_scopes(raw: object) -> tuple[str, ...]:
return ()
def _to_oauth_token(payload: dict[str, object]) -> OAuthToken | None:
def _to_oauth_token(payload: Mapping[str, object]) -> OAuthToken | None:
access_token = payload.get("access_token")
if not isinstance(access_token, str):
return None

View file

@ -1,18 +1,11 @@
import asyncio
import importlib
from collections.abc import Awaitable, Callable, Mapping
from datetime import datetime
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
Dict,
List,
Literal,
Mapping,
Optional,
Set,
Tuple,
Union,
)
import httpx
@ -38,6 +31,9 @@ from litellm.proxy._experimental.mcp_server.utils import (
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
if TYPE_CHECKING:
from litellm.proxy._experimental.mcp_server.db import OAuthCredentialPayload
from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers
from litellm.types.mcp import MCPAuth
from litellm.types.utils import CallTypes
@ -97,12 +93,12 @@ if MCP_AVAILABLE:
########################################################
############ MCP Server REST API Routes #################
async def _safe_fire_mcp_tool_call_logging(
logging_obj: Optional[Any],
logging_obj: Any | None,
result: Any,
start_time: datetime,
end_time: datetime,
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
request_data: Optional[Mapping[str, object]] = None,
user_api_key_auth: UserAPIKeyAuth | None = None,
request_data: Mapping[str, object] | None = None,
) -> None:
if logging_obj is None:
return
@ -134,7 +130,7 @@ if MCP_AVAILABLE:
async def _handle_virtual_mcp_tool(
request: Request,
data: Dict[str, Any],
data: dict[str, Any],
tool_name: str,
user_api_key_dict: UserAPIKeyAuth,
) -> Any:
@ -212,9 +208,9 @@ if MCP_AVAILABLE:
def _get_server_auth_header(
server,
mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]],
mcp_auth_header: Optional[str],
) -> Optional[Union[Dict[str, str], str]]:
mcp_server_auth_headers: dict[str, dict[str, str]] | None,
mcp_auth_header: str | None,
) -> dict[str, str] | str | None:
"""Helper function to get server-specific auth header with case-insensitive matching."""
from litellm.proxy._experimental.mcp_server.utils import (
lookup_mcp_server_auth_in_headers,
@ -230,7 +226,7 @@ if MCP_AVAILABLE:
return server_auth
return mcp_auth_header
def _is_v1_resolved_oauth2_server(server: Optional[MCPServer]) -> bool:
def _is_v1_resolved_oauth2_server(server: MCPServer | None) -> bool:
"""Whether this server's per-user OAuth2 token is still resolved by v1.
A server the v2 resolver owns reads its stored token from the resolver at connect
@ -246,7 +242,7 @@ if MCP_AVAILABLE:
return False
return to_server_spec(server) is None
def _v1_resolved_oauth2_server_ids(allowed_server_ids: List[str]) -> Set[str]:
def _v1_resolved_oauth2_server_ids(allowed_server_ids: list[str]) -> set[str]:
"""Return the subset of *allowed_server_ids* whose per-user OAuth2 token is still
resolved by v1.
@ -260,10 +256,10 @@ if MCP_AVAILABLE:
}
async def _get_user_oauth_extra_headers(
server,
server: MCPServer,
user_api_key_dict: UserAPIKeyAuth,
prefetched_creds: Optional[Dict[str, Dict[str, Any]]] = None,
) -> Optional[Dict[str, str]]:
prefetched_creds: dict[str, "OAuthCredentialPayload"] | None = None,
) -> dict[str, str] | None:
"""
For OAuth2 servers, look up the user's stored access token and return it
as extra_headers {"Authorization": "Bearer <token>"} so that it reaches
@ -315,7 +311,7 @@ if MCP_AVAILABLE:
async def _prefetch_user_oauth_creds(
user_api_key_dict: UserAPIKeyAuth,
) -> Dict[str, Dict[str, Any]]:
) -> dict[str, "OAuthCredentialPayload"]:
"""Fetch all OAuth2 credentials for the user in a single DB query.
Returns a dict keyed by server_id. Used to avoid N+1 DB queries when
@ -379,8 +375,8 @@ if MCP_AVAILABLE:
def _resolve_mcp_server_id_for_rest(
server_id: str,
allowed_server_ids: Union[Set[str], List[str]],
client_ip: Optional[str] = None,
allowed_server_ids: set[str] | list[str],
client_ip: str | None = None,
) -> str:
"""
Map REST ``server_id`` (UUID, server_name, or alias) to canonical server_id.
@ -400,7 +396,7 @@ if MCP_AVAILABLE:
request: Request,
user_api_key_dict: UserAPIKeyAuth,
server_id: str,
) -> Tuple[List[MCPServer], str]:
) -> tuple[list[MCPServer], str]:
"""
Resolve allowed MCP servers for a tool call with IP filtering.
@ -471,7 +467,7 @@ if MCP_AVAILABLE:
)
# Build allowed_mcp_servers list (only include allowed servers)
allowed_mcp_servers: List[MCPServer] = []
allowed_mcp_servers: list[MCPServer] = []
for allowed_server_id in allowed_server_ids_set:
server = global_mcp_server_manager.get_mcp_server_by_id(allowed_server_id)
if server is not None:
@ -482,9 +478,9 @@ if MCP_AVAILABLE:
async def _get_tools_for_single_server(
server,
server_auth_header,
raw_headers: Optional[Dict[str, str]] = None,
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
extra_headers: Optional[Dict[str, str]] = None,
raw_headers: dict[str, str] | None = None,
user_api_key_auth: UserAPIKeyAuth | None = None,
extra_headers: dict[str, str] | None = None,
apply_tool_filters: bool = True,
):
"""Helper function to get tools for a single server.
@ -530,7 +526,7 @@ if MCP_AVAILABLE:
async def _resolve_allowed_mcp_servers_for_tool_call(
user_api_key_dict: UserAPIKeyAuth,
server_id: str,
) -> List[MCPServer]:
) -> list[MCPServer]:
"""Resolve allowed MCP servers for the given user and validate server_id access."""
auth_contexts = await build_effective_auth_contexts(user_api_key_dict)
allowed_server_ids_set = set()
@ -545,7 +541,7 @@ if MCP_AVAILABLE:
"message": f"The key is not allowed to access server {server_id}",
},
)
allowed_mcp_servers: List[MCPServer] = []
allowed_mcp_servers: list[MCPServer] = []
for allowed_server_id in allowed_server_ids_set:
server = global_mcp_server_manager.get_mcp_server_by_id(allowed_server_id)
if server is not None:
@ -554,10 +550,10 @@ if MCP_AVAILABLE:
async def _list_tools_for_single_server(
server_id: str,
allowed_server_ids: List[str],
rest_client_ip: Optional[str],
allowed_server_ids: list[str],
rest_client_ip: str | None,
mcp_server_auth_headers: dict,
mcp_auth_header: Optional[str],
mcp_auth_header: str | None,
raw_headers_from_request: dict,
user_api_key_dict: UserAPIKeyAuth,
apply_tool_filters: bool = True,
@ -644,12 +640,12 @@ if MCP_AVAILABLE:
"message": "Successfully retrieved tools",
}
def _as_query_str(value: Any) -> Optional[str]:
def _as_query_str(value: Any) -> str | None:
"""Coerce an Optional[str] Query param to str|None, dropping unresolved FastAPI defaults."""
return value if isinstance(value, str) else None
async def _resolve_toolset_scope(
toolset_name: Optional[str],
toolset_name: str | None,
user_api_key_dict: UserAPIKeyAuth,
) -> UserAPIKeyAuth:
"""Resolve ``toolset_name`` to its scoped ``UserAPIKeyAuth``, or return unchanged."""
@ -670,11 +666,9 @@ if MCP_AVAILABLE:
@router.get("/tools/list", dependencies=[Depends(user_api_key_auth)])
async def list_tool_rest_api(
request: Request,
server_id: Optional[str] = Query(None, description="The server id to list tools for"),
mcp_server_name: Optional[str] = Query(
None, description="Filter tools to a single MCP server by name or alias"
),
toolset_name: Optional[str] = Query(None, description="Filter tools to a single toolset by name"),
server_id: str | None = Query(None, description="The server id to list tools for"),
mcp_server_name: str | None = Query(None, description="Filter tools to a single MCP server by name or alias"),
toolset_name: str | None = Query(None, description="Filter tools to a single toolset by name"),
include_disabled_tools: bool = Query(
False,
description=(
@ -981,7 +975,7 @@ if MCP_AVAILABLE:
) = await _resolve_allowed_mcp_servers_with_ip_filter(request, user_api_key_dict, server_id)
# Look up per-user OAuth headers for this server (mirrors list_tool_rest_api).
user_oauth_extra_headers: Optional[Dict[str, str]] = None
user_oauth_extra_headers: dict[str, str] | None = None
target_server = next(
(s for s in allowed_mcp_servers if s.server_id == canonical_server_id),
None,
@ -1094,18 +1088,18 @@ if MCP_AVAILABLE:
(client_id, client_secret, scopes) any value may be ``None``.
"""
creds = request.credentials if isinstance(request.credentials, dict) else {}
client_id: Optional[str] = creds.get("client_id")
client_secret: Optional[str] = creds.get("client_secret")
client_id: str | None = creds.get("client_id")
client_secret: str | None = creds.get("client_secret")
scopes_raw = creds.get("scopes")
scopes: Optional[List[str]] = scopes_raw if isinstance(scopes_raw, list) else None
scopes: list[str] | None = scopes_raw if isinstance(scopes_raw, list) else None
return client_id, client_secret, scopes
async def _execute_with_mcp_client(
request: NewMCPServerRequest,
operation: Callable[..., Awaitable[Any]],
mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None,
oauth2_headers: Optional[Dict[str, str]] = None,
raw_headers: Optional[Dict[str, str]] = None,
mcp_auth_header: str | dict[str, str] | None = None,
oauth2_headers: dict[str, str] | None = None,
raw_headers: dict[str, str] | None = None,
) -> dict:
"""
Create a temporary MCP client from *request*, run *operation*, and return the result.
@ -1128,7 +1122,7 @@ if MCP_AVAILABLE:
try:
client_id, client_secret, scopes = _extract_credentials(request)
_oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = request.oauth2_flow or (
_oauth2_flow: Literal["client_credentials", "authorization_code"] | None = request.oauth2_flow or (
"client_credentials" if client_id and client_secret and request.token_url else None
)
# client_credentials requires token_url to fetch a token; without it the
@ -1244,7 +1238,7 @@ if MCP_AVAILABLE:
spec = await load_openapi_spec_async(spec_path)
paths = spec.get("paths", {})
components = spec.get("components", {})
tools: List[dict] = []
tools: list[dict] = []
used_names: set = set()
for path, path_item in paths.items():
for method in ("get", "post", "put", "delete", "patch"):
@ -1351,7 +1345,7 @@ if MCP_AVAILABLE:
headers = request.headers
mcp_auth_header: Optional[str] = None
mcp_auth_header: str | None = None
if new_mcp_server_request.auth_type in {
MCPAuth.api_key,
MCPAuth.bearer_token,
@ -1365,7 +1359,7 @@ if MCP_AVAILABLE:
# Authorization doubles as the admission fallback (LITELLM_API_KEY_HEADER_NAME_SECONDARY):
# when the primary x-litellm-api-key header is absent, the Authorization value is the
# caller's LiteLLM key, not an upstream token, and must never be forwarded upstream.
oauth2_headers: Optional[Dict[str, str]] = None
oauth2_headers: dict[str, str] | None = None
if new_mcp_server_request.auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES and headers.get(
MCPRequestHandler.LITELLM_API_KEY_HEADER_NAME_PRIMARY
):
@ -1376,8 +1370,8 @@ if MCP_AVAILABLE:
return await session.list_tools()
list_tools_response = await client.run_with_session(_list_tools_session_operation)
list_tools_result: List[MCPTool] = list_tools_response.tools
model_dumped_tools: List[dict] = [tool.model_dump() for tool in list_tools_result]
list_tools_result: list[MCPTool] = list_tools_response.tools
model_dumped_tools: list[dict] = [tool.model_dump() for tool in list_tools_result]
return {
"tools": model_dumped_tools,
"error": None,

File diff suppressed because it is too large Load diff

View file

@ -26858,12 +26858,6 @@
}
],
"title": "Start Date"
},
"total_spend": {
"default": 0.0,
"description": "Deduplicated spend of every request that called at least one tool in the window; less than the sum of per-tool attributed spend whenever multi-tool requests exist",
"title": "Total Spend",
"type": "number"
}
},
"title": "ToolSpendResponse",
@ -27417,7 +27411,7 @@
},
"/v1/tool/spend": {
"get": {
"description": "Spend attributed to each tool over a date range, for the Cost Optimization dashboard.\n\nJoins ``LiteLLM_SpendLogToolIndex`` (which tool names ran on which request) to\n``LiteLLM_SpendLogs`` (what the request cost). A request that used multiple tools\ncounts its full spend toward each of those tools, so per-tool numbers are\nattributions. ``total_spend`` is the deduplicated spend of every request that\ncalled at least one tool in the window, so it never double counts.\n\n``start_date`` is clamped to at most 30 days before ``end_date`` (serving up to\n31 calendar dates inclusive, the same width as the endpoint's default window):\na wider requested range is clamped, and the response's ``start_date`` reflects\nthe effective window actually served.",
"description": "Spend attributed to each tool over a date range, for the Cost Optimization dashboard.\n\nReads the ``LiteLLM_DailyToolSpend`` rollup, written at request time from invoked\ntools only (MCP tool calls and response tool_calls; declaring a tool without\ninvoking it does not count). A request that invoked multiple tools counts its\nfull spend toward each of them, so per-tool numbers are attributions and do not\nsum to a deduplicated total.\n\n``by_tool`` is the top ``TOOL_SPEND_TOP_TOOLS`` tools by spend, aggregated in\nSQL, and ``daily`` covers only those tools, so the response is bounded by\ndays x TOOL_SPEND_TOP_TOOLS regardless of the requested range or how many\ndistinct tool names exist.",
"operationId": "get_tool_spend_v1_tool_spend_get",
"parameters": [
{
@ -27588,7 +27582,7 @@
},
"/v1/tool/{tool_name}/logs": {
"get": {
"description": "Return paginated spend logs for requests that used this tool (from SpendLogToolIndex).",
"description": "Return paginated spend logs for requests that invoked this tool (from SpendLogToolIndex).\nDeclaring a tool in a request body without the model invoking it does not create an entry.",
"operationId": "get_tool_usage_logs_v1_tool__tool_name__logs_get",
"parameters": [
{

View file

@ -3654,6 +3654,13 @@ DB_CONNECTION_ERROR_TYPES = (
httpx.ReadTimeout,
)
# What a NON-IDEMPOTENT write (increment upsert) may retry: only ConnectError
# proves the statements never reached the database. Post-send errors are
# ambiguous; a stalled statement can leave its transaction open on the pooled
# connection, where a retry stacks a second increment set into the same commit.
# Idempotent writes (create_many with skip_duplicates) may retry the full tuple.
DB_RETRY_SAFE_ERROR_TYPES = (httpx.ConnectError,)
class SSOUserDefinedValues(TypedDict):
models: List[str]

View file

@ -1,7 +1,8 @@
import hashlib
import json
from collections.abc import Iterator, Mapping, Sequence
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from typing import Any, Protocol, TypedDict
import litellm
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
@ -13,9 +14,81 @@ from litellm.repositories.table_repositories import AgentsRepository
from litellm.types.agents import AgentConfig, AgentResponse, PatchAgentRequest
class AgentObjectPermissionRecord(Protocol):
def model_dump(self) -> dict[str, object]: ...
def dict(self) -> dict[str, object]: ...
class AgentRecordDump(TypedDict):
agent_id: str
agent_name: str
litellm_params: dict[str, object] | None
agent_card_params: dict[str, object]
static_headers: dict[str, str] | None
extra_headers: list[str] | None
object_permission: dict[str, object] | None
spend: float
tpm_limit: int | None
rpm_limit: int | None
session_tpm_limit: int | None
session_rpm_limit: int | None
created_at: datetime
updated_at: datetime
created_by: str | None
updated_by: str | None
class AgentRecord(Protocol):
agent_id: str
agent_name: str
object_permission_id: str | None
object_permission: AgentObjectPermissionRecord | None
spend: float
def model_dump(self) -> AgentRecordDump: ...
def __iter__(self) -> Iterator[tuple[str, object]]: ...
class AgentTableClient(Protocol):
async def create(
self,
data: Mapping[str, object],
include: Mapping[str, bool] | None = None,
) -> AgentRecord: ...
async def find_unique(
self,
where: Mapping[str, object],
include: Mapping[str, bool] | None = None,
) -> AgentRecord | None: ...
async def find_many(
self,
where: Mapping[str, object] | None = None,
order: Mapping[str, str] | None = None,
include: Mapping[str, bool] | None = None,
) -> Sequence[AgentRecord]: ...
async def update(
self,
where: Mapping[str, object],
data: Mapping[str, object],
include: Mapping[str, bool] | None = None,
) -> AgentRecord: ...
async def delete(self, where: Mapping[str, object]) -> AgentRecord: ...
def agents_table(prisma_client: PrismaClient) -> AgentTableClient:
table: AgentTableClient = AgentsRepository(prisma_client).table
return table
class AgentRegistry:
def __init__(self):
self.agent_list: List[AgentResponse] = []
self.agent_list: list[AgentResponse] = []
def reset_agent_list(self):
self.agent_list = []
@ -26,13 +99,13 @@ class AgentRegistry:
def deregister_agent(self, agent_name: str):
self.agent_list = [agent for agent in self.agent_list if agent.agent_name != agent_name]
def get_agent_list(self, agent_names: Optional[List[str]] = None):
def get_agent_list(self, agent_names: Sequence[str] | None = None):
if agent_names is not None:
return [agent for agent in self.agent_list if agent.agent_name in agent_names]
return self.agent_list
def get_public_agent_list(self) -> List[AgentResponse]:
public_agent_list: List[AgentResponse] = []
def get_public_agent_list(self) -> list[AgentResponse]:
public_agent_list: list[AgentResponse] = []
if litellm.public_agent_groups is None:
return public_agent_list
for agent in self.agent_list:
@ -43,7 +116,7 @@ class AgentRegistry:
def _create_agent_id(self, agent_config: AgentConfig) -> str:
return hashlib.sha256(json.dumps(agent_config, sort_keys=True).encode()).hexdigest()
def load_agents_from_config(self, agent_config: Optional[List[AgentConfig]] = None):
def load_agents_from_config(self, agent_config: Sequence[AgentConfig] | None = None):
if agent_config is None:
return None
@ -63,8 +136,8 @@ class AgentRegistry:
def load_agents_from_db_and_config(
self,
agent_config: Optional[List[AgentConfig]] = None,
db_agents: Optional[List[Dict[str, Any]]] = None,
agent_config: Sequence[AgentConfig] | None = None,
db_agents: list[dict[str, Any]] | None = None,
):
self.reset_agent_list()
@ -96,7 +169,7 @@ class AgentRegistry:
agent: AgentConfig,
prisma_client: PrismaClient,
created_by: str,
agent_id: Optional[str] = None,
agent_id: str | None = None,
) -> AgentResponse:
"""
Add an agent to the database.
@ -126,18 +199,18 @@ class AgentRegistry:
agent_card_params: str = safe_dumps(agent_card_params_dict)
# Handle object_permission (MCP tool access for agent)
object_permission_id: Optional[str] = None
object_permission_id: str | None = None
if agent.get("object_permission") is not None:
agent_copy = dict(agent)
object_permission_id = await handle_update_object_permission_common(agent_copy, None, prisma_client)
# Serialize static_headers
static_headers_obj = agent.get("static_headers")
static_headers_val: Optional[str] = safe_dumps(dict(static_headers_obj)) if static_headers_obj else None
static_headers_val: str | None = safe_dumps(dict(static_headers_obj)) if static_headers_obj else None
extra_headers_val: Optional[List[str]] = agent.get("extra_headers")
extra_headers_val = agent.get("extra_headers")
create_data: Dict[str, Any] = {
create_data: dict[str, object] = {
"agent_name": agent_name,
"litellm_params": litellm_params,
"agent_card_params": agent_card_params,
@ -166,7 +239,7 @@ class AgentRegistry:
create_data[rate_field] = _val
# Create agent in DB
created_agent = await AgentsRepository(prisma_client).table.create(
created_agent = await agents_table(prisma_client).create(
data=create_data,
include={"object_permission": True},
)
@ -181,12 +254,12 @@ class AgentRegistry:
except Exception as e:
raise Exception(f"Error adding agent to DB: {str(e)}")
async def delete_agent_from_db(self, agent_id: str, prisma_client: PrismaClient) -> Dict[str, Any]:
async def delete_agent_from_db(self, agent_id: str, prisma_client: PrismaClient) -> Mapping[str, object]:
"""
Delete an agent from the database
"""
try:
deleted_agent = await AgentsRepository(prisma_client).table.delete(where={"agent_id": agent_id})
deleted_agent = await agents_table(prisma_client).delete(where={"agent_id": agent_id})
return dict(deleted_agent)
except Exception as e:
raise Exception(f"Error deleting agent from DB: {str(e)}")
@ -221,7 +294,7 @@ class AgentRegistry:
raise Exception(f"Agent with ID {agent_id} not found")
augment_agent = {**existing_agent, **agent}
update_data: Dict[str, Any] = {}
update_data: dict[str, Any] = {}
if augment_agent.get("agent_name"):
update_data["agent_name"] = augment_agent.get("agent_name")
if augment_agent.get("litellm_params"):
@ -254,7 +327,7 @@ class AgentRegistry:
if object_permission_id is not None:
update_data["object_permission_id"] = object_permission_id
# Patch agent in DB
patched_agent = await AgentsRepository(prisma_client).table.update(
patched_agent = await agents_table(prisma_client).update(
where={"agent_id": agent_id},
data={
**update_data,
@ -307,9 +380,9 @@ class AgentRegistry:
static_headers_val_u: str = (
safe_dumps(dict(static_headers_obj_u)) if static_headers_obj_u is not None else safe_dumps({})
)
extra_headers_val_u: List[str] = agent.get("extra_headers") or []
extra_headers_val_u = agent.get("extra_headers") or []
update_data: Dict[str, Any] = {
update_data: dict[str, object] = {
"agent_name": agent_name,
"litellm_params": litellm_params,
"agent_card_params": agent_card_params,
@ -330,7 +403,7 @@ class AgentRegistry:
update_data[rate_field] = _val
if agent.get("object_permission") is not None:
existing_agent = await AgentsRepository(prisma_client).table.find_unique(where={"agent_id": agent_id})
existing_agent = await agents_table(prisma_client).find_unique(where={"agent_id": agent_id})
existing_object_permission_id = (
existing_agent.object_permission_id if existing_agent is not None else None
)
@ -344,7 +417,7 @@ class AgentRegistry:
update_data["object_permission_id"] = object_permission_id
# Update agent in DB
updated_agent = await AgentsRepository(prisma_client).table.update(
updated_agent = await agents_table(prisma_client).update(
where={"agent_id": agent_id},
data=update_data,
include={"object_permission": True},
@ -363,17 +436,17 @@ class AgentRegistry:
@staticmethod
async def get_all_agents_from_db(
prisma_client: PrismaClient,
) -> List[Dict[str, Any]]:
) -> list[dict[str, object]]:
"""
Get all agents from the database
"""
try:
agents_from_db = await AgentsRepository(prisma_client).table.find_many(
agents_from_db = await agents_table(prisma_client).find_many(
order={"created_at": "desc"},
include={"object_permission": True},
)
agents: List[Dict[str, Any]] = []
agents: list[dict[str, object]] = []
for agent in agents_from_db:
agent_dict = dict(agent)
# object_permission is eagerly loaded via include above
@ -391,7 +464,7 @@ class AgentRegistry:
def get_agent_by_id(
self,
agent_id: str,
) -> Optional[AgentResponse]:
) -> AgentResponse | None:
"""
Get an agent by its ID from the database
"""
@ -404,7 +477,7 @@ class AgentRegistry:
except Exception as e:
raise Exception(f"Error getting agent from DB: {str(e)}")
def get_agent_by_name(self, agent_name: str) -> Optional[AgentResponse]:
def get_agent_by_name(self, agent_name: str) -> AgentResponse | None:
"""
Get an agent by its name from the database
"""

View file

@ -11,9 +11,11 @@ Follows the A2A Spec.
import asyncio
import os
import uuid
from typing import Any, Dict, List, Mapping
from collections.abc import Mapping, Sequence
from typing import TypedDict
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from typing_extensions import Required
import litellm
from litellm._logging import verbose_proxy_logger
@ -30,6 +32,7 @@ from litellm.proxy.common_utils.rbac_utils import check_feature_access_for_user
from litellm.proxy.management_endpoints.common_daily_activity import get_daily_activity
from litellm.proxy.utils import get_custom_url
from litellm.types.agents import (
AgentCard,
AgentConfig,
AgentKeySummary,
AgentMakePublicResponse,
@ -49,7 +52,7 @@ def _proxy_base_url(http_request: Request) -> str:
return get_custom_url(str(http_request.base_url), route=None)
def _validate_protocol_version(upstream_card: Mapping[str, Any] | None) -> None:
def _validate_protocol_version(upstream_card: AgentCard | None) -> None:
"""Reject an agent card pinning an unsupported A2A protocol version."""
version = upstream_card.get("protocolVersion") if upstream_card else None
if version is not None and normalize_protocol_version(version) is None:
@ -63,12 +66,12 @@ def _validate_protocol_version(upstream_card: Mapping[str, Any] | None) -> None:
def _build_merged_agent_card(
upstream_card: Mapping[str, Any] | None,
upstream_card: AgentCard | None,
*,
agent_id: str,
http_request: Request,
agent_name: str | None = None,
) -> Dict[str, Any]:
) -> dict[str, object]:
"""Apply the LiteLLM-fronting merge to ``upstream_card`` for ``agent_id``."""
proxy_base = _proxy_base_url(http_request)
_validate_protocol_version(upstream_card)
@ -88,7 +91,7 @@ def _build_merged_agent_card(
router = APIRouter()
async def _attach_keys_to_agents(agents: list[AgentResponse], prisma_client) -> None:
async def _attach_keys_to_agents(agents: Sequence[AgentResponse], prisma_client) -> None:
"""Attach each agent's virtual keys, derived from the key table's agent_id
foreign key. Mirrors how spend is joined into the agent response so the UI
never has to cross-reference a full key dump client-side. Only non-secret
@ -113,7 +116,7 @@ async def _attach_keys_to_agents(agents: list[AgentResponse], prisma_client) ->
def _redact_sensitive_agent_fields(
agents: list[AgentResponse],
agents: Sequence[AgentResponse],
) -> list[AgentResponse]:
"""
Return copies of the given agents with sensitive configuration fields
@ -156,9 +159,15 @@ AGENT_HEALTH_CHECK_TIMEOUT_SECONDS = float(os.environ.get("LITELLM_AGENT_HEALTH_
AGENT_HEALTH_CHECK_GATHER_TIMEOUT_SECONDS = float(os.environ.get("LITELLM_AGENT_HEALTH_CHECK_GATHER_TIMEOUT", "30.0"))
class _AgentHealthResult(TypedDict, total=False):
agent_id: Required[str]
healthy: Required[bool]
error: str
async def _check_agent_url_health(
agent: AgentResponse,
) -> Dict[str, Any]:
) -> _AgentHealthResult:
"""
Perform a GET request against the agent's URL and return the health result.
@ -194,7 +203,7 @@ async def _check_agent_url_health(
"/v1/agents",
tags=["[beta] A2A Agents"],
dependencies=[Depends(user_api_key_auth)],
response_model=List[AgentResponse],
response_model=list[AgentResponse],
)
async def get_agents(
request: Request,
@ -230,7 +239,7 @@ async def get_agents(
)
try:
returned_agents: List[AgentResponse] = []
returned_agents: list[AgentResponse] = []
# Admin users get all agents
if (
@ -256,7 +265,7 @@ async def get_agents(
if prisma_client is not None:
agent_ids = [agent.agent_id for agent in returned_agents]
if agent_ids:
db_agents = await AgentsRepository(prisma_client).table.find_many(
db_agents = await agents_table(prisma_client).find_many(
where={"agent_id": {"in": agent_ids}},
)
spend_map = {a.agent_id: a.spend for a in db_agents}
@ -285,7 +294,7 @@ async def get_agents(
agents_with_url = [agent for agent in returned_agents if (agent.agent_card_params or {}).get("url")]
agents_without_url = [agent for agent in returned_agents if not (agent.agent_card_params or {}).get("url")]
try:
health_results = await asyncio.wait_for(
health_results: Sequence[_AgentHealthResult] = await asyncio.wait_for(
asyncio.gather(*[_check_agent_url_health(agent) for agent in agents_with_url]),
timeout=AGENT_HEALTH_CHECK_GATHER_TIMEOUT_SECONDS,
)
@ -317,10 +326,12 @@ async def get_agents(
#### CRUD ENDPOINTS FOR AGENTS ####
from litellm.proxy.agent_endpoints.agent_registry import (
agents_table,
)
from litellm.proxy.agent_endpoints.agent_registry import (
global_agent_registry as AGENT_REGISTRY,
)
from litellm.repositories.table_repositories import AgentsRepository
@router.post(
@ -487,7 +498,7 @@ async def get_agent_by_id(
try:
agent = AGENT_REGISTRY.get_agent_by_id(agent_id=agent_id)
if agent is None:
agent_row = await AgentsRepository(prisma_client).table.find_unique(
agent_row = await agents_table(prisma_client).find_unique(
where={"agent_id": agent_id},
include={"object_permission": True},
)
@ -501,7 +512,7 @@ async def get_agent_by_id(
agent = AgentResponse(**agent_dict) # type: ignore
else:
# Agent found in memory — refresh spend from DB
db_row = await AgentsRepository(prisma_client).table.find_unique(where={"agent_id": agent_id})
db_row = await agents_table(prisma_client).find_unique(where={"agent_id": agent_id})
if db_row is not None:
agent.spend = db_row.spend
@ -578,7 +589,7 @@ async def update_agent(
try:
# Check if agent exists
existing_agent = await AgentsRepository(prisma_client).table.find_unique(where={"agent_id": agent_id})
existing_agent = await agents_table(prisma_client).find_unique(where={"agent_id": agent_id})
if existing_agent is not None:
existing_agent = dict(existing_agent)
@ -680,7 +691,7 @@ async def patch_agent(
try:
# Check if agent exists
existing_agent = await AgentsRepository(prisma_client).table.find_unique(where={"agent_id": agent_id})
existing_agent = await agents_table(prisma_client).find_unique(where={"agent_id": agent_id})
if existing_agent is not None:
existing_agent = dict(existing_agent)
@ -767,9 +778,9 @@ async def delete_agent(
try:
# Check if agent exists
existing_agent = await AgentsRepository(prisma_client).table.find_unique(where={"agent_id": agent_id})
existing_agent = await agents_table(prisma_client).find_unique(where={"agent_id": agent_id})
if existing_agent is not None:
existing_agent = dict[Any, Any](existing_agent)
existing_agent = dict[str, object](existing_agent)
if existing_agent is None:
raise HTTPException(status_code=404, detail=f"Agent with ID {agent_id} not found in DB.")
@ -849,7 +860,7 @@ async def make_agent_public(
agent = AGENT_REGISTRY.get_agent_by_id(agent_id=agent_id)
if agent is None:
# check if agent exists in DB
agent = await AgentsRepository(prisma_client).table.find_unique(where={"agent_id": agent_id})
agent = await agents_table(prisma_client).find_unique(where={"agent_id": agent_id})
if agent is not None:
agent = AgentResponse(**agent.model_dump()) # type: ignore
@ -966,7 +977,7 @@ async def make_agents_public(
agent = AGENT_REGISTRY.get_agent_by_id(agent_id=agent_id)
if agent is None:
# check if agent exists in DB
agent = await AgentsRepository(prisma_client).table.find_unique(where={"agent_id": agent_id})
agent = await agents_table(prisma_client).find_unique(where={"agent_id": agent_id})
if agent is not None:
agent = AgentResponse(**agent.model_dump()) # type: ignore
@ -1031,7 +1042,7 @@ async def get_agent_daily_activity(
)
agent_ids_list = agent_ids.split(",") if agent_ids else None
exclude_agent_ids_list: List[str] | None = None
exclude_agent_ids_list: list[str] | None = None
if exclude_agent_ids:
exclude_agent_ids_list = exclude_agent_ids.split(",") if exclude_agent_ids else None
@ -1044,7 +1055,7 @@ async def get_agent_daily_activity(
)
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
where_condition: Dict[str, Any] = {}
where_condition: dict[str, object] = {}
if not _user_has_admin_view(user_api_key_dict):
permitted_agent_ids = await AgentRequestHandler.get_allowed_agents(user_api_key_auth=user_api_key_dict)
# `get_allowed_agents` returns an empty list when the caller's key
@ -1058,7 +1069,7 @@ async def get_agent_daily_activity(
if user_api_key_dict.user_id is None:
permitted_agent_ids = []
else:
owned_records = await AgentsRepository(prisma_client).table.find_many(
owned_records = await agents_table(prisma_client).find_many(
where={"created_by": user_api_key_dict.user_id}
)
permitted_agent_ids = [a.agent_id for a in owned_records]
@ -1093,8 +1104,10 @@ async def get_agent_daily_activity(
if agent_ids_list:
where_condition["agent_id"] = {"in": list(agent_ids_list)}
agent_records = await AgentsRepository(prisma_client).table.find_many(where=where_condition)
agent_metadata = {agent.agent_id: {"agent_name": agent.agent_name} for agent in agent_records}
agent_records = await agents_table(prisma_client).find_many(where=where_condition)
agent_metadata: Mapping[str, dict[str, object]] = {
agent.agent_id: {"agent_name": agent.agent_name} for agent in agent_records
}
return await get_daily_activity(
prisma_client=prisma_client,

View file

@ -31,6 +31,10 @@ _EXTRA_SENSITIVE_CALLBACK_KEYS = {"gcs_path_service_account"}
# already-encrypted input cheaply (no decrypt-attempt round trip) and
# avoid double-encrypting if `LITELLM_SALT_KEY` is rotated between writes.
_CALLBACK_VAR_ENCRYPTED_PREFIX = "litellm_enc::"
# Metadata slots that hold operator-configured callback setup (and therefore
# integration credentials). Resolved from UserAPIKeyAuth during pre-call setup,
# never read back off the copies stamped into request metadata.
_CALLBACK_CONFIG_SLOTS = frozenset({"logging", "callback_settings"})
blue_color_code = "\033[94m"
reset_color_code = "\033[0m"
@ -547,6 +551,13 @@ def normalize_callback_names(callbacks: Iterable[Any]) -> List[Any]:
return [c.lower() if isinstance(c, str) else c for c in callbacks]
def strip_callback_config(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
"""Return key/team metadata without the slots that carry callback credentials."""
if not isinstance(metadata, dict):
return metadata
return {k: v for k, v in metadata.items() if k not in _CALLBACK_CONFIG_SLOTS}
def encrypt_callback_vars(metadata: Any) -> Any:
"""Return a deep copy of metadata with callback_vars values encrypted at rest.

View file

@ -34,7 +34,7 @@ from litellm.constants import (
)
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.proxy._types import (
DB_CONNECTION_ERROR_TYPES,
DB_RETRY_SAFE_ERROR_TYPES,
BaseDailySpendTransaction,
DailyAgentSpendTransaction,
DailyEndUserSpendTransaction,
@ -182,6 +182,12 @@ class DBSpendUpdateWriter:
payload=payload,
prisma_client=prisma_client,
)
await self._enqueue_tool_usage_transaction(
payload=payload,
completion_response=completion_response,
prisma_client=prisma_client,
kwargs=kwargs,
)
else:
verbose_proxy_logger.debug(
"disable_spend_logs=True. Skipping writing spend logs to db. Other spend updates - Key/User/Team table will still occur."
@ -223,6 +229,36 @@ class DBSpendUpdateWriter:
end_user_id,
)
async def _enqueue_tool_usage_transaction(
self,
payload: SpendLogsPayload,
completion_response: "litellm.ModelResponse | Any | Exception | None",
prisma_client: "PrismaClient | None",
kwargs: "dict | None" = None,
) -> None:
try:
if prisma_client is None:
return
from litellm.proxy.db.spend_log_tool_index import (
build_tool_usage_transaction,
)
transaction = build_tool_usage_transaction(
request_id=payload["request_id"],
start_time_iso=str(payload["startTime"]),
mcp_namespaced_tool_name=payload.get("mcp_namespaced_tool_name"),
spend=payload["spend"],
total_tokens=payload["total_tokens"],
completion_response=completion_response,
realtime_tool_calls=(kwargs or {}).get("realtime_tool_calls"),
)
if transaction is None:
return
async with prisma_client._tool_usage_transactions_lock:
prisma_client.tool_usage_transactions.append(transaction)
except Exception as e:
verbose_proxy_logger.debug("_enqueue_tool_usage_transaction error (non-blocking): %s", e)
def _enqueue_tool_registry_upsert(
self,
kwargs: Optional[dict],
@ -299,21 +335,10 @@ class DBSpendUpdateWriter:
_enqueue(name)
# --- Response tool_calls (OpenAI format; Anthropic pass-through converts tool_use here) ---
if completion_response is not None and hasattr(completion_response, "choices"):
for choice in completion_response.choices or []:
message = getattr(choice, "message", None)
if message is None:
continue
tool_calls = getattr(message, "tool_calls", None)
if not tool_calls:
continue
for tc in tool_calls:
fn = getattr(tc, "function", None)
if fn is None:
continue
tool_name = getattr(fn, "name", None)
if tool_name:
_enqueue(tool_name)
from litellm.proxy.db.spend_log_tool_index import response_tool_call_names
for tool_name in response_tool_call_names(completion_response):
_enqueue(tool_name)
except Exception as e:
verbose_proxy_logger.debug("_enqueue_tool_registry_upsert error (non-blocking): %s", e)
@ -1096,7 +1121,7 @@ class DBSpendUpdateWriter:
data={"spend": {"increment": response_cost}},
)
break
except DB_CONNECTION_ERROR_TYPES as e:
except DB_RETRY_SAFE_ERROR_TYPES as e:
if i >= n_retry_times: # If we've reached the maximum number of retries
_raise_failed_update_spend_exception(
e=e,
@ -1139,7 +1164,7 @@ class DBSpendUpdateWriter:
},
)
break
except DB_CONNECTION_ERROR_TYPES as e:
except DB_RETRY_SAFE_ERROR_TYPES as e:
if i >= n_retry_times: # If we've reached the maximum number of retries
_raise_failed_update_spend_exception(
e=e,
@ -1172,7 +1197,7 @@ class DBSpendUpdateWriter:
data={"spend": {"increment": response_cost}},
)
break
except DB_CONNECTION_ERROR_TYPES as e:
except DB_RETRY_SAFE_ERROR_TYPES as e:
if i >= n_retry_times: # If we've reached the maximum number of retries
_raise_failed_update_spend_exception(
e=e,
@ -1219,7 +1244,7 @@ class DBSpendUpdateWriter:
)
# Transaction succeeded, break out of retry loop
break
except DB_CONNECTION_ERROR_TYPES as e:
except DB_RETRY_SAFE_ERROR_TYPES as e:
if i >= n_retry_times: # If we've reached the maximum number of retries
_raise_failed_update_spend_exception(
e=e,
@ -1261,7 +1286,7 @@ class DBSpendUpdateWriter:
data={"spend": {"increment": response_cost}},
)
break
except DB_CONNECTION_ERROR_TYPES as e:
except DB_RETRY_SAFE_ERROR_TYPES as e:
if i >= n_retry_times: # If we've reached the maximum number of retries
_raise_failed_update_spend_exception(
e=e,
@ -1347,7 +1372,7 @@ class DBSpendUpdateWriter:
data={"spend": {"increment": response_cost}},
)
break
except DB_CONNECTION_ERROR_TYPES as e:
except DB_RETRY_SAFE_ERROR_TYPES as e:
if i >= n_retry_times:
_raise_failed_update_spend_exception(
e=e,
@ -1644,7 +1669,7 @@ class DBSpendUpdateWriter:
break
except DB_CONNECTION_ERROR_TYPES as e:
except DB_RETRY_SAFE_ERROR_TYPES as e:
if i >= n_retry_times:
_raise_failed_update_spend_exception(
e=e,

View file

@ -1,140 +1,150 @@
"""
Track tool usage for the dashboard: insert into SpendLogToolIndex when spend logs
are written, so "last N requests for tool X" and "how is this tool called in production"
queries are fast.
Tool usage tracking for the dashboard.
At request time the spend writer builds one ToolUsageTransaction per request that
invoked tools (MCP namespaced tool name plus response tool_calls; declared-but-not-
invoked tools are excluded) and queues it on the prisma client. The spend-log flush
job drains the queue into LiteLLM_SpendLogToolIndex (per-request drill-down) and
LiteLLM_DailyToolSpend (the per-day rollup the Cost Optimization card reads) in a
single transaction, so a failed flush never leaves a partial rollup increment.
"""
from __future__ import annotations
import asyncio
import random
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, List, Set
from itertools import groupby
from typing import TYPE_CHECKING, Any, Sequence
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.proxy.utils import PrismaClient
from litellm.repositories.table_repositories import SpendLogToolIndexRepository
from litellm.proxy._types import DB_RETRY_SAFE_ERROR_TYPES
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
def _add_tool_calls_to_set(tool_calls: Any, out: Set[str]) -> None:
"""Extract tool names from OpenAI-style tool_calls list into out."""
if not isinstance(tool_calls, list):
return
for tc in tool_calls:
if not isinstance(tc, dict):
continue
fn = tc.get("function")
if isinstance(fn, dict):
name = fn.get("name")
if name and isinstance(name, str) and name.strip():
out.add(name.strip())
@dataclass(frozen=True, slots=True)
class ToolUsageTransaction:
request_id: str
date: str
start_time: datetime
tool_names: tuple[str, ...]
spend: float
total_tokens: int
def _parse_tool_names_from_payload(payload: Dict[str, Any]) -> Set[str]:
"""
Extract deduplicated tool names from a spend log payload.
Sources: mcp_namespaced_tool_name, response (tool_calls), proxy_server_request (tools).
"""
tool_names: Set[str] = set()
def response_tool_call_names(completion_response: Any) -> tuple[str, ...]:
"""Tool names invoked in a completion response, in call order, for any response
surface get_tool_calls_from_response understands (chat completions, Responses
API output items, Anthropic Messages tool_use blocks). Reads every choice of
an ``n>1`` chat response: each choice cost money and its tool calls ran."""
if completion_response is None or isinstance(completion_response, Exception):
return ()
from litellm.litellm_core_utils.prompt_templates.factory import (
get_tool_calls_from_response,
)
# Top-level MCP tool name (single tool per request for that flow)
mcp_name = payload.get("mcp_namespaced_tool_name")
if mcp_name and isinstance(mcp_name, str) and mcp_name.strip():
tool_names.add(mcp_name.strip())
# Response: OpenAI-style tool_calls[].function.name or choices[0].message.tool_calls
response_raw = payload.get("response")
if response_raw:
response_obj = safe_json_loads(response_raw, default=None) if isinstance(response_raw, str) else response_raw
if isinstance(response_obj, dict):
_add_tool_calls_to_set(response_obj.get("tool_calls"), tool_names)
choices = response_obj.get("choices")
if isinstance(choices, list) and choices:
msg = choices[0].get("message") if isinstance(choices[0], dict) else None
if isinstance(msg, dict):
_add_tool_calls_to_set(msg.get("tool_calls"), tool_names)
# Request body: tools[].function.name
request_raw = payload.get("proxy_server_request")
if request_raw:
request_obj = safe_json_loads(request_raw, default=None) if isinstance(request_raw, str) else request_raw
if isinstance(request_obj, dict):
body = request_obj.get("body", request_obj)
if isinstance(body, dict):
request_obj = body
if isinstance(request_obj, dict):
tools = request_obj.get("tools")
if isinstance(tools, list):
for t in tools:
if isinstance(t, dict):
fn = t.get("function")
if isinstance(fn, dict):
name = fn.get("name")
if name and isinstance(name, str) and name.strip():
tool_names.add(name.strip())
return tool_names
return tuple(
stripped
for tool_call in get_tool_calls_from_response(completion_response, include_all_choices=True)
if isinstance(name := tool_call.get("name"), str) and (stripped := name.strip())
)
async def process_spend_logs_tool_usage(
prisma_client: PrismaClient,
logs_to_process: List[Dict[str, Any]],
) -> None:
"""
After spend logs are written: insert SpendLogToolIndex rows from each payload.
Extracts tool names from mcp_namespaced_tool_name, response tool_calls, and
proxy_server_request tools.
"""
if not logs_to_process:
return
index_rows: List[Dict[str, Any]] = []
for payload in logs_to_process:
request_id = payload.get("request_id")
start_time = payload.get("startTime")
if not request_id or not start_time:
continue
if isinstance(start_time, str):
try:
start_time = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
except (ValueError, TypeError):
continue
if start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
tool_names = _parse_tool_names_from_payload(payload)
for tool_name in tool_names:
index_rows.append(
{
"request_id": request_id,
"tool_name": tool_name,
"start_time": start_time,
}
)
if not index_rows:
return
def build_tool_usage_transaction(
request_id: str,
start_time_iso: str,
mcp_namespaced_tool_name: str | None,
spend: float,
total_tokens: int,
completion_response: Any,
realtime_tool_calls: Any = None,
) -> ToolUsageTransaction | None:
"""None when the request invoked no tools. Realtime sessions carry invoked
tools in kwargs["realtime_tool_calls"] (OpenAI tool_calls shape) rather than
on a response object, so they are normalized through the same owner by
wrapping them in the chat-completion shape. Date derivation must match the
daily spend writer's ``startTime.split("T")[0]`` so rollup rows land in the
same UTC day bucket as LiteLLM_DailyUserSpend."""
mcp_names = (
(mcp_namespaced_tool_name.strip(),) if mcp_namespaced_tool_name and mcp_namespaced_tool_name.strip() else ()
)
realtime_names = (
response_tool_call_names({"choices": [{"message": {"tool_calls": realtime_tool_calls}}]})
if realtime_tool_calls
else ()
)
tool_names = tuple(dict.fromkeys(mcp_names + response_tool_call_names(completion_response) + realtime_names))
if not tool_names:
return None
try:
index_data = []
for r in index_rows:
st = r["start_time"]
if isinstance(st, str):
try:
st = datetime.fromisoformat(st.replace("Z", "+00:00"))
except (ValueError, TypeError):
continue
if st.tzinfo is None:
st = st.replace(tzinfo=timezone.utc)
index_data.append(
{
"request_id": r["request_id"],
"tool_name": r["tool_name"],
"start_time": st,
}
)
if index_data:
await SpendLogToolIndexRepository(prisma_client).table.create_many(
data=index_data,
skip_duplicates=True,
)
except Exception as e:
verbose_proxy_logger.warning("Tool usage tracking (SpendLogToolIndex) failed (non-fatal): %s", e)
start_time = datetime.fromisoformat(start_time_iso.replace("Z", "+00:00"))
except ValueError:
return None
return ToolUsageTransaction(
request_id=request_id,
date=start_time_iso.split("T")[0],
start_time=start_time if start_time.tzinfo else start_time.replace(tzinfo=timezone.utc),
tool_names=tool_names,
spend=spend,
total_tokens=total_tokens,
)
async def flush_tool_usage_transactions(
prisma_client: PrismaClient,
transactions: Sequence[ToolUsageTransaction],
n_retry_times: int = 3,
) -> None:
"""Write index rows and rollup upserts for a drained queue batch in one
transaction. Retries only ConnectError, the one failure that proves the
statements never reached the database. Post-send failures (Read timeouts
and errors) are ambiguous and are NOT retried: the engine can abandon the
transaction open on the pooled connection, so a retry's statements stack
into the same transaction and one commit applies both increment sets.
Ambiguous failures drop the batch; the caller logs it at error. Callers
must not add their own retry around this function."""
if not transactions:
return
index_rows = [
{"request_id": txn.request_id, "tool_name": tool_name, "start_time": txn.start_time}
for txn in transactions
for tool_name in txn.tool_names
]
per_tool_day = sorted(
((txn.date, tool_name, txn.spend, txn.total_tokens) for txn in transactions for tool_name in txn.tool_names),
key=lambda entry: (entry[0], entry[1]),
)
for attempt in range(n_retry_times + 1):
try:
async with prisma_client.db.batch_() as batcher:
batcher.litellm_spendlogtoolindex.create_many(data=index_rows, skip_duplicates=True)
for (date_key, tool_name), grouped in groupby(per_tool_day, key=lambda entry: (entry[0], entry[1])):
entries = tuple(grouped)
spend = sum(entry[2] for entry in entries)
total_tokens = sum(entry[3] for entry in entries)
batcher.litellm_dailytoolspend.upsert(
where={"date_tool_name": {"date": date_key, "tool_name": tool_name}},
data={
"create": {
"date": date_key,
"tool_name": tool_name,
"spend": spend,
"total_tokens": total_tokens,
"request_count": len(entries),
},
"update": {
"spend": {"increment": spend},
"total_tokens": {"increment": total_tokens},
"request_count": {"increment": len(entries)},
},
},
)
return
except DB_RETRY_SAFE_ERROR_TYPES:
if attempt >= n_retry_times:
raise
await asyncio.sleep(2**attempt + random.uniform(0, 1))

View file

@ -28,6 +28,11 @@ from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client, # pyright: ignore[reportUnknownVariableType]
httpxSpecialProvider,
)
from litellm.proxy.guardrails.guardrail_hooks.content_text import (
content_to_text,
is_all_text_parts,
merge_rewritten_text_parts,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.guardrails import GuardrailEventHooks, Mode
from litellm.types.integrations.custom_logger import AgenticLoopPlan, AgenticLoopRequestPatch
@ -51,6 +56,60 @@ def _is_object_list(value: object) -> TypeGuard[list[object]]: # guard-ok: isin
return isinstance(value, list)
def _flatten_messages_for_compression(messages: list[dict[str, object]]) -> list[dict[str, object]]:
"""Collapse all-text list-of-parts content to plain strings for /v1/compress.
The compression service's transforms only rewrite string content and skip
the OpenAI list-of-parts shape, which is what every Anthropic-format
request translates to. Only rows whose parts are ALL text are flattened:
cache_control breakpoints are positional (each caches the prefix ending
at its part), so merging text across a non-text part would move a later
breakpoint to the other side of it. Rows with non-text parts are sent
unchanged and pass through the service untouched.
"""
flattened: list[dict[str, object]] = []
for msg in messages:
content = msg.get("content")
if is_all_text_parts(content):
text = content_to_text(content)
if text:
flattened.append({**msg, "content": text})
continue
flattened.append(msg)
return flattened
def _restore_content_shapes(
originals: list[dict[str, object]], returned: list[dict[str, object]]
) -> list[dict[str, object]]:
"""Write compressed text back into each original row's content shape.
Rows are matched positionally; the pairing is only trusted when the
service kept the row count and every role lines up. If it restructured
the conversation (e.g. dropped rows), its output is adopted as-is, which
is the pre-flattening behavior.
"""
if len(returned) != len(originals):
return returned
for orig, ret in zip(originals, returned):
if orig.get("role") != ret.get("role"):
return returned
restored: list[dict[str, object]] = []
for orig, ret in zip(originals, returned):
orig_content = orig.get("content")
ret_content = ret.get("content")
if isinstance(orig_content, list) and isinstance(ret_content, str):
if ret_content == content_to_text(orig_content):
# Untouched row: keep the exact original parts, including
# per-part fields like cache_control on later text parts.
restored.append({**ret, "content": orig_content})
else:
restored.append({**ret, "content": merge_rewritten_text_parts(orig_content, ret_content)})
else:
restored.append(ret)
return restored
def extract_hashes_from_messages(messages: list[dict[str, object]]) -> list[str]:
hashes: list[str] = []
for msg in messages:
@ -491,10 +550,11 @@ class HeadroomGuardrail(CustomGuardrail):
model = self.headroom_model or request_data.get("model")
start_time = time.time()
compressed, compression_succeeded, stats = await self._call_compress(
messages=messages,
messages=_flatten_messages_for_compression(messages),
model=model if isinstance(model, str) else None,
)
end_time = time.time()
compressed = _restore_content_shapes(originals=messages, returned=compressed)
from litellm.proxy.common_utils.callback_utils import (
add_guardrail_to_applied_guardrails_header,

View file

@ -4,11 +4,13 @@ GET /guardrails/usage/overview, /guardrails/usage/detail/:id, /guardrails/usage/
"""
import json
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from typing import TYPE_CHECKING, Any, Literal, Union, overload
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from typing_extensions import NotRequired, TypedDict
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
@ -21,12 +23,65 @@ from litellm.repositories.table_repositories import (
SpendLogsRepository,
)
if TYPE_CHECKING:
from prisma import models as prisma_models
from prisma import types as prisma_types
from prisma.actions import LiteLLM_GuardrailsTableActions, LiteLLM_PolicyTableActions
from litellm.proxy.utils import PrismaClient
from litellm.types.guardrails import Guardrail
_DbOrConfigGuardrail = Union[prisma_models.LiteLLM_GuardrailsTable, Guardrail]
_DailyMetricsRow = Union[prisma_models.LiteLLM_DailyGuardrailMetrics, prisma_models.LiteLLM_DailyPolicyMetrics]
router = APIRouter()
def _guardrails_table(
prisma_client: "PrismaClient",
) -> "LiteLLM_GuardrailsTableActions[prisma_models.LiteLLM_GuardrailsTable]":
guardrails_table: LiteLLM_GuardrailsTableActions[prisma_models.LiteLLM_GuardrailsTable] = GuardrailsRepository(
prisma_client
).table
return guardrails_table
def _policies_table(
prisma_client: "PrismaClient",
) -> "LiteLLM_PolicyTableActions[prisma_models.LiteLLM_PolicyTable]":
policies_table: LiteLLM_PolicyTableActions[prisma_models.LiteLLM_PolicyTable] = PolicyRepository(
prisma_client
).table
return policies_table
# --- Response models ---
class UsageChartPoint(TypedDict):
date: str
passed: int
blocked: int
score: NotRequired[float | None]
class _MetricTotals(TypedDict):
requests: int
passed: int
blocked: int
flagged: int
class _PrevPeriodCounts(TypedDict):
req: int
blocked: int
class _DailyPassBlocked(TypedDict):
passed: int
blocked: int
class UsageOverviewRow(BaseModel):
id: str
name: str
@ -34,15 +89,15 @@ class UsageOverviewRow(BaseModel):
provider: str
requestsEvaluated: int
failRate: float
avgScore: Optional[float]
avgLatency: Optional[float]
avgScore: float | None
avgLatency: float | None
status: str # healthy | warning | critical
trend: str # up | down | stable
class UsageOverviewResponse(BaseModel):
rows: List[UsageOverviewRow]
chart: List[Dict[str, Any]] # [{ date, passed, blocked }]
rows: list[UsageOverviewRow]
chart: list[UsageChartPoint] # [{ date, passed, blocked }]
totalRequests: int
totalBlocked: int
passRate: float
@ -55,28 +110,28 @@ class UsageDetailResponse(BaseModel):
provider: str
requestsEvaluated: int
failRate: float
avgScore: Optional[float]
avgLatency: Optional[float]
avgScore: float | None
avgLatency: float | None
status: str
trend: str
description: Optional[str]
time_series: List[Dict[str, Any]]
description: str | None
time_series: list[UsageChartPoint]
class UsageLogEntry(BaseModel):
id: str
timestamp: str
action: str # blocked | passed | flagged
score: Optional[float]
latency_ms: Optional[float]
model: Optional[str]
input_snippet: Optional[str]
output_snippet: Optional[str]
reason: Optional[str]
score: float | None
latency_ms: float | None
model: str | None
input_snippet: str | None
output_snippet: str | None
reason: str | None
class UsageLogsResponse(BaseModel):
logs: List[UsageLogEntry]
logs: list[UsageLogEntry]
total: int
page: int
page_size: int
@ -101,10 +156,10 @@ def _trend_from_comparison(current_fail: float, previous_fail: float) -> str:
return "stable"
def _aggregate_daily_metrics(metrics: Any, id_attr: str) -> Dict[str, Dict[str, Any]]:
agg: Dict[str, Dict[str, Any]] = {}
def _aggregate_daily_metrics(metrics: "Sequence[_DailyMetricsRow]", id_attr: str) -> Mapping[str, _MetricTotals]:
agg: dict[str, _MetricTotals] = {}
for m in metrics:
gid = getattr(m, id_attr)
gid: str = getattr(m, id_attr)
if gid not in agg:
agg[gid] = {"requests": 0, "passed": 0, "blocked": 0, "flagged": 0}
agg[gid]["requests"] += int(m.requests_evaluated or 0)
@ -114,10 +169,10 @@ def _aggregate_daily_metrics(metrics: Any, id_attr: str) -> Dict[str, Dict[str,
return agg
def _prev_fail_rates(metrics_prev: Any, id_attr: str) -> Dict[str, float]:
prev_agg_raw: Dict[str, Dict[str, int]] = {}
def _prev_fail_rates(metrics_prev: "Sequence[_DailyMetricsRow]", id_attr: str) -> Mapping[str, float]:
prev_agg_raw: dict[str, _PrevPeriodCounts] = {}
for m in metrics_prev:
gid = getattr(m, id_attr)
gid: str = getattr(m, id_attr)
r, b = int(m.requests_evaluated or 0), int(m.blocked_count or 0)
if gid not in prev_agg_raw:
prev_agg_raw[gid] = {"req": 0, "blocked": 0}
@ -126,8 +181,8 @@ def _prev_fail_rates(metrics_prev: Any, id_attr: str) -> Dict[str, float]:
return {gid: (100.0 * v["blocked"] / v["req"]) if v["req"] else 0.0 for gid, v in prev_agg_raw.items()}
def _chart_from_metrics(metrics: Any) -> List[Dict[str, Any]]:
chart_by_date: Dict[str, Dict[str, int]] = {}
def _chart_from_metrics(metrics: "Sequence[_DailyMetricsRow]") -> list[UsageChartPoint]:
chart_by_date: dict[str, _DailyPassBlocked] = {}
for m in metrics:
d = m.date
if d not in chart_by_date:
@ -137,14 +192,26 @@ def _chart_from_metrics(metrics: Any) -> List[Dict[str, Any]]:
return [{"date": d, "passed": v["passed"], "blocked": v["blocked"]} for d, v in sorted(chart_by_date.items())]
def _get_guardrail_field(g: Any, field: str) -> Any:
_GuardrailStrField = Literal["guardrail_id", "guardrail_name"]
_GuardrailObjectField = Literal["litellm_params", "guardrail_info"]
@overload
def _get_guardrail_field(g: "_DbOrConfigGuardrail", field: _GuardrailStrField) -> str | None: ...
@overload
def _get_guardrail_field(g: "_DbOrConfigGuardrail", field: _GuardrailObjectField) -> object: ...
def _get_guardrail_field(g: "_DbOrConfigGuardrail", field: _GuardrailStrField | _GuardrailObjectField) -> object:
"""Read `field` off a guardrail whether it's a Prisma row (attr) or a dict/TypedDict (key)."""
if isinstance(g, dict):
return g.get(field)
return getattr(g, field, None)
def _to_dict(value: Any) -> Dict[str, Any]:
def _to_dict(value: object) -> dict[str, Any]:
"""Coerce a pydantic model (e.g. LitellmParams) / dict value into a plain dict."""
if isinstance(value, BaseModel):
return value.model_dump(exclude_none=True)
@ -153,7 +220,7 @@ def _to_dict(value: Any) -> Dict[str, Any]:
return {}
def _get_guardrail_attrs(g: Any) -> tuple[Any, str]:
def _get_guardrail_attrs(g: "_DbOrConfigGuardrail") -> tuple[Any, str]:
"""Get (guardrail_id, display_name) from guardrail - handles Prisma model or dict."""
gid = _get_guardrail_field(g, "guardrail_id")
name = _get_guardrail_field(g, "guardrail_name")
@ -161,18 +228,18 @@ def _get_guardrail_attrs(g: Any) -> tuple[Any, str]:
def _guardrail_overview_rows(
guardrails: Any,
agg: Dict[str, Dict[str, Any]],
prev_agg: Dict[str, float],
) -> List[UsageOverviewRow]:
rows: List[UsageOverviewRow] = []
covered_keys: set = set()
guardrails: "Sequence[_DbOrConfigGuardrail]",
agg: Mapping[str, _MetricTotals],
prev_agg: Mapping[str, float],
) -> list[UsageOverviewRow]:
rows: list[UsageOverviewRow] = []
covered_keys: set[str] = set()
for g in guardrails:
gid, display_name = _get_guardrail_attrs(g)
# Metrics are keyed by logical name from spend log metadata; guardrails table uses UUID
lookup_keys = [k for k in (display_name, gid) if k]
lookup_keys: Sequence[str] = [k for k in (display_name, gid) if k]
covered_keys.update(lookup_keys)
a = {"requests": 0, "passed": 0, "blocked": 0, "flagged": 0}
a: _MetricTotals = {"requests": 0, "passed": 0, "blocked": 0, "flagged": 0}
for k in lookup_keys:
if k in agg:
a = agg[k]
@ -229,11 +296,11 @@ def _guardrail_overview_rows(
def _policy_overview_rows(
policies: Any,
agg: Dict[str, Dict[str, Any]],
prev_agg: Dict[str, float],
) -> List[UsageOverviewRow]:
rows: List[UsageOverviewRow] = []
policies: "Sequence[prisma_models.LiteLLM_PolicyTable]",
agg: Mapping[str, _MetricTotals],
prev_agg: Mapping[str, float],
) -> list[UsageOverviewRow]:
rows: list[UsageOverviewRow] = []
for p in policies:
pid = p.policy_id
a = agg.get(pid, {"requests": 0, "passed": 0, "blocked": 0, "flagged": 0})
@ -264,8 +331,8 @@ def _policy_overview_rows(
response_model=UsageOverviewResponse,
)
async def guardrails_usage_overview(
start_date: Optional[str] = Query(None, description="YYYY-MM-DD"),
end_date: Optional[str] = Query(None, description="YYYY-MM-DD"),
start_date: str | None = Query(None, description="YYYY-MM-DD"),
end_date: str | None = Query(None, description="YYYY-MM-DD"),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""Return guardrail performance overview for the dashboard."""
@ -281,23 +348,23 @@ async def guardrails_usage_overview(
from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER
try:
db_guardrails = await GuardrailsRepository(prisma_client).table.find_many()
db_guardrails = await _guardrails_table(prisma_client).find_many()
seen_ids = {gid for g in db_guardrails if (gid := _get_guardrail_field(g, "guardrail_id")) is not None}
config_guardrails = [
g for g in IN_MEMORY_GUARDRAIL_HANDLER.list_config_guardrails() if g.get("guardrail_id") not in seen_ids
]
guardrails: List[Any] = [*db_guardrails, *config_guardrails]
guardrails: Sequence[_DbOrConfigGuardrail] = [*db_guardrails, *config_guardrails]
# Daily metrics in range
metrics = await DailyGuardrailMetricsRepository(prisma_client).table.find_many(
where={"date": {"gte": start, "lte": end}}
)
metrics: Sequence[prisma_models.LiteLLM_DailyGuardrailMetrics] = await DailyGuardrailMetricsRepository(
prisma_client
).table.find_many(where={"date": {"gte": start, "lte": end}})
# Previous period for trend
start_prev = (datetime.strptime(start, "%Y-%m-%d") - timedelta(days=7)).strftime("%Y-%m-%d")
metrics_prev = await DailyGuardrailMetricsRepository(prisma_client).table.find_many(
where={"date": {"gte": start_prev, "lt": start}}
)
metrics_prev: Sequence[prisma_models.LiteLLM_DailyGuardrailMetrics] = await DailyGuardrailMetricsRepository(
prisma_client
).table.find_many(where={"date": {"gte": start_prev, "lt": start}})
agg = _aggregate_daily_metrics(metrics, "guardrail_id")
prev_agg = _prev_fail_rates(metrics_prev, "guardrail_id")
@ -327,8 +394,8 @@ async def guardrails_usage_overview(
)
async def guardrails_usage_detail(
guardrail_id: str,
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""Return single guardrail usage metrics and time series."""
@ -345,7 +412,7 @@ async def guardrails_usage_detail(
from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER
guardrail: Any = await GuardrailsRepository(prisma_client).table.find_unique(where={"guardrail_id": guardrail_id})
guardrail = await _guardrails_table(prisma_client).find_unique(where={"guardrail_id": guardrail_id})
if guardrail is None:
guardrail = IN_MEMORY_GUARDRAIL_HANDLER.get_config_guardrail_by_id(guardrail_id=guardrail_id)
if guardrail is None:
@ -357,13 +424,17 @@ async def guardrails_usage_detail(
logical_id = _get_guardrail_field(guardrail, "guardrail_name")
metric_ids = [i for i in (logical_id, guardrail_id) if i]
metrics = await DailyGuardrailMetricsRepository(prisma_client).table.find_many(
metrics: Sequence[prisma_models.LiteLLM_DailyGuardrailMetrics] = await DailyGuardrailMetricsRepository(
prisma_client
).table.find_many(
where={
"guardrail_id": {"in": metric_ids},
"date": {"gte": start, "lte": end},
}
)
metrics_prev = await DailyGuardrailMetricsRepository(prisma_client).table.find_many(
metrics_prev: Sequence[prisma_models.LiteLLM_DailyGuardrailMetrics] = await DailyGuardrailMetricsRepository(
prisma_client
).table.find_many(
where={
"guardrail_id": {"in": metric_ids},
"date": {"lt": start},
@ -380,14 +451,14 @@ async def guardrails_usage_detail(
trend = _trend_from_comparison(fail_rate, prev_fail)
# Aggregate by date in case metrics exist under both UUID and logical name
ts_by_date: Dict[str, Dict[str, Any]] = {}
ts_by_date: dict[str, _DailyPassBlocked] = {}
for m in metrics:
d = m.date
if d not in ts_by_date:
ts_by_date[d] = {"passed": 0, "blocked": 0}
ts_by_date[d]["passed"] += int(m.passed_count or 0)
ts_by_date[d]["blocked"] += int(m.blocked_count or 0)
time_series = [
time_series: list[UsageChartPoint] = [
{"date": d, "passed": v["passed"], "blocked": v["blocked"], "score": None}
for d, v in sorted(ts_by_date.items())
]
@ -412,18 +483,18 @@ async def guardrails_usage_detail(
def _build_usage_logs_where(
guardrail_ids: Optional[List[str]],
policy_id: Optional[str],
start_date: Optional[str],
end_date: Optional[str],
) -> Dict[str, Any]:
where: Dict[str, Any] = {}
guardrail_ids: list[str] | None,
policy_id: str | None,
start_date: str | None,
end_date: str | None,
) -> "prisma_types.LiteLLM_SpendLogGuardrailIndexWhereInput":
where: prisma_types.LiteLLM_SpendLogGuardrailIndexWhereInput = {}
if guardrail_ids:
where["guardrail_id"] = {"in": guardrail_ids} if len(guardrail_ids) > 1 else guardrail_ids[0]
if policy_id:
where["policy_id"] = policy_id
if start_date or end_date:
st_filter: Dict[str, Any] = {}
st_filter: prisma_types.DateTimeFilter = {}
if start_date:
sd = start_date.replace("Z", "+00:00").strip()
if "T" not in sd:
@ -438,7 +509,9 @@ def _build_usage_logs_where(
return where
def _usage_log_entry_from_row(r: Any, sl: Any, action_filter: Optional[str]) -> Optional[UsageLogEntry]:
def _usage_log_entry_from_row(
r: "prisma_models.LiteLLM_SpendLogGuardrailIndex", sl: Any, action_filter: str | None
) -> UsageLogEntry | None:
meta = sl.metadata
if isinstance(meta, str):
try:
@ -488,7 +561,7 @@ def _usage_log_entry_from_row(r: Any, sl: Any, action_filter: Optional[str]) ->
)
def _snippet(text: Any, max_len: int = 200) -> Optional[str]:
def _snippet(text: Any, max_len: int = 200) -> str | None:
if text is None:
return None
if isinstance(text, str):
@ -510,7 +583,7 @@ def _snippet(text: Any, max_len: int = 200) -> Optional[str]:
return result
def _input_snippet_for_log(sl: Any) -> Optional[str]:
def _input_snippet_for_log(sl: "prisma_models.LiteLLM_SpendLogs") -> str | None:
"""Snippet for request input: prefer messages, fall back to proxy_server_request (same as drawer)."""
out = _snippet(sl.messages)
if out:
@ -541,13 +614,13 @@ def _input_snippet_for_log(sl: Any) -> Optional[str]:
response_model=UsageLogsResponse,
)
async def guardrails_usage_logs(
guardrail_id: Optional[str] = Query(None),
policy_id: Optional[str] = Query(None),
guardrail_id: str | None = Query(None),
policy_id: str | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
action: Optional[str] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
action: str | None = Query(None),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""Return paginated run logs for a guardrail (or policy) from SpendLogs via index."""
@ -562,13 +635,11 @@ async def guardrails_usage_logs(
try:
# Index rows may store either guardrail_id (UUID) or guardrail_name from metadata.
# Query by both so we match regardless of which was written.
effective_guardrail_ids: List[str] = [guardrail_id] if guardrail_id else []
effective_guardrail_ids: list[str] = [guardrail_id] if guardrail_id else []
if guardrail_id:
from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER
guardrail: Any = await GuardrailsRepository(prisma_client).table.find_unique(
where={"guardrail_id": guardrail_id}
)
guardrail = await _guardrails_table(prisma_client).find_unique(where={"guardrail_id": guardrail_id})
if guardrail is None:
guardrail = IN_MEMORY_GUARDRAIL_HANDLER.get_config_guardrail_by_id(guardrail_id=guardrail_id)
if guardrail:
@ -577,19 +648,23 @@ async def guardrails_usage_logs(
effective_guardrail_ids.append(logical_name)
where = _build_usage_logs_where(effective_guardrail_ids or None, policy_id, start_date, end_date)
index_rows = await SpendLogGuardrailIndexRepository(prisma_client).table.find_many(
index_rows: Sequence[prisma_models.LiteLLM_SpendLogGuardrailIndex] = await SpendLogGuardrailIndexRepository(
prisma_client
).table.find_many(
where=where,
order={"start_time": "desc"},
skip=(page - 1) * page_size,
take=page_size + 1,
)
total = await SpendLogGuardrailIndexRepository(prisma_client).table.count(where=where)
total: int = await SpendLogGuardrailIndexRepository(prisma_client).table.count(where=where)
request_ids = [r.request_id for r in index_rows[:page_size]]
if not request_ids:
return UsageLogsResponse(logs=[], total=total, page=page, page_size=page_size)
spend_logs = await SpendLogsRepository(prisma_client).table.find_many(where={"request_id": {"in": request_ids}})
spend_logs: Sequence[prisma_models.LiteLLM_SpendLogs] = await SpendLogsRepository(
prisma_client
).table.find_many(where={"request_id": {"in": request_ids}})
log_by_id = {s.request_id: s for s in spend_logs}
logs_out: List[UsageLogEntry] = []
logs_out: list[UsageLogEntry] = []
for r in index_rows[:page_size]:
sl = log_by_id.get(r.request_id)
if not sl:
@ -614,8 +689,8 @@ async def guardrails_usage_logs(
response_model=UsageOverviewResponse,
)
async def policies_usage_overview(
start_date: Optional[str] = Query(None, description="YYYY-MM-DD"),
end_date: Optional[str] = Query(None, description="YYYY-MM-DD"),
start_date: str | None = Query(None, description="YYYY-MM-DD"),
end_date: str | None = Query(None, description="YYYY-MM-DD"),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""Return policy performance overview for the dashboard."""
@ -629,11 +704,13 @@ async def policies_usage_overview(
start = start_date or (now - timedelta(days=7)).strftime("%Y-%m-%d")
try:
policies = await PolicyRepository(prisma_client).table.find_many()
metrics = await DailyPolicyMetricsRepository(prisma_client).table.find_many(
where={"date": {"gte": start, "lte": end}}
)
metrics_prev = await DailyPolicyMetricsRepository(prisma_client).table.find_many(
policies = await _policies_table(prisma_client).find_many()
metrics: Sequence[prisma_models.LiteLLM_DailyPolicyMetrics] = await DailyPolicyMetricsRepository(
prisma_client
).table.find_many(where={"date": {"gte": start, "lte": end}})
metrics_prev: Sequence[prisma_models.LiteLLM_DailyPolicyMetrics] = await DailyPolicyMetricsRepository(
prisma_client
).table.find_many(
where={
"date": {
"gte": (datetime.strptime(start, "%Y-%m-%d") - timedelta(days=7)).strftime("%Y-%m-%d"),

View file

@ -35,6 +35,7 @@ from litellm.proxy._types import (
from litellm.proxy.common_utils.callback_utils import (
decrypt_callback_vars,
get_metadata_variable_name_from_kwargs,
strip_callback_config,
)
from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers
@ -1274,7 +1275,7 @@ class LiteLLMProxyRequestSetup:
user_api_key_budget_reset_at=(
user_api_key_dict.budget_reset_at.isoformat() if user_api_key_dict.budget_reset_at else None
),
user_api_key_auth_metadata=user_api_key_dict.metadata,
user_api_key_auth_metadata=strip_callback_config(user_api_key_dict.metadata),
)
return user_api_key_logged_metadata
@ -1912,8 +1913,8 @@ async def add_litellm_data_to_request(
data[_metadata_variable_name]["user_api_key_user_spend"] = user_api_key_dict.user_spend
data[_metadata_variable_name]["user_api_key_user_max_budget"] = user_api_key_dict.user_max_budget
data[_metadata_variable_name]["user_api_key_metadata"] = user_api_key_dict.metadata
data[_metadata_variable_name]["user_api_key_team_metadata"] = user_api_key_dict.team_metadata
data[_metadata_variable_name]["user_api_key_metadata"] = strip_callback_config(user_api_key_dict.metadata)
data[_metadata_variable_name]["user_api_key_team_metadata"] = strip_callback_config(user_api_key_dict.team_metadata)
data[_metadata_variable_name]["user_api_key_object_permission_id"] = getattr(
user_api_key_dict, "object_permission_id", None
)

View file

@ -1,9 +1,15 @@
import asyncio
from collections.abc import Awaitable, Callable, Mapping, Sequence
from datetime import datetime
from types import SimpleNamespace
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple, Union
from typing import (
TYPE_CHECKING,
Protocol,
Union,
)
from fastapi import HTTPException, status
from typing_extensions import TypedDict
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors
@ -16,6 +22,7 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
BreakdownMetrics,
DailySpendData,
DailySpendMetadata,
GroupedData,
KeyMetadata,
KeyMetricWithMetadata,
MetricWithMetadata,
@ -23,8 +30,16 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
SpendMetrics,
)
if TYPE_CHECKING:
from prisma.models import (
LiteLLM_DeletedVerificationToken as PrismaDeletedVerificationToken,
)
from prisma.models import (
LiteLLM_VerificationToken as PrismaVerificationToken,
)
# Mapping from Prisma accessor names to actual PostgreSQL table names.
_PRISMA_TO_PG_TABLE: Dict[str, str] = {
_PRISMA_TO_PG_TABLE: Mapping[str, str] = {
"litellm_dailyuserspend": "LiteLLM_DailyUserSpend",
"litellm_dailyteamspend": "LiteLLM_DailyTeamSpend",
"litellm_dailyorganizationspend": "LiteLLM_DailyOrganizationSpend",
@ -34,7 +49,98 @@ _PRISMA_TO_PG_TABLE: Dict[str, str] = {
}
def update_metrics(existing_metrics: SpendMetrics, record: Any) -> SpendMetrics:
class DailySpendRecord(Protocol):
@property
def date(self) -> str: ...
@property
def api_key(self) -> str: ...
@property
def model(self) -> str | None: ...
@property
def model_group(self) -> str | None: ...
@property
def custom_llm_provider(self) -> str | None: ...
@property
def mcp_namespaced_tool_name(self) -> str | None: ...
@property
def endpoint(self) -> str | None: ...
@property
def prompt_tokens(self) -> int: ...
@property
def completion_tokens(self) -> int: ...
@property
def spend(self) -> float: ...
@property
def cache_read_input_tokens(self) -> int: ...
@property
def cache_creation_input_tokens(self) -> int: ...
@property
def compression_saved_tokens(self) -> int: ...
@property
def compression_savings_spend(self) -> float: ...
@property
def prompt_caching_savings_spend(self) -> float: ...
@property
def api_requests(self) -> int: ...
@property
def successful_requests(self) -> int: ...
@property
def failed_requests(self) -> int: ...
class _KeyMetadataDict(TypedDict, total=False):
key_alias: str | None
team_id: str | None
_WhereValue = Union[str, dict[str, object]]
class _AggregatedSpendData(TypedDict):
results: list[DailySpendData]
totals: SpendMetrics
class _GroupingSetsRow(SimpleNamespace):
date: str
api_key: str | None
model: str | None
model_group: str | None
custom_llm_provider: str | None
mcp_namespaced_tool_name: str | None
endpoint: str | None
group_level: int
spend: float | None
prompt_tokens: int | None
completion_tokens: int | None
cache_read_input_tokens: int | None
cache_creation_input_tokens: int | None
compression_saved_tokens: int | None
compression_savings_spend: float | None
prompt_caching_savings_spend: float | None
api_requests: int | None
successful_requests: int | None
failed_requests: int | None
def update_metrics(existing_metrics: SpendMetrics, record: DailySpendRecord) -> SpendMetrics:
"""Update metrics with new record data.
Rollup rows can carry None for numeric fields when SUM() spans zero rows
@ -58,7 +164,7 @@ def update_metrics(existing_metrics: SpendMetrics, record: Any) -> SpendMetrics:
return existing_metrics
def _is_user_agent_tag(tag: Optional[str]) -> bool:
def _is_user_agent_tag(tag: str | None) -> bool:
"""Determine whether a tag should be treated as a User-Agent tag."""
if not tag:
return False
@ -66,15 +172,15 @@ def _is_user_agent_tag(tag: Optional[str]) -> bool:
return normalized_tag.startswith("user-agent:") or normalized_tag.startswith("user agent:")
def compute_tag_metadata_totals(records: List[Any]) -> SpendMetrics:
def compute_tag_metadata_totals(records: Sequence[DailySpendRecord]) -> SpendMetrics:
"""
Deduplicate spend metrics for tags using request_id, ignoring User-Agent prefixed tags.
Each unique request_id contributes at most one record (the tag with max spend) to metadata.
"""
deduped_records: Dict[str, Any] = {}
deduped_records: dict[str, DailySpendRecord] = {}
for record in records:
request_id = getattr(record, "request_id", None)
request_id: str | None = getattr(record, "request_id", None)
if not request_id:
continue
@ -94,12 +200,12 @@ def compute_tag_metadata_totals(records: List[Any]) -> SpendMetrics:
def update_breakdown_metrics(
breakdown: BreakdownMetrics,
record: Any,
model_metadata: Dict[str, Dict[str, Any]],
provider_metadata: Dict[str, Dict[str, Any]],
api_key_metadata: Dict[str, Dict[str, Any]],
entity_id_field: Optional[str] = None,
entity_metadata_field: Optional[Dict[str, dict]] = None,
record: DailySpendRecord,
model_metadata: Mapping[str, dict[str, object]],
provider_metadata: Mapping[str, dict[str, object]],
api_key_metadata: Mapping[str, _KeyMetadataDict],
entity_id_field: str | None = None,
entity_metadata_field: Mapping[str, dict[str, object]] | None = None,
) -> BreakdownMetrics:
"""Updates breakdown metrics for a single record using the existing update_metrics function"""
@ -269,23 +375,27 @@ def update_breakdown_metrics(
async def get_api_key_metadata(
prisma_client: PrismaClient,
api_keys: Set[str],
) -> Dict[str, Dict[str, Any]]:
api_keys: set[str],
) -> dict[str, _KeyMetadataDict]:
"""Get api key metadata, falling back to deleted keys table for keys not found in active table.
This ensures that key_alias and team_id are preserved in historical activity logs
even after a key is deleted or regenerated.
"""
key_records = await VerificationTokenRepository(prisma_client).table.find_many(
key_records: list[PrismaVerificationToken] = await VerificationTokenRepository(prisma_client).table.find_many(
where={"token": {"in": list(api_keys)}}
)
result = {k.token: {"key_alias": k.key_alias, "team_id": k.team_id} for k in key_records}
result: dict[str, _KeyMetadataDict] = {
k.token: {"key_alias": k.key_alias, "team_id": k.team_id} for k in key_records
}
# For any keys not found in the active table, check the deleted keys table
missing_keys = api_keys - set(result.keys())
if missing_keys:
try:
deleted_key_records = await DeletedVerificationTokenRepository(prisma_client).table.find_many(
deleted_key_records: list[PrismaDeletedVerificationToken] = await DeletedVerificationTokenRepository(
prisma_client
).table.find_many(
where={"token": {"in": list(missing_keys)}},
order={"deleted_at": "desc"},
)
@ -309,8 +419,8 @@ async def get_api_key_metadata(
def _adjust_dates_for_timezone(
start_date: str,
end_date: str,
timezone_offset_minutes: Optional[int],
) -> Tuple[str, str]:
timezone_offset_minutes: int | None,
) -> tuple[str, str]:
"""
Pass-through for the local date range; the timezone offset is intentionally ignored here.
@ -335,19 +445,19 @@ def _adjust_dates_for_timezone(
def _build_where_conditions(
*,
entity_id_field: str,
entity_id: Optional[Union[str, List[str]]],
entity_id: str | list[str] | None,
start_date: str,
end_date: str,
model: Optional[str],
api_key: Optional[Union[str, List[str]]],
exclude_entity_ids: Optional[List[str]] = None,
timezone_offset_minutes: Optional[int] = None,
) -> Dict[str, Any]:
model: str | None,
api_key: str | list[str] | None,
exclude_entity_ids: list[str] | None = None,
timezone_offset_minutes: int | None = None,
) -> dict[str, "_WhereValue"]:
"""Build prisma where clause for daily activity queries."""
# Adjust dates for timezone if provided
adjusted_start, adjusted_end = _adjust_dates_for_timezone(start_date, end_date, timezone_offset_minutes)
where_conditions: Dict[str, Any] = {
where_conditions: dict[str, _WhereValue] = {
"date": {
"gte": adjusted_start,
"lte": adjusted_end,
@ -369,7 +479,7 @@ def _build_where_conditions(
where_conditions[entity_id_field] = {"equals": entity_id}
if exclude_entity_ids:
current = where_conditions.get(entity_id_field, {})
current: _WhereValue = where_conditions.get(entity_id_field, {})
if isinstance(current, str):
current = {"equals": current}
current["not"] = {"in": exclude_entity_ids}
@ -382,14 +492,14 @@ def _build_aggregated_sql_query(
*,
table_name: str,
entity_id_field: str,
entity_id: Optional[Union[str, List[str]]],
entity_id: str | list[str] | None,
start_date: str,
end_date: str,
model: Optional[str],
api_key: Optional[str],
exclude_entity_ids: Optional[List[str]] = None,
timezone_offset_minutes: Optional[int] = None,
) -> Tuple[str, List[Any]]:
model: str | None,
api_key: str | None,
exclude_entity_ids: list[str] | None = None,
timezone_offset_minutes: int | None = None,
) -> tuple[str, list[str]]:
"""Build a parameterized SQL GROUP BY query for aggregated daily activity.
Groups by (date, api_key, model, model_group, custom_llm_provider,
@ -406,8 +516,8 @@ def _build_aggregated_sql_query(
adjusted_start, adjusted_end = _adjust_dates_for_timezone(start_date, end_date, timezone_offset_minutes)
sql_conditions: List[str] = []
sql_params: List[Any] = []
sql_conditions: list[str] = []
sql_params: list[str] = []
p = 1 # parameter index (1-based for PostgreSQL $N placeholders)
# Date range (always present)
@ -506,17 +616,17 @@ def _build_aggregated_sql_query(
def _aggregate_spend_records_sync(
*,
records: List[Any],
api_key_metadata: Dict[str, Dict[str, Any]],
entity_id_field: Optional[str],
entity_metadata_field: Optional[Dict[str, dict]],
) -> Dict[str, Any]:
model_metadata: Dict[str, Dict[str, Any]] = {}
provider_metadata: Dict[str, Dict[str, Any]] = {}
records: Sequence[DailySpendRecord],
api_key_metadata: Mapping[str, _KeyMetadataDict],
entity_id_field: str | None,
entity_metadata_field: Mapping[str, dict[str, object]] | None,
) -> _AggregatedSpendData:
model_metadata: dict[str, dict[str, object]] = {}
provider_metadata: dict[str, dict[str, object]] = {}
results: List[DailySpendData] = []
results: list[DailySpendData] = []
total_metrics = SpendMetrics()
grouped_data: Dict[str, Dict[str, Any]] = {}
grouped_data: dict[str, GroupedData] = {}
for record in records:
date_str = record.date
@ -557,18 +667,18 @@ def _aggregate_spend_records_sync(
async def _aggregate_spend_records(
*,
prisma_client: PrismaClient,
records: List[Any],
entity_id_field: Optional[str],
entity_metadata_field: Optional[Dict[str, dict]],
) -> Dict[str, Any]:
records: Sequence[DailySpendRecord],
entity_id_field: str | None,
entity_metadata_field: Mapping[str, dict[str, object]] | None,
) -> _AggregatedSpendData:
"""Aggregate rows into DailySpendData list and total metrics.
The per-row loop is offloaded to a worker thread via asyncio.to_thread so
a large result set doesn't peg the event loop.
"""
api_keys: Set[str] = {record.api_key for record in records if record.api_key}
api_keys: set[str] = {record.api_key for record in records if record.api_key}
api_key_metadata: Dict[str, Dict[str, Any]] = {}
api_key_metadata: dict[str, _KeyMetadataDict] = {}
if api_keys:
api_key_metadata = await get_api_key_metadata(prisma_client, api_keys)
@ -603,7 +713,7 @@ _GROUP_DATE_ENDPOINT = 62 # 0b0111110
_GROUP_DATE_ENDPOINT_API_KEY = 30 # 0b0011110
def _record_to_spend_metrics(record: Any) -> SpendMetrics:
def _record_to_spend_metrics(record: _GroupingSetsRow) -> SpendMetrics:
"""Build a SpendMetrics directly from one already-aggregated rollup row.
SUM() over zero rows is SQL NULL, so rollup rows (notably the grand-total
@ -627,16 +737,16 @@ def _record_to_spend_metrics(record: Any) -> SpendMetrics:
)
def _key_metadata(api_key_metadata: Dict[str, Dict[str, Any]], api_key: str) -> KeyMetadata:
def _key_metadata(api_key_metadata: Mapping[str, _KeyMetadataDict], api_key: str) -> KeyMetadata:
meta = api_key_metadata.get(api_key, {})
return KeyMetadata(key_alias=meta.get("key_alias"), team_id=meta.get("team_id"))
def _aggregate_grouping_sets_records_sync(
*,
records: List[Any],
api_key_metadata: Dict[str, Dict[str, Any]],
) -> Dict[str, Any]:
records: Sequence[_GroupingSetsRow],
api_key_metadata: Mapping[str, _KeyMetadataDict],
) -> _AggregatedSpendData:
"""Build the response from rollup rows produced by the GROUPING SETS query.
Each row carries a `group_level` bitmask (from Postgres GROUPING()) that
@ -645,16 +755,16 @@ def _aggregate_grouping_sets_records_sync(
summing in Python and no nested update_metrics calls.
"""
total_metrics = SpendMetrics()
grouped_data: Dict[str, Dict[str, Any]] = {}
grouped_data: dict[str, GroupedData] = {}
def ensure_date(date_str: str) -> Dict[str, Any]:
bucket = grouped_data.get(date_str)
def ensure_date(date_str: str) -> GroupedData:
bucket: GroupedData | None = grouped_data.get(date_str)
if bucket is None:
bucket = {"metrics": SpendMetrics(), "breakdown": BreakdownMetrics()}
grouped_data[date_str] = bucket
return bucket
def assign_metric_with_metadata(target: Dict[str, MetricWithMetadata], key: str, metrics: SpendMetrics) -> None:
def assign_metric_with_metadata(target: dict[str, MetricWithMetadata], key: str, metrics: SpendMetrics) -> None:
existing = target.get(key)
if existing is None:
target[key] = MetricWithMetadata(metrics=metrics, metadata={})
@ -662,7 +772,7 @@ def _aggregate_grouping_sets_records_sync(
existing.metrics = metrics
def assign_api_key_breakdown(
target: Dict[str, MetricWithMetadata],
target: dict[str, MetricWithMetadata],
parent_key: str,
api_key: str,
metrics: SpendMetrics,
@ -753,12 +863,12 @@ def _aggregate_grouping_sets_records_sync(
async def _aggregate_grouping_sets_records(
*,
prisma_client: PrismaClient,
records: List[Any],
) -> Dict[str, Any]:
records: Sequence[_GroupingSetsRow],
) -> _AggregatedSpendData:
"""Async wrapper: fetch api_key_metadata, then dispatch on a worker thread."""
api_keys: Set[str] = {r.api_key for r in records if r.api_key}
api_keys: set[str] = {r.api_key for r in records if r.api_key}
api_key_metadata: Dict[str, Dict[str, Any]] = {}
api_key_metadata: dict[str, _KeyMetadataDict] = {}
if api_keys:
api_key_metadata = await get_api_key_metadata(prisma_client, api_keys)
@ -770,21 +880,22 @@ async def _aggregate_grouping_sets_records(
async def get_daily_activity(
prisma_client: Optional[PrismaClient],
prisma_client: PrismaClient | None,
table_name: str,
entity_id_field: str,
entity_id: Optional[Union[str, List[str]]],
entity_metadata_field: Optional[Dict[str, dict]],
start_date: Optional[str],
end_date: Optional[str],
model: Optional[str],
api_key: Optional[Union[str, List[str]]],
entity_id: str | list[str] | None,
entity_metadata_field: Mapping[str, dict[str, object]] | None,
start_date: str | None,
end_date: str | None,
model: str | None,
api_key: str | list[str] | None,
page: int,
page_size: int,
exclude_entity_ids: Optional[List[str]] = None,
metadata_metrics_func: Optional[Callable[[List[Any]], SpendMetrics]] = None,
timezone_offset_minutes: Optional[int] = None,
resolve_entity_metadata: Optional[Callable[[list[Any]], Awaitable[dict[str, dict]]]] = None,
exclude_entity_ids: list[str] | None = None,
metadata_metrics_func: Callable[[Sequence[DailySpendRecord]], SpendMetrics] | None = None,
timezone_offset_minutes: int | None = None,
resolve_entity_metadata: Callable[[Sequence[DailySpendRecord]], Awaitable[dict[str, dict[str, object]]]]
| None = None,
) -> SpendAnalyticsPaginatedResponse:
"""Common function to get daily activity for any entity type.
@ -819,7 +930,7 @@ async def get_daily_activity(
)
# Get total count for pagination
total_count = await getattr(prisma_client.db, table_name).count(where=where_conditions)
total_count: int = await getattr(prisma_client.db, table_name).count(where=where_conditions)
# Fetch paginated results.
# ``date`` alone is not a unique sort key -- a busy tenant has many
@ -831,7 +942,7 @@ async def get_daily_activity(
# total. Adding ``id`` (the row's UUID primary key, present on both
# LiteLLM_DailyUserSpend and LiteLLM_DailyTeamSpend) as a tiebreaker
# gives every page a stable cursor (#30164).
daily_spend_data = await getattr(prisma_client.db, table_name).find_many(
daily_spend_data: Sequence[DailySpendRecord] = await getattr(prisma_client.db, table_name).find_many(
where=where_conditions,
order=[
{"date": "desc"},
@ -889,17 +1000,17 @@ async def get_daily_activity(
async def get_daily_activity_aggregated(
prisma_client: Optional[PrismaClient],
prisma_client: PrismaClient | None,
table_name: str,
entity_id_field: str,
entity_id: Optional[Union[str, List[str]]],
entity_metadata_field: Optional[Dict[str, dict]],
start_date: Optional[str],
end_date: Optional[str],
model: Optional[str],
api_key: Optional[str],
exclude_entity_ids: Optional[List[str]] = None,
timezone_offset_minutes: Optional[int] = None,
entity_id: str | list[str] | None,
entity_metadata_field: Mapping[str, dict[str, object]] | None,
start_date: str | None,
end_date: str | None,
model: str | None,
api_key: str | None,
exclude_entity_ids: list[str] | None = None,
timezone_offset_minutes: int | None = None,
) -> SpendAnalyticsPaginatedResponse:
"""Aggregated variant that returns the full result set (no pagination).
@ -939,7 +1050,7 @@ async def get_daily_activity_aggregated(
if rows is None:
rows = []
records = [SimpleNamespace(**row) for row in rows]
records = [_GroupingSetsRow(**row) for row in rows]
# The grouping-sets dispatcher places each row directly in its bucket
# using the row's GROUPING() bitmask. No Python-side summing needed.

View file

@ -15,8 +15,9 @@ These are members of a Team on LiteLLM
import asyncio
import json
import traceback
from collections.abc import Sequence
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Union, cast
from typing import Any, Optional, cast
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
@ -29,6 +30,7 @@ from litellm.proxy.auth.auth_checks import get_team_object, get_user_object
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventHooks
from litellm.proxy.management_endpoints.common_daily_activity import (
DailySpendRecord,
get_daily_activity,
get_daily_activity_aggregated,
)
@ -59,17 +61,17 @@ from litellm.repositories.verification_token_repository import (
from litellm.types.proxy.management_endpoints.common_daily_activity import (
SpendAnalyticsPaginatedResponse,
)
from litellm.types.proxy.management_endpoints.scim_v2 import (
SCIM_ENTERPRISE_METADATA_KEY,
SCIM_ENTITLEMENTS_METADATA_KEY,
SCIM_ROLES_METADATA_KEY,
)
from litellm.types.proxy.management_endpoints.internal_user_endpoints import (
BulkUpdateUserRequest,
BulkUpdateUserResponse,
UserListResponse,
UserUpdateResult,
)
from litellm.types.proxy.management_endpoints.scim_v2 import (
SCIM_ENTERPRISE_METADATA_KEY,
SCIM_ENTITLEMENTS_METADATA_KEY,
SCIM_ROLES_METADATA_KEY,
)
if TYPE_CHECKING:
from litellm.proxy.proxy_server import PrismaClient
@ -127,11 +129,11 @@ def _update_internal_new_user_params(data_json: dict, data: NewUserRequest) -> d
async def _check_duplicate_user_field(
field_name: str,
field_value: Optional[str],
field_value: str | None,
prisma_client: Any,
*,
case_insensitive: bool = False,
label: Optional[str] = None,
label: str | None = None,
) -> None:
"""
Helper function to check if a field already exists in the user table.
@ -167,7 +169,7 @@ async def _check_duplicate_user_field(
)
async def _check_duplicate_user_email(user_email: Optional[str], prisma_client: Any) -> None:
async def _check_duplicate_user_email(user_email: str | None, prisma_client: Any) -> None:
"""
Helper function to check if a user email already exists in the database.
"""
@ -180,7 +182,7 @@ async def _check_duplicate_user_email(user_email: Optional[str], prisma_client:
)
async def _check_duplicate_user_id(user_id: Optional[str], prisma_client: Any) -> None:
async def _check_duplicate_user_id(user_id: str | None, prisma_client: Any) -> None:
"""
Helper function to check if a user id already exists in the database.
"""
@ -194,7 +196,7 @@ async def _check_duplicate_user_id(user_id: Optional[str], prisma_client: Any) -
async def _add_user_to_organizations(
user_id: str,
organizations: List[str],
organizations: list[str],
prisma_client: "PrismaClient",
user_api_key_dict: UserAPIKeyAuth,
):
@ -231,8 +233,8 @@ async def _add_user_to_team(
user_id: str,
team_id: str,
user_api_key_dict: UserAPIKeyAuth,
user_email: Optional[str] = None,
max_budget_in_team: Optional[float] = None,
user_email: str | None = None,
max_budget_in_team: float | None = None,
user_role: Literal["user", "admin"] = "user",
):
from litellm.proxy.management_endpoints.team_endpoints import team_member_add
@ -258,10 +260,12 @@ async def _add_user_to_team(
)
)
else:
verbose_proxy_logger.debug(
"litellm.proxy.management_endpoints.internal_user_endpoints.new_user(): Exception occured - {}".format(
str(e)
)
verbose_proxy_logger.error(
"litellm.proxy.management_endpoints.internal_user_endpoints._add_user_to_team(): "
"failed to add user %s to team %s - %s",
user_id,
team_id,
str(e),
)
except Exception as e:
if "already exists" in str(e) or "doesn't exist" in str(e):
@ -277,10 +281,17 @@ async def _add_user_to_team(
)
)
else:
verbose_proxy_logger.error(
"litellm.proxy.management_endpoints.internal_user_endpoints._add_user_to_team(): "
"failed to add user %s to team %s - %s",
user_id,
team_id,
str(e),
)
raise e
def check_if_default_team_set() -> Optional[Union[List[str], List[NewUserRequestTeam]]]:
def check_if_default_team_set() -> list[str] | list[NewUserRequestTeam] | None:
if litellm.default_internal_user_params is None:
return None
teams = litellm.default_internal_user_params.get("teams")
@ -306,9 +317,9 @@ def check_if_default_team_set() -> Optional[Union[List[str], List[NewUserRequest
async def add_new_user_to_default_team(
user_id: str,
user_email: Optional[str],
user_email: str | None,
user_api_key_dict: UserAPIKeyAuth,
teams: Union[List[str], List[NewUserRequestTeam]],
teams: list[str] | list[NewUserRequestTeam],
prisma_client: "PrismaClient",
):
tasks = []
@ -459,7 +470,7 @@ async def new_user(
teams = data.teams
if teams is None:
teams = check_if_default_team_set()
organization_ids = cast(Optional[List[str]], data_json.pop("organizations", None))
organization_ids = cast(list[str] | None, data_json.pop("organizations", None))
response = await generate_key_helper_fn(request_type="user", **data_json)
# Admin UI Logic
@ -484,7 +495,7 @@ async def new_user(
prisma_client=prisma_client,
)
user_id = cast(Optional[str], response.get("user_id", None))
user_id = cast(str | None, response.get("user_id", None))
if organization_ids is not None and user_id is not None:
await _add_user_to_organizations(
@ -560,9 +571,9 @@ async def ui_get_available_role(
def get_team_from_list(
team_list: Optional[Union[List[LiteLLM_TeamTable], List[TeamListResponseObject]]],
team_list: list[LiteLLM_TeamTable] | list[TeamListResponseObject] | None,
team_id: str,
) -> Optional[Union[LiteLLM_TeamTable, LiteLLM_TeamMembership]]:
) -> LiteLLM_TeamTable | LiteLLM_TeamMembership | None:
if team_list is None:
return None
@ -584,12 +595,12 @@ def _is_valid_user_id(user_id: str) -> bool:
return True
def get_user_id_from_request(request: Request) -> Optional[str]:
def get_user_id_from_request(request: Request) -> str | None:
"""
Get the user id from the request
"""
# Get the raw query string and parse it properly to handle + characters
user_id: Optional[str] = None
user_id: str | None = None
query_string = str(request.url.query)
if "user_id=" in query_string:
# Extract the user_id value from the raw query string
@ -605,14 +616,14 @@ def get_user_id_from_request(request: Request) -> Optional[str]:
return user_id
def _normalize_user_info_user_id(request: Request, user_id: Optional[str]) -> Optional[str]:
def _normalize_user_info_user_id(request: Request, user_id: str | None) -> str | None:
"""Normalize URL-decoded user_id while preserving '+' characters."""
if user_id is not None and " " in user_id:
return get_user_id_from_request(request=request)
return user_id
def _enforce_user_info_access(user_id: Optional[str], user_api_key_dict: UserAPIKeyAuth) -> None:
def _enforce_user_info_access(user_id: str | None, user_api_key_dict: UserAPIKeyAuth) -> None:
"""Re-validate that the caller may read the resolved ``user_id`` after
URL-decoding has been finalized.
@ -645,10 +656,10 @@ def _enforce_user_info_access(user_id: Optional[str], user_api_key_dict: UserAPI
async def _get_user_info_teams(
prisma_client: Any,
user_id: Optional[str],
user_info: Optional[Any],
user_id: str | None,
user_info: Any | None,
user_api_key_dict: UserAPIKeyAuth,
) -> tuple[list[Any], Optional[list[Any]]]:
) -> tuple[list[Any], list[Any] | None]:
"""Fetch and merge teams from membership + user.teams field."""
from litellm.proxy.management_endpoints.team_endpoints import list_team
@ -667,7 +678,7 @@ async def _get_user_info_teams(
team_list = teams_1
team_id_list = [team.team_id for team in teams_1]
teams_2: Optional[list[Any]] = None
teams_2: list[Any] | None = None
target_team_ids = getattr(user_info, "teams", None)
if target_team_ids and isinstance(target_team_ids, list):
@ -701,8 +712,8 @@ _SCIM_DIRECTORY_METADATA_KEYS = frozenset(
def _redact_scim_enterprise_metadata(
metadata: Optional[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
metadata: dict[str, Any] | None,
) -> dict[str, Any] | None:
"""SCIM enterprise attributes, entitlements, and roles are persisted in user
metadata so reporting can group on them, but they are directory-only fields
that generic user-info endpoints must not surface; SCIM clients read them
@ -713,11 +724,11 @@ def _redact_scim_enterprise_metadata(
def _build_user_info_response(
user_id: Optional[str],
user_info: Optional[Any],
keys: Optional[List[LiteLLM_VerificationToken]],
user_id: str | None,
user_info: Any | None,
keys: list[LiteLLM_VerificationToken] | None,
team_list: list[Any],
teams_1: Optional[list[Any]],
teams_1: list[Any] | None,
) -> UserInfoResponse:
"""Create UserInfoResponse while filtering sensitive fields."""
if user_info is None and keys is not None:
@ -749,7 +760,7 @@ def _build_user_info_response(
@management_endpoint_wrapper
async def user_info(
request: Request,
user_id: Optional[str] = fastapi.Query(default=None, description="User ID in the request parameters"),
user_id: str | None = fastapi.Query(default=None, description="User ID in the request parameters"),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
@ -886,7 +897,7 @@ async def _check_user_info_v2_access(
@management_endpoint_wrapper
async def user_info_v2(
request: Request,
user_id: Optional[str] = fastapi.Query(default=None, description="User ID in the request parameters"),
user_id: str | None = fastapi.Query(default=None, description="User ID in the request parameters"),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
@ -996,7 +1007,7 @@ async def _get_user_info_for_proxy_admin(user_api_key_dict: UserAPIKeyAuth):
verbose_proxy_logger.debug("results_keys: %s", results)
_keys_in_db: List = results[0]["keys"] or []
_keys_in_db: list = results[0]["keys"] or []
# cast all keys to LiteLLM_VerificationToken
keys_in_db = []
for key in _keys_in_db:
@ -1005,7 +1016,7 @@ async def _get_user_info_for_proxy_admin(user_api_key_dict: UserAPIKeyAuth):
keys_in_db.append(LiteLLM_VerificationToken(**key))
# cast all teams to LiteLLM_TeamTable
_teams_in_db: List = results[0]["teams"] or []
_teams_in_db: list = results[0]["teams"] or []
_teams_in_db = [LiteLLM_TeamTable(**team) for team in _teams_in_db]
_teams_in_db.sort(key=lambda x: getattr(x, "team_alias", "") or "")
returned_keys = _process_keys_for_user_info(keys=keys_in_db, all_teams=_teams_in_db)
@ -1032,8 +1043,8 @@ async def _get_user_info_for_proxy_admin(user_api_key_dict: UserAPIKeyAuth):
def _process_keys_for_user_info(
keys: Optional[List[LiteLLM_VerificationToken]],
all_teams: Optional[Union[List[LiteLLM_TeamTable], List[TeamListResponseObject]]],
keys: list[LiteLLM_VerificationToken] | None,
all_teams: list[LiteLLM_TeamTable] | list[TeamListResponseObject] | None,
):
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
from litellm.proxy.proxy_server import general_settings, litellm_master_key_hash
@ -1073,9 +1084,7 @@ def _process_keys_for_user_info(
return returned_keys
def _update_internal_user_params(
data_json: dict, data: Union[UpdateUserRequest, UpdateUserRequestNoUserIDorEmail]
) -> dict:
def _update_internal_user_params(data_json: dict, data: UpdateUserRequest | UpdateUserRequestNoUserIDorEmail) -> dict:
non_default_values = {}
fields_set = data.fields_set() if hasattr(data, "fields_set") else set()
@ -1124,11 +1133,11 @@ def _update_internal_user_params(
async def _schedule_user_update_audit_log(
response: Dict[str, Any],
existing_user_row: Optional[BaseModel],
litellm_changed_by: Optional[str],
response: dict[str, Any],
existing_user_row: BaseModel | None,
litellm_changed_by: str | None,
user_api_key_dict: UserAPIKeyAuth,
litellm_proxy_admin_name: Optional[str],
litellm_proxy_admin_name: str | None,
) -> None:
from litellm.proxy.proxy_server import prisma_client
@ -1156,7 +1165,7 @@ async def _schedule_user_update_audit_log(
def _check_user_update_authz(
user_request: UpdateUserRequest,
user_api_key_dict: UserAPIKeyAuth,
existing_user_row: Optional[BaseModel],
existing_user_row: BaseModel | None,
) -> None:
"""Authorization checks for /user/update — raises HTTPException on failure."""
if user_request.user_role is not None and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value:
@ -1201,8 +1210,8 @@ async def _invalidate_user_spend_counter_if_changed(
async def _update_single_user_helper(
user_request: UpdateUserRequest,
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: Optional[str] = None,
) -> Dict[str, Any]:
litellm_changed_by: str | None = None,
) -> dict[str, Any]:
"""
Helper function to update a single user.
Used by both user_update and bulk_user_update endpoints.
@ -1226,7 +1235,7 @@ async def _update_single_user_helper(
non_default_values = _update_internal_user_params(data_json=data_json, data=user_request)
_hash_password_in_dict(non_default_values)
existing_user_row: Optional[BaseModel] = None
existing_user_row: BaseModel | None = None
if user_request.user_id:
existing_user_row = await UserRepository(prisma_client).table.find_first(
where={"user_id": user_request.user_id}
@ -1261,7 +1270,7 @@ async def _update_single_user_helper(
)
existing_metadata = (
cast(Dict, getattr(existing_user_row, "metadata", {}) or {}) if existing_user_row is not None else {}
cast(dict, getattr(existing_user_row, "metadata", {}) or {}) if existing_user_row is not None else {}
)
non_default_values = prepare_metadata_fields(
@ -1274,7 +1283,7 @@ async def _update_single_user_helper(
validate_finite_spend(non_default_values.get("spend"))
# Perform the update
response: Optional[Dict[str, Any]] = None
response: dict[str, Any] | None = None
if user_request.user_id and len(user_request.user_id) > 0:
non_default_values["user_id"] = user_request.user_id
@ -1434,11 +1443,11 @@ async def user_update(
async def bulk_update_processed_users(
users_to_update: List[UpdateUserRequest],
users_to_update: list[UpdateUserRequest],
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: Optional[str] = None,
litellm_changed_by: str | None = None,
) -> BulkUpdateUserResponse:
results: List[UserUpdateResult] = []
results: list[UserUpdateResult] = []
successful_updates = 0
failed_updates = 0
@ -1502,7 +1511,7 @@ async def bulk_update_processed_users(
async def bulk_user_update(
data: BulkUpdateUserRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
litellm_changed_by: Optional[str] = Header(
litellm_changed_by: str | None = Header(
None,
description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
),
@ -1578,7 +1587,7 @@ async def bulk_user_update(
)
# Determine the list of users to update
users_to_update: Union[List[UpdateUserRequest], List[UpdateUserRequestNoUserIDorEmail]] = []
users_to_update: list[UpdateUserRequest] | list[UpdateUserRequestNoUserIDorEmail] = []
if data.all_users and data.user_updates:
# Only proxy admins can update all users at once
@ -1616,7 +1625,7 @@ async def bulk_user_update(
successful_updates = 0
failed_updates = 0
results: List[UserUpdateResult] = []
results: list[UserUpdateResult] = []
try:
# Perform bulk database update
@ -1696,7 +1705,7 @@ async def bulk_user_update(
)
return await bulk_update_processed_users(
users_to_update=cast(List[UpdateUserRequest], users_to_update),
users_to_update=cast(list[UpdateUserRequest], users_to_update),
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
)
@ -1704,7 +1713,7 @@ async def bulk_user_update(
async def get_user_key_counts(
prisma_client,
user_ids: Optional[List[str]] = None,
user_ids: list[str] | None = None,
):
"""
Helper function to get the count of keys for each user using Prisma's count method.
@ -1739,8 +1748,8 @@ async def get_user_key_counts(
return result
def _validate_sort_params(sort_by: Optional[str], sort_order: str) -> Optional[Dict[str, str]]:
order_by: Dict[str, str] = {}
def _validate_sort_params(sort_by: str | None, sort_order: str) -> dict[str, str] | None:
order_by: dict[str, str] = {}
if sort_by is None:
return None
@ -1773,11 +1782,11 @@ def _validate_sort_params(sort_by: Optional[str], sort_order: str) -> Optional[D
async def _authorize_user_list_request(
user_api_key_dict: UserAPIKeyAuth,
organization_ids: Optional[str],
organization_ids: str | None,
prisma_client: Any,
user_api_key_cache: Any,
proxy_logging_obj: Any,
) -> Optional[str]:
) -> str | None:
"""
Authorize the /user/list request and return the (possibly scoped) organization_ids string.
@ -1844,19 +1853,19 @@ async def _authorize_user_list_request(
response_model=UserListResponse,
)
async def get_users(
role: Optional[str] = fastapi.Query(default=None, description="Filter users by role"),
user_ids: Optional[str] = fastapi.Query(default=None, description="Get list of users by user_ids"),
sso_user_ids: Optional[str] = fastapi.Query(default=None, description="Get list of users by sso_user_id"),
user_email: Optional[str] = fastapi.Query(default=None, description="Filter users by partial email match"),
team: Optional[str] = fastapi.Query(default=None, description="Filter users by team id"),
role: str | None = fastapi.Query(default=None, description="Filter users by role"),
user_ids: str | None = fastapi.Query(default=None, description="Get list of users by user_ids"),
sso_user_ids: str | None = fastapi.Query(default=None, description="Get list of users by sso_user_id"),
user_email: str | None = fastapi.Query(default=None, description="Filter users by partial email match"),
team: str | None = fastapi.Query(default=None, description="Filter users by team id"),
page: int = fastapi.Query(default=1, ge=1, description="Page number"),
page_size: int = fastapi.Query(default=25, ge=1, le=100, description="Number of items per page"),
sort_by: Optional[str] = fastapi.Query(
sort_by: str | None = fastapi.Query(
default=None,
description="Column to sort by (e.g. 'user_id', 'user_email', 'created_at', 'spend')",
),
sort_order: str = fastapi.Query(default="asc", description="Sort order ('asc' or 'desc')"),
organization_ids: Optional[str] = fastapi.Query(
organization_ids: str | None = fastapi.Query(
default=None,
description="Filter users by organization membership. Comma-separated list of org IDs.",
),
@ -1914,7 +1923,7 @@ async def get_users(
skip = (page - 1) * page_size
# Build where conditions based on provided parameters
where_conditions: Dict[str, Any] = {}
where_conditions: dict[str, Any] = {}
if role:
where_conditions["user_role"] = role
@ -1958,7 +1967,7 @@ async def get_users(
# Build order_by conditions
order_by: Optional[Dict[str, str]] = (
order_by: dict[str, str] | None = (
_validate_sort_params(sort_by, sort_order) if sort_by is not None and isinstance(sort_by, str) else None
)
@ -1984,7 +1993,7 @@ async def get_users(
total_pages = -(-total_count // page_size) # Ceiling division
# Prepare response
user_list: List[LiteLLM_UserTableWithKeyCount] = []
user_list: list[LiteLLM_UserTableWithKeyCount] = []
if users is not None:
for user in users:
user_dump = user.model_dump()
@ -2011,7 +2020,7 @@ async def get_users(
async def delete_user(
data: DeleteUserRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
litellm_changed_by: Optional[str] = Header(
litellm_changed_by: str | None = Header(
None,
description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
),
@ -2080,7 +2089,7 @@ async def delete_user(
# Batch-fetch target memberships once before the per-user loop. Avoids
# an N+1 DB call when delete_user is called with a large user_ids list.
target_org_ids_by_user: Dict[str, set] = {}
target_org_ids_by_user: dict[str, set] = {}
if not caller_is_proxy_admin:
all_target_memberships = await OrganizationMembershipRepository(prisma_client).table.find_many(
where={"user_id": {"in": data.user_ids}}
@ -2156,7 +2165,7 @@ async def delete_user(
),
)
if is_member_in_team:
_db_new_team_members: List[dict] = [m.model_dump() for m in new_team_members]
_db_new_team_members: list[dict] = [m.model_dump() for m in new_team_members]
team.members_with_roles = json.dumps(_db_new_team_members)
teams_to_update.append(team)
@ -2241,11 +2250,11 @@ async def add_internal_user_to_organization(
async def _resolve_org_filter_for_user_search(
user_api_key_dict: UserAPIKeyAuth,
team_id: Optional[str],
team_id: str | None,
prisma_client: Any,
user_api_key_cache: Any,
proxy_logging_obj: Any,
) -> Optional[List[str]]:
) -> list[str] | None:
"""
Return a list of org IDs to filter by, or ``None`` for no filter.
@ -2279,7 +2288,7 @@ async def _resolve_org_filter_for_user_search(
# Collect org IDs from ALL org memberships (any role, not just ORG_ADMIN).
# This allows team admins who are org members to search users in their org.
member_org_ids: List[str] = []
member_org_ids: list[str] = []
if caller_user is not None:
member_org_ids = [m.organization_id for m in (caller_user.organization_memberships or [])]
@ -2311,7 +2320,7 @@ async def _resolve_team_org_filter(
prisma_client: Any,
user_api_key_cache: Any,
proxy_logging_obj: Any,
) -> List[str]:
) -> list[str]:
"""Look up the team and return its org as a filter list, or raise 403."""
from litellm.proxy.management_endpoints.common_utils import _is_user_team_admin
@ -2351,13 +2360,13 @@ async def _resolve_team_org_filter(
dependencies=[Depends(user_api_key_auth)],
include_in_schema=False,
responses={
200: {"model": List[LiteLLM_UserTableFiltered]},
200: {"model": list[LiteLLM_UserTableFiltered]},
},
)
async def ui_view_users(
user_id: Optional[str] = fastapi.Query(default=None, description="User ID in the request parameters"),
user_email: Optional[str] = fastapi.Query(default=None, description="User email in the request parameters"),
team_id: Optional[str] = fastapi.Query(
user_id: str | None = fastapi.Query(default=None, description="User ID in the request parameters"),
user_email: str | None = fastapi.Query(default=None, description="User email in the request parameters"),
team_id: str | None = fastapi.Query(
default=None,
description="Team ID — used when a team admin searches for users to add to their team",
),
@ -2400,7 +2409,7 @@ async def ui_view_users(
skip = (page - 1) * page_size
# Build where conditions based on provided parameters
where_conditions: Dict[str, Any] = {}
where_conditions: dict[str, Any] = {}
if user_id:
where_conditions["user_id"] = {
@ -2419,7 +2428,7 @@ async def ui_view_users(
where_conditions["organization_memberships"] = {"some": {"organization_id": {"in": org_filter_ids}}}
# Query users with pagination and filters
users: Optional[List[BaseModel]] = await UserRepository(prisma_client).table.find_many(
users: list[BaseModel] | None = await UserRepository(prisma_client).table.find_many(
where=where_conditions,
skip=skip,
take=page_size,
@ -2441,10 +2450,14 @@ async def ui_view_users(
# Using shared metric helper implementations from common_daily_activity
async def _resolve_user_email_metadata(prisma_client: "PrismaClient", records: list[Any]) -> dict[str, dict]:
async def _resolve_user_email_metadata(
prisma_client: "PrismaClient", records: Sequence[DailySpendRecord]
) -> dict[str, dict]:
"""Map each user_id on the page to its email/alias so the Usage dashboard can
label the 'Spend Per User' chart with the email instead of the raw UUID."""
user_ids = {record.user_id for record in records if getattr(record, "user_id", None)}
user_ids = {
user_id for record in records if isinstance(user_id := getattr(record, "user_id", None), str) and user_id
}
if not user_ids:
return {}
users = await UserRepository(prisma_client).table.find_many(where={"user_id": {"in": list(user_ids)}})
@ -2459,29 +2472,29 @@ async def _resolve_user_email_metadata(prisma_client: "PrismaClient", records: l
)
@management_endpoint_wrapper
async def get_user_daily_activity(
start_date: Optional[str] = fastapi.Query(
start_date: str | None = fastapi.Query(
default=None,
description="Start date in YYYY-MM-DD format",
),
end_date: Optional[str] = fastapi.Query(
end_date: str | None = fastapi.Query(
default=None,
description="End date in YYYY-MM-DD format",
),
model: Optional[str] = fastapi.Query(
model: str | None = fastapi.Query(
default=None,
description="Filter by specific model",
),
api_key: Optional[str] = fastapi.Query(
api_key: str | None = fastapi.Query(
default=None,
description="Filter by specific API key",
),
user_id: Optional[str] = fastapi.Query(
user_id: str | None = fastapi.Query(
default=None,
description="Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id.",
),
page: int = fastapi.Query(default=1, description="Page number for pagination", ge=1),
page_size: int = fastapi.Query(default=50, description="Items per page", ge=1, le=1000),
timezone: Optional[int] = fastapi.Query(
timezone: int | None = fastapi.Query(
default=None,
description="Timezone offset in minutes from UTC (e.g., 480 for PST). "
"Matches JavaScript's Date.getTimezoneOffset() convention.",
@ -2568,27 +2581,27 @@ async def get_user_daily_activity(
)
@management_endpoint_wrapper
async def get_user_daily_activity_aggregated(
start_date: Optional[str] = fastapi.Query(
start_date: str | None = fastapi.Query(
default=None,
description="Start date in YYYY-MM-DD format",
),
end_date: Optional[str] = fastapi.Query(
end_date: str | None = fastapi.Query(
default=None,
description="End date in YYYY-MM-DD format",
),
model: Optional[str] = fastapi.Query(
model: str | None = fastapi.Query(
default=None,
description="Filter by specific model",
),
api_key: Optional[str] = fastapi.Query(
api_key: str | None = fastapi.Query(
default=None,
description="Filter by specific API key",
),
user_id: Optional[str] = fastapi.Query(
user_id: str | None = fastapi.Query(
default=None,
description="Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id.",
),
timezone: Optional[int] = fastapi.Query(
timezone: int | None = fastapi.Query(
default=None,
description="Timezone offset in minutes from UTC (e.g., 480 for PST). "
"Matches JavaScript's Date.getTimezoneOffset() convention.",

View file

@ -19,9 +19,10 @@ import functools
import importlib
import json
import os
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Iterable, List, Literal, Optional, Set
from typing import Any, Literal
from fastapi import (
APIRouter,
@ -47,10 +48,10 @@ from litellm._logging import verbose_logger, verbose_proxy_logger
from litellm._uuid import uuid
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
from litellm.proxy._experimental.mcp_server.utils import (
build_env_var_setup_url,
collect_env_var_references,
LITELLM_MCP_SERVER_DESCRIPTION,
LITELLM_MCP_SERVER_NAME,
build_env_var_setup_url,
collect_env_var_references,
get_server_prefix,
parse_admin_env_vars,
)
@ -194,7 +195,7 @@ if MCP_AVAILABLE:
expires_at: datetime
def _validate_mcp_server_name_fields(payload: Any) -> None:
candidates: List[tuple[str, Optional[str]]] = []
candidates: list[tuple[str, str | None]] = []
server_name = getattr(payload, "server_name", None)
alias = getattr(payload, "alias", None)
@ -260,7 +261,7 @@ if MCP_AVAILABLE:
general_settings as proxy_general_settings,
)
required_fields: Optional[List[str]] = proxy_general_settings.get("mcp_required_fields")
required_fields: list[str] | None = proxy_general_settings.get("mcp_required_fields")
if not required_fields:
return
@ -320,7 +321,7 @@ if MCP_AVAILABLE:
return server.server_name
return server.server_id
def _build_mcp_registry_entry_for_server(server: MCPServer, base_url: str) -> Dict[str, Any]:
def _build_mcp_registry_entry_for_server(server: MCPServer, base_url: str) -> dict[str, Any]:
server_name = _build_mcp_registry_server_name(server)
title = server_name
description = server_name
@ -344,7 +345,7 @@ if MCP_AVAILABLE:
],
}
def _build_builtin_registry_entry(base_url: str) -> Dict[str, Any]:
def _build_builtin_registry_entry(base_url: str) -> dict[str, Any]:
remote_url = _build_registry_remote_url(base_url, "/mcp")
return {
"name": LITELLM_MCP_SERVER_NAME,
@ -359,7 +360,7 @@ if MCP_AVAILABLE:
],
}
_temporary_mcp_servers: Dict[str, _TemporaryMCPServerEntry] = {}
_temporary_mcp_servers: dict[str, _TemporaryMCPServerEntry] = {}
def _prune_expired_temporary_mcp_servers() -> None:
if not _temporary_mcp_servers:
@ -391,7 +392,7 @@ if MCP_AVAILABLE:
if cache_backend is None or not hasattr(cache_backend, "async_set_cache"):
return
payload: Dict[str, Any] = server.model_dump(mode="json")
payload: dict[str, Any] = server.model_dump(mode="json")
payload_json = json.dumps(payload)
try:
encrypted_payload = encrypt_value_helper(payload_json)
@ -414,7 +415,7 @@ if MCP_AVAILABLE:
async def _get_temporary_mcp_server_from_redis(
server_id: str,
) -> Optional[MCPServer]:
) -> MCPServer | None:
"""
Best-effort read from Redis shared cache. Returns None on miss/errors.
@ -455,7 +456,7 @@ if MCP_AVAILABLE:
return None
if not isinstance(loaded, dict):
return None
payload_dict: Dict[str, Any] = loaded
payload_dict: dict[str, Any] = loaded
try:
return MCPServer(**payload_dict)
@ -465,7 +466,7 @@ if MCP_AVAILABLE:
async def get_cached_temporary_mcp_server(
server_id: str,
) -> Optional[MCPServer]:
) -> MCPServer | None:
_prune_expired_temporary_mcp_servers()
entry = _temporary_mcp_servers.get(server_id)
if entry is None:
@ -520,7 +521,7 @@ if MCP_AVAILABLE:
def _redact_mcp_credentials_list(
mcp_servers: Iterable[LiteLLM_MCPServerTable],
) -> List[LiteLLM_MCPServerTable]:
) -> list[LiteLLM_MCPServerTable]:
return [_redact_mcp_credentials(server) for server in mcp_servers]
def _user_is_full_admin(user_api_key_dict: UserAPIKeyAuth) -> bool:
@ -587,7 +588,7 @@ if MCP_AVAILABLE:
def _sanitize_mcp_server_list_for_non_admin(
mcp_servers: Iterable[LiteLLM_MCPServerTable],
) -> List[LiteLLM_MCPServerTable]:
) -> list[LiteLLM_MCPServerTable]:
return [_sanitize_mcp_server_for_non_admin(s) for s in mcp_servers]
def _sanitize_mcp_server_for_virtual_key(
@ -644,7 +645,7 @@ if MCP_AVAILABLE:
def _sanitize_mcp_server_list_for_virtual_key(
mcp_servers: Iterable[LiteLLM_MCPServerTable],
) -> List[LiteLLM_MCPServerTable]:
) -> list[LiteLLM_MCPServerTable]:
return [_sanitize_mcp_server_for_virtual_key(server) for server in mcp_servers]
# (server attribute, credentials key) a session server inherits from the server it derives from.
@ -697,7 +698,7 @@ if MCP_AVAILABLE:
except AttributeError:
pass
payload_dict: Dict[str, Any]
payload_dict: dict[str, Any]
try:
payload_dict = payload.model_dump() # type: ignore[attr-defined]
except AttributeError:
@ -707,7 +708,7 @@ if MCP_AVAILABLE:
def _build_temporary_mcp_server_record(
payload: NewMCPServerRequest,
created_by: Optional[str],
created_by: str | None,
) -> LiteLLM_MCPServerTable:
now = datetime.utcnow()
server_id = payload.server_id or str(uuid.uuid4())
@ -848,7 +849,7 @@ if MCP_AVAILABLE:
verbose_proxy_logger.debug("MCP registry request from IP=%s", client_ip)
base_url = get_request_base_url(request)
registry_servers: List[Dict[str, Any]] = []
registry_servers: list[dict[str, Any]] = []
registry_servers.append({"server": _build_builtin_registry_entry(base_url)})
# Centralized IP-based filtering: external callers only see public servers
@ -881,7 +882,7 @@ if MCP_AVAILABLE:
async def _get_team_scoped_mcp_server_list(
team_id: str,
) -> List[LiteLLM_MCPServerTable]:
) -> list[LiteLLM_MCPServerTable]:
"""
Return MCP servers scoped to a team: team's allowed servers + allow_all_keys servers.
Used by the Create Key UI to populate the MCP server dropdown.
@ -908,7 +909,7 @@ if MCP_AVAILABLE:
return []
# Collect servers from registry
servers: List[LiteLLM_MCPServerTable] = []
servers: list[LiteLLM_MCPServerTable] = []
for server_id in all_allowed_ids:
server = global_mcp_server_manager.get_mcp_server_by_id(server_id)
if server is not None:
@ -919,7 +920,7 @@ if MCP_AVAILABLE:
async def _resolve_accessible_mcp_servers(
user_api_key_dict: UserAPIKeyAuth,
) -> List[LiteLLM_MCPServerTable]:
) -> list[LiteLLM_MCPServerTable]:
"""The server set the dashboard grid shows (GET /v1/mcp/server, no team
filter), returned unredacted. Callers that surface this to a client must
apply their own redaction; the per-user env-var status endpoint relies on
@ -932,7 +933,7 @@ if MCP_AVAILABLE:
if _get_user_mcp_management_mode() == "view_all" and not _is_restricted_virtual_key_request(user_api_key_dict):
return await global_mcp_server_manager.get_all_mcp_servers_unfiltered()
aggregated: Dict[str, LiteLLM_MCPServerTable] = {}
aggregated: dict[str, LiteLLM_MCPServerTable] = {}
for auth_context in await build_effective_auth_contexts(user_api_key_dict):
for server in await global_mcp_server_manager.get_all_allowed_mcp_servers(user_api_key_auth=auth_context):
aggregated.setdefault(server.server_id, server)
@ -942,11 +943,11 @@ if MCP_AVAILABLE:
"/server",
description="Returns the mcp server list with associated teams",
dependencies=[Depends(user_api_key_auth)],
response_model=List[LiteLLM_MCPServerTable],
response_model=list[LiteLLM_MCPServerTable],
)
async def fetch_all_mcp_servers(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
team_id: Optional[str] = Query(
team_id: str | None = Query(
None,
description="Filter MCP servers by team scope. When provided, returns only "
"servers the team has access to plus globally available (allow_all_keys) servers. "
@ -1048,7 +1049,7 @@ if MCP_AVAILABLE:
dependencies=[Depends(user_api_key_auth)],
)
async def health_check_servers(
server_ids: Optional[List[str]] = Query(
server_ids: list[str] | None = Query(
None,
description="Server IDs to check. If not provided, checks all accessible servers.",
),
@ -1081,7 +1082,7 @@ if MCP_AVAILABLE:
auth_contexts = await build_effective_auth_contexts(user_api_key_dict)
server_status_map: Dict[str, Optional[Literal["healthy", "unhealthy", "unknown"]]] = {}
server_status_map: dict[str, Literal["healthy", "unhealthy", "unknown"] | None] = {}
for auth_context in auth_contexts:
servers = await global_mcp_server_manager.get_all_mcp_servers_with_health_and_teams(
user_api_key_auth=auth_context,
@ -1399,7 +1400,7 @@ if MCP_AVAILABLE:
async def add_mcp_server(
payload: NewMCPServerRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
litellm_changed_by: Optional[str] = Header(
litellm_changed_by: str | None = Header(
None,
description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
),
@ -1489,7 +1490,7 @@ if MCP_AVAILABLE:
async def add_session_mcp_server(
payload: NewMCPServerRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
litellm_changed_by: Optional[str] = Header(
litellm_changed_by: str | None = Header(
None,
description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
),
@ -1647,7 +1648,7 @@ if MCP_AVAILABLE:
async def _get_cached_temporary_mcp_server_or_404(
server_id: str,
user_api_key_dict: UserAPIKeyAuth,
request: Optional[Request] = None,
request: Request | None = None,
) -> MCPServer:
server = await get_cached_temporary_mcp_server(server_id)
resolved_from_temp_cache = server is not None
@ -1677,7 +1678,7 @@ if MCP_AVAILABLE:
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": f"Access denied to MCP server {server_id}"},
)
allowed_server_ids: Set[str] = set()
allowed_server_ids: set[str] = set()
for auth_context in await build_effective_auth_contexts(user_api_key_dict):
allowed_server_ids.update(await global_mcp_server_manager.get_allowed_mcp_servers(auth_context))
if server.server_id not in allowed_server_ids:
@ -1696,13 +1697,13 @@ if MCP_AVAILABLE:
request: Request,
server_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(_mcp_oauth_user_api_key_auth),
client_id: Optional[str] = None,
client_id: str | None = None,
redirect_uri: str = Query(...),
state: str = "",
code_challenge: Optional[str] = None,
code_challenge_method: Optional[str] = None,
response_type: Optional[str] = None,
scope: Optional[str] = None,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
response_type: str | None = None,
scope: str | None = None,
):
mcp_server = await _get_cached_temporary_mcp_server_or_404(server_id, user_api_key_dict, request=request)
_raise_if_not_oauth2(mcp_server)
@ -1756,13 +1757,13 @@ if MCP_AVAILABLE:
server_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(_mcp_oauth_user_api_key_auth),
grant_type: str = Form(...),
code: Optional[str] = Form(None),
redirect_uri: Optional[str] = Form(None),
client_id: Optional[str] = Form(None),
client_secret: Optional[str] = Form(None),
code_verifier: Optional[str] = Form(None),
refresh_token: Optional[str] = Form(None),
scope: Optional[str] = Form(None),
code: str | None = Form(None),
redirect_uri: str | None = Form(None),
client_id: str | None = Form(None),
client_secret: str | None = Form(None),
code_verifier: str | None = Form(None),
refresh_token: str | None = Form(None),
scope: str | None = Form(None),
):
mcp_server = await _get_cached_temporary_mcp_server_or_404(server_id, user_api_key_dict, request=request)
_raise_if_not_oauth2(mcp_server)
@ -1844,7 +1845,7 @@ if MCP_AVAILABLE:
async def remove_mcp_server(
server_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
litellm_changed_by: Optional[str] = Header(
litellm_changed_by: str | None = Header(
None,
description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
),
@ -2007,7 +2008,7 @@ if MCP_AVAILABLE:
# expires_at rather than recomputing it here (which could diverge by
# milliseconds or if the storage logic ever adds a grace period).
stored = await get_user_oauth_credential(prisma_client, user_id, server_id)
expires_at: Optional[str] = stored.get("expires_at") if stored else None
expires_at: str | None = stored.get("expires_at") if stored else None
return MCPOAuthUserCredentialStatus(
server_id=server_id,
has_credential=True,
@ -2076,7 +2077,7 @@ if MCP_AVAILABLE:
cred = await get_user_oauth_credential(prisma_client, user_id, server_id)
if cred is None:
return MCPOAuthUserCredentialStatus(server_id=server_id, has_credential=False, is_expired=False)
expires_at: Optional[str] = cred.get("expires_at")
expires_at: str | None = cred.get("expires_at")
is_expired = False
if expires_at:
try:
@ -2096,7 +2097,7 @@ if MCP_AVAILABLE:
"/user-credentials",
description="List all OAuth2 MCP credentials stored for the calling user",
dependencies=[Depends(user_api_key_auth)],
response_model=List[MCPUserCredentialListItem],
response_model=list[MCPUserCredentialListItem],
)
@management_endpoint_wrapper
async def list_mcp_user_credentials(
@ -2114,13 +2115,15 @@ if MCP_AVAILABLE:
if not oauth_creds:
return []
# Fetch server metadata for display names — single batch query instead of N+1.
server_ids = [c["server_id"] for c in oauth_creds]
server_ids = [c["server_id"] for c in oauth_creds if "server_id" in c]
servers = {srv.server_id: srv for srv in await get_mcp_servers(prisma_client, server_ids)}
items: List[MCPUserCredentialListItem] = []
items: list[MCPUserCredentialListItem] = []
for cred in oauth_creds:
if "server_id" not in cred:
continue
sid = cred["server_id"]
srv = servers.get(sid)
expires_at: Optional[str] = cred.get("expires_at")
expires_at: str | None = cred.get("expires_at")
items.append(
MCPUserCredentialListItem(
server_id=sid,
@ -2182,7 +2185,7 @@ if MCP_AVAILABLE:
def _compute_user_env_var_status(
*,
server: LiteLLM_MCPServerTable,
stored_values: Dict[str, str],
stored_values: dict[str, str],
) -> MCPUserEnvVarsStatus:
"""Build a status object for one server given the user's stored values.
@ -2211,7 +2214,7 @@ if MCP_AVAILABLE:
user_var_names = {spec["name"] for spec in user_specs}
blocking = {name for name in (referenced & user_var_names) if name not in global_values}
required: List[MCPUserEnvVarSpec] = []
required: list[MCPUserEnvVarSpec] = []
missing_count = 0
for spec in user_specs:
name = spec["name"]
@ -2334,12 +2337,12 @@ if MCP_AVAILABLE:
description="Per-user MCP env var status across every server the user can access. "
"Used by the dashboard to highlight servers with missing per-user vars.",
dependencies=[Depends(user_api_key_auth)],
response_model=List[MCPUserEnvVarsStatus],
response_model=list[MCPUserEnvVarsStatus],
)
@management_endpoint_wrapper
async def list_mcp_user_env_var_status(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> List[MCPUserEnvVarsStatus]:
) -> list[MCPUserEnvVarsStatus]:
prisma_client = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
user_id = user_api_key_dict.user_id or ""
if not user_id:
@ -2349,7 +2352,7 @@ if MCP_AVAILABLE:
return []
server_ids = [s.server_id for s in accessible]
stored_bulk = await get_user_env_vars_bulk(prisma_client, user_id, server_ids)
statuses: List[MCPUserEnvVarsStatus] = []
statuses: list[MCPUserEnvVarsStatus] = []
for server in accessible:
stored = stored_bulk.get(server.server_id, {})
status_obj = _compute_user_env_var_status(server=server, stored_values=stored)
@ -2368,7 +2371,7 @@ if MCP_AVAILABLE:
async def edit_mcp_server(
payload: UpdateMCPServerRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
litellm_changed_by: Optional[str] = Header(
litellm_changed_by: str | None = Header(
None,
description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
),
@ -2564,16 +2567,16 @@ if MCP_AVAILABLE:
"mcp_registry.json",
)
_mcp_registry_cache: Optional[Dict[str, Any]] = None
_mcp_registry_cache: dict[str, Any] | None = None
def _load_mcp_registry() -> Dict[str, Any]:
def _load_mcp_registry() -> dict[str, Any]:
"""Load the curated MCP registry from disk. Cached after first read."""
global _mcp_registry_cache
if _mcp_registry_cache is not None:
return _mcp_registry_cache
try:
with open(_MCP_REGISTRY_PATH, "r") as f:
data: Dict[str, Any] = json.load(f)
data: dict[str, Any] = json.load(f)
except Exception as e:
verbose_proxy_logger.warning(f"Failed to load MCP registry from {_MCP_REGISTRY_PATH}: {e}")
data = {"servers": []}
@ -2586,8 +2589,8 @@ if MCP_AVAILABLE:
dependencies=[Depends(user_api_key_auth)],
)
async def discover_mcp_servers(
query: Optional[str] = Query(None, description="Search filter for server names and descriptions"),
category: Optional[str] = Query(None, description="Filter by category"),
query: str | None = Query(None, description="Search filter for server names and descriptions"),
category: str | None = Query(None, description="Filter by category"),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
@ -2641,9 +2644,9 @@ if MCP_AVAILABLE:
)
@functools.lru_cache(maxsize=1)
def _load_openapi_registry() -> Dict[str, Any]:
def _load_openapi_registry() -> dict[str, Any]:
with open(_OPENAPI_REGISTRY_PATH, "r") as f:
data: Dict[str, Any] = json.load(f)
data: dict[str, Any] = json.load(f)
return data
@router.get(
@ -2694,7 +2697,7 @@ if MCP_AVAILABLE:
async def add_mcp_toolset(
payload: NewMCPToolsetRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
litellm_changed_by: Optional[str] = Header(None),
litellm_changed_by: str | None = Header(None),
):
"""Create a named toolset — a curated selection of {server_id, tool_name} pairs."""
prisma_client = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
@ -2783,7 +2786,7 @@ if MCP_AVAILABLE:
async def edit_mcp_toolset(
payload: UpdateMCPToolsetRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
litellm_changed_by: Optional[str] = Header(None),
litellm_changed_by: str | None = Header(None),
):
prisma_client = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:
@ -2833,7 +2836,7 @@ if MCP_AVAILABLE:
async def remove_mcp_toolset(
toolset_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
litellm_changed_by: Optional[str] = Header(None),
litellm_changed_by: str | None = Header(None),
):
prisma_client = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:

View file

@ -1040,22 +1040,6 @@ class ModelManagementAuthChecks:
return True
def _deployment_name_and_model(deployment: Optional[Union[Deployment, Dict[str, object]]]) -> Tuple[Optional[str], str]:
"""Return (model_name, litellm_params.model) for a deployment.
delete_deployment is annotated to return a Deployment but hands back the raw
model_list dict at runtime, so both shapes are handled; the model defaults to "".
"""
if deployment is None:
return None, ""
if isinstance(deployment, dict):
name = deployment.get("model_name")
params = deployment.get("litellm_params")
model = params.get("model") if isinstance(params, dict) else None
return (name if isinstance(name, str) else None), (model if isinstance(model, str) else "")
return deployment.model_name, str(getattr(deployment.litellm_params, "model", "") or "")
#### [BETA] - This is a beta endpoint, format might change based on user feedback. - https://github.com/BerriAI/litellm/issues/964
@router.post(
"/model/delete",
@ -1127,19 +1111,7 @@ async def delete_model(
## DELETE FROM ROUTER ##
if llm_router is not None:
deleted_deployment = llm_router.delete_deployment(id=model_info.id)
# delete_deployment only drops the deployment from model_list; the auto/
# complexity router registries are keyed by model_name and would otherwise
# retain a stale (now unbacked) entry, so evict it here too. Guard on the
# auto_router/ prefix (as clear_cache does): a regular DB model that merely
# shares a model_name with a config-defined router must not evict that router,
# since add_deployment never restores config-defined routers.
deleted_name, deleted_model = _deployment_name_and_model(deleted_deployment)
if deleted_name is not None and deleted_model.startswith("auto_router/"):
llm_router.auto_routers.pop(deleted_name, None)
llm_router.complexity_routers.pop(deleted_name, None)
llm_router.adaptive_routers.pop(deleted_name, None)
llm_router.quality_routers.pop(deleted_name, None)
llm_router.delete_deployment(id=model_info.id)
# Runs after the row delete so the sibling check sees post-delete state.
if model_params.model_info.team_id is not None:

View file

@ -13,7 +13,13 @@ Endpoints for /organization operations
#### ORGANIZATION MANAGEMENT ####
from typing import Annotated, Any, Dict, List, Mapping, Optional, Tuple
from collections.abc import Mapping, Sequence
from typing import (
TYPE_CHECKING,
Annotated,
Protocol,
overload,
)
import fastapi
from fastapi import APIRouter, Depends, HTTPException, Request, status
@ -57,9 +63,162 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
)
from litellm.utils import _update_dictionary
if TYPE_CHECKING:
from types import TracebackType
from prisma.models import LiteLLM_BudgetTable as PrismaBudgetTable
from prisma.models import (
LiteLLM_ObjectPermissionTable as PrismaObjectPermissionTable,
)
from prisma.models import (
LiteLLM_OrganizationMembership as PrismaOrganizationMembership,
)
from prisma.models import LiteLLM_OrganizationTable as PrismaOrganizationTable
from prisma.models import LiteLLM_UserTable as PrismaUserTable
router = APIRouter()
class _UserTableClient(Protocol):
async def find_unique(self, where: Mapping[str, object]) -> "PrismaUserTable | None": ...
class _BudgetTableClient(Protocol):
async def create(self, data: Mapping[str, object]) -> "PrismaBudgetTable": ...
class _ObjectPermissionTableClient(Protocol):
async def create(self, data: Mapping[str, object]) -> "PrismaObjectPermissionTable": ...
class _OrganizationTableClient(Protocol):
async def create(
self, data: Mapping[str, object], include: Mapping[str, object] | None = None
) -> "PrismaOrganizationTable": ...
async def find_unique(
self, where: Mapping[str, object], include: Mapping[str, object] | None = None
) -> "PrismaOrganizationTable | None": ...
async def find_many(
self,
where: Mapping[str, object] | None = None,
include: Mapping[str, object] | None = None,
) -> "Sequence[PrismaOrganizationTable]": ...
async def update(
self,
where: Mapping[str, object],
data: Mapping[str, object],
include: Mapping[str, object] | None = None,
) -> "PrismaOrganizationTable": ...
async def delete(
self, where: Mapping[str, object], include: Mapping[str, object] | None = None
) -> "PrismaOrganizationTable | None": ...
class _OrganizationMembershipTableClient(Protocol):
async def create(self, data: Mapping[str, object]) -> "PrismaOrganizationMembership": ...
async def find_unique(
self, where: Mapping[str, object], include: Mapping[str, object] | None = None
) -> "PrismaOrganizationMembership | None": ...
async def find_many(
self, where: Mapping[str, object] | None = None
) -> "Sequence[PrismaOrganizationMembership]": ...
async def update(
self, where: Mapping[str, object], data: Mapping[str, object]
) -> "PrismaOrganizationMembership": ...
async def delete(self, where: Mapping[str, object]) -> "PrismaOrganizationMembership | None": ...
async def delete_many(self, where: Mapping[str, object]) -> int: ...
class _TeamTableClient(Protocol):
async def delete_many(self, where: Mapping[str, object]) -> int: ...
class _VerificationTokenTableClient(Protocol):
async def delete_many(self, where: Mapping[str, object]) -> int: ...
class _ObjectPermissionTxClient(Protocol):
async def upsert(
self, where: Mapping[str, object], data: Mapping[str, object]
) -> "PrismaObjectPermissionTable": ...
class _BudgetTxClient(Protocol):
async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> "PrismaBudgetTable | None": ...
class _TransactionTables(Protocol):
@property
def litellm_objectpermissiontable(self) -> "_ObjectPermissionTxClient": ...
@property
def litellm_budgettable(self) -> "_BudgetTxClient": ...
@property
def litellm_organizationtable(self) -> "_OrganizationTableClient": ...
class _TransactionManager(Protocol):
async def __aenter__(self) -> "_TransactionTables": ...
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: "TracebackType | None",
) -> bool | None: ...
@overload
def _table(repository: BudgetRepository) -> "_BudgetTableClient": ...
@overload
def _table(repository: ObjectPermissionRepository) -> "_ObjectPermissionTableClient": ...
@overload
def _table(repository: OrganizationRepository) -> "_OrganizationTableClient": ...
@overload
def _table(repository: OrganizationMembershipRepository) -> "_OrganizationMembershipTableClient": ...
@overload
def _table(repository: TeamRepository) -> "_TeamTableClient": ...
@overload
def _table(repository: UserRepository) -> "_UserTableClient": ...
@overload
def _table(repository: VerificationTokenRepository) -> "_VerificationTokenTableClient": ...
def _table(
repository: BudgetRepository
| ObjectPermissionRepository
| OrganizationRepository
| OrganizationMembershipRepository
| TeamRepository
| UserRepository
| VerificationTokenRepository,
) -> object:
prisma_table: object = repository.table
return prisma_table
async def _verify_org_access(
organization_id: str,
user_api_key_dict: UserAPIKeyAuth,
@ -265,14 +424,15 @@ async def new_organization(
detail={"error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}"},
)
user_object_correct_type: Optional[LiteLLM_UserTable] = None
user_object_correct_type: LiteLLM_UserTable | None = None
if user_api_key_dict.user_id is not None:
try:
user_object = await UserRepository(prisma_client).table.find_unique(
user_object = await _table(UserRepository(prisma_client)).find_unique(
where={"user_id": user_api_key_dict.user_id}
)
user_object_correct_type = LiteLLM_UserTable(**user_object.model_dump())
if user_object is not None:
user_object_correct_type = LiteLLM_UserTable.model_validate(user_object.model_dump())
except Exception:
pass
@ -285,19 +445,21 @@ async def new_organization(
budget_params = LiteLLM_BudgetTable.model_fields.keys()
# Only include Budget Params when creating an entry in litellm_budgettable
_json_data = data.json(exclude_none=True)
_json_data = _STR_OBJECT_DICT_ADAPTER.validate_python(data.json(exclude_none=True))
_budget_data = {k: v for k, v in _json_data.items() if k in budget_params}
budget_row = LiteLLM_BudgetTable(**_budget_data)
budget_row = LiteLLM_BudgetTable.model_validate(_budget_data)
new_budget = prisma_client.jsonify_object(budget_row.json(exclude_none=True))
new_budget = _STR_OBJECT_DICT_ADAPTER.validate_python(
prisma_client.jsonify_object(budget_row.json(exclude_none=True))
)
_budget = await BudgetRepository(prisma_client).table.create(
_budget = await _table(BudgetRepository(prisma_client)).create(
data={
**new_budget, # type: ignore
**new_budget,
"created_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
"updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
}
) # type: ignore
)
data.budget_id = _budget.budget_id
@ -339,11 +501,13 @@ async def new_organization(
value=getattr(data, field),
)
new_organization_row = prisma_client.jsonify_object(organization_row.json(exclude_none=True))
new_organization_row = _STR_OBJECT_DICT_ADAPTER.validate_python(
prisma_client.jsonify_object(organization_row.json(exclude_none=True))
)
verbose_proxy_logger.info(f"new_organization_row: {json.dumps(new_organization_row, indent=2)}")
response = await OrganizationRepository(prisma_client).table.create(
response = await _table(OrganizationRepository(prisma_client)).create(
data={
**new_organization_row, # type: ignore
**new_organization_row,
},
include={"litellm_budget_table": True},
)
@ -357,14 +521,14 @@ async def new_organization(
tags=["organization management"],
)
async def get_organization_daily_activity(
organization_ids: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
model: Optional[str] = None,
api_key: Optional[str] = None,
organization_ids: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
model: str | None = None,
api_key: str | None = None,
page: int = 1,
page_size: int = 10,
exclude_organization_ids: Optional[str] = None,
exclude_organization_ids: str | None = None,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
@ -382,13 +546,13 @@ async def get_organization_daily_activity(
# Parse comma-separated ids
org_ids_list = organization_ids.split(",") if organization_ids else None
exclude_org_ids_list: Optional[List[str]] = None
exclude_org_ids_list: list[str] | None = None
if exclude_organization_ids:
exclude_org_ids_list = exclude_organization_ids.split(",") if exclude_organization_ids else None
# Restrict non-proxy-admins to only organizations where they are org_admin
if not _user_has_admin_view(user_api_key_dict):
memberships = await OrganizationMembershipRepository(prisma_client).table.find_many(
memberships = await _table(OrganizationMembershipRepository(prisma_client)).find_many(
where={"user_id": user_api_key_dict.user_id}
)
admin_org_ids = [m.organization_id for m in memberships if m.user_role == LitellmUserRoles.ORG_ADMIN.value]
@ -405,11 +569,10 @@ async def get_organization_daily_activity(
)
# Fetch organization aliases for metadata
where_condition = {}
where_condition = _STR_OBJECT_DICT_ADAPTER.validate_python({})
if org_ids_list:
where_condition["organization_id"] = {"in": list(org_ids_list)}
org_aliases = await OrganizationRepository(prisma_client).table.find_many(where=where_condition)
org_alias_metadata = {o.organization_id: {"organization_alias": o.organization_alias} for o in org_aliases}
org_aliases = await _table(OrganizationRepository(prisma_client)).find_many(where=where_condition)
# Query daily activity for organizations
return await get_daily_activity(
@ -417,7 +580,7 @@ async def get_organization_daily_activity(
table_name="litellm_dailyorganizationspend",
entity_id_field="organization_id",
entity_id=org_ids_list,
entity_metadata_field=org_alias_metadata,
entity_metadata_field={o.organization_id: {"organization_alias": o.organization_alias} for o in org_aliases},
exclude_entity_ids=exclude_org_ids_list,
start_date=start_date,
end_date=end_date,
@ -430,8 +593,8 @@ async def get_organization_daily_activity(
async def _set_object_permission(
data: NewOrganizationRequest,
prisma_client: Optional[PrismaClient],
) -> Optional[str]:
prisma_client: PrismaClient | None,
) -> str | None:
"""
Creates the LiteLLM_ObjectPermissionTable record for the organization.
- Handles permissions for vector stores and mcp servers.
@ -442,7 +605,7 @@ async def _set_object_permission(
return None
if data.object_permission is not None:
created_object_permission = await ObjectPermissionRepository(prisma_client).table.create(
created_object_permission = await _table(ObjectPermissionRepository(prisma_client)).create(
data=data.object_permission.model_dump(exclude_none=True),
)
del data.object_permission
@ -534,10 +697,14 @@ async def update_organization(
if updated_organization_row_json.get("metadata") is not None:
existing_metadata = existing_organization_row.metadata or {}
updated_metadata = updated_organization_row_json.get("metadata", {})
merged_metadata = _update_dictionary(existing_dict=existing_metadata.copy(), new_dict=updated_metadata)
merged_metadata: Mapping[str, object] = _update_dictionary(
existing_dict=existing_metadata.copy(), new_dict=updated_metadata
)
updated_organization_row_json["metadata"] = merged_metadata
updated_organization_row = prisma_client.jsonify_object(updated_organization_row_json)
updated_organization_row = _STR_OBJECT_DICT_ADAPTER.validate_python(
prisma_client.jsonify_object(updated_organization_row_json)
)
if data.object_permission is not None:
updated_organization_row = await handle_update_object_permission(
data_json=updated_organization_row,
@ -559,7 +726,7 @@ async def update_organization(
for field in LiteLLM_BudgetTable.model_fields.keys():
updated_organization_row.pop(field, None)
response = await OrganizationRepository(prisma_client).table.update(
response = await _table(OrganizationRepository(prisma_client)).update(
where={"organization_id": data.organization_id},
data=updated_organization_row,
include={"members": True, "teams": True, "litellm_budget_table": True},
@ -569,9 +736,9 @@ async def update_organization(
async def handle_update_object_permission(
data_json: dict,
data_json: dict[str, object],
existing_organization_row: LiteLLM_OrganizationTable,
) -> dict:
) -> dict[str, object]:
"""
Handle the update of object permission for an organization.
@ -677,7 +844,7 @@ async def update_organization_v2(
prisma_client=prisma_client,
)
existing_organization_row = await OrganizationRepository(prisma_client).table.find_unique(
existing_organization_row = await _table(OrganizationRepository(prisma_client)).find_unique(
where={"organization_id": organization_id},
)
if existing_organization_row is None:
@ -711,15 +878,18 @@ async def update_organization_v2(
else ({"object_permission_id": None} if object_permission_cleared else {})
)
organization_write_data = prisma_client.jsonify_object(
{
**org_column_updates,
**object_permission_write,
"updated_by": user_api_key_dict.user_id,
}
organization_write_data = _STR_OBJECT_DICT_ADAPTER.validate_python(
prisma_client.jsonify_object(
{
**org_column_updates,
**object_permission_write,
"updated_by": user_api_key_dict.user_id,
}
)
)
async with prisma_client.db.tx() as tx:
tx_manager: _TransactionManager = prisma_client.db.tx()
async with tx_manager as tx:
if object_permission_upsert is not None:
await tx.litellm_objectpermissiontable.upsert(
where={"object_permission_id": object_permission_upsert.object_permission_id},
@ -729,11 +899,12 @@ async def update_organization_v2(
},
)
if budget_updates:
budget_write_data = _STR_OBJECT_DICT_ADAPTER.validate_python(
prisma_client.jsonify_object(dict(build_budget_write_data(budget_updates, user_api_key_dict.user_id)))
)
await tx.litellm_budgettable.update(
where={"budget_id": existing_organization_row.budget_id},
data=prisma_client.jsonify_object(
dict(build_budget_write_data(budget_updates, user_api_key_dict.user_id))
),
data=budget_write_data,
)
response = await tx.litellm_organizationtable.update(
where={"organization_id": organization_id},
@ -748,7 +919,7 @@ async def update_organization_v2(
"/organization/delete",
tags=["organization management"],
dependencies=[Depends(user_api_key_auth)],
response_model=List[LiteLLM_OrganizationTableWithMembers],
response_model=list[LiteLLM_OrganizationTableWithMembers],
)
async def delete_organization(
data: DeleteOrganizationRequest,
@ -778,15 +949,15 @@ async def delete_organization(
deleted_orgs = []
for organization_id in data.organization_ids:
# delete all teams in the organization
await TeamRepository(prisma_client).table.delete_many(where={"organization_id": organization_id})
await _table(TeamRepository(prisma_client)).delete_many(where={"organization_id": organization_id})
# delete all members in the organization
await OrganizationMembershipRepository(prisma_client).table.delete_many(
await _table(OrganizationMembershipRepository(prisma_client)).delete_many(
where={"organization_id": organization_id}
)
# delete all keys in the organization
await VerificationTokenRepository(prisma_client).table.delete_many(where={"organization_id": organization_id})
await _table(VerificationTokenRepository(prisma_client)).delete_many(where={"organization_id": organization_id})
# delete the organization
deleted_org = await OrganizationRepository(prisma_client).table.delete(
deleted_org = await _table(OrganizationRepository(prisma_client)).delete(
where={"organization_id": organization_id},
include={"members": True, "teams": True, "litellm_budget_table": True},
)
@ -804,13 +975,11 @@ async def delete_organization(
"/organization/list",
tags=["organization management"],
dependencies=[Depends(user_api_key_auth)],
response_model=List[LiteLLM_OrganizationTableWithMembers],
response_model=list[LiteLLM_OrganizationTableWithMembers],
)
async def list_organization(
org_id: Optional[str] = fastapi.Query(
default=None, description="Filter organizations by exact organization_id match"
),
org_alias: Optional[str] = fastapi.Query(
org_id: str | None = fastapi.Query(default=None, description="Filter organizations by exact organization_id match"),
org_alias: str | None = fastapi.Query(
default=None,
description="Filter organizations by partial organization_alias match. Supports case-insensitive search.",
),
@ -849,7 +1018,7 @@ async def list_organization(
)
# Build where conditions based on provided filters
where_conditions: Dict[str, Any] = {}
where_conditions: dict[str, object] = {}
if org_id:
where_conditions["organization_id"] = org_id
@ -862,13 +1031,13 @@ async def list_organization(
# if proxy admin or admin viewer - get all orgs (with optional filters)
if _user_has_admin_view(user_api_key_dict):
response = await OrganizationRepository(prisma_client).table.find_many(
response = await _table(OrganizationRepository(prisma_client)).find_many(
where=where_conditions if where_conditions else None,
include={"litellm_budget_table": True, "members": True, "teams": True},
)
# if internal user - get orgs they are a member of (with optional filters)
else:
org_memberships = await OrganizationMembershipRepository(prisma_client).table.find_many(
org_memberships = await _table(OrganizationMembershipRepository(prisma_client)).find_many(
where={"user_id": user_api_key_dict.user_id}
)
membership_org_ids = [membership.organization_id for membership in org_memberships]
@ -882,7 +1051,7 @@ async def list_organization(
response = []
else:
where_conditions["organization_id"] = org_id
response = await OrganizationRepository(prisma_client).table.find_many(
response = await _table(OrganizationRepository(prisma_client)).find_many(
where=where_conditions,
include={
"litellm_budget_table": True,
@ -893,7 +1062,7 @@ async def list_organization(
else:
# Filter by membership and any additional filters
where_conditions["organization_id"] = {"in": membership_org_ids}
response = await OrganizationRepository(prisma_client).table.find_many(
response = await _table(OrganizationRepository(prisma_client)).find_many(
where=where_conditions,
include={
"litellm_budget_table": True,
@ -933,9 +1102,7 @@ async def info_organization(
prisma_client=prisma_client,
)
response: Optional[LiteLLM_OrganizationTableWithMembers] = await OrganizationRepository(
prisma_client
).table.find_unique(
response = await _table(OrganizationRepository(prisma_client)).find_unique(
where={"organization_id": organization_id},
include={
"litellm_budget_table": True,
@ -952,7 +1119,7 @@ async def info_organization(
if response is None:
raise HTTPException(status_code=404, detail={"error": "Organization not found"})
response_pydantic_obj = LiteLLM_OrganizationTableWithMembers(**response.model_dump())
response_pydantic_obj = LiteLLM_OrganizationTableWithMembers.model_validate(response.model_dump())
return response_pydantic_obj
@ -988,7 +1155,7 @@ async def deprecated_info_organization(
prisma_client=prisma_client,
)
response = await OrganizationRepository(prisma_client).table.find_many(
response = await _table(OrganizationRepository(prisma_client)).find_many(
where={"organization_id": {"in": data.organizations}},
include={"litellm_budget_table": True},
)
@ -1065,7 +1232,7 @@ async def organization_member_add(
)
# Check if organization exists
existing_organization_row = await OrganizationRepository(prisma_client).table.find_unique(
existing_organization_row = await _table(OrganizationRepository(prisma_client)).find_unique(
where={"organization_id": data.organization_id}
)
if existing_organization_row is None:
@ -1076,14 +1243,14 @@ async def organization_member_add(
},
)
members: List[OrgMember]
if isinstance(data.member, List):
members: Sequence[OrgMember]
if isinstance(data.member, list):
members = data.member
else:
members = [data.member]
updated_users: List[LiteLLM_UserTable] = []
updated_organization_memberships: List[LiteLLM_OrganizationMembershipTable] = []
updated_users: list[LiteLLM_UserTable] = []
updated_organization_memberships: list[LiteLLM_OrganizationMembershipTable] = []
for member in members:
(
@ -1138,7 +1305,7 @@ async def find_member_if_email(user_email: str, prisma_client: PrismaClient) ->
"error": f"Unique user not found for user_email={user_email}. Potential duplicate OR non-existent user_email in LiteLLM_UserTable. Use 'user_id' instead."
},
)
existing_user_email_row_pydantic = LiteLLM_UserTable(**existing_user_email_row.model_dump())
existing_user_email_row_pydantic = LiteLLM_UserTable.model_validate(existing_user_email_row.model_dump())
return existing_user_email_row_pydantic
@ -1176,7 +1343,7 @@ async def organization_member_update(
)
# Check if organization exists
existing_organization_row = await OrganizationRepository(prisma_client).table.find_unique(
existing_organization_row = await _table(OrganizationRepository(prisma_client)).find_unique(
where={"organization_id": data.organization_id}
)
if existing_organization_row is None:
@ -1193,7 +1360,9 @@ async def organization_member_update(
data.user_id = existing_user_email_row.user_id
try:
existing_organization_membership = await OrganizationMembershipRepository(prisma_client).table.find_unique(
existing_organization_membership = await _table(
OrganizationMembershipRepository(prisma_client)
).find_unique(
where={
"user_id_organization_id": {
"user_id": data.user_id,
@ -1218,7 +1387,7 @@ async def organization_member_update(
# org-scoped operations. An org-admin of any org could otherwise
# alter a PROXY_ADMIN user's per-org role, which has downstream
# effects on admin UI filtering and scope derivation.
target_user_row = await UserRepository(prisma_client).table.find_unique(where={"user_id": data.user_id})
target_user_row = await _table(UserRepository(prisma_client)).find_unique(where={"user_id": data.user_id})
if target_user_row is not None and getattr(target_user_row, "user_role", None) in (
LitellmUserRoles.PROXY_ADMIN.value,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
@ -1235,7 +1404,7 @@ async def organization_member_update(
# Update member role
if data.role is not None:
await OrganizationMembershipRepository(prisma_client).table.update(
await _table(OrganizationMembershipRepository(prisma_client)).update(
where={
"user_id_organization_id": {
"user_id": data.user_id,
@ -1258,7 +1427,7 @@ async def organization_member_update(
)
# update organization membership with new budget_id
await OrganizationMembershipRepository(prisma_client).table.update(
await _table(OrganizationMembershipRepository(prisma_client)).update(
where={
"user_id_organization_id": {
"user_id": data.user_id,
@ -1267,9 +1436,7 @@ async def organization_member_update(
},
data={"budget_id": budget_id},
)
final_organization_membership: Optional[BaseModel] = await OrganizationMembershipRepository(
prisma_client
).table.find_unique(
final_organization_membership = await _table(OrganizationMembershipRepository(prisma_client)).find_unique(
where={
"user_id_organization_id": {
"user_id": data.user_id,
@ -1285,8 +1452,8 @@ async def organization_member_update(
detail={"error": f"Member not found in organization={data.organization_id} for user_id={data.user_id}"},
)
final_organization_membership_pydantic = LiteLLM_OrganizationMembershipTable(
**final_organization_membership.model_dump(exclude_none=True)
final_organization_membership_pydantic = LiteLLM_OrganizationMembershipTable.model_validate(
final_organization_membership.model_dump(exclude_none=True)
)
return final_organization_membership_pydantic
except Exception as e:
@ -1328,7 +1495,7 @@ async def organization_member_delete(
existing_user_email_row = await find_member_if_email(data.user_email, prisma_client)
data.user_id = existing_user_email_row.user_id
member_to_delete = await OrganizationMembershipRepository(prisma_client).table.delete(
member_to_delete = await _table(OrganizationMembershipRepository(prisma_client)).delete(
where={
"user_id_organization_id": {
"user_id": data.user_id,
@ -1347,7 +1514,7 @@ async def add_member_to_organization(
member: OrgMember,
organization_id: str,
prisma_client: PrismaClient,
) -> Tuple[LiteLLM_UserTable, LiteLLM_OrganizationMembershipTable]:
) -> tuple[LiteLLM_UserTable, LiteLLM_OrganizationMembershipTable]:
"""
Add a member to an organization
@ -1357,12 +1524,12 @@ async def add_member_to_organization(
"""
try:
user_object: Optional[LiteLLM_UserTable] = None
user_object: LiteLLM_UserTable | None = None
existing_user_id_row = None
existing_user_email_row = None
## Check if user exists in LiteLLM_UserTable - user exists - either the user_id or user_email is in LiteLLM_UserTable
if member.user_id is not None:
existing_user_id_row = await UserRepository(prisma_client).table.find_unique(
existing_user_id_row = await _table(UserRepository(prisma_client)).find_unique(
where={"user_id": member.user_id}
)
@ -1387,16 +1554,16 @@ async def add_member_to_organization(
_returned_user = await prisma_client.insert_data(data=new_user_defaults, table_name="user") # type: ignore
if _returned_user is not None:
user_object = LiteLLM_UserTable(**_returned_user.model_dump())
user_object = LiteLLM_UserTable.model_validate(_returned_user.model_dump())
elif existing_user_email_row is not None and len(existing_user_email_row) > 1:
raise HTTPException(
status_code=400,
detail={"error": "Multiple users with this email found in db. Please use 'user_id' instead."},
)
elif existing_user_email_row is not None:
user_object = LiteLLM_UserTable(**existing_user_email_row.model_dump())
user_object = LiteLLM_UserTable.model_validate(existing_user_email_row.model_dump())
elif existing_user_id_row is not None:
user_object = LiteLLM_UserTable(**existing_user_id_row.model_dump())
user_object = LiteLLM_UserTable.model_validate(existing_user_id_row.model_dump())
else:
raise HTTPException(
status_code=404,
@ -1409,14 +1576,16 @@ async def add_member_to_organization(
)
# Add user to organization
_organization_membership = await OrganizationMembershipRepository(prisma_client).table.create(
_organization_membership = await _table(OrganizationMembershipRepository(prisma_client)).create(
data={
"organization_id": organization_id,
"user_id": user_object.user_id,
"user_role": member.role,
}
)
organization_membership = LiteLLM_OrganizationMembershipTable(**_organization_membership.model_dump())
organization_membership = LiteLLM_OrganizationMembershipTable.model_validate(
_organization_membership.model_dump()
)
return user_object, organization_membership
except Exception as e:

View file

@ -12,8 +12,14 @@ All /tag management endpoints
import asyncio
import json
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
from typing import (
TYPE_CHECKING,
Protocol,
TypedDict,
overload,
)
from fastapi import APIRouter, Depends, HTTPException, Query
@ -42,16 +48,101 @@ from litellm.types.tag_management import (
)
if TYPE_CHECKING:
from prisma.models import LiteLLM_BudgetTable as PrismaBudgetTable
from prisma.models import LiteLLM_ProxyModelTable as PrismaProxyModelTable
from prisma.models import LiteLLM_TagTable as PrismaTagTable
from prisma.models import LiteLLM_VerificationToken as PrismaVerificationToken
from litellm import Router
from litellm.proxy.utils import PrismaClient
from litellm.types.router import Deployment
router = APIRouter()
class _TagRecord(Protocol):
tag_name: str
description: str | None
models: Sequence[str]
model_info: object
budget_id: str | None
created_at: datetime
updated_at: datetime
created_by: str | None
litellm_budget_table: "PrismaBudgetTable | None"
class _TagTableClient(Protocol):
async def find_unique(self, where: Mapping[str, object]) -> "_TagRecord | None": ...
async def find_many(
self,
where: Mapping[str, object] | None = None,
include: Mapping[str, object] | None = None,
) -> "Sequence[_TagRecord]": ...
async def create(self, data: Mapping[str, object]) -> "PrismaTagTable": ...
async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> "PrismaTagTable": ...
async def delete(self, where: Mapping[str, object]) -> "PrismaTagTable | None": ...
class _ModelTableClient(Protocol):
async def find_many(self, where: Mapping[str, object] | None = None) -> "Sequence[PrismaProxyModelTable]": ...
class _VerificationTokenTableClient(Protocol):
async def find_many(
self,
where: Mapping[str, object] | None = None,
select: Mapping[str, object] | None = None,
) -> "Sequence[PrismaVerificationToken]": ...
class _DailyTagSpendGroupByRow(TypedDict):
tag: str | None
_min: Mapping[str, object]
_max: Mapping[str, object]
class _DailyTagSpendTableClient(Protocol):
async def group_by(
self,
by: Sequence[str],
where: Mapping[str, object] | None = None,
min: Mapping[str, object] | None = None,
max: Mapping[str, object] | None = None,
) -> "Sequence[_DailyTagSpendGroupByRow]": ...
@overload
def _table(repository: DailyTagSpendRepository) -> "_DailyTagSpendTableClient": ...
@overload
def _table(repository: ModelRepository) -> "_ModelTableClient": ...
@overload
def _table(repository: TagRepository) -> "_TagTableClient": ...
@overload
def _table(repository: VerificationTokenRepository) -> "_VerificationTokenTableClient": ...
def _table(
repository: DailyTagSpendRepository | ModelRepository | TagRepository | VerificationTokenRepository,
) -> object:
prisma_table: object = repository.table
return prisma_table
async def _get_internal_user_api_keys(
prisma_client,
prisma_client: "PrismaClient",
user_api_key_dict: UserAPIKeyAuth,
) -> List[str]:
) -> list[str]:
user_role = user_api_key_dict.user_role
if user_role is None or not user_role.is_internal_user_role:
return []
@ -64,7 +155,7 @@ async def _get_internal_user_api_keys(
if user_id is None:
return sorted(user_api_keys)
key_records = await VerificationTokenRepository(prisma_client).table.find_many(
key_records = await _table(VerificationTokenRepository(prisma_client)).find_many(
where={"user_id": user_id},
select={"token": True},
)
@ -74,9 +165,9 @@ async def _get_internal_user_api_keys(
async def _get_tag_list_scope(
prisma_client,
prisma_client: "PrismaClient",
user_api_key_dict: UserAPIKeyAuth,
) -> Optional[Dict[str, dict]]:
) -> Mapping[str, Mapping[str, Sequence[str]]] | None:
user_role = user_api_key_dict.user_role
if user_api_key_has_admin_view(user_api_key_dict) or (user_role is None or not user_role.is_internal_user_role):
return None
@ -89,10 +180,10 @@ async def _get_tag_list_scope(
async def _get_tag_daily_activity_api_key_filter(
prisma_client,
prisma_client: "PrismaClient",
user_api_key_dict: UserAPIKeyAuth,
requested_api_key: Optional[str],
) -> Optional[Union[str, List[str]]]:
requested_api_key: str | None,
) -> str | list[str] | None:
user_role = user_api_key_dict.user_role
if user_api_key_has_admin_view(user_api_key_dict) or (user_role is None or not user_role.is_internal_user_role):
return requested_api_key
@ -106,17 +197,17 @@ async def _get_tag_daily_activity_api_key_filter(
return scoped_api_keys
async def _get_model_names(prisma_client, model_ids: list) -> Dict[str, str]:
async def _get_model_names(prisma_client: "PrismaClient", model_ids: Sequence[str]) -> dict[str, str]:
"""Helper function to get model names from model IDs"""
try:
models = await ModelRepository(prisma_client).table.find_many(where={"model_id": {"in": model_ids}})
models = await _table(ModelRepository(prisma_client)).find_many(where={"model_id": {"in": model_ids}})
return {model.model_id: model.model_name for model in models}
except Exception as e:
verbose_proxy_logger.error(f"Error getting model names: {str(e)}")
return {}
async def get_deployments_by_model(model: str, llm_router: "Router") -> List["Deployment"]:
async def get_deployments_by_model(model: str, llm_router: "Router") -> list["Deployment"]:
"""
Get all deployments by model
"""
@ -181,7 +272,7 @@ async def new_tag(
raise HTTPException(status_code=500, detail=CommonProxyErrors.no_llm_router.value)
try:
# Check if tag already exists
existing_tag = await TagRepository(prisma_client).table.find_unique(where={"tag_name": tag.name})
existing_tag = await _table(TagRepository(prisma_client)).find_unique(where={"tag_name": tag.name})
if existing_tag is not None:
raise HTTPException(status_code=400, detail=f"Tag {tag.name} already exists")
@ -198,7 +289,7 @@ async def new_tag(
model_info = await _get_model_names(prisma_client, tag.models or [])
# Create new tag in database
new_tag_record = await TagRepository(prisma_client).table.create(
new_tag_record = await _table(TagRepository(prisma_client)).create(
data={
"tag_name": tag.name,
"description": tag.description,
@ -321,7 +412,7 @@ async def update_tag(
try:
# Check if tag exists
existing_tag = await TagRepository(prisma_client).table.find_unique(where={"tag_name": tag.name})
existing_tag = await _table(TagRepository(prisma_client)).find_unique(where={"tag_name": tag.name})
if existing_tag is None:
raise HTTPException(status_code=404, detail=f"Tag {tag.name} not found")
@ -351,7 +442,7 @@ async def update_tag(
update_data["budget_id"] = budget_id
# Update tag in database
updated_tag_record = await TagRepository(prisma_client).table.update(
updated_tag_record = await _table(TagRepository(prisma_client)).update(
where={"tag_name": tag.name},
data=update_data,
)
@ -398,7 +489,7 @@ async def info_tag(
try:
# Query tags from database with budget info
tag_records = await TagRepository(prisma_client).table.find_many(
tag_records = await _table(TagRepository(prisma_client)).find_many(
where={"tag_name": {"in": data.names}},
include={"litellm_budget_table": True},
)
@ -413,7 +504,7 @@ async def info_tag(
requested_tags = {}
for tag_record in tag_records:
# Parse model_info from JSON
model_info = {}
model_info: object = {}
if tag_record.model_info:
if isinstance(tag_record.model_info, str):
model_info = json.loads(tag_record.model_info)
@ -441,7 +532,7 @@ async def info_tag(
raise HTTPException(status_code=500, detail=str(e))
def _validate_tag_list_date_range(start_date: Optional[str], end_date: Optional[str]) -> None:
def _validate_tag_list_date_range(start_date: str | None, end_date: str | None) -> None:
"""Require both dates together, and enforce YYYY-MM-DD format with start <= end."""
if (start_date is None) != (end_date is None):
raise HTTPException(
@ -472,7 +563,7 @@ def _validate_tag_list_date_range(start_date: Optional[str], end_date: Optional[
)
async def list_tags(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
start_date: Optional[str] = Query(
start_date: str | None = Query(
None,
description=(
"Optional start date (YYYY-MM-DD). When provided together with "
@ -480,7 +571,7 @@ async def list_tags(
"Stored tags are always returned."
),
),
end_date: Optional[str] = Query(
end_date: str | None = Query(
None,
description="Optional end date (YYYY-MM-DD). Must be given with start_date.",
),
@ -506,13 +597,13 @@ async def list_tags(
# Prisma's distinct fetches all columns for all rows and deduplicates
# in application code, which is extremely slow on large tables.
# See: https://www.prisma.io/docs/orm/prisma-client/queries/aggregation-grouping-summarizing#distinct-under-the-hood
dynamic_tag_where: Dict[str, Any] = {"tag": {"not": None}}
dynamic_tag_where: dict[str, object] = {"tag": {"not": None}}
if tag_scope:
dynamic_tag_where = {**dynamic_tag_where, **tag_scope}
if start_date is not None and end_date is not None:
dynamic_tag_where["date"] = {"gte": start_date, "lte": end_date}
dynamic_tag_rows = await DailyTagSpendRepository(prisma_client).table.group_by(
dynamic_tag_rows = await _table(DailyTagSpendRepository(prisma_client)).group_by(
by=["tag"],
where=dynamic_tag_where,
min={"created_at": True},
@ -526,7 +617,7 @@ async def list_tags(
stored_tag_where = {"tag_name": {"in": used_tag_names}} if tag_scope is not None else None
## QUERY STORED TAGS ##
tag_records = await TagRepository(prisma_client).table.find_many(
tag_records = await _table(TagRepository(prisma_client)).find_many(
where=stored_tag_where,
include={"litellm_budget_table": True},
)
@ -536,7 +627,7 @@ async def list_tags(
for tag_record in tag_records:
stored_tag_names.add(tag_record.tag_name)
# Parse model_info from JSON
model_info = {}
model_info: object = {}
if tag_record.model_info:
if isinstance(tag_record.model_info, str):
model_info = json.loads(tag_record.model_info)
@ -598,12 +689,12 @@ async def delete_tag(
try:
# Check if tag exists
existing_tag = await TagRepository(prisma_client).table.find_unique(where={"tag_name": data.name})
existing_tag = await _table(TagRepository(prisma_client)).find_unique(where={"tag_name": data.name})
if existing_tag is None:
raise HTTPException(status_code=404, detail=f"Tag {data.name} not found")
# Delete tag from database
await TagRepository(prisma_client).table.delete(where={"tag_name": data.name})
await _table(TagRepository(prisma_client)).delete(where={"tag_name": data.name})
return {"message": f"Tag {data.name} deleted successfully"}
except Exception as e:
@ -617,11 +708,11 @@ async def delete_tag(
dependencies=[Depends(user_api_key_auth)],
)
async def get_tag_daily_activity(
tags: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
model: Optional[str] = None,
api_key: Optional[str] = None,
tags: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
model: str | None = None,
api_key: str | None = None,
page: int = 1,
page_size: int = 10,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),

View file

@ -11,21 +11,21 @@ POST /v1/tool/policy - Update the input_policy / output_policy for a
import uuid
from datetime import datetime, timedelta, timezone
from itertools import groupby
from typing import TYPE_CHECKING, Annotated, Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, TypeAdapter
from pydantic import BaseModel, Field, TypeAdapter
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
from litellm._logging import verbose_proxy_logger
from litellm.constants import TOOL_SPEND_MAX_WINDOW_DAYS
from litellm.constants import TOOL_SPEND_TOP_TOOLS
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.repositories.object_permission_repository import ObjectPermissionRepository
from litellm.repositories.table_repositories import (
DailyToolSpendRepository,
SpendLogsRepository,
SpendLogToolIndexRepository,
)
@ -142,53 +142,18 @@ def _parse_day_start(value: str | None) -> datetime | None:
)
class _ToolSpendRow(BaseModel):
date: str
class _ToolSpendSums(BaseModel):
spend: float = 0.0
total_tokens: int = 0
request_count: int = 0
class _TopToolRow(BaseModel):
tool_name: str
call_count: int
spend: float
total_tokens: int
sums: _ToolSpendSums = Field(alias="_sum")
class _RequestTotalRow(BaseModel):
total_spend: float
_TOOL_SPEND_ROWS = TypeAdapter(list[_ToolSpendRow])
_REQUEST_TOTAL_ROWS = TypeAdapter(list[_RequestTotalRow])
def _summarize_tool(name: str, grp: tuple[_ToolSpendRow, ...]) -> ToolSpendEntry:
return ToolSpendEntry(
tool_name=name,
spend=sum(r.spend for r in grp),
call_count=sum(r.call_count for r in grp),
total_tokens=sum(r.total_tokens for r in grp),
)
def _build_tool_spend_response(
rows: list[_ToolSpendRow],
total_spend: float,
start_date: str,
end_date: str,
) -> ToolSpendResponse:
daily = [
ToolSpendDailyEntry(date=r.date, tool_name=r.tool_name, spend=r.spend, call_count=r.call_count) for r in rows
]
grouped = groupby(sorted(rows, key=lambda r: r.tool_name), key=lambda r: r.tool_name)
by_tool = sorted(
(_summarize_tool(name, tuple(grp)) for name, grp in grouped),
key=lambda e: e.spend,
reverse=True,
)
return ToolSpendResponse(
by_tool=by_tool,
daily=daily,
total_spend=total_spend,
start_date=start_date,
end_date=end_date,
)
_TOP_TOOL_ROWS = TypeAdapter(list[_TopToolRow])
@router.get(
@ -205,16 +170,16 @@ async def get_tool_spend(
"""
Spend attributed to each tool over a date range, for the Cost Optimization dashboard.
Joins ``LiteLLM_SpendLogToolIndex`` (which tool names ran on which request) to
``LiteLLM_SpendLogs`` (what the request cost). A request that used multiple tools
counts its full spend toward each of those tools, so per-tool numbers are
attributions. ``total_spend`` is the deduplicated spend of every request that
called at least one tool in the window, so it never double counts.
Reads the ``LiteLLM_DailyToolSpend`` rollup, written at request time from invoked
tools only (MCP tool calls and response tool_calls; declaring a tool without
invoking it does not count). A request that invoked multiple tools counts its
full spend toward each of them, so per-tool numbers are attributions and do not
sum to a deduplicated total.
``start_date`` is clamped to at most 30 days before ``end_date`` (serving up to
31 calendar dates inclusive, the same width as the endpoint's default window):
a wider requested range is clamped, and the response's ``start_date`` reflects
the effective window actually served.
``by_tool`` is the top ``TOOL_SPEND_TOP_TOOLS`` tools by spend, aggregated in
SQL, and ``daily`` covers only those tools, so the response is bounded by
days x TOOL_SPEND_TOP_TOOLS regardless of the requested range or how many
distinct tool names exist.
"""
from litellm.proxy.proxy_server import prisma_client
@ -230,64 +195,46 @@ async def get_tool_spend(
if prisma_client is None:
raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
now = datetime.now(timezone.utc)
end_day = _parse_day_start(end_date)
# Anchor the floor to a midnight so the clamp compares dates with dates:
# parsed start_dates are midnight-aligned, and a floor carrying now's
# time-of-day would invisibly truncate an explicit start_date to mid-day.
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
window_floor = (end_day or today) - timedelta(days=TOOL_SPEND_MAX_WINDOW_DAYS)
start_dt = _parse_day_start(start_date) or window_floor
if start_dt < window_floor:
start_dt = window_floor
end_exclusive = (end_day + timedelta(days=1)) if end_day else now
end_day = _parse_day_start(end_date) or datetime.now(timezone.utc)
start_day = _parse_day_start(start_date) or end_day - timedelta(days=30)
start_str = start_day.strftime("%Y-%m-%d")
end_str = end_day.strftime("%Y-%m-%d")
date_window = {"date": {"gte": start_str, "lte": end_str}}
# ti.start_time defines the window in both queries; the sl."startTime" bounds
# exist only so the planner can use the SpendLogs startTime index, and carry a
# 1s margin because the two writers can disagree by ~1ms on the same request.
rows = await prisma_client.db.query_raw(
"""
SELECT to_char(ti.start_time, 'YYYY-MM-DD') AS date,
ti.tool_name AS tool_name,
COUNT(*)::int AS call_count,
COALESCE(SUM(sl.spend), 0)::double precision AS spend,
COALESCE(SUM(sl.total_tokens), 0)::bigint AS total_tokens
FROM "LiteLLM_SpendLogToolIndex" ti
JOIN "LiteLLM_SpendLogs" sl ON sl.request_id = ti.request_id
WHERE ti.start_time >= ($1::timestamptz AT TIME ZONE 'UTC')
AND ti.start_time < ($2::timestamptz AT TIME ZONE 'UTC')
AND sl."startTime" >= ($1::timestamptz AT TIME ZONE 'UTC') - interval '1 second'
AND sl."startTime" < ($2::timestamptz AT TIME ZONE 'UTC') + interval '1 second'
GROUP BY date, ti.tool_name
ORDER BY date ASC, spend DESC
""",
start_dt.isoformat(),
end_exclusive.isoformat(),
)
totals = await prisma_client.db.query_raw(
"""
SELECT COALESCE(SUM(sl.spend), 0)::double precision AS total_spend
FROM "LiteLLM_SpendLogs" sl
WHERE sl."startTime" >= ($1::timestamptz AT TIME ZONE 'UTC') - interval '1 second'
AND sl."startTime" < ($2::timestamptz AT TIME ZONE 'UTC') + interval '1 second'
AND EXISTS (
SELECT 1
FROM "LiteLLM_SpendLogToolIndex" ti
WHERE ti.request_id = sl.request_id
AND ti.start_time >= ($1::timestamptz AT TIME ZONE 'UTC')
AND ti.start_time < ($2::timestamptz AT TIME ZONE 'UTC')
table = DailyToolSpendRepository(prisma_client).table
top_tools = _TOP_TOOL_ROWS.validate_python(
await table.group_by(
by=["tool_name"],
sum={"spend": True, "total_tokens": True, "request_count": True},
where=date_window,
order={"_sum": {"spend": "desc"}},
take=TOOL_SPEND_TOP_TOOLS,
)
""",
start_dt.isoformat(),
end_exclusive.isoformat(),
or []
)
total_rows = _REQUEST_TOTAL_ROWS.validate_python(totals or [])
return _build_tool_spend_response(
rows=_TOOL_SPEND_ROWS.validate_python(rows or []),
total_spend=total_rows[0].total_spend if total_rows else 0.0,
start_date=start_dt.strftime("%Y-%m-%d"),
end_date=(end_day or now).strftime("%Y-%m-%d"),
by_tool = [
ToolSpendEntry(
tool_name=row.tool_name,
spend=row.sums.spend,
call_count=row.sums.request_count,
total_tokens=row.sums.total_tokens,
)
for row in top_tools
]
daily_rows = (
await table.find_many(
where={**date_window, "tool_name": {"in": [row.tool_name for row in top_tools]}},
order=[{"date": "asc"}, {"spend": "desc"}],
)
if top_tools
else []
)
daily = [
ToolSpendDailyEntry(date=row.date, tool_name=row.tool_name, spend=row.spend, call_count=row.request_count)
for row in daily_rows
]
return ToolSpendResponse(by_tool=by_tool, daily=daily, start_date=start_str, end_date=end_str)
@router.get(
@ -388,7 +335,8 @@ async def get_tool_usage_logs(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Return paginated spend logs for requests that used this tool (from SpendLogToolIndex).
Return paginated spend logs for requests that invoked this tool (from SpendLogToolIndex).
Declaring a tool in a request body without the model invoking it does not create an entry.
"""
from litellm.proxy.proxy_server import prisma_client

View file

@ -8,8 +8,16 @@ by policy_attachments (see AttachmentRegistry).
"""
import json
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from typing import (
TYPE_CHECKING,
Any,
Optional,
Protocol,
TypedDict,
Union,
)
from litellm._logging import verbose_proxy_logger
from litellm.repositories.table_repositories import PolicyRepository
@ -33,7 +41,89 @@ if TYPE_CHECKING:
POLICY_VERSION_ID_PREFIX = "policy_"
def _row_to_policy_db_response(row: Any) -> PolicyDBResponse:
class _RawPipelineStep(TypedDict):
guardrail: str
class _RawPipelineConfig(TypedDict, total=False):
mode: str
steps: Sequence[Union[PipelineStep, "_RawPipelineStep"]]
class _PolicyRow(Protocol):
policy_id: str
policy_name: str
version_number: int
version_status: str
parent_version_id: str | None
is_latest: bool
published_at: datetime | None
production_at: datetime | None
inherit: str | None
description: str | None
guardrails_add: list[str] | None
guardrails_remove: list[str] | None
condition: dict[str, object] | None
pipeline: dict[str, object] | None
created_at: datetime
updated_at: datetime
created_by: str | None
updated_by: str | None
class _PolicyVersionSourceRow(Protocol):
policy_id: str
policy_name: str
version_number: int
inherit: str | None
description: str | None
guardrails_add: Sequence[str] | None
guardrails_remove: Sequence[str] | None
condition: Mapping[str, object] | str | None
pipeline: Mapping[str, object] | str | None
class _PolicyTableClient(Protocol):
async def create(self, data: Mapping[str, object]) -> _PolicyRow: ...
async def find_unique(self, where: Mapping[str, object]) -> _PolicyRow | None: ...
async def find_many(
self,
where: Mapping[str, object] | None = None,
order: Mapping[str, str] | None = None,
) -> Sequence[_PolicyRow]: ...
async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> _PolicyRow: ...
async def update_many(self, where: Mapping[str, object], data: Mapping[str, object]) -> int: ...
async def delete(self, where: Mapping[str, object]) -> _PolicyRow | None: ...
async def delete_many(self, where: Mapping[str, object]) -> int: ...
class _PolicyVersionSourceTableClient(Protocol):
async def find_unique(self, where: Mapping[str, object]) -> _PolicyVersionSourceRow | None: ...
async def find_first(
self,
where: Mapping[str, object],
order: Mapping[str, str] | None = None,
) -> _PolicyVersionSourceRow | None: ...
def _policy_table(prisma_client: "PrismaClient") -> _PolicyTableClient:
table: _PolicyTableClient = PolicyRepository(prisma_client).table
return table
def _policy_version_source_table(prisma_client: "PrismaClient") -> _PolicyVersionSourceTableClient:
table: _PolicyVersionSourceTableClient = PolicyRepository(prisma_client).table
return table
def _row_to_policy_db_response(row: _PolicyRow) -> PolicyDBResponse:
"""Build PolicyDBResponse from a Prisma LiteLLM_PolicyTable row."""
return PolicyDBResponse(
policy_id=row.policy_id,
@ -71,11 +161,11 @@ class PolicyRegistry:
"""
def __init__(self):
self._policies: Dict[str, Policy] = {}
self._policies_by_id: Dict[str, Tuple[str, Policy]] = {}
self._policies: dict[str, Policy] = {}
self._policies_by_id: dict[str, tuple[str, Policy]] = {}
self._initialized: bool = False
def load_policies(self, policies_config: Dict[str, Any]) -> None:
def load_policies(self, policies_config: Mapping[str, dict[str, object]]) -> None:
"""
Load policies from a configuration dictionary.
@ -98,7 +188,7 @@ class PolicyRegistry:
self._initialized = True
verbose_proxy_logger.info(f"Loaded {len(self._policies)} policies")
def _parse_policy(self, policy_name: str, policy_data: Dict[str, Any]) -> Policy:
def _parse_policy(self, policy_name: str, policy_data: dict[str, Any]) -> Policy:
"""
Parse a policy from raw configuration data.
@ -139,13 +229,13 @@ class PolicyRegistry:
@staticmethod
def _parse_pipeline(
pipeline_data: Optional[Dict[str, Any]],
) -> Optional[GuardrailPipeline]:
pipeline_data: Optional["_RawPipelineConfig"],
) -> GuardrailPipeline | None:
"""Parse a pipeline configuration from raw data."""
if pipeline_data is None:
return None
steps_data = pipeline_data.get("steps", [])
steps_data: Sequence[PipelineStep | _RawPipelineStep] = pipeline_data.get("steps", [])
steps = [PipelineStep(**step_data) if isinstance(step_data, dict) else step_data for step_data in steps_data]
return GuardrailPipeline(
@ -153,7 +243,7 @@ class PolicyRegistry:
steps=steps,
)
def get_policy(self, policy_name: str) -> Optional[Policy]:
def get_policy(self, policy_name: str) -> Policy | None:
"""
Get a policy by name.
@ -165,7 +255,7 @@ class PolicyRegistry:
"""
return self._policies.get(policy_name)
def get_all_policies(self) -> Dict[str, Policy]:
def get_all_policies(self) -> dict[str, Policy]:
"""
Get all loaded policies.
@ -174,7 +264,7 @@ class PolicyRegistry:
"""
return self._policies.copy()
def get_policy_names(self) -> List[str]:
def get_policy_names(self) -> list[str]:
"""
Get list of all policy names.
@ -247,7 +337,7 @@ class PolicyRegistry:
self,
policy_request: PolicyCreateRequest,
prisma_client: "PrismaClient",
created_by: Optional[str] = None,
created_by: str | None = None,
) -> PolicyDBResponse:
"""
Add a policy to the database.
@ -263,7 +353,7 @@ class PolicyRegistry:
try:
now = datetime.now(timezone.utc)
# Build data dict; new policy is v1 production
data: Dict[str, Any] = {
data: dict[str, object] = {
"policy_name": policy_request.policy_name,
"version_number": 1,
"version_status": "production",
@ -289,7 +379,7 @@ class PolicyRegistry:
validated_pipeline = GuardrailPipeline(**policy_request.pipeline)
data["pipeline"] = json.dumps(validated_pipeline.model_dump())
created_policy = await PolicyRepository(prisma_client).table.create(data=data)
created_policy = await _policy_table(prisma_client).create(data=data)
# Also add to in-memory registry
policy = self._parse_policy(
@ -317,7 +407,7 @@ class PolicyRegistry:
policy_id: str,
policy_request: PolicyUpdateRequest,
prisma_client: "PrismaClient",
updated_by: Optional[str] = None,
updated_by: str | None = None,
) -> PolicyDBResponse:
"""
Update a policy in the database. Only draft versions can be updated.
@ -335,7 +425,7 @@ class PolicyRegistry:
Exception: If policy is not in draft status (only drafts are editable).
"""
try:
existing = await PolicyRepository(prisma_client).table.find_unique(where={"policy_id": policy_id})
existing = await _policy_table(prisma_client).find_unique(where={"policy_id": policy_id})
if existing is None:
raise Exception(f"Policy with ID {policy_id} not found")
version_status = getattr(existing, "version_status", "production")
@ -343,7 +433,7 @@ class PolicyRegistry:
raise Exception(f"Only draft versions can be updated. This policy has status '{version_status}'.")
# Build update data - only include fields that are set
update_data: Dict[str, Any] = {
update_data: dict[str, object] = {
"updated_at": datetime.now(timezone.utc),
"updated_by": updated_by,
}
@ -364,7 +454,7 @@ class PolicyRegistry:
validated_pipeline = GuardrailPipeline(**policy_request.pipeline)
update_data["pipeline"] = json.dumps(validated_pipeline.model_dump())
updated_policy = await PolicyRepository(prisma_client).table.update(
updated_policy = await _policy_table(prisma_client).update(
where={"policy_id": policy_id},
data=update_data,
)
@ -380,7 +470,7 @@ class PolicyRegistry:
self,
policy_id: str,
prisma_client: "PrismaClient",
) -> Dict[str, Any]:
) -> Mapping[str, str]:
"""
Delete a policy version from the database.
@ -395,7 +485,7 @@ class PolicyRegistry:
Dict with "message" and optional "warning" if production was deleted.
"""
try:
policy = await PolicyRepository(prisma_client).table.find_unique(where={"policy_id": policy_id})
policy = await _policy_table(prisma_client).find_unique(where={"policy_id": policy_id})
if policy is None:
raise Exception(f"Policy with ID {policy_id} not found")
@ -404,9 +494,9 @@ class PolicyRegistry:
policy_name = policy.policy_name
# Delete from DB
await PolicyRepository(prisma_client).table.delete(where={"policy_id": policy_id})
await _policy_table(prisma_client).delete(where={"policy_id": policy_id})
result: Dict[str, Any] = {"message": f"Policy {policy_id} deleted successfully"}
result: dict[str, str] = {"message": f"Policy {policy_id} deleted successfully"}
# Remove from in-memory registry only if this was the production version
if version_status == "production":
@ -425,7 +515,7 @@ class PolicyRegistry:
self,
policy_id: str,
prisma_client: "PrismaClient",
) -> Optional[PolicyDBResponse]:
) -> PolicyDBResponse | None:
"""
Get a policy by ID from the database.
@ -437,7 +527,7 @@ class PolicyRegistry:
PolicyDBResponse if found, None otherwise
"""
try:
policy = await PolicyRepository(prisma_client).table.find_unique(where={"policy_id": policy_id})
policy = await _policy_table(prisma_client).find_unique(where={"policy_id": policy_id})
if policy is None:
return None
@ -447,7 +537,7 @@ class PolicyRegistry:
verbose_proxy_logger.exception(f"Error getting policy from DB: {e}")
raise Exception(f"Error getting policy from DB: {str(e)}")
def get_policy_by_id_for_request(self, policy_id: str) -> Optional[Tuple[str, Policy]]:
def get_policy_by_id_for_request(self, policy_id: str) -> tuple[str, Policy] | None:
"""
Return a policy version by ID from in-memory cache (no DB access).
@ -466,8 +556,8 @@ class PolicyRegistry:
async def get_all_policies_from_db(
self,
prisma_client: "PrismaClient",
version_status: Optional[str] = None,
) -> List[PolicyDBResponse]:
version_status: str | None = None,
) -> list[PolicyDBResponse]:
"""
Get all policies from the database, optionally filtered by version_status.
@ -480,11 +570,11 @@ class PolicyRegistry:
List of PolicyDBResponse objects
"""
try:
where: Dict[str, Any] = {}
where: dict[str, str] = {}
if version_status is not None:
where["version_status"] = version_status
policies = await PolicyRepository(prisma_client).table.find_many(
policies = await _policy_table(prisma_client).find_many(
where=where if where else None,
order={"created_at": "desc"},
)
@ -524,7 +614,7 @@ class PolicyRegistry:
self.add_policy(policy_response.policy_name, policy)
self._policies_by_id = {}
non_production = await PolicyRepository(prisma_client).table.find_many(
non_production = await _policy_table(prisma_client).find_many(
where={"version_status": {"in": ["draft", "published"]}},
order={"created_at": "desc"},
)
@ -557,7 +647,7 @@ class PolicyRegistry:
self,
policy_name: str,
prisma_client: "PrismaClient",
) -> List[str]:
) -> list[str]:
"""
Resolve all guardrails for a policy from the database.
@ -622,7 +712,7 @@ class PolicyRegistry:
PolicyVersionListResponse with policy_name and list of versions
"""
try:
rows = await PolicyRepository(prisma_client).table.find_many(
rows = await _policy_table(prisma_client).find_many(
where={"policy_name": policy_name},
order={"version_number": "desc"},
)
@ -640,8 +730,8 @@ class PolicyRegistry:
self,
policy_name: str,
prisma_client: "PrismaClient",
source_policy_id: Optional[str] = None,
created_by: Optional[str] = None,
source_policy_id: str | None = None,
created_by: str | None = None,
) -> PolicyDBResponse:
"""
Create a new draft version of a policy. Copies all fields from the source.
@ -658,14 +748,16 @@ class PolicyRegistry:
"""
try:
if source_policy_id is not None:
source = await PolicyRepository(prisma_client).table.find_unique(where={"policy_id": source_policy_id})
source = await _policy_version_source_table(prisma_client).find_unique(
where={"policy_id": source_policy_id}
)
if source is None:
raise Exception(f"Source policy {source_policy_id} not found")
if source.policy_name != policy_name:
raise Exception(f"Source policy name '{source.policy_name}' does not match '{policy_name}'")
else:
# Find current production version for this policy_name
prod = await PolicyRepository(prisma_client).table.find_first(
prod = await _policy_version_source_table(prisma_client).find_first(
where={
"policy_name": policy_name,
"version_status": "production",
@ -676,7 +768,7 @@ class PolicyRegistry:
source = prod
# Next version number
latest = await PolicyRepository(prisma_client).table.find_first(
latest = await _policy_version_source_table(prisma_client).find_first(
where={"policy_name": policy_name},
order={"version_number": "desc"},
)
@ -684,12 +776,12 @@ class PolicyRegistry:
now = datetime.now(timezone.utc)
# Set is_latest=False on all existing versions for this policy_name
await PolicyRepository(prisma_client).table.update_many(
await _policy_table(prisma_client).update_many(
where={"policy_name": policy_name},
data={"is_latest": False},
)
data: Dict[str, Any] = {
data: dict[str, object] = {
"policy_name": policy_name,
"version_number": next_num,
"version_status": "draft",
@ -714,7 +806,7 @@ class PolicyRegistry:
if source.pipeline is not None:
data["pipeline"] = json.dumps(source.pipeline) if isinstance(source.pipeline, dict) else source.pipeline
created = await PolicyRepository(prisma_client).table.create(data=data)
created = await _policy_table(prisma_client).create(data=data)
return _row_to_policy_db_response(created)
except Exception as e:
verbose_proxy_logger.exception(f"Error creating new version: {e}")
@ -725,7 +817,7 @@ class PolicyRegistry:
policy_id: str,
new_status: str,
prisma_client: "PrismaClient",
updated_by: Optional[str] = None,
updated_by: str | None = None,
) -> PolicyDBResponse:
"""
Update a policy version's status. Valid transitions:
@ -748,7 +840,7 @@ class PolicyRegistry:
if new_status not in ("published", "production"):
raise Exception(f"Invalid status '{new_status}'. Use 'published' or 'production'.")
row = await PolicyRepository(prisma_client).table.find_unique(where={"policy_id": policy_id})
row = await _policy_table(prisma_client).find_unique(where={"policy_id": policy_id})
if row is None:
raise Exception(f"Policy with ID {policy_id} not found")
@ -759,7 +851,7 @@ class PolicyRegistry:
if new_status == "published":
if current != "draft":
raise Exception(f"Only draft versions can be published. Current status: '{current}'.")
updated = await PolicyRepository(prisma_client).table.update(
updated = await _policy_table(prisma_client).update(
where={"policy_id": policy_id},
data={
"version_status": "published",
@ -780,7 +872,7 @@ class PolicyRegistry:
raise Exception("Cannot promote draft directly to production. Publish the version first.")
# Demote current production to published
await PolicyRepository(prisma_client).table.update_many(
await _policy_table(prisma_client).update_many(
where={
"policy_name": policy_name,
"version_status": "production",
@ -793,7 +885,7 @@ class PolicyRegistry:
)
# Promote this version to production
updated = await PolicyRepository(prisma_client).table.update(
updated = await _policy_table(prisma_client).update(
where={"policy_id": policy_id},
data={
"version_status": "production",
@ -843,8 +935,8 @@ class PolicyRegistry:
PolicyVersionCompareResponse with both versions and field_diffs
"""
try:
a = await PolicyRepository(prisma_client).table.find_unique(where={"policy_id": policy_id_a})
b = await PolicyRepository(prisma_client).table.find_unique(where={"policy_id": policy_id_b})
a = await _policy_table(prisma_client).find_unique(where={"policy_id": policy_id_a})
b = await _policy_table(prisma_client).find_unique(where={"policy_id": policy_id_b})
if a is None:
raise Exception(f"Policy {policy_id_a} not found")
if b is None:
@ -854,15 +946,15 @@ class PolicyRegistry:
resp_b = _row_to_policy_db_response(b)
# Compare fields that are part of policy content (not metadata)
compare_fields = [
compare_fields = (
"inherit",
"description",
"guardrails_add",
"guardrails_remove",
"condition",
"pipeline",
]
field_diffs: Dict[str, Dict[str, Any]] = {}
)
field_diffs: dict[str, dict[str, object]] = {}
for field in compare_fields:
val_a = getattr(resp_a, field)
val_b = getattr(resp_b, field)
@ -882,7 +974,7 @@ class PolicyRegistry:
self,
policy_name: str,
prisma_client: "PrismaClient",
) -> Dict[str, str]:
) -> Mapping[str, str]:
"""
Delete all versions of a policy. Also removes from in-memory registry.
@ -894,7 +986,7 @@ class PolicyRegistry:
Dict with success message
"""
try:
await PolicyRepository(prisma_client).table.delete_many(where={"policy_name": policy_name})
await _policy_table(prisma_client).delete_many(where={"policy_name": policy_name})
self.remove_policy(policy_name)
return {"message": f"All versions of policy '{policy_name}' deleted successfully"}
except Exception as e:
@ -903,7 +995,7 @@ class PolicyRegistry:
# Global singleton instance
_policy_registry: Optional[PolicyRegistry] = None
_policy_registry: PolicyRegistry | None = None
def get_policy_registry() -> PolicyRegistry:

View file

@ -109,6 +109,7 @@ from litellm.proxy.common_utils.callback_utils import (
is_sensitive_callback_key,
normalize_callback_names,
process_callback,
strip_callback_config,
)
from litellm.proxy.common_utils.realtime_utils import _realtime_request_body
from litellm.router_utils.add_retry_fallback_headers import (
@ -13408,7 +13409,7 @@ async def async_queue_request(
# extra_body); see above for the same guard upstream.
data["metadata"] = {}
data["metadata"]["user_api_key"] = user_api_key_dict.api_key
data["metadata"]["user_api_key_metadata"] = user_api_key_dict.metadata
data["metadata"]["user_api_key_metadata"] = strip_callback_config(user_api_key_dict.metadata)
_headers = _safe_get_request_headers(request).copy()
_headers.pop("authorization", None) # do not store the original `sk-..` api key in the db
data["metadata"]["headers"] = _headers

View file

@ -1102,6 +1102,19 @@ model LiteLLM_SpendLogToolIndex {
@@index([start_time])
}
// Daily tool spend rollup (one row per tool per day) the Cost Optimization card reads this, never SpendLogs
model LiteLLM_DailyToolSpend {
date String
tool_name String
spend Float @default(0.0)
total_tokens BigInt @default(0)
request_count BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@id([date, tool_name])
}
// Prompt table for storing prompt configurations
model LiteLLM_PromptTable {
id String @id @default(uuid())

View file

@ -2,6 +2,7 @@
import asyncio
import json
import os
from collections import Counter
from collections.abc import Mapping
from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union
from urllib.parse import urlparse
@ -25,6 +26,7 @@ from litellm.repositories.table_repositories import (
SSOConfigRepository,
UISettingsRepository,
)
from litellm.repositories.team_repository import TeamRepository
from litellm.types.proxy.management_endpoints.ui_sso import (
DefaultTeamSSOParams,
SSOConfig,
@ -598,6 +600,51 @@ async def get_default_team_settings():
)
def _default_team_ids(teams: list[str] | list[NewUserRequestTeam]) -> tuple[str, ...]:
return tuple(team if isinstance(team, str) else team.team_id for team in teams)
async def _validate_default_teams_exist(teams: list[str] | list[NewUserRequestTeam]) -> None:
"""Reject default teams that cannot be assigned.
New users are added to these teams long after the settings are saved, and that
consume path swallows the resulting 404, so an unknown team id would silently
drop every future user's team assignment unless it is caught here.
"""
team_ids = _default_team_ids(teams)
if not team_ids:
return
duplicate_ids = tuple(team_id for team_id, count in Counter(team_ids).items() if count > 1)
if duplicate_ids:
raise HTTPException(
status_code=400,
detail={
"error": f"Duplicate default team id(s): {', '.join(duplicate_ids)}. List each default team only once."
},
)
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"error": "Database not connected. Please connect a database."},
)
existing_teams = await TeamRepository(prisma_client).find_many(where={"team_id": {"in": list(team_ids)}})
existing_team_ids = {team.team_id for team in existing_teams}
missing_ids = tuple(team_id for team_id in team_ids if team_id not in existing_team_ids)
if missing_ids:
raise HTTPException(
status_code=400,
detail={
"error": f"Team(s) not found: {', '.join(missing_ids)}. "
"A team must exist before it can be set as a default team for new users."
},
)
async def update_default_team_member_budget(teams: List[NewUserRequestTeam], user_api_key_dict: UserAPIKeyAuth):
"""
1. Update the max member budget for the team
@ -706,6 +753,9 @@ async def update_internal_user_settings(
Update the default internal user parameters for SSO users.
These settings will be applied to new users who sign in via SSO.
"""
if settings.teams is not None:
await _validate_default_teams_exist(settings.teams)
if settings.teams is not None and all(isinstance(team, NewUserRequestTeam) for team in settings.teams):
await update_default_team_member_budget(
settings.teams,

View file

@ -41,6 +41,7 @@ from litellm.constants import (
)
from litellm.proxy._types import (
DB_CONNECTION_ERROR_TYPES,
DB_RETRY_SAFE_ERROR_TYPES,
CommonProxyErrors,
ProxyErrorTypes,
ProxyException,
@ -175,6 +176,7 @@ if TYPE_CHECKING:
from prisma.client import TransactionManager
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.proxy.db.spend_log_tool_index import ToolUsageTransaction
Span = Union[_Span, Any]
else:
@ -2917,6 +2919,8 @@ async def prefetch_config_params(prisma_client: Any, param_names: List[str]) ->
class PrismaClient:
spend_log_transactions: List = []
_spend_log_transactions_lock = asyncio.Lock()
tool_usage_transactions: List["ToolUsageTransaction"] = []
_tool_usage_transactions_lock = asyncio.Lock()
def __init__(
self,
@ -5334,7 +5338,7 @@ class ProxyUpdateSpend:
)
break
except DB_CONNECTION_ERROR_TYPES as e:
except DB_RETRY_SAFE_ERROR_TYPES as e:
if i >= n_retry_times: # If we've reached the maximum number of retries
_raise_failed_update_spend_exception(
e=e, start_time=start_time, proxy_logging_obj=proxy_logging_obj
@ -5473,12 +5477,15 @@ async def update_spend(
queue_size = len(prisma_client.spend_log_transactions)
verbose_proxy_logger.debug("Spend Logs transactions: {}".format(queue_size))
async with prisma_client._tool_usage_transactions_lock:
tool_usage_queue_size = len(prisma_client.tool_usage_transactions)
# Process spend log transactions when called directly.
# This keeps backwards compatibility with the old behavior.
# See update_spend_logs_job and _monitor_spend_logs_queue for the new behavior.
# Safe to keep: under high concurrency this can take up to ~30s to run,
# so it's unlikely to overlap with monitor_spend_logs_queue.
if queue_size > 0:
if queue_size > 0 or tool_usage_queue_size > 0:
await update_spend_logs_job(
prisma_client=prisma_client,
db_writer_client=db_writer_client,
@ -5545,10 +5552,14 @@ async def update_spend_logs_job(
n_retry_times = 3
MAX_LOGS_PER_INTERVAL = 10000
# Atomically pop batch from queue
# Atomically pop batch from queue. The tool usage queue counts toward the
# emptiness check: a spend-log write failure aborts a run before the tool
# drain below, and those entries must not strand once the spend queue drains.
async with prisma_client._spend_log_transactions_lock:
queue_size = len(prisma_client.spend_log_transactions)
if queue_size == 0:
async with prisma_client._tool_usage_transactions_lock:
tool_queue_size = len(prisma_client.tool_usage_transactions)
if queue_size == 0 and tool_queue_size == 0:
return
async with prisma_client._spend_log_transactions_lock:
@ -5579,17 +5590,23 @@ async def update_spend_logs_job(
guardrail_tracking_err,
)
# Tool usage tracking (same batch): SpendLogToolIndex for "last N requests for tool X"
# Tool usage tracking: drain the request-time queue into the tool index and the
# LiteLLM_DailyToolSpend rollup. Never retried; a dropped batch is permanently
# absent from the rollup, so failures log at error.
async with prisma_client._tool_usage_transactions_lock:
tool_usage_to_process = prisma_client.tool_usage_transactions[:MAX_LOGS_PER_INTERVAL]
prisma_client.tool_usage_transactions = prisma_client.tool_usage_transactions[len(tool_usage_to_process) :]
try:
from litellm.proxy.db.spend_log_tool_index import process_spend_logs_tool_usage
from litellm.proxy.db.spend_log_tool_index import flush_tool_usage_transactions
await process_spend_logs_tool_usage(
await flush_tool_usage_transactions(
prisma_client=prisma_client,
logs_to_process=logs_to_process,
transactions=tool_usage_to_process,
)
except Exception as tool_tracking_err:
verbose_proxy_logger.warning(
"Spend tracking - tool usage tracking failed (non-fatal): %s",
verbose_proxy_logger.error(
"Spend tracking - tool usage flush failed; %s tool usage transactions dropped: %s",
len(tool_usage_to_process),
tool_tracking_err,
)
@ -5625,9 +5642,13 @@ async def _monitor_spend_logs_queue(
while True:
try:
# Check queue size with lock protection
# Check queue sizes with lock protection; the tool usage queue keeps
# the monitor firing when a prior failed run left it nonempty.
async with prisma_client._spend_log_transactions_lock:
queue_size = len(prisma_client.spend_log_transactions)
spend_queue_size = len(prisma_client.spend_log_transactions)
async with prisma_client._tool_usage_transactions_lock:
tool_queue_size = len(prisma_client.tool_usage_transactions)
queue_size = spend_queue_size + tool_queue_size
if queue_size > 0:
if queue_size >= threshold:

View file

@ -23,6 +23,7 @@ from litellm.repositories.table_repositories import (
DailyGuardrailMetricsRepository,
DailyPolicyMetricsRepository,
DailyTagSpendRepository,
DailyToolSpendRepository,
DeletedTeamRepository,
DeletedVerificationTokenRepository,
DeprecatedVerificationTokenRepository,
@ -104,6 +105,7 @@ __all__ = [
"ManagedVectorStoreIndexRepository",
"WorkflowMessageRepository",
"DailyTagSpendRepository",
"DailyToolSpendRepository",
"SpendLogToolIndexRepository",
"SpendLogGuardrailIndexRepository",
"UserNotificationsRepository",

View file

@ -181,6 +181,10 @@ class SpendLogToolIndexRepository(PrismaTableRepository):
table_name = "litellm_spendlogtoolindex"
class DailyToolSpendRepository(PrismaTableRepository):
table_name = "litellm_dailytoolspend"
class SpendLogGuardrailIndexRepository(PrismaTableRepository):
table_name = "litellm_spendlogguardrailindex"

View file

@ -3,18 +3,37 @@ VerificationToken repository for database operations on LiteLLM_VerificationToke
"""
import json
from collections.abc import Iterator, Mapping
from datetime import datetime
from typing import Any, Dict, List, Optional, Type
from typing import TYPE_CHECKING, Any, Protocol
from litellm.models.verification_token import (
LiteLLM_VerificationToken,
)
from litellm.repositories.base_repository import BaseRepository
if TYPE_CHECKING:
from prisma.models import (
LiteLLM_VerificationToken as PrismaVerificationToken,
)
from litellm.proxy.utils import PrismaClient
class _DictConvertible(Protocol):
def dict(self) -> dict[str, object]: ...
def __iter__(self) -> Iterator[tuple[str, object]]: ...
class VerificationTokenRepository(BaseRepository[LiteLLM_VerificationToken]):
"""Repository for verification token (API key) database operations."""
@property
def prisma_client(self) -> "PrismaClient":
prisma_client: PrismaClient = super().prisma_client
return prisma_client
@property
def table(self) -> Any:
return self.prisma_client.db.litellm_verificationtoken
@ -24,10 +43,10 @@ class VerificationTokenRepository(BaseRepository[LiteLLM_VerificationToken]):
return self.prisma_client.db.litellm_deletedverificationtoken
@property
def model_class(self) -> Type[LiteLLM_VerificationToken]:
def model_class(self) -> type[LiteLLM_VerificationToken]:
return LiteLLM_VerificationToken
def _to_model(self, record: Any) -> Optional[LiteLLM_VerificationToken]:
def _to_model(self, record: _DictConvertible | None) -> LiteLLM_VerificationToken | None:
"""Convert a database record to a VerificationToken model."""
if record is None:
return None
@ -46,42 +65,43 @@ class VerificationTokenRepository(BaseRepository[LiteLLM_VerificationToken]):
"litellm_budget_table",
]
for field in json_fields:
if isinstance(data.get(field), str):
data[field] = json.loads(data[field])
value = data.get(field)
if isinstance(value, str):
data[field] = json.loads(value)
if data.get("org_id") is None and data.get("organization_id") is not None:
data["org_id"] = data["organization_id"]
return LiteLLM_VerificationToken(**data)
return LiteLLM_VerificationToken.model_validate(data)
async def find_by_id(self, token: str, id_field: str = "token") -> Optional[LiteLLM_VerificationToken]:
async def find_by_id(self, token: str, id_field: str = "token") -> LiteLLM_VerificationToken | None:
return await super().find_by_id(token, id_field)
async def find_by_alias(self, key_alias: str) -> Optional[LiteLLM_VerificationToken]:
async def find_by_alias(self, key_alias: str) -> LiteLLM_VerificationToken | None:
"""Find a token by key alias."""
records = await self.table.find_many(where={"key_alias": key_alias})
records: list[PrismaVerificationToken] = await self.table.find_many(where={"key_alias": key_alias})
if records:
return self._to_model(records[0])
return None
async def find_by_user_id(self, user_id: str) -> List[LiteLLM_VerificationToken]:
async def find_by_user_id(self, user_id: str) -> list[LiteLLM_VerificationToken]:
"""Find all tokens belonging to a user."""
records = await self.table.find_many(where={"user_id": user_id})
records: list[PrismaVerificationToken] = await self.table.find_many(where={"user_id": user_id})
return self._to_model_list(records)
async def find_by_team_id(self, team_id: str) -> List[LiteLLM_VerificationToken]:
async def find_by_team_id(self, team_id: str) -> list[LiteLLM_VerificationToken]:
"""Find all tokens belonging to a team."""
records = await self.table.find_many(where={"team_id": team_id})
records: list[PrismaVerificationToken] = await self.table.find_many(where={"team_id": team_id})
return self._to_model_list(records)
async def find_by_project_id(self, project_id: str) -> List[LiteLLM_VerificationToken]:
async def find_by_project_id(self, project_id: str) -> list[LiteLLM_VerificationToken]:
"""Find all tokens belonging to a project."""
records = await self.table.find_many(where={"project_id": project_id})
records: list[PrismaVerificationToken] = await self.table.find_many(where={"project_id": project_id})
return self._to_model_list(records)
async def find_active_tokens(self) -> List[LiteLLM_VerificationToken]:
async def find_active_tokens(self) -> list[LiteLLM_VerificationToken]:
"""Find all active (non-expired, non-blocked) tokens."""
records = await self.table.find_many(
records: list[PrismaVerificationToken] = await self.table.find_many(
where={
"blocked": {"not": True},
"OR": [{"expires": None}, {"expires": {"gt": datetime.utcnow()}}],
@ -92,31 +112,31 @@ class VerificationTokenRepository(BaseRepository[LiteLLM_VerificationToken]):
def _build_token_data(
self,
token: str,
key_name: Optional[str] = None,
key_alias: Optional[str] = None,
max_budget: Optional[float] = None,
expires: Optional[datetime] = None,
models: Optional[List[str]] = None,
aliases: Optional[Dict[str, str]] = None,
config: Optional[Dict[str, Any]] = None,
user_id: Optional[str] = None,
team_id: Optional[str] = None,
agent_id: Optional[str] = None,
project_id: Optional[str] = None,
max_parallel_requests: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None,
tpm_limit: Optional[int] = None,
rpm_limit: Optional[int] = None,
budget_duration: Optional[str] = None,
allowed_cache_controls: Optional[List[str]] = None,
allowed_routes: Optional[List[str]] = None,
permissions: Optional[Dict[str, Any]] = None,
org_id: Optional[str] = None,
created_by: Optional[str] = None,
object_permission_id: Optional[str] = None,
access_group_ids: Optional[List[str]] = None,
budget_id: Optional[str] = None,
) -> Dict[str, Any]:
key_name: str | None = None,
key_alias: str | None = None,
max_budget: float | None = None,
expires: datetime | None = None,
models: list[str] | None = None,
aliases: dict[str, str] | None = None,
config: Mapping[str, object] | None = None,
user_id: str | None = None,
team_id: str | None = None,
agent_id: str | None = None,
project_id: str | None = None,
max_parallel_requests: int | None = None,
metadata: Mapping[str, object] | None = None,
tpm_limit: int | None = None,
rpm_limit: int | None = None,
budget_duration: str | None = None,
allowed_cache_controls: list[str] | None = None,
allowed_routes: list[str] | None = None,
permissions: Mapping[str, object] | None = None,
org_id: str | None = None,
created_by: str | None = None,
object_permission_id: str | None = None,
access_group_ids: list[str] | None = None,
budget_id: str | None = None,
) -> dict[str, object]:
"""Build data dictionary for token creation."""
json_fields = {
"aliases": aliases,
@ -145,7 +165,7 @@ class VerificationTokenRepository(BaseRepository[LiteLLM_VerificationToken]):
"access_group_ids": access_group_ids,
"budget_id": budget_id,
}
data: Dict[str, Any] = {k: v for k, v in simple_fields.items() if v is not None}
data: dict[str, object] = {k: v for k, v in simple_fields.items() if v is not None}
for key, val in json_fields.items():
if val is not None:
data[key] = json.dumps(val)
@ -159,30 +179,30 @@ class VerificationTokenRepository(BaseRepository[LiteLLM_VerificationToken]):
async def create_token(
self,
token: str,
key_name: Optional[str] = None,
key_alias: Optional[str] = None,
max_budget: Optional[float] = None,
expires: Optional[datetime] = None,
models: Optional[List[str]] = None,
aliases: Optional[Dict[str, str]] = None,
config: Optional[Dict[str, Any]] = None,
user_id: Optional[str] = None,
team_id: Optional[str] = None,
agent_id: Optional[str] = None,
project_id: Optional[str] = None,
max_parallel_requests: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None,
tpm_limit: Optional[int] = None,
rpm_limit: Optional[int] = None,
budget_duration: Optional[str] = None,
allowed_cache_controls: Optional[List[str]] = None,
allowed_routes: Optional[List[str]] = None,
permissions: Optional[Dict[str, Any]] = None,
org_id: Optional[str] = None,
created_by: Optional[str] = None,
object_permission_id: Optional[str] = None,
access_group_ids: Optional[List[str]] = None,
budget_id: Optional[str] = None,
key_name: str | None = None,
key_alias: str | None = None,
max_budget: float | None = None,
expires: datetime | None = None,
models: list[str] | None = None,
aliases: dict[str, str] | None = None,
config: Mapping[str, object] | None = None,
user_id: str | None = None,
team_id: str | None = None,
agent_id: str | None = None,
project_id: str | None = None,
max_parallel_requests: int | None = None,
metadata: Mapping[str, object] | None = None,
tpm_limit: int | None = None,
rpm_limit: int | None = None,
budget_duration: str | None = None,
allowed_cache_controls: list[str] | None = None,
allowed_routes: list[str] | None = None,
permissions: Mapping[str, object] | None = None,
org_id: str | None = None,
created_by: str | None = None,
object_permission_id: str | None = None,
access_group_ids: list[str] | None = None,
budget_id: str | None = None,
) -> LiteLLM_VerificationToken:
"""Create a new verification token."""
data = self._build_token_data(
@ -217,28 +237,28 @@ class VerificationTokenRepository(BaseRepository[LiteLLM_VerificationToken]):
async def update_token(
self,
token: str,
updated_by: Optional[str] = None,
key_name: Optional[str] = None,
key_alias: Optional[str] = None,
max_budget: Optional[float] = None,
expires: Optional[datetime] = None,
models: Optional[List[str]] = None,
aliases: Optional[Dict[str, str]] = None,
config: Optional[Dict[str, Any]] = None,
max_parallel_requests: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None,
tpm_limit: Optional[int] = None,
rpm_limit: Optional[int] = None,
budget_duration: Optional[str] = None,
allowed_cache_controls: Optional[List[str]] = None,
allowed_routes: Optional[List[str]] = None,
permissions: Optional[Dict[str, Any]] = None,
blocked: Optional[bool] = None,
object_permission_id: Optional[str] = None,
access_group_ids: Optional[List[str]] = None,
) -> Optional[LiteLLM_VerificationToken]:
updated_by: str | None = None,
key_name: str | None = None,
key_alias: str | None = None,
max_budget: float | None = None,
expires: datetime | None = None,
models: list[str] | None = None,
aliases: dict[str, str] | None = None,
config: Mapping[str, object] | None = None,
max_parallel_requests: int | None = None,
metadata: Mapping[str, object] | None = None,
tpm_limit: int | None = None,
rpm_limit: int | None = None,
budget_duration: str | None = None,
allowed_cache_controls: list[str] | None = None,
allowed_routes: list[str] | None = None,
permissions: Mapping[str, object] | None = None,
blocked: bool | None = None,
object_permission_id: str | None = None,
access_group_ids: list[str] | None = None,
) -> LiteLLM_VerificationToken | None:
"""Update a verification token."""
data: Dict[str, Any] = {}
data: dict[str, object] = {}
if updated_by is not None:
data["updated_by"] = updated_by
if key_name is not None:
@ -283,10 +303,10 @@ class VerificationTokenRepository(BaseRepository[LiteLLM_VerificationToken]):
async def delete_token(
self,
token: str,
deleted_by: Optional[str] = None,
deleted_by_api_key: Optional[str] = None,
litellm_changed_by: Optional[str] = None,
) -> Optional[LiteLLM_VerificationToken]:
deleted_by: str | None = None,
deleted_by_api_key: str | None = None,
litellm_changed_by: str | None = None,
) -> LiteLLM_VerificationToken | None:
"""Delete a token and archive it to the deleted tokens table.
Uses a transaction to ensure atomicity of the archive-then-delete operation.
@ -307,14 +327,14 @@ class VerificationTokenRepository(BaseRepository[LiteLLM_VerificationToken]):
return token_record
def _build_archive_data(self, token: LiteLLM_VerificationToken) -> Dict[str, Any]:
def _build_archive_data(self, token: LiteLLM_VerificationToken) -> dict[str, object]:
"""Build archive data with only columns present in LiteLLM_DeletedVerificationToken.
Serializes JSON columns to strings (the archive table stores them as JSON
columns the same way the live table does) and maps ``org_id`` onto the
``organization_id`` column so the foreign key is preserved.
"""
data = token.model_dump(exclude_none=True)
data: dict[str, object] = token.model_dump(exclude_none=True)
for field in ("object_permission", "litellm_budget_table", "budget_limits"):
data.pop(field, None)
@ -336,24 +356,24 @@ class VerificationTokenRepository(BaseRepository[LiteLLM_VerificationToken]):
data[field] = json.dumps(data[field])
return data
async def update_spend(self, token: str, spend: float) -> Optional[LiteLLM_VerificationToken]:
async def update_spend(self, token: str, spend: float) -> LiteLLM_VerificationToken | None:
"""Update token spend."""
return await self.update(token, {"spend": spend}, id_field="token")
async def update_last_active(self, token: str) -> Optional[LiteLLM_VerificationToken]:
async def update_last_active(self, token: str) -> LiteLLM_VerificationToken | None:
"""Update the last_active timestamp."""
return await self.update(token, {"last_active": datetime.utcnow()}, id_field="token")
async def block_token(self, token: str, updated_by: Optional[str] = None) -> Optional[LiteLLM_VerificationToken]:
async def block_token(self, token: str, updated_by: str | None = None) -> LiteLLM_VerificationToken | None:
"""Block a token."""
data: Dict[str, Any] = {"blocked": True}
data: dict[str, object] = {"blocked": True}
if updated_by is not None:
data["updated_by"] = updated_by
return await self.update(token, data, id_field="token")
async def unblock_token(self, token: str, updated_by: Optional[str] = None) -> Optional[LiteLLM_VerificationToken]:
async def unblock_token(self, token: str, updated_by: str | None = None) -> LiteLLM_VerificationToken | None:
"""Unblock a token."""
data: Dict[str, Any] = {"blocked": False}
data: dict[str, object] = {"blocked": False}
if updated_by is not None:
data["updated_by"] = updated_by
return await self.update(token, data, id_field="token")

View file

@ -7702,6 +7702,21 @@ class Router:
"""True when this deployment opts in via the `auto_router/adaptive_router` model prefix."""
return litellm_params.model.startswith("auto_router/adaptive_router")
def _deployment_participates_in_adaptive_routing(self, litellm_params: LiteLLM_Params) -> bool:
"""True when this deployment owns an `adaptive_routers` entry once finalized:
a dedicated adaptive router, or a complexity router whose config enables the
adaptive companion. Mirrors the two arms of
`_finalize_adaptive_router_if_configured`, which is the registry's only writer."""
if self._is_adaptive_router_deployment(litellm_params=litellm_params):
return True
if not self._is_complexity_router_deployment(litellm_params=litellm_params):
return False
config = litellm_params.complexity_router_config
if not config:
return False
adaptive_flag: object = config.get("adaptive")
return bool(adaptive_flag)
@staticmethod
def _has_registered_strategy(
registry: dict[str, list[TaggedPreRoutingStrategy[_PreRoutingStrategyT]]],
@ -7734,20 +7749,56 @@ class Router:
TaggedPreRoutingStrategy(tags=tags, strategy=strategy),
]
@staticmethod
def _unregister_pre_routing_strategy(
registry: dict[str, list[TaggedPreRoutingStrategy[_PreRoutingStrategyT]]],
model_name: str,
tags: tuple[str, ...],
) -> bool:
"""Drop the strategy registered for this exact (model_name, tags) pair, leaving
strategies registered under the same name with different tags in place. Returns
whether anything was actually dropped."""
existing = registry.get(model_name, [])
remaining = [entry for entry in existing if entry.tags != tags]
if len(remaining) == len(existing):
return False
if remaining:
registry[model_name] = remaining
else:
registry.pop(model_name, None)
return True
def _unregister_pre_routing_strategy_for_deployment(self, deployment: Deployment) -> None:
"""
Release the pre-routing strategy a deployment holds, so removing it from the
model_list also frees its (model_name, tags) slot.
Without this, re-adding the deployment (an edit arriving via upsert_deployment,
or a router recreated under a name that was deleted earlier) hits the
"already exists" guard in `_register_pre_routing_strategy`, which
`ignore_invalid_deployments` swallows - the deployment then silently never
makes it back into the model_list.
Released from every registry rather than the first match, because registration is
one-to-many: a complexity router configured with `adaptive` is also registered in
`adaptive_routers` under the same (model_name, tags) by the deferred finalize pass.
Guarded on the auto_router/ prefix so removing a *regular* deployment can't evict a
router that merely shares its model_name.
"""
if not deployment.litellm_params.model.startswith("auto_router/"):
return
model_name = deployment.model_name
tags = self._deployment_tags(deployment)
for registry in (self.auto_routers, self.complexity_routers, self.quality_routers):
self._unregister_pre_routing_strategy(registry, model_name, tags)
if self._unregister_pre_routing_strategy(self.adaptive_routers, model_name, tags):
self._sync_adaptive_router_hooks()
def _finalize_adaptive_router_if_configured(self) -> None:
"""Locate every adaptive-router deployment in the finalized model_list and
build an AdaptiveRouter for each. Safe no-op when none are configured.
Idempotent: skips any deployment whose (model_name, tags) pair is already
initialized, so hot-reloads don't rebuild routers that would lose state."""
# Drop any adaptive-router hooks left over from a previous Router
# instance (e.g. after `/config/reload` replaced `llm_router`). Without
# this, stale AdaptiveRouterPostCallHook callbacks from the old Router
# remain wired up in `litellm.callbacks` and double-fire signal
# recording for every request.
from litellm.router_strategy.adaptive_router.hooks import (
AdaptiveRouterPostCallHook,
)
for entry in self.model_list or []:
lp = entry.get("litellm_params") if isinstance(entry, dict) else entry.litellm_params
lp_model = (lp.get("model") if isinstance(lp, dict) else lp.model) if lp else None
@ -7779,6 +7830,16 @@ class Router:
TaggedPreRoutingStrategy(tags=tagged.tags, strategy=adaptive_router),
]
self._sync_adaptive_router_hooks()
def _sync_adaptive_router_hooks(self) -> None:
"""Rebuild the AdaptiveRouterPostCallHook set so it is exactly one hook per
currently registered adaptive router. Run at every point the adaptive registry
changes, otherwise a released router keeps recording turns through its hook."""
from litellm.router_strategy.adaptive_router.hooks import (
AdaptiveRouterPostCallHook,
)
for callback in litellm.logging_callback_manager.get_custom_loggers_for_type(AdaptiveRouterPostCallHook):
litellm.logging_callback_manager.remove_callback_from_all_lists(callback)
for tagged_adaptive_routers in self.adaptive_routers.values():
@ -8401,13 +8462,29 @@ class Router:
self._invalidate_access_groups_cache()
self._update_deployment_indices_after_removal(model_id=deployment_id, removal_idx=removal_idx)
# Free the outgoing deployment's pre-routing strategy slot (keyed by the
# OLD model_name/tags) before the re-add below re-registers it.
self._unregister_pre_routing_strategy_for_deployment(deployment=_deployment_on_router)
# if the model_id is not in router
self.add_deployment(deployment=deployment)
# add_deployment() builds every strategy EXCEPT the adaptive one, which
# set_model_list() defers until the whole model_list is visible. Re-run that
# deferred pass so an adaptive router whose slot was just released above is
# rebuilt rather than left unregistered.
if self._deployment_participates_in_adaptive_routing(litellm_params=deployment.litellm_params) or (
_deployment_on_router is not None
and self._deployment_participates_in_adaptive_routing(
litellm_params=_deployment_on_router.litellm_params
)
):
self._finalize_adaptive_router_if_configured()
return deployment
except Exception as e:
if self.ignore_invalid_deployments:
verbose_router_logger.debug(
f"Error upserting deployment: {e}, ignoring and continuing with other deployments."
verbose_router_logger.warning(
f"Error upserting deployment {deployment.model_name} (id={deployment.model_info.id}): {e}. "
"Dropping it and continuing with other deployments."
)
return None
else:
@ -8436,6 +8513,16 @@ class Router:
_budget_limiter = self._get_router_deployment_budget_limiter()
if _budget_limiter is not None:
_budget_limiter.unregister_deployment_budget(model_id=id)
try:
self._unregister_pre_routing_strategy_for_deployment(
deployment=item if isinstance(item, Deployment) else Deployment(**item)
)
except Exception:
verbose_router_logger.exception(
"delete_deployment: could not release pre-routing strategies for model_id=%s; "
"the deployment is out of the model_list and its indices are repaired",
id,
)
return item
else:
return None

View file

@ -124,12 +124,5 @@ class ToolSpendDailyEntry(BaseModel):
class ToolSpendResponse(BaseModel):
by_tool: List[ToolSpendEntry] = Field(default_factory=list)
daily: List[ToolSpendDailyEntry] = Field(default_factory=list)
total_spend: float = Field(
0.0,
description=(
"Deduplicated spend of every request that called at least one tool in the window; "
"less than the sum of per-tool attributed spend whenever multi-tool requests exist"
),
)
start_date: str | None = None
end_date: str | None = None

View file

@ -1,12 +1,12 @@
{
"ANN001": {
"limit": 3152
"limit": 3142
},
"ANN002": {
"limit": 69
},
"ANN003": {
"limit": 835
"limit": 831
},
"ANN201": {
"limit": 2138
@ -24,7 +24,7 @@
"limit": 130
},
"ANN401": {
"limit": 2074
"limit": 2015
},
"ASYNC230": {
"limit": 14
@ -123,7 +123,7 @@
"limit": 52
},
"I001": {
"limit": 273
"limit": 270
},
"LOG015": {
"limit": 8
@ -135,7 +135,7 @@
"limit": 30
},
"PERF401": {
"limit": 146
"limit": 144
},
"PERF402": {
"limit": 9
@ -222,7 +222,7 @@
"limit": 38
},
"RET504": {
"limit": 719
"limit": 717
},
"RUF010": {
"limit": 874
@ -237,7 +237,7 @@
"limit": 41
},
"RUF022": {
"limit": 84
"limit": 85
},
"RUF023": {
"limit": 5
@ -306,7 +306,7 @@
"limit": 9
},
"TID251": {
"limit": 2700
"limit": 2652
},
"TRY002": {
"limit": 548
@ -324,10 +324,10 @@
"limit": 883
},
"UP006": {
"limit": 12789
"limit": 12147
},
"UP007": {
"limit": 2570
"limit": 2526
},
"UP008": {
"limit": 5
@ -354,7 +354,7 @@
"limit": 4
},
"UP035": {
"limit": 2284
"limit": 2232
},
"UP036": {
"limit": 4
@ -363,6 +363,6 @@
"limit": 105
},
"UP045": {
"limit": 18458
"limit": 17824
}
}

View file

@ -1102,6 +1102,19 @@ model LiteLLM_SpendLogToolIndex {
@@index([start_time])
}
// Daily tool spend rollup (one row per tool per day) the Cost Optimization card reads this, never SpendLogs
model LiteLLM_DailyToolSpend {
date String
tool_name String
spend Float @default(0.0)
total_tokens BigInt @default(0)
request_count BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@id([date, tool_name])
}
// Prompt table for storing prompt configurations
model LiteLLM_PromptTable {
id String @id @default(uuid())

View file

@ -11,12 +11,13 @@ here because only this suite uses them.
from __future__ import annotations
import time
import warnings
from dataclasses import dataclass
from pydantic import BaseModel, ConfigDict, Field
from e2e_http import NoBody, Result, get_external, is_ok
from e2e_http import NoBody, Result, Success, get_external, is_ok
from proxy_client import ProxyClient
@ -290,12 +291,46 @@ class A2AClient:
proxy: ProxyClient
def register_agent(self, body: AgentRegisterBody) -> Result[AgentResponse]:
return self.proxy.transport.post(
"""Register an agent and, on success, wait until the data plane serves it.
/v1/agents is a control-plane route; the /a2a/{agent_id} routes that serve
the card and run message/send are data plane, and only see the agent after
the next DB reload. A card read or message/send issued the instant this
returns can therefore 404 on the agent it just created. Waiting here keeps
every caller from having to poll, the same way ProxyClient.create_model
waits for a new model to become servable.
"""
result = self.proxy.transport.post(
"/v1/agents",
headers=self.proxy.transport.master,
json=body,
response_type=AgentResponse,
)
if isinstance(result, Success):
self._await_agent_servable(result.data.agent_id)
return result
def _await_agent_servable(self, agent_id: str) -> None:
"""Block until the data plane serves `agent_id`'s card, or fail loudly at
poll_timeout (a real propagation problem, surfaced here rather than as a
downstream 404 on whichever /a2a call the test happened to make first)."""
deadline = time.monotonic() + self.proxy.poll_timeout
while True:
result = self.proxy.transport.get(
f"/a2a/{agent_id}/.well-known/agent-card.json",
headers=self.proxy.transport.master,
params=NoBody(),
response_type=ServedAgentCard,
)
if isinstance(result, Success):
return
if time.monotonic() >= deadline:
raise AssertionError(
f"agent {agent_id!r} was registered but never became servable on the "
f"data plane within {self.proxy.poll_timeout}s of POST /v1/agents "
f"(control/data-plane propagation issue); last card read: {result}"
)
time.sleep(self.proxy.poll_interval)
def get_agent(self, agent_id: str) -> Result[AgentResponse]:
return self.proxy.transport.get(

View file

@ -5,6 +5,9 @@
- {id: llm.chat_completions.openai.passthrough.nonstream.cost_logged, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: basic, streaming: nonstream, assertions: [works, cost_logged], source: "test_passthrough_e2e.py", rationale: "OpenAI-format chat via the raw /openai/{endpoint} passthrough (/openai/v1/chat/completions); proxy swaps in OPENAI_API_KEY and still logs a costed pass_through_endpoint row (LIT-4752)"}
- {id: llm.chat_completions.openai.tool_use.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: openai, capability: tool_use, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "OpenAI function_calling; high usage"}
- {id: llm.chat_completions.openai.tool_use.stream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: openai, capability: tool_use, streaming: stream, assertions: [works], source: "model_prices json", rationale: "Tool calls over streaming"}
- {id: llm.chat_completions.openai.basic.stream.bridge_shares_chunk_id, module: llm, tier: P0, subject_endpoint: chat_completions, route: openai, capability: basic, streaming: stream, assertions: [stable_chunk_id], source: "completion_extras/litellm_responses_transformation/transformation.py", rationale: "A responses-only model served over /chat/completions must stream every chunk under one chat completion id; per-chunk ids make id-accumulating SDKs drop the response", fail_before_fix: proven}
- {id: llm.chat_completions.openai.basic.stream.bridge_streams_sse, module: llm, tier: P0, subject_endpoint: chat_completions, route: openai, capability: basic, streaming: stream, assertions: [works], source: "completion_extras/litellm_responses_transformation/handler.py", rationale: "The Responses bridge must answer a streaming chat request with real SSE (content deltas, finish_reason, [DONE]), never a completed response the SSE generator cannot iterate"}
- {id: llm.chat_completions.openai.tool_use.stream.bridge_streams_tool_call, module: llm, tier: P0, subject_endpoint: chat_completions, route: openai, capability: tool_use, streaming: stream, assertions: [works], source: "completion_extras/litellm_responses_transformation/transformation.py", rationale: "Tool calls translated from Responses events must reassemble into one named call with parseable argument JSON over the bridged stream"}
- {id: llm.chat_completions.openai.vision.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: openai, capability: vision, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "gpt-4o vision; high usage"}
- {id: llm.chat_completions.openai.prompt_cache_5m.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: openai, capability: prompt_cache_5m, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Prompt caching cost optimization"}
- {id: llm.chat_completions.openai.service_tier.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: service_tier, streaming: nonstream, assertions: [works], source: "OpenAI service_tier param", rationale: "OpenAI scale-tier request option is forwarded and echoed"}

View file

@ -137,6 +137,7 @@ class StreamingResponse(BaseModel):
# quota) arrive as SSE error events inside an otherwise-successful response;
# the consumed body is elided, so this is the only place they surface.
stream_error: str | None = None
stream_done: bool = False
@property
def ok(self) -> bool:
@ -408,6 +409,7 @@ def _streaming_outcome(resp: requests.Response, stream: bool) -> StreamingRespon
chunks = 0
stream_error: str | None = None
stream_events: list[str] = []
stream_done = False
for line in lines:
if not line:
continue
@ -415,7 +417,9 @@ def _streaming_outcome(resp: requests.Response, stream: bool) -> StreamingRespon
decoded_line = line.decode(errors="replace")
if decoded_line.startswith("data: "):
payload = decoded_line.removeprefix("data: ")
if payload != "[DONE]":
if payload == "[DONE]":
stream_done = True
else:
stream_events.append(payload)
if stream_error is None and (
line.startswith(b"event: error")
@ -433,6 +437,7 @@ def _streaming_outcome(resp: requests.Response, stream: bool) -> StreamingRespon
body="<streamed>",
chunks=chunks,
stream_events=stream_events,
stream_done=stream_done,
stream_error=stream_error,
)

View file

@ -5,6 +5,7 @@ and chat through them on the shared ProxyClient so resources.defer cleans up.
from __future__ import annotations
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Literal
@ -287,3 +288,24 @@ class GuardrailsClient:
def build_client(proxy: ProxyClient) -> GuardrailsClient:
return GuardrailsClient(proxy=proxy)
def poll_until_blocked(call: Callable[[], Result[ChatResponse]]) -> Result[ChatResponse]:
"""Retry a call that a guardrail should reject until it is, returning the last result.
Registering a guardrail is a control-plane write; the data-plane worker that
serves /chat/completions picks it up only on its next periodic DB sync (~30s in
proxy_server.py). A call issued right after the create therefore runs against a
worker that has no guardrail yet and is allowed through, which is in-flight
propagation rather than a guardrail that failed to block. Polling to the deadline
waits that out so the assertions judge the synced state; a guardrail that never
blocks still fails, on the last allowed result.
"""
deadline = time.monotonic() + POLL_TIMEOUT
last = call()
while time.monotonic() < deadline:
if not isinstance(last, Success):
return last
time.sleep(POLL_INTERVAL)
last = call()
return last

View file

@ -18,7 +18,7 @@ import pytest
from e2e_config import unique_marker
from e2e_http import UnknownApiError
from guardrails_client import GuardrailsClient
from guardrails_client import GuardrailsClient, poll_until_blocked
from lifecycle import ResourceManager
pytestmark = pytest.mark.e2e
@ -50,7 +50,9 @@ class TestBedrockGuardrail:
# Selected per request rather than registered default_on, so an upstream
# ApplyGuardrail failure surfaces here instead of 403ing every other suite
# running against this proxy.
result = client.chat(scoped_key, MODEL, BLOCKED_PROMPT, guardrails=[name])
result = poll_until_blocked(
lambda: client.chat(scoped_key, MODEL, BLOCKED_PROMPT, guardrails=[name])
)
match result:
case UnknownApiError(status_code=status, body=body):

View file

@ -14,9 +14,11 @@ the shared proxy, and the chat backend is a gemini deployment created for the te
from __future__ import annotations
import time
import pytest
from e2e_config import unique_marker
from e2e_config import POLL_INTERVAL, POLL_TIMEOUT, unique_marker
from e2e_http import unwrap
from guardrails_client import BlockCodeExecutionParamsBody, GuardrailsClient
from lifecycle import ResourceManager
@ -54,7 +56,18 @@ class TestBlockCodeExecutionGuardrail:
)
resources.defer(lambda: client.delete_guardrail(guardrail_id))
# This guardrail replaces the reply rather than erroring, so wait for the
# block marker to appear instead of for a non-success status. The data-plane
# worker only picks a new guardrail up on its next DB sync (~30s), so the
# first call after the create is served without it.
deadline = time.monotonic() + POLL_TIMEOUT
blocked = unwrap(client.chat(scoped_key, model, EXECUTION_REQUEST, guardrails=[name]))
while time.monotonic() < deadline:
if _BLOCK_MARKER in _first_content(blocked).lower():
break
time.sleep(POLL_INTERVAL)
blocked = unwrap(client.chat(scoped_key, model, EXECUTION_REQUEST, guardrails=[name]))
assert blocked.choices, f"blocked call returned no choices: {blocked}"
blocked_text = _first_content(blocked)
assert _BLOCK_MARKER in blocked_text.lower(), (

View file

@ -16,7 +16,11 @@ import pytest
from e2e_config import unique_marker
from e2e_http import UnknownApiError, unwrap
from guardrails_client import GuardrailsClient, OpenAIModerationParamsBody
from guardrails_client import (
GuardrailsClient,
OpenAIModerationParamsBody,
poll_until_blocked,
)
from lifecycle import ResourceManager
pytestmark = pytest.mark.e2e
@ -45,7 +49,9 @@ class TestOpenAIModerationGuardrail:
)
resources.defer(lambda: client.delete_guardrail(guardrail_id))
blocked = client.chat(scoped_key, model, FLAGGED_PROMPT, guardrails=[name])
blocked = poll_until_blocked(
lambda: client.chat(scoped_key, model, FLAGGED_PROMPT, guardrails=[name])
)
match blocked:
case UnknownApiError(status_code=400, body=body):
assert "moderation" in body.lower(), (

View file

@ -1,8 +1,8 @@
"""Live e2e: the built-in Presidio PII guardrail masks PII on the request, on the
model output, and in what the proxy logs.
"""Live e2e: the built-in Presidio PII guardrail masks PII on the request and on
the model output.
Presidio replaces detected PII with `<ENTITY_TYPE>` placeholders (e.g.
`<EMAIL_ADDRESS>`) via a real analyzer + anonymizer. Three modes are checked
`<EMAIL_ADDRESS>`) via a real analyzer + anonymizer. Two modes are checked
independently, each opted into per request (default_on=False) so it never touches
unrelated traffic:
@ -10,32 +10,31 @@ unrelated traffic:
repeat-verbatim request comes back with the placeholder, never the raw email
- post_call (apply_to_output): PII the model itself emits is masked on the way
out, so the caller never receives the raw value the model produced
- logging_only: the call is not blocked, and the request the proxy records is
masked. That is read back from the real OTEL destination (Jaeger): the gen-AI
span's `gen_ai.input.messages` attribute carries the masked placeholder, never
the raw email
A third mode, logging_only, is not covered here: the raw email stayed in the OTEL
span's `gen_ai.input.messages` on every attempt over a full poll deadline while
these two modes masked correctly, so that cell is tracked in LIT-4841 rather than
asserted against known-failing behavior.
Analyzer/anonymizer bases come from PRESIDIO_ANALYZER_API_BASE /
PRESIDIO_ANONYMIZER_API_BASE (compose provides the in-network hosts; point them at
locally published container ports for a host run). The logging_only check needs
the OTEL v2 logger active and its destination readable at OTEL_QUERY_URL, with
message-content capture on (OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT).
The chat backend is a gemini deployment created for the test.
locally published container ports for a host run). The chat backend is a gemini
deployment created for the test.
"""
from __future__ import annotations
import os
import time
from collections.abc import Callable
import pytest
from e2e_config import POLL_INTERVAL, POLL_TIMEOUT, unique_marker
from e2e_http import NoBody, require_successful_call, unwrap
from e2e_http import unwrap
from guardrails_client import GuardrailMode, GuardrailsClient, PresidioParamsBody
from lifecycle import ResourceManager
from models import ChatBody, ChatMessage, ChatResponse, ReadinessDetailsResponse
from otel_client import JaegerSpan, OtelReader, build_otel_reader
from models import ChatResponse
pytestmark = pytest.mark.e2e
@ -44,10 +43,6 @@ PLACEHOLDER = "<EMAIL_ADDRESS>"
ECHO_REQUEST = f"Repeat the following text back exactly, verbatim, with no changes: My email is {RAW_EMAIL}"
EMIT_REQUEST = f"Output exactly this one line and nothing else: Please contact {RAW_EMAIL} today"
LOG_REQUEST = f"Say hello and include this email once verbatim: {RAW_EMAIL}"
OTEL_V2_LOGGER = "OpenTelemetryV2"
INPUT_MESSAGES_TAG = "gen_ai.input.messages"
def _content(response: ChatResponse) -> str:
@ -57,35 +52,6 @@ def _content(response: ChatResponse) -> str:
return (message.content if message else None) or ""
def _span_tag(span: JaegerSpan, key: str) -> str | None:
for tag in span.tags:
if tag.key == key and isinstance(tag.value, str):
return tag.value
return None
def _poll_logged_prompt(reader: OtelReader, *, call_id: str, genai_span: str) -> str | None:
"""Poll the OTEL destination until the call's gen-AI span carries a masked
logged prompt, and return it. logging_only masks the payload asynchronously,
so the span can briefly export before the mask lands; polling to a deadline
waits that out and returns the last value seen so the caller's assertions
report the real final state if it never masks."""
deadline = time.monotonic() + POLL_TIMEOUT
last: str | None = None
while time.monotonic() < deadline:
for trace in reader.traces_for_call(call_id):
for span in trace.spans:
if span.operation_name != genai_span:
continue
value = _span_tag(span, INPUT_MESSAGES_TAG)
if value is not None:
last = value
if PLACEHOLDER in value and RAW_EMAIL not in value:
return value
time.sleep(POLL_INTERVAL)
return last
def _presidio_params(
mode: GuardrailMode, *, apply_to_output: bool = False, logging_only: bool = False
) -> PresidioParamsBody:
@ -101,19 +67,25 @@ def _presidio_params(
)
def _require_otel_v2_active(client: GuardrailsClient) -> None:
details = unwrap(
client.proxy.transport.get(
"/health/readiness/details",
headers=client.proxy.transport.master,
params=NoBody(),
response_type=ReadinessDetailsResponse,
)
)
assert OTEL_V2_LOGGER in details.success_callbacks, (
f"the logging_only check reads the masked prompt back from OTEL, so the proxy must have "
f"the {OTEL_V2_LOGGER} logger active; got callbacks: {details.success_callbacks}"
)
def _poll_until_masked(call: Callable[[], str]) -> str:
"""Retry a call until the guardrail masks its PII, returning the last content.
Registering a guardrail is a control-plane write; the data-plane worker that
serves /chat/completions only picks it up on its next periodic DB sync (~30s
in proxy_server.py), so a call issued the instant after the create runs
against a worker that has no guardrail yet and passes the raw value through.
That is in-flight propagation, not a masking failure. Polling to the deadline
waits it out, so the assertions that follow judge the synced state; if the
mask never lands the last unmasked content is returned and they still fail.
"""
deadline = time.monotonic() + POLL_TIMEOUT
last = call()
while time.monotonic() < deadline:
if PLACEHOLDER in last and RAW_EMAIL not in last:
return last
time.sleep(POLL_INTERVAL)
last = call()
return last
class TestPresidioGuardrail:
@ -129,8 +101,10 @@ class TestPresidioGuardrail:
guardrail_id = client.register(name, _presidio_params("pre_call"))
resources.defer(lambda: client.delete_guardrail(guardrail_id))
echoed = _content(
unwrap(client.chat(scoped_key, model, ECHO_REQUEST, guardrails=[name], max_tokens=128))
echoed = _poll_until_masked(
lambda: _content(
unwrap(client.chat(scoped_key, model, ECHO_REQUEST, guardrails=[name], max_tokens=128))
)
)
assert RAW_EMAIL not in echoed, (
"pre_call masking must strip the raw email before the model sees it, but the "
@ -153,8 +127,10 @@ class TestPresidioGuardrail:
guardrail_id = client.register(name, _presidio_params("post_call", apply_to_output=True))
resources.defer(lambda: client.delete_guardrail(guardrail_id))
out = _content(
unwrap(client.chat(scoped_key, model, EMIT_REQUEST, guardrails=[name], max_tokens=128))
out = _poll_until_masked(
lambda: _content(
unwrap(client.chat(scoped_key, model, EMIT_REQUEST, guardrails=[name], max_tokens=128))
)
)
assert RAW_EMAIL not in out, (
"post_call masking must strip PII the model emitted, but the raw email reached the "
@ -163,46 +139,3 @@ class TestPresidioGuardrail:
assert PLACEHOLDER in out, (
f"the masked placeholder should replace the model's PII output, got: {out[:300]!r}"
)
@pytest.mark.covers(
"guardrail.presidio.logging_only.masks",
exercised_on=["chat_completions"],
)
def test_logging_only_masks_the_logged_prompt(
self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str
) -> None:
_require_otel_v2_active(client)
reader = build_otel_reader()
model = client.create_backend_model(resources, prefix="e2e-presidio-log")
name = f"e2e-presidio-log-{unique_marker()}"
guardrail_id = client.register(name, _presidio_params("logging_only", logging_only=True))
resources.defer(lambda: client.delete_guardrail(guardrail_id))
outcome = client.proxy.transport.send(
"/chat/completions",
headers=client.proxy.transport.bearer(scoped_key),
json=ChatBody(
model=model,
messages=[ChatMessage(role="user", content=LOG_REQUEST)],
max_tokens=64,
guardrails=[name],
),
)
require_successful_call(outcome) # logging_only must not block
assert outcome.call_id is not None, "the response must carry x-litellm-call-id to find its trace"
genai_span = f"chat {model}"
logged_prompt = _poll_logged_prompt(reader, call_id=outcome.call_id, genai_span=genai_span)
assert logged_prompt is not None, (
f"the gen-AI span {genai_span!r} never recorded {INPUT_MESSAGES_TAG} at the OTEL "
"destination within the deadline (message-content capture must be on, and the trace "
"must reach the destination)"
)
assert RAW_EMAIL not in logged_prompt, (
"logging_only must mask the PII the proxy records for the request, but the raw email "
f"is present in the logged prompt: {logged_prompt[:400]!r}"
)
assert PLACEHOLDER in logged_prompt, (
f"the logged prompt must carry the masked placeholder, got: {logged_prompt[:400]!r}"
)

View file

@ -84,12 +84,6 @@ OPENAI_VISION_BACKEND = "openai/gpt-4o"
# OpenAI caches a shared prompt prefix once it exceeds ~1024 tokens; this is well
# past that, so a repeat call reports cached prompt tokens.
CACHE_PREFIX = (
"You are a meticulous assistant. Follow these standing instructions exactly. "
* 300
)
def _vision_messages() -> list[ChatMessage]:
return [
ChatMessage(
@ -582,36 +576,6 @@ class TestOpenAIChatCompletions:
response = unwrap(client.proxy.chat(key, ChatBody(model=model, messages=_vision_messages(), max_tokens=32)))
_assert_describes_cat(response)
@pytest.mark.covers(
"llm.chat_completions.openai.prompt_cache_5m.nonstream.works",
exercised_on=["chat_completions"],
)
def test_openai_chat_prompt_cache_hits_on_repeat(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
model = f"e2e-openai-cache-{unique_marker()}"
model_id = client.proxy.create_model(
model, LiteLLMParamsBody(model=OPENAI_BACKEND, api_key="os.environ/OPENAI_API_KEY")
)
resources.defer(lambda: client.proxy.delete_model(model_id))
key = resources.key()
body = ChatBody(
model=model,
messages=[
ChatMessage(role="system", content=CACHE_PREFIX),
ChatMessage(role="user", content="Reply with the single word pong."),
],
max_tokens=16,
)
unwrap(client.proxy.chat(key, body))
second = unwrap(client.proxy.chat(key, body))
details = second.usage.prompt_tokens_details if second.usage else None
assert details and details.cached_tokens and details.cached_tokens > 0, (
f"a repeated large-prefix prompt must report cached prompt tokens, got usage={second.usage}"
)
@pytest.mark.covers(
"llm.chat_completions.openai.tool_use.stream.works",
exercised_on=["chat_completions"],

View file

@ -160,18 +160,6 @@ def test_anthropic_passthrough_tool_call_logs_cost(
assert row.custom_llm_provider == "anthropic"
@pytest.mark.covers("llm.chat_completions.openai.passthrough.nonstream.cost_logged")
def test_openai_passthrough_nonstreaming_logs_cost(
client: PassthroughClient, scoped_key: str
) -> None:
result = client.openai_chat(scoped_key, "gpt-5.4-mini", "Say hello in one word")
require_successful_call(result)
row = _fetch_cost_breakdown(client, result)
assert row.custom_llm_provider == "openai"
assert "gpt-5" in (row.model or "")
class TestPassthroughModelAllowlist:
"""A passthrough route must honor the calling key's model allow-list.

View file

@ -13,6 +13,8 @@ this specific request's header - not a stale or cached one - got there.
from __future__ import annotations
import time
import pytest
from pydantic import BaseModel, Field
@ -78,9 +80,39 @@ def _create_passthrough(client: PassthroughClient, *, path: str) -> PassThroughE
assert created.endpoints, "create returned no endpoints"
endpoint = created.endpoints[0]
assert endpoint.id, "created pass-through endpoint has no id"
_await_route_serving(client, path=path)
return endpoint
def _await_route_serving(client: PassthroughClient, *, path: str) -> None:
"""Block until the data plane routes `path`, instead of 404ing on it.
POST /config/pass_through_endpoint is a control-plane write; the worker that
serves the route only registers it on its next config reload, so a call issued
right after the create gets a bare 404 that looks like a broken route rather
than in-flight propagation. Measured at ~18s on a live proxy.
"""
deadline = time.monotonic() + client.proxy.poll_timeout
while True:
# Any non-404 means the route is registered; this probe deliberately sends
# no anthropic-version so it is rejected upstream rather than billing a
# real completion on every poll.
result = client.proxy.transport.send(
path,
headers=client.proxy.transport.master,
json=_messages_body(),
)
if result.status_code != 404:
return
if time.monotonic() >= deadline:
raise AssertionError(
f"pass-through route {path!r} was created but never became routable on the "
f"data plane within {client.proxy.poll_timeout}s (config reload issue); "
f"last status {result.status_code}: {result.body[:200]}"
)
time.sleep(client.proxy.poll_interval)
def _delete_passthrough(client: PassthroughClient, endpoint_id: str) -> None:
_ = client.proxy.transport.delete(
"/config/pass_through_endpoint",

View file

@ -0,0 +1,173 @@
"""Live /chat/completions streaming through the Responses API bridge.
Responses-only models (gpt-5.3-codex here, the same shape as the GPT-5.6 models
customers reach over bedrock_mantle) cannot serve /chat/completions natively, so the
proxy translates the request to /v1/responses and translates each Responses event back
into a chat completion chunk. Two customer-visible contracts only hold on that path:
- every chunk of one stream carries the same ``id`` (#32854). The bridge builds a chunk
per Responses event, so a regression there hands each chunk a fresh ``chatcmpl-<uuid>``
and SDKs that accumulate by id (openai-go's ChatCompletionAccumulator) silently drop
everything after the first chunk while the HTTP response still looks healthy
- the bridge always answers a streaming request with a real SSE stream (#33154). When it
hands back an already-completed response instead, the proxy's SSE generator dies with
"'async for' requires an object with __aiter__ method" mid-stream
"""
from __future__ import annotations
import pytest
from pydantic import BaseModel
from e2e_config import unique_marker
from e2e_http import StreamingResponse
from lifecycle import ResourceManager
from models import ChatBody, ChatMessage, ChatTool, ChatToolFunction, LiteLLMParamsBody
from passthrough_client import PassthroughClient
pytestmark = pytest.mark.e2e
RESPONSES_ONLY_BACKEND = "openai/gpt-5.3-codex"
class _BridgeToolCallFunction(BaseModel):
name: str | None = None
arguments: str | None = None
class _BridgeToolCall(BaseModel):
function: _BridgeToolCallFunction = _BridgeToolCallFunction()
class _BridgeDelta(BaseModel):
content: str | None = None
tool_calls: list[_BridgeToolCall] | None = None
class _BridgeChoice(BaseModel):
delta: _BridgeDelta = _BridgeDelta()
finish_reason: str | None = None
class _BridgeChunk(BaseModel):
id: str
choices: list[_BridgeChoice] = []
class _WeatherArgs(BaseModel):
location: str
_WEATHER_TOOL = ChatTool(
function=ChatToolFunction(
name="get_weather",
description="Get the current weather for a location",
parameters={
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
)
)
def _bridge_chunks(result: StreamingResponse) -> list[_BridgeChunk]:
"""Parse the SSE events of a bridged stream, failing loudly on a stream that never
established, carried an error event, or delivered no chunks."""
assert result.ok and result.is_streaming, f"bridged stream was not established: {result}"
assert result.stream_error is None, f"bridged stream carried an error event: {result.stream_error}"
chunks = [_BridgeChunk.model_validate_json(event) for event in result.stream_events]
assert chunks, f"bridged stream delivered no chunks: {result.body[:500]}"
return chunks
class TestResponsesBridgeChatCompletionsStreaming:
@pytest.fixture
def bridged_model(self, client: PassthroughClient, resources: ResourceManager) -> str:
model = f"e2e-bridge-stream-{unique_marker()}"
model_id = client.proxy.create_model(
model,
LiteLLMParamsBody(model=RESPONSES_ONLY_BACKEND, api_key="os.environ/OPENAI_API_KEY"),
)
resources.defer(lambda: client.proxy.delete_model(model_id))
return model
@pytest.mark.covers(
"llm.chat_completions.openai.basic.stream.bridge_shares_chunk_id",
exercised_on=["chat_completions"],
)
def test_bridged_stream_shares_one_chunk_id(
self, client: PassthroughClient, resources: ResourceManager, bridged_model: str
) -> None:
result = client.proxy.chat_stream(
resources.key(),
ChatBody(
model=bridged_model,
messages=[ChatMessage(role="user", content=f"Count from 1 to 5, one number per line. {unique_marker()}")],
max_tokens=64,
stream=True,
),
)
chunks = _bridge_chunks(result)
ids = {chunk.id for chunk in chunks}
assert len(ids) == 1, f"bridged stream used {len(ids)} different chunk ids: {sorted(ids)[:5]}"
assert ids.pop().startswith("chatcmpl-"), f"bridged chunk id is not chat-completion shaped: {chunks[0].id}"
@pytest.mark.covers(
"llm.chat_completions.openai.basic.stream.bridge_streams_sse",
exercised_on=["chat_completions"],
)
def test_bridged_stream_delivers_content_finish_reason_and_done(
self, client: PassthroughClient, resources: ResourceManager, bridged_model: str
) -> None:
result = client.proxy.chat_stream(
resources.key(),
ChatBody(
model=bridged_model,
messages=[ChatMessage(role="user", content=f"Reply with the single word pong. {unique_marker()}")],
max_tokens=32,
stream=True,
),
)
chunks = _bridge_chunks(result)
content = "".join(choice.delta.content or "" for chunk in chunks for choice in chunk.choices)
assert content.strip(), f"bridged stream completed with no content deltas: {result.stream_events[:3]}"
assert any(
choice.finish_reason for chunk in chunks for choice in chunk.choices
), f"bridged stream never emitted a finish_reason: {result.stream_events[-3:]}"
assert result.stream_done, f"bridged stream did not terminate with [DONE]: {result.stream_events[-2:]}"
@pytest.mark.covers(
"llm.chat_completions.openai.tool_use.stream.bridge_streams_tool_call",
exercised_on=["chat_completions"],
)
def test_bridged_stream_reassembles_tool_call(
self, client: PassthroughClient, resources: ResourceManager, bridged_model: str
) -> None:
result = client.proxy.chat_stream(
resources.key(),
ChatBody(
model=bridged_model,
messages=[
ChatMessage(
role="user",
content="What is the weather in San Francisco? Use the get_weather tool.",
)
],
tools=[_WEATHER_TOOL],
tool_choice="required",
max_tokens=256,
stream=True,
),
)
chunks = _bridge_chunks(result)
calls = [call for chunk in chunks for choice in chunk.choices for call in (choice.delta.tool_calls or [])]
assert calls, f"bridged stream returned no tool call for a tool-forced prompt: {result.stream_events[:5]}"
name = "".join(call.function.name or "" for call in calls)
arguments = "".join(call.function.arguments or "" for call in calls)
assert name == "get_weather", f"bridged stream streamed the wrong tool name: {name!r}"
args = _WeatherArgs.model_validate_json(arguments)
assert args.location.strip(), f"bridged tool call arguments missing location: {arguments!r}"

View file

@ -1,122 +0,0 @@
"""Live e2e: /v1/responses with store + metadata (LIT-1201 customer path).
Customers attach metadata and store=true, then continue with previous_response_id.
Both turns must succeed, and any Redis keys written for the session must carry a
positive TTL (not unbounded).
"""
from __future__ import annotations
import os
import socket
import time
import pytest
from pydantic import BaseModel, ConfigDict
from e2e_config import unique_marker
from e2e_http import require_successful_call
from endpoints_client import EndpointsClient, ResponsesResult
from lifecycle import ResourceManager
from models import LiteLLMParamsBody
pytestmark = pytest.mark.e2e
class ResponsesMetadataBody(BaseModel):
model: str
input: str
store: bool = True
metadata: dict[str, str]
previous_response_id: str | None = None
instructions: str | None = "You are a helpful assistant."
class RedisKeyInfo(BaseModel):
model_config = ConfigDict(frozen=True)
key: str
ttl: int
def _redis_scan(marker: str) -> tuple[RedisKeyInfo, ...]:
import redis
host = os.environ["REDIS_HOST"]
port = int((os.environ.get("REDIS_PORT") or "6379").strip() or "6379")
try:
with socket.create_connection((host, port), timeout=3):
pass
except OSError as exc:
raise AssertionError(
f"REDIS_HOST={host!r}:{port} unreachable ({exc}); "
"LIT-1201 TTL check needs Redis the proxy writes to."
) from exc
client = redis.Redis(host=host, port=port, decode_responses=True, socket_timeout=5)
found: list[RedisKeyInfo] = []
for key in client.scan_iter(match=f"*{marker}*", count=200):
found.append(RedisKeyInfo(key=str(key), ttl=int(client.ttl(key))))
return tuple(found)
class TestResponsesMetadata:
@pytest.mark.covers(
"llm.responses.openai.basic.nonstream.works",
"other.config.responses.metadata_redis_ttl_bounded",
exercised_on=["responses"],
)
def test_store_metadata_continues_and_redis_keys_have_ttl(
self, endpoints_client: EndpointsClient, resources: ResourceManager
) -> None:
# Anthropic avoids OpenAI/Gemini quota flakes; Responses translation still
# exercises store + metadata + previous_response_id on the proxy.
marker = unique_marker()
model = f"e2e-resp-meta-{marker}"
model_id = endpoints_client.create_model(
model,
LiteLLMParamsBody(
model="anthropic/claude-haiku-4-5-20251001",
api_key="os.environ/ANTHROPIC_API_KEY",
),
)
resources.defer(lambda: endpoints_client.delete_model(model_id))
key = resources.key()
first = endpoints_client.proxy.transport.send(
"/v1/responses",
headers=endpoints_client.proxy.transport.bearer(key),
json=ResponsesMetadataBody(
model=model,
input=f"Remember marker {marker}. Reply with one word.",
metadata={"session_id": marker, "customer": "e2e"},
),
)
require_successful_call(first)
parsed = ResponsesResult.model_validate_json(first.body)
assert parsed.id, f"responses must return an id: {first.body[:300]}"
assert parsed.text.strip(), f"responses returned empty text: {first.body[:300]}"
second = endpoints_client.proxy.transport.send(
"/v1/responses",
headers=endpoints_client.proxy.transport.bearer(key),
json=ResponsesMetadataBody(
model=model,
input="Reply with the single word ok.",
previous_response_id=parsed.id,
metadata={"session_id": marker, "turn": "2"},
),
)
require_successful_call(second)
second_parsed = ResponsesResult.model_validate_json(second.body)
assert second_parsed.text.strip(), (
f"previous_response_id follow-up returned empty text: {second.body[:300]}"
)
time.sleep(1.0)
keys = _redis_scan(marker)
unbounded = tuple(k for k in keys if k.ttl == -1)
assert not unbounded, (
"responses metadata must not leave Redis keys without TTL (LIT-1201); "
f"unbounded={unbounded}"
)

View file

@ -11,12 +11,13 @@ request/response bodies are co-located here because only this suite speaks MCP.
from __future__ import annotations
import time
from collections.abc import Mapping
from dataclasses import dataclass
from pydantic import BaseModel, ConfigDict, Field, RootModel
from e2e_http import Headers, NoBody, Result, unwrap
from e2e_http import Headers, NoBody, Result, Success, unwrap
from models import KeyGenerateBody, ObjectPermission
from proxy_client import ProxyClient
@ -223,6 +224,31 @@ class McpClient:
response_type=McpToolsListResponse,
)
def await_tool(self, key: str, server_id: str, needle: str) -> str:
"""Poll tools/list until `server_id` serves a tool matching `needle`, and
return its fully-qualified name. Fails at poll_timeout.
/v1/mcp/server returns as soon as the DB row is written, but the gateway
runs the initialize + tools/list handshake against the upstream lazily on
the first request that needs it, and reports a server it has not
discovered yet exactly like a dead one: an empty tool list. Waiting is
what separates the two.
"""
deadline = time.monotonic() + self.proxy.poll_timeout
while True:
result = self.list_tools(key)
if isinstance(result, Success):
tool_name = result.data.tool_name_containing(server_id, needle)
if tool_name is not None:
return tool_name
if time.monotonic() >= deadline:
raise AssertionError(
f"server {server_id} never served a tool matching {needle!r} within "
f"{self.proxy.poll_timeout}s of registration (upstream unreachable, or "
f"the key's grant was not applied); last tools/list: {result}"
)
time.sleep(self.proxy.poll_interval)
def register_mcp_content_filter(self, *, name: str, blocked_keyword: str) -> str:
"""Register a default-on content-filter guardrail that runs on the MCP
tool-call hook (pre_mcp_call) and blocks a single keyword. The keyword is

View file

@ -77,12 +77,7 @@ class TestDatadogMcpRoundTrip:
"within the poll deadline; MCP search would have nothing to find"
)
tools = unwrap(client.list_tools(key))
tool_name = tools.tool_name_containing(server_id, SEARCH_LOGS_TOOL)
assert tool_name is not None, (
f"granted key never saw {SEARCH_LOGS_TOOL} on server {server_id}; "
f"tools={tools.tool_names_for_server(server_id)}"
)
tool_name = client.await_tool(key, server_id, SEARCH_LOGS_TOOL)
call = unwrap(
client.call_tool(

View file

@ -22,7 +22,7 @@ import pytest
from datadog_mcp import SEARCH_LOGS_TOOL, assert_dd_mcp_creds, register_datadog_mcp
from e2e_config import DD_SEARCH_FROM, unique_marker
from e2e_http import Result, Success, UnknownApiError, unwrap
from e2e_http import Result, Success, UnknownApiError
from lifecycle import ResourceManager
from mcp_client import McpCallToolResponse, McpClient, McpToolArguments
@ -75,12 +75,7 @@ class TestMcpToolCallGuardrail:
key = client.generate_key(user_id=f"e2e-mcp-guard-{marker}", mcp_servers=[server_id])
resources.defer(lambda: client.proxy.delete_key(key))
tools = unwrap(client.list_tools(key))
tool_name = tools.tool_name_containing(server_id, SEARCH_LOGS_TOOL)
assert tool_name is not None, (
f"granted key never saw {SEARCH_LOGS_TOOL} on server {server_id}; "
f"tools={tools.tool_names_for_server(server_id)}"
)
tool_name = client.await_tool(key, server_id, SEARCH_LOGS_TOOL)
def search(query: str) -> Result[McpCallToolResponse]:
arguments: McpToolArguments = {

View file

@ -48,12 +48,7 @@ class TestMcpKeyWithoutAccessIsDenied:
permitted_key = _key(client, resources, mcp_servers=[server_id])
denied_key = _key(client, resources, mcp_servers=None)
permitted = unwrap(client.list_tools(permitted_key))
tool_name = permitted.tool_name_containing(server_id, SEARCH_LOGS_TOOL)
assert tool_name is not None, (
f"granted key did not see {SEARCH_LOGS_TOOL} (upstream dead or grant not applied): "
f"{permitted.tool_names_for_server(server_id)}"
)
_ = client.await_tool(permitted_key, server_id, SEARCH_LOGS_TOOL)
denied_tools = unwrap(client.list_tools(denied_key)).tool_names_for_server(server_id)
assert denied_tools == frozenset(), (
@ -73,12 +68,7 @@ class TestMcpKeyWithoutAccessIsDenied:
permitted_key = _key(client, resources, mcp_servers=[server_id])
denied_key = _key(client, resources, mcp_servers=None)
permitted = unwrap(client.list_tools(permitted_key))
tool_name = permitted.tool_name_containing(server_id, SEARCH_LOGS_TOOL)
assert tool_name is not None, (
f"granted key did not discover {SEARCH_LOGS_TOOL} (upstream dead or grant not applied): "
f"{permitted.tool_names_for_server(server_id)}"
)
tool_name = client.await_tool(permitted_key, server_id, SEARCH_LOGS_TOOL)
search_args = {
"query": "service:litellm",

View file

@ -28,11 +28,13 @@ class MockPrismaClient:
# Initialize transaction lists
self.spend_log_transactions = []
self.daily_user_spend_transactions = {}
self.tool_usage_transactions = []
# Add lock for spend_log_transactions (matches real PrismaClient)
# Add locks for the transaction queues (matches real PrismaClient)
import asyncio
self._spend_log_transactions_lock = asyncio.Lock()
self._tool_usage_transactions_lock = asyncio.Lock()
def jsonify_object(self, obj):
return obj

View file

@ -203,3 +203,65 @@ async def test_acompletion_preserves_top_level_stream_flag_in_responses_request(
assert result is stream
assert transform_request.call_args.kwargs["optional_params"]["stream"] is True
def _completed_chat_response() -> ModelResponse:
return ModelResponse(
id="chatcmpl-completed",
model="gpt-5.4",
choices=[
{
"index": 0,
"message": {"role": "assistant", "content": "pong"},
"finish_reason": "stop",
}
],
)
@pytest.mark.asyncio
async def test_acompletion_streams_completed_model_response():
"""A streaming request whose bridge call comes back already completed must still be
handed back as an async-iterable stream. Returning the bare ModelResponse crashed the
proxy's SSE generator with "'async for' requires an object with __aiter__ method".
Regression for #33154."""
completed = _completed_chat_response()
bridge = ResponsesToCompletionBridgeHandler()
with (
patch.object(
bridge.transformation_handler,
"transform_request",
return_value={"model": "gpt-5.4", "input": "hi"},
),
patch("litellm.aresponses", new=AsyncMock(return_value=completed)),
):
result = await bridge.acompletion(**_bridge_kwargs(stream=True))
assert isinstance(result, CustomStreamWrapper), f"streaming request got {type(result)}"
chunks = [chunk async for chunk in result]
assert "".join(
chunk.choices[0].delta.content or "" for chunk in chunks
) == "pong", f"completed response did not stream its content: {chunks}"
assert [c for c in chunks if c.choices[0].finish_reason], "stream never emitted a finish_reason"
def test_completion_streams_completed_model_response():
completed = _completed_chat_response()
bridge = ResponsesToCompletionBridgeHandler()
with (
patch.object(
bridge.transformation_handler,
"transform_request",
return_value={"model": "gpt-5.4", "input": "hi"},
),
patch("litellm.responses", return_value=completed),
):
result = bridge.completion(**_bridge_kwargs(stream=True))
assert isinstance(result, CustomStreamWrapper), f"streaming request got {type(result)}"
chunks = list(result)
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks) == "pong", (
f"completed response did not stream its content: {chunks}"
)

View file

@ -2855,6 +2855,41 @@ def test_streaming_function_call_tool_id_for_degenerate_call_id():
assert stream_tool_id("fc_2", "call_tokyo") == "call_tokyo"
def test_streaming_chunks_share_one_chat_completion_id():
"""Every chunk of one streamed chat completion must carry the same ``id``, per the
OpenAI spec. The bridge builds a fresh ``ModelResponseStream`` per Responses event,
so without a stream-scoped id each chunk got a new ``chatcmpl-<uuid>`` and clients
that validate id consistency (openai-go's ChatCompletionAccumulator) silently
dropped every chunk after the first. Regression for #32854."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
OpenAiResponsesToChatCompletionStreamIterator,
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(
streaming_response=None, sync_stream=True
)
events = [
{"type": "response.created", "response": {"id": "resp_abc", "output": []}},
{"type": "response.output_text.delta", "delta": "Hel"},
{"type": "response.output_text.delta", "delta": "lo"},
{
"type": "response.completed",
"response": {"id": "resp_abc", "output": [{"type": "message"}]},
},
]
ids = [iterator.chunk_parser(event).id for event in events]
assert len(set(ids)) == 1, f"streamed chunks carried different ids: {ids}"
assert ids[0], "streamed chunks carried an empty id"
other_stream = OpenAiResponsesToChatCompletionStreamIterator(
streaming_response=None, sync_stream=True
)
assert (
other_stream.chunk_parser(events[1]).id != ids[0]
), "a separate stream must get its own id, not a process-wide one"
@pytest.mark.asyncio
@pytest.mark.parametrize(

View file

@ -347,3 +347,90 @@ class TestLangsmithRedactUserApiKeyInfo:
assert "user_api_key_user_id" not in nested
assert nested["session_id"] == "sess-1"
assert extra["session_id"] == "sess-1"
def test_redact_enabled_strips_user_api_key_info_from_inputs(self, reset_redact_flag):
"""
Regression (LIT-4306): `inputs` is the whole StandardLoggingPayload, so
`redact_user_api_key_info` has to cover `inputs.metadata` the same way it
covers `extra` - including the nested `requester_metadata` copy. Before
the fix `extra` was redacted and `inputs` shipped every user_api_key_*
field verbatim.
"""
litellm.redact_user_api_key_info = True
logger = self._logger()
metadata = self._metadata_with_user_api_key_fields()
metadata["user_api_key_auth_metadata"] = {"priority": "high"}
payload = {
"id": "run-1",
"response": {"choices": []},
"metadata": metadata,
"startTime": 1.0,
"endTime": 2.0,
"request_tags": [],
"error_str": None,
"status": "success",
"response_cost": 0.0,
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
}
credentials = {
"LANGSMITH_API_KEY": "test-key",
"LANGSMITH_PROJECT": "test-project",
"LANGSMITH_BASE_URL": "https://api.smith.langchain.com",
}
data = logger._prepare_log_data(
kwargs={"litellm_params": {"metadata": metadata}, "standard_logging_object": payload},
response_obj=None,
start_time=1.0,
end_time=2.0,
credentials=credentials,
)
inputs_metadata = data["inputs"]["metadata"]
assert [k for k in inputs_metadata if k.startswith("user_api_key")] == []
assert [k for k in inputs_metadata["requester_metadata"] if k.startswith("user_api_key")] == []
# inputs and extra must agree - they go through the same redaction now
assert [k for k in data["extra"] if k.startswith("user_api_key")] == []
# non-identity payload is untouched
assert inputs_metadata["model"] == "gpt-4"
assert inputs_metadata["requester_metadata"]["session_id"] == "sess-1"
assert data["inputs"]["total_tokens"] == 2
# the shared standard_logging_object other loggers read is not mutated
assert "user_api_key_hash" in payload["metadata"]
assert "user_api_key_user_id" in payload["metadata"]["requester_metadata"]
def test_redact_disabled_keeps_user_api_key_info_in_inputs(self, reset_redact_flag):
"""Flag off: the identity fields stay. The flag governs them, not this fix."""
litellm.redact_user_api_key_info = False
logger = self._logger()
metadata = self._metadata_with_user_api_key_fields()
payload = {
"id": "run-1",
"response": {"choices": []},
"metadata": metadata,
"startTime": 1.0,
"endTime": 2.0,
"request_tags": [],
"error_str": None,
"status": "success",
"response_cost": 0.0,
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
}
data = logger._prepare_log_data(
kwargs={"litellm_params": {"metadata": metadata}, "standard_logging_object": payload},
response_obj=None,
start_time=1.0,
end_time=2.0,
credentials={
"LANGSMITH_API_KEY": "test-key",
"LANGSMITH_PROJECT": "test-project",
"LANGSMITH_BASE_URL": "https://api.smith.langchain.com",
},
)
assert data["inputs"]["metadata"]["user_api_key_hash"] == "abc123"

View file

@ -258,6 +258,158 @@ class TestPrometheusCacheMetrics:
# Should not emit read metric, because explicit provider value is zero.
mock_logger.litellm_provider_cache_read_input_tokens_metric.labels.assert_not_called()
def test_provider_cache_creation_fallback_to_cache_write_tokens(
self, sample_enum_values
):
"""OpenAI-style usage (prompt_tokens_details.cache_write_tokens, no top-level
cache_creation_input_tokens) must populate the provider cache creation metric."""
mock_logger = MagicMock()
from litellm.integrations.prometheus import PrometheusLogger
standard_logging_payload = {
"cache_hit": False,
"total_tokens": 12100,
"prompt_tokens": 12000,
"completion_tokens": 100,
"model_group": "openai",
"request_tags": [],
"metadata": {
"usage_object": {
"prompt_tokens_details": {
"cached_tokens": 0,
"cache_write_tokens": 800,
},
}
},
}
mock_logger.litellm_cache_hits_metric = MagicMock()
mock_logger.litellm_cache_misses_metric = MagicMock()
mock_logger.litellm_cached_tokens_metric = MagicMock()
mock_logger.litellm_provider_cache_read_input_tokens_metric = MagicMock()
mock_logger.litellm_provider_cache_creation_input_tokens_metric = MagicMock()
mock_logger.get_labels_for_metric = MagicMock(
return_value=[
"model",
"hashed_api_key",
"api_key_alias",
"team",
"team_alias",
"end_user",
"user",
]
)
PrometheusLogger._increment_cache_metrics(
mock_logger,
standard_logging_payload=standard_logging_payload,
enum_values=sample_enum_values,
)
mock_logger.litellm_provider_cache_creation_input_tokens_metric.labels().inc.assert_called_once_with(
800
)
def test_provider_cache_creation_fallback_to_cache_creation_tokens(
self, sample_enum_values
):
"""Normalized litellm usage dumps carry cache_creation_tokens in
prompt_tokens_details; the fallback must read it when cache_write_tokens is absent."""
mock_logger = MagicMock()
from litellm.integrations.prometheus import PrometheusLogger
standard_logging_payload = {
"cache_hit": False,
"total_tokens": 100,
"prompt_tokens": 50,
"completion_tokens": 50,
"model_group": "openai",
"request_tags": [],
"metadata": {
"usage_object": {
"prompt_tokens_details": {"cache_creation_tokens": 42},
}
},
}
mock_logger.litellm_cache_hits_metric = MagicMock()
mock_logger.litellm_cache_misses_metric = MagicMock()
mock_logger.litellm_cached_tokens_metric = MagicMock()
mock_logger.litellm_provider_cache_read_input_tokens_metric = MagicMock()
mock_logger.litellm_provider_cache_creation_input_tokens_metric = MagicMock()
mock_logger.get_labels_for_metric = MagicMock(
return_value=[
"model",
"hashed_api_key",
"api_key_alias",
"team",
"team_alias",
"end_user",
"user",
]
)
PrometheusLogger._increment_cache_metrics(
mock_logger,
standard_logging_payload=standard_logging_payload,
enum_values=sample_enum_values,
)
mock_logger.litellm_provider_cache_creation_input_tokens_metric.labels().inc.assert_called_once_with(
42
)
def test_provider_cache_creation_does_not_fallback_on_explicit_zero(
self, sample_enum_values
):
"""Explicit cache_creation_input_tokens=0 must not trigger fallback to
prompt_tokens_details, mirroring the cache-read semantics."""
mock_logger = MagicMock()
from litellm.integrations.prometheus import PrometheusLogger
standard_logging_payload = {
"cache_hit": False,
"total_tokens": 100,
"prompt_tokens": 50,
"completion_tokens": 50,
"model_group": "openai",
"request_tags": [],
"metadata": {
"usage_object": {
"cache_creation_input_tokens": 0,
"prompt_tokens_details": {"cache_write_tokens": 800},
}
},
}
mock_logger.litellm_cache_hits_metric = MagicMock()
mock_logger.litellm_cache_misses_metric = MagicMock()
mock_logger.litellm_cached_tokens_metric = MagicMock()
mock_logger.litellm_provider_cache_read_input_tokens_metric = MagicMock()
mock_logger.litellm_provider_cache_creation_input_tokens_metric = MagicMock()
mock_logger.get_labels_for_metric = MagicMock(
return_value=[
"model",
"hashed_api_key",
"api_key_alias",
"team",
"team_alias",
"end_user",
"user",
]
)
PrometheusLogger._increment_cache_metrics(
mock_logger,
standard_logging_payload=standard_logging_payload,
enum_values=sample_enum_values,
)
mock_logger.litellm_provider_cache_creation_input_tokens_metric.labels.assert_not_called()
def test_increment_cache_metrics_when_cache_hit_is_none(self, sample_enum_values):
"""Test that no metrics are incremented when cache_hit is None"""
# Create mock for PrometheusLogger instance

View file

@ -150,6 +150,57 @@ class TestIncrementTokenDetailMetrics:
10.0
)
def test_cache_creation_falls_back_to_cache_write_tokens(self, sample_enum_values):
logger = _make_mock_logger()
payload = {
"metadata": {
"usage_object": {
"prompt_tokens": 12000,
"completion_tokens": 100,
"total_tokens": 12100,
"prompt_tokens_details": {
"cached_tokens": 0,
"cache_write_tokens": 800,
},
}
},
}
PrometheusLogger._increment_token_detail_metrics(
logger,
standard_logging_payload=payload,
enum_values=sample_enum_values,
)
logger.litellm_input_cache_creation_tokens_metric.labels().inc.assert_called_once_with(
800.0
)
def test_cache_write_tokens_takes_precedence_over_cache_creation_tokens(
self, sample_enum_values
):
logger = _make_mock_logger()
payload = {
"metadata": {
"usage_object": {
"prompt_tokens_details": {
"cache_creation_tokens": 25,
"cache_write_tokens": 800,
},
}
},
}
PrometheusLogger._increment_token_detail_metrics(
logger,
standard_logging_payload=payload,
enum_values=sample_enum_values,
)
logger.litellm_input_cache_creation_tokens_metric.labels().inc.assert_called_once_with(
800.0
)
def test_skips_metrics_when_value_is_zero(self, sample_enum_values):
logger = _make_mock_logger()
payload = {

View file

@ -3166,3 +3166,34 @@ async def test_bedrock_converse_message_level_cache_point_preserves_ttl_async():
)
assert _collect_cache_points(result) == [{"type": "default", "ttl": "1h"}]
def _n_choices_response(*names_per_choice):
from types import SimpleNamespace
choices = [
SimpleNamespace(
message=SimpleNamespace(
tool_calls=[SimpleNamespace(id=f"c{i}", function=SimpleNamespace(name=name, arguments="{}"))]
)
)
for i, name in enumerate(names_per_choice)
]
return SimpleNamespace(choices=choices)
def test_get_tool_calls_from_response_defaults_to_primary_choice_only():
from litellm.litellm_core_utils.prompt_templates.factory import get_tool_calls_from_response
response = _n_choices_response("tool_alpha", "tool_beta")
assert [tc["name"] for tc in get_tool_calls_from_response(response)] == ["tool_alpha"]
def test_get_tool_calls_from_response_include_all_choices_reads_every_choice():
from litellm.litellm_core_utils.prompt_templates.factory import get_tool_calls_from_response
response = _n_choices_response("tool_alpha", "tool_beta")
names = [tc["name"] for tc in get_tool_calls_from_response(response, include_all_choices=True)]
assert names == ["tool_alpha", "tool_beta"]

View file

@ -252,9 +252,7 @@ def test_transform_cancel_eval_response(config: OpenAIEvalsConfig):
"object": "eval",
"status": "cancelled",
},
request=httpx.Request(
"POST", "https://api.openai.com/v1/evals/eval_123/cancel"
),
request=httpx.Request("POST", "https://api.openai.com/v1/evals/eval_123/cancel"),
)
result = config.transform_cancel_eval_response(
@ -276,8 +274,169 @@ def test_transform_run_requests_encode_eval_and_run_ids(config: OpenAIEvalsConfi
headers={},
)
assert (
url
== "https://api.openai.com/v1/evals/..%2F..%2Fevals%3Fx%3D1%23frag/runs/..%2Fruns%23other/cancel"
)
assert url == "https://api.openai.com/v1/evals/..%2F..%2Fevals%3Fx%3D1%23frag/runs/..%2Fruns%23other/cancel"
assert request_body == {}
def _eval_json_response(url: str, method: str = "GET") -> httpx.Response:
return httpx.Response(
status_code=200,
json={
"id": "eval_123",
"object": "eval",
"created_at": 1234567890,
"name": "Test Eval",
"data_source_config": {"type": "stored_completions"},
"testing_criteria": [],
},
request=httpx.Request(method, url),
)
def _run_json(run_id: str = "evalrun_123", status: str = "queued") -> dict:
return {
"id": run_id,
"object": "eval.run",
"created_at": 1234567890,
"status": status,
"data_source": {"type": "completions"},
"eval_id": "eval_123",
}
def test_transform_get_eval_response(config: OpenAIEvalsConfig):
result = config.transform_get_eval_response(
raw_response=_eval_json_response("https://api.openai.com/v1/evals/eval_123"),
logging_obj=None,
)
assert result.id == "eval_123"
assert result.object == "eval"
assert result.name == "Test Eval"
def test_transform_update_eval_response(config: OpenAIEvalsConfig):
result = config.transform_update_eval_response(
raw_response=_eval_json_response("https://api.openai.com/v1/evals/eval_123", method="POST"),
logging_obj=None,
)
assert result.id == "eval_123"
assert result.name == "Test Eval"
def test_transform_create_run_response(config: OpenAIEvalsConfig):
response = httpx.Response(
status_code=200,
json=_run_json(),
request=httpx.Request("POST", "https://api.openai.com/v1/evals/eval_123/runs"),
)
result = config.transform_create_run_response(
raw_response=response,
logging_obj=None,
)
assert result.id == "evalrun_123"
assert result.status == "queued"
assert result.eval_id == "eval_123"
def test_transform_list_runs_request(config: OpenAIEvalsConfig):
url, query_params = config.transform_list_runs_request(
eval_id="eval_123",
list_params={"limit": 5, "after": "evalrun_1", "order": "asc"},
litellm_params=GenericLiteLLMParams(api_base="https://api.openai.com"),
headers={},
)
assert url == "https://api.openai.com/v1/evals/eval_123/runs"
assert query_params == {"limit": 5, "after": "evalrun_1", "order": "asc"}
def test_transform_list_runs_response(config: OpenAIEvalsConfig):
response = httpx.Response(
status_code=200,
json={
"object": "list",
"data": [_run_json()],
"first_id": "evalrun_123",
"last_id": "evalrun_123",
"has_more": False,
},
request=httpx.Request("GET", "https://api.openai.com/v1/evals/eval_123/runs"),
)
result = config.transform_list_runs_response(
raw_response=response,
logging_obj=None,
)
assert result.object == "list"
assert len(result.data) == 1
assert result.data[0].id == "evalrun_123"
assert result.has_more is False
def test_transform_get_run_response(config: OpenAIEvalsConfig):
response = httpx.Response(
status_code=200,
json=_run_json(status="completed"),
request=httpx.Request("GET", "https://api.openai.com/v1/evals/eval_123/runs/evalrun_123"),
)
result = config.transform_get_run_response(
raw_response=response,
logging_obj=None,
)
assert result.id == "evalrun_123"
assert result.status == "completed"
def test_transform_cancel_run_response(config: OpenAIEvalsConfig):
response = httpx.Response(
status_code=200,
json={"id": "evalrun_123", "object": "eval.run", "status": "cancelled"},
request=httpx.Request(
"POST",
"https://api.openai.com/v1/evals/eval_123/runs/evalrun_123/cancel",
),
)
result = config.transform_cancel_run_response(
raw_response=response,
logging_obj=None,
)
assert result.id == "evalrun_123"
assert result.status == "cancelled"
def test_transform_delete_run_request(config: OpenAIEvalsConfig):
url, headers, request_body = config.transform_delete_run_request(
eval_id="eval_123",
run_id="evalrun_123",
api_base="https://api.openai.com",
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert url == "https://api.openai.com/v1/evals/eval_123/runs/evalrun_123"
assert request_body == {}
def test_transform_delete_run_response(config: OpenAIEvalsConfig):
response = httpx.Response(
status_code=200,
json={"run_id": "evalrun_123", "object": "eval.run.deleted", "deleted": True},
request=httpx.Request("DELETE", "https://api.openai.com/v1/evals/eval_123/runs/evalrun_123"),
)
result = config.transform_delete_run_response(
raw_response=response,
logging_obj=None,
)
assert result.run_id == "evalrun_123"
assert result.deleted is True

View file

@ -4,9 +4,11 @@ Tests for Volcengine Responses API transformation.
import os
import sys
from typing import List, Literal, Optional, Union
import httpx
import pytest
from pydantic import BaseModel, Field
sys.path.insert(0, os.path.abspath("../../../../.."))
@ -32,12 +34,10 @@ class TestVolcengineResponsesAPITransformation:
)
assert config is not None, "Config should not be None for Volcengine provider"
assert isinstance(
config, VolcEngineResponsesAPIConfig
), f"Expected VolcEngineResponsesAPIConfig, got {type(config)}"
assert (
config.custom_llm_provider == LlmProviders.VOLCENGINE
), "custom_llm_provider should be VOLCENGINE"
assert isinstance(config, VolcEngineResponsesAPIConfig), (
f"Expected VolcEngineResponsesAPIConfig, got {type(config)}"
)
assert config.custom_llm_provider == LlmProviders.VOLCENGINE, "custom_llm_provider should be VOLCENGINE"
def test_parallel_tool_calls_dropped(self):
"""Volcengine does not list parallel_tool_calls; ensure it is removed."""
@ -54,9 +54,7 @@ class TestVolcengineResponsesAPITransformation:
drop_params=False,
)
assert (
"parallel_tool_calls" not in mapped
), "parallel_tool_calls must be dropped"
assert "parallel_tool_calls" not in mapped, "parallel_tool_calls must be dropped"
assert mapped.get("temperature") == 0.5
assert "metadata" not in mapped, "Undocumented params should not be included"
@ -91,14 +89,10 @@ class TestVolcengineResponsesAPITransformation:
default_url = config.get_complete_url(api_base=None, litellm_params={})
assert default_url == "https://ark.cn-beijing.volces.com/api/v3/responses"
api_base_with_api = config.get_complete_url(
api_base="https://custom.volc.com/api/v3", litellm_params={}
)
api_base_with_api = config.get_complete_url(api_base="https://custom.volc.com/api/v3", litellm_params={})
assert api_base_with_api == "https://custom.volc.com/api/v3/responses"
api_base_full = config.get_complete_url(
api_base="https://custom.volc.com/api/v3/responses", litellm_params={}
)
api_base_full = config.get_complete_url(api_base="https://custom.volc.com/api/v3/responses", litellm_params={})
assert api_base_full == "https://custom.volc.com/api/v3/responses"
def test_response_id_path_requests_encode_response_id(self):
@ -112,10 +106,7 @@ class TestVolcengineResponsesAPITransformation:
headers={},
)
assert (
url
== "https://custom.volc.com/api/v3/responses/..%2F..%2Fresponses%2Fother%3Fx%3D1%23frag/cancel"
)
assert url == "https://custom.volc.com/api/v3/responses/..%2F..%2Fresponses%2Fother%3Fx%3D1%23frag/cancel"
assert params == {}
@pytest.mark.parametrize(
@ -125,9 +116,7 @@ class TestVolcengineResponsesAPITransformation:
(GenericLiteLLMParams(api_key="attr-key"), "attr-key"),
],
)
def test_validate_environment_uses_api_key(
self, monkeypatch, litellm_params, expected_key
):
def test_validate_environment_uses_api_key(self, monkeypatch, litellm_params, expected_key):
"""validate_environment should pull api key from params/env and attach headers."""
config = VolcEngineResponsesAPIConfig()
@ -135,9 +124,7 @@ class TestVolcengineResponsesAPITransformation:
monkeypatch.delenv("ARK_API_KEY", raising=False)
monkeypatch.delenv("VOLCENGINE_API_KEY", raising=False)
headers = config.validate_environment(
headers={}, model="volcengine/demo-model", litellm_params=litellm_params
)
headers = config.validate_environment(headers={}, model="volcengine/demo-model", litellm_params=litellm_params)
assert headers.get("Authorization") == f"Bearer {expected_key}"
assert headers.get("Content-Type") == "application/json"
@ -151,9 +138,7 @@ class TestVolcengineResponsesAPITransformation:
monkeypatch.delenv("VOLCENGINE_API_KEY", raising=False)
with pytest.raises(ValueError):
config.validate_environment(
headers={}, model="volcengine/demo", litellm_params={}
)
config.validate_environment(headers={}, model="volcengine/demo", litellm_params={})
def test_unsupported_params_are_dropped_with_extra_body(self):
"""Unknown fields (including extra_body) should be dropped before send."""
@ -240,9 +225,7 @@ class TestVolcengineResponsesAPITransformation:
# Use class name comparison instead of isinstance to avoid issues with
# module reloading during parallel test execution (conftest reloads litellm)
assert (
type(error).__name__ == "VolcEngineError"
), f"Expected VolcEngineError, got {type(error).__name__}"
assert type(error).__name__ == "VolcEngineError", f"Expected VolcEngineError, got {type(error).__name__}"
assert error.status_code == 400
assert error.message == "bad request"
assert error.headers.get("x") == "y"
@ -296,3 +279,206 @@ class TestVolcengineResponsesAPITransformation:
assert isinstance(result, DeleteResponseResult)
assert result.deleted is True
def test_transform_streaming_response_fills_missing_required_fields(self):
config = VolcEngineResponsesAPIConfig()
event = config.transform_streaming_response(
model="volcengine/demo-model",
parsed_chunk={"type": "response.completed", "response": {"id": "resp_1"}},
logging_obj=None,
)
assert type(event).__name__ == "ResponseCompletedEvent"
assert event.type == "response.completed"
assert event.response.id == "resp_1"
assert event.response.output == []
assert event.response.created_at == 0
def test_transform_response_api_response_falls_back_to_model_construct(self):
config = VolcEngineResponsesAPIConfig()
http_response = httpx.Response(
status_code=200,
json={"id": "resp_fallback", "created_at": 123, "output": "not-a-list"},
request=httpx.Request("POST", "https://example.com/responses"),
headers={"x-test": "1"},
)
result = config.transform_response_api_response(
model="volcengine/demo-model",
raw_response=http_response,
logging_obj=type(
"Logger",
(),
{"post_call": staticmethod(lambda **kwargs: None)},
),
)
assert result.id == "resp_fallback"
assert result.output == "not-a-list"
assert result._hidden_params["headers"].get("x-test") == "1"
def test_transform_delete_response_api_request_builds_url(self):
config = VolcEngineResponsesAPIConfig()
url, data = config.transform_delete_response_api_request(
response_id="resp_123",
api_base="https://custom.volc.com/api/v3/responses",
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert url == "https://custom.volc.com/api/v3/responses/resp_123"
assert data == {}
def test_transform_get_response_api_request_and_response(self):
config = VolcEngineResponsesAPIConfig()
url, data = config.transform_get_response_api_request(
response_id="resp 123",
api_base="https://custom.volc.com/api/v3/responses",
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert url == "https://custom.volc.com/api/v3/responses/resp%20123"
assert data == {}
http_response = httpx.Response(
status_code=200,
json={
"id": "resp_123",
"object": "response",
"created_at": 123,
"status": "completed",
"output": [],
"model": "demo-model",
},
request=httpx.Request("GET", url),
headers={"x-test": "1"},
)
result = config.transform_get_response_api_response(
raw_response=http_response,
logging_obj=None,
)
assert result.id == "resp_123"
assert result._hidden_params["headers"].get("x-test") == "1"
def test_transform_cancel_response_api_response_parses_json(self):
config = VolcEngineResponsesAPIConfig()
http_response = httpx.Response(
status_code=200,
json={
"id": "resp_123",
"object": "response",
"created_at": 123,
"status": "cancelled",
"output": [],
"model": "demo-model",
},
request=httpx.Request("POST", "https://example.com/responses/resp_123/cancel"),
headers={"x-test": "1"},
)
result = config.transform_cancel_response_api_response(
raw_response=http_response,
logging_obj=None,
)
assert result.id == "resp_123"
assert result.status == "cancelled"
assert result._hidden_params["headers"].get("x-test") == "1"
def test_transform_list_input_items_request_builds_query_params(self):
config = VolcEngineResponsesAPIConfig()
url, params = config.transform_list_input_items_request(
response_id="resp_123",
api_base="https://custom.volc.com/api/v3/responses",
litellm_params=GenericLiteLLMParams(),
headers={},
after="item_a",
before="item_b",
include=["metadata", "usage"],
limit=5,
order="asc",
)
assert url == "https://custom.volc.com/api/v3/responses/resp_123/input_items"
assert params == {
"after": "item_a",
"before": "item_b",
"include": "metadata,usage",
"limit": 5,
"order": "asc",
}
def test_transform_list_input_items_response_returns_parsed_body(self):
config = VolcEngineResponsesAPIConfig()
payload = {"object": "list", "data": [{"id": "item_1"}]}
http_response = httpx.Response(
status_code=200,
json=payload,
request=httpx.Request("GET", "https://example.com/responses/resp_123/input_items"),
)
result = config.transform_list_input_items_response(
raw_response=http_response,
logging_obj=None,
)
assert result == payload
class _FillWidget(BaseModel):
type: Literal["widget"]
count: int
parts: List[str]
label: Optional[str]
class _FillGadget(BaseModel):
type: Literal["gadget"]
name: str
class _FillEnvelope(BaseModel):
kind: str = "envelope"
tags: List[str] = Field(default_factory=lambda: ["default-tag"])
payload: Union[_FillWidget, _FillGadget]
entries: List[_FillWidget]
note: Optional[str]
values: Union[List[str], str]
class TestVolcengineStreamingFieldFill:
def test_fill_uses_defaults_factories_and_heuristics(self):
filled = VolcEngineResponsesAPIConfig._fill_missing_fields(
{"payload": {"type": "gadget", "name": "g"}, "entries": [{"type": "widget"}]},
_FillEnvelope,
)
assert filled["kind"] == "envelope"
assert filled["tags"] == ["default-tag"]
assert filled["note"] is None
assert filled["values"] == []
validated = _FillEnvelope.model_validate(filled)
assert isinstance(validated.payload, _FillGadget)
assert validated.entries[0].count == 0
assert validated.entries[0].parts == []
assert validated.entries[0].label is None
def test_fill_selects_union_member_by_type_literal(self):
filled = VolcEngineResponsesAPIConfig._fill_missing_fields(
{"payload": {"type": "widget"}, "entries": []},
_FillEnvelope,
)
validated = _FillEnvelope.model_validate(filled)
assert isinstance(validated.payload, _FillWidget)
assert validated.payload.count == 0
assert validated.payload.parts == []
assert validated.payload.label is None

View file

@ -18,6 +18,7 @@ from litellm.proxy.common_utils.callback_utils import (
get_remaining_tokens_and_requests_from_request_data,
normalize_callback_names,
sanitize_openai_provider_metadata,
strip_callback_config,
)
import litellm
@ -452,3 +453,41 @@ def test_initialize_callbacks_on_proxy_non_dict_callback_specific_params_root(
)
finally:
litellm.callbacks = original_callbacks
def test_strip_callback_config_drops_credential_bearing_slots():
"""
`logging` and `callback_settings` hold operator-configured integration
credentials. Both must be dropped from the key/team metadata the proxy
stamps into request metadata, while every other field survives untouched
(`priority` is read back by the dynamic rate limiter, `guardrails` by the
guardrail hooks).
"""
metadata = {
"logging": [
{
"callback_name": "langsmith",
"callback_vars": {"langsmith_api_key": "litellm_enc::ciphertext"},
}
],
"callback_settings": {"callback_vars": {"langfuse_secret_key": "litellm_enc::other"}},
"priority": "high",
"guardrails": ["presidio"],
"langsmith_provisioning": {"api_key_id": "prov-1"},
}
stripped = strip_callback_config(metadata)
assert "logging" not in stripped
assert "callback_settings" not in stripped
assert stripped["priority"] == "high"
assert stripped["guardrails"] == ["presidio"]
assert stripped["langsmith_provisioning"] == {"api_key_id": "prov-1"}
# the caller's dict (UserAPIKeyAuth.metadata) is shared state - never mutate it
assert "logging" in metadata
assert "callback_settings" in metadata
@pytest.mark.parametrize("value", [None, "not-a-dict", 42])
def test_strip_callback_config_passes_through_non_dicts(value):
assert strip_callback_config(value) is value

View file

@ -76,6 +76,162 @@ async def test_daily_spend_tracking_with_disabled_spend_logs():
assert call_args["payload"]["custom_llm_provider"] == "openai"
def _tool_call_response(*names: str) -> object:
from types import SimpleNamespace
tool_calls = [SimpleNamespace(function=SimpleNamespace(name=name)) for name in names]
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(tool_calls=tool_calls))])
def _tool_usage_prisma() -> MagicMock:
prisma = MagicMock()
prisma.tool_usage_transactions = []
prisma._tool_usage_transactions_lock = asyncio.Lock()
prisma.spend_log_transactions = []
prisma._spend_log_transactions_lock = asyncio.Lock()
return prisma
def _minimal_spend_payload() -> dict:
return {
"request_id": "req-tool-1",
"startTime": datetime(2026, 7, 25, 10, 0, tzinfo=timezone.utc),
"endTime": datetime(2026, 7, 25, 10, 0, 1, tzinfo=timezone.utc),
"spend": 0.0,
"total_tokens": 42,
"mcp_namespaced_tool_name": None,
}
@pytest.mark.asyncio
async def test_update_database_enqueues_tool_usage_for_invoked_tools():
db_writer = DBSpendUpdateWriter()
db_writer._insert_spend_log_to_db = AsyncMock()
db_writer._batch_database_updates = AsyncMock()
prisma = _tool_usage_prisma()
with (
patch("litellm.proxy.proxy_server.disable_spend_logs", False),
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.litellm_proxy_budget_name", "test-budget"),
patch(
"litellm.proxy.spend_tracking.spend_tracking_utils.get_logging_payload",
return_value=_minimal_spend_payload(),
),
):
await db_writer.update_database(
token="test-token",
user_id="test-user",
end_user_id=None,
team_id=None,
org_id=None,
kwargs={"model": "gpt-4"},
completion_response=_tool_call_response("get_weather"),
start_time=datetime.now(timezone.utc),
end_time=datetime.now(timezone.utc),
response_cost=0.1,
)
await asyncio.sleep(0)
assert len(prisma.tool_usage_transactions) == 1
transaction = prisma.tool_usage_transactions[0]
assert transaction.request_id == "req-tool-1"
assert transaction.tool_names == ("get_weather",)
assert transaction.spend == 0.1
assert transaction.total_tokens == 42
assert transaction.date == "2026-07-25"
@pytest.mark.asyncio
async def test_update_database_enqueues_realtime_tool_usage():
db_writer = DBSpendUpdateWriter()
db_writer._insert_spend_log_to_db = AsyncMock()
db_writer._batch_database_updates = AsyncMock()
prisma = _tool_usage_prisma()
with (
patch("litellm.proxy.proxy_server.disable_spend_logs", False),
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.litellm_proxy_budget_name", "test-budget"),
patch(
"litellm.proxy.spend_tracking.spend_tracking_utils.get_logging_payload",
return_value=_minimal_spend_payload(),
),
):
await db_writer.update_database(
token="test-token",
user_id="test-user",
end_user_id=None,
team_id=None,
org_id=None,
kwargs={
"model": "gpt-realtime",
"realtime_tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "rt_tool", "arguments": "{}"}}
],
},
completion_response=None,
start_time=datetime.now(timezone.utc),
end_time=datetime.now(timezone.utc),
response_cost=0.2,
)
await asyncio.sleep(0)
assert len(prisma.tool_usage_transactions) == 1
assert prisma.tool_usage_transactions[0].tool_names == ("rt_tool",)
def test_enqueue_tool_registry_upsert_reads_every_choice():
from types import SimpleNamespace as NS
db_writer = DBSpendUpdateWriter()
db_writer.tool_discovery_queue = MagicMock()
response = NS(
choices=[
NS(message=NS(tool_calls=[NS(function=NS(name="tool_alpha"))])),
NS(message=NS(tool_calls=[NS(function=NS(name="tool_beta"))])),
]
)
db_writer._enqueue_tool_registry_upsert(kwargs={}, completion_response=response)
enqueued = [call.args[0]["tool_name"] for call in db_writer.tool_discovery_queue.add_update.call_args_list]
assert enqueued == ["tool_alpha", "tool_beta"]
@pytest.mark.asyncio
async def test_update_database_skips_tool_usage_when_spend_logs_disabled():
db_writer = DBSpendUpdateWriter()
db_writer._insert_spend_log_to_db = AsyncMock()
db_writer._batch_database_updates = AsyncMock()
prisma = _tool_usage_prisma()
with (
patch("litellm.proxy.proxy_server.disable_spend_logs", True),
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.litellm_proxy_budget_name", "test-budget"),
patch(
"litellm.proxy.spend_tracking.spend_tracking_utils.get_logging_payload",
return_value=_minimal_spend_payload(),
),
):
await db_writer.update_database(
token="test-token",
user_id="test-user",
end_user_id=None,
team_id=None,
org_id=None,
kwargs={"model": "gpt-4"},
completion_response=_tool_call_response("get_weather"),
start_time=datetime.now(timezone.utc),
end_time=datetime.now(timezone.utc),
response_cost=0.1,
)
await asyncio.sleep(0)
assert prisma.tool_usage_transactions == []
@pytest.mark.asyncio
async def test_update_daily_spend_with_null_entity_id():
"""
@ -152,6 +308,84 @@ async def test_update_daily_spend_with_null_entity_id():
assert create_data["failed_requests"] == 0
def _daily_txn(user_id: str = "user1") -> dict:
return {
"user_id": user_id,
"date": "2024-01-01",
"api_key": "test-api-key",
"model": "gpt-4",
"custom_llm_provider": "openai",
"prompt_tokens": 10,
"completion_tokens": 20,
"spend": 0.1,
"api_requests": 1,
"successful_requests": 1,
"failed_requests": 0,
}
@pytest.mark.asyncio
async def test_update_daily_spend_does_not_retry_post_send_ambiguous_errors():
# Regression for the double-apply hazard: a ReadTimeout means the batch was
# sent and its outcome is unknown; the engine can leave the transaction open
# on the pooled connection, so retrying stacks a second set of increments
# into it and one commit applies both. Post-send failures must drop the
# batch (loudly), never retry it.
import httpx
mock_prisma_client = MagicMock()
mock_prisma_client.db.batch_ = MagicMock(side_effect=httpx.ReadTimeout("ambiguous"))
proxy_logging = MagicMock()
proxy_logging.failure_handler = AsyncMock()
with pytest.raises(httpx.ReadTimeout):
await DBSpendUpdateWriter._update_daily_spend(
n_retry_times=3,
prisma_client=mock_prisma_client,
proxy_logging_obj=proxy_logging,
daily_spend_transactions={"k1": _daily_txn()},
entity_type="user",
entity_id_field="user_id",
table_name="litellm_dailyuserspend",
unique_constraint_name="user_id_date_api_key_model_custom_llm_provider_mcp_namespaced_tool_name_endpoint",
)
mock_prisma_client.db.batch_.assert_called_once()
@pytest.mark.asyncio
async def test_update_daily_spend_retries_connect_errors(monkeypatch):
# ConnectError proves the statements never reached the database, so it is
# the one failure the writer may retry.
import httpx
mock_batcher = MagicMock()
good_ctx = MagicMock()
good_ctx.__aenter__ = AsyncMock(return_value=mock_batcher)
good_ctx.__aexit__ = AsyncMock(return_value=None)
mock_prisma_client = MagicMock()
mock_prisma_client.db.batch_ = MagicMock(side_effect=[httpx.ConnectError("down"), good_ctx])
proxy_logging = MagicMock()
proxy_logging.failure_handler = AsyncMock()
async def fake_sleep(seconds: float) -> None:
return None
monkeypatch.setattr("litellm.proxy.db.db_spend_update_writer.asyncio.sleep", fake_sleep)
await DBSpendUpdateWriter._update_daily_spend(
n_retry_times=3,
prisma_client=mock_prisma_client,
proxy_logging_obj=proxy_logging,
daily_spend_transactions={"k1": _daily_txn()},
entity_type="user",
entity_id_field="user_id",
table_name="litellm_dailyuserspend",
unique_constraint_name="user_id_date_api_key_model_custom_llm_provider_mcp_namespaced_tool_name_endpoint",
)
assert mock_prisma_client.db.batch_.call_count == 2
@pytest.mark.asyncio
async def test_update_daily_spend_sorting():
"""

View file

@ -0,0 +1,348 @@
"""
Tests for the tool usage writer: ToolUsageTransaction construction (invoked tools
only) and the flush that writes LiteLLM_SpendLogToolIndex plus the
LiteLLM_DailyToolSpend rollup in one transaction.
"""
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from litellm.proxy.db.spend_log_tool_index import (
ToolUsageTransaction,
build_tool_usage_transaction,
flush_tool_usage_transactions,
response_tool_call_names,
)
def _response_with_tool_calls(*names: str) -> SimpleNamespace:
tool_calls = [SimpleNamespace(function=SimpleNamespace(name=name)) for name in names]
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(tool_calls=tool_calls))])
class _FakeBatcher:
def __init__(self) -> None:
self.litellm_spendlogtoolindex = MagicMock()
self.litellm_dailytoolspend = MagicMock()
async def __aenter__(self) -> "_FakeBatcher":
return self
async def __aexit__(self, *args: Any) -> None:
return None
def _prisma_with_batcher() -> tuple[MagicMock, _FakeBatcher]:
batcher = _FakeBatcher()
prisma = MagicMock()
prisma.db.batch_ = MagicMock(return_value=batcher)
return prisma, batcher
class TestBuildToolUsageTransaction:
def test_declared_tools_never_reach_the_transaction(self):
# Regression for the inflation bug: the builder's only non-MCP source is
# the response's tool_calls, so a request declaring N tools while the
# model invokes one produces exactly one attribution.
transaction = build_tool_usage_transaction(
request_id="r1",
start_time_iso="2026-07-25T10:00:00+00:00",
mcp_namespaced_tool_name=None,
spend=0.5,
total_tokens=100,
completion_response=_response_with_tool_calls("get_weather"),
)
assert transaction is not None
assert transaction.tool_names == ("get_weather",)
def test_no_invoked_tools_returns_none(self):
assert (
build_tool_usage_transaction(
request_id="r1",
start_time_iso="2026-07-25T10:00:00+00:00",
mcp_namespaced_tool_name=None,
spend=0.5,
total_tokens=100,
completion_response=SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(tool_calls=None))]),
)
is None
)
def test_mcp_name_and_response_names_dedupe(self):
transaction = build_tool_usage_transaction(
request_id="r1",
start_time_iso="2026-07-25T10:00:00+00:00",
mcp_namespaced_tool_name="srv/tool_a",
spend=0.5,
total_tokens=100,
completion_response=_response_with_tool_calls("srv/tool_a", "tool_b", "tool_b"),
)
assert transaction is not None
assert transaction.tool_names == ("srv/tool_a", "tool_b")
def test_date_matches_daily_spend_writer_derivation(self):
# The daily spend writer derives its date bucket as
# payload["startTime"].split("T")[0] (db_spend_update_writer.py), i.e. the
# timestamp's own calendar date, NOT the astimezone-UTC date. A non-UTC
# isoformat pins the difference: 2026-07-25T22:00:00-07:00 is 2026-07-26
# in UTC but must bucket as 2026-07-25 to match LiteLLM_DailyUserSpend.
start_time_iso = "2026-07-25T22:00:00-07:00"
transaction = build_tool_usage_transaction(
request_id="r1",
start_time_iso=start_time_iso,
mcp_namespaced_tool_name="srv/tool_a",
spend=0.5,
total_tokens=100,
completion_response=None,
)
assert transaction is not None
assert transaction.date == start_time_iso.split("T")[0] == "2026-07-25"
def test_realtime_tool_calls_reach_the_transaction(self):
# Realtime sessions carry invoked tools in kwargs["realtime_tool_calls"]
# (OpenAI tool_calls dict shape, built in realtime_streaming.py), not on a
# response object; they must land in the rollup like any other invocation.
realtime_tool_calls = [
{"id": "call_1", "type": "function", "function": {"name": "rt_get_weather", "arguments": "{}"}},
]
transaction = build_tool_usage_transaction(
request_id="r1",
start_time_iso="2026-07-25T10:00:00+00:00",
mcp_namespaced_tool_name=None,
spend=0.5,
total_tokens=100,
completion_response=None,
realtime_tool_calls=realtime_tool_calls,
)
assert transaction is not None
assert transaction.tool_names == ("rt_get_weather",)
def test_realtime_names_dedupe_against_response_names(self):
realtime_tool_calls = [{"type": "function", "function": {"name": "get_weather", "arguments": "{}"}}]
transaction = build_tool_usage_transaction(
request_id="r1",
start_time_iso="2026-07-25T10:00:00+00:00",
mcp_namespaced_tool_name=None,
spend=0.5,
total_tokens=100,
completion_response=_response_with_tool_calls("get_weather"),
realtime_tool_calls=realtime_tool_calls,
)
assert transaction is not None
assert transaction.tool_names == ("get_weather",)
def test_n_greater_than_one_tools_from_every_choice_reach_the_transaction(self):
# Regression: an n>1 request pays for every choice, and a tool invoked
# only in a later choice really ran; it must not be dropped because the
# extractor read choices[0] alone.
from types import SimpleNamespace as NS
response = NS(
choices=[
NS(message=NS(tool_calls=[NS(function=NS(name="tool_alpha"))])),
NS(message=NS(tool_calls=[NS(function=NS(name="tool_beta"))])),
]
)
transaction = build_tool_usage_transaction(
request_id="r1",
start_time_iso="2026-07-25T10:00:00+00:00",
mcp_namespaced_tool_name=None,
spend=0.5,
total_tokens=100,
completion_response=response,
)
assert transaction is not None
assert transaction.tool_names == ("tool_alpha", "tool_beta")
def test_unparseable_start_time_returns_none(self):
assert (
build_tool_usage_transaction(
request_id="r1",
start_time_iso="not-a-timestamp",
mcp_namespaced_tool_name="srv/tool_a",
spend=0.5,
total_tokens=100,
completion_response=None,
)
is None
)
class TestResponseToolCallNames:
def test_unrecognized_shapes_yield_nothing(self):
assert response_tool_call_names(None) == ()
assert response_tool_call_names(SimpleNamespace()) == ()
assert response_tool_call_names(ValueError("boom")) == ()
def test_blank_names_are_dropped(self):
assert response_tool_call_names(_response_with_tool_calls(" ", "real_tool")) == ("real_tool",)
def test_responses_api_output_function_calls(self):
# Regression: /v1/responses carries invocations in output[] items of
# type function_call, not in choices; they must reach the rollup.
response = SimpleNamespace(
output=[
SimpleNamespace(type="function_call", name="get_weather", call_id="c1", arguments="{}"),
SimpleNamespace(type="message", name=None, call_id=None, arguments=None),
]
)
assert response_tool_call_names(response) == ("get_weather",)
def test_anthropic_messages_tool_use_blocks(self):
response = {
"content": [
{"type": "text", "text": "checking"},
{"type": "tool_use", "id": "t1", "name": "ant_get_weather", "input": {"city": "Paris"}},
]
}
assert response_tool_call_names(response) == ("ant_get_weather",)
def _transaction(
request_id: str,
date: str = "2026-07-25",
tool_names: tuple = ("tool_a",),
spend: float = 1.0,
total_tokens: int = 10,
) -> ToolUsageTransaction:
from datetime import datetime, timezone
return ToolUsageTransaction(
request_id=request_id,
date=date,
start_time=datetime(2026, 7, 25, 10, 0, tzinfo=timezone.utc),
tool_names=tool_names,
spend=spend,
total_tokens=total_tokens,
)
class TestFlushToolUsageTransactions:
@pytest.mark.asyncio
async def test_multi_tool_request_attributes_full_spend_to_each_tool(self):
prisma, batcher = _prisma_with_batcher()
await flush_tool_usage_transactions(
prisma_client=prisma,
transactions=[_transaction("r1", tool_names=("tool_a", "tool_b"), spend=0.10, total_tokens=100)],
)
index_rows = batcher.litellm_spendlogtoolindex.create_many.call_args.kwargs["data"]
assert [(r["request_id"], r["tool_name"]) for r in index_rows] == [("r1", "tool_a"), ("r1", "tool_b")]
assert batcher.litellm_spendlogtoolindex.create_many.call_args.kwargs["skip_duplicates"] is True
upserts = {
c.kwargs["where"]["date_tool_name"]["tool_name"]: c.kwargs["data"]
for c in batcher.litellm_dailytoolspend.upsert.call_args_list
}
assert set(upserts) == {"tool_a", "tool_b"}
for data in upserts.values():
assert data["create"]["spend"] == 0.10
assert data["create"]["request_count"] == 1
assert data["update"]["spend"] == {"increment": 0.10}
assert data["update"]["request_count"] == {"increment": 1}
@pytest.mark.asyncio
async def test_same_day_same_tool_aggregates_within_batch(self):
prisma, batcher = _prisma_with_batcher()
await flush_tool_usage_transactions(
prisma_client=prisma,
transactions=[
_transaction("r1", spend=0.10, total_tokens=100),
_transaction("r2", spend=0.30, total_tokens=200),
],
)
assert batcher.litellm_dailytoolspend.upsert.call_count == 1
data = batcher.litellm_dailytoolspend.upsert.call_args.kwargs["data"]
assert data["create"] == {
"date": "2026-07-25",
"tool_name": "tool_a",
"spend": pytest.approx(0.40),
"total_tokens": 300,
"request_count": 2,
}
assert data["update"]["spend"] == {"increment": pytest.approx(0.40)}
assert data["update"]["total_tokens"] == {"increment": 300}
assert data["update"]["request_count"] == {"increment": 2}
@pytest.mark.asyncio
async def test_index_rows_and_rollup_share_one_transaction(self):
# Both writes go through the same batch_() so a failed flush cannot leave
# index rows without their rollup increments (or vice versa); increments
# are not idempotent, so partial states must be unreachable.
prisma, batcher = _prisma_with_batcher()
await flush_tool_usage_transactions(
prisma_client=prisma,
transactions=[_transaction("r1")],
)
prisma.db.batch_.assert_called_once()
batcher.litellm_spendlogtoolindex.create_many.assert_called_once()
batcher.litellm_dailytoolspend.upsert.assert_called_once()
@pytest.mark.asyncio
async def test_empty_batch_touches_nothing(self):
prisma, _ = _prisma_with_batcher()
await flush_tool_usage_transactions(prisma_client=prisma, transactions=[])
prisma.db.batch_.assert_not_called()
@pytest.mark.asyncio
async def test_connection_errors_retry_and_succeed(self, monkeypatch):
# A failed batch commits nothing, so retrying a connection error cannot
# double-count; the flush must retry rather than drop the batch.
import httpx
batcher = _FakeBatcher()
prisma = MagicMock()
prisma.db.batch_ = MagicMock(side_effect=[httpx.ConnectError("down"), batcher])
sleeps: list[float] = []
async def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
monkeypatch.setattr("litellm.proxy.db.spend_log_tool_index.asyncio.sleep", fake_sleep)
await flush_tool_usage_transactions(prisma_client=prisma, transactions=[_transaction("r1")])
assert prisma.db.batch_.call_count == 2
assert len(sleeps) == 1
batcher.litellm_dailytoolspend.upsert.assert_called_once()
@pytest.mark.asyncio
async def test_connection_errors_exhaust_retries_then_raise(self, monkeypatch):
import httpx
prisma = MagicMock()
prisma.db.batch_ = MagicMock(side_effect=httpx.ConnectError("down"))
async def fake_sleep(seconds: float) -> None:
return None
monkeypatch.setattr("litellm.proxy.db.spend_log_tool_index.asyncio.sleep", fake_sleep)
with pytest.raises(httpx.ConnectError):
await flush_tool_usage_transactions(
prisma_client=prisma, transactions=[_transaction("r1")], n_retry_times=2
)
assert prisma.db.batch_.call_count == 3
@pytest.mark.asyncio
async def test_non_connection_errors_do_not_retry(self):
prisma = MagicMock()
prisma.db.batch_ = MagicMock(side_effect=ValueError("bad data"))
with pytest.raises(ValueError):
await flush_tool_usage_transactions(prisma_client=prisma, transactions=[_transaction("r1")])
prisma.db.batch_.assert_called_once()
@pytest.mark.asyncio
@pytest.mark.parametrize("ambiguous_error", ["ReadTimeout", "ReadError"])
async def test_post_send_ambiguous_errors_drop_without_retry(self, ambiguous_error):
# A ReadTimeout means the statements were sent and the outcome is
# unknown; the engine can leave the transaction open on the pooled
# connection, so a retry's statements would stack into it and one
# commit would apply both increment sets. These must never retry.
import httpx
error = getattr(httpx, ambiguous_error)("ambiguous")
prisma = MagicMock()
prisma.db.batch_ = MagicMock(side_effect=error)
with pytest.raises((httpx.ReadTimeout, httpx.ReadError)):
await flush_tool_usage_transactions(prisma_client=prisma, transactions=[_transaction("r1")])
prisma.db.batch_.assert_called_once()

View file

@ -1551,3 +1551,234 @@ async def test_apply_guardrail_litellm_timeout_fail_open_forwards_uncompressed()
)
assert result["structured_messages"] == ORIGINAL_MESSAGES
# ---------------------------------------------------------------------------
# Content-parts flattening (LIT-4795)
#
# Anthropic-format requests translate to messages whose content is a list of
# part dicts. The compression service only rewrites string content, so the
# guardrail flattens ALL-TEXT part lists on the wire and restores the
# original shapes afterwards. Rows with non-text parts are never flattened:
# cache_control breakpoints are positional, and merging text across a
# non-text part would move a later breakpoint to the other side of it.
# ---------------------------------------------------------------------------
PARTS_MESSAGES = [
{
"role": "system",
"content": [
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
{
"type": "text",
"text": "Second system block. " + "B" * 5000,
"cache_control": {"type": "ephemeral", "ttl": "1h"},
},
],
},
{
"role": "user",
"content": [
{"type": "text", "text": "Mixed row text."},
{"type": "image_url", "image_url": {"url": "https://example.com/x.png"}},
],
},
{"role": "tool", "content": "tool output " + "C" * 500},
]
FLATTENED_SYSTEM_TEXT = "You are Claude Code.\n\nSecond system block. " + "B" * 5000
def _parts_copy() -> list:
return json.loads(json.dumps(PARTS_MESSAGES))
def _echo_wire_view() -> list:
"""What the service receives (and echoes back when it changes nothing)."""
return [
{"role": "system", "content": FLATTENED_SYSTEM_TEXT},
json.loads(json.dumps(PARTS_MESSAGES[1])),
{"role": "tool", "content": "tool output " + "C" * 500},
]
@pytest.mark.asyncio
async def test_apply_guardrail_flattens_all_text_rows_only(
guardrail: HeadroomGuardrail,
):
inputs = GenericGuardrailAPIInputs(
texts=["B" * 5000],
structured_messages=_parts_copy(),
)
mock_response = _make_compress_response(_echo_wire_view())
with patch.object(
guardrail.async_handler,
"post",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_post:
await guardrail.apply_guardrail(
inputs=inputs,
request_data={"model": "claude-fable-5"},
input_type="request",
)
wire_messages = mock_post.call_args.kwargs["json"]["messages"]
assert wire_messages[0]["content"] == FLATTENED_SYSTEM_TEXT
# Mixed text+image row is never flattened: merging its text would move a
# later cache_control breakpoint across the image part.
assert isinstance(wire_messages[1]["content"], list)
assert wire_messages[2]["content"] == "tool output " + "C" * 500
@pytest.mark.asyncio
async def test_apply_guardrail_restores_rewritten_all_text_row(
guardrail: HeadroomGuardrail,
):
inputs = GenericGuardrailAPIInputs(
texts=["B" * 5000],
structured_messages=_parts_copy(),
)
compressed = _echo_wire_view()
compressed[0]["content"] = "compressed system. Retrieve more: hash=b573993006976af767214fac"
mock_response = _make_compress_response(compressed)
with patch.object(
guardrail.async_handler,
"post",
new_callable=AsyncMock,
return_value=mock_response,
):
result = await guardrail.apply_guardrail(
inputs=inputs,
request_data={"model": "claude-fable-5"},
input_type="request",
)
messages = result["structured_messages"]
system_content = messages[0]["content"]
# Rewritten all-text row collapses to one part carrying the LAST declared
# breakpoint: an Anthropic breakpoint caches the prefix ending at its
# part, so after the merge the last one (and its TTL) still describes the
# row.
assert isinstance(system_content, list)
assert len(system_content) == 1
assert system_content[0]["text"] == "compressed system. Retrieve more: hash=b573993006976af767214fac"
assert system_content[0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}
# Mixed row passes through byte-identical.
assert messages[1]["content"] == PARTS_MESSAGES[1]["content"]
# Hashes inside restored parts still drive retrieve-tool injection.
assert has_headroom_retrieve_tool(result.get("tools") or [])
@pytest.mark.asyncio
async def test_apply_guardrail_keeps_originals_when_service_echoes_unchanged(
guardrail: HeadroomGuardrail,
):
inputs = GenericGuardrailAPIInputs(
texts=["B" * 5000],
structured_messages=_parts_copy(),
)
mock_response = _make_compress_response(_echo_wire_view())
with patch.object(
guardrail.async_handler,
"post",
new_callable=AsyncMock,
return_value=mock_response,
):
result = await guardrail.apply_guardrail(
inputs=inputs,
request_data={"model": "claude-fable-5"},
input_type="request",
)
messages = result["structured_messages"]
assert [m["content"] for m in messages] == [m["content"] for m in PARTS_MESSAGES]
@pytest.mark.asyncio
async def test_apply_guardrail_adopts_service_output_when_rows_dropped(
guardrail: HeadroomGuardrail,
):
inputs = GenericGuardrailAPIInputs(
texts=["B" * 5000],
structured_messages=_parts_copy(),
)
dropped = [
{"role": "system", "content": FLATTENED_SYSTEM_TEXT},
{"role": "user", "content": "B" * 50},
]
mock_response = _make_compress_response(dropped)
with patch.object(
guardrail.async_handler,
"post",
new_callable=AsyncMock,
return_value=mock_response,
):
result = await guardrail.apply_guardrail(
inputs=inputs,
request_data={"model": "claude-fable-5"},
input_type="request",
)
assert result["structured_messages"] == dropped
@pytest.mark.asyncio
async def test_apply_guardrail_sends_textless_parts_rows_unflattened(
guardrail: HeadroomGuardrail,
):
image_only = [
{"role": "user", "content": [{"type": "image_url", "image_url": {"url": "https://example.com/x.png"}}]},
{"role": "user", "content": "D" * 5000},
]
inputs = GenericGuardrailAPIInputs(
texts=["D" * 5000],
structured_messages=json.loads(json.dumps(image_only)),
)
mock_response = _make_compress_response(json.loads(json.dumps(image_only)))
with patch.object(
guardrail.async_handler,
"post",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_post:
await guardrail.apply_guardrail(
inputs=inputs,
request_data={"model": "claude-fable-5"},
input_type="request",
)
wire_messages = mock_post.call_args.kwargs["json"]["messages"]
assert isinstance(wire_messages[0]["content"], list)
assert wire_messages[1]["content"] == "D" * 5000
@pytest.mark.asyncio
async def test_fail_open_returns_original_parts_shapes():
guardrail = _make_guardrail(unreachable_fallback="fail_open")
inputs = GenericGuardrailAPIInputs(
texts=["B" * 5000],
structured_messages=_parts_copy(),
)
with patch.object(
guardrail.async_handler,
"post",
new_callable=AsyncMock,
side_effect=httpx.ConnectError("boom"),
):
result = await guardrail.apply_guardrail(
inputs=inputs,
request_data={},
input_type="request",
)
messages = result["structured_messages"]
assert [m["content"] for m in messages] == [m["content"] for m in PARTS_MESSAGES]

View file

@ -3605,3 +3605,65 @@ async def test_add_new_user_to_default_team_string_teams_have_no_member_budget(m
assert mock_add.call_args.kwargs["max_budget_in_team"] is None
assert mock_add.call_args.kwargs["team_id"] == "string-team"
@pytest.mark.asyncio
async def test_add_user_to_team_logs_unknown_team_at_error(mocker, caplog):
"""A default team that no longer exists makes every membership write 404.
The failure is swallowed so user creation still succeeds, so the log line is
the only signal an operator gets; it must be ERROR and name the team.
"""
import logging
from fastapi import HTTPException
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_add_user_to_team,
)
mocker.patch(
"litellm.proxy.management_endpoints.team_endpoints.team_member_add",
new_callable=mocker.AsyncMock,
side_effect=HTTPException(status_code=404, detail={"error": "Team not found"}),
)
with caplog.at_level(logging.DEBUG, logger="LiteLLM Proxy"):
await _add_user_to_team(
user_id="sso-user",
team_id="deleted-team",
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
)
errors = [r.getMessage() for r in caplog.records if r.levelno >= logging.ERROR]
assert len(errors) == 1, f"expected exactly one ERROR log, got {errors}"
assert "deleted-team" in errors[0]
assert "sso-user" in errors[0]
@pytest.mark.asyncio
async def test_add_user_to_team_keeps_already_a_member_quiet(mocker, caplog):
"""Re-adding an existing member is expected on every login and must not
produce an ERROR, otherwise the real failures above are lost in the noise."""
import logging
from fastapi import HTTPException
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_add_user_to_team,
)
mocker.patch(
"litellm.proxy.management_endpoints.team_endpoints.team_member_add",
new_callable=mocker.AsyncMock,
side_effect=HTTPException(status_code=400, detail={"error": "User already exists in team"}),
)
with caplog.at_level(logging.DEBUG, logger="LiteLLM Proxy"):
await _add_user_to_team(
user_id="sso-user",
team_id="existing-team",
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert [r.getMessage() for r in caplog.records if r.levelno >= logging.ERROR] == []

View file

@ -636,14 +636,33 @@ class TestDeleteModelClearsRouterRegistry:
not just from model_list, or a stale (now unbacked) router entry lingers until restart.
"""
@staticmethod
def _complexity_router_deployment(model_id: str, tags: list | None = None) -> dict:
return {
"model_name": "smart-router",
"litellm_params": {
"model": "auto_router/complexity_router",
"complexity_router_config": {"tiers": {"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o"}},
"complexity_router_default_model": "gpt-4o",
**({"tags": tags} if tags else {}),
},
"model_info": {"id": model_id, "db_model": True},
}
@pytest.mark.asyncio
async def test_delete_model_pops_router_registries(self):
async def test_delete_model_releases_only_the_deleted_routers_slot(self):
"""Deleting one tagged router must release its own slot and leave a sibling
sharing the model_name registered. A blanket pop(model_name) here would take
both down, and nothing reloads on the delete path to restore the survivor.
"""
import litellm
from litellm.proxy.management_endpoints.model_management_endpoints import ModelInfoDelete
from litellm.proxy.management_endpoints.model_management_endpoints import (
delete_model as delete_model_endpoint,
)
from litellm.proxy.management_endpoints.model_management_endpoints import ModelInfoDelete
model_id = "router-del-1"
surviving_id = "router-del-2"
admin_user = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
db_row = LiteLLM_ProxyModelTable(
model_id=model_id,
@ -660,16 +679,16 @@ class TestDeleteModelClearsRouterRegistry:
mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=db_row)
mock_prisma.db.litellm_proxymodeltable.delete = AsyncMock(return_value=db_row)
mock_router = MagicMock()
mock_router.delete_deployment = MagicMock(
return_value={
"model_name": "smart-router",
"litellm_params": {"model": "auto_router/complexity_router"},
"model_info": {"id": model_id},
}
real_router = litellm.Router(
model_list=[
{"model_name": "gpt-4o", "litellm_params": {"model": "gpt-4o"}},
{"model_name": "gpt-4o-mini", "litellm_params": {"model": "gpt-4o-mini"}},
self._complexity_router_deployment(model_id, tags=["team-a"]),
self._complexity_router_deployment(surviving_id, tags=["team-b"]),
],
ignore_invalid_deployments=True,
)
mock_router.auto_routers = {"smart-router": MagicMock()}
mock_router.complexity_routers = {"smart-router": MagicMock()}
assert len(real_router.complexity_routers["smart-router"]) == 2
_PS = "litellm.proxy.proxy_server"
with (
@ -679,16 +698,17 @@ class TestDeleteModelClearsRouterRegistry:
patch(f"{_PS}.proxy_logging_obj", MagicMock()),
patch(f"{_PS}.general_settings", {}),
patch(f"{_PS}.premium_user", True),
patch(f"{_PS}.llm_router", mock_router),
patch(f"{_PS}.llm_router", real_router),
):
await delete_model_endpoint(
model_info=ModelInfoDelete(id=model_id),
user_api_key_dict=admin_user,
)
mock_router.delete_deployment.assert_called_once_with(id=model_id)
assert "smart-router" not in mock_router.auto_routers
assert "smart-router" not in mock_router.complexity_routers
assert model_id not in [m["model_info"]["id"] for m in real_router.model_list]
surviving = real_router.complexity_routers["smart-router"]
assert len(surviving) == 1
assert surviving[0].tags == ("team-b",)
@pytest.mark.asyncio
async def test_delete_regular_model_preserves_config_router_sharing_name(self):

View file

@ -19,11 +19,7 @@ from fastapi.testclient import TestClient
sys.path.insert(0, os.path.abspath("../../.."))
from litellm.proxy.management_endpoints.tool_management_endpoints import (
_build_tool_spend_response,
_ToolSpendRow,
router,
)
from litellm.proxy.management_endpoints.tool_management_endpoints import router
from litellm.types.tool_management import LiteLLM_ToolTableRow
# --- helpers ---
@ -64,6 +60,30 @@ def _override_auth():
_MOCK_PRISMA = MagicMock()
def _rollup_row(date: str, tool_name: str, spend: float, request_count: int, total_tokens: int) -> MagicMock:
row = MagicMock()
row.date = date
row.tool_name = tool_name
row.spend = spend
row.request_count = request_count
row.total_tokens = total_tokens
return row
def _group_row(tool_name: str, spend: float, request_count: int, total_tokens: int) -> dict:
return {"tool_name": tool_name, "_sum": {"spend": spend, "total_tokens": total_tokens, "request_count": request_count}}
def _rollup_prisma(group_rows: list, daily_rows: list | None = None) -> MagicMock:
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=[])
prisma.db.litellm_spendlogs.find_many = AsyncMock(return_value=[])
prisma.db.litellm_spendlogtoolindex.find_many = AsyncMock(return_value=[])
prisma.db.litellm_dailytoolspend.group_by = AsyncMock(return_value=group_rows)
prisma.db.litellm_dailytoolspend.find_many = AsyncMock(return_value=daily_rows or [])
return prisma
# --- test class ---
@ -154,21 +174,23 @@ class TestToolManagementEndpoints:
assert resp.status_code == 422
def test_tool_spend_route_not_shadowed_by_get_tool(self):
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=[])
prisma = _rollup_prisma([])
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get("/v1/tool/spend")
assert resp.status_code == 200
assert resp.json()["by_tool"] == []
def test_tool_spend_aggregates_and_sorts(self):
rows = [
{"date": "2026-07-01", "tool_name": "search", "call_count": 2, "spend": 1.0, "total_tokens": 100},
{"date": "2026-07-02", "tool_name": "search", "call_count": 1, "spend": 4.0, "total_tokens": 50},
{"date": "2026-07-01", "tool_name": "read_file", "call_count": 3, "spend": 2.0, "total_tokens": 300},
def test_tool_spend_serves_sql_aggregates_and_daily_series(self):
group_rows = [
_group_row("search", spend=5.0, request_count=3, total_tokens=150),
_group_row("read_file", spend=2.0, request_count=3, total_tokens=300),
]
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(side_effect=[rows, [{"total_spend": 5.5}]])
daily_rows = [
_rollup_row("2026-07-01", "search", spend=1.0, request_count=2, total_tokens=100),
_rollup_row("2026-07-01", "read_file", spend=2.0, request_count=3, total_tokens=300),
_rollup_row("2026-07-02", "search", spend=4.0, request_count=1, total_tokens=50),
]
prisma = _rollup_prisma(group_rows, daily_rows)
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get("/v1/tool/spend?start_date=2026-07-01&end_date=2026-07-02")
assert resp.status_code == 200
@ -179,94 +201,89 @@ class TestToolManagementEndpoints:
assert search["call_count"] == 3
assert search["total_tokens"] == 150
assert len(body["daily"]) == 3
assert body["daily"][0]["call_count"] == 2
assert body["start_date"] == "2026-07-01"
assert body["end_date"] == "2026-07-02"
assert body["total_spend"] == 5.5
def test_tool_spend_coerces_bigint_string_sums(self):
# prisma group_by returns BigInt sums as strings ("808"); the response
# must coerce them to ints rather than 500 on validation.
group_rows = [{"tool_name": "search", "_sum": {"spend": 0.5, "total_tokens": "808", "request_count": "3"}}]
prisma = _rollup_prisma(group_rows)
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get("/v1/tool/spend?start_date=2026-07-01&end_date=2026-07-02")
assert resp.status_code == 200
assert resp.json()["by_tool"][0]["total_tokens"] == 808
assert resp.json()["by_tool"][0]["call_count"] == 3
def test_tool_spend_daily_restricted_to_top_tools_and_capped(self):
from litellm.constants import TOOL_SPEND_TOP_TOOLS
group_rows = [_group_row("search", spend=5.0, request_count=1, total_tokens=10)]
prisma = _rollup_prisma(group_rows)
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get("/v1/tool/spend?start_date=2026-07-01&end_date=2026-07-02")
assert resp.status_code == 200
group_kwargs = prisma.db.litellm_dailytoolspend.group_by.await_args.kwargs
assert group_kwargs["take"] == TOOL_SPEND_TOP_TOOLS
assert group_kwargs["order"] == {"_sum": {"spend": "desc"}}
daily_where = prisma.db.litellm_dailytoolspend.find_many.await_args.kwargs["where"]
assert daily_where["tool_name"] == {"in": ["search"]}
def test_tool_spend_skips_daily_query_when_no_tools(self):
prisma = _rollup_prisma([])
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get("/v1/tool/spend?start_date=2026-07-01&end_date=2026-07-02")
assert resp.status_code == 200
prisma.db.litellm_dailytoolspend.find_many.assert_not_awaited()
@patch("litellm.proxy.proxy_server.prisma_client", None)
def test_tool_spend_no_db_returns_500(self):
resp = self.client.get("/v1/tool/spend")
assert resp.status_code == 500
def test_tool_spend_end_date_is_inclusive_via_exclusive_next_day_bound(self):
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=[])
def test_tool_spend_reads_rollup_only_never_spendlogs(self):
# Regression for the GA blocker: the dashboard aggregate must be served
# entirely from LiteLLM_DailyToolSpend; any query_raw or SpendLogs table
# access on this path reintroduces the per-request scan.
prisma = _rollup_prisma([])
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get("/v1/tool/spend?start_date=2026-07-01&end_date=2026-07-02")
assert resp.status_code == 200
expected_binds = (
datetime(2026, 7, 1, tzinfo=timezone.utc).isoformat(),
datetime(2026, 7, 3, tzinfo=timezone.utc).isoformat(),
)
assert prisma.db.query_raw.await_count == 2
for call in prisma.db.query_raw.await_args_list:
assert tuple(call.args[1:]) == expected_binds
prisma.db.query_raw.assert_not_awaited()
prisma.db.litellm_spendlogs.find_many.assert_not_awaited()
prisma.db.litellm_spendlogtoolindex.find_many.assert_not_awaited()
prisma.db.litellm_dailytoolspend.group_by.assert_awaited_once()
def test_tool_spend_windows_rollup_by_inclusive_date_strings(self):
prisma = _rollup_prisma([])
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get("/v1/tool/spend?start_date=2026-07-01&end_date=2026-07-02")
assert resp.status_code == 200
where = prisma.db.litellm_dailytoolspend.group_by.await_args.kwargs["where"]
assert where == {"date": {"gte": "2026-07-01", "lte": "2026-07-02"}}
assert resp.json()["end_date"] == "2026-07-02"
def test_tool_spend_start_clamped_to_30_days_before_end(self):
# Clamped floor is end_date minus 30 days, serving up to 31 calendar dates
# inclusive: deliberately the same width as the endpoint's default window,
# so the dashboard's default range never triggers the clamp.
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=[])
def test_tool_spend_wide_range_served_fully(self):
# Regression: the 30-day clamp is gone; a 182-day request is served as
# requested because the rollup read is O(tools x dates).
prisma = _rollup_prisma([])
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get("/v1/tool/spend?start_date=2026-01-01&end_date=2026-07-01")
assert resp.status_code == 200
expected_binds = (
datetime(2026, 6, 1, tzinfo=timezone.utc).isoformat(),
datetime(2026, 7, 2, tzinfo=timezone.utc).isoformat(),
)
assert prisma.db.query_raw.await_count == 2
for call in prisma.db.query_raw.await_args_list:
assert tuple(call.args[1:]) == expected_binds
assert resp.json()["start_date"] == "2026-06-01"
where = prisma.db.litellm_dailytoolspend.group_by.await_args.kwargs["where"]
assert where == {"date": {"gte": "2026-01-01", "lte": "2026-07-01"}}
assert resp.json()["start_date"] == "2026-01-01"
assert resp.json()["end_date"] == "2026-07-01"
def test_tool_spend_range_within_cap_is_not_clamped(self):
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=[])
def test_tool_spend_defaults_to_trailing_30_days(self):
prisma = _rollup_prisma([])
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get("/v1/tool/spend?start_date=2026-06-25&end_date=2026-07-01")
resp = self.client.get("/v1/tool/spend")
assert resp.status_code == 200
for call in prisma.db.query_raw.await_args_list:
assert call.args[1] == datetime(2026, 6, 25, tzinfo=timezone.utc).isoformat()
assert resp.json()["start_date"] == "2026-06-25"
def test_tool_spend_start_honored_when_end_date_omitted(self):
# Regression: with end_date omitted the floor anchors to today's UTC
# midnight, not now's time-of-day, so an explicit start_date exactly 30
# days back is served from midnight rather than truncated to mid-day.
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=[])
floor_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get(f"/v1/tool/spend?start_date={floor_day.strftime('%Y-%m-%d')}")
assert resp.status_code == 200
for call in prisma.db.query_raw.await_args_list:
assert call.args[1] == floor_day.isoformat()
assert resp.json()["start_date"] == floor_day.strftime("%Y-%m-%d")
def test_tool_spend_clamp_without_end_date_lands_on_midnight(self):
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=[])
floor_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get("/v1/tool/spend?start_date=2020-01-01")
assert resp.status_code == 200
for call in prisma.db.query_raw.await_args_list:
assert call.args[1] == floor_day.isoformat()
assert resp.json()["start_date"] == floor_day.strftime("%Y-%m-%d")
def test_tool_spend_total_query_bounds_outer_spendlogs_scan(self):
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=[])
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get("/v1/tool/spend?start_date=2026-07-01&end_date=2026-07-02")
assert resp.status_code == 200
for call in prisma.db.query_raw.await_args_list:
sql = call.args[0]
assert 'sl."startTime" >=' in sql
assert 'sl."startTime" <' in sql
today = datetime.now(timezone.utc)
assert resp.json()["end_date"] == today.strftime("%Y-%m-%d")
assert resp.json()["start_date"] == (today - timedelta(days=30)).strftime("%Y-%m-%d")
@pytest.mark.parametrize(
"query",
@ -279,13 +296,12 @@ class TestToolManagementEndpoints:
],
)
def test_tool_spend_malformed_date_returns_400(self, query: str):
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=[])
prisma = _rollup_prisma([])
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = self.client.get(f"/v1/tool/spend?{query}")
assert resp.status_code == 400
assert "Invalid date format" in resp.json()["detail"]
prisma.db.query_raw.assert_not_awaited()
prisma.db.litellm_dailytoolspend.group_by.assert_not_awaited()
def test_tool_spend_non_admin_returns_403(self):
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
@ -296,38 +312,8 @@ class TestToolManagementEndpoints:
api_key="sk-user", user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER
)
client = TestClient(app, raise_server_exceptions=True)
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=[])
prisma = _rollup_prisma([])
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
resp = client.get("/v1/tool/spend")
assert resp.status_code == 403
prisma.db.query_raw.assert_not_awaited()
def _spend_row(date: str, tool_name: str, spend: float, call_count: int = 1, total_tokens: int = 10) -> _ToolSpendRow:
return _ToolSpendRow(date=date, tool_name=tool_name, call_count=call_count, spend=spend, total_tokens=total_tokens)
class TestBuildToolSpendResponse:
def test_multi_tool_attribution_double_counts_per_tool_but_not_total(self):
rows = [
_spend_row("2026-07-01", "a", spend=3.0),
_spend_row("2026-07-01", "b", spend=3.0),
]
resp = _build_tool_spend_response(rows, total_spend=3.0, start_date="2026-07-01", end_date="2026-07-01")
by_tool = {t.tool_name: t.spend for t in resp.by_tool}
assert by_tool == {"a": 3.0, "b": 3.0}
assert resp.total_spend == 3.0
def test_groups_across_days_and_sorts_by_spend(self):
rows = [
_spend_row("2026-07-01", "b", spend=1.0, call_count=2, total_tokens=100),
_spend_row("2026-07-02", "b", spend=4.0, call_count=1, total_tokens=50),
_spend_row("2026-07-01", "a", spend=2.0, call_count=3, total_tokens=300),
]
resp = _build_tool_spend_response(rows, total_spend=7.0, start_date="2026-07-01", end_date="2026-07-02")
assert [(t.tool_name, t.spend, t.call_count, t.total_tokens) for t in resp.by_tool] == [
("b", 5.0, 3, 150),
("a", 2.0, 3, 300),
]
assert len(resp.daily) == 3
prisma.db.litellm_dailytoolspend.group_by.assert_not_awaited()

View file

@ -5913,3 +5913,39 @@ async def test_overwrite_user_with_key_hash_rejects_alias_without_marker(monkeyp
)
assert updated_data["user"] == "caller-chosen-id"
def test_get_sanitized_user_information_from_key_drops_callback_config():
"""
Regression (LIT-4306): `user_api_key_auth_metadata` lands in the
StandardLoggingPayload every integration receives, so the per-key callback
config (and the integration credentials inside it) must not ride along.
Everything else - notably `priority`, which the dynamic rate limiter reads
back off this exact field - has to survive.
"""
user_api_key_dict = UserAPIKeyAuth(
api_key="test-key-hash",
metadata={
"logging": [
{
"callback_name": "langsmith",
"callback_vars": {"langsmith_api_key": "litellm_enc::ciphertext"},
}
],
"callback_settings": {"callback_vars": {"langfuse_secret_key": "litellm_enc::other"}},
"priority": "high",
},
)
result = LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(
user_api_key_dict=user_api_key_dict
)
auth_metadata = result["user_api_key_auth_metadata"]
assert "logging" not in auth_metadata
assert "callback_settings" not in auth_metadata
assert "litellm_enc::" not in json.dumps(auth_metadata)
assert auth_metadata["priority"] == "high"
# UserAPIKeyAuth is the live auth object; the per-key callbacks are resolved
# from it during pre-call, so it must not be mutated by building the log view
assert "logging" in (user_api_key_dict.metadata or {})

View file

@ -193,6 +193,12 @@ async def test_cleanup_old_spend_logs_batch_deletion():
tool_index_sql = mock_db.execute_raw.call_args_list[3][0][0]
assert 'DELETE FROM "LiteLLM_SpendLogToolIndex"' in tool_index_sql
# The LiteLLM_DailyToolSpend rollup must outlive spend-log retention: it is
# the only copy of tool spend history once its per-request sources expire,
# so spend-log cleanup must never touch it.
for call in mock_db.execute_raw.call_args_list:
assert "LiteLLM_DailyToolSpend" not in call[0][0]
@pytest.mark.asyncio
async def test_cleanup_old_spend_logs_retention_period_cutoff():

View file

@ -2680,6 +2680,142 @@ def test_update_ui_settings_writes_audit_log(monkeypatch):
app.dependency_overrides.pop(user_api_key_auth, None)
@pytest.fixture
def mock_team_lookup(monkeypatch):
"""Back /update/internal_user_settings with a fake team table.
Yields the set of team ids that exist; the test mutates it before the call.
Also exposes the find_many mock so a test can assert the lookup was skipped.
"""
from unittest.mock import AsyncMock, MagicMock
import litellm
import litellm.proxy.proxy_server as proxy_server_module
existing_team_ids: set = set()
async def _find_many(where):
requested = where["team_id"]["in"]
return [{"team_id": team_id} for team_id in requested if team_id in existing_team_ids]
find_many = AsyncMock(side_effect=_find_many)
fake_prisma = MagicMock()
fake_prisma.db.litellm_teamtable.find_many = find_many
member_budget_update = AsyncMock()
monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma)
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
monkeypatch.setattr(litellm, "default_internal_user_params", {})
monkeypatch.setattr(
"litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.update_default_team_member_budget",
member_budget_update,
)
return {
"existing_team_ids": existing_team_ids,
"find_many": find_many,
"member_budget_update": member_budget_update,
}
def test_update_internal_user_settings_rejects_unknown_team_object(mock_proxy_config, mock_auth, mock_team_lookup):
"""Regression: saving a default team that doesn't exist used to return 200,
then silently fail for every SSO user because the membership write 404s."""
mock_team_lookup["existing_team_ids"].add("real-team")
resp = client.patch(
"/update/internal_user_settings",
json={
"max_budget": 10.0,
"teams": [
{"team_id": "real-team", "max_budget_in_team": 5.0},
{"team_id": "ghost-team"},
],
},
)
assert resp.status_code == 400, resp.text
assert "ghost-team" in resp.json()["detail"]["error"]
assert "real-team" not in resp.json()["detail"]["error"]
assert mock_proxy_config["save_call_count"]() == 0
assert mock_team_lookup["member_budget_update"].await_count == 0, (
"per-member budgets must not be written before the team ids are validated"
)
import litellm
assert litellm.default_internal_user_params == {}
def test_update_internal_user_settings_rejects_unknown_team_string(mock_proxy_config, mock_auth, mock_team_lookup):
"""The bare-string team shape must be validated too."""
resp = client.patch(
"/update/internal_user_settings",
json={"teams": ["ghost-team"]},
)
assert resp.status_code == 400, resp.text
assert "ghost-team" in resp.json()["detail"]["error"]
assert mock_proxy_config["save_call_count"]() == 0
def test_update_internal_user_settings_rejects_duplicate_team_ids(mock_proxy_config, mock_auth, mock_team_lookup):
"""Listing a team twice makes its per-member budget a race between the two
entries, so the payload is rejected rather than silently resolved."""
mock_team_lookup["existing_team_ids"].add("real-team")
resp = client.patch(
"/update/internal_user_settings",
json={
"teams": [
{"team_id": "real-team", "max_budget_in_team": 5.0},
{"team_id": "real-team", "max_budget_in_team": 50.0},
]
},
)
assert resp.status_code == 400, resp.text
assert "real-team" in resp.json()["detail"]["error"]
assert mock_proxy_config["save_call_count"]() == 0
def test_update_internal_user_settings_saves_when_all_teams_exist(mock_proxy_config, mock_auth, mock_team_lookup):
"""Valid team ids still save, and still reach the per-member budget update."""
mock_team_lookup["existing_team_ids"].update({"team-a", "team-b"})
resp = client.patch(
"/update/internal_user_settings",
json={
"max_budget": 10.0,
"teams": [
{"team_id": "team-a", "max_budget_in_team": 5.0},
{"team_id": "team-b"},
],
},
)
assert resp.status_code == 200, resp.text
assert [team["team_id"] for team in resp.json()["settings"]["teams"]] == [
"team-a",
"team-b",
]
assert mock_proxy_config["save_call_count"]() == 1
mock_team_lookup["member_budget_update"].assert_awaited_once()
def test_update_internal_user_settings_without_teams_skips_team_lookup(mock_proxy_config, mock_auth, mock_team_lookup):
"""Settings changes that don't touch teams must not pay for a DB round trip."""
resp = client.patch(
"/update/internal_user_settings",
json={"max_budget": 10.0},
)
assert resp.status_code == 200, resp.text
mock_team_lookup["find_many"].assert_not_awaited()
assert mock_proxy_config["save_call_count"]() == 1
def test_update_mcp_semantic_filter_settings_requires_proxy_admin(monkeypatch):
"""Non-admin callers must not mutate global MCP semantic filter settings."""
from litellm.proxy._types import UserAPIKeyAuth

View file

@ -128,6 +128,8 @@ def mock_prisma_client() -> MagicMock:
client.proxy_logging_obj.failure_handler = AsyncMock()
client.spend_log_transactions = []
client._spend_log_transactions_lock = asyncio.Lock()
client.tool_usage_transactions = []
client._tool_usage_transactions_lock = asyncio.Lock()
client.jsonify_object = lambda data: dict(data)
client.db.is_connected = MagicMock(return_value=False)
client.db.connect = AsyncMock()

View file

@ -68,12 +68,12 @@ async def test_update_end_user_spend_upserts_each_end_user(
@pytest.mark.asyncio
async def test_update_end_user_spend_retries_on_connection_error(
async def test_update_end_user_spend_retries_on_connect_error(
mock_prisma_client: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``DB_CONNECTION_ERROR_TYPES`` failures should be retried with backoff;
once retries are exhausted, ``_raise_failed_update_spend_exception`` is
invoked and the original exception bubbles up.
"""``DB_RETRY_SAFE_ERROR_TYPES`` (ConnectError, statements provably never
sent) retries with backoff; once retries are exhausted the original
exception bubbles up via ``_raise_failed_update_spend_exception``.
"""
import httpx
import litellm.proxy.utils as utils_mod
@ -85,11 +85,11 @@ async def test_update_end_user_spend_retries_on_connection_error(
monkeypatch.setattr(utils_mod.asyncio, "sleep", _fake_sleep)
err = httpx.ReadError("conn reset")
err = httpx.ConnectError("down")
mock_prisma_client.db.tx = MagicMock(side_effect=err)
proxy_logging = MagicMock()
proxy_logging.failure_handler = AsyncMock()
with pytest.raises(httpx.ReadError):
with pytest.raises(httpx.ConnectError):
await ProxyUpdateSpend.update_end_user_spend(
n_retry_times=1,
prisma_client=mock_prisma_client,
@ -99,6 +99,29 @@ async def test_update_end_user_spend_retries_on_connection_error(
assert sleeps == [1.0]
@pytest.mark.asyncio
@pytest.mark.parametrize("ambiguous_error_name", ["ReadTimeout", "ReadError"])
async def test_update_end_user_spend_does_not_retry_post_send_ambiguous_errors(
mock_prisma_client: Any, ambiguous_error_name: str
) -> None:
"""Post-send errors are ambiguous and retrying can double-apply increments
(see DB_RETRY_SAFE_ERROR_TYPES); they must raise on the first attempt."""
import httpx
err = getattr(httpx, ambiguous_error_name)("ambiguous")
mock_prisma_client.db.tx = MagicMock(side_effect=err)
proxy_logging = MagicMock()
proxy_logging.failure_handler = AsyncMock()
with pytest.raises((httpx.ReadTimeout, httpx.ReadError)):
await ProxyUpdateSpend.update_end_user_spend(
n_retry_times=3,
prisma_client=mock_prisma_client,
proxy_logging_obj=proxy_logging,
end_user_list_transactions={"u": 1.0},
)
mock_prisma_client.db.tx.assert_called_once()
@pytest.mark.asyncio
async def test_update_end_user_spend_non_connection_error_raises_immediately(
mock_prisma_client: Any,

View file

@ -188,6 +188,35 @@ async def test_update_spend_logs_job_skips_when_queue_empty(
assert mock_prisma_client.db.litellm_spendlogs.create_many.await_count == 0
@pytest.mark.asyncio
async def test_update_spend_logs_job_drains_tool_queue_when_spend_queue_empty(
mock_prisma_client: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
# Regression: a spend-log write failure aborts a run before the tool drain,
# so tool transactions can outlive the spend queue; the job must still run
# for them instead of early-returning on the empty spend queue.
import litellm.proxy.db.spend_log_tool_index as tool_mod
import litellm.proxy.guardrails.usage_tracking as guard_mod
proxy_logging = MagicMock()
proxy_logging.failure_handler = AsyncMock()
mock_prisma_client.spend_log_transactions = []
mock_prisma_client.tool_usage_transactions = [MagicMock()]
mock_prisma_client.db.litellm_spendlogs.create_many = AsyncMock()
monkeypatch.setattr(guard_mod, "process_spend_logs_guardrail_usage", AsyncMock(), raising=False)
flush_stub = AsyncMock()
monkeypatch.setattr(tool_mod, "flush_tool_usage_transactions", flush_stub, raising=False)
await update_spend_logs_job(
prisma_client=mock_prisma_client,
db_writer_client=None,
proxy_logging_obj=proxy_logging,
)
assert len(flush_stub.await_args.kwargs["transactions"]) == 1
assert mock_prisma_client.tool_usage_transactions == []
@pytest.mark.asyncio
async def test_update_spend_logs_job_processes_and_clears_queue(
mock_prisma_client: Any, make_spend_log_row: Any, monkeypatch: pytest.MonkeyPatch
@ -208,7 +237,7 @@ async def test_update_spend_logs_job_processes_and_clears_queue(
guard_mod, "process_spend_logs_guardrail_usage", AsyncMock(), raising=False
)
monkeypatch.setattr(
tool_mod, "process_spend_logs_tool_usage", AsyncMock(), raising=False
tool_mod, "flush_tool_usage_transactions", AsyncMock(), raising=False
)
await update_spend_logs_job(

View file

@ -5936,3 +5936,446 @@ async def test_acreate_batch_request_bedrock_tags_override_deployment_tags():
bedrock_tags=request_tags,
)
assert mock_sign.call_args.kwargs["data"]["tags"] == request_tags
class TestPreRoutingStrategyRegistryLifecycle:
"""
Regression tests: a deployment leaving the model_list must release the
pre-routing strategy slot it holds in `auto_routers` / `complexity_routers` /
`adaptive_routers` / `quality_routers`.
Before this fix, editing an auto-router-family model (a UI save, which reaches
every other pod as an `upsert_deployment` from the periodic DB reload) popped
the deployment out of the model_list and then failed to re-add it: registration
raised "already exists" against the stale registry entry, and
`ignore_invalid_deployments=True` swallowed the error. The router vanished from
the Models page and stayed gone until a proxy restart, while the DB row and the
"saved successfully" response both looked fine.
"""
@staticmethod
def _complexity_router_params(default_model: str, tags=None) -> dict:
return {
"model": "auto_router/complexity_router",
"complexity_router_config": {
"tiers": {"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o", "COMPLEX": "gpt-4o"}
},
"complexity_router_default_model": default_model,
**({"tags": tags} if tags else {}),
}
@classmethod
def _router_with_complexity_router(cls, default_model: str = "gpt-4o") -> "litellm.Router":
return litellm.Router(
model_list=[
{"model_name": "gpt-4o", "litellm_params": {"model": "gpt-4o"}},
{"model_name": "gpt-4o-mini", "litellm_params": {"model": "gpt-4o-mini"}},
{
"model_name": "smart-router",
"litellm_params": cls._complexity_router_params(default_model),
"model_info": {"id": "router-1", "db_model": True},
},
],
ignore_invalid_deployments=True,
)
@staticmethod
def _model_names(router: "litellm.Router") -> list:
return [model["model_name"] for model in router.model_list]
def test_upsert_of_edited_router_keeps_it_routable(self):
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
router = self._router_with_complexity_router()
router.upsert_deployment(
deployment=Deployment(
model_name="smart-router",
litellm_params=LiteLLM_Params(**self._complexity_router_params("gpt-4o-mini")),
model_info=ModelInfo(id="router-1", db_model=True),
)
)
assert "smart-router" in self._model_names(router)
registered = router.complexity_routers["smart-router"]
assert len(registered) == 1
# the surviving strategy is the edited one, not the pre-edit leftover
assert registered[0].strategy.config.default_model == "gpt-4o-mini"
def test_unchanged_upsert_leaves_router_untouched(self):
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
router = self._router_with_complexity_router()
strategy_before = router.complexity_routers["smart-router"][0].strategy
for _ in range(3):
router.upsert_deployment(
deployment=Deployment(
model_name="smart-router",
litellm_params=LiteLLM_Params(**self._complexity_router_params("gpt-4o")),
model_info=ModelInfo(id="router-1", db_model=True),
)
)
assert "smart-router" in self._model_names(router)
assert router.complexity_routers["smart-router"][0].strategy is strategy_before
def test_delete_frees_the_name_for_a_new_router(self):
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
router = self._router_with_complexity_router()
router.delete_deployment(id="router-1")
assert "smart-router" not in router.complexity_routers
router.add_deployment(
deployment=Deployment(
model_name="smart-router",
litellm_params=LiteLLM_Params(**self._complexity_router_params("gpt-4o-mini")),
model_info=ModelInfo(id="router-2", db_model=True),
)
)
assert "smart-router" in self._model_names(router)
assert router.complexity_routers["smart-router"][0].strategy.config.default_model == "gpt-4o-mini"
def test_delete_only_frees_the_matching_tag_slot(self):
router = litellm.Router(
model_list=[
{"model_name": "gpt-4o", "litellm_params": {"model": "gpt-4o"}},
{"model_name": "gpt-4o-mini", "litellm_params": {"model": "gpt-4o-mini"}},
{
"model_name": "shared-router",
"litellm_params": self._complexity_router_params("gpt-4o", tags=["team-a"]),
"model_info": {"id": "router-a"},
},
{
"model_name": "shared-router",
"litellm_params": self._complexity_router_params("gpt-4o-mini", tags=["team-b"]),
"model_info": {"id": "router-b"},
},
],
ignore_invalid_deployments=True,
)
assert len(router.complexity_routers["shared-router"]) == 2
router.delete_deployment(id="router-a")
remaining = router.complexity_routers["shared-router"]
assert len(remaining) == 1
assert remaining[0].tags == ("team-b",)
def test_delete_of_regular_model_preserves_router_sharing_its_name(self):
router = litellm.Router(
model_list=[
{"model_name": "gpt-4o", "litellm_params": {"model": "gpt-4o"}},
{"model_name": "gpt-4o-mini", "litellm_params": {"model": "gpt-4o-mini"}},
{
"model_name": "shared-name",
"litellm_params": self._complexity_router_params("gpt-4o"),
"model_info": {"id": "router-1"},
},
{
"model_name": "shared-name",
"litellm_params": {"model": "openai/gpt-4o"},
"model_info": {"id": "regular-1"},
},
],
ignore_invalid_deployments=True,
)
strategy = router.complexity_routers["shared-name"][0].strategy
router.delete_deployment(id="regular-1")
assert router.complexity_routers["shared-name"][0].strategy is strategy
def test_upsert_of_edited_adaptive_router_rebuilds_it(self):
"""Adaptive routers are built by set_model_list()'s deferred pass, not by
add_deployment(), so releasing the slot on edit must be paired with a rebuild -
otherwise the edit silently turns adaptive routing off."""
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
def adaptive_params(available_models: list) -> dict:
return {
"model": "auto_router/adaptive_router",
"adaptive_router_config": {"available_models": available_models},
}
router = litellm.Router(
model_list=[
{"model_name": "gpt-4o", "litellm_params": {"model": "openai/gpt-4o"}},
{"model_name": "gpt-4o-mini", "litellm_params": {"model": "openai/gpt-4o-mini"}},
{
"model_name": "adaptive-router",
"litellm_params": adaptive_params(["gpt-4o-mini"]),
"model_info": {"id": "router-1", "db_model": True},
},
],
ignore_invalid_deployments=True,
)
assert "adaptive-router" in router.adaptive_routers
router.upsert_deployment(
deployment=Deployment(
model_name="adaptive-router",
litellm_params=LiteLLM_Params(**adaptive_params(["gpt-4o", "gpt-4o-mini"])),
model_info=ModelInfo(id="router-1", db_model=True),
)
)
assert "adaptive-router" in self._model_names(router)
registered = router.adaptive_routers["adaptive-router"]
assert len(registered) == 1
assert set(registered[0].strategy.config.available_models) == {"gpt-4o", "gpt-4o-mini"}
def test_delete_repairs_indices_even_when_strategy_release_fails(self):
"""Structural removal and strategy release are not equally critical. Once the entry
leaves model_list the index maps must be repaired no matter what, so releasing the
registry slot runs after that repair and cannot abandon the router half-updated."""
router = self._router_with_complexity_router()
idx = router.model_id_to_deployment_index_map["router-1"]
router.model_list[idx] = {"model_name": "smart-router", "litellm_params": None}
returned = router.delete_deployment(id="router-1")
assert returned is not None
assert "router-1" not in router.model_id_to_deployment_index_map
assert all(entry.get("model_info", {}).get("id") != "router-1" for entry in router.model_list)
assert router.get_deployment(model_id="router-1") is None
assert "gpt-4o" in self._model_names(router)
def test_delete_of_adaptive_enabled_complexity_router_frees_both_registries(self):
"""A complexity router with adaptive set is registered in BOTH complexity_routers
and adaptive_routers under the same (model_name, tags). Releasing only the first
match leaves the adaptive strategy live, so a deleted alias stays routable and its
post-call hook keeps recording."""
import litellm as litellm_module
from litellm.router_strategy.adaptive_router.hooks import AdaptiveRouterPostCallHook
params = {
"model": "auto_router/complexity_router",
"complexity_router_config": {
"tiers": {"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o"},
"adaptive": True,
},
"complexity_router_default_model": "gpt-4o",
}
router = litellm.Router(
model_list=[
{"model_name": "gpt-4o", "litellm_params": {"model": "openai/gpt-4o"}},
{"model_name": "gpt-4o-mini", "litellm_params": {"model": "openai/gpt-4o-mini"}},
{
"model_name": "hybrid-router",
"litellm_params": params,
"model_info": {"id": "router-1", "db_model": True},
},
],
ignore_invalid_deployments=True,
)
assert "hybrid-router" in router.complexity_routers
assert "hybrid-router" in router.adaptive_routers
hooks = litellm_module.logging_callback_manager.get_custom_loggers_for_type(AdaptiveRouterPostCallHook)
assert len(hooks) == 1
router.delete_deployment(id="router-1")
assert "hybrid-router" not in router.complexity_routers
assert "hybrid-router" not in router.adaptive_routers
remaining_hooks = litellm_module.logging_callback_manager.get_custom_loggers_for_type(
AdaptiveRouterPostCallHook
)
assert remaining_hooks == []
def test_upsert_of_edited_quality_router_keeps_it_routable(self):
"""_unregister_pre_routing_strategy_for_deployment dispatches on four prefixes;
quality_router is one of them and would otherwise go unexercised."""
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
def quality_params(default_model: str) -> dict:
return {
"model": "auto_router/quality_router",
"quality_router_default_model": default_model,
}
router = litellm.Router(
model_list=[
{"model_name": "gpt-4o", "litellm_params": {"model": "openai/gpt-4o"}},
{"model_name": "gpt-4o-mini", "litellm_params": {"model": "openai/gpt-4o-mini"}},
{
"model_name": "quality-router",
"litellm_params": quality_params("gpt-4o"),
"model_info": {"id": "router-1", "db_model": True},
},
],
ignore_invalid_deployments=True,
)
assert "quality-router" in router.quality_routers
router.upsert_deployment(
deployment=Deployment(
model_name="quality-router",
litellm_params=LiteLLM_Params(**quality_params("gpt-4o-mini")),
model_info=ModelInfo(id="router-1", db_model=True),
)
)
assert "quality-router" in self._model_names(router)
registered = router.quality_routers["quality-router"]
assert len(registered) == 1
assert registered[0].strategy.config.default_model == "gpt-4o-mini"
@staticmethod
def _hybrid_router_params(tiers: dict) -> dict:
return {
"model": "auto_router/complexity_router",
"complexity_router_config": {"tiers": tiers, "adaptive": True},
"complexity_router_default_model": "gpt-4o",
}
@classmethod
def _router_with_hybrid_router(cls) -> "litellm.Router":
return litellm.Router(
model_list=[
{"model_name": "gpt-4o", "litellm_params": {"model": "openai/gpt-4o"}},
{"model_name": "gpt-4o-mini", "litellm_params": {"model": "openai/gpt-4o-mini"}},
{
"model_name": "hybrid-router",
"litellm_params": cls._hybrid_router_params({"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o"}),
"model_info": {"id": "router-1", "db_model": True},
},
],
ignore_invalid_deployments=True,
)
def test_upsert_of_edited_hybrid_complexity_router_relinks_adaptive(self):
"""Editing an adaptive-enabled complexity router releases its adaptive companion
along with the complexity slot; the finalize re-run must fire for it (not just for
`auto_router/adaptive_router` deployments) or the rebuilt complexity router keeps
routing while bandit recording, DB persistence and /adaptive_router/state all
silently stop until the next full reload."""
import litellm as litellm_module
from litellm.router_strategy.adaptive_router.hooks import AdaptiveRouterPostCallHook
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
router = self._router_with_hybrid_router()
assert "hybrid-router" in router.adaptive_routers
router.upsert_deployment(
deployment=Deployment(
model_name="hybrid-router",
litellm_params=LiteLLM_Params(
**self._hybrid_router_params(
{"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o", "COMPLEX": "gpt-4o"}
)
),
model_info=ModelInfo(id="router-1", db_model=True),
)
)
assert "hybrid-router" in self._model_names(router)
assert "hybrid-router" in router.complexity_routers
assert "hybrid-router" in router.adaptive_routers
rebuilt = router.complexity_routers["hybrid-router"][0].strategy
assert router.adaptive_routers["hybrid-router"][0].strategy is rebuilt.adaptive_router
hooks = litellm_module.logging_callback_manager.get_custom_loggers_for_type(AdaptiveRouterPostCallHook)
assert len(hooks) == 1
def test_upsert_turning_adaptive_on_builds_the_companion(self):
"""An edit that flips `adaptive: true` on an existing complexity router must
register the companion immediately; neither side of the old prefix-only gate
matches a complexity deployment, so the flip was a silent no-op until restart."""
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
router = self._router_with_complexity_router()
assert "smart-router" not in router.adaptive_routers
params = self._complexity_router_params("gpt-4o")
params["complexity_router_config"] = {**params["complexity_router_config"], "adaptive": True}
router.upsert_deployment(
deployment=Deployment(
model_name="smart-router",
litellm_params=LiteLLM_Params(**params),
model_info=ModelInfo(id="router-1", db_model=True),
)
)
assert "smart-router" in router.adaptive_routers
def test_unregister_pre_routing_strategy_scopes_the_drop_by_tags(self):
"""The bool return drives the hook re-sync; a tag mismatch must report False and
leave the registry untouched, and dropping the last entry must free the key."""
from litellm.types.router import TaggedPreRoutingStrategy
registry = {
"m": [
TaggedPreRoutingStrategy(tags=("team-a",), strategy=object()),
TaggedPreRoutingStrategy(tags=(), strategy=object()),
]
}
assert litellm.Router._unregister_pre_routing_strategy(registry, "m", ("team-b",)) is False
assert len(registry["m"]) == 2
assert litellm.Router._unregister_pre_routing_strategy(registry, "m", ("team-a",)) is True
assert [entry.tags for entry in registry["m"]] == [()]
assert litellm.Router._unregister_pre_routing_strategy(registry, "m", ()) is True
assert "m" not in registry
def test_unregister_for_deployment_ignores_non_router_deployments(self):
"""Direct twin of the endpoint-level test: a regular deployment that shares a
router's model_name must not evict the router's registry slot."""
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
router = self._router_with_complexity_router()
router._unregister_pre_routing_strategy_for_deployment(
deployment=Deployment(
model_name="smart-router",
litellm_params=LiteLLM_Params(model="openai/gpt-4o"),
model_info=ModelInfo(id="plain-1", db_model=True),
)
)
assert "smart-router" in router.complexity_routers
def test_sync_adaptive_router_hooks_keeps_one_hook_per_registered_router(self):
"""Re-syncing must replace, not accumulate: a duplicated hook double-fires
bandit signal recording for every request."""
import litellm as litellm_module
from litellm.router_strategy.adaptive_router.hooks import AdaptiveRouterPostCallHook
router = self._router_with_hybrid_router()
router._sync_adaptive_router_hooks()
router._sync_adaptive_router_hooks()
hooks = litellm_module.logging_callback_manager.get_custom_loggers_for_type(AdaptiveRouterPostCallHook)
assert len(hooks) == 1
def test_deployment_participates_in_adaptive_routing_matrix(self):
"""The upsert finalize re-run keys off this predicate for both the incoming and
outgoing deployment; a false negative silently strands the adaptive companion."""
from litellm.types.router import LiteLLM_Params
router = self._router_with_complexity_router()
cases = [
({"model": "auto_router/adaptive_router", "adaptive_router_config": {}}, True),
(self._hybrid_router_params({"SIMPLE": "gpt-4o-mini"}), True),
(self._complexity_router_params("gpt-4o"), False),
(
{
"model": "auto_router/complexity_router",
"complexity_router_config": {"tiers": {"SIMPLE": "gpt-4o-mini"}, "adaptive": False},
"complexity_router_default_model": "gpt-4o",
},
False,
),
({"model": "openai/gpt-4o"}, False),
]
for params, expected in cases:
actual = router._deployment_participates_in_adaptive_routing(
litellm_params=LiteLLM_Params(**params)
)
assert actual is expected, params["model"]

View file

@ -1,9 +1,9 @@
{
"LIT001": {
"limit": 23408
"limit": 23287
},
"LIT002": {
"limit": 27511
"limit": 27473
},
"LIT003": {
"limit": 292
@ -15,7 +15,7 @@
"limit": 0
},
"LIT006": {
"limit": 1111
"limit": 1109
},
"LIT007": {
"limit": 0
@ -24,6 +24,6 @@
"limit": 1004
},
"LIT009": {
"limit": 2501
"limit": 2495
}
}

View file

@ -225,11 +225,6 @@
"count": 1
}
},
"src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -1867,11 +1862,6 @@
"count": 1
}
},
"src/app/(dashboard)/users/_components/DefaultUserSettings.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/users/_components/edit_user.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -3368,10 +3358,10 @@
"count": 5
},
"no-restricted-syntax": {
"count": 154
"count": 153
},
"prefer-const": {
"count": 33
"count": 32
}
},
"src/components/object_permissions_view.tsx": {
@ -4339,11 +4329,6 @@
"count": 1
}
},
"src/lib/http/client.ts": {
"no-nested-ternary": {
"count": 1
}
},
"src/utils/dataUtils.test.ts": {
"max-nested-callbacks": {
"count": 1
@ -4365,4 +4350,4 @@
"count": 1
}
}
}
}

View file

@ -5,7 +5,7 @@ const mockUserDailyActivityCall = vi.fn();
vi.mock("@/components/networking", () => ({
userDailyActivityCall: (...args: unknown[]) => mockUserDailyActivityCall(...args),
getToolSpend: vi.fn().mockResolvedValue({ by_tool: [], daily: [], total_spend: 0, start_date: null, end_date: null }),
getToolSpend: vi.fn().mockResolvedValue({ by_tool: [], daily: [], start_date: null, end_date: null }),
getGeneralSettingsCall: vi.fn().mockResolvedValue([]),
}));
@ -19,7 +19,7 @@ vi.mock("@/components/shared/charts", () => ({
DonutChart: () => <div />,
BarChart: () => <div />,
CustomLegend: () => <div />,
DEFAULT_COLOR_CYCLE: ["emerald"],
SEQUENTIAL_COLOR_RAMP: ["indigo"],
}));
vi.mock("@/app/(dashboard)/router-settings/_components/general_settings", () => ({

View file

@ -23,18 +23,37 @@ vi.mock("@/components/shared/charts", () => ({
DonutChart: ({ data, label }: { data: unknown; label: string }) => (
<div data-testid="donut-chart" data-label={label} data-slices={JSON.stringify(data)} />
),
BarChart: ({ data, categories }: { data: unknown; categories: string[] }) => (
<div data-testid="bar-chart" data-categories={categories.join(",")} data-series={JSON.stringify(data)} />
BarChart: ({
data,
categories,
colors,
showLegend,
maxBarSize,
}: {
data: unknown;
categories: string[];
colors?: readonly string[];
showLegend?: boolean;
maxBarSize?: number;
}) => (
<div
data-testid="bar-chart"
data-categories={categories.join(",")}
data-colors={(colors ?? []).join(",")}
data-show-legend={String(showLegend ?? true)}
data-max-bar-size={maxBarSize === undefined ? "" : String(maxBarSize)}
data-series={JSON.stringify(data)}
/>
),
CustomLegend: ({ categories }: { categories: readonly string[] }) => (
<div data-testid="chart-legend">{categories.join(",")}</div>
),
DEFAULT_COLOR_CYCLE: ["emerald", "blue", "violet", "amber"],
SEQUENTIAL_COLOR_RAMP: ["indigo", "blue", "sky", "cyan"],
}));
import UsageTab from "./UsageTab";
const emptyToolSpend: ToolSpendResponse = { by_tool: [], daily: [], total_spend: 0, start_date: null, end_date: null };
const emptyToolSpend: ToolSpendResponse = { by_tool: [], daily: [], start_date: null, end_date: null };
const baseMetrics = (overrides: Partial<SpendMetrics>): SpendMetrics => ({
spend: 0,
@ -216,7 +235,6 @@ describe("UsageTab", () => {
{ tool_name: "read_file", spend: 1.0, call_count: 2, total_tokens: 50 },
],
daily: [{ date: "2026-07-12", tool_name: "search", spend: 4.0, call_count: 3 }],
total_spend: 5.0,
start_date: "2026-07-12",
end_date: "2026-07-12",
};
@ -225,32 +243,29 @@ describe("UsageTab", () => {
const bars = await findAllByTestId("bar-chart");
const series = JSON.parse(bars[0].getAttribute("data-series") ?? "[]");
expect(series[0]).toMatchObject({ tool_name: "search", spend: 4.0 });
// The 64px bar cap is this card's opt-in; the shared BarChart must not cap
// by default (other consumers keep their pre-existing geometry).
expect(bars[0].getAttribute("data-max-bar-size")).toBe("64");
});
it("notes the 30-day cap when the server clamps the tool spend window", async () => {
it("renders the tool legend once outside the charts, with both charts sharing the tool colors", async () => {
const toolSpend = {
by_tool: [{ tool_name: "search", spend: 4.0, call_count: 3, total_tokens: 150 }],
by_tool: [
{ tool_name: "search", spend: 4.0, call_count: 3, total_tokens: 150 },
{ tool_name: "read_file", spend: 1.0, call_count: 2, total_tokens: 50 },
],
daily: [{ date: "2026-07-12", tool_name: "search", spend: 4.0, call_count: 3 }],
total_spend: 4.0,
start_date: "2026-07-05",
end_date: "2026-07-14",
start_date: "2026-07-12",
end_date: "2026-07-12",
};
const { findByText } = renderWith([day("2026-07-12", {})], { toolSpend });
const { findAllByTestId, getAllByTestId } = renderWith([day("2026-07-12", {})], { toolSpend });
expect(await findByText(/capped at 30 days before the end of the selected range/)).toBeInTheDocument();
});
const bars = await findAllByTestId("bar-chart");
const [totalByTool, dailyByTool] = bars.slice(-2);
expect(dailyByTool.getAttribute("data-show-legend")).toBe("false");
expect(totalByTool.getAttribute("data-colors")).toBe(dailyByTool.getAttribute("data-colors"));
it("shows no cap note when the served window matches the request", async () => {
const toolSpend = {
by_tool: [{ tool_name: "search", spend: 4.0, call_count: 3, total_tokens: 150 }],
daily: [{ date: "2026-07-12", tool_name: "search", spend: 4.0, call_count: 3 }],
total_spend: 4.0,
start_date: "2026-07-01",
end_date: "2026-07-14",
};
const { findAllByTestId, queryByText } = renderWith([day("2026-07-12", {})], { toolSpend });
await findAllByTestId("bar-chart");
expect(queryByText(/capped at 30 days before the end of the selected range/)).not.toBeInTheDocument();
const toolLegends = getAllByTestId("chart-legend").filter((legend) => legend.textContent === "search,read_file");
expect(toolLegends).toHaveLength(1);
});
});

View file

@ -3,7 +3,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { Info } from "lucide-react";
import { AreaChart, BarChart, CustomLegend, DonutChart, DEFAULT_COLOR_CYCLE } from "@/components/shared/charts";
import { AreaChart, BarChart, CustomLegend, DonutChart, SEQUENTIAL_COLOR_RAMP } from "@/components/shared/charts";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -34,7 +34,6 @@ interface UsageTabProps {
const EMPTY_TOOL_SPEND: ToolSpendResponse = {
by_tool: [],
daily: [],
total_spend: 0,
start_date: null,
end_date: null,
};
@ -103,7 +102,6 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
const toolSpend = toolSpendState?.key === rangeKey ? toolSpendState.data : null;
const toolSpendLoading = toolSpendEnabled && toolSpend === null;
const toolSpendWindowClamped = !!toolSpend?.start_date && !!startTime && toolSpend.start_date > isoDay(startTime);
const compressionTotal = useMemo(() => results.reduce((sum, d) => sum + compressionOf(d.metrics), 0), [results]);
const cachingTotal = useMemo(() => results.reduce((sum, d) => sum + cachingOf(d.metrics), 0), [results]);
@ -168,7 +166,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
})),
[toolSpend, topToolNames],
);
const toolColors = useMemo(() => DEFAULT_COLOR_CYCLE.slice(0, Math.max(topToolNames.length, 1)), [topToolNames]);
const toolColors = useMemo(() => SEQUENTIAL_COLOR_RAMP.slice(0, Math.max(topToolNames.length, 1)), [topToolNames]);
return (
<div className="w-full space-y-6">
@ -262,15 +260,10 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
<CardHeader>
<CardTitle>Spend by tool</CardTitle>
<p className="text-sm text-muted-foreground">
Spend on requests that called each tool (MCP and client-side tools). A request that used multiple tools
counts its full spend toward each, so this attributes rather than partitions spend.
Spend on requests that invoked each tool (MCP and client-side tools); declaring a tool without invoking it
does not count. A request that invoked multiple tools counts its full spend toward each, so this attributes
rather than partitions spend.
</p>
{toolSpendWindowClamped && (
<p className="text-xs text-muted-foreground">
Tool spend is capped at 30 days before the end of the selected range; showing spend since{" "}
{toolSpend?.start_date}.
</p>
)}
</CardHeader>
<CardContent>
{topTools.length === 0 ? (
@ -285,22 +278,27 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
data={topToolsChart}
index="tool_name"
categories={["spend"]}
colors={["emerald"]}
colors={toolColors}
colorByDatum
layout="vertical"
yAxisWidth={140}
maxBarSize={64}
showLegend={false}
valueFormatter={usd}
/>
</div>
<div>
<p className="mb-2 text-sm font-medium text-muted-foreground">Daily spend by tool</p>
<CustomLegend categories={topToolNames} colors={toolColors} />
<BarChart
data={dailyToolSeries}
index="date"
categories={topToolNames}
colors={toolColors}
stack
maxBarSize={64}
valueFormatter={usd}
showLegend={false}
/>
</div>
</div>

View file

@ -1,153 +0,0 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import DefaultUserSettings from "./DefaultUserSettings";
import * as networking from "@/components/networking";
vi.mock("@/components/networking", () => ({
getInternalUserSettings: vi.fn(),
updateInternalUserSettings: vi.fn(),
modelAvailableCall: vi.fn(),
}));
vi.mock("@/components/common_components/budget_duration_dropdown", () => ({
default: ({ value, onChange }: { value: string | null; onChange: (value: string | null) => void }) => (
<select data-testid="budget-duration" value={value || ""} onChange={(e) => onChange(e.target.value || null)}>
<option value="">Select duration</option>
<option value="daily">Daily</option>
<option value="monthly">Monthly</option>
</select>
),
getBudgetDurationLabel: (value: string) => value,
}));
vi.mock("@/components/key_team_helpers/fetch_available_models_team_key", () => ({
getModelDisplayName: (model: string) => model,
}));
describe("DefaultUserSettings", () => {
const mockGetInternalUserSettings = vi.mocked(networking.getInternalUserSettings);
const mockUpdateInternalUserSettings = vi.mocked(networking.updateInternalUserSettings);
const mockModelAvailableCall = vi.mocked(networking.modelAvailableCall);
const defaultProps = {
accessToken: "test-token",
userID: "user-123",
userRole: "Admin",
possibleUIRoles: {
internal_user_admin: {
ui_label: "Admin",
description: "Full access",
},
internal_user_viewer: {
ui_label: "Viewer",
description: "Read-only access",
},
},
};
const mockSettings = {
values: {
user_role: "internal_user_admin",
budget_duration: "monthly",
max_budget: 1000,
teams: [],
},
field_schema: {
description: "Default user settings",
properties: {
user_role: {
type: "string",
description: "User role",
},
budget_duration: {
type: "string",
description: "Budget duration",
},
max_budget: {
type: "number",
description: "Maximum budget",
},
teams: {
type: "array",
description: "Teams",
},
},
},
};
beforeEach(() => {
mockGetInternalUserSettings.mockClear();
mockUpdateInternalUserSettings.mockClear();
mockModelAvailableCall.mockClear();
mockModelAvailableCall.mockResolvedValue({
data: [{ id: "gpt-4" }, { id: "gpt-3.5-turbo" }],
});
});
it("should render", async () => {
mockGetInternalUserSettings.mockResolvedValue(mockSettings);
render(<DefaultUserSettings {...defaultProps} />);
await waitFor(() => {
expect(mockGetInternalUserSettings).toHaveBeenCalled();
});
expect(screen.getByText("Default User Settings")).toBeInTheDocument();
});
it("should toggle edit mode when edit button is clicked", async () => {
mockGetInternalUserSettings.mockResolvedValue(mockSettings);
render(<DefaultUserSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Edit Settings")).toBeInTheDocument();
});
const editButton = screen.getByText("Edit Settings");
act(() => {
fireEvent.click(editButton);
});
expect(screen.getByText("Cancel")).toBeInTheDocument();
expect(screen.getByText("Save Changes")).toBeInTheDocument();
expect(screen.queryByText("Edit Settings")).not.toBeInTheDocument();
});
it("should save settings when save button is clicked", async () => {
mockGetInternalUserSettings.mockResolvedValue(mockSettings);
mockUpdateInternalUserSettings.mockResolvedValue({
settings: {
...mockSettings.values,
max_budget: 2000,
},
});
render(<DefaultUserSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Edit Settings")).toBeInTheDocument();
});
const editButton = screen.getByText("Edit Settings");
act(() => {
fireEvent.click(editButton);
});
await waitFor(() => {
expect(screen.getByText("Save Changes")).toBeInTheDocument();
});
const saveButton = screen.getByText("Save Changes");
act(() => {
fireEvent.click(saveButton);
});
await waitFor(() => {
expect(mockUpdateInternalUserSettings).toHaveBeenCalled();
});
expect(screen.getByText("Edit Settings")).toBeInTheDocument();
});
});

View file

@ -1,492 +0,0 @@
import React, { useState, useEffect } from "react";
import { Card, Title, Text, Divider, TextInput } from "@tremor/react";
import { Button, Typography, Spin, Switch, Select, InputNumber } from "antd";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import { getInternalUserSettings, updateInternalUserSettings, modelAvailableCall } from "@/components/networking";
import BudgetDurationDropdown, {
getBudgetDurationLabel,
} from "@/components/common_components/budget_duration_dropdown";
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import NotificationManager from "@/components/molecules/notifications_manager";
interface DefaultUserSettingsProps {
accessToken: string | null;
possibleUIRoles?: Record<string, Record<string, string>> | null;
userID: string;
userRole: string;
}
interface TeamEntry {
team_id: string;
max_budget_in_team?: number;
user_role: "user" | "admin";
}
const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
accessToken,
possibleUIRoles,
userID,
userRole,
}) => {
const [loading, setLoading] = useState<boolean>(true);
const [settings, setSettings] = useState<any>(null);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [editedValues, setEditedValues] = useState<any>({});
const [saving, setSaving] = useState<boolean>(false);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const { Paragraph } = Typography;
const { Option } = Select;
useEffect(() => {
const fetchSSOSettings = async () => {
if (!accessToken) {
setLoading(false);
return;
}
try {
const data = await getInternalUserSettings(accessToken);
setSettings(data);
setEditedValues(data.values || {});
// Fetch available models
if (accessToken) {
try {
const modelResponse = await modelAvailableCall(accessToken, userID, userRole);
if (modelResponse && modelResponse.data) {
const modelNames = modelResponse.data.map((model: { id: string }) => model.id);
setAvailableModels(modelNames);
}
} catch (error) {
console.error("Error fetching available models:", error);
}
}
} catch (error) {
console.error("Error fetching SSO settings:", error);
NotificationManager.fromBackend("Failed to fetch SSO settings");
} finally {
setLoading(false);
}
};
fetchSSOSettings();
}, [accessToken]);
const handleSaveSettings = async () => {
if (!accessToken) return;
setSaving(true);
try {
// Convert empty strings to null
const processedValues = Object.entries(editedValues).reduce(
(acc, [key, value]) => {
acc[key] = value === "" ? null : value;
return acc;
},
{} as Record<string, any>,
);
const updatedSettings = await updateInternalUserSettings(accessToken, processedValues);
setSettings({ ...settings, values: updatedSettings.settings });
setIsEditing(false);
} catch (error) {
console.error("Error updating SSO settings:", error);
NotificationManager.fromBackend("Failed to update settings: " + error);
} finally {
setSaving(false);
}
};
const handleTextInputChange = (key: string, value: any) => {
setEditedValues((prev: Record<string, any>) => ({
...prev,
[key]: value,
}));
};
// Helper function to normalize teams array to consistent format
const normalizeTeams = (teams: any[]): TeamEntry[] => {
if (!teams || !Array.isArray(teams)) return [];
return teams.map((team) => {
if (typeof team === "string") {
return {
team_id: team,
user_role: "user" as const,
};
} else if (typeof team === "object" && team.team_id) {
return {
team_id: team.team_id,
max_budget_in_team: team.max_budget_in_team,
user_role: team.user_role || "user",
};
}
return {
team_id: "",
user_role: "user" as const,
};
});
};
// Teams editor component
const renderTeamsEditor = (teams: any[]) => {
const normalizedTeams = normalizeTeams(teams);
const updateTeam = (index: number, field: keyof TeamEntry, value: any) => {
const updatedTeams = [...normalizedTeams];
updatedTeams[index] = {
...updatedTeams[index],
[field]: value,
};
handleTextInputChange("teams", updatedTeams);
};
const addTeam = () => {
const newTeam: TeamEntry = {
team_id: "",
user_role: "user",
};
handleTextInputChange("teams", [...normalizedTeams, newTeam]);
};
const removeTeam = (index: number) => {
const updatedTeams = normalizedTeams.filter((_, i) => i !== index);
handleTextInputChange("teams", updatedTeams);
};
return (
<div className="space-y-3">
{normalizedTeams.map((team, index) => (
<div key={index} className="border rounded-lg p-4 bg-gray-50">
<div className="flex items-center justify-between mb-3">
<Text className="font-medium">Team {index + 1}</Text>
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => removeTeam(index)}>
Remove
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<Text className="text-sm font-medium mb-1">Team ID</Text>
<TextInput
value={team.team_id}
onChange={(e) => updateTeam(index, "team_id", e.target.value)}
placeholder="Enter team ID"
/>
</div>
<div>
<Text className="text-sm font-medium mb-1">Max Budget in Team</Text>
<InputNumber
style={{ width: "100%" }}
value={team.max_budget_in_team}
onChange={(value) => updateTeam(index, "max_budget_in_team", value)}
placeholder="Optional"
min={0}
step={0.01}
precision={2}
/>
</div>
<div>
<Text className="text-sm font-medium mb-1">User Role</Text>
<Select
style={{ width: "100%" }}
value={team.user_role}
onChange={(value) => updateTeam(index, "user_role", value)}
>
<Option value="user">User</Option>
<Option value="admin">Admin</Option>
</Select>
</div>
</div>
</div>
))}
<Button icon={<PlusOutlined />} onClick={addTeam} className="w-full">
Add Team
</Button>
</div>
);
};
const renderEditableField = (key: string, property: any, value: any) => {
const type = property.type;
if (key === "teams") {
return <div className="mt-2">{renderTeamsEditor(editedValues[key] || [])}</div>;
} else if (key === "user_role" && possibleUIRoles) {
return (
<Select
style={{ width: "100%" }}
value={editedValues[key] || ""}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
>
{Object.entries(possibleUIRoles)
.filter(([role]) => role.includes("internal_user"))
.map(([role, { ui_label, description }]) => (
<Option key={role} value={role}>
<div className="flex items-center">
<span>{ui_label}</span>
<span className="ml-2 text-xs text-gray-500">{description}</span>
</div>
</Option>
))}
</Select>
);
} else if (key === "budget_duration") {
return (
<BudgetDurationDropdown
value={editedValues[key] || null}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
/>
);
} else if (type === "boolean") {
return (
<div className="mt-2">
<Switch checked={!!editedValues[key]} onChange={(checked) => handleTextInputChange(key, checked)} />
</div>
);
} else if (type === "array" && property.items?.enum) {
return (
<Select
mode="multiple"
style={{ width: "100%" }}
value={editedValues[key] || []}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
>
{property.items.enum.map((option: string) => (
<Option key={option} value={option}>
{option}
</Option>
))}
</Select>
);
} else if (key === "models") {
return (
<Select
mode="multiple"
style={{ width: "100%" }}
value={editedValues[key] || []}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
>
<Option key="no-default-models" value="no-default-models">
No Default Models
</Option>
<Option key="all-proxy-models" value="all-proxy-models">
All Proxy Models
</Option>
{availableModels.map((model: string) => (
<Option key={model} value={model}>
{getModelDisplayName(model)}
</Option>
))}
</Select>
);
} else if (type === "string" && property.enum) {
return (
<Select
style={{ width: "100%" }}
value={editedValues[key] || ""}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
>
{property.enum.map((option: string) => (
<Option key={option} value={option}>
{option}
</Option>
))}
</Select>
);
} else {
return (
<TextInput
value={editedValues[key] !== undefined ? String(editedValues[key]) : ""}
onChange={(e) => handleTextInputChange(key, e.target.value)}
placeholder={property.description || ""}
className="mt-2"
/>
);
}
};
const renderValue = (key: string, value: any): JSX.Element => {
if (value === null || value === undefined) return <span className="text-gray-400">Not set</span>;
if (key === "teams" && Array.isArray(value)) {
if (value.length === 0) return <span className="text-gray-400">No teams assigned</span>;
const normalizedTeams = normalizeTeams(value);
return (
<div className="space-y-2 mt-1">
{normalizedTeams.map((team, index) => (
<div key={index} className="border rounded-lg p-3 bg-white">
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-sm">
<div>
<span className="font-medium text-gray-600">Team ID:</span>
<p className="text-gray-900">{team.team_id || "Not specified"}</p>
</div>
<div>
<span className="font-medium text-gray-600">Max Budget:</span>
<p className="text-gray-900">
{team.max_budget_in_team !== undefined
? `$${formatNumberWithCommas(team.max_budget_in_team, 4)}`
: "No limit"}
</p>
</div>
<div>
<span className="font-medium text-gray-600">Role:</span>
<p className="text-gray-900 capitalize">{team.user_role}</p>
</div>
</div>
</div>
))}
</div>
);
}
if (key === "user_role" && possibleUIRoles && possibleUIRoles[value]) {
const { ui_label, description } = possibleUIRoles[value];
return (
<div>
<span className="font-medium">{ui_label}</span>
{description && <p className="text-xs text-gray-500 mt-1">{description}</p>}
</div>
);
}
if (key === "budget_duration") {
return <span>{getBudgetDurationLabel(value)}</span>;
}
if (typeof value === "boolean") {
return <span>{value ? "Enabled" : "Disabled"}</span>;
}
if (key === "models" && Array.isArray(value)) {
if (value.length === 0) return <span className="text-gray-400">None</span>;
return (
<div className="flex flex-wrap gap-2 mt-1">
{value.map((model, index) => (
<span key={index} className="px-2 py-1 bg-blue-100 rounded-sm text-xs">
{getModelDisplayName(model)}
</span>
))}
</div>
);
}
if (typeof value === "object") {
if (Array.isArray(value)) {
if (value.length === 0) return <span className="text-gray-400">None</span>;
return (
<div className="flex flex-wrap gap-2 mt-1">
{value.map((item, index) => (
<span key={index} className="px-2 py-1 bg-blue-100 rounded-sm text-xs">
{typeof item === "object" ? JSON.stringify(item) : String(item)}
</span>
))}
</div>
);
}
return (
<pre className="bg-gray-100 p-2 rounded-sm text-xs overflow-auto mt-1">{JSON.stringify(value, null, 2)}</pre>
);
}
return <span>{String(value)}</span>;
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<Spin size="large" />
</div>
);
}
if (!settings) {
return (
<Card>
<Text>No settings available or you do not have permission to view them.</Text>
</Card>
);
}
// Dynamically render settings based on the schema
const renderSettings = () => {
const { values, field_schema } = settings;
if (!field_schema || !field_schema.properties) {
return <Text>No schema information available</Text>;
}
return Object.entries(field_schema.properties).map(([key, property]: [string, any]) => {
const value = values[key];
const displayName = key.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
return (
<div key={key} className="mb-6 pb-6 border-b border-gray-200 last:border-0">
<Text className="font-medium text-lg">{displayName}</Text>
<Paragraph className="text-sm text-gray-500 mt-1">
{property.description || "No description available"}
</Paragraph>
{isEditing ? (
<div className="mt-2">{renderEditableField(key, property, value)}</div>
) : (
<div className="mt-1 p-2 bg-gray-50 rounded-sm">{renderValue(key, value)}</div>
)}
</div>
);
});
};
return (
<Card>
<div className="flex justify-between items-center mb-4">
<Title>Default User Settings</Title>
{!loading &&
settings &&
(isEditing ? (
<div className="flex gap-2">
<Button
onClick={() => {
setIsEditing(false);
setEditedValues(settings.values || {});
}}
disabled={saving}
>
Cancel
</Button>
<Button type="primary" onClick={handleSaveSettings} loading={saving}>
Save Changes
</Button>
</div>
) : (
<Button type="primary" onClick={() => setIsEditing(true)}>
Edit Settings
</Button>
))}
</div>
{settings?.field_schema?.description && (
<Paragraph className="mb-4">{settings.field_schema.description}</Paragraph>
)}
<Divider />
<div className="mt-4 space-y-4">{renderSettings()}</div>
</Card>
);
};
export default DefaultUserSettings;

View file

@ -0,0 +1,296 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useInfiniteTeams: () => ({
data: {
pages: [
{
teams: [
{ team_id: "team-alpha", team_alias: "Alpha" },
{ team_id: "team-beta", team_alias: "Beta" },
],
},
],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
}),
}));
vi.mock("@/components/ModelSelect/ModelSelect", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/components/ModelSelect/ModelSelect")>();
return {
MODEL_SENTINEL_OPTIONS: actual.MODEL_SENTINEL_OPTIONS,
ModelSelect: ({ onChange }: { onChange: (values: string[]) => void }) => (
<button type="button" onClick={() => onChange(["all-proxy-models"])}>
set-models
</button>
),
};
});
import NotificationsManager from "@/components/molecules/notifications_manager";
import { DefaultUserSettingsForm } from "./DefaultUserSettingsForm";
import type { InternalUserSettings } from "./mapper";
const POSSIBLE_UI_ROLES = {
internal_user: { ui_label: "Internal User", description: "create and view own keys" },
internal_user_viewer: { ui_label: "Internal Viewer", description: "view own keys" },
proxy_admin: { ui_label: "Admin", description: "all permissions" },
};
const SETTINGS: InternalUserSettings = {
values: {
user_role: "internal_user",
max_budget: 100,
budget_duration: "30d",
models: ["gpt-5.2"],
teams: [{ team_id: "team-alpha", max_budget_in_team: 25, user_role: "user" }],
},
field_schema: {},
};
const SAVED_BODY = {
user_role: "internal_user",
max_budget: 100,
budget_duration: "30d",
models: ["gpt-5.2"],
teams: [{ team_id: "team-alpha", max_budget_in_team: 25, user_role: "user" }],
};
const renderForm = (overrides?: {
fetchSettings?: ReturnType<typeof vi.fn>;
updateSettings?: ReturnType<typeof vi.fn>;
}) => {
const fetchSettings = overrides?.fetchSettings ?? vi.fn().mockResolvedValue(SETTINGS);
const updateSettings = overrides?.updateSettings ?? vi.fn().mockResolvedValue(undefined);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<DefaultUserSettingsForm
possibleUIRoles={POSSIBLE_UI_ROLES}
fetchSettings={fetchSettings}
updateSettings={updateSettings}
/>
</QueryClientProvider>,
);
return { fetchSettings, updateSettings };
};
const saveButton = async () => await screen.findByRole("button", { name: "Save Changes" });
const enterEditMode = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(await screen.findByRole("button", { name: "Edit Settings" }));
};
describe("DefaultUserSettingsForm", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows a read-only summary until Edit Settings is clicked", async () => {
renderForm();
expect(await screen.findByText("Internal User")).toBeInTheDocument();
expect(screen.getByText("100")).toBeInTheDocument();
expect(screen.getByText("monthly")).toBeInTheDocument();
expect(screen.getByText("gpt-5.2")).toBeInTheDocument();
expect(screen.getByText(/team-alpha/)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save Changes" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("Max Budget (USD)")).not.toBeInTheDocument();
});
it("labels model sentinels in the read-only summary", async () => {
renderForm({
fetchSettings: vi
.fn()
.mockResolvedValue({ ...SETTINGS, values: { ...SETTINGS.values, models: ["all-proxy-models"] } }),
});
expect(await screen.findByText("All Proxy Models")).toBeInTheDocument();
});
it("disables Save until the loaded settings are edited", async () => {
const user = userEvent.setup();
renderForm();
await enterEditMode(user);
expect(await saveButton()).toBeDisabled();
});
it("shows an error instead of the form when the settings cannot be loaded", async () => {
renderForm({ fetchSettings: vi.fn().mockRejectedValue(new Error("nope")) });
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load the default user settings.");
expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save Changes" })).not.toBeInTheDocument();
});
it("sends every field on save, not only the edited one", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.clear(await screen.findByLabelText("Max Budget (USD)"));
await user.type(screen.getByLabelText("Max Budget (USD)"), "250");
await user.click(await saveButton());
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
expect(updateSettings).toHaveBeenCalledWith({ ...SAVED_BODY, max_budget: 250 });
});
it("clears an emptied budget with null", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.clear(await screen.findByLabelText("Max Budget (USD)"));
await user.click(await saveButton());
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
expect(updateSettings).toHaveBeenCalledWith({ ...SAVED_BODY, max_budget: null });
});
it("sends the models selection through unchanged, sentinel values included", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.click(await screen.findByRole("button", { name: "set-models" }));
await user.click(await saveButton());
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
expect(updateSettings).toHaveBeenCalledWith({ ...SAVED_BODY, models: ["all-proxy-models"] });
});
it("saves a team that was picked from the searchable list", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.click(await screen.findByRole("button", { name: "Add Team" }));
await user.click(screen.getAllByLabelText("Team")[1]);
await user.click(await screen.findByText("Beta"));
await user.click(await saveButton());
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
expect(updateSettings).toHaveBeenCalledWith({
...SAVED_BODY,
teams: [
{ team_id: "team-alpha", max_budget_in_team: 25, user_role: "user" },
{ team_id: "team-beta", max_budget_in_team: null, user_role: "user" },
],
});
});
it("never turns a team id typed into the picker into a saved team", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.click(await screen.findByRole("button", { name: "Add Team" }));
await user.type(screen.getAllByLabelText("Team")[1], "team-alhpa");
await user.keyboard("{Escape}");
await user.click(await saveButton());
expect(await screen.findByText("Select a team")).toBeInTheDocument();
expect(screen.getAllByLabelText("Team")[1]).toHaveValue("");
expect(updateSettings).not.toHaveBeenCalled();
});
it("blocks saving the same default team twice", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.click(await screen.findByRole("button", { name: "Add Team" }));
await user.click(screen.getAllByLabelText("Team")[1]);
await user.click(await screen.findByText("Alpha"));
await user.click(await saveButton());
expect(await screen.findByText("This team is already listed")).toBeInTheDocument();
expect(updateSettings).not.toHaveBeenCalled();
});
it("drops a removed team row from the saved settings", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.click(await screen.findByRole("button", { name: "Remove" }));
await user.click(await saveButton());
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
expect(updateSettings).toHaveBeenCalledWith({ ...SAVED_BODY, teams: null });
});
it("returns to the read-only view showing the new values after a successful save", async () => {
const user = userEvent.setup();
const updated = { ...SETTINGS, values: { ...SETTINGS.values, max_budget: 250 } };
const { updateSettings } = renderForm({
fetchSettings: vi.fn().mockResolvedValueOnce(SETTINGS).mockResolvedValue(updated),
});
await enterEditMode(user);
await user.clear(await screen.findByLabelText("Max Budget (USD)"));
await user.type(screen.getByLabelText("Max Budget (USD)"), "250");
await user.click(await saveButton());
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
expect(await screen.findByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
expect(await screen.findByText("250")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save Changes" })).not.toBeInTheDocument();
expect(NotificationsManager.success).toHaveBeenCalledWith("Default user settings updated successfully");
await enterEditMode(user);
expect(await saveButton()).toBeDisabled();
});
it("keeps the edit and surfaces the backend error when the save fails", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm({
updateSettings: vi.fn().mockRejectedValue(new Error("Team(s) not found: team-alhpa.")),
});
await enterEditMode(user);
await user.clear(await screen.findByLabelText("Max Budget (USD)"));
await user.type(screen.getByLabelText("Max Budget (USD)"), "250");
await user.click(await saveButton());
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith("Team(s) not found: team-alhpa."),
);
expect(await saveButton()).toBeEnabled();
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(250);
});
it("discards edits and returns to the read-only view when Cancel is pressed", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.clear(await screen.findByLabelText("Max Budget (USD)"));
await user.type(screen.getByLabelText("Max Budget (USD)"), "250");
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(await screen.findByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
expect(screen.getByText("100")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save Changes" })).not.toBeInTheDocument();
expect(updateSettings).not.toHaveBeenCalled();
await enterEditMode(user);
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(100);
expect(await saveButton()).toBeDisabled();
});
});

Some files were not shown because too many files have changed in this diff Show more