Merge remote-tracking branch 'origin/main' into litellm_customer_hierarchy_enforcement

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

# Conflicts:
#	pyproject.toml
This commit is contained in:
kerry 2026-11-13 01:09:21 +00:00
commit c9ea4e20db
103 changed files with 6971 additions and 394 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View file

@ -1,3 +1,5 @@
from hashlib import sha256
from json import dumps
from typing import TYPE_CHECKING, Final
from litellm.accounting.delivery_projection import entity_transactions, project_deliveries
@ -30,6 +32,7 @@ async def apply_deliveries(
await writer.apply_accounting_entities(
prisma_client, proxy_logging_obj, transaction, entity_transactions(contributions)
)
await _apply_customer_usage(transaction, contributions)
for table in sorted(DAILY_SPEND_TABLES.values(), key=lambda item: item.name):
rows = tuple(
{**dict(item.key), **dict(item.values), **dict(item.increments)}
@ -60,6 +63,45 @@ async def apply_deliveries(
return contributions
async def _apply_customer_usage(transaction: LedgerTransaction, contributions: tuple[Contribution, ...]) -> None:
for item in contributions:
if item.table == "LiteLLM_DailyCustomerMemberSpend":
await _upsert_customer_daily(transaction, item)
if item.table == "LiteLLM_CustomerOrganizationTable":
await transaction.litellm_customerorganizationtable.update_many(
where=dict(item.key), data={"spend": {"increment": dict(item.increments)["spend"]}}
)
if item.table == "LiteLLM_CustomerTeamTable":
await transaction.litellm_customerteamtable.update_many(
where=dict(item.key), data={"spend": {"increment": dict(item.increments)["spend"]}}
)
if item.table == "LiteLLM_CustomerMemberTable":
await transaction.litellm_customermembertable.update_many(
where=dict(item.key), data={"spend": {"increment": dict(item.increments)["spend"]}}
)
async def _upsert_customer_daily(transaction: LedgerTransaction, contribution: Contribution) -> None:
row_id: Final = _customer_daily_row_id(contribution.key)
await transaction.litellm_dailycustomermemberspend.upsert(
where={"id": row_id},
data={
"create": {
"id": row_id,
**dict(contribution.key),
**dict(contribution.values),
**dict(contribution.increments),
},
"update": {name: {"increment": value} for name, value in contribution.increments},
},
)
def _customer_daily_row_id(key: tuple[tuple[str, str], ...]) -> str:
encoded: Final = dumps(sorted(key), separators=(",", ":")).encode("utf-8")
return sha256(encoded).hexdigest()
def _window(item: Contribution) -> WindowSpendTransaction:
key: Final = dict(item.key)
values: Final = dict(item.values)

View file

@ -14,6 +14,7 @@ from litellm.constants import (
MODEL_INSIGHTS_TASK_TAG_PREFIX,
)
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import GuardrailCostByUnitEntry
from litellm.proxy.common_utils.customer_usage import CUSTOMER_USAGE_METADATA_KEY, CustomerUsageAttribution
from litellm.proxy.db.daily_spend_bulk_upsert import DAILY_SPEND_TABLES, daily_spend_entity_ids
from litellm.proxy.db.model_insights_tasks import load_model_insight_tasks
from litellm.proxy.db.model_usage_rollup import date_from_start_time, utc_date_from_start_time
@ -221,10 +222,53 @@ def project(row: Mapping[str, object]) -> tuple[Contribution, ...]:
*_window_contributions(row, metadata),
*_model_contributions(row, metadata),
*_guardrail_contributions(row, metadata),
*_customer_usage_contributions(row, metadata),
)
return tuple(_capped(contribution) for contribution in contributions if _indexable(contribution))
def _customer_usage_contributions(
row: Mapping[str, object], metadata: Mapping[str, object]
) -> tuple[Contribution, ...]:
serialized: Final = metadata.get(CUSTOMER_USAGE_METADATA_KEY)
date: Final = _date(row)
if not isinstance(serialized, str) or date is None:
return ()
try:
attribution: Final = CustomerUsageAttribution.model_validate_json(serialized)
except ValidationError:
return ()
counters: Final = _daily_counters(row, metadata)
identities: Final = (
("LiteLLM_CustomerOrganizationTable", "customer_org_id", attribution.customer_org_id),
("LiteLLM_CustomerTeamTable", "customer_team_id", attribution.customer_team_id),
("LiteLLM_CustomerMemberTable", "customer_member_id", attribution.customer_member_id),
)
return (
Contribution(
"LiteLLM_DailyCustomerMemberSpend",
(
("date", date),
("customer_org_id", attribution.customer_org_id),
("customer_team_id", attribution.customer_team_id or ""),
("customer_member_id", attribution.customer_member_id or ""),
("model", _text(row.get("model"))),
("model_group", _text(row.get("model_group"))),
("custom_llm_provider", _text(row.get("custom_llm_provider"))),
("api_key", _text(row.get("api_key"))),
("mcp_namespaced_tool_name", _text(row.get("mcp_namespaced_tool_name"))),
("endpoint", _text(ROUTE_ENDPOINT_MAPPING.get(_text(row.get("call_type"))))),
),
counters,
),
*(
_spend(table, ((column, identity),), float(_number(row.get("spend"))))
for table, column, identity in identities
if identity is not None
),
)
def _indexable(contribution: Contribution) -> bool:
texts: Final = (value for _, value in (*contribution.key, *contribution.values) if value is not None)
return all(len(text.encode("utf-8", "surrogatepass")) <= _MAX_INDEXED_TEXT_BYTES for text in texts)

View file

@ -31,6 +31,7 @@ _METADATA_FIELDS: Final = frozenset(
"compression_savings",
"internal_call_origin",
"billed_retry_attempt",
"user_api_key_customer_usage",
}
)

View file

@ -1144,11 +1144,29 @@ def _served_or_priced_requested_model(
if served in (requested, requested.partition("/")[2]):
return served
pricing_provider: Final = _get_provider_for_cost_calc(requested, custom_llm_provider)
profile_of_served: Final = _is_bedrock_cross_region_profile_of(requested_model, served_model, pricing_provider)
if profile_of_served and _resolves_to_rates(requested, pricing_provider):
return requested
if _resolves_to_rates(served, pricing_provider):
return served
return requested if _resolves_to_rates(requested, pricing_provider) else served
def _is_bedrock_cross_region_profile_of(profile: str, served_model: str, custom_llm_provider: str | None) -> bool:
if custom_llm_provider != "bedrock":
return False
from litellm.llms.bedrock.common_utils import (
get_bedrock_cross_region_inference_regions,
strip_bedrock_routing_prefix,
)
region, _, profiled_model = strip_bedrock_routing_prefix(profile).partition(".")
return (
region in get_bedrock_cross_region_inference_regions()
and strip_bedrock_routing_prefix(served_model) == profiled_model
)
def _strip_unregistered_leading_segments(model: str, region_name: str | None) -> str:
"""Resolve a provider-prefixed slash alias like "vertex_ai/vertex/claude-opus-5" to the
registered cost key ("vertex_ai/claude-opus-5"), keeping the model unchanged when it already

View file

@ -1,6 +1,6 @@
[project]
name = "litellm-enterprise"
version = "0.1.83"
version = "0.1.84"
description = "Package for LiteLLM Enterprise features"
readme = "README.md"
requires-python = ">=3.9"
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
module-root = ""
[tool.commitizen]
version = "0.1.83"
version = "0.1.84"
version_files = [
"pyproject.toml:^version",
"../../pyproject.toml:litellm-enterprise==",

View file

@ -308,6 +308,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
"LiteLLM_DailyUserSpend",
"LiteLLM_DailyGlobalSpend",
"LiteLLM_DailyOrganizationSpend",
"LiteLLM_DailyCustomerMemberSpend",
"LiteLLM_DailyEndUserSpend",
"LiteLLM_DailyAgentSpend",
"LiteLLM_DailyTeamSpend",

View file

@ -1,7 +1,9 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Final
import httpx
from pydantic import TypeAdapter
from litellm.llms.base_llm.chat.transformation import BaseConfig
from litellm.types.llms.openai import AllEmbeddingInputValues, AllMessageValues
@ -16,6 +18,24 @@ else:
LiteLLMLoggingObj = Any
_BODY_FIELDS: Final = TypeAdapter(dict[str, object])
def extra_body_fields(optional_params: Mapping[str, object] | None) -> Mapping[str, object]:
"""The caller's ``extra_body`` fields, merged under the provider body after the transform so they add fields but never
replace the input or a mapped param."""
extra_body: Final = (optional_params or {}).get("extra_body")
return _BODY_FIELDS.validate_python(extra_body) if isinstance(extra_body, Mapping) else {}
def split_extra_body(
optional_params: Mapping[str, object] | None,
) -> tuple[Mapping[str, object], Mapping[str, object]]:
"""The params without ``extra_body``, and the ``extra_body`` fields to merge into the provider body."""
params: Final = _BODY_FIELDS.validate_python(optional_params or {})
return {k: v for k, v in params.items() if k != "extra_body"}, extra_body_fields(params)
class BaseEmbeddingConfig(BaseConfig, ABC):
@abstractmethod
def transform_embedding_request(

View file

@ -6,6 +6,7 @@ import copy
import json
import urllib.parse
from collections.abc import Callable, Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, ClassVar, Final, get_args, overload
import httpx
@ -15,6 +16,7 @@ import litellm
from litellm.constants import BEDROCK_EMBEDDING_PROVIDERS_LITERAL
from litellm.litellm_core_utils.hidden_params import set_hidden_param
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.base_llm.embedding.transformation import extra_body_fields
from litellm.llms.cohere.embed.handler import embedding as cohere_embedding
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
@ -103,7 +105,7 @@ class BedrockEmbedding(BaseAWSLLM):
region_from_model: str | None = None,
) -> tuple[Credentials | None, str]:
auth_params: Final = pop_aws_auth_params(optional_params)
explicit_region_name: Final = optional_params.pop("aws_region_name", None)
explicit_region_name: Final = _OPTIONAL_STR.validate_python(optional_params.pop("aws_region_name", None))
aws_region_name = region_from_model if explicit_region_name is None else explicit_region_name
### SET REGION NAME ###
@ -291,9 +293,10 @@ class BedrockEmbedding(BaseAWSLLM):
provider: BEDROCK_EMBEDDING_PROVIDERS_LITERAL,
api_key: str | None = None,
is_async_invoke: bool | None = False,
extra_body: Mapping[str, object] = MappingProxyType({}),
):
responses: Final[list[dict]] = []
for data in batch_data:
for data in ({**extra_body, **item} for item in batch_data):
headers = {"Content-Type": "application/json"}
if extra_headers is not None:
headers = {"Content-Type": "application/json", **extra_headers}
@ -360,9 +363,10 @@ class BedrockEmbedding(BaseAWSLLM):
provider: BEDROCK_EMBEDDING_PROVIDERS_LITERAL,
api_key: str | None = None,
is_async_invoke: bool | None = False,
extra_body: Mapping[str, object] = MappingProxyType({}),
):
responses: Final[list[dict]] = []
for data in batch_data:
for data in ({**extra_body, **item} for item in batch_data):
headers = {"Content-Type": "application/json"}
if extra_headers is not None:
headers = {"Content-Type": "application/json", **extra_headers}
@ -462,9 +466,12 @@ class BedrockEmbedding(BaseAWSLLM):
)
inference_params = copy.deepcopy(optional_params)
inference_params = {
k: v for k, v in inference_params.items() if k.lower() not in self.aws_authentication_params
k: v
for k, v in inference_params.items()
if k.lower() not in self.aws_authentication_params and k != "extra_body"
}
inference_params.pop("user", None) # make sure user is not passed in for bedrock call
extra_body: Final = extra_body_fields(optional_params)
data: CohereEmbeddingRequest | None = None
batch_data: list | None = None
@ -535,6 +542,8 @@ class BedrockEmbedding(BaseAWSLLM):
)
batch_data.append(nova_request)
request_data: Final = None if data is None else {**extra_body, **data}
### SET RUNTIME ENDPOINT ###
endpoint_url, proxy_endpoint_url = self.get_runtime_endpoint(
api_base=api_base,
@ -552,6 +561,7 @@ class BedrockEmbedding(BaseAWSLLM):
client=(client if client is not None and isinstance(client, AsyncHTTPHandler) else None),
timeout=timeout,
batch_data=batch_data,
extra_body=extra_body,
credentials=credentials,
extra_headers=extra_headers,
endpoint_url=endpoint_url,
@ -566,6 +576,7 @@ class BedrockEmbedding(BaseAWSLLM):
client=(client if client is not None and isinstance(client, HTTPHandler) else None),
timeout=timeout,
batch_data=batch_data,
extra_body=extra_body,
credentials=credentials,
extra_headers=extra_headers,
endpoint_url=endpoint_url,
@ -579,7 +590,7 @@ class BedrockEmbedding(BaseAWSLLM):
if returned_response is None:
raise Exception("Unable to map Bedrock request to provider")
return returned_response
elif data is None:
elif request_data is None:
raise Exception("Unable to map Bedrock request to provider")
headers = {"Content-Type": "application/json"}
@ -591,7 +602,7 @@ class BedrockEmbedding(BaseAWSLLM):
aws_region_name=aws_region_name,
extra_headers=extra_headers,
endpoint_url=endpoint_url,
data=json.dumps(data),
data=json.dumps(request_data),
headers=headers,
api_key=api_key,
)
@ -607,7 +618,7 @@ class BedrockEmbedding(BaseAWSLLM):
logging_obj=logging_obj,
optional_params=optional_params,
encoding=encoding,
data=data,
data=request_data,
complete_api_base=prepped.url,
api_key=None,
aembedding=aembedding,

View file

@ -66,7 +66,7 @@ class NvidiaNimEmbeddingConfig:
self,
non_default_params: dict,
optional_params: dict,
kwargs: dict | None = None,
kwargs: Mapping[str, object] | None = None,
):
if "extra_body" not in optional_params:
optional_params["extra_body"] = {}
@ -80,5 +80,11 @@ class NvidiaNimEmbeddingConfig:
if kwargs is not None:
# pass kwargs in extra_body
optional_params["extra_body"].update(kwargs)
nested_extra_body: Final = kwargs.get("extra_body")
optional_params["extra_body"].update(
{
**{k: v for k, v in kwargs.items() if k != "extra_body"},
**(nested_extra_body if isinstance(nested_extra_body, Mapping) else {}),
}
)
return optional_params

View file

@ -14,6 +14,7 @@ from typing_extensions import NotRequired, ReadOnly
import litellm
from litellm.litellm_core_utils.core_helpers import require_attributes
from litellm.litellm_core_utils.url_utils import ensure_api_route
from litellm.llms.base_llm.embedding.transformation import split_extra_body
from litellm.types.mcp import has_header
from litellm.types.utils import EmbeddingResponse
@ -45,14 +46,15 @@ def _prepare_ollama_embedding_payload(
data: Final[dict[str, object]] = {"model": model, "input": prompts}
special_optional_params: Final = ["truncate", "options", "keep_alive", "dimensions"]
for k, v in optional_params.items():
body_params, extra_body = split_extra_body(optional_params)
for k, v in body_params.items():
if k in special_optional_params:
data[k] = v
else:
data.setdefault("options", {})
if isinstance(data["options"], dict):
data["options"].update({k: v})
return data
return {**extra_body, **data}
def _process_ollama_embedding_response(

View file

@ -14,6 +14,7 @@ from litellm.litellm_core_utils.asyncify import asyncify
from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.optional_imports import ensure_optional_import
from litellm.llms.base_llm.embedding.transformation import extra_body_fields
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM, pop_aws_auth_params
from litellm.llms.bedrock.web_identity_session_policy import SAGEMAKER_SESSION_POLICY, WebIdentitySessionPolicy
from litellm.llms.custom_httpx.http_handler import (
@ -594,8 +595,11 @@ class SagemakerLLM(BaseAWSLLM):
inference_component_name: Final = embedding_params.get("model_id")
if inference_component_name is not None and not isinstance(inference_component_name, str):
raise SagemakerError(status_code=400, message="model_id must be the inference component name as a string")
body_params: Final = {k: v for k, v in embedding_params.items() if k != "model_id"}
request_data: Final = provider_config.transform_embedding_request(endpoint_name, input, body_params, {})
body_params: Final = {k: v for k, v in embedding_params.items() if k not in ("model_id", "extra_body")}
request_data: Final = {
**extra_body_fields(embedding_params),
**provider_config.transform_embedding_request(endpoint_name, input, body_params, {}),
}
data: Final = json.dumps(request_data).encode("utf-8")
invoke_args: Final = _InvokeEndpointArgs(
EndpointName=endpoint_name,

View file

@ -10,6 +10,7 @@ from typing import Annotated, Final, Literal, cast
from pydantic import ConfigDict, Field, TypeAdapter, ValidationError
from litellm.exceptions import BadRequestError
from litellm.llms.base_llm.embedding.transformation import extra_body_fields
from litellm.llms.vertex_ai.common_utils import GEMINI_FILES_API_URI_PREFIX, gemini_video_metadata_from_openai
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.llms.vertex_ai import (
@ -414,6 +415,7 @@ def transform_openai_input_gemini_content(
gemini_model_name: Final = f"models/{model}"
gemini_params: Final = _filter_embed_params(optional_params)
extra_body: Final = extra_body_fields(optional_params)
input_list: Final = _top_level_elements(input)
requests: Final[list[EmbedContentRequest]] = []
@ -432,7 +434,11 @@ def transform_openai_input_gemini_content(
content=ContentType(parts=parts),
**gemini_params,
)
requests.append(request)
requests.append(
cast( # cast-ok: extra_body carries raw Gemini fields the TypedDict does not list
EmbedContentRequest, {**extra_body, **request}
)
)
return VertexAIBatchEmbeddingsRequestBody(requests=requests)
@ -472,6 +478,7 @@ def transform_openai_input_gemini_embed_content(
parts.append(_build_part_for_input(element, resolved_files=resolved_files, drop_params=drop_params))
request_body: Final[dict] = {
**extra_body_fields(optional_params),
"content": ContentType(parts=parts),
**gemini_params,
}

View file

@ -21,7 +21,7 @@ from litellm.types.utils import (
)
from litellm.utils import count_characters, is_base64_encoded
from ...base_llm.embedding.transformation import BaseEmbeddingConfig
from ...base_llm.embedding.transformation import BaseEmbeddingConfig, extra_body_fields
from ..common_utils import VertexAIError
@ -189,8 +189,11 @@ class VertexAIMultimodalEmbeddingConfig(BaseEmbeddingConfig):
request_data["instances"] = [vertex_request_instance]
extra_body: Final = extra_body_fields(optional_params)
if "outputDimensionality" in optional_params:
request_data["parameters"] = {"dimension": optional_params["outputDimensionality"]}
request_data["parameters"] = {**extra_body, "dimension": optional_params["outputDimensionality"]}
elif extra_body:
request_data["parameters"] = dict(extra_body)
return cast(dict, request_data)

View file

@ -1,10 +1,11 @@
import types
from collections.abc import Mapping
from typing import Final, Literal
from typing import Final, Literal, cast # noqa: TID251 # extra_body carries raw fields the TypedDict lacks
from pydantic import TypeAdapter
from typing_extensions import ReadOnly, TypedDict
from litellm.llms.base_llm.embedding.transformation import split_extra_body
from litellm.llms.vertex_ai.common_utils import pop_vertex_request_labels
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.utils import EmbeddingResponse, PromptTokensDetailsWrapper, Usage
@ -164,7 +165,12 @@ class VertexAITextEmbeddingConfig(LiteLLMBaseModel):
vertex_text_embedding_input_list.append(embedding_input)
vertex_request["instances"] = vertex_text_embedding_input_list
vertex_request["parameters"] = EmbeddingParameters(**optional_params)
body_params, extra_body = split_extra_body(optional_params)
vertex_request["parameters"] = (
cast( # cast-ok: extra_body carries raw Vertex fields the TypedDict does not list
EmbeddingParameters, {**extra_body, **body_params}
)
)
if labels:
vertex_request["labels"] = labels
@ -205,7 +211,12 @@ class VertexAITextEmbeddingConfig(LiteLLMBaseModel):
vertex_text_embedding_input_list.append(embedding_input)
vertex_request["instances"] = vertex_text_embedding_input_list
vertex_request["parameters"] = TextEmbeddingFineTunedParameters(**optional_params)
body_params, extra_body = split_extra_body(optional_params)
vertex_request["parameters"] = (
cast( # cast-ok: extra_body carries raw Vertex fields the TypedDict does not list
TextEmbeddingFineTunedParameters, {**extra_body, **body_params}
)
)
# Remove 'shared_session' from parameters if present
if vertex_request["parameters"] is not None and "shared_session" in vertex_request["parameters"]:
del vertex_request["parameters"]["shared_session"]

View file

@ -28650,7 +28650,7 @@
"mode": "chat",
"output_cost_per_token": 2.8e-07,
"source": "https://docs.fireworks.ai/serverless/pricing",
"successor_model": "fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731",
"successor_model": "fireworks_ai/accounts/fireworks/models/deepseek-v4p1-flash",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -29124,7 +29124,7 @@
"mode": "chat",
"output_cost_per_token": 2.8e-07,
"source": "https://docs.fireworks.ai/serverless/pricing",
"successor_model": "fireworks_ai/deepseek-v4-flash-0731",
"successor_model": "fireworks_ai/deepseek-v4p1-flash",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -83697,7 +83697,7 @@
"mode": "chat",
"output_cost_per_token": 2.1e-07,
"source": "https://api.fireworks.ai/v1/serverless/models",
"successor_model": "fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731",
"successor_model": "fireworks_ai/accounts/fireworks/models/deepseek-v4p1-flash",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,

View file

@ -0,0 +1,47 @@
SET LOCAL lock_timeout = '10s';
CREATE TABLE IF NOT EXISTS "LiteLLM_DailyCustomerMemberSpend" (
"id" TEXT NOT NULL,
"date" TEXT NOT NULL,
"customer_org_id" TEXT NOT NULL,
"customer_team_id" TEXT NOT NULL DEFAULT '',
"customer_member_id" TEXT NOT NULL DEFAULT '',
"model" TEXT NOT NULL DEFAULT '',
"model_group" TEXT NOT NULL DEFAULT '',
"custom_llm_provider" TEXT NOT NULL DEFAULT '',
"mcp_namespaced_tool_name" TEXT NOT NULL DEFAULT '',
"endpoint" TEXT NOT NULL DEFAULT '',
"api_key" TEXT NOT NULL DEFAULT '',
"prompt_tokens" BIGINT NOT NULL DEFAULT 0,
"completion_tokens" BIGINT NOT NULL DEFAULT 0,
"cache_read_input_tokens" BIGINT NOT NULL DEFAULT 0,
"cache_creation_input_tokens" BIGINT NOT NULL DEFAULT 0,
"compression_saved_tokens" BIGINT NOT NULL DEFAULT 0,
"api_requests" BIGINT NOT NULL DEFAULT 0,
"successful_requests" BIGINT NOT NULL DEFAULT 0,
"failed_requests" BIGINT NOT NULL DEFAULT 0,
"total_response_time_ms" BIGINT NOT NULL DEFAULT 0,
"timed_requests" BIGINT NOT NULL DEFAULT 0,
"spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
"compression_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
"prompt_caching_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
"gateway_injected_caching_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
"autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
"guardrail_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LiteLLM_DailyCustomerMemberSpend_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "LiteLLM_DailyCustomerMemberSpend_date_idx"
ON "LiteLLM_DailyCustomerMemberSpend"("date");
CREATE INDEX IF NOT EXISTS "LiteLLM_DailyCustomerMemberSpend_customer_org_id_date_idx"
ON "LiteLLM_DailyCustomerMemberSpend"("customer_org_id", "date");
CREATE INDEX IF NOT EXISTS "LiteLLM_DailyCustomerMemberSpend_customer_team_id_date_idx"
ON "LiteLLM_DailyCustomerMemberSpend"("customer_team_id", "date");
CREATE INDEX IF NOT EXISTS "LiteLLM_DailyCustomerMemberSpend_customer_member_id_date_idx"
ON "LiteLLM_DailyCustomerMemberSpend"("customer_member_id", "date");
CREATE INDEX IF NOT EXISTS "LiteLLM_DailyCustomerMemberSpend_api_key_idx"
ON "LiteLLM_DailyCustomerMemberSpend"("api_key");
CREATE INDEX IF NOT EXISTS "LiteLLM_DailyCustomerMemberSpend_model_idx"
ON "LiteLLM_DailyCustomerMemberSpend"("model");

View file

@ -1035,6 +1035,45 @@ model LiteLLM_DailyOrganizationSpend {
}
// Track daily end user (customer) spend metrics per model and key
model LiteLLM_DailyCustomerMemberSpend {
id String @id
date String
customer_org_id String
customer_team_id String @default("")
customer_member_id String @default("")
model String @default("")
model_group String @default("")
custom_llm_provider String @default("")
mcp_namespaced_tool_name String @default("")
endpoint String @default("")
api_key String @default("")
spend Float @default(0.0)
prompt_tokens BigInt @default(0)
completion_tokens BigInt @default(0)
cache_read_input_tokens BigInt @default(0)
cache_creation_input_tokens BigInt @default(0)
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
gateway_injected_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
guardrail_spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([date])
@@index([customer_org_id, date])
@@index([customer_team_id, date])
@@index([customer_member_id, date])
@@index([api_key])
@@index([model])
}
model LiteLLM_DailyEndUserSpend {
id String @id @default(uuid())
end_user_id String?

View file

@ -726,12 +726,15 @@ class LiteLLMRoutes(enum.Enum):
"/jwt/key/mapping/info",
"/management/v1/customer_organizations",
"/management/v1/customer_organizations/{customer_org_id}",
"/management/v1/customer_organizations/daily/activity",
"/management/v1/customer_organizations/{customer_org_id}/default_budgets",
"/management/v1/customer_organizations/{customer_org_id}/default_budgets/{budget_id}",
"/management/v1/customer_teams",
"/management/v1/customer_teams/{customer_team_id}",
"/management/v1/customer_teams/daily/activity",
"/management/v1/customer_members",
"/management/v1/customer_members/{customer_member_id}",
"/management/v1/customer_members/daily/activity",
]
+ key_management_routes
+ mcp_management_routes
@ -3855,6 +3858,7 @@ class SpendLogsRouterMetadata(TypedDict):
class SpendLogsMetadata(TypedDict):
user_api_key_customer_usage: ReadOnly[NotRequired[str | None]]
actor_agent_id: ReadOnly[NotRequired[str | None]]
target_agent_id: ReadOnly[NotRequired[str | None]]
billing_agent_id: ReadOnly[NotRequired[str | None]]

View file

@ -354,6 +354,7 @@ async def _sign_in(
response = await generate_key_helper_fn(
llm_router=None,
request_type="key",
actor=None,
**{
"user_role": LitellmUserRoles.PROXY_ADMIN,
"duration": LITELLM_UI_SESSION_DURATION,

View file

@ -0,0 +1,187 @@
from collections.abc import Mapping
from dataclasses import dataclass
from enum import Enum
from typing import Annotated, Final, Protocol, TypeAlias
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
from litellm.repositories.prisma_protocols import TableActions
CustomerIdentifier: TypeAlias = Annotated[str, Field(strict=True, min_length=1, max_length=256)]
_IDENTITY_FIELDS: Final = ("customer_org_id", "customer_team_id", "customer_member_id")
CUSTOMER_USAGE_METADATA_KEY: Final = "user_api_key_customer_usage"
_FIELDS: Final = TypeAdapter(Mapping[str, object])
class CustomerUsageInput(BaseModel):
model_config = ConfigDict(frozen=True)
customer_org_id: CustomerIdentifier | None = None
customer_team_id: CustomerIdentifier | None = None
customer_member_id: CustomerIdentifier | None = None
class CustomerUsageAttribution(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
customer_org_id: CustomerIdentifier
customer_team_id: CustomerIdentifier | None = None
customer_member_id: CustomerIdentifier | None = None
class _Team(BaseModel):
model_config = ConfigDict(frozen=True, from_attributes=True)
parent_org_id: str
class _Member(BaseModel):
model_config = ConfigDict(frozen=True, from_attributes=True)
parent_team_id: str
class _KeyMetadata(BaseModel):
model_config = ConfigDict(frozen=True, from_attributes=True)
metadata: object = None
class CustomerUsageDatabase(Protocol):
@property
def litellm_customerorganizationtable(self) -> TableActions[object]: ...
@property
def litellm_customerteamtable(self) -> TableActions[object]: ...
@property
def litellm_customermembertable(self) -> TableActions[object]: ...
class CustomerUsageError(str, Enum):
INVALID = "Customer usage metadata contains invalid identifiers."
MISMATCH = "Customer usage identifiers must belong to the same organization and team."
FORBIDDEN = "Customer usage must match the customer organization assigned to this API key."
UNBOUND = "Assign a customer organization to the API key before tracking customer usage."
NOT_FOUND = "The customer usage organization, team, or member does not exist."
UNAVAILABLE = "Customer usage tracking requires a database and spend accounting ledger."
KEY_BINDING_FORBIDDEN = "Only proxy admins can change a key's customer organization, team, or member binding."
@dataclass(frozen=True, slots=True)
class CustomerUsageFailure:
error: CustomerUsageError
def normalize_customer_key_metadata(
metadata: Mapping[str, object] | None,
existing_metadata: Mapping[str, object] | None,
*,
administrator: bool,
) -> Mapping[str, object] | CustomerUsageFailure | None:
if administrator:
return metadata
stored: Final = existing_metadata or {}
requested: Final = metadata or {}
if any(name in requested and requested[name] != stored.get(name) for name in _IDENTITY_FIELDS):
return CustomerUsageFailure(CustomerUsageError.KEY_BINDING_FORBIDDEN)
bindings: Final = {name: stored[name] for name in _IDENTITY_FIELDS if stored.get(name) is not None}
if not bindings:
return metadata
return {**requested, **bindings}
def _input(metadata: Mapping[str, object], binding: Mapping[str, object]) -> CustomerUsageInput | CustomerUsageFailure:
if any(
name in metadata and name in binding and metadata[name] != binding[name]
for name in _IDENTITY_FIELDS
if binding.get(name) is not None
):
return CustomerUsageFailure(CustomerUsageError.FORBIDDEN)
try:
return CustomerUsageInput.model_validate(
{**metadata, **{name: binding[name] for name in _IDENTITY_FIELDS if binding.get(name) is not None}}
)
except ValidationError:
return CustomerUsageFailure(CustomerUsageError.INVALID)
def _hierarchy_failure(error: CustomerUsageError, *, administrator: bool) -> CustomerUsageFailure:
if administrator:
return CustomerUsageFailure(error)
return CustomerUsageFailure(CustomerUsageError.FORBIDDEN)
async def resolve_customer_usage(
*,
metadata: Mapping[str, object],
key_metadata: Mapping[str, object],
administrator: bool,
database: CustomerUsageDatabase | None,
ledger_enabled: bool,
) -> CustomerUsageAttribution | CustomerUsageFailure | None:
if not customer_usage_requested(metadata, key_metadata):
return None
candidate: Final = _input(metadata, key_metadata)
if isinstance(candidate, CustomerUsageFailure):
return candidate
if not administrator and key_metadata.get("customer_org_id") is None:
return CustomerUsageFailure(CustomerUsageError.UNBOUND)
if database is None or not ledger_enabled:
return CustomerUsageFailure(CustomerUsageError.UNAVAILABLE)
member: Final = (
await database.litellm_customermembertable.find_unique(
where={"customer_member_id": candidate.customer_member_id}
)
if candidate.customer_member_id is not None
else None
)
if candidate.customer_member_id is not None and member is None:
return _hierarchy_failure(CustomerUsageError.NOT_FOUND, administrator=administrator)
member_team: Final = _Member.model_validate(member).parent_team_id if member is not None else None
if candidate.customer_team_id is not None and member_team is not None and candidate.customer_team_id != member_team:
return _hierarchy_failure(CustomerUsageError.MISMATCH, administrator=administrator)
team_id: Final = candidate.customer_team_id or member_team
team: Final = (
await database.litellm_customerteamtable.find_unique(where={"customer_team_id": team_id})
if team_id is not None
else None
)
if team_id is not None and team is None:
return _hierarchy_failure(CustomerUsageError.NOT_FOUND, administrator=administrator)
team_org: Final = _Team.model_validate(team).parent_org_id if team is not None else None
if candidate.customer_org_id is not None and team_org is not None and candidate.customer_org_id != team_org:
return _hierarchy_failure(CustomerUsageError.MISMATCH, administrator=administrator)
organization_id: Final = candidate.customer_org_id or team_org
if organization_id is None:
return CustomerUsageFailure(CustomerUsageError.INVALID)
organization: Final = await database.litellm_customerorganizationtable.find_unique(
where={"customer_org_id": organization_id}
)
if organization is None:
return _hierarchy_failure(CustomerUsageError.NOT_FOUND, administrator=administrator)
return CustomerUsageAttribution(
customer_org_id=organization_id,
customer_team_id=team_id,
customer_member_id=candidate.customer_member_id,
)
def customer_usage_requested(metadata: Mapping[str, object], key_metadata: Mapping[str, object]) -> bool:
return any(metadata.get(name) is not None or key_metadata.get(name) is not None for name in _IDENTITY_FIELDS)
def customer_usage_fields(value: object) -> Mapping[str, object]:
return _FIELDS.validate_python(value) if isinstance(value, Mapping) else {}
def customer_usage_key_fields(key: object) -> Mapping[str, object]:
return customer_usage_fields(_KeyMetadata.model_validate(key).metadata)
def customer_usage_http_status(error: CustomerUsageError) -> int:
if error == CustomerUsageError.UNAVAILABLE:
return 503
if error in (CustomerUsageError.UNBOUND, CustomerUsageError.FORBIDDEN, CustomerUsageError.KEY_BINDING_FORBIDDEN):
return 403
return 400

View file

@ -393,6 +393,20 @@ def _tightest_team_share(descriptors: Iterable[RateLimitDescriptor | None]) -> t
return min(shares, key=lambda share: share[1], default=None)
def _tightest_of(*shares: tuple[RateLimitDescriptor, int] | None) -> tuple[RateLimitDescriptor, int] | None:
return min((share for share in shares if share is not None), key=lambda share: share[1], default=None)
def _capped_idle_share(
share: RateLimitDescriptor | None, cap: RateLimitDescriptor | None
) -> tuple[RateLimitDescriptor, int] | None:
"""A capped team's usage while the model is idle sits on its cap counter, read against its share."""
tightest: Final = _tightest_team_share((share,))
if tightest is None or cap is None:
return None
return cap, tightest[1]
def _usage_of(kwargs: Mapping[str, object], response_obj: object) -> Usage | None:
"""Usage of a modeled response, a Responses WebSocket turn's combined usage, or its raw event list."""
usage: Final = getattr(response_obj, "usage", None)
@ -431,7 +445,7 @@ class _BorrowingTeamShare:
ceiling: RateLimitDescriptor | None
def enforced(self, saturated: bool) -> RateLimitDescriptor | None:
return self.share if saturated else self.ceiling
return self.share if saturated else None
def _team_limit_name(rate_limit_type: str) -> str:
@ -483,15 +497,25 @@ def _team_model_settlement(
return ()
served: Final = stash.borrowing_team_model_fallback_limits.get(model_group) if model_group is not None else None
admitted: Final = stash.borrowing_team_model_limit
cap: Final = stash.borrowing_team_model_cap
reserved_tokens: Final = stash.borrowing_team_model_reserved_tokens
cap_reserved_tokens: Final = stash.borrowing_team_model_cap_reserved_tokens
window: Final = stash.borrowing_team_model_reserved_window
cap_window: Final = stash.borrowing_team_model_cap_reserved_window
if served is None:
return _window_guarded(_team_model_token_increment(admitted, total_tokens - reserved_tokens, ttl), window)
return (
*_window_guarded(_team_model_token_increment(admitted, total_tokens - reserved_tokens, ttl), window),
*_window_guarded(_team_model_token_increment(cap, total_tokens - cap_reserved_tokens, ttl), cap_window),
)
return (
*_window_guarded(_team_model_token_increment(admitted, -reserved_tokens, ttl), window),
*_window_guarded(_team_model_token_increment(cap, -cap_reserved_tokens, ttl), cap_window),
*_window_guarded(
_team_model_token_increment(served.descriptor, total_tokens - served.reserved_tokens, ttl), served.window
),
*_window_guarded(
_team_model_token_increment(served.cap, total_tokens - served.cap_reserved_tokens, ttl), served.cap_window
),
)
@ -861,12 +885,16 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
return _BorrowingTeamShare(
limits=limits,
share=self._team_share_descriptor(value, limits),
ceiling=None if ceiling is None else self._team_share_descriptor(value, ceiling),
ceiling=None
if ceiling is None
else self._team_share_descriptor(value, ceiling, TEAM_MODEL_LIMIT_MAX_BORROW_MULTIPLE),
)
def _team_share_descriptor(self, value: str, limits: BorrowingTeamModelLimits) -> RateLimitDescriptor:
def _team_share_descriptor(
self, value: str, limits: BorrowingTeamModelLimits, key: str = TEAM_MODEL_LIMIT_AT_SATURATION
) -> RateLimitDescriptor:
return RateLimitDescriptor(
key=TEAM_MODEL_LIMIT_AT_SATURATION,
key=key,
value=value,
rate_limit={
"tokens_per_unit": limits.tpm,
@ -884,7 +912,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
user_api_key_dict: UserAPIKeyAuth,
team_share: _BorrowingTeamShare,
) -> ProxyRateLimitError:
if saturation >= _get_priority_settings().saturation_threshold:
if status["descriptor_key"] != TEAM_MODEL_LIMIT_MAX_BORROW_MULTIPLE:
return self._team_limit_error(model, status, saturation, priority, user_api_key_dict)
return self._team_borrow_ceiling_error(
model, status, saturation, priority, user_api_key_dict, team_share.limits
@ -925,8 +953,13 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
loggers: Final = _prometheus_loggers()
if not loggers or stash is None or model_group is None or settlement.user_api_key_team_id is None:
return
share: Final = _tightest_team_share(
(stash.borrowing_team_model_limit, stash.priority_class_share) if hold is None else (hold.descriptor,)
share: Final = (
_tightest_of(_tightest_team_share((hold.descriptor,)), _capped_idle_share(hold.share, hold.cap))
if hold is not None
else _tightest_of(
_tightest_team_share((stash.borrowing_team_model_limit, stash.priority_class_share)),
_capped_idle_share(stash.borrowing_team_model_share, stash.borrowing_team_model_cap),
)
)
if share is None:
return
@ -1081,10 +1114,19 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
saturation: Final = await self._check_model_saturation(requested_model, model_group_info)
saturated: Final = saturation >= _get_priority_settings().saturation_threshold
enforced_team: Final = None if borrowing is None else borrowing.enforced(saturated)
team_cap: Final = None if borrowing is None else borrowing.ceiling
if connection_stash is not None and borrowing is not None:
connection_stash.borrowing_team_model_limit = enforced_team
descriptors: Final = (share_descriptors if saturated else ()) + (
() if enforced_team is None else (enforced_team,)
connection_stash.borrowing_team_model_limit = (
connection_stash.borrowing_team_model_limit
if enforced_team is None and connection_stash.borrowing_team_model_reserved_tokens
else enforced_team
)
connection_stash.borrowing_team_model_share = borrowing.share
connection_stash.borrowing_team_model_cap = team_cap
descriptors: Final = (
(share_descriptors if saturated else ())
+ (() if enforced_team is None else (enforced_team,))
+ (() if team_cap is None else (team_cap,))
)
if not descriptors:
return
@ -1229,28 +1271,34 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
if team_share is not None:
descriptors_to_check.append(team_share.share)
enforced_team_share: Final = None if team_share is None else team_share.enforced(should_enforce_priority)
team_cap: Final = None if team_share is None else team_share.ceiling
held_team_share: Final = enforced_team_share if enforced_team_share is not None else team_cap
get_or_create_request_stash().borrowing_team_model_limit = enforced_team_share
get_or_create_request_stash().borrowing_team_model_share = None if team_share is None else team_share.share
get_or_create_request_stash().borrowing_team_model_cap = team_cap
enforced_descriptors: Final[list[RateLimitDescriptor]] = [model_wide_descriptor]
if priority_descriptors and should_enforce_priority:
enforced_descriptors.extend(priority_descriptors)
if enforced_team_share is not None:
enforced_descriptors.append(enforced_team_share)
if team_cap is not None:
enforced_descriptors.append(team_cap)
per_request_increment: Final[dict[Literal["requests", "tokens"], int]] = {
"requests": 1,
"tokens": 0,
}
team_reservation: Final = (
await self._team_token_reservation(enforced_team_share, data, model, call_type, user_api_key_dict)
if enforced_team_share is not None
await self._team_token_reservation(team_share.share, data, model, call_type, user_api_key_dict)
if team_share is not None and held_team_share is not None
else 0
)
atomic_response: Final = await self.v3_limiter.atomic_check_and_increment_by_n(
descriptors=enforced_descriptors,
increments=[
{"requests": 1, "tokens": team_reservation}
if descriptor is enforced_team_share
if descriptor is enforced_team_share or descriptor is team_cap
else per_request_increment
for descriptor in enforced_descriptors
],
@ -1310,7 +1358,10 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
llm_provider=llm_provider,
)
if descriptor_key == TEAM_MODEL_LIMIT_AT_SATURATION and team_share is not None:
if (
descriptor_key in (TEAM_MODEL_LIMIT_AT_SATURATION, TEAM_MODEL_LIMIT_MAX_BORROW_MULTIPLE)
and team_share is not None
):
raise self._team_share_error(model, status, saturation, priority, user_api_key_dict, team_share)
if descriptor_key == PRIORITY_CLASS_SHARE_AT_SATURATION and share is not None:
raise self._class_share_error(model, status, saturation, priority, user_api_key_dict, share)
@ -1355,11 +1406,15 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
# enforced. The increment-then-check semantics of should_rate_limit
# are fine because we ignore the OVER_LIMIT response.
stash: Final = get_or_create_request_stash()
stash.borrowing_team_model_reserved_tokens = team_reservation
stash.borrowing_team_model_reserved_tokens = team_reservation if enforced_team_share is not None else 0
stash.borrowing_team_model_cap_reserved_tokens = team_reservation if team_cap is not None else 0
stash.borrowing_team_model_reserved_window = (
_reserved_window(atomic_response, enforced_team_share) if team_reservation else None
)
self._cap_admitted_output(enforced_team_share, team_reservation, data, call_type)
stash.borrowing_team_model_cap_reserved_window = (
_reserved_window(atomic_response, team_cap) if team_reservation else None
)
self._cap_admitted_output(None if team_share is None else team_share.share, team_reservation, data, call_type)
if priority_descriptors and share is not None and not should_enforce_priority:
get_or_create_request_stash().rate_limit_response = atomic_response
elif priority_descriptors and not should_enforce_priority:
@ -1434,33 +1489,48 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
return True
saturation: Final = await self._check_model_saturation(model, model_group_info)
enforced: Final = team_share.enforced(saturation >= _get_priority_settings().saturation_threshold)
cap: Final = team_share.ceiling
held: Final = enforced if enforced is not None else cap
reservation: Final = (
await self._team_token_reservation(
enforced,
team_share.share,
{**request_data, "model": model},
model,
get_or_create_request_stash().call_type,
user_api_key_dict,
)
if enforced is not None
if held is not None
else 0
)
team_descriptors: Final = tuple(d for d in (enforced, cap) if d is not None)
if not commit:
return enforced is None or await self._share_has_room(enforced, reservation, user_api_key_dict)
for descriptor in team_descriptors:
if not await self._share_has_room(descriptor, reservation, user_api_key_dict):
return False
return True
model_wide_descriptor: Final = self._create_model_tracking_descriptor(
model=model, model_group_info=model_group_info, high_limit_multiplier=1
)
response: Final = await self.v3_limiter.atomic_check_and_increment_by_n(
descriptors=[model_wide_descriptor, *(() if enforced is None else (enforced,))],
descriptors=[model_wide_descriptor, *team_descriptors],
increments=[
{"requests": 1, "tokens": 0},
*(() if enforced is None else ({"requests": 1, "tokens": reservation},)),
*({"requests": 1, "tokens": reservation} for _ in team_descriptors),
],
parent_otel_span=user_api_key_dict.parent_otel_span,
)
if response["overall_code"] == "OVER_LIMIT":
return False
self._hold_fallback_share(model, team_share.share, enforced, reservation, _reserved_window(response, enforced))
self._hold_fallback_share(
model,
team_share.share,
enforced,
reservation if enforced is not None else 0,
_reserved_window(response, enforced),
cap,
reservation if cap is not None else 0,
_reserved_window(response, cap),
)
return True
async def _share_has_room(
@ -1482,16 +1552,34 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
charged: RateLimitDescriptor | None,
reserved_tokens: int,
window: ReservationWindowIdentity | None,
cap: RateLimitDescriptor | None = None,
cap_reserved_tokens: int = 0,
cap_window: ReservationWindowIdentity | None = None,
) -> None:
"""Remember the hold under the requested name and the resolved group, so settlement finds it either way.
``charged`` is the share the target's usage settles to, or None for an idle target whose usage is forgiven.
``cap`` is the team's borrow ceiling on the target, charged whether or not the target is saturated. Each
counter carries an earlier hold on the same target only while that hold sits in the counter's current window.
"""
stash: Final = get_or_create_request_stash()
earlier: Final = stash.borrowing_team_model_fallback_limits.get(model)
carried: Final = earlier.reserved_tokens if earlier is not None and earlier.window == window else 0
carried: Final = (
earlier.reserved_tokens if earlier is not None and window is not None and earlier.window == window else 0
)
carried_cap: Final = (
earlier.cap_reserved_tokens
if earlier is not None and cap_window is not None and earlier.cap_window == cap_window
else 0
)
hold: Final = BorrowingTeamModelHold(
descriptor=charged, reserved_tokens=reserved_tokens + carried, window=window
descriptor=charged,
reserved_tokens=reserved_tokens + carried,
window=window,
cap=cap,
cap_reserved_tokens=cap_reserved_tokens + carried_cap,
cap_window=cap_window,
share=share,
)
stash.borrowing_team_model_fallback_limits = MappingProxyType(
{**stash.borrowing_team_model_fallback_limits, model: hold, share["value"].rpartition(":")[2]: hold}
@ -1814,7 +1902,9 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
)
if stash is not None:
stash.borrowing_team_model_reserved_tokens = 0
stash.borrowing_team_model_cap_reserved_tokens = 0
stash.borrowing_team_model_reserved_window = None
stash.borrowing_team_model_cap_reserved_window = None
stash.borrowing_team_model_fallback_limits = MappingProxyType(
{k: h for k, h in stash.borrowing_team_model_fallback_limits.items() if h is not served}
)
@ -1918,28 +2008,41 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
_usage_of(callback_kwargs, None if isinstance(response_obj, BaseException) else response_obj),
self.v3_limiter.get_rate_limit_type(),
)
own_usage: Final = used if failed_hold is None else 0
own_delta: Final = own_usage - stash.borrowing_team_model_reserved_tokens
own_cap_delta: Final = own_usage - stash.borrowing_team_model_cap_reserved_tokens
refund: Final = (
*_window_guarded(
_team_model_token_increment(
stash.borrowing_team_model_limit,
(used if failed_hold is None else 0) - stash.borrowing_team_model_reserved_tokens,
self.v3_limiter.window_size,
),
_team_model_token_increment(stash.borrowing_team_model_limit, own_delta, self.v3_limiter.window_size),
stash.borrowing_team_model_reserved_window,
),
*_window_guarded(
_team_model_token_increment(stash.borrowing_team_model_cap, own_cap_delta, self.v3_limiter.window_size),
stash.borrowing_team_model_cap_reserved_window,
),
*(
()
if failed_hold is None
else _window_guarded(
_team_model_token_increment(
failed_hold.descriptor, used - failed_hold.reserved_tokens, self.v3_limiter.window_size
else (
*_window_guarded(
_team_model_token_increment(
failed_hold.descriptor, used - failed_hold.reserved_tokens, self.v3_limiter.window_size
),
failed_hold.window,
),
*_window_guarded(
_team_model_token_increment(
failed_hold.cap, used - failed_hold.cap_reserved_tokens, self.v3_limiter.window_size
),
failed_hold.cap_window,
),
failed_hold.window,
)
),
)
stash.borrowing_team_model_reserved_tokens = 0
stash.borrowing_team_model_cap_reserved_tokens = 0
stash.borrowing_team_model_reserved_window = None
stash.borrowing_team_model_cap_reserved_window = None
if failed_hold is not None:
stash.borrowing_team_model_fallback_limits = MappingProxyType(
{k: h for k, h in stash.borrowing_team_model_fallback_limits.items() if h is not failed_hold}

View file

@ -581,6 +581,10 @@ class BorrowingTeamModelHold:
descriptor: RateLimitDescriptor | None
reserved_tokens: int = 0
window: ReservationWindowIdentity | None = None
cap: RateLimitDescriptor | None = None
cap_reserved_tokens: int = 0
cap_window: ReservationWindowIdentity | None = None
share: RateLimitDescriptor | None = None
@dataclass(slots=True)
@ -631,6 +635,10 @@ class RequestRateLimiterStash:
borrowing_team_model_limit: RateLimitDescriptor | None = None
borrowing_team_model_reserved_tokens: int = 0
borrowing_team_model_reserved_window: ReservationWindowIdentity | None = None
borrowing_team_model_share: RateLimitDescriptor | None = None
borrowing_team_model_cap: RateLimitDescriptor | None = None
borrowing_team_model_cap_reserved_tokens: int = 0
borrowing_team_model_cap_reserved_window: ReservationWindowIdentity | None = None
borrowing_team_model_fallback_limits: Mapping[str, BorrowingTeamModelHold] = field(
default_factory=lambda: MappingProxyType({})
)

View file

@ -59,6 +59,7 @@ from litellm.litellm_core_utils.core_helpers import (
iter_items,
keyword_arguments,
normalize_drop_params,
require_attributes,
)
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
@ -93,6 +94,16 @@ from litellm.proxy.common_utils.callback_utils import (
key_logging_entries,
strip_callback_config,
)
from litellm.proxy.common_utils.customer_usage import (
CUSTOMER_USAGE_METADATA_KEY,
CustomerUsageDatabase,
CustomerUsageFailure,
customer_usage_fields,
customer_usage_http_status,
customer_usage_key_fields,
customer_usage_requested,
resolve_customer_usage,
)
from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # legacy module exports
_safe_get_request_headers, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
detach_request_parsed_body,
@ -2716,7 +2727,7 @@ async def add_litellm_data_to_request(
"""
from litellm.proxy.proxy_server import llm_router, premium_user
from litellm.proxy.proxy_server import llm_router, premium_user, prisma_client
from litellm.types.proxy.litellm_pre_call_utils import RedactedDict, SecretFields
# Strip internal-only keys from user input before the proxy sets its own.
@ -2993,6 +3004,33 @@ async def add_litellm_data_to_request(
if _metadata_variable_name == "metadata":
data.pop("litellm_metadata", None)
_customer_metadata: Final = customer_usage_fields(_REQUEST_FIELDS.validate_python(data)[_metadata_variable_name])
_customer_key_metadata: Final = customer_usage_key_fields(user_api_key_dict)
_customer_usage: Final = await resolve_customer_usage(
metadata=_customer_metadata,
key_metadata=_customer_key_metadata,
administrator=user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN,
database=require_attributes(
prisma_client.db,
CustomerUsageDatabase,
"litellm_customerorganizationtable",
"litellm_customerteamtable",
"litellm_customermembertable",
)
if prisma_client is not None and customer_usage_requested(_customer_metadata, _customer_key_metadata)
else None,
ledger_enabled=general_settings_view is None
or general_settings_view.get("use_spend_logs_ledger", True) is not False,
)
if isinstance(_customer_usage, CustomerUsageFailure):
raise HTTPException(
status_code=customer_usage_http_status(_customer_usage.error), detail=_customer_usage.error.value
)
if _customer_usage is not None:
data[_metadata_variable_name][CUSTOMER_USAGE_METADATA_KEY] = ( # rebind-ok: data is an out-param
_customer_usage.model_dump_json()
)
data = LiteLLMProxyRequestSetup.add_user_api_key_auth_to_request_metadata(
data=data,
user_api_key_dict=user_api_key_dict,

View file

@ -79,9 +79,11 @@ from litellm.proxy.management_endpoints.common_utils import ( # noqa: F401 # l
from litellm.proxy.management_endpoints.key_management_endpoints import ( # noqa: F401 # legacy module exports
_check_permissions_caller_permission, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
check_permissions_caller_permission,
customer_key_actor,
generate_key_helper_fn,
prepare_metadata_fields,
raise_on_logging_credential_change,
validated_customer_key_metadata,
)
from litellm.proxy.management_helpers.bulk_user_deletion import delete_users_from_current_teams
from litellm.proxy.management_helpers.object_permission_utils import ( # noqa: F401 # legacy module exports
@ -560,17 +562,15 @@ async def new_user(
}'
```
"""
actor: Final = customer_key_actor(user_api_key_dict)
if data.auto_create_key and actor is not None:
validated_customer_key_metadata(data.metadata, None, actor)
try:
from litellm.proxy.proxy_server import _license_check, prisma_client
if prisma_client is None:
raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value)
if prisma_client is None:
raise HTTPException(
status_code=500,
detail=CommonProxyErrors.db_not_connected_error.value,
)
validate_budget_duration(data.budget_duration)
# Check for duplicate user_id or email
@ -629,7 +629,12 @@ async def new_user(
teams = check_if_default_team_set()
organization_ids: Final = cast(list[str] | None, data_json.pop("organizations", None))
response: Final = await generate_key_helper_fn(request_type="user", **data_json, llm_router=None)
response: Final = await generate_key_helper_fn(
request_type="user",
**data_json,
llm_router=None,
actor=actor,
)
created_user_id: Final = cast(str | None, response.get("user_id", None))
if created_user_id is not None:
forget_missing_user(created_user_id)

View file

@ -82,6 +82,12 @@ from litellm.proxy.common_utils.config_sync_pubsub import (
coordination_redis_cache,
publish_config_change,
)
from litellm.proxy.common_utils.customer_usage import (
CustomerUsageFailure,
customer_usage_fields,
customer_usage_http_status,
normalize_customer_key_metadata,
)
from litellm.proxy.common_utils.openai_error_payload import openai_error_param, proxy_exception_for
from litellm.proxy.common_utils.rbac_utils import check_org_admin_can_generate_keys
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time, keeps_due_budget_reset
@ -1479,6 +1485,25 @@ async def _stored_user_role(prisma_client: PrismaClient | None, user_id: str | N
return user_row.user_role if user_row is not None else None
def validated_customer_key_metadata(
metadata: object,
existing_metadata: object,
actor: UserAPIKeyAuth | None,
) -> Mapping[str, object] | None:
binding: Final = normalize_customer_key_metadata(
customer_usage_fields(metadata) if metadata is not None else None,
customer_usage_fields(existing_metadata) if existing_metadata is not None else None,
administrator=actor is not None and actor.user_role == LitellmUserRoles.PROXY_ADMIN.value,
)
if isinstance(binding, CustomerUsageFailure):
raise HTTPException(status_code=customer_usage_http_status(binding.error), detail=binding.error.value)
return binding
def customer_key_actor(value: object) -> UserAPIKeyAuth | None:
return value if isinstance(value, UserAPIKeyAuth) else None
async def _common_key_generation_helper(
data: GenerateKeyRequest,
user_api_key_dict: UserAPIKeyAuth,
@ -1493,6 +1518,8 @@ async def _common_key_generation_helper(
prisma_client,
)
validated_customer_key_metadata(data.metadata, None, user_api_key_dict)
acting_as_team_admin: Final = _team_admin_requests_member_key(
data=data,
team_table=team_table,
@ -1857,7 +1884,7 @@ async def _common_key_generation_helper(
try:
response = await generate_key_helper_fn(
request_type="key", **data_json, table_name="key", llm_router=llm_router
request_type="key", **data_json, table_name="key", llm_router=llm_router, actor=user_api_key_dict
)
except KeyProjectBindingError:
if prisma_client is not None:
@ -3062,6 +3089,7 @@ async def prepare_key_update_data(
*,
prisma_client: PrismaClient | None = None,
llm_router: Router | None = None,
user_api_key_dict: UserAPIKeyAuth | None = None,
):
if data.router_settings is not None or (
"router_settings" not in data.model_fields_set
@ -3080,6 +3108,13 @@ async def prepare_key_update_data(
llm_router=llm_router,
)
data_json: Final[dict] = data.model_dump(exclude_unset=True)
if "metadata" in data_json:
customer_binding: Final = validated_customer_key_metadata(
data.metadata,
_StoredKeyMetadata.model_validate(existing_key_row, from_attributes=True).metadata,
user_api_key_dict,
)
data_json["metadata"] = dict(customer_binding) if customer_binding is not None else None
data_json.pop("key", None)
data_json.pop("new_key", None)
data_json.pop("grace_period", None) # Request-only param, not a DB column
@ -3477,7 +3512,11 @@ async def _process_single_key_update(
# Prepare update data
raise_on_logging_credential_change(key_request.metadata, existing_key_row, user_api_key_dict)
non_default_values = await prepare_key_update_data(
data=key_request, existing_key_row=existing_key_row, prisma_client=prisma_client, llm_router=llm_router
data=key_request,
existing_key_row=existing_key_row,
prisma_client=prisma_client,
llm_router=llm_router,
user_api_key_dict=user_api_key_dict,
)
await _enforce_custom_key_policy(
@ -4231,7 +4270,11 @@ async def update_key_fn(
_enforce_upperbound_key_params(data, fill_defaults=False)
raise_on_logging_credential_change(data.metadata, existing_key_row, user_api_key_dict)
non_default_values: Final = await prepare_key_update_data(
data=data, existing_key_row=existing_key_row, prisma_client=prisma_client, llm_router=llm_router
data=data,
existing_key_row=existing_key_row,
prisma_client=prisma_client,
llm_router=llm_router,
user_api_key_dict=user_api_key_dict,
)
# Only validate key_alias format if it's actually being changed
@ -5384,7 +5427,9 @@ async def generate_key_helper_fn(
budget_limits: list | None = None, # multiple concurrent budget windows
*,
llm_router: Router | None = None,
actor: UserAPIKeyAuth | None = None,
):
customer_metadata: Final = validated_customer_key_metadata(_DECODED_JSON.validate_python(metadata), None, actor)
from litellm.proxy.proxy_server import premium_user, prisma_client
if prisma_client is None:
@ -5436,7 +5481,7 @@ async def generate_key_helper_fn(
router_settings_json: Final = safe_dumps(router_settings) if router_settings is not None else safe_dumps({})
metadata_json: Final = metadata_json_with_limits(
metadata,
customer_metadata,
model_rpm_limit=model_rpm_limit,
model_tpm_limit=model_tpm_limit,
mcp_rpm_limit=mcp_rpm_limit,
@ -6419,7 +6464,11 @@ async def _execute_virtual_key_regeneration(
_enforce_upperbound_key_params(data, fill_defaults=False)
raise_on_logging_credential_change(data.metadata, key_in_db, user_api_key_dict)
non_default_values = await prepare_key_update_data(
data=data, existing_key_row=key_in_db, prisma_client=prisma_client, llm_router=llm_router
data=data,
existing_key_row=key_in_db,
prisma_client=prisma_client,
llm_router=llm_router,
user_api_key_dict=user_api_key_dict,
)
# Only validate key_alias format if it's actually being changed
new_key_alias: Final = non_default_values.get("key_alias")

View file

@ -8,6 +8,9 @@ from litellm.proxy.management.users.endpoints import router as current_user_rout
from litellm.proxy.management_endpoints.management_v1.budgets import (
router as budgets_router,
)
from litellm.proxy.management_endpoints.management_v1.customer_usage import (
router as customer_usage_router,
)
from litellm.proxy.management_endpoints.management_v1.customers import (
router as customers_router,
)
@ -24,6 +27,7 @@ from litellm.proxy.management_endpoints.management_v1.users import (
router: Final = APIRouter()
router.include_router(budgets_router)
router.include_router(customers_router)
router.include_router(customer_usage_router)
router.include_router(spend_logs_router)
router.include_router(teams_router)
router.include_router(users_router)

View file

@ -0,0 +1,220 @@
from collections.abc import Sequence
from enum import Enum
from functools import partial
from typing import Annotated, Final, Protocol
from fastapi import APIRouter, Depends
from pydantic import TypeAdapter
from litellm.litellm_core_utils.core_helpers import require_attributes
from litellm.proxy.list_api.common import reject_unknown_query_params
from litellm.proxy.management_endpoints.common_daily_activity import DailySpendRecord, get_daily_activity
from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX
from litellm.proxy.management_endpoints.management_v1.customers import (
raise_public,
reject_duplicate_list_params,
require_customer_admin,
)
from litellm.proxy.management_helpers.customer_hierarchy import ErrorCode, Failure, PrismaRecord
from litellm.proxy.management_helpers.utils import (
management_endpoint_wrapper, # pyright: ignore[reportUnknownVariableType] # legacy decorator is untyped
)
from litellm.proxy.utils import PrismaClient
from litellm.repositories.chunked_in import find_many_in
from litellm.repositories.prisma_protocols import DatabaseClient, TableActions
from litellm.types.proxy.management_endpoints.common_daily_activity import SpendAnalyticsPaginatedResponse
class CustomerEntity(str, Enum):
ORGANIZATION = "customer_org_id"
TEAM = "customer_team_id"
MEMBER = "customer_member_id"
class CustomerMetadataDatabase(Protocol):
@property
def litellm_customerorganizationtable(self) -> TableActions[PrismaRecord]: ...
@property
def litellm_customerteamtable(self) -> TableActions[PrismaRecord]: ...
@property
def litellm_customermembertable(self) -> TableActions[PrismaRecord]: ...
_TEXT: Final = TypeAdapter(str)
_OPTIONAL_TEXT: Final[TypeAdapter[str | None]] = TypeAdapter(str | None)
def get_customer_usage_prisma() -> PrismaClient:
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise_public(Failure(ErrorCode.DATABASE_UNAVAILABLE))
return prisma_client
def metadata_table(prisma_client: PrismaClient, entity: CustomerEntity) -> TableActions[PrismaRecord]:
database: Final = require_attributes(prisma_client, DatabaseClient, "db").db
tables: Final = require_attributes(
database,
CustomerMetadataDatabase,
"litellm_customerorganizationtable",
"litellm_customerteamtable",
"litellm_customermembertable",
)
if entity is CustomerEntity.ORGANIZATION:
return tables.litellm_customerorganizationtable
if entity is CustomerEntity.TEAM:
return tables.litellm_customerteamtable
return tables.litellm_customermembertable
async def page_aliases(
table: TableActions[PrismaRecord], entity: CustomerEntity, records: Sequence[DailySpendRecord]
) -> dict[str, dict[str, object]]: # mutable-ok: get_daily_activity requires dictionary-returning metadata callbacks
identifiers: Final = tuple(_OPTIONAL_TEXT.validate_python(getattr(row, entity.value, None)) for row in records)
rows: Final = await find_many_in(table, entity.value, tuple(value for value in identifiers if value))
values: Final = tuple(row.model_dump() for row in rows)
return {
_TEXT.validate_python(row[entity.value]): {
"alias": _OPTIONAL_TEXT.validate_python(row.get("alias")) or _TEXT.validate_python(row[entity.value])
}
for row in values
}
async def customer_activity(
prisma_client: PrismaClient,
entity: CustomerEntity,
entity_ids: str | None,
excluded_ids: str | None,
start_date: str | None,
end_date: str | None,
model: str | None,
api_key: str | None,
page: int,
page_size: int,
) -> SpendAnalyticsPaginatedResponse:
return await get_daily_activity(
prisma_client=prisma_client,
table_name="litellm_dailycustomermemberspend",
entity_id_field=entity.value,
entity_id=entity_ids.split(",") if entity_ids else None,
entity_metadata_field=None,
exclude_entity_ids=excluded_ids.split(",") if excluded_ids else None,
start_date=start_date,
end_date=end_date,
model=model,
api_key=api_key,
page=page,
page_size=page_size,
resolve_entity_metadata=partial(page_aliases, metadata_table(prisma_client, entity), entity),
)
router: Final = APIRouter(
prefix=MANAGEMENT_V1_PREFIX,
tags=["customer usage"],
dependencies=(
Depends(require_customer_admin),
Depends(reject_unknown_query_params),
Depends(reject_duplicate_list_params),
),
)
@router.get(
"/customer_organizations/daily/activity",
response_model=SpendAnalyticsPaginatedResponse,
description="Get paginated daily customer organization activity with model, provider, key and organization breakdowns. "
"Filters accept comma-separated organization IDs. Proxy admins only.",
)
@management_endpoint_wrapper
async def customer_organization_daily_activity(
prisma_client: Annotated[PrismaClient, Depends(get_customer_usage_prisma)],
customer_org_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_customer_org_ids: str | None = None,
) -> SpendAnalyticsPaginatedResponse:
return await customer_activity(
prisma_client,
CustomerEntity.ORGANIZATION,
customer_org_ids,
exclude_customer_org_ids,
start_date,
end_date,
model,
api_key,
page,
page_size,
)
@router.get(
"/customer_teams/daily/activity",
response_model=SpendAnalyticsPaginatedResponse,
description="Get paginated daily customer team activity with model, provider, key and team breakdowns. "
"Filters accept comma-separated team IDs. Proxy admins only.",
)
@management_endpoint_wrapper
async def customer_team_daily_activity(
prisma_client: Annotated[PrismaClient, Depends(get_customer_usage_prisma)],
customer_team_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_customer_team_ids: str | None = None,
) -> SpendAnalyticsPaginatedResponse:
return await customer_activity(
prisma_client,
CustomerEntity.TEAM,
customer_team_ids,
exclude_customer_team_ids,
start_date,
end_date,
model,
api_key,
page,
page_size,
)
@router.get(
"/customer_members/daily/activity",
response_model=SpendAnalyticsPaginatedResponse,
description="Get paginated daily customer member activity with model, provider, key and member breakdowns. "
"Filters accept comma-separated member IDs. Proxy admins only.",
)
@management_endpoint_wrapper
async def customer_member_daily_activity(
prisma_client: Annotated[PrismaClient, Depends(get_customer_usage_prisma)],
customer_member_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_customer_member_ids: str | None = None,
) -> SpendAnalyticsPaginatedResponse:
return await customer_activity(
prisma_client,
CustomerEntity.MEMBER,
customer_member_ids,
exclude_customer_member_ids,
start_date,
end_date,
model,
api_key,
page,
page_size,
)

View file

@ -19970,6 +19970,7 @@ async def _generate_onboarding_ui_session_token(user_obj: _UserTableRow) -> str:
response: Final = await generate_key_helper_fn(
llm_router=llm_router,
request_type="key",
actor=None,
**{
"user_role": user_obj.user_role,
"duration": LITELLM_UI_SESSION_DURATION,

View file

@ -1035,6 +1035,45 @@ model LiteLLM_DailyOrganizationSpend {
}
// Track daily end user (customer) spend metrics per model and key
model LiteLLM_DailyCustomerMemberSpend {
id String @id
date String
customer_org_id String
customer_team_id String @default("")
customer_member_id String @default("")
model String @default("")
model_group String @default("")
custom_llm_provider String @default("")
mcp_namespaced_tool_name String @default("")
endpoint String @default("")
api_key String @default("")
spend Float @default(0.0)
prompt_tokens BigInt @default(0)
completion_tokens BigInt @default(0)
cache_read_input_tokens BigInt @default(0)
cache_creation_input_tokens BigInt @default(0)
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
gateway_injected_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
guardrail_spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([date])
@@index([customer_org_id, date])
@@index([customer_team_id, date])
@@index([customer_member_id, date])
@@index([api_key])
@@index([model])
}
model LiteLLM_DailyEndUserSpend {
id String @id @default(uuid())
end_user_id String?

View file

@ -14,6 +14,18 @@ if TYPE_CHECKING:
class LedgerTransaction(Protocol):
@property
def litellm_dailycustomermemberspend(self) -> TableActions[object]: ...
@property
def litellm_customerorganizationtable(self) -> TableActions[object]: ...
@property
def litellm_customerteamtable(self) -> TableActions[object]: ...
@property
def litellm_customermembertable(self) -> TableActions[object]: ...
@property
def litellm_dailyguardrailusageunits(self) -> TableActions[object]: ...

View file

@ -96,6 +96,9 @@ class _DailyActivityDatabase(Protocol):
@property
def litellm_dailyenduserspend(self) -> TableActions[DailyActivityRow]: ...
@property
def litellm_dailycustomermemberspend(self) -> TableActions[DailyActivityRow]: ...
@property
def litellm_dailyagentspend(self) -> TableActions[DailyActivityRow]: ...
@ -178,6 +181,8 @@ def _daily_rows_table(
return prisma_client.db.litellm_dailyorganizationspend
if table is DailyActivityTable.CUSTOMER:
return prisma_client.db.litellm_dailyenduserspend
if table is DailyActivityTable.CUSTOMER_MEMBER:
return prisma_client.db.litellm_dailycustomermemberspend
if table is DailyActivityTable.AGENT:
return prisma_client.db.litellm_dailyagentspend
assert_never(table)

View file

@ -43,6 +43,7 @@ PRISMA_TO_PG_TABLE: Final[Mapping[DailyActivityTable, str]] = MappingProxyType(
DailyActivityTable.TAG: "LiteLLM_DailyTagSpend",
DailyActivityTable.ORGANIZATION: "LiteLLM_DailyOrganizationSpend",
DailyActivityTable.CUSTOMER: "LiteLLM_DailyEndUserSpend",
DailyActivityTable.CUSTOMER_MEMBER: "LiteLLM_DailyCustomerMemberSpend",
DailyActivityTable.AGENT: "LiteLLM_DailyAgentSpend",
}
)

View file

@ -1035,6 +1035,45 @@ model LiteLLM_DailyOrganizationSpend {
}
// Track daily end user (customer) spend metrics per model and key
model LiteLLM_DailyCustomerMemberSpend {
id String @id
date String
customer_org_id String
customer_team_id String @default("")
customer_member_id String @default("")
model String @default("")
model_group String @default("")
custom_llm_provider String @default("")
mcp_namespaced_tool_name String @default("")
endpoint String @default("")
api_key String @default("")
spend Float @default(0.0)
prompt_tokens BigInt @default(0)
completion_tokens BigInt @default(0)
cache_read_input_tokens BigInt @default(0)
cache_creation_input_tokens BigInt @default(0)
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
gateway_injected_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
guardrail_spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([date])
@@index([customer_org_id, date])
@@index([customer_team_id, date])
@@index([customer_member_id, date])
@@index([api_key])
@@index([model])
}
model LiteLLM_DailyEndUserSpend {
id String @id @default(uuid())
end_user_id String?

View file

@ -505,6 +505,8 @@ class CohereEmbeddingRequest(TypedDict, total=False):
truncate: Literal["NONE", "START", "END"]
embedding_types: Literal["float", "int8", "uint8", "binary", "ubinary"]
output_dimension: int
max_tokens: ReadOnly[int]
priority: ReadOnly[int]
class CohereEmbeddingRequestWithModel(CohereEmbeddingRequest):

View file

@ -12,6 +12,7 @@ class DailyActivityTable(str, Enum):
TAG = "litellm_dailytagspend"
ORGANIZATION = "litellm_dailyorganizationspend"
CUSTOMER = "litellm_dailyenduserspend"
CUSTOMER_MEMBER = "litellm_dailycustomermemberspend"
AGENT = "litellm_dailyagentspend"
@ -22,6 +23,7 @@ _ENTITY_FIELDS: Mapping[DailyActivityTable, frozenset[str]] = MappingProxyType(
DailyActivityTable.TAG: frozenset(("tag",)),
DailyActivityTable.ORGANIZATION: frozenset(("organization_id",)),
DailyActivityTable.CUSTOMER: frozenset(("end_user_id",)),
DailyActivityTable.CUSTOMER_MEMBER: frozenset(("customer_org_id", "customer_team_id", "customer_member_id")),
DailyActivityTable.AGENT: frozenset(("agent_id",)),
}
)

View file

@ -0,0 +1,166 @@
"use client";
import { useMemo } from "react";
import { parseAsString, parseAsInteger, useQueryStates } from "nuqs";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
import { formatCompact, formatUsd } from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/cva.config";
import type { CustomerHierarchyUsage, CustomerUsageEntity } from "./customerUsageApi";
const MEMBER_TABLE_PARSERS = { memberSearch: parseAsString.withDefault(""), memberPage: parseAsInteger.withDefault(1) };
const PAGE_SIZE = 20;
const memberStatus = (member: CustomerUsageEntity) => {
if (member.budgetLimit === null) return "customerPortal.analytics.noCap" as const;
if (member.spend > member.budgetLimit) return "customerPortal.analytics.overBudget" as const;
if (member.spend === member.budgetLimit) return "customerPortal.analytics.atBudget" as const;
return "customerPortal.analytics.withinBudget" as const;
};
export default function CustomerMemberSpend({
data,
onMemberClick,
}: {
data: CustomerHierarchyUsage;
onMemberClick: (id: string) => void;
}) {
const { t } = useTranslation();
const [table, setTable] = useQueryStates(MEMBER_TABLE_PARSERS);
const search = table.memberSearch;
const usage = useMemo(() => new Map(data.members.map((member) => [member.id, member])), [data.members]);
const members = useMemo(
() =>
data.memberBudgets
.filter((member) => member.label.toLocaleLowerCase().includes(search.toLocaleLowerCase()))
.map((member) => ({ ...member, rangeUsage: usage.get(member.id) }))
.sort((a, b) => (b.rangeUsage?.spend ?? 0) - (a.rangeUsage?.spend ?? 0) || a.label.localeCompare(b.label)),
[data.memberBudgets, search, usage],
);
const lastPage = Math.max(1, Math.ceil(members.length / PAGE_SIZE));
const page = Math.max(1, Math.min(table.memberPage, lastPage));
const start = (page - 1) * PAGE_SIZE;
return (
<section className="border-t pt-6" aria-label={t("customerPortal.analytics.memberSpend")}>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-medium tracking-tight">{t("customerPortal.analytics.memberSpend")}</h2>
<p className="mt-1 text-xs text-muted-foreground">{t("customerPortal.analytics.memberSpendHint")}</p>
</div>
<div className="relative w-full sm:w-56">
<Search aria-hidden="true" className="absolute top-2.5 left-3 size-3.5 text-muted-foreground" />
<Input
className="h-8 pl-9 text-xs"
value={search}
onChange={(event) => void setTable({ memberSearch: event.target.value, memberPage: null })}
aria-label={t("customerPortal.analytics.searchMembers")}
placeholder={t("customerPortal.analytics.searchMembers")}
/>
</div>
</div>
<Table aria-label={t("customerPortal.analytics.memberSpend")}>
<TableHeader>
<TableRow>
<TableHead className="pl-0">{t("customerPortal.analytics.member")}</TableHead>
<TableHead className="text-right">{t("customerPortal.analytics.spend")}</TableHead>
<TableHead className="text-right">{t("customerPortal.analytics.tokens")}</TableHead>
<TableHead className="text-right">{t("customerPortal.analytics.currentBudget")}</TableHead>
<TableHead className="pr-0 text-right">{t("customerPortal.analytics.status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.slice(start, start + PAGE_SIZE).map((member) => {
const atLimit = member.budgetLimit !== null && member.spend >= member.budgetLimit;
return (
<TableRow key={member.id}>
<TableCell className="py-4 pl-0">
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium"
>
{member.label
.split(/\s+/)
.slice(0, 2)
.map((part) => part.charAt(0))
.join("")
.toLocaleUpperCase()}
</span>
<div className="min-w-0">
<button
type="button"
className="block text-left font-medium hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => onMemberClick(member.id)}
>
{member.label}
</button>
<span className="block font-mono text-[11px] text-muted-foreground">{member.id}</span>
</div>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">{formatUsd(member.rangeUsage?.spend ?? 0)}</TableCell>
<TableCell className="text-right tabular-nums">
{formatCompact(member.rangeUsage?.tokens ?? 0)}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{member.budgetLimit === null
? t("customerPortal.analytics.noBudget")
: `${formatUsd(member.spend)} / ${formatUsd(member.budgetLimit)}`}
</TableCell>
<TableCell className="pr-0 text-right">
<span
className={cn(
"inline-flex rounded-full bg-muted px-2 py-0.5 text-xs",
atLimit && "bg-destructive/10 text-destructive",
)}
>
{t(memberStatus(member))}
</span>
</TableCell>
</TableRow>
);
})}
{members.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
{t(search ? "customerPortal.analytics.noMatches" : "customerPortal.analytics.noMembers")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{members.length > PAGE_SIZE && (
<div className="mt-3 flex items-center justify-between border-t pt-3 text-xs text-muted-foreground">
<span>
{t("customerPortal.analytics.pagination", {
from: start + 1,
to: Math.min(start + PAGE_SIZE, members.length),
total: members.length,
})}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={page === 1}
onClick={() => void setTable({ memberPage: page - 1 })}
>
{t("customerPortal.analytics.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={page === lastPage}
onClick={() => void setTable({ memberPage: page + 1 })}
>
{t("customerPortal.analytics.next")}
</Button>
</div>
</div>
)}
</section>
);
}

View file

@ -0,0 +1,112 @@
import type { ParseKeys } from "i18next";
import { ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { formatUsd } from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import { uiHref } from "@/utils/uiHref";
import type { CustomerHierarchyUsage, CustomerUsageEntity, CustomerUsageScope } from "./customerUsageApi";
interface CustomerUsageHeaderProps {
scope: CustomerUsageScope;
data: CustomerHierarchyUsage | undefined;
dateValue: DateRangePickerValue;
preset: string | null;
onOrganizationClick: () => void;
onTeamClick: () => void;
onDateRangeChange: (value: DateRangePickerValue, preset: string | null) => void;
}
const scopeLabel = (scope: CustomerUsageScope): ParseKeys => {
if (scope.member) return "customerPortal.analytics.memberUsage";
if (scope.team) return "customerPortal.analytics.teamUsage";
return "customerPortal.analytics.organizationUsage";
};
function CustomerUsageBreadcrumbs({
scope,
data,
onOrganizationClick,
onTeamClick,
}: Pick<CustomerUsageHeaderProps, "scope" | "data" | "onOrganizationClick" | "onTeamClick">) {
const { t } = useTranslation();
return (
<nav
aria-label={t("customerPortal.analytics.breadcrumb")}
className="mb-5 flex flex-wrap items-center gap-2 text-xs text-muted-foreground"
>
<a href={uiHref("customers")} className="hover:text-foreground">
{t("customerPortal.analytics.customers")}
</a>
<ChevronRight aria-hidden="true" className="size-3" />
<button type="button" className="hover:text-foreground" onClick={onOrganizationClick}>
{data?.organization.label ?? scope.org}
</button>
{scope.team && (
<>
<ChevronRight aria-hidden="true" className="size-3" />
<button type="button" className="hover:text-foreground" onClick={onTeamClick}>
{data?.team?.label ?? scope.team}
</button>
</>
)}
{scope.member && (
<>
<ChevronRight aria-hidden="true" className="size-3" />
<span aria-current="page">{data?.member?.label ?? scope.member}</span>
</>
)}
</nav>
);
}
function CustomerCurrentSpend({ entity }: { entity: CustomerUsageEntity }) {
const { t } = useTranslation();
return (
<p className="mt-3 text-xs text-muted-foreground">
{t("customerPortal.analytics.currentSpend")}{" "}
<span className="font-medium tabular-nums text-foreground">{formatUsd(entity.spend)}</span>
{entity.budgetLimit === null
? ` · ${t("customerPortal.analytics.noBudget")}`
: ` / ${formatUsd(entity.budgetLimit)}`}
</p>
);
}
export default function CustomerUsageHeader({
scope,
data,
dateValue,
preset,
onOrganizationClick,
onTeamClick,
onDateRangeChange,
}: CustomerUsageHeaderProps) {
const { t } = useTranslation();
const entity = data?.member ?? data?.team ?? data?.organization;
const label = entity?.label ?? scope.member ?? scope.team ?? scope.org;
return (
<header>
<CustomerUsageBreadcrumbs
scope={scope}
data={data}
onOrganizationClick={onOrganizationClick}
onTeamClick={onTeamClick}
/>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="mb-1 text-xs text-muted-foreground">{t(scopeLabel(scope))}</p>
<h1 className="text-2xl font-semibold tracking-tight">{label}</h1>
<p className="mt-1.5 text-sm text-muted-foreground">{t("customerPortal.analytics.subtitle")}</p>
{entity && <CustomerCurrentSpend entity={entity} />}
</div>
<AdvancedDatePicker
value={dateValue}
presetShortLabel={preset}
showTimeRange={false}
onValueChange={onDateRangeChange}
/>
</div>
</header>
);
}

View file

@ -0,0 +1,200 @@
"use client";
import type { ParseKeys } from "i18next";
import { useTranslation } from "react-i18next";
import { ChevronRight, Search } from "lucide-react";
import {
type UsageMetric,
formatCompact,
formatUsd,
} from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/cva.config";
import type { CustomerUsageRow } from "./customerUsageData";
import { ModelMark } from "@/app/(dashboard)/usage/_components/components/overview/BreakdownChart";
import { colorForModel } from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import { providerForModel } from "@/app/(dashboard)/usage/_components/components/overview/modelProvider";
export const CUSTOMER_USAGE_DIMENSIONS = ["models", "api_keys", "teams"] as const;
export type CustomerUsageDimension = (typeof CUSTOMER_USAGE_DIMENSIONS)[number];
const DIMENSION_LABELS: Record<CustomerUsageDimension, ParseKeys> = {
models: "customerPortal.analytics.models",
api_keys: "customerPortal.analytics.keys",
teams: "customerPortal.analytics.teams",
};
const PAGE_SIZE = 10;
interface CustomerUsageRankingsProps {
rows: readonly CustomerUsageRow[];
dimension: CustomerUsageDimension;
metric: UsageMetric;
search: string;
page: number;
colorByModel: ReadonlyMap<string, string>;
onSearchChange: (search: string) => void;
onPageChange: (page: number) => void;
onDrillDown: (dimension: "teams", id: string) => void;
}
export default function CustomerUsageRankings({
rows,
dimension,
metric,
search,
page,
colorByModel,
onSearchChange,
onPageChange,
onDrillDown,
}: CustomerUsageRankingsProps) {
const { t } = useTranslation();
const filtered = rows.filter((row) => row.label.toLocaleLowerCase().includes(search.toLocaleLowerCase()));
const lastPage = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const currentPage = Math.min(Math.max(1, page), lastPage);
const start = (currentPage - 1) * PAGE_SIZE;
const canDrillDown = dimension === "teams";
return (
<section className="min-w-0" aria-label={t("customerPortal.analytics.breakdown")}>
<div className="flex flex-wrap items-center justify-end gap-x-5 gap-y-3">
<div className="relative mb-2 w-full sm:w-52">
<Search aria-hidden="true" className="absolute top-2.5 left-3 size-3.5 text-muted-foreground" />
<Input
aria-label={t("customerPortal.analytics.search")}
placeholder={t("customerPortal.analytics.search")}
className="h-8 pl-9 text-xs"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/>
</div>
</div>
<div>
<p className="py-3 text-xs text-muted-foreground">{t("customerPortal.analytics.rankingHint")}</p>
<Table aria-label={t(DIMENSION_LABELS[dimension])}>
<TableHeader>
<TableRow>
<TableHead className="w-10 pl-0">{t("customerPortal.analytics.rank")}</TableHead>
<TableHead>{t(DIMENSION_LABELS[dimension])}</TableHead>
<TableHead className="min-w-28 text-right">{t("customerPortal.analytics.share")}</TableHead>
<TableHead className="text-right" aria-sort={metric === "spend" ? "descending" : undefined}>
{t("customerPortal.analytics.spend")}
</TableHead>
<TableHead className="text-right" aria-sort={metric === "tokens" ? "descending" : undefined}>
{t("customerPortal.analytics.tokens")}
</TableHead>
<TableHead className="pr-0 text-right" aria-sort={metric === "requests" ? "descending" : undefined}>
{t("customerPortal.analytics.requests")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.slice(start, start + PAGE_SIZE).map((row, index) => (
<TableRow key={row.id}>
<TableCell className="pl-0 text-xs tabular-nums text-muted-foreground">{start + index + 1}</TableCell>
<TableCell className="max-w-72 py-4">
{canDrillDown && row.id ? (
<button
type="button"
onClick={() => onDrillDown(dimension, row.id)}
className="flex max-w-full items-center gap-2 text-left font-medium hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
>
<span className="truncate">{row.label}</span>
<ChevronRight aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground" />
</button>
) : (
<span className="flex min-w-0 items-center gap-3" title={row.label}>
{dimension === "models" && (
<ModelMark model={row.id} color={colorForModel(row.id, colorByModel)} />
)}
<span className="min-w-0">
<span className="block truncate font-medium">{row.label}</span>
{dimension === "models" && providerForModel(row.id) && (
<span className="block text-xs text-muted-foreground">
{t("usage.breakdown.byProvider", { provider: providerForModel(row.id) })}
</span>
)}
</span>
</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-3">
<div
aria-hidden="true"
className="hidden h-1.5 w-20 overflow-hidden rounded-full bg-muted md:block"
>
<div
className="h-full rounded-full bg-primary"
style={{
width: `${Math.min(100, Math.max(0, row.share * 100))}%`,
...(dimension === "models" ? { backgroundColor: colorForModel(row.id, colorByModel) } : {}),
}}
/>
</div>
<span className="w-12 text-xs tabular-nums">
{t("format.number", { val: row.share * 100, maximumFractionDigits: 1 })}%
</span>
</div>
</TableCell>
<TableCell className={cn("text-right tabular-nums", metric !== "spend" && "text-muted-foreground")}>
{formatUsd(row.spend)}
</TableCell>
<TableCell
title={row.tokens.toLocaleString()}
className={cn("text-right tabular-nums", metric !== "tokens" && "text-muted-foreground")}
>
{formatCompact(row.tokens)}
</TableCell>
<TableCell
title={row.requests.toLocaleString()}
className={cn("pr-0 text-right tabular-nums", metric !== "requests" && "text-muted-foreground")}
>
{formatCompact(row.requests)}
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
{t(search ? "customerPortal.analytics.noMatches" : "customerPortal.analytics.noBreakdown")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{filtered.length > PAGE_SIZE && (
<div className="flex items-center justify-between border-t pt-3 text-xs text-muted-foreground">
<span>
{t("customerPortal.analytics.pagination", {
from: start + 1,
to: Math.min(start + PAGE_SIZE, filtered.length),
total: filtered.length,
})}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={currentPage === 1}
onClick={() => onPageChange(currentPage - 1)}
>
{t("customerPortal.analytics.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={currentPage === lastPage}
onClick={() => onPageChange(currentPage + 1)}
>
{t("customerPortal.analytics.next")}
</Button>
</div>
</div>
)}
</div>
</section>
);
}

View file

@ -0,0 +1,152 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
bucketSeries,
bucketTotals,
formatMetricValue,
labelForDate,
type UsageMetric,
type Granularity,
type OverviewTotals,
} from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import { ChartSkeleton, Segmented } from "@/app/(dashboard)/usage/_components/components/overview/Primitives";
import { StackedUsageChart } from "@/components/shared/charts";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { CustomerHierarchyUsage } from "./customerUsageApi";
import { customerUsageSeries } from "./customerUsageChartData";
import { CUSTOMER_USAGE_DIMENSIONS, type CustomerUsageDimension } from "./CustomerUsageRankings";
import { ModelMark } from "@/app/(dashboard)/usage/_components/components/overview/BreakdownChart";
const DIMENSION_LABELS = {
models: "customerPortal.analytics.models",
api_keys: "customerPortal.analytics.keys",
teams: "customerPortal.analytics.teams",
} as const;
interface CustomerUsageTrendProps {
data: CustomerHierarchyUsage | undefined;
dimension: CustomerUsageDimension;
onDimensionChange: (dimension: CustomerUsageDimension) => void;
totals: OverviewTotals;
metric: UsageMetric;
bucket: Granularity;
loading: boolean;
colorByModel: ReadonlyMap<string, string>;
onMetricChange: (metric: UsageMetric) => void;
onBucketChange: (bucket: Granularity) => void;
}
export default function CustomerUsageTrend({
data,
dimension,
onDimensionChange,
totals,
metric,
bucket,
loading,
colorByModel,
onMetricChange,
onBucketChange,
}: CustomerUsageTrendProps) {
const { t } = useTranslation();
const dailySeries = useMemo(
() =>
data
? customerUsageSeries(data, dimension, metric, colorByModel)
: { data: [], keys: [], labels: [], colors: [] },
[data, dimension, metric, colorByModel],
);
const series = useMemo(() => bucketSeries(dailySeries, bucket), [dailySeries, bucket]);
const bucketValues = useMemo(() => bucketTotals(series), [series]);
const empty = totals.requests === 0 && totals.tokens === 0 && totals.spend === 0;
return (
<section aria-label={t("customerPortal.analytics.trend")} className="border-b pb-5">
<div className="mb-5 flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="text-base font-medium tracking-tight">{t("customerPortal.analytics.trend")}</h2>
<p className="mt-1 text-xs text-muted-foreground">
{t("customerPortal.analytics.trendHint", { dimension: t(DIMENSION_LABELS[dimension]).toLocaleLowerCase() })}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Select value={dimension} onValueChange={(value) => onDimensionChange(value as CustomerUsageDimension)}>
<SelectTrigger size="sm" aria-label={t("customerPortal.analytics.breakDownBy")}>
<span className="text-muted-foreground">{t("customerPortal.analytics.breakDownBy")}</span>
<SelectValue>{t(DIMENSION_LABELS[dimension])}</SelectValue>
</SelectTrigger>
<SelectContent>
{CUSTOMER_USAGE_DIMENSIONS.map((value) => (
<SelectItem key={value} value={value}>
{t(DIMENSION_LABELS[value])}
</SelectItem>
))}
</SelectContent>
</Select>
<Segmented<UsageMetric>
label={t("customerPortal.analytics.metric")}
value={metric}
options={[
{ value: "spend", label: t("customerPortal.analytics.spend") },
{ value: "tokens", label: t("customerPortal.analytics.tokens") },
{ value: "requests", label: t("customerPortal.analytics.requests") },
]}
onChange={(metric) => onMetricChange(metric)}
/>
<div className="h-5 w-px bg-border" aria-hidden="true" />
<Segmented
label={t("customerPortal.analytics.bucket")}
value={bucket}
options={[
{ value: "day", label: t("customerPortal.analytics.daily") },
{ value: "week", label: t("customerPortal.analytics.weekly") },
]}
onChange={(bucket) => onBucketChange(bucket)}
/>
</div>
</div>
{loading && (
<div role="status" aria-label={t("customerPortal.analytics.loading")}>
<ChartSkeleton className="h-72 sm:h-80" />
</div>
)}
{!loading && empty && (
<div className="flex h-72 items-center justify-center text-sm text-muted-foreground">
{t("customerPortal.analytics.empty")}
</div>
)}
{!loading && !empty && (
<>
<StackedUsageChart
data={series.data}
series={series.keys}
labels={series.labels}
colors={series.colors}
xKey="date"
xLabel={(date) => labelForDate(series, date)}
format={(value) => formatMetricValue(value, metric)}
totalFor={(date) => bucketValues.get(date)}
className="h-72 sm:h-80"
/>
<div className="mt-3 flex flex-wrap justify-center gap-x-5 gap-y-2 text-xs text-muted-foreground">
{series.labels.map((label, index) => (
<span key={series.keys[index]} className="inline-flex items-center gap-1.5">
{dimension === "models" ? (
<ModelMark model={label} color={series.colors[index]} />
) : (
<span
aria-hidden="true"
className="size-2 rounded-sm"
style={{ backgroundColor: series.colors[index] }}
/>
)}
{label}
</span>
))}
</div>
</>
)}
</section>
);
}

View file

@ -0,0 +1,336 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerBaseUrlGetter } from "@/lib/http/runtime";
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import { customerHierarchyUsageCall } from "./customerUsageApi";
const audit = {
created_at: "2026-11-01T00:00:00Z",
created_by: "admin",
updated_at: "2026-11-01T00:00:00Z",
updated_by: "admin",
spend: 40,
};
const organization = {
...audit,
customer_org_id: "org-1",
alias: "Customer organization",
budget: { max_budget: 100 },
budget_reset_at: "2026-12-01T00:00:00Z",
default_budgets: [],
};
const teams = [
{ ...audit, customer_team_id: "team-a", parent_org_id: "org-1", alias: "Product", budget: { max_budget: 50 } },
{ ...audit, customer_team_id: "team-b", parent_org_id: "org-1", alias: "Operations", budget: null },
];
const members = [
{
...audit,
customer_member_id: "member-a",
parent_team_id: "team-a",
alias: "Member One",
effective_budget: { max_budget: 10 },
},
{
...audit,
customer_member_id: "member-b",
parent_team_id: "team-b",
alias: "Member Two",
effective_budget: { max_budget: 20 },
},
];
const metrics = (spend: number, tokens: number, requests: number): SpendMetrics => ({
spend,
prompt_tokens: tokens,
completion_tokens: 0,
total_tokens: tokens,
api_requests: requests,
successful_requests: requests,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
});
const records = [
{
organization: "org-1",
team: "team-a",
member: "member-a",
model: "model-a",
key: "hash-a",
alias: "Assistant key",
metrics: metrics(3, 100, 8),
},
{
organization: "org-1",
team: "team-b",
member: "member-b",
model: "model-b",
key: "hash-b",
alias: "Search key",
metrics: metrics(1, 500, 2),
},
];
type UsageRecord = (typeof records)[number];
const daily = (record: UsageRecord, entityId: string): DailyData => {
const key = { metrics: record.metrics, metadata: { key_alias: record.alias, team_id: null } };
const entity = { metrics: record.metrics, metadata: {}, api_key_breakdown: { [record.key]: key } };
return {
date: "2026-11-01",
metrics: record.metrics,
breakdown: {
models: { [record.model]: entity },
model_groups: { [record.model]: entity },
providers: {},
mcp_servers: {},
api_keys: { [record.key]: key },
entities: { [entityId]: entity },
},
};
};
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
const requestUrl = (input: Parameters<typeof fetch>[0]) =>
new URL(input instanceof Request ? input.url : String(input), "http://localhost");
const fetchMock = vi.fn<typeof fetch>();
const urls = () => fetchMock.mock.calls.map(([input]) => requestUrl(input));
const dailyUrls = () => urls().filter((url) => url.pathname.endsWith("/daily/activity"));
const request = {
scope: { org: "org-1", team: null, member: null },
accessToken: "test-session",
startDate: "2026-11-01",
endDate: "2026-11-03",
};
const list = <T>(data: T[], page = 1, totalPages = 1) => ({
data,
meta: { total_count: data.length, page, page_size: 100, total_pages: totalPages },
});
const activityDimension = (resource: string | undefined): "organization" | "team" | "member" => {
if (resource === "customer_organizations") return "organization";
if (resource === "customer_teams") return "team";
return "member";
};
const filterFor = (resource: string | undefined) =>
({
organization: "customer_org_ids",
team: "customer_team_ids",
member: "customer_member_ids",
})[activityDimension(resource)];
const activityResponse = (url: URL) => {
const collection = url.pathname.split("/").at(-3);
const dimension = activityDimension(collection);
const filter = filterFor(collection);
const ids = url.searchParams.get(filter)?.split(",") ?? [];
const selected = records.filter((row) => ids.includes(row[dimension]));
const page = Number(url.searchParams.get("page"));
const current = selected.slice(page - 1, page);
const values = current[0]?.metrics ?? metrics(0, 0, 0);
return json({
results: current.map((row) => daily(row, row[dimension])),
metadata: {
...EMPTY_DAILY_ACTIVITY_METADATA,
total_spend: values.spend,
total_tokens: values.total_tokens,
total_api_requests: values.api_requests,
page,
total_pages: selected.length,
has_more: page < selected.length,
},
});
};
const serve = (input: Parameters<typeof fetch>[0]): Response => {
const url = requestUrl(input);
if (url.pathname.endsWith("/daily/activity")) return activityResponse(url);
if (url.pathname === "/management/v1/customer_organizations/org-1") return json(organization);
if (url.pathname === "/management/v1/customer_teams") {
const page = Number(url.searchParams.get("page"));
return json(list([teams[page - 1]], page, 2));
}
if (url.pathname === "/management/v1/customer_members")
return json(list(members.filter((member) => member.parent_team_id === url.searchParams.get("parent_team_id"))));
const team = teams.find((team) => url.pathname.endsWith(`/customer_teams/${team.customer_team_id}`));
if (team) return json(team);
const member = members.find((member) => url.pathname.endsWith(`/customer_members/${member.customer_member_id}`));
if (member) return json(member);
throw new Error(`Unexpected usage request ${url.pathname}`);
};
beforeEach(() => {
registerBaseUrlGetter(() => "http://localhost");
fetchMock.mockReset();
fetchMock.mockImplementation((input) => Promise.resolve(serve(input)));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => vi.unstubAllGlobals());
describe("customerHierarchyUsageCall", () => {
it("should read exact daily activity routes, all pages, and scoped descendant lists", async () => {
fetchMock.mockImplementation((input) => {
const url = requestUrl(input);
if (url.pathname === "/management/v1/customer_members" && url.searchParams.get("parent_team_id") === "team-a") {
const page = Number(url.searchParams.get("page"));
const member = page === 1 ? members[0] : { ...members[0], customer_member_id: "member-c" };
return Promise.resolve(json(list([member], page, 2)));
}
return Promise.resolve(serve(input));
});
const result = await customerHierarchyUsageCall(request);
expect(result.metadata.total_spend).toBe(4);
expect(result.metadata.total_tokens).toBe(600);
expect(result.metadata.total_api_requests).toBe(10);
expect(result.results).toHaveLength(1);
expect(result.results[0].metrics.spend).toBe(4);
expect(result.results[0].breakdown.model_groups["model-b"].metrics.total_tokens).toBe(500);
expect(result.api_keys.map((row) => row.label).sort()).toEqual(["Assistant key", "Search key"]);
expect(result.teams.map((row) => [row.id, row.spend])).toEqual([
["team-a", 3],
["team-b", 1],
]);
expect(result.members.map((row) => [row.id, row.tokens])).toEqual([
["member-a", 100],
["member-b", 500],
]);
expect(result.teamResults).toHaveLength(1);
expect(result.teamResults[0].metrics.spend).toBe(4);
expect(result.teamResults[0].breakdown.entities["team-a"]).toMatchObject({
metadata: { alias: "Product" },
metrics: { spend: 3 },
});
expect(result.memberResults).toHaveLength(1);
expect(result.memberResults[0].breakdown.entities["member-b"].metrics.total_tokens).toBe(500);
expect(result.memberBudgets[0]).toMatchObject({ id: "member-a", spend: 40, budgetLimit: 10 });
for (const url of dailyUrls()) {
expect(url.pathname).toMatch(/^\/management\/v1\/customer_(organizations|teams|members)\/daily\/activity$/);
expect(url.searchParams.get("start_date")).toBe(request.startDate);
expect(url.searchParams.get("end_date")).toBe(request.endDate);
expect(url.searchParams.get("page_size")).toBe("100");
expect([...url.searchParams.keys()].filter((name) => name.endsWith("_ids"))).toHaveLength(1);
}
const organizationPages = dailyUrls().filter((url) => url.searchParams.get("customer_org_ids") === "org-1");
expect(organizationPages.map((url) => url.searchParams.get("page"))).toEqual(["1", "2"]);
expect(
urls()
.filter((url) => url.pathname === "/management/v1/customer_teams")
.map((url) => [url.searchParams.get("parent_org_id"), url.searchParams.get("page")]),
).toEqual([
["org-1", "1"],
["org-1", "2"],
]);
expect(
dailyUrls()
.find((url) => url.searchParams.has("customer_team_ids"))
?.searchParams.get("customer_team_ids"),
).toBe("team-a,team-b");
expect(
dailyUrls()
.find((url) => url.searchParams.has("customer_member_ids"))
?.searchParams.get("customer_member_ids"),
).toBe("member-a,member-c,member-b");
expect(
urls().some(
(url) =>
url.pathname === "/management/v1/customer_members" &&
url.searchParams.get("parent_team_id") === "team-a" &&
url.searchParams.get("page") === "2",
),
).toBe(true);
});
it("should infer member ancestry and retain live spend and effective budget separately from range totals", async () => {
const result = await customerHierarchyUsageCall({
...request,
scope: { org: "org-1", team: null, member: "member-a" },
});
expect(result.team?.id).toBe("team-a");
expect(result.member).toMatchObject({ id: "member-a", spend: 40, budgetLimit: 10 });
expect(result.organization).toMatchObject({ spend: 40, budgetLimit: 100, budgetResetAt: "2026-12-01T00:00:00Z" });
expect(result.metadata.total_spend).toBe(3);
expect(result.teams).toEqual([{ id: "team-a", label: "Product", spend: 3, tokens: 100, requests: 8 }]);
expect(Object.keys(result.teamResults[0].breakdown.entities)).toEqual(["team-a"]);
expect(result.teamResults[0].breakdown.entities["team-a"].metrics.spend).toBe(3);
expect(result.memberResults[0].breakdown.entities["member-a"].metrics.total_tokens).toBe(100);
expect(dailyUrls()).toHaveLength(1);
expect(dailyUrls()[0].searchParams.get("customer_member_ids")).toBe("member-a");
expect(urls().some((url) => url.pathname === "/management/v1/customer_members")).toBe(false);
});
it("should reject mismatched ancestry before requesting any usage", async () => {
fetchMock.mockImplementation((input) =>
Promise.resolve(
requestUrl(input).pathname.endsWith("/customer_teams/team-a")
? json({ ...teams[0], parent_org_id: "another-org" })
: serve(input),
),
);
await expect(
customerHierarchyUsageCall({ ...request, scope: { org: "org-1", team: "team-a", member: null } }),
).rejects.toThrow("does not belong");
expect(dailyUrls()).toHaveLength(0);
});
it("should never send an empty descendant filter and should retain unassigned usage", async () => {
fetchMock.mockImplementation((input) =>
Promise.resolve(requestUrl(input).pathname === "/management/v1/customer_teams" ? json(list([])) : serve(input)),
);
const result = await customerHierarchyUsageCall(request);
expect(dailyUrls().every((url) => url.searchParams.get("customer_org_ids") === "org-1")).toBe(true);
expect(result.teams).toEqual([
{ id: "", label: "Unassigned or removed team", spend: 4, tokens: 600, requests: 10 },
]);
expect(result.members).toEqual([
{ id: "", label: "Unassigned or removed member", spend: 4, tokens: 600, requests: 10 },
]);
});
it("should fail a snapshot when a later page fails instead of presenting partial totals", async () => {
fetchMock.mockImplementation((input) => {
const url = requestUrl(input);
return Promise.resolve(
url.searchParams.get("customer_org_ids") === "org-1" && url.searchParams.get("page") === "2"
? json({ error: "Second page unavailable" }, 503)
: serve(input),
);
});
await expect(customerHierarchyUsageCall(request)).rejects.toThrow("Second page unavailable");
});
it("should reject identifiers the comma-separated API cannot represent instead of broadening filters", async () => {
await expect(
customerHierarchyUsageCall({ ...request, scope: { org: "org-1,org-2", team: null, member: null } }),
).rejects.toThrow();
expect(fetchMock).not.toHaveBeenCalled();
});
it("should chunk descendant filters and keep every request phase within four concurrent reads", async () => {
const manyTeams = Array.from({ length: 205 }, (_, index) => ({ ...teams[0], customer_team_id: `team-${index}` }));
let active = 0;
let maximumActive = 0;
fetchMock.mockImplementation(async (input) => {
active++;
maximumActive = Math.max(maximumActive, active);
await Promise.resolve();
const url = requestUrl(input);
active--;
if (url.pathname === "/management/v1/customer_teams") {
const page = Number(url.searchParams.get("page"));
return json(list(manyTeams.slice((page - 1) * 100, page * 100), page, 3));
}
if (url.pathname === "/management/v1/customer_members") return json(list([]));
if (url.pathname === "/management/v1/customer_teams/daily/activity") {
return json({ results: [], metadata: EMPTY_DAILY_ACTIVITY_METADATA });
}
return serve(input);
});
await customerHierarchyUsageCall(request);
expect(maximumActive).toBeLessThanOrEqual(4);
const teamFilters = dailyUrls()
.filter((url) => url.searchParams.has("customer_team_ids"))
.map((url) => url.searchParams.get("customer_team_ids")!.split(","));
expect(teamFilters.map((ids) => ids.length)).toEqual([100, 100, 5]);
expect(new Set(teamFilters.flat()).size).toBe(manyTeams.length);
expect(dailyUrls().some((url) => url.searchParams.has("customer_member_ids"))).toBe(false);
});
});

View file

@ -0,0 +1,265 @@
import { apiClient } from "@/components/networking";
import { type DailyActivityAggregatedResponse } from "@/components/UsagePage/dailyActivityApi";
import type { components } from "@/lib/http/schema";
import i18n from "@/i18n/i18n";
import {
mergeCustomerActivityPages,
customerEntityRankings,
customerKeyRankings,
customerEntityDailyData,
type CustomerUsageRow,
} from "./customerUsageData";
type Organization = components["schemas"]["Organization"];
type Team = components["schemas"]["Team"];
type Member = components["schemas"]["CustomerMember"];
type Collection = "customer_organizations" | "customer_teams" | "customer_members";
type UsageRow = Omit<CustomerUsageRow, "share">;
export interface CustomerUsageEntity {
id: string;
label: string;
spend: number;
budgetLimit: number | null;
budgetResetAt: string | null;
}
export interface CustomerHierarchyUsage extends ReturnType<typeof mergeCustomerActivityPages> {
organization: CustomerUsageEntity;
team: CustomerUsageEntity | null;
member: CustomerUsageEntity | null;
teams: UsageRow[];
members: UsageRow[];
api_keys: UsageRow[];
teamResults: ReturnType<typeof mergeCustomerActivityPages>["results"];
memberResults: ReturnType<typeof mergeCustomerActivityPages>["results"];
memberBudgets: CustomerUsageEntity[];
}
export interface CustomerUsageScope {
org: string;
team: string | null;
member: string | null;
}
interface CustomerUsageRequest {
scope: CustomerUsageScope;
accessToken: string;
startDate: string;
endDate: string;
signal?: AbortSignal;
}
interface ResolvedScope {
organization: Organization;
team: Team | null;
member: Member | null;
}
const PREFIX = "/management/v1";
const PAGE_SIZE = 100;
const MAX_PAGES = 1_000;
const MAX_CONCURRENT = 4;
const ID_FILTER: Record<Collection, string> = {
customer_organizations: "customer_org_ids",
customer_teams: "customer_team_ids",
customer_members: "customer_member_ids",
};
const assertIdentifiers = (ids: readonly string[]) => {
if (ids.some((id) => !id || id.includes(","))) throw new Error(i18n.t("customerPortal.analytics.invalidIdentifier"));
};
const assertScope = (valid: boolean) => {
if (!valid) throw new Error(i18n.t("customerPortal.analytics.scopeMismatch"));
};
const batches = async <T, R>(items: readonly T[], task: (item: T) => Promise<R>): Promise<R[]> => {
const results: R[] = [];
for (let start = 0; start < items.length; start += MAX_CONCURRENT) {
results.push(...(await Promise.all(items.slice(start, start + MAX_CONCURRENT).map(task))));
}
return results;
};
const info = <T>(resource: Collection, id: string, request: CustomerUsageRequest) =>
apiClient.get<T>(`${PREFIX}/${resource}/${encodeURIComponent(id)}`, {
accessToken: request.accessToken,
signal: request.signal,
});
const resolveScope = async (request: CustomerUsageRequest): Promise<ResolvedScope> => {
const { scope } = request;
assertIdentifiers([scope.org, ...[scope.team, scope.member].filter((id): id is string => id !== null)]);
const [organization, requestedTeam, member] = await Promise.all([
info<Organization>("customer_organizations", scope.org, request),
scope.team ? info<Team>("customer_teams", scope.team, request) : null,
scope.member ? info<Member>("customer_members", scope.member, request) : null,
]);
const team = requestedTeam ?? (member ? await info<Team>("customer_teams", member.parent_team_id, request) : null);
assertScope(organization.customer_org_id === scope.org);
assertScope(!team || team.parent_org_id === scope.org);
assertScope(!scope.team || team?.customer_team_id === scope.team);
assertScope(!member || member.customer_member_id === scope.member);
assertScope(!member || member.parent_team_id === team?.customer_team_id);
return { organization, team, member };
};
const listAll = async <T>(
resource: Collection,
parent: Record<string, string>,
request: CustomerUsageRequest,
): Promise<T[]> => {
const rows: T[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const result = await apiClient.get<{ data: T[]; meta: components["schemas"]["ListMeta"] }>(
`${PREFIX}/${resource}`,
{
accessToken: request.accessToken,
signal: request.signal,
query: { ...parent, page, page_size: PAGE_SIZE },
},
);
rows.push(...result.data);
if (page >= result.meta.total_pages) return rows;
}
throw new Error(i18n.t("customerPortal.analytics.incompletePages"));
};
const descendants = async (scope: ResolvedScope, request: CustomerUsageRequest) => {
const teams = scope.team
? [scope.team]
: await listAll<Team>("customer_teams", { parent_org_id: request.scope.org }, request);
if (teams.some((team) => team.parent_org_id !== request.scope.org))
throw new Error(i18n.t("customerPortal.analytics.scopeMismatch"));
const memberLists = scope.member
? [[scope.member]]
: await batches(teams, async (team) => {
const members = await listAll<Member>("customer_members", { parent_team_id: team.customer_team_id }, request);
if (members.some((member) => member.parent_team_id !== team.customer_team_id))
throw new Error(i18n.t("customerPortal.analytics.scopeMismatch"));
return members;
});
return {
teams: [...new Map(teams.map((team) => [team.customer_team_id, team])).values()],
members: [...new Map(memberLists.flat().map((member) => [member.customer_member_id, member])).values()],
};
};
const activityPages = async (resource: Collection, ids: readonly string[], request: CustomerUsageRequest) => {
assertIdentifiers(ids);
const pages: DailyActivityAggregatedResponse[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const result = await apiClient.get<DailyActivityAggregatedResponse>(`${PREFIX}/${resource}/daily/activity`, {
accessToken: request.accessToken,
signal: request.signal,
query: {
start_date: request.startDate,
end_date: request.endDate,
[ID_FILTER[resource]]: ids.join(","),
page,
page_size: PAGE_SIZE,
},
});
if (!result.metadata) throw new Error(i18n.t("customerPortal.analytics.incompletePages"));
pages.push(result);
if (!result.metadata.has_more && page >= result.metadata.total_pages) return pages;
}
throw new Error(i18n.t("customerPortal.analytics.incompletePages"));
};
interface ActivityJob {
dimension: "scope" | "teams" | "members";
resource: Collection;
ids: readonly string[];
}
const chunkJobs = (resource: Collection, dimension: ActivityJob["dimension"], ids: readonly string[]): ActivityJob[] =>
Array.from({ length: Math.ceil(ids.length / PAGE_SIZE) }, (_, index) => ({
resource,
dimension,
ids: ids.slice(index * PAGE_SIZE, (index + 1) * PAGE_SIZE),
}));
const entity = (id: string, record: Organization | Team | Member): CustomerUsageEntity => ({
id,
label: record.alias || id,
spend: record.spend,
budgetLimit: ("effective_budget" in record ? record.effective_budget : record.budget)?.max_budget ?? null,
budgetResetAt: record.budget_reset_at ?? null,
});
const selectedResource = (scope: ResolvedScope): Collection => {
if (scope.member) return "customer_members";
if (scope.team) return "customer_teams";
return "customer_organizations";
};
export const customerHierarchyUsageCall = async (request: CustomerUsageRequest): Promise<CustomerHierarchyUsage> => {
const selected = await resolveScope(request);
const children = await descendants(selected, request);
const resource = selectedResource(selected);
const selectedId = selected.member?.customer_member_id ?? selected.team?.customer_team_id ?? request.scope.org;
const jobs: ActivityJob[] = [
{ dimension: "scope", resource, ids: [selectedId] },
...(!selected.team
? chunkJobs(
"customer_teams",
"teams",
children.teams.map((team) => team.customer_team_id),
)
: []),
...(!selected.member
? chunkJobs(
"customer_members",
"members",
children.members.map((member) => member.customer_member_id),
)
: []),
];
const activity = await batches(jobs, async (job) => ({
dimension: job.dimension,
pages: await activityPages(job.resource, job.ids, request),
}));
const primary = mergeCustomerActivityPages(
activity.filter((result) => result.dimension === "scope").flatMap((result) => result.pages),
);
const teamDays = selected.team
? primary.results
: mergeCustomerActivityPages(
activity.filter((result) => result.dimension === "teams").flatMap((result) => result.pages),
).results;
const memberDays = selected.member
? primary.results
: mergeCustomerActivityPages(
activity.filter((result) => result.dimension === "members").flatMap((result) => result.pages),
).results;
const teamLabels = new Map(
children.teams.map((team) => [team.customer_team_id, team.alias || team.customer_team_id]),
);
const memberLabels = new Map(
children.members.map((member) => [member.customer_member_id, member.alias || member.customer_member_id]),
);
return {
...primary,
organization: entity(selected.organization.customer_org_id, selected.organization),
team: selected.team ? entity(selected.team.customer_team_id, selected.team) : null,
member: selected.member ? entity(selected.member.customer_member_id, selected.member) : null,
teams: customerEntityRankings(teamDays, teamLabels, primary.metadata, {
unassignedLabel: i18n.t("customerPortal.analytics.unassignedTeam"),
onlyEntityId: selected.member ? selected.team?.customer_team_id : undefined,
}),
members: customerEntityRankings(memberDays, memberLabels, primary.metadata, {
unassignedLabel: i18n.t("customerPortal.analytics.unassignedMember"),
}),
api_keys: customerKeyRankings(primary.results, i18n.t("customerPortal.analytics.unnamedKey")),
teamResults: customerEntityDailyData(
primary.results,
teamDays,
teamLabels,
selected.member ? selected.team?.customer_team_id : undefined,
),
memberResults: customerEntityDailyData(primary.results, memberDays, memberLabels),
memberBudgets: children.members.map((member) => entity(member.customer_member_id, member)),
};
};

View file

@ -0,0 +1,185 @@
import { describe, expect, it } from "vitest";
import {
bucketTotals,
OTHER_COLOR,
type Series,
type UsageMetric,
} from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import { stackedUsageColor } from "@/components/shared/charts";
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
import type { DailyData, MetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types";
import i18n from "@/i18n/i18n";
import type { CustomerHierarchyUsage } from "./customerUsageApi";
import { customerUsageSeries } from "./customerUsageChartData";
import type { CustomerUsageDimension } from "./CustomerUsageRankings";
const metrics = (spend: number, tokens: number, requests: number): SpendMetrics => ({
spend,
prompt_tokens: tokens,
completion_tokens: 0,
total_tokens: tokens,
api_requests: requests,
successful_requests: requests,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
});
const entry = (spend: number, tokens: number, requests: number): MetricWithMetadata => ({
metrics: metrics(spend, tokens, requests),
metadata: {},
api_key_breakdown: {},
});
const day = (date: string, total: SpendMetrics, breakdown: Partial<DailyData["breakdown"]> = {}): DailyData => ({
date,
metrics: total,
breakdown: { models: {}, model_groups: {}, providers: {}, mcp_servers: {}, api_keys: {}, entities: {}, ...breakdown },
});
const snapshot = (): CustomerHierarchyUsage => ({
organization: { id: "org", label: "Organization", spend: 0, budgetLimit: null, budgetResetAt: null },
team: null,
member: null,
memberBudgets: [],
metadata: { ...EMPTY_DAILY_ACTIVITY_METADATA },
results: [
day("2026-11-01", metrics(100, 1_000, 100), {
models: { deployed: entry(100, 1_000, 100) },
model_groups: { alpha: entry(60, 300, 20), beta: entry(40, 700, 80) },
api_keys: {
"hash-a": { metrics: metrics(30, 800, 70), metadata: { key_alias: "Support", team_id: null } },
"hash-b": { metrics: metrics(70, 200, 30), metadata: { key_alias: "Search", team_id: null } },
},
}),
day("2026-11-02", metrics(60, 900, 30)),
day("2026-11-03", metrics(50, 500, 40)),
],
teamResults: [
day("2026-11-01", metrics(75, 800, 60), {
entities: { "team-a": entry(25, 100, 40), "team-b": entry(50, 700, 20) },
}),
day("2026-11-02", metrics(10, 90, 5), { entities: { "team-a": entry(10, 90, 5) } }),
day("2026-11-09", metrics(999, 999, 999), { entities: { "team-a": entry(999, 999, 999) } }),
],
memberResults: [
day("2026-11-01", metrics(80, 700, 80), {
entities: { "member-a": entry(20, 200, 30), "member-b": entry(60, 500, 50) },
}),
],
teams: [
{ id: "team-a", label: "Product", spend: 35, tokens: 190, requests: 45 },
{ id: "team-b", label: "Operations", spend: 50, tokens: 700, requests: 20 },
],
members: [
{ id: "member-a", label: "Member A", spend: 20, tokens: 200, requests: 30 },
{ id: "member-b", label: "Member B", spend: 60, tokens: 500, requests: 50 },
],
api_keys: [
{ id: "hash-a", label: "Support", spend: 30, tokens: 800, requests: 70 },
{ id: "hash-b", label: "Search", spend: 70, tokens: 200, requests: 30 },
],
});
const modelColors = new Map([
["alpha", stackedUsageColor(3)],
["beta", stackedUsageColor(4)],
]);
const valueAt = (series: Series, label: string, date = "2026-11-01"): number => {
const index = series.labels.indexOf(label);
return Number(series.data.find((row) => row.date === date)?.[series.keys[index]] ?? 0);
};
const colorOf = (series: Series, label: string): string => series.colors[series.labels.indexOf(label)];
const other = () => i18n.t("usage.overview.otherSeries");
describe("customerUsageSeries", () => {
it.each<[CustomerUsageDimension, string, number]>([
["models", "alpha", 60],
["api_keys", "Support", 30],
["teams", "Product", 25],
])("should use %s values without adding the other breakdowns", (dimension, label, value) => {
const series = customerUsageSeries(snapshot(), dimension, "spend", modelColors);
expect(valueAt(series, label)).toBe(value);
expect(bucketTotals(series).get("2026-11-01")).toBe(100);
expect(series.labels).not.toContain("deployed");
});
it.each<[UsageMetric, number, number, number]>([
["spend", 25, 50, 25],
["tokens", 100, 700, 200],
["requests", 40, 20, 40],
])("should use %s for both named teams and the unattributed remainder", (metric, first, second, remainder) => {
const series = customerUsageSeries(snapshot(), "teams", metric, modelColors);
expect(valueAt(series, "Product")).toBe(first);
expect(valueAt(series, "Operations")).toBe(second);
expect(valueAt(series, other())).toBe(remainder);
expect(colorOf(series, other())).toBe(OTHER_COLOR);
});
it("should preserve all selected-scope dates and totals when companion records are missing", () => {
const series = customerUsageSeries(snapshot(), "teams", "spend", modelColors);
expect(series.data.map((row) => row.date)).toEqual(["2026-11-01", "2026-11-02", "2026-11-03"]);
expect(valueAt(series, "Product", "2026-11-02")).toBe(10);
expect(valueAt(series, other(), "2026-11-02")).toBe(50);
expect(valueAt(series, "Product", "2026-11-03")).toBe(0);
expect(valueAt(series, other(), "2026-11-03")).toBe(50);
expect([...bucketTotals(series).values()]).toEqual([100, 60, 50]);
});
it("should include removed and unnamed descendants in Other rather than a misleading named bucket", () => {
const data = snapshot();
const removed = day("2026-11-01", metrics(100, 1_000, 100), {
entities: { "team-a": entry(25, 100, 40), removed: entry(10, 150, 10), "": entry(5, 50, 5) },
});
const series = customerUsageSeries({ ...data, teamResults: [removed] }, "teams", "tokens", modelColors);
expect(series.labels).toEqual(["Product", other()]);
expect(valueAt(series, "Product")).toBe(100);
expect(valueAt(series, other())).toBe(900);
});
it("should keep spend-ranked palette colors attached to API keys when the selected metric changes", () => {
const spend = customerUsageSeries(snapshot(), "api_keys", "spend", modelColors);
const tokens = customerUsageSeries(snapshot(), "api_keys", "tokens", modelColors);
expect(spend.labels.slice(0, 2)).toEqual(["Search", "Support"]);
expect(tokens.labels.slice(0, 2)).toEqual(["Support", "Search"]);
expect(colorOf(spend, "Search")).toBe(stackedUsageColor(0));
expect(colorOf(tokens, "Search")).toBe(stackedUsageColor(0));
expect(colorOf(spend, "Support")).toBe(stackedUsageColor(1));
expect(colorOf(tokens, "Support")).toBe(stackedUsageColor(1));
});
it("should use key aliases and hide hashes even when a label is missing or repeats the hash", () => {
const data = snapshot();
const series = customerUsageSeries(
{ ...data, api_keys: [{ ...data.api_keys[0], label: " hash-a " }] },
"api_keys",
"spend",
modelColors,
);
expect(series.labels).not.toContain("hash-a");
expect(series.labels).not.toContain("hash-b");
expect(series.labels.filter((label) => label === i18n.t("customerPortal.analytics.unnamedKey"))).toHaveLength(2);
expect(bucketTotals(series).get("2026-11-01")).toBe(100);
});
it("should preserve model colors and fall back to the models breakdown when groups are absent", () => {
const data = snapshot();
const color = stackedUsageColor(5);
const series = customerUsageSeries(
{ ...data, results: [day("2026-11-01", metrics(7, 70, 2), { models: { deployed: entry(7, 70, 2) } })] },
"models",
"tokens",
new Map([["deployed", color]]),
);
expect(series.labels).toEqual(["deployed"]);
expect(valueAt(series, "deployed")).toBe(70);
expect(colorOf(series, "deployed")).toBe(color);
});
});

View file

@ -0,0 +1,71 @@
import {
rankValue,
rollUpBreakdown,
seriesBy,
type Series,
type UsageMetric,
} from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import { stackedUsageColor } from "@/components/shared/charts";
import type { DailyData } from "@/components/UsagePage/types";
import i18n from "@/i18n/i18n";
import type { CustomerHierarchyUsage } from "./customerUsageApi";
import type { CustomerUsageDimension } from "./CustomerUsageRankings";
const TOP_SERIES = 8;
const entityDays = (
primary: readonly DailyData[],
entities: readonly DailyData[],
labels: ReadonlyMap<string, string>,
): DailyData[] => {
const byDate = new Map(entities.map((day) => [day.date, day]));
return primary.map((day) => ({
...day,
breakdown: {
...day.breakdown,
model_groups: Object.fromEntries(
Object.entries(byDate.get(day.date)?.breakdown.entities ?? {}).filter(([id]) => id && labels.has(id)),
),
},
}));
};
export const customerUsageSeries = (
data: CustomerHierarchyUsage,
dimension: CustomerUsageDimension,
metric: UsageMetric,
colorByModel: ReadonlyMap<string, string>,
): Series => {
if (dimension === "models") {
const days = data.results.map((day) => ({
...day,
breakdown: {
...day.breakdown,
model_groups:
Object.keys(day.breakdown.model_groups).length > 0 ? day.breakdown.model_groups : day.breakdown.models,
},
}));
return seriesBy(days, "model_groups", metric, { top: TOP_SERIES, colorByKey: colorByModel });
}
const unnamedKey = i18n.t("customerPortal.analytics.unnamedKey");
const rows = data[dimension];
const labels = new Map(
rows
.filter((row) => row.id)
.map((row) => {
const label = row.label.trim();
return [row.id, dimension === "api_keys" && (!label || label === row.id) ? unnamedKey : label];
}),
);
const days = dimension === "api_keys" ? data.results : entityDays(data.results, data.teamResults, labels);
const breakdown = dimension === "api_keys" ? "api_keys" : "model_groups";
const ranking = rollUpBreakdown(days, breakdown).sort((a, b) => b.spend - a.spend || a.key.localeCompare(b.key));
const colors = new Map(ranking.map((row, index) => [row.key, stackedUsageColor(index)]));
const series = seriesBy(days, breakdown, metric, { top: TOP_SERIES, colorByKey: colors });
const namedCount = Math.min(TOP_SERIES, ranking.filter((row) => rankValue(row, metric) > 0).length);
return {
...series,
labels: series.labels.map((id, index) => (index < namedCount ? labels.get(id) ?? unnamedKey : id)),
};
};

View file

@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import type { DailyData, MetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types";
import { rankCustomerModels, rankCustomerUsage, type CustomerUsageRow } from "./customerUsageData";
const metrics = (spend: number, tokens: number, requests: number): SpendMetrics => ({
spend,
prompt_tokens: tokens,
completion_tokens: 0,
total_tokens: tokens,
api_requests: requests,
successful_requests: requests,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
});
const model = (spend: number, tokens: number, requests: number): MetricWithMetadata => ({
metrics: metrics(spend, tokens, requests),
metadata: {},
api_key_breakdown: {},
});
const day = (
date: string,
modelGroups: Record<string, MetricWithMetadata>,
models: Record<string, MetricWithMetadata> = {},
): DailyData => ({
date,
metrics: metrics(0, 0, 0),
breakdown: {
models,
model_groups: modelGroups,
providers: {},
mcp_servers: {},
api_keys: {},
entities: {},
},
});
const totals = { spend: 10, tokens: 1_000, requests: 20 };
describe("rankCustomerUsage", () => {
it("should sort by the selected metric and calculate shares against overall customer totals", () => {
const rows = [
{ id: "assistant", label: "Assistant", spend: 3, tokens: 100, requests: 8 },
{ id: "search", label: "Search", spend: 1, tokens: 500, requests: 2 },
];
expect(rankCustomerUsage(rows, "spend", totals).map((row) => [row.label, row.share])).toEqual([
["Assistant", 0.3],
["Search", 0.1],
]);
expect(rankCustomerUsage(rows, "tokens", totals).map((row) => [row.label, row.share])).toEqual([
["Search", 0.5],
["Assistant", 0.1],
]);
expect(rankCustomerUsage(rows, "requests", totals).map((row) => [row.label, row.share])).toEqual([
["Assistant", 0.4],
["Search", 0.1],
]);
});
it("should preserve zero-cost token usage while removing rows with no activity", () => {
const freeUsage = { id: "assistant", label: "Assistant", spend: 0, tokens: 200, requests: 3 };
const inactive = { id: "inactive", label: "Inactive", spend: 0, tokens: 0, requests: 0 };
expect(rankCustomerUsage([inactive, freeUsage], "tokens", totals)).toEqual([{ ...freeUsage, share: 0.2 }]);
expect(rankCustomerUsage([freeUsage], "spend", totals)).toEqual([{ ...freeUsage, share: 0 }]);
});
it("should use stable label and id order for ties without modifying the source rows", () => {
const rows: readonly Omit<CustomerUsageRow, "share">[] = Object.freeze([
{ id: "b", label: "Search", spend: 1, tokens: 100, requests: 1 },
{ id: "c", label: "Assistant", spend: 1, tokens: 100, requests: 1 },
{ id: "a", label: "Assistant", spend: 1, tokens: 100, requests: 1 },
]);
expect(rankCustomerUsage(rows, "tokens", totals).map((row) => row.id)).toEqual(["a", "c", "b"]);
expect(rows.map((row) => row.id)).toEqual(["b", "c", "a"]);
});
it("should return finite zero shares when the overall metric is zero", () => {
const rows = [{ id: "assistant", label: "Assistant", spend: 0, tokens: 200, requests: 1 }];
expect(rankCustomerUsage(rows, "spend", { spend: 0, tokens: 200, requests: 1 })[0].share).toBe(0);
expect(rankCustomerUsage([], "spend", totals)).toEqual([]);
});
});
describe("rankCustomerModels", () => {
it("should sum models across days without adding model and model-group totals together", () => {
const days = [
day("2026-11-01", { assistant: model(2, 100, 3) }, { assistant: model(2, 100, 3) }),
day("2026-11-02", { assistant: model(1, 200, 2), search: model(0, 400, 1) }),
day("2026-11-03", {}, { assistant: model(1, 100, 1) }),
];
expect(rankCustomerModels(days, "tokens", totals)).toEqual([
{ id: "assistant", label: "assistant", spend: 4, tokens: 400, requests: 6, share: 0.4 },
{ id: "search", label: "search", spend: 0, tokens: 400, requests: 1, share: 0.4 },
]);
});
it("should return no model rows for an empty date range", () => {
expect(rankCustomerModels([], "spend", totals)).toEqual([]);
});
});

View file

@ -0,0 +1,262 @@
import type { DailyData, SpendMetrics, BreakdownMetrics, MetricWithMetadata } from "@/components/UsagePage/types";
import {
EMPTY_DAILY_ACTIVITY_METADATA,
toDailyData,
type DailyActivityAggregatedResponse,
type DailyActivityMetadata,
} from "@/components/UsagePage/dailyActivityApi";
import {
rollUpBreakdown,
type UsageMetric,
} from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
export interface CustomerUsageTotals {
spend: number;
tokens: number;
requests: number;
}
export interface CustomerUsageRow extends CustomerUsageTotals {
id: string;
label: string;
share: number;
}
export const rankCustomerUsage = (
rows: readonly Omit<CustomerUsageRow, "share">[],
metric: UsageMetric,
totals: CustomerUsageTotals,
): CustomerUsageRow[] =>
rows
.filter((row) => row.spend > 0 || row.tokens > 0 || row.requests > 0)
.map((row) => ({ ...row, share: totals[metric] > 0 ? row[metric] / totals[metric] : 0 }))
.sort((a, b) => b[metric] - a[metric] || a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
export const rankCustomerModels = (
days: readonly DailyData[],
metric: UsageMetric,
totals: CustomerUsageTotals,
): CustomerUsageRow[] => {
const withModelGroups = days.map((day) => ({
...day,
breakdown: {
...day.breakdown,
model_groups:
Object.keys(day.breakdown.model_groups).length > 0 ? day.breakdown.model_groups : day.breakdown.models,
},
}));
return rankCustomerUsage(
rollUpBreakdown(withModelGroups, "model_groups").map((model) => ({
id: model.key,
label: model.key,
spend: model.spend,
tokens: model.tokens,
requests: model.requests,
})),
metric,
totals,
);
};
const ZERO_METRICS: SpendMetrics = {
spend: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
api_requests: 0,
successful_requests: 0,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
};
const sumMetrics = (values: readonly SpendMetrics[]): SpendMetrics =>
values.reduce(
(total, value) => ({
...total,
...Object.fromEntries(
(Object.keys(value) as (keyof SpendMetrics)[]).map((field) => [
field,
(total[field] ?? 0) + (value[field] ?? 0),
]),
),
}),
ZERO_METRICS,
);
const mergeKeys = (
left: BreakdownMetrics["api_keys"],
right: BreakdownMetrics["api_keys"],
): BreakdownMetrics["api_keys"] => {
const result = { ...left };
for (const [id, entry] of Object.entries(right)) {
const previous = result[id];
result[id] = {
...entry,
metrics: sumMetrics([...(previous ? [previous.metrics] : []), entry.metrics]),
metadata: {
...previous?.metadata,
...entry.metadata,
key_alias: entry.metadata.key_alias ?? previous?.metadata.key_alias ?? null,
},
};
}
return result;
};
const mergeMetrics = (
left: Record<string, MetricWithMetadata>,
right: Record<string, MetricWithMetadata>,
): Record<string, MetricWithMetadata> => {
const result = { ...left };
for (const [id, entry] of Object.entries(right)) {
const previous = result[id];
result[id] = {
...entry,
metrics: sumMetrics([...(previous ? [previous.metrics] : []), entry.metrics]),
metadata: { ...previous?.metadata, ...entry.metadata },
api_key_breakdown: mergeKeys(previous?.api_key_breakdown ?? {}, entry.api_key_breakdown),
};
}
return result;
};
const mergeBreakdowns = (left: BreakdownMetrics, right: BreakdownMetrics): BreakdownMetrics => ({
models: mergeMetrics(left.models, right.models),
model_groups: mergeMetrics(left.model_groups, right.model_groups),
providers: mergeMetrics(left.providers, right.providers),
mcp_servers: mergeMetrics(left.mcp_servers, right.mcp_servers),
endpoints: mergeMetrics(left.endpoints ?? {}, right.endpoints ?? {}),
entities: mergeMetrics(left.entities, right.entities),
api_keys: mergeKeys(left.api_keys, right.api_keys),
});
export const mergeCustomerActivityPages = (
pages: readonly DailyActivityAggregatedResponse[],
): { results: DailyData[]; metadata: DailyActivityMetadata } => {
const days = new Map<string, DailyData>();
for (const day of pages.flatMap(toDailyData)) {
const previous = days.get(day.date);
days.set(
day.date,
previous
? {
date: day.date,
metrics: sumMetrics([previous.metrics, day.metrics]),
breakdown: mergeBreakdowns(previous.breakdown, day.breakdown),
}
: day,
);
}
const results = [...days.values()].sort((a, b) => a.date.localeCompare(b.date));
const total = sumMetrics(results.map((day) => day.metrics));
return {
results,
metadata: {
...EMPTY_DAILY_ACTIVITY_METADATA,
total_spend: total.spend,
total_flat_cost: total.flat_cost ?? 0,
total_ptu_hours: total.ptu_hours ?? 0,
total_prompt_tokens: total.prompt_tokens,
total_completion_tokens: total.completion_tokens,
total_tokens: total.total_tokens,
total_api_requests: total.api_requests,
total_successful_requests: total.successful_requests,
total_failed_requests: total.failed_requests,
total_cache_read_input_tokens: total.cache_read_input_tokens,
total_cache_creation_input_tokens: total.cache_creation_input_tokens,
total_compression_saved_tokens: total.compression_saved_tokens ?? 0,
total_compression_savings_spend: total.compression_savings_spend ?? 0,
total_prompt_caching_savings_spend: total.prompt_caching_savings_spend ?? 0,
total_gateway_injected_caching_savings_spend: total.gateway_injected_caching_savings_spend ?? 0,
total_autorouter_savings_spend: total.autorouter_savings_spend ?? 0,
total_response_time_ms: total.total_response_time_ms ?? 0,
total_timed_requests: total.timed_requests ?? 0,
total_api_keys: new Set(results.flatMap((day) => Object.keys(day.breakdown.api_keys))).size,
},
};
};
const usageTotals = (value: SpendMetrics): CustomerUsageTotals => ({
spend: value.spend,
tokens: value.total_tokens,
requests: value.api_requests,
});
const addTotals = (left: CustomerUsageTotals, right: CustomerUsageTotals): CustomerUsageTotals => ({
spend: left.spend + right.spend,
tokens: left.tokens + right.tokens,
requests: left.requests + right.requests,
});
export const customerKeyRankings = (
days: readonly DailyData[],
unnamedKey: string,
): Omit<CustomerUsageRow, "share">[] => {
const keys = new Map<string, Omit<CustomerUsageRow, "share">>();
for (const [id, entry] of days.flatMap((day) => Object.entries(day.breakdown.api_keys))) {
const previous = keys.get(id);
keys.set(id, {
id,
label: entry.metadata.key_alias?.trim() || previous?.label || unnamedKey,
...addTotals(previous ?? { spend: 0, tokens: 0, requests: 0 }, usageTotals(entry.metrics)),
});
}
return [...keys.values()];
};
interface EntityRankingOptions {
unassignedLabel: string;
onlyEntityId?: string;
}
export const customerEntityDailyData = (
primary: readonly DailyData[],
days: readonly DailyData[],
labels: ReadonlyMap<string, string>,
onlyEntityId?: string,
): DailyData[] => {
const byDate = new Map(days.map((day) => [day.date, day]));
return primary.map((day) => {
const entities = onlyEntityId
? {
[onlyEntityId]: {
metrics: day.metrics,
metadata: { alias: labels.get(onlyEntityId) },
api_key_breakdown: day.breakdown.api_keys,
},
}
: Object.fromEntries(
Object.entries(byDate.get(day.date)?.breakdown.entities ?? {})
.filter(([id]) => labels.has(id))
.map(([id, entry]) => [id, { ...entry, metadata: { ...entry.metadata, alias: labels.get(id) } }]),
);
return { ...day, breakdown: { ...day.breakdown, entities } };
});
};
export const customerEntityRankings = (
days: readonly DailyData[],
labels: ReadonlyMap<string, string>,
metadata: DailyActivityMetadata,
options: EntityRankingOptions,
): Omit<CustomerUsageRow, "share">[] => {
const entries = new Map<string, CustomerUsageTotals>();
for (const [id, entry] of days.flatMap((day) => Object.entries(day.breakdown.entities))) {
const identity = options.onlyEntityId ?? id;
if (labels.has(identity))
entries.set(
identity,
addTotals(entries.get(identity) ?? { spend: 0, tokens: 0, requests: 0 }, usageTotals(entry.metrics)),
);
}
const rows = [...entries].map(([id, total]) => ({ id, label: labels.get(id)!, ...total }));
const assigned = rows.reduce(addTotals, { spend: 0, tokens: 0, requests: 0 });
const unassigned = {
spend: Math.max(0, metadata.total_spend - assigned.spend),
tokens: Math.max(0, metadata.total_tokens - assigned.tokens),
requests: Math.max(0, metadata.total_api_requests - assigned.requests),
};
return unassigned.spend > 0.000001 || unassigned.tokens > 0 || unassigned.requests > 0
? [...rows, { id: "", label: options.unassignedLabel, ...unassigned }]
: rows;
};

View file

@ -0,0 +1,122 @@
import { useMemo } from "react";
import { skipToken, useQuery } from "@tanstack/react-query";
import { parseAsInteger, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import {
dateRangeFromParams,
dateRangePatch,
defaultUsageDateRange,
usageUrlParsers,
} from "@/app/(dashboard)/usage/_components/usageUrlState";
import {
modelColorMap,
overviewTotals,
type Granularity,
type UsageMetric,
} from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import { formatDate } from "@/components/networking";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
import type { DailyData } from "@/components/UsagePage/types";
import { customerHierarchyUsageCall, type CustomerUsageScope } from "./customerUsageApi";
import { rankCustomerModels, rankCustomerUsage } from "./customerUsageData";
import { CUSTOMER_USAGE_DIMENSIONS, type CustomerUsageDimension } from "./CustomerUsageRankings";
const usageParsers = {
org: parseAsString,
team: parseAsString,
member: parseAsString,
metric: parseAsStringLiteral(["spend", "tokens", "requests"] as const).withDefault("spend"),
breakdown: parseAsStringLiteral(CUSTOMER_USAGE_DIMENSIONS).withDefault("models"),
bucket: parseAsStringLiteral(["day", "week"] as const).withDefault("day"),
search: parseAsString.withDefault(""),
usagePage: parseAsInteger.withDefault(1),
range: usageUrlParsers.range,
from: usageUrlParsers.from,
to: usageUrlParsers.to,
};
const EMPTY_RESULTS: readonly DailyData[] = [];
const ORGANIZATION_PATCH = { team: null, member: null, search: null, usagePage: null };
const TEAM_PATCH = { member: null, search: null, usagePage: null };
const usageRequest = (
scope: CustomerUsageScope | null,
accessToken: string | null,
startDate: string | undefined,
endDate: string | undefined,
) => {
if (!scope || !accessToken) return null;
if (!startDate || !endDate) return null;
return { scope, accessToken, startDate, endDate };
};
export default function useCustomerHierarchyUsage() {
const { accessToken } = useAuthorized();
const [params, setParams] = useQueryStates(usageParsers);
const defaultRange = useMemo(() => defaultUsageDateRange("month_to_date"), []);
const dateValue = useMemo(() => dateRangeFromParams(params) ?? defaultRange.value, [params, defaultRange]);
const preset = params.range ?? (params.from === null && params.to === null ? defaultRange.preset : null);
const startDate = dateValue.from ? formatDate(dateValue.from) : undefined;
const endDate = dateValue.to ? formatDate(dateValue.to) : undefined;
const scope = params.org ? { org: params.org, team: params.team, member: params.member } : null;
const request = usageRequest(scope, accessToken, startDate, endDate);
const usage = useQuery({
queryKey: ["customer-hierarchy", "usage", params.org, params.team, params.member, startDate, endDate],
queryFn: request ? ({ signal }) => customerHierarchyUsageCall({ ...request, signal }) : skipToken,
});
const data = usage.data;
const results = data?.results ?? EMPTY_RESULTS;
const totals = useMemo(() => overviewTotals(data?.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, null), [data]);
const modelColors = useMemo(() => modelColorMap(results), [results]);
const rows = useMemo(
() =>
params.breakdown === "models"
? rankCustomerModels(results, params.metric, totals)
: rankCustomerUsage(data?.[params.breakdown] ?? [], params.metric, totals),
[data, results, params.breakdown, params.metric, totals],
);
const drillDown = (dimension: "teams" | "members", id: string) => {
const patch = {
...(dimension === "teams" ? { team: id, member: null } : { member: id }),
search: null,
usagePage: null,
breakdown: null,
};
void setParams(patch, { history: "push" });
};
const setDateRange = (value: DateRangePickerValue, selectedPreset: string | null) => {
const { range, from, to } = dateRangePatch(value, selectedPreset);
const patch = { range, from, to, usagePage: null };
void setParams(patch);
};
const setDimension = (breakdown: CustomerUsageDimension) =>
void setParams({ breakdown, search: null, usagePage: null });
const selectOrganization = () => void setParams(ORGANIZATION_PATCH, { history: "push" });
const selectTeam = () => void setParams(TEAM_PATCH, { history: "push" });
const setMetric = (metric: UsageMetric) => void setParams({ metric, usagePage: null });
const setBucket = (bucket: Granularity) => void setParams({ bucket });
const setSearch = (search: string) => void setParams({ search, usagePage: null });
const setPage = (usagePage: number) => void setParams({ usagePage });
return {
params,
scope,
dateValue,
preset,
usage,
data,
results,
totals,
modelColors,
rows,
drillDown,
setDateRange,
setDimension,
selectOrganization,
selectTeam,
setMetric,
setBucket,
setSearch,
setPage,
};
}

View file

@ -0,0 +1,379 @@
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerBaseUrlGetter } from "@/lib/http/runtime";
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
import type { SpendMetrics } from "@/components/UsagePage/types";
import {
act,
chooseSelectOption,
fireEvent,
renderWithProviders,
screen,
testQueryClient,
waitFor,
within,
} from "../../../../../tests/test-utils";
import CustomerHierarchyUsagePage from "./page";
const metrics = (spend: number, tokens: number, requests: number): SpendMetrics => ({
spend,
api_requests: requests,
successful_requests: requests,
failed_requests: 0,
total_tokens: tokens,
prompt_tokens: tokens,
completion_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
});
const audit = {
created_at: "2026-11-01T00:00:00Z",
created_by: "admin",
updated_at: "2026-11-01T00:00:00Z",
updated_by: "admin",
spend: 40,
};
const organization = {
...audit,
customer_org_id: "org-1",
alias: "Customer organization",
budget: { max_budget: 100 },
default_budgets: [],
};
const teams = [
{ ...audit, customer_team_id: "team-a", parent_org_id: "org-1", alias: "Product", budget: null },
{ ...audit, customer_team_id: "team-b", parent_org_id: "org-1", alias: "Operations", budget: null },
];
const members = [
{
...audit,
customer_member_id: "member-a",
parent_team_id: "team-a",
alias: "Member One",
effective_budget: { max_budget: 10 },
},
{
...audit,
customer_member_id: "member-b",
parent_team_id: "team-b",
alias: "Member Two",
effective_budget: { max_budget: 20 },
},
];
const records = [
{
organization: "org-1",
team: "team-a",
member: "member-a",
model: "model-a",
key: "hash-a",
alias: "Assistant key",
metrics: metrics(12, 100, 2),
date: "2026-11-01",
},
{
organization: "org-1",
team: "team-b",
member: "member-b",
model: "model-b",
key: "hash-b",
alias: "Search key",
metrics: metrics(3, 600, 5),
date: "2026-11-02",
},
];
const fetchMock = vi.fn<typeof fetch>();
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
const requestUrl = (input: Parameters<typeof fetch>[0]) =>
new URL(input instanceof Request ? input.url : String(input), "http://localhost");
const requestedUrls = () => fetchMock.mock.calls.map(([input]) => requestUrl(input));
const initialUrl = "?org=org-1&from=2026-11-01&to=2026-11-03&source=portal";
const tableRows = (name: string) => within(screen.getByRole("table", { name })).getAllByRole("row").slice(1);
const list = <T,>(data: T[]) => ({ data, meta: { total_count: data.length, page: 1, page_size: 100, total_pages: 1 } });
const selectBreakdown = (user: ReturnType<typeof userEvent.setup>, name: string) =>
chooseSelectOption(user, screen.getByRole("combobox", { name: "Break down by" }), name);
const activityDimension = (resource: string | undefined): "organization" | "team" | "member" => {
if (resource === "customer_organizations") return "organization";
if (resource === "customer_teams") return "team";
return "member";
};
const filterFor = (resource: string | undefined) =>
({
organization: "customer_org_ids",
team: "customer_team_ids",
member: "customer_member_ids",
})[activityDimension(resource)];
const activityUrl = (resource: string, id: string) => {
const filter = filterFor(resource);
return requestedUrls().findLast(
(url) => url.pathname === `/management/v1/${resource}/daily/activity` && url.searchParams.get(filter) === id,
);
};
const serve = (input: Parameters<typeof fetch>[0]): Response => {
const url = requestUrl(input);
if (url.pathname.endsWith("/daily/activity")) {
const resource = url.pathname.split("/").at(-3);
const dimension = activityDimension(resource);
const filter = filterFor(resource);
const ids = url.searchParams.get(filter)?.split(",") ?? [];
const selected = records.filter((record) => ids.includes(record[dimension]));
const results = selected.map((record) => {
const key = { metrics: record.metrics, metadata: { key_alias: record.alias, team_id: null } };
const entry = { metrics: record.metrics, metadata: {}, api_key_breakdown: { [record.key]: key } };
return {
date: record.date,
metrics: record.metrics,
breakdown: {
model_groups: { [record.model]: entry },
api_keys: { [record.key]: key },
entities: { [record[dimension]]: entry },
},
};
});
return json({ results, metadata: EMPTY_DAILY_ACTIVITY_METADATA });
}
if (url.pathname === "/management/v1/customer_organizations/org-1") return json(organization);
if (url.pathname === "/management/v1/customer_teams") return json(list(teams));
if (url.pathname === "/management/v1/customer_members")
return json(list(members.filter((member) => member.parent_team_id === url.searchParams.get("parent_team_id"))));
const team = teams.find((team) => url.pathname.endsWith(`/customer_teams/${team.customer_team_id}`));
if (team) return json(team);
const member = members.find((member) => url.pathname.endsWith(`/customer_members/${member.customer_member_id}`));
if (member) return json(member);
throw new Error(`Unexpected usage request ${url.pathname}`);
};
describe("CustomerHierarchyUsagePage", () => {
beforeEach(() => {
testQueryClient.clear();
registerBaseUrlGetter(() => "http://localhost");
fetchMock.mockReset();
fetchMock.mockImplementation((input) => Promise.resolve(serve(input)));
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => {
callback({ didTimeout: false, timeRemaining: () => 50 });
return 1;
});
});
afterEach(() => {
testQueryClient.clear();
vi.unstubAllGlobals();
});
it("should render organization totals and model rankings from the selected UTC date range", async () => {
renderWithProviders(<CustomerHierarchyUsagePage />, { searchParams: initialUrl });
expect(await screen.findByRole("heading", { name: "Customer organization" })).toBeInTheDocument();
expect(screen.getByText("$15.00")).toBeInTheDocument();
expect(screen.getByTitle("700 tokens")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Usage over time" })).toBeInTheDocument();
expect(tableRows("Models")[0]).toHaveTextContent("model-a");
expect(within(tableRows("Models")[0]).getByText("80%")).toBeInTheDocument();
expect(tableRows("Models")[1]).toHaveTextContent("model-b");
expect(activityUrl("customer_organizations", "org-1")?.pathname).toBe(
"/management/v1/customer_organizations/daily/activity",
);
expect(activityUrl("customer_organizations", "org-1")?.searchParams.get("start_date")).toBe("2026-11-01");
expect(activityUrl("customer_organizations", "org-1")?.searchParams.get("end_date")).toBe("2026-11-03");
});
it("should apply the global metric to sorting and shares across models, keys and teams", async () => {
const user = userEvent.setup();
renderWithProviders(<CustomerHierarchyUsagePage />, { searchParams: initialUrl });
await screen.findByRole("table", { name: "Models" });
await user.click(screen.getByRole("radio", { name: "Tokens" }));
expect(tableRows("Models")[0]).toHaveTextContent("model-b");
expect(within(tableRows("Models")[0]).getByText("85.7%")).toBeInTheDocument();
for (const [tab, leader] of [
["API keys", "Search key"],
["Teams", "Operations"],
]) {
await selectBreakdown(user, tab);
expect(tableRows(tab)[0]).toHaveTextContent(leader);
expect(within(tableRows(tab)[0]).getByText("85.7%")).toBeInTheDocument();
expect(within(screen.getByRole("region", { name: "Usage over time" })).getByText(leader)).toBeInTheDocument();
}
await user.click(screen.getByRole("radio", { name: "Requests" }));
expect(tableRows("Teams")[0]).toHaveTextContent("Operations");
expect(within(tableRows("Teams")[0]).getByText("71.4%")).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Requests" })).toHaveAttribute("aria-sort", "descending");
});
it("should drill into customer teams and members and retain dates and unrelated URL state", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWithProviders(<CustomerHierarchyUsagePage />, { searchParams: initialUrl, onUrlUpdate });
await screen.findByRole("heading", { name: "Customer organization" });
await selectBreakdown(user, "Teams");
await user.click(within(screen.getByRole("table", { name: "Teams" })).getByRole("button", { name: "Product" }));
expect(await screen.findByRole("heading", { name: "Product" })).toBeInTheDocument();
expect(activityUrl("customer_teams", "team-a")?.pathname).toBe("/management/v1/customer_teams/daily/activity");
expect(activityUrl("customer_teams", "team-a")?.searchParams.get("customer_team_ids")).toBe("team-a");
await user.click(
within(screen.getByRole("table", { name: "Member spend" })).getByRole("button", { name: "Member One" }),
);
expect(await screen.findByRole("heading", { name: "Member One" })).toBeInTheDocument();
const memberUrl = activityUrl("customer_members", "member-a")!;
expect(memberUrl.pathname).toBe("/management/v1/customer_members/daily/activity");
expect(memberUrl.searchParams.get("customer_member_ids")).toBe("member-a");
expect(memberUrl.searchParams.has("customer_org_ids")).toBe(false);
expect(memberUrl.searchParams.has("customer_team_ids")).toBe(false);
expect(memberUrl.searchParams.get("start_date")).toBe("2026-11-01");
expect(memberUrl.searchParams.get("end_date")).toBe("2026-11-03");
expect(onUrlUpdate).toHaveBeenLastCalledWith(
expect.objectContaining({
queryString: "?org=org-1&from=2026-11-01&to=2026-11-03&source=portal&team=team-a&member=member-a",
}),
);
await user.click(screen.getByRole("button", { name: "Customer organization" }));
expect(await screen.findByRole("heading", { name: "Customer organization" })).toBeInTheDocument();
expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: initialUrl }));
});
it("should fall back to Models for a removed Members breakdown and offer only chart breakdowns", async () => {
const user = userEvent.setup();
renderWithProviders(<CustomerHierarchyUsagePage />, { searchParams: `${initialUrl}&breakdown=members` });
expect(await screen.findByRole("table", { name: "Models" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Top models" })).toBeInTheDocument();
expect(screen.getByRole("table", { name: "Member spend" })).toBeInTheDocument();
await user.click(screen.getByRole("combobox", { name: "Break down by" }));
expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual([
"Models",
"API keys",
"Teams",
]);
});
it("should retain a separate member spend table with range usage and current budget status across chart breakdowns", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWithProviders(<CustomerHierarchyUsagePage />, { searchParams: initialUrl, onUrlUpdate });
const table = await screen.findByRole("table", { name: "Member spend" });
const firstMember = within(table).getAllByRole("row")[1];
expect(firstMember).toHaveTextContent("Member One");
expect(within(firstMember).getByText("$12.00")).toBeInTheDocument();
expect(within(firstMember).getByText("$40.00 / $10.00")).toBeInTheDocument();
expect(within(firstMember).getByText("Over budget")).toBeInTheDocument();
await selectBreakdown(user, "Teams");
await user.click(screen.getByRole("radio", { name: "Tokens" }));
expect(tableRows("Teams")[0]).toHaveTextContent("Operations");
expect(within(table).getAllByRole("row")[1]).toHaveTextContent("Member One");
const requestCount = fetchMock.mock.calls.length;
act(() =>
fireEvent.change(screen.getByRole("textbox", { name: "Search members" }), { target: { value: "Member One" } }),
);
expect(within(table).queryByText("Member Two")).not.toBeInTheDocument();
expect(within(table).getByText("Member One")).toBeInTheDocument();
await waitFor(() =>
expect(new URLSearchParams(onUrlUpdate.mock.lastCall?.[0].queryString).get("memberSearch")).toBe("Member One"),
);
const updatedUrl = new URLSearchParams(onUrlUpdate.mock.lastCall?.[0].queryString);
expect(updatedUrl.get("org")).toBe("org-1");
expect(updatedUrl.get("breakdown")).toBe("teams");
expect(updatedUrl.get("metric")).toBe("tokens");
expect(updatedUrl.get("source")).toBe("portal");
expect(fetchMock.mock.calls).toHaveLength(requestCount);
});
it("should clear the previous scope values while customer team usage loads", async () => {
const user = userEvent.setup();
renderWithProviders(<CustomerHierarchyUsagePage />, { searchParams: initialUrl });
await screen.findByRole("heading", { name: "Customer organization" });
await selectBreakdown(user, "Teams");
fetchMock.mockImplementation(() => new Promise<Response>(() => {}));
await user.click(within(screen.getByRole("table", { name: "Teams" })).getByRole("button", { name: "Product" }));
expect(screen.getByRole("status", { name: "Loading customer usage" })).toBeInTheDocument();
expect(screen.queryByText("$15.00")).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Customer organization" })).not.toBeInTheDocument();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
await waitFor(() =>
expect(requestedUrls().some((url) => url.pathname === "/management/v1/customer_teams/team-a")).toBe(true),
);
});
it("should request a changed date range without showing stale totals or losing the customer scope", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWithProviders(<CustomerHierarchyUsagePage />, { searchParams: initialUrl, onUrlUpdate });
await screen.findByRole("heading", { name: "Customer organization" });
fetchMock.mockImplementation((input) =>
requestUrl(input).pathname.endsWith("/daily/activity") &&
requestUrl(input).searchParams.get("start_date") === "2026-10-01"
? new Promise<Response>(() => {})
: Promise.resolve(serve(input)),
);
await user.click(screen.getByRole("button", { name: / - / }));
act(() => {
fireEvent.change(screen.getByLabelText("Start date"), { target: { value: "2026-10-01" } });
fireEvent.change(screen.getByLabelText("End date"), { target: { value: "2026-10-07" } });
});
await user.click(screen.getByRole("button", { name: "Apply" }));
await waitFor(() =>
expect(activityUrl("customer_organizations", "org-1")?.searchParams.get("start_date")).toBe("2026-10-01"),
);
expect(activityUrl("customer_organizations", "org-1")?.searchParams.get("end_date")).toBe("2026-10-07");
expect(activityUrl("customer_organizations", "org-1")?.pathname).toBe(
"/management/v1/customer_organizations/daily/activity",
);
expect(screen.getByRole("status", { name: "Loading customer usage" })).toBeInTheDocument();
expect(screen.queryByText("$15.00")).not.toBeInTheDocument();
expect(onUrlUpdate).toHaveBeenLastCalledWith(
expect.objectContaining({ queryString: "?org=org-1&from=2026-10-01&to=2026-10-07&source=portal" }),
);
});
it("should show a failed request as an error and load usage when retried", async () => {
const user = userEvent.setup();
fetchMock.mockResolvedValueOnce(json({ error: "Usage temporarily unavailable" }, 503));
renderWithProviders(<CustomerHierarchyUsagePage />, { searchParams: initialUrl });
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load customer usage");
expect(screen.getByRole("alert")).toHaveTextContent("Usage temporarily unavailable");
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
expect(screen.queryByText("No usage in this range")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("$15.00")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("should show an empty date range after the scoped request succeeds without activity", async () => {
fetchMock.mockImplementation((input) =>
Promise.resolve(
requestUrl(input).pathname.endsWith("/daily/activity")
? json({ results: [], metadata: EMPTY_DAILY_ACTIVITY_METADATA })
: serve(input),
),
);
renderWithProviders(<CustomerHierarchyUsagePage />, { searchParams: initialUrl });
expect(await screen.findByText("No usage in this range")).toBeInTheDocument();
expect(screen.getByTitle("0 tokens")).toBeInTheDocument();
expect(screen.queryByRole("status", { name: "Loading customer usage" })).not.toBeInTheDocument();
expect(screen.getByText("No usage recorded for this breakdown.")).toBeInTheDocument();
});
it("should request an organization selection without issuing an unscoped usage request", () => {
renderWithProviders(<CustomerHierarchyUsagePage />, { searchParams: "?source=portal" });
expect(screen.getByText("Open a customer organization to explore its usage.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Back to customers" })).toHaveAttribute("href", "/ui/customers");
expect(fetchMock).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,136 @@
"use client";
import { useTranslation } from "react-i18next";
import UsageStatStrip from "@/app/(dashboard)/usage/_components/components/overview/UsageStatStrip";
import { ErrorState } from "@/components/shared/ErrorState";
import { Button } from "@/components/ui/button";
import { extractProxyErrorMessage } from "@/lib/http/client";
import { uiHref } from "@/utils/uiHref";
import CustomerMemberSpend from "../_components/CustomerMemberSpend";
import CustomerUsageHeader from "../_components/CustomerUsageHeader";
import CustomerUsageRankings from "../_components/CustomerUsageRankings";
import CustomerUsageTrend from "../_components/CustomerUsageTrend";
import useCustomerHierarchyUsage from "../_components/useCustomerHierarchyUsage";
const METRIC_LABELS = {
spend: "customerPortal.analytics.spend",
tokens: "customerPortal.analytics.tokens",
requests: "customerPortal.analytics.requests",
} as const;
const TOP_DIMENSION_LABELS = {
models: "customerPortal.analytics.topModels",
api_keys: "customerPortal.analytics.topKeys",
teams: "customerPortal.analytics.topTeams",
} as const;
export default function CustomerHierarchyUsagePage() {
const { t } = useTranslation();
const {
params,
scope,
dateValue,
preset,
usage,
data,
results,
totals,
modelColors,
rows,
drillDown,
setDateRange,
setDimension,
selectOrganization,
selectTeam,
setMetric,
setBucket,
setSearch,
setPage,
} = useCustomerHierarchyUsage();
const metricLabel = t(METRIC_LABELS[params.metric]);
if (!scope)
return (
<div className="px-8 py-12">
<h1 className="text-xl font-semibold">{t("customerPortal.analytics.title")}</h1>
<p className="mt-2 text-sm text-muted-foreground">{t("customerPortal.analytics.selectOrganization")}</p>
<a className="mt-5 inline-block text-sm font-medium text-primary hover:underline" href={uiHref("customers")}>
{t("customerPortal.analytics.backToCustomers")}
</a>
</div>
);
return (
<main
className="mx-auto w-full max-w-[1600px] space-y-7 px-5 py-6 sm:px-8"
aria-label={t("customerPortal.analytics.title")}
>
<CustomerUsageHeader
scope={scope}
data={data}
dateValue={dateValue}
preset={preset}
onOrganizationClick={selectOrganization}
onTeamClick={selectTeam}
onDateRangeChange={setDateRange}
/>
{usage.isError ? (
<ErrorState
title={t("customerPortal.analytics.error")}
message={extractProxyErrorMessage(usage.error)}
onRetry={() => void usage.refetch()}
/>
) : (
<>
<UsageStatStrip
results={results}
totals={totals}
loading={usage.isPending}
requestCountsPending={usage.isPending}
budget={null}
showBudgetHint={false}
/>
<CustomerUsageTrend
data={data}
dimension={params.breakdown}
onDimensionChange={setDimension}
totals={totals}
metric={params.metric}
bucket={params.bucket}
loading={usage.isPending}
colorByModel={modelColors}
onMetricChange={setMetric}
onBucketChange={setBucket}
/>
{!usage.isPending && (
<>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-base font-medium tracking-tight">{t(TOP_DIMENSION_LABELS[params.breakdown])}</h2>
<p className="text-xs text-muted-foreground">
{t("customerPortal.analytics.sortedBy", { metric: metricLabel.toLocaleLowerCase() })}
</p>
</div>
<CustomerUsageRankings
rows={rows}
dimension={params.breakdown}
metric={params.metric}
search={params.search}
page={params.usagePage}
colorByModel={modelColors}
onSearchChange={setSearch}
onPageChange={setPage}
onDrillDown={drillDown}
/>
</>
)}
{data && <CustomerMemberSpend data={data} onMemberClick={(id) => drillDown("members", id)} />}
{(params.team || params.member) && (
<Button variant="link" className="h-auto p-0 text-xs text-muted-foreground" onClick={selectOrganization}>
{t("customerPortal.analytics.allOrganizationUsage")}
</Button>
)}
<p className="text-[11px] leading-5 text-muted-foreground">{t("customerPortal.analytics.freshness")}</p>
</>
)}
</main>
);
}

View file

@ -84,7 +84,7 @@ export function BreakdownControls({
}
/** Provider logo on a soft tile with a series-colored dot, so mixed logo styles still read as a set. */
function ModelMark({ model, color }: { model: string; color: string }) {
export function ModelMark({ model, color }: { model: string; color: string }) {
const provider = providerForModel(model);
return (
<span className="relative inline-flex size-7 shrink-0 items-center justify-center rounded-md border bg-background">

View file

@ -4,6 +4,7 @@ import {
extractLoggingSettings,
formatMetadataForDisplay,
stripTagsFromMetadata,
stripFieldManagedKeysFromMetadata,
} from "./key_info_utils";
describe("filterSensitiveMetadata", () => {
@ -57,3 +58,15 @@ describe("stripTagsFromMetadata", () => {
expect(input).toEqual(originalCopy);
});
});
describe("stripFieldManagedKeysFromMetadata", () => {
it.each(["ops@qa.test", ["ops@qa.test"], 80])(
"leaves a max_budget_alert_emails value that is not a threshold map in the Metadata box: %j",
(value) => {
expect(stripFieldManagedKeysFromMetadata({ cost_center: "ops", max_budget_alert_emails: value })).toEqual({
cost_center: "ops",
max_budget_alert_emails: value,
});
},
);
});

View file

@ -2,6 +2,8 @@
* Utility functions for handling key information and metadata
*/
import { budgetAlertEmailsMetadata, KEY_MAX_BUDGET_ALERT_EMAILS_KEY } from "./key_team_helpers/budgetAlertEmails";
// Fields that should be filtered out from metadata display due to security concerns
const SENSITIVE_METADATA_FIELDS = ["logging"] as const;
@ -61,3 +63,14 @@ export const stripTagsFromMetadata = (metadata: any) => {
const { tags, ...rest } = metadata as Record<string, any>;
return rest;
};
export const stripFieldManagedKeysFromMetadata = (
metadata: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null | undefined => {
const withoutTags: Record<string, unknown> | null | undefined = stripTagsFromMetadata(metadata);
if (!withoutTags || typeof withoutTags !== "object") {
return withoutTags;
}
const { [KEY_MAX_BUDGET_ALERT_EMAILS_KEY]: _maxBudgetAlertEmails, ...rest } = withoutTags;
return { ...rest, ...budgetAlertEmailsMetadata({}, KEY_MAX_BUDGET_ALERT_EMAILS_KEY, withoutTags) };
};

View file

@ -0,0 +1,89 @@
import React from "react";
import { type ArrayPath, type Control, type FieldPath, type FieldValues, useFieldArray } from "react-hook-form";
import { CircleMinus, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { FormField } from "@/components/shared/form/FormField";
import NumericalInput from "../shared/numerical_input";
import type { BudgetAlertRow } from "./budgetAlertEmails";
export interface BudgetAlertEmailsEditorText {
readonly thresholdPlaceholder: string;
readonly recipientsPlaceholder: string;
readonly removeThreshold: string;
readonly addThreshold: string;
}
type RowsPath<TFieldValues extends FieldValues> = string extends keyof TFieldValues ? string : ArrayPath<TFieldValues>;
interface BudgetAlertEmailsEditorProps<TFieldValues extends FieldValues> {
readonly control: Control<TFieldValues>;
readonly name: RowsPath<TFieldValues>;
readonly label: React.ReactNode;
readonly text: BudgetAlertEmailsEditorText;
}
const EMPTY_ROW: BudgetAlertRow = { threshold: null, emails: "" };
export const BudgetAlertEmailsEditor = <TFieldValues extends FieldValues>({
control,
name,
label,
text,
}: BudgetAlertEmailsEditorProps<TFieldValues>) => {
const { fields, append, remove } = useFieldArray({ control, name: name as ArrayPath<TFieldValues> });
const rowField = (index: number, column: keyof BudgetAlertRow) =>
`${name}.${index}.${column}` as FieldPath<TFieldValues>;
return (
<Field>
<FieldLabel>{label}</FieldLabel>
{fields.map((row, index) => (
<div key={row.id} className="mb-2 flex items-start gap-2">
<FormField control={control} name={rowField(index, "threshold")} className="w-32">
{({ ref, value, onChange, ...field }) => (
<NumericalInput
{...field}
ref={ref}
value={value ?? ""}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onChange(event.target.value === "" ? null : Number(event.target.value))
}
placeholder={text.thresholdPlaceholder}
min={1}
max={100}
step={1}
/>
)}
</FormField>
<FormField control={control} name={rowField(index, "emails")} className="flex-1">
{({ ref, value, ...field }) => (
<Input {...field} ref={ref} value={value ?? ""} placeholder={text.recipientsPlaceholder} />
)}
</FormField>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={text.removeThreshold}
className="mt-1 text-destructive"
onClick={() => remove(index)}
>
<CircleMinus className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
className="w-full border-dashed"
onClick={() => append(EMPTY_ROW as Parameters<typeof append>[0])}
>
<Plus className="size-4" />
{text.addThreshold}
</Button>
</Field>
);
};

View file

@ -0,0 +1,228 @@
import { describe, expect, it } from "vitest";
import {
budgetAlertEmailsFromRows,
budgetAlertEmailsMetadata,
budgetAlertEmailsRowsCanShow,
budgetAlertRowsFromMetadata,
isValidThreshold,
KEY_MAX_BUDGET_ALERT_EMAILS_KEY,
TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY,
teamMemberBudgetAlertSummary,
} from "./budgetAlertEmails";
describe("budgetAlertRowsFromMetadata", () => {
it("turns the stored threshold map into rows sorted by threshold", () => {
const metadata = {
team_member_max_budget_alert_emails: { "100": ["finance@example.com", "cto@example.com"], "50": [] },
};
expect(budgetAlertRowsFromMetadata(metadata, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([
{ threshold: 50, emails: "" },
{ threshold: 100, emails: "finance@example.com, cto@example.com" },
]);
});
it("drops non-numeric thresholds and non-email recipients instead of crashing", () => {
const metadata = {
team_member_max_budget_alert_emails: { fifty: [], "90": { a: 1 }, "100": ["a@b.c"] },
};
expect(budgetAlertRowsFromMetadata(metadata, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([
{ threshold: 100, emails: "a@b.c" },
]);
});
it("loads a null recipient list as an owner-only row and keeps the strings of a mixed list", () => {
const metadata = { max_budget_alert_emails: { "75": null, "80": ["b@example.com", null, 3] } };
expect(budgetAlertRowsFromMetadata(metadata, KEY_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([
{ threshold: 75, emails: "" },
{ threshold: 80, emails: "b@example.com" },
]);
});
it("lists API-stored string recipients in the team member summary", () => {
expect(teamMemberBudgetAlertSummary({ team_member_max_budget_alert_emails: { "80": "a@x.com, b@x.com" } })).toEqual(
["80%: member, a@x.com, b@x.com"],
);
});
it("leaves a zero-padded threshold to the Metadata box so it never duplicates a row", () => {
const metadata = { max_budget_alert_emails: { "080": ["padded@example.com"], "80": ["plain@example.com"] } };
expect(budgetAlertRowsFromMetadata(metadata, KEY_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([
{ threshold: 80, emails: "plain@example.com" },
]);
});
it("still loads a zero-padded team member threshold as a row, since the team form has no Metadata box fallback", () => {
const metadata = { team_member_max_budget_alert_emails: { "080": ["finance@example.com"] } };
expect(budgetAlertRowsFromMetadata(metadata, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([
{ threshold: 80, emails: "finance@example.com" },
]);
});
it("keeps API-stored comma-separated string recipients, which the backend also accepts", () => {
const metadata = {
max_budget_alert_emails: { "80": "finance@example.com, cto@example.com", "90": ["ops@example.com"] },
};
const rows = budgetAlertRowsFromMetadata(metadata, KEY_MAX_BUDGET_ALERT_EMAILS_KEY);
expect(rows).toEqual([
{ threshold: 80, emails: "finance@example.com, cto@example.com" },
{ threshold: 90, emails: "ops@example.com" },
]);
expect(budgetAlertEmailsFromRows(rows)).toEqual({
"80": ["finance@example.com", "cto@example.com"],
"90": ["ops@example.com"],
});
});
it("drops API-stored thresholds outside 1 to 100 so they never block the form", () => {
const metadata = {
team_member_max_budget_alert_emails: { "0": ["a@b.c"], "50": [], "101": ["a@b.c"] },
};
expect(budgetAlertRowsFromMetadata(metadata, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([
{ threshold: 50, emails: "" },
]);
});
it.each([undefined, null, "50", { team_member_max_budget_alert_emails: "50" }, { soft_budget_alerting_emails: [] }])(
"returns no rows for unrelated or malformed metadata %j",
(metadata) => {
expect(budgetAlertRowsFromMetadata(metadata, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([]);
},
);
it("reads only the metadata key it is asked for", () => {
const metadata = {
team_member_max_budget_alert_emails: { "50": ["team@example.com"] },
max_budget_alert_emails: { "80": ["shared-mailbox@example.com"] },
};
expect(budgetAlertRowsFromMetadata(metadata, KEY_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([
{ threshold: 80, emails: "shared-mailbox@example.com" },
]);
});
});
describe("budgetAlertEmailsMetadata", () => {
it("writes the threshold map under the given metadata key", () => {
expect(
budgetAlertEmailsMetadata(
budgetAlertEmailsFromRows([{ threshold: 80, emails: "dl@example.com" }]),
KEY_MAX_BUDGET_ALERT_EMAILS_KEY,
{},
),
).toEqual({ max_budget_alert_emails: { "80": ["dl@example.com"] } });
});
it.each([{ rows: [] }, { rows: [{ threshold: null, emails: "dl@example.com" }] }])(
"leaves the key out when no row has a valid threshold $rows",
({ rows }) => {
expect(budgetAlertEmailsMetadata(budgetAlertEmailsFromRows(rows), KEY_MAX_BUDGET_ALERT_EMAILS_KEY, {})).toEqual(
{},
);
},
);
it("keeps the entries the rows cannot show and lets the rows own every threshold they can", () => {
const boxMetadata = {
max_budget_alert_emails: {
"50": ["old@example.com"],
"80": ["removed@example.com"],
"90": null,
"150": ["over@example.com"],
"0": ["zero@example.com"],
"12.5": ["fraction@example.com"],
soon: "label@example.com",
},
};
expect(
budgetAlertEmailsMetadata(
budgetAlertEmailsFromRows([{ threshold: 50, emails: "new@example.com" }]),
KEY_MAX_BUDGET_ALERT_EMAILS_KEY,
boxMetadata,
),
).toEqual({
max_budget_alert_emails: {
"50": ["new@example.com"],
"150": ["over@example.com"],
"0": ["zero@example.com"],
"12.5": ["fraction@example.com"],
soon: "label@example.com",
},
});
});
});
describe("budgetAlertEmailsRowsCanShow", () => {
it("rebuilds the stored map together with the entries the rows cannot show, values untouched", () => {
const stored = {
max_budget_alert_emails: {
"50": "a@example.com, b@example.com",
"75": null,
"80": ["c@example.com", null],
"150": ["over@example.com"],
},
};
expect(
budgetAlertEmailsMetadata(
budgetAlertEmailsRowsCanShow(stored, KEY_MAX_BUDGET_ALERT_EMAILS_KEY),
KEY_MAX_BUDGET_ALERT_EMAILS_KEY,
stored,
),
).toEqual(stored);
expect(budgetAlertEmailsRowsCanShow(stored, KEY_MAX_BUDGET_ALERT_EMAILS_KEY)).not.toHaveProperty("150");
});
});
describe("budgetAlertEmailsFromRows", () => {
it("builds the threshold map, splitting, trimming and deduplicating recipients", () => {
expect(
budgetAlertEmailsFromRows([
{ threshold: 50, emails: "" },
{ threshold: 100, emails: " finance@example.com,cto@example.com , finance@example.com, " },
]),
).toEqual({ "50": [], "100": ["finance@example.com", "cto@example.com"] });
});
it("skips rows without a valid threshold", () => {
expect(
budgetAlertEmailsFromRows([
{ threshold: null, emails: "finance@example.com" },
{ threshold: 0, emails: "" },
{ threshold: 101, emails: "" },
{ threshold: 12.5, emails: "" },
{ threshold: 80, emails: "" },
]),
).toEqual({ "80": [] });
});
it("round-trips the stored config", () => {
const stored = { team_member_max_budget_alert_emails: { "50": [], "100": ["finance@example.com"] } };
expect(
budgetAlertEmailsFromRows(budgetAlertRowsFromMetadata(stored, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY)),
).toEqual(stored.team_member_max_budget_alert_emails);
});
});
describe("isValidThreshold", () => {
it.each([
[1, true],
[50, true],
[100, true],
[0, false],
[101, false],
[33.3, false],
[null, false],
])("treats %s as valid=%s", (threshold, valid) => {
expect(isValidThreshold(threshold)).toBe(valid);
});
});
describe("teamMemberBudgetAlertSummary", () => {
it("states that the member is always notified and lists extra recipients", () => {
expect(
teamMemberBudgetAlertSummary({
team_member_max_budget_alert_emails: { "100": ["finance@example.com"], "50": [] },
}),
).toEqual(["50%: member", "100%: member, finance@example.com"]);
});
});

View file

@ -0,0 +1,143 @@
import { z } from "zod";
export const TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY = "team_member_max_budget_alert_emails" as const;
export const KEY_MAX_BUDGET_ALERT_EMAILS_KEY = "max_budget_alert_emails" as const;
export type BudgetAlertEmailsMetadataKey =
| typeof TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY
| typeof KEY_MAX_BUDGET_ALERT_EMAILS_KEY;
export interface BudgetAlertRow {
readonly threshold: number | null;
readonly emails: string;
}
export type BudgetAlertEmails = Readonly<Record<string, readonly string[]>>;
export type BudgetAlertRowIssue = "thresholdRange" | "duplicateThreshold";
const splitEmails = (emails: string): readonly string[] =>
Array.from(
new Set(
emails
.split(",")
.map((email) => email.trim())
.filter((email) => email.length > 0),
),
);
const storedRecipients = (value: unknown): readonly string[] | null => {
if (value === null) return [];
if (typeof value === "string") return splitEmails(value);
if (Array.isArray(value)) return value.filter((email): email is string => typeof email === "string");
return null;
};
const THRESHOLD_MIN = 1;
const THRESHOLD_MAX = 100;
export const isValidThreshold = (threshold: number | null): threshold is number => {
const isWholeNumber = threshold !== null && Number.isInteger(threshold);
return isWholeNumber && threshold >= THRESHOLD_MIN && threshold <= THRESHOLD_MAX;
};
const isBudgetAlertMap = (value: unknown): value is Readonly<Record<string, unknown>> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const storedBudgetAlertValue = (metadata: unknown, metadataKey: BudgetAlertEmailsMetadataKey): unknown =>
typeof metadata === "object" && metadata !== null ? (metadata as Record<string, unknown>)[metadataKey] : undefined;
const storedBudgetAlertConfig = (
metadata: unknown,
metadataKey: BudgetAlertEmailsMetadataKey,
): Readonly<Record<string, unknown>> => {
const config = storedBudgetAlertValue(metadata, metadataKey);
return isBudgetAlertMap(config) ? config : {};
};
const ROW_THRESHOLD_KEY_PATTERN: Readonly<Record<BudgetAlertEmailsMetadataKey, RegExp>> = {
[KEY_MAX_BUDGET_ALERT_EMAILS_KEY]: /^[1-9]\d*$/,
[TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY]: /^\d+$/,
};
const budgetAlertRowFromEntry = (
[key, emails]: readonly [string, unknown],
metadataKey: BudgetAlertEmailsMetadataKey,
): BudgetAlertRow | null => {
const threshold = Number(key);
const recipients = storedRecipients(emails);
return ROW_THRESHOLD_KEY_PATTERN[metadataKey].test(key) && isValidThreshold(threshold) && recipients !== null
? { threshold, emails: recipients.join(", ") }
: null;
};
export const budgetAlertRowsFromMetadata = (
metadata: unknown,
metadataKey: BudgetAlertEmailsMetadataKey,
): readonly BudgetAlertRow[] =>
Object.entries(storedBudgetAlertConfig(metadata, metadataKey))
.flatMap((entry) => {
const row = budgetAlertRowFromEntry(entry, metadataKey);
return row === null ? [] : [row];
})
.sort((a, b) => (a.threshold ?? 0) - (b.threshold ?? 0));
const storedBudgetAlertEntries = (
metadata: unknown,
metadataKey: BudgetAlertEmailsMetadataKey,
rowsCanShow: boolean,
): Readonly<Record<string, unknown>> =>
Object.fromEntries(
Object.entries(storedBudgetAlertConfig(metadata, metadataKey)).filter(
(entry) => (budgetAlertRowFromEntry(entry, metadataKey) !== null) === rowsCanShow,
),
);
export const budgetAlertEmailsRowsCanShow = (
metadata: unknown,
metadataKey: BudgetAlertEmailsMetadataKey,
): Readonly<Record<string, unknown>> => storedBudgetAlertEntries(metadata, metadataKey, true);
export const budgetAlertEmailsRowsCannotShow = (
metadata: unknown,
metadataKey: BudgetAlertEmailsMetadataKey,
): Readonly<Record<string, unknown>> => storedBudgetAlertEntries(metadata, metadataKey, false);
export const budgetAlertEmailsFromRows = (rows: readonly BudgetAlertRow[]): BudgetAlertEmails =>
Object.fromEntries(
rows
.filter((row) => isValidThreshold(row.threshold))
.map((row) => [String(row.threshold), splitEmails(row.emails)]),
);
export const budgetAlertEmailsMetadata = (
rowEntries: Readonly<Record<string, unknown>>,
metadataKey: BudgetAlertEmailsMetadataKey,
metadataWithHiddenEntries: unknown,
): Readonly<Partial<Record<BudgetAlertEmailsMetadataKey, unknown>>> => {
const boxValue = storedBudgetAlertValue(metadataWithHiddenEntries, metadataKey);
if (Object.keys(rowEntries).length === 0 && boxValue !== undefined && !isBudgetAlertMap(boxValue)) {
return { [metadataKey]: boxValue };
}
const emails = {
...budgetAlertEmailsRowsCannotShow(metadataWithHiddenEntries, metadataKey),
...rowEntries,
};
return Object.keys(emails).length > 0 ? { [metadataKey]: emails } : {};
};
export const budgetAlertRowsSchema = (message: (issue: BudgetAlertRowIssue) => string) =>
z.array(z.object({ threshold: z.number().nullable(), emails: z.string() })).superRefine((rows, ctx) => {
rows.forEach((row, index) => {
if (!isValidThreshold(row.threshold)) {
ctx.addIssue({ code: "custom", message: message("thresholdRange"), path: [index, "threshold"] });
} else if (rows.filter((other) => other.threshold === row.threshold).length > 1) {
ctx.addIssue({ code: "custom", message: message("duplicateThreshold"), path: [index, "threshold"] });
}
});
});
export const teamMemberBudgetAlertSummary = (metadata: unknown): readonly string[] =>
budgetAlertRowsFromMetadata(metadata, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY).map((row) =>
row.emails.length > 0 ? `${row.threshold}%: member, ${row.emails}` : `${row.threshold}%: member`,
);

View file

@ -29,6 +29,78 @@ it.each([false, true])("preserves the prompt storage restriction %s in key metad
expect(JSON.parse(String(result.payload.metadata))).toEqual({ note: "keep", store_prompts_in_spend_logs: store });
});
it("merges the budget alert rows into the metadata JSON box content", () => {
const result = build(
{
metadata: JSON.stringify({ cost_center: "ops" }),
max_budget_alert_emails: [
{ threshold: 80, emails: "shared-mailbox@example.com" },
{ threshold: null, emails: "ignored@example.com" },
],
},
{ disabledCallbacks: ["langfuse"] },
);
expect(result.kind).toBe("ok");
if (result.kind !== "ok") throw new Error(result.kind);
expect(result.payload).not.toHaveProperty("max_budget_alert_emails");
expect(JSON.parse(String(result.payload.metadata))).toEqual({
cost_center: "ops",
litellm_disabled_callbacks: ["langfuse"],
max_budget_alert_emails: { "80": ["shared-mailbox@example.com"] },
});
});
it("keeps typed recipient entries the rows cannot show and lets the rows own every threshold they can", () => {
const result = build({
metadata: JSON.stringify({
cost_center: "ops",
max_budget_alert_emails: { "500": "typed@example.com", "80": ["stale@example.com"] },
}),
max_budget_alert_emails: [{ threshold: 50, emails: "row@example.com" }],
});
expect(result.kind).toBe("ok");
if (result.kind !== "ok") throw new Error(result.kind);
expect(JSON.parse(String(result.payload.metadata))).toEqual({
cost_center: "ops",
max_budget_alert_emails: { "500": "typed@example.com", "50": ["row@example.com"] },
});
});
it("stores a typed max_budget_alert_emails string as typed when no row is set", () => {
const result = build({ metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: "ops@qa.test" }) });
expect(result.kind).toBe("ok");
if (result.kind !== "ok") throw new Error(result.kind);
expect(JSON.parse(String(result.payload.metadata))).toEqual({
cost_center: "ops",
max_budget_alert_emails: "ops@qa.test",
});
});
it("replaces a typed max_budget_alert_emails string with the threshold rows", () => {
const result = build({
metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: "ops@qa.test" }),
max_budget_alert_emails: [{ threshold: 80, emails: "row@example.com" }],
});
expect(result.kind).toBe("ok");
if (result.kind !== "ok") throw new Error(result.kind);
expect(JSON.parse(String(result.payload.metadata))).toEqual({
cost_center: "ops",
max_budget_alert_emails: { "80": ["row@example.com"] },
});
});
it("keeps the budget alert rows when the metadata JSON box holds null", () => {
const result = build({
metadata: "null",
max_budget_alert_emails: [{ threshold: 80, emails: "shared-mailbox@example.com" }],
});
expect(result.kind).toBe("ok");
if (result.kind !== "ok") throw new Error(result.kind);
expect(JSON.parse(String(result.payload.metadata))).toEqual({
max_budget_alert_emails: { "80": ["shared-mailbox@example.com"] },
});
});
const payloadOf = (result: KeyPayloadResult): Record<string, unknown> => {
expect(result.kind).toBe("ok");
if (result.kind !== "ok") throw new Error("unreachable");

View file

@ -1,6 +1,12 @@
import { mapDisplayToInternalNames } from "../callback_info_helpers";
import { NEVER_RESETS_BUDGET_DURATION } from "../common_components/budget_duration_dropdown";
import type { RouterSettingsAccordionValue } from "../common_components/RouterSettingsAccordion";
import {
type BudgetAlertRow,
budgetAlertEmailsFromRows,
budgetAlertEmailsMetadata,
KEY_MAX_BUDGET_ALERT_EMAILS_KEY,
} from "../key_team_helpers/budgetAlertEmails";
import type { BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
import type { ModelMaxBudget } from "../key_team_helpers/ModelMaxBudgetEditor";
import { tagRowsToLimits, type TagRateLimitEntry } from "../key_team_helpers/TagRateLimitEditor";
@ -103,7 +109,18 @@ const buildMetadataJson = (values: Record<string, unknown>, input: KeyCreateInpu
input.disabledCallbacks.length > 0
? { ...(logged as object), litellm_disabled_callbacks: mapDisplayToInternalNames(input.disabledCallbacks) }
: logged;
return JSON.stringify(disabled);
const metadataObject = disabled ?? {};
const alertEmails = budgetAlertEmailsMetadata(
budgetAlertEmailsFromRows((values[KEY_MAX_BUDGET_ALERT_EMAILS_KEY] as readonly BudgetAlertRow[] | undefined) ?? []),
KEY_MAX_BUDGET_ALERT_EMAILS_KEY,
metadataObject,
);
if (typeof metadataObject !== "object" || Array.isArray(metadataObject)) return JSON.stringify(metadataObject);
const { [KEY_MAX_BUDGET_ALERT_EMAILS_KEY]: _typedAlertEmails, ...withoutTypedAlertEmails } = metadataObject as Record<
string,
unknown
>;
return JSON.stringify({ ...withoutTypedAlertEmails, ...alertEmails });
};
interface PermissionSources {
@ -153,6 +170,7 @@ const consumedSourceKeys = (
new Set<string>([
"mcp_tool_permissions",
"allowed_skills",
KEY_MAX_BUDGET_ALERT_EMAILS_KEY,
...(values.disable_global_guardrails ? [] : ["disable_global_guardrails"]),
...(vectorStores ? ["allowed_vector_store_ids"] : []),
...(mcp ? ["allowed_mcp_servers_and_groups"] : []),

View file

@ -1,6 +1,6 @@
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
import { act, fireEvent, renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
import type { Team } from "../key_team_helpers/key_list";
import { keyCreateCall, keyCreateServiceAccountCall, modelAvailableCall, userFilterUICall } from "../networking";
import { toast } from "@/lib/toast";
@ -593,6 +593,68 @@ describe("CreateKey", () => {
expect((await createdPayload()).metadata).toBe('{"team":"research"}');
});
const addBudgetAlertRow = async (threshold: string, emails: string) => {
await userEvent.click(await screen.findByRole("button", { name: "Add Budget Alert Threshold" }));
const thresholds = screen.getAllByPlaceholderText("% of budget");
fireEvent.change(thresholds[thresholds.length - 1], { target: { value: threshold } });
const recipients = screen.getAllByPlaceholderText("Additional recipients, e.g. finance@example.com");
fireEvent.change(recipients[recipients.length - 1], { target: { value: emails } });
};
it("merges the budget alert rows into the metadata JSON box content", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
fireEvent.change(await screen.findByLabelText("Metadata"), { target: { value: '{"team":"research"}' } });
await addBudgetAlertRow("80", "shared-mailbox@example.com, finance-dl@example.com");
await submit();
const payload = await createdPayload();
expect(JSON.parse(String(payload.metadata))).toEqual({
team: "research",
max_budget_alert_emails: { "80": ["shared-mailbox@example.com", "finance-dl@example.com"] },
});
expect(payload).not.toHaveProperty("max_budget_alert_emails");
});
it("moves a recipient map typed into the metadata JSON box into the rows on blur", async () => {
vi.mocked(toast.info).mockClear();
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await addBudgetAlertRow("80", "row@example.com");
const metadataBox = await screen.findByLabelText("Metadata");
fireEvent.change(metadataBox, {
target: { value: JSON.stringify({ team: "research", max_budget_alert_emails: { "80": "typed@example.com" } }) },
});
fireEvent.blur(metadataBox);
expect(toast.info).toHaveBeenCalledWith(
"Moved alert thresholds 80% from metadata to the Budget Alert Thresholds rows",
);
expect(screen.getByPlaceholderText("Additional recipients, e.g. finance@example.com")).toHaveValue(
"typed@example.com",
);
await submit();
expect(JSON.parse(String((await createdPayload()).metadata))).toEqual({
team: "research",
max_budget_alert_emails: { "80": ["typed@example.com"] },
});
});
it("blocks the create on a duplicate budget alert threshold", async () => {
await openModal();
await nameTheKey();
await openSection(/Optional Settings/i);
await addBudgetAlertRow("80", "a@example.com");
await addBudgetAlertRow("80", "b@example.com");
await submit();
expect(await screen.findAllByText("Duplicate threshold")).toHaveLength(2);
expect(vi.mocked(keyCreateCall)).not.toHaveBeenCalled();
});
it("carries the typed key alias and the chosen team onto the wire", async () => {
state.teams = [{ team_id: "team-1", team_alias: "Team One", models: [] }];
await openModal({ teams: state.teams as unknown as Team[] });

View file

@ -42,6 +42,7 @@ import {
MountedFormField,
MountedFormProvider,
projectMountedValues,
useMountedName,
useMountRegistry,
type MountedFormValues,
} from "../common_components/MountedFormField";
@ -56,6 +57,13 @@ import TeamDropdown from "../common_components/team_dropdown";
import OrganizationDropdown from "../common_components/OrganizationDropdown";
import ProjectDropdown from "../common_components/ProjectDropdown";
import { CreateUserButton } from "../CreateUserButton";
import { moveMetadataBudgetAlertsToRows } from "../templates/KeyEditViewControls";
import { BudgetAlertEmailsEditor, type BudgetAlertEmailsEditorText } from "../key_team_helpers/BudgetAlertEmailsEditor";
import {
type BudgetAlertRow,
budgetAlertRowsSchema,
KEY_MAX_BUDGET_ALERT_EMAILS_KEY,
} from "../key_team_helpers/budgetAlertEmails";
import { BudgetFallbacksEditor } from "../key_team_helpers/BudgetFallbacksEditor";
import { END_USER_BUDGET_HINT, EndUserBudgetSelect } from "../key_team_helpers/EndUserBudgetSelect";
import { BudgetWindowEntry, BudgetWindowsEditor } from "../key_team_helpers/BudgetWindowsEditor";
@ -132,6 +140,15 @@ type McpSelectorValue = { servers: string[]; accessGroups: string[]; toolsets?:
type AgentSelectorValue = { agents: string[]; accessGroups: string[] };
const MountedBudgetAlertEmailsEditor = (props: {
readonly control: Control<MountedFormValues>;
readonly label: React.ReactNode;
readonly text: BudgetAlertEmailsEditorText;
}) => {
useMountedName(KEY_MAX_BUDGET_ALERT_EMAILS_KEY);
return <BudgetAlertEmailsEditor {...props} name={KEY_MAX_BUDGET_ALERT_EMAILS_KEY} />;
};
const isBlank = (value: unknown): boolean => value === undefined || value === null || value === "";
const requiredRule = (required: boolean, message: string) => ({
@ -532,7 +549,21 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) =>
void form.handleSubmit(() => handleCreate(projectMountedValues(registry, form.getValues)))(event);
void form.handleSubmit(() => {
const values = projectMountedValues(registry, form.getValues);
const alertRows = budgetAlertRowsSchema((issue) =>
issue === "thresholdRange"
? t("keyDetail.maxBudgetAlertEmails.thresholdRange")
: t("keyDetail.maxBudgetAlertEmails.duplicateThreshold"),
).safeParse(values[KEY_MAX_BUDGET_ALERT_EMAILS_KEY] ?? []);
if (!alertRows.success) {
alertRows.error.issues.forEach((issue) =>
form.setError([KEY_MAX_BUDGET_ALERT_EMAILS_KEY, ...issue.path].join("."), { message: issue.message }),
);
return;
}
return handleCreate(values);
})(event);
// Fetch available models when team or auth changes.
// Note: Model prefill from URL params is handled by the useEffect below, which
@ -1093,6 +1124,25 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
/>
)}
</MountedFormField>
<div className="mt-4">
<MountedBudgetAlertEmailsEditor
control={form.control}
label={
<span>
{t("keyDetail.maxBudgetAlertEmails.budgetAlertThresholds")}{" "}
<SimpleTooltip content={t("keyDetail.maxBudgetAlertEmails.budgetAlertThresholdsHint")}>
<Info className="ml-1 inline size-3.5 align-text-bottom" />
</SimpleTooltip>
</span>
}
text={{
thresholdPlaceholder: t("keyDetail.maxBudgetAlertEmails.thresholdPlaceholder"),
recipientsPlaceholder: t("keyDetail.maxBudgetAlertEmails.recipientsPlaceholder"),
removeThreshold: t("keyDetail.maxBudgetAlertEmails.removeThreshold"),
addThreshold: t("keyDetail.maxBudgetAlertEmails.addThreshold"),
}}
/>
</div>
<MountedFormField
className="mt-4"
label={
@ -1599,6 +1649,18 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
value={(control.value as string | undefined) ?? ""}
rows={4}
placeholder={t("createKey.metadata.placeholder")}
onBlur={() => {
control.onBlur();
moveMetadataBudgetAlertsToRows(
form.getValues("metadata") as string | undefined,
form.getValues(KEY_MAX_BUDGET_ALERT_EMAILS_KEY) as BudgetAlertRow[] | undefined,
(metadata, rows) => {
form.setValue("metadata", metadata, { shouldDirty: true });
form.setValue(KEY_MAX_BUDGET_ALERT_EMAILS_KEY, rows, { shouldDirty: true });
},
t,
);
}}
/>
</>
)}

View file

@ -130,12 +130,13 @@ import {
import TeamMembersComponent from "./TeamMemberTab";
import { useModelAccessGroupNames } from "@/app/(dashboard)/hooks/models/useModels";
import {
isValidThreshold,
budgetAlertEmailsFromRows,
budgetAlertRowsFromMetadata,
budgetAlertRowsSchema,
TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY,
teamMemberBudgetAlertEmailsFromRows,
teamMemberBudgetAlertRowsFromMetadata,
teamMemberBudgetAlertSummary,
} from "./teamMemberBudgetAlertEmails";
} from "../key_team_helpers/budgetAlertEmails";
import { BudgetAlertEmailsEditor } from "../key_team_helpers/BudgetAlertEmailsEditor";
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
import ResetMemberBudgetsDialog from "./ResetMemberBudgetsDialog";
import { customBudgetMemberUserIds, shouldPromptMemberBudgetReset } from "./memberBudgetReset";
@ -387,26 +388,11 @@ const buildTeamUpdateFieldsSchema = (t: TFunction) =>
team_member_key_duration: z.string().optional(),
team_member_tpm_limit: numericInputSchema,
team_member_rpm_limit: numericInputSchema,
team_member_max_budget_alert_emails: z
.array(z.object({ threshold: z.number().nullable(), emails: z.string() }))
.superRefine((rows, ctx) => {
rows.forEach((row, index) => {
if (!isValidThreshold(row.threshold)) {
ctx.addIssue({
code: "custom",
message: t("teams.teamInfo.validation.thresholdRange"),
path: [index, "threshold"],
});
} else if (rows.filter((other) => other.threshold === row.threshold).length > 1) {
ctx.addIssue({
code: "custom",
message: t("teams.teamInfo.validation.duplicateThreshold"),
path: [index, "threshold"],
});
}
});
})
.optional(),
team_member_max_budget_alert_emails: budgetAlertRowsSchema((issue) =>
issue === "thresholdRange"
? t("teams.teamInfo.validation.thresholdRange")
: t("teams.teamInfo.validation.duplicateThreshold"),
).optional(),
budget_duration: z.string().nullish(),
tpm_limit: numericInputSchema,
rpm_limit: numericInputSchema,
@ -551,7 +537,9 @@ const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]):
team_member_key_duration: info.metadata?.team_member_key_duration,
team_member_tpm_limit: info.team_member_budget_table?.tpm_limit,
team_member_rpm_limit: info.team_member_budget_table?.rpm_limit,
team_member_max_budget_alert_emails: [...teamMemberBudgetAlertRowsFromMetadata(info.metadata)],
team_member_max_budget_alert_emails: [
...budgetAlertRowsFromMetadata(info.metadata, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY),
],
budget_duration: info.budget_duration,
tpm_limit: info.tpm_limit,
rpm_limit: info.rpm_limit,
@ -639,11 +627,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
append: appendModelLimit,
remove: removeModelLimit,
} = useFieldArray({ control: form.control, name: "modelLimits" });
const {
fields: memberBudgetAlertRows,
append: appendMemberBudgetAlertRow,
remove: removeMemberBudgetAlertRow,
} = useFieldArray({ control: form.control, name: "team_member_max_budget_alert_emails" });
const [teamMemberSettingsOpen, setTeamMemberSettingsOpen] = useState(false);
const [searchToolSettingsOpen, setSearchToolSettingsOpen] = useState(false);
const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false);
@ -1081,7 +1064,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const memberBudgetAlertEmails =
values.team_member_max_budget_alert_emails !== undefined
? teamMemberBudgetAlertEmailsFromRows(values.team_member_max_budget_alert_emails)
? budgetAlertEmailsFromRows(values.team_member_max_budget_alert_emails)
: info.metadata?.[TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY];
const memberBudgetAlertEmailsMetadata =
memberBudgetAlertEmails !== undefined && Object.keys(memberBudgetAlertEmails).length > 0
@ -1877,71 +1860,22 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
/>
)}
</FormField>
<Field>
<FieldLabel>
{labelWithHint(
t("teams.teamInfo.editForm.teamMemberSettings.budgetAlertThresholds"),
t("teams.teamInfo.editForm.teamMemberSettings.budgetAlertThresholdsHint"),
)}
</FieldLabel>
{memberBudgetAlertRows.map((row, index) => (
<div key={row.id} className="mb-2 flex items-start gap-2">
<FormField
control={form.control}
name={`team_member_max_budget_alert_emails.${index}.threshold`}
className="w-32"
>
{({ ref, value, onChange, ...field }) => (
<NumericalInput
{...field}
ref={ref}
value={value ?? ""}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onChange(event.target.value === "" ? null : Number(event.target.value))
}
placeholder={t("teams.teamInfo.editForm.teamMemberSettings.thresholdPlaceholder")}
min={1}
max={100}
step={1}
/>
)}
</FormField>
<FormField
control={form.control}
name={`team_member_max_budget_alert_emails.${index}.emails`}
className="flex-1"
>
{({ ref, value, ...field }) => (
<UIInput
{...field}
ref={ref}
value={value ?? ""}
placeholder={t("teams.teamInfo.editForm.teamMemberSettings.recipientsPlaceholder")}
/>
)}
</FormField>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("teams.teamInfo.editForm.teamMemberSettings.removeThreshold")}
className="mt-1 text-destructive"
onClick={() => removeMemberBudgetAlertRow(index)}
>
<CircleMinus className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
className="w-full border-dashed"
onClick={() => appendMemberBudgetAlertRow({ threshold: null, emails: "" })}
>
<Plus className="size-4" />
{t("teams.teamInfo.editForm.teamMemberSettings.addThreshold")}
</Button>
</Field>
<BudgetAlertEmailsEditor
control={form.control}
name="team_member_max_budget_alert_emails"
label={labelWithHint(
t("teams.teamInfo.editForm.teamMemberSettings.budgetAlertThresholds"),
t("teams.teamInfo.editForm.teamMemberSettings.budgetAlertThresholdsHint"),
)}
text={{
thresholdPlaceholder: t("teams.teamInfo.editForm.teamMemberSettings.thresholdPlaceholder"),
recipientsPlaceholder: t(
"teams.teamInfo.editForm.teamMemberSettings.recipientsPlaceholder",
),
removeThreshold: t("teams.teamInfo.editForm.teamMemberSettings.removeThreshold"),
addThreshold: t("teams.teamInfo.editForm.teamMemberSettings.addThreshold"),
}}
/>
</FieldGroup>
</CollapsibleContent>
</Collapsible>

View file

@ -1,94 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isValidThreshold,
teamMemberBudgetAlertEmailsFromRows,
teamMemberBudgetAlertRowsFromMetadata,
teamMemberBudgetAlertSummary,
} from "./teamMemberBudgetAlertEmails";
describe("teamMemberBudgetAlertRowsFromMetadata", () => {
it("turns the stored threshold map into rows sorted by threshold", () => {
const metadata = {
team_member_max_budget_alert_emails: { "100": ["finance@example.com", "cto@example.com"], "50": [] },
};
expect(teamMemberBudgetAlertRowsFromMetadata(metadata)).toEqual([
{ threshold: 50, emails: "" },
{ threshold: 100, emails: "finance@example.com, cto@example.com" },
]);
});
it("drops non-numeric thresholds and non-list recipients instead of crashing", () => {
const metadata = {
team_member_max_budget_alert_emails: { fifty: [], "75": "finance@example.com", "90": [1], "100": ["a@b.c"] },
};
expect(teamMemberBudgetAlertRowsFromMetadata(metadata)).toEqual([{ threshold: 100, emails: "a@b.c" }]);
});
it("drops API-stored thresholds outside 1 to 100 so they never block the form", () => {
const metadata = {
team_member_max_budget_alert_emails: { "0": ["a@b.c"], "50": [], "101": ["a@b.c"] },
};
expect(teamMemberBudgetAlertRowsFromMetadata(metadata)).toEqual([{ threshold: 50, emails: "" }]);
});
it.each([undefined, null, "50", { team_member_max_budget_alert_emails: "50" }, { soft_budget_alerting_emails: [] }])(
"returns no rows for unrelated or malformed metadata %j",
(metadata) => {
expect(teamMemberBudgetAlertRowsFromMetadata(metadata)).toEqual([]);
},
);
});
describe("teamMemberBudgetAlertEmailsFromRows", () => {
it("builds the threshold map, splitting, trimming and deduplicating recipients", () => {
expect(
teamMemberBudgetAlertEmailsFromRows([
{ threshold: 50, emails: "" },
{ threshold: 100, emails: " finance@example.com,cto@example.com , finance@example.com, " },
]),
).toEqual({ "50": [], "100": ["finance@example.com", "cto@example.com"] });
});
it("skips rows without a valid threshold", () => {
expect(
teamMemberBudgetAlertEmailsFromRows([
{ threshold: null, emails: "finance@example.com" },
{ threshold: 0, emails: "" },
{ threshold: 101, emails: "" },
{ threshold: 12.5, emails: "" },
{ threshold: 80, emails: "" },
]),
).toEqual({ "80": [] });
});
it("round-trips the stored config", () => {
const stored = { team_member_max_budget_alert_emails: { "50": [], "100": ["finance@example.com"] } };
expect(teamMemberBudgetAlertEmailsFromRows(teamMemberBudgetAlertRowsFromMetadata(stored))).toEqual(
stored.team_member_max_budget_alert_emails,
);
});
});
describe("isValidThreshold", () => {
it.each([
[1, true],
[50, true],
[100, true],
[0, false],
[101, false],
[33.3, false],
[null, false],
])("treats %s as valid=%s", (threshold, valid) => {
expect(isValidThreshold(threshold)).toBe(valid);
});
});
describe("teamMemberBudgetAlertSummary", () => {
it("states that the member is always notified and lists extra recipients", () => {
expect(
teamMemberBudgetAlertSummary({
team_member_max_budget_alert_emails: { "100": ["finance@example.com"], "50": [] },
}),
).toEqual(["50%: member", "100%: member, finance@example.com"]);
});
});

View file

@ -1,57 +0,0 @@
export const TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY = "team_member_max_budget_alert_emails" as const;
export interface TeamMemberBudgetAlertRow {
readonly threshold: number | null;
readonly emails: string;
}
export type TeamMemberBudgetAlertEmails = Readonly<Record<string, readonly string[]>>;
const isEmailList = (value: unknown): value is readonly string[] =>
Array.isArray(value) && value.every((email) => typeof email === "string");
const splitEmails = (emails: string): readonly string[] =>
Array.from(
new Set(
emails
.split(",")
.map((email) => email.trim())
.filter((email) => email.length > 0),
),
);
const THRESHOLD_MIN = 1;
const THRESHOLD_MAX = 100;
export const isValidThreshold = (threshold: number | null): threshold is number => {
const isWholeNumber = threshold !== null && Number.isInteger(threshold);
return isWholeNumber && threshold >= THRESHOLD_MIN && threshold <= THRESHOLD_MAX;
};
export const teamMemberBudgetAlertRowsFromMetadata = (metadata: unknown): readonly TeamMemberBudgetAlertRow[] => {
if (typeof metadata !== "object" || metadata === null) return [];
const config: unknown = (metadata as Record<string, unknown>)[TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY];
if (typeof config !== "object" || config === null || Array.isArray(config)) return [];
return Object.entries(config as Record<string, unknown>)
.flatMap(([key, emails]) => {
const threshold = Number(key);
return /^\d+$/.test(key) && isValidThreshold(threshold) && isEmailList(emails)
? [{ threshold, emails: emails.join(", ") }]
: [];
})
.sort((a, b) => (a.threshold ?? 0) - (b.threshold ?? 0));
};
export const teamMemberBudgetAlertEmailsFromRows = (
rows: readonly TeamMemberBudgetAlertRow[],
): TeamMemberBudgetAlertEmails =>
Object.fromEntries(
rows
.filter((row) => isValidThreshold(row.threshold))
.map((row) => [String(row.threshold), splitEmails(row.emails)]),
);
export const teamMemberBudgetAlertSummary = (metadata: unknown): readonly string[] =>
teamMemberBudgetAlertRowsFromMetadata(metadata).map((row) =>
row.emails.length > 0 ? `${row.threshold}%: member, ${row.emails}` : `${row.threshold}%: member`,
);

View file

@ -14,7 +14,8 @@ import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem";
import { StorePromptsMetadataToggle } from "../common_components/StorePromptsToggle";
import NumericalInput from "../shared/numerical_input";
import SkillSelector from "../skills/SkillSelector";
import { moveTagsOutOfMetadataJson } from "./keyEditFieldNormalizers";
import type { BudgetAlertRow } from "../key_team_helpers/budgetAlertEmails";
import { moveBudgetAlertEmailsOutOfMetadataJson, moveTagsOutOfMetadataJson } from "./keyEditFieldNormalizers";
import { AgentsAndGroups, KeyEditFormValues } from "./keyEditFormValues";
export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => (
@ -186,8 +187,42 @@ export const moveMetadataTagsToTagsField = (form: KeyEditForm, t: TFunction): vo
}
};
export const KeyMetadataField = ({ form }: { form: KeyEditForm }) => {
export const moveMetadataBudgetAlertsToRows = (
metadata: string | undefined,
rows: readonly BudgetAlertRow[] | undefined,
write: (metadata: string, rows: BudgetAlertRow[]) => void,
t: TFunction,
): void => {
const moved = moveBudgetAlertEmailsOutOfMetadataJson(metadata, rows);
if (moved === null) return;
write(moved.metadata, [...moved.rows]);
const thresholds = moved.movedThresholds.map((threshold) => `${threshold}%`).join(", ");
toast.info(t("keyDetail.maxBudgetAlertEmails.movedThresholds", { thresholds }));
};
export const moveMetadataBudgetAlertsToKeyRows = (form: KeyEditForm, t: TFunction): void =>
moveMetadataBudgetAlertsToRows(
form.getValues("metadata"),
form.getValues("max_budget_alert_emails"),
(metadata, rows) => {
form.setValue("metadata", metadata, { shouldDirty: true });
form.setValue("max_budget_alert_emails", rows, { shouldDirty: true });
},
t,
);
export const KeyMetadataField = ({
form,
budgetAlertRowsShown,
}: {
form: KeyEditForm;
budgetAlertRowsShown: boolean;
}) => {
const { t } = useTranslation();
const moveTypedFieldsOutOfMetadata = () => {
moveMetadataTagsToTagsField(form, t);
if (budgetAlertRowsShown) moveMetadataBudgetAlertsToKeyRows(form, t);
};
return (
<FormField
control={form.control}
@ -204,7 +239,7 @@ export const KeyMetadataField = ({ form }: { form: KeyEditForm }) => {
rows={10}
onBlur={() => {
field.onBlur();
moveMetadataTagsToTagsField(form, t);
moveTypedFieldsOutOfMetadata();
}}
/>
</>

View file

@ -196,7 +196,8 @@ vi.mock("./key_edit_view", async () => {
React.createElement(
"button",
{
onClick: () => props.onSubmit((globalThis as any).__TEST_FORM_VALUES ?? {}),
onClick: () =>
props.onSubmit((globalThis as any).__TEST_FORM_VALUES ?? {}, (globalThis as any).__TEST_DIRTY_FIELDS ?? []),
},
"Mock Submit",
),
@ -231,7 +232,7 @@ const baseKeyData = {
next_rotation_at: null as any,
};
const renderView = (premiumUser: boolean) => {
const renderView = (premiumUser: boolean, keyData: Record<string, unknown> = baseKeyData) => {
// Configure the mock for this test
mockUseAuthorized.mockReturnValue({
accessToken: "access_abc",
@ -245,19 +246,14 @@ const renderView = (premiumUser: boolean) => {
});
return render(
<KeyInfoView
keyId="tok_123"
onClose={() => {}}
keyData={baseKeyData as any}
onKeyDataUpdate={() => {}}
teams={[]}
/>,
<KeyInfoView keyId="tok_123" onClose={() => {}} keyData={keyData as any} onKeyDataUpdate={() => {}} teams={[]} />,
);
};
beforeEach(() => {
vi.clearAllMocks();
(globalThis as any).__TEST_FORM_VALUES = undefined;
(globalThis as any).__TEST_DIRTY_FIELDS = undefined;
});
// ---- Tests ----
@ -715,3 +711,171 @@ describe("KeyInfoView handleKeyUpdate organization", () => {
expect(shownOrganization()).toHaveTextContent("org-1");
});
});
describe("KeyInfoView handleKeyUpdate max_budget_alert_emails", () => {
const submit = async (
formValues: Record<string, unknown>,
dirtyFields: readonly string[] = ["max_budget_alert_emails"],
keyData: Record<string, unknown> = baseKeyData,
) => {
renderView(true, keyData);
fireEvent.click(screen.getByText("Settings"));
fireEvent.click(screen.getByText("Edit Settings"));
Object.assign(globalThis, {
__TEST_FORM_VALUES: { token: "tok_123", ...formValues },
__TEST_DIRTY_FIELDS: dirtyFields,
});
fireEvent.click(screen.getByText("Mock Submit"));
await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled());
return keyUpdateCallMock.mock.calls[0][1];
};
it("writes the alert rows into metadata next to the JSON box content and the dedicated metadata fields", async () => {
const formValues = {
metadata: JSON.stringify({ cost_center: "ops", nested: { owner: "platform" } }),
tags: ["prod"],
guardrails: ["gr-1"],
logging_settings: [{ callback_name: "langfuse" }],
disabled_callbacks: ["langsmith"],
max_budget_alert_emails: [
{ threshold: 80, emails: "shared-mailbox@example.com, finance-dl@example.com" },
{ threshold: 50, emails: "" },
],
};
const expectedMetadata = {
cost_center: "ops",
nested: { owner: "platform" },
tags: ["prod"],
guardrails: ["gr-1"],
logging: [{ callback_name: "langfuse" }],
litellm_disabled_callbacks: ["langsmith"],
max_budget_alert_emails: { "80": ["shared-mailbox@example.com", "finance-dl@example.com"], "50": [] },
};
const sentPayload = await submit(formValues);
expect(sentPayload.metadata).toEqual(expectedMetadata);
expect(sentPayload).not.toHaveProperty("max_budget_alert_emails");
});
it("clears the stored recipients when every row is removed, even if the JSON box still names them", async () => {
const sentPayload = await submit({
metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: { "80": ["old@example.com"] } }),
max_budget_alert_emails: [],
});
expect(sentPayload.metadata).toEqual({ cost_center: "ops" });
});
it("writes the alert rows when the metadata JSON box is left empty", async () => {
const sentPayload = await submit({
metadata: "",
max_budget_alert_emails: [{ threshold: 90, emails: "dl@example.com" }],
});
expect(sentPayload.metadata).toEqual({ max_budget_alert_emails: { "90": ["dl@example.com"] } });
});
it("sends the stored map verbatim on a save that does not edit the rows", async () => {
const storedAlertEmails = {
"50": ["a@example.com"],
"75": null,
"80": ["b@example.com", null],
"150": ["c@example.com"],
};
const sentPayload = await submit(
{
key_alias: "renamed",
metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: { "150": ["c@example.com"] } }),
max_budget_alert_emails: [{ threshold: 50, emails: "a@example.com" }],
},
["key_alias"],
{ ...baseKeyData, metadata: { cost_center: "ops", max_budget_alert_emails: storedAlertEmails } },
);
expect(sentPayload.metadata).toEqual({ cost_center: "ops", max_budget_alert_emails: storedAlertEmails });
});
const KEY_WITH_HIDDEN_ALERT = {
...baseKeyData,
metadata: {
cost_center: "ops",
max_budget_alert_emails: {
"50": ["old@example.com"],
"80": ["removed@example.com"],
"150": ["c@example.com"],
},
},
};
it("keeps the entry the rows cannot show that the JSON box still holds when the rows were edited", async () => {
const sentPayload = await submit(
{
metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: { "150": ["c@example.com"] } }),
max_budget_alert_emails: [{ threshold: 50, emails: "new@example.com" }],
},
["max_budget_alert_emails"],
KEY_WITH_HIDDEN_ALERT,
);
expect(sentPayload.metadata).toEqual({
cost_center: "ops",
max_budget_alert_emails: { "50": ["new@example.com"], "150": ["c@example.com"] },
});
});
it("keeps only the box's hidden entry when every row is removed", async () => {
const sentPayload = await submit(
{
metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: { "150": ["c@example.com"] } }),
max_budget_alert_emails: [],
},
["max_budget_alert_emails"],
KEY_WITH_HIDDEN_ALERT,
);
expect(sentPayload.metadata).toEqual({
cost_center: "ops",
max_budget_alert_emails: { "150": ["c@example.com"] },
});
});
it("clears the map when every row is removed and the hidden entry is deleted from the JSON box", async () => {
const sentPayload = await submit(
{ metadata: JSON.stringify({ cost_center: "ops" }), max_budget_alert_emails: [] },
["max_budget_alert_emails"],
KEY_WITH_HIDDEN_ALERT,
);
expect(sentPayload.metadata).toEqual({ cost_center: "ops" });
});
it("saves an entry the rows cannot show typed into the JSON box on an untouched-rows edit", async () => {
const sentPayload = await submit(
{
metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: { "0": ["zero@example.com"] } }),
max_budget_alert_emails: [],
},
["metadata"],
{ ...baseKeyData, metadata: { cost_center: "ops" } },
);
expect(sentPayload.metadata).toEqual({
cost_center: "ops",
max_budget_alert_emails: { "0": ["zero@example.com"] },
});
});
it("saves a stored max_budget_alert_emails string back unchanged on an unrelated edit", async () => {
const sentPayload = await submit(
{
key_alias: "renamed",
metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: "ops@qa.test" }),
max_budget_alert_emails: [],
},
["key_alias"],
{ ...baseKeyData, metadata: { cost_center: "ops", max_budget_alert_emails: "ops@qa.test" } },
);
expect(sentPayload.metadata).toEqual({ cost_center: "ops", max_budget_alert_emails: "ops@qa.test" });
});
});

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { moveTagsOutOfMetadataJson } from "./keyEditFieldNormalizers";
import { moveBudgetAlertEmailsOutOfMetadataJson, moveTagsOutOfMetadataJson } from "./keyEditFieldNormalizers";
describe("moveTagsOutOfMetadataJson", () => {
it("moves a tags array out of the JSON and appends it to the current tags", () => {
@ -48,3 +48,45 @@ describe("moveTagsOutOfMetadataJson", () => {
expect(moveTagsOutOfMetadataJson("null", ["a"])).toBeNull();
});
});
describe("moveBudgetAlertEmailsOutOfMetadataJson", () => {
it("moves the typed thresholds into the rows, letting a typed entry replace the row with its threshold", () => {
const typed = JSON.stringify({
env: "prod",
max_budget_alert_emails: { "80": "typed@example.com, dl@example.com", "90": null },
});
const rows = [
{ threshold: 50, emails: "kept@example.com" },
{ threshold: 80, emails: "replaced@example.com" },
];
expect(moveBudgetAlertEmailsOutOfMetadataJson(typed, rows)).toEqual({
metadata: '{\n "env": "prod"\n}',
rows: [
{ threshold: 50, emails: "kept@example.com" },
{ threshold: 80, emails: "typed@example.com, dl@example.com" },
{ threshold: 90, emails: "" },
],
movedThresholds: [80, 90],
});
});
it("leaves the typed entries the rows cannot show in the JSON", () => {
const typed = JSON.stringify({ max_budget_alert_emails: { "80": ["a@example.com"], "150": ["b@example.com"] } });
expect(moveBudgetAlertEmailsOutOfMetadataJson(typed, [])).toEqual({
metadata: JSON.stringify({ max_budget_alert_emails: { "150": ["b@example.com"] } }, null, 2),
rows: [{ threshold: 80, emails: "a@example.com" }],
movedThresholds: [80],
});
});
it("returns null when nothing typed can become a row", () => {
expect(
moveBudgetAlertEmailsOutOfMetadataJson('{"max_budget_alert_emails": {"150": ["b@example.com"]}}', []),
).toBeNull();
expect(moveBudgetAlertEmailsOutOfMetadataJson('{"env": "prod"}', [])).toBeNull();
expect(moveBudgetAlertEmailsOutOfMetadataJson('{"max_budget_alert_emails": {', [])).toBeNull();
expect(moveBudgetAlertEmailsOutOfMetadataJson(undefined, [])).toBeNull();
});
});

View file

@ -1,5 +1,11 @@
import type { TFunction } from "i18next";
import i18n from "@/i18n/i18n";
import {
type BudgetAlertRow,
budgetAlertEmailsRowsCannotShow,
budgetAlertRowsFromMetadata,
KEY_MAX_BUDGET_ALERT_EMAILS_KEY,
} from "../key_team_helpers/budgetAlertEmails";
const WORD_FORM_BUDGET_DURATIONS: Record<string, string> = {
hourly: "1h",
@ -75,6 +81,27 @@ export const moveTagsOutOfMetadataJson = (
return { metadata: JSON.stringify(rest, null, 2), tags: [...existing, ...movedTags], movedTags };
};
export type MovedMetadataBudgetAlerts = {
readonly metadata: string;
readonly rows: readonly BudgetAlertRow[];
readonly movedThresholds: readonly (number | null)[];
};
export const moveBudgetAlertEmailsOutOfMetadataJson = (
metadataJson: string | undefined,
currentRows: readonly BudgetAlertRow[] | undefined,
): MovedMetadataBudgetAlerts | null => {
const parsed = metadataJson === undefined ? null : parseJsonObject(metadataJson);
const typedRows = budgetAlertRowsFromMetadata(parsed, KEY_MAX_BUDGET_ALERT_EMAILS_KEY);
if (parsed === null || typedRows.length === 0) return null;
const movedThresholds = typedRows.map((row) => row.threshold);
const unmoved = budgetAlertEmailsRowsCannotShow(parsed, KEY_MAX_BUDGET_ALERT_EMAILS_KEY);
const { [KEY_MAX_BUDGET_ALERT_EMAILS_KEY]: _moved, ...rest } = parsed;
const metadata = Object.keys(unmoved).length > 0 ? { ...rest, [KEY_MAX_BUDGET_ALERT_EMAILS_KEY]: unmoved } : rest;
const keptRows = (currentRows ?? []).filter((row) => !movedThresholds.includes(row.threshold));
return { metadata: JSON.stringify(metadata, null, 2), rows: [...keptRows, ...typedRows], movedThresholds };
};
export const currentValuePlaceholder = (
premiumUser: boolean,
current: unknown,

View file

@ -4,7 +4,13 @@ import { UseFormReturn, UseFormSetValue } from "react-hook-form";
import { KeyResponse } from "../key_team_helpers/key_list";
import { ToolPermissionsChangeOrigin } from "../mcp_server_management/MCPToolPermissions";
import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils";
import { extractLoggingSettings, formatMetadataForDisplay, stripFieldManagedKeysFromMetadata } from "../key_info_utils";
import {
type BudgetAlertRow,
budgetAlertRowsFromMetadata,
budgetAlertRowsSchema,
KEY_MAX_BUDGET_ALERT_EMAILS_KEY,
} from "../key_team_helpers/budgetAlertEmails";
import { mapInternalToDisplayNames } from "../callback_info_helpers";
import { estimateChecks, estimateFields } from "./estimatedOutputTokens";
import { canonicalBudgetDuration, keyOrganizationId } from "./keyEditFieldNormalizers";
@ -26,6 +32,7 @@ export interface KeyEditFormValues {
allowed_routes?: string;
max_budget?: number | string | null;
soft_budget?: number | string | null;
max_budget_alert_emails?: BudgetAlertRow[];
budget_duration?: string | null;
tpm_limit?: number | string | null;
tpm_limit_type?: string | null;
@ -76,6 +83,7 @@ export const toKeyEditFormValues = (keyData: KeyResponse): KeyEditFormValues =>
max_budget: keyData.max_budget,
soft_budget:
(keyData.litellm_budget_table as { soft_budget?: number | null } | null | undefined)?.soft_budget ?? null,
max_budget_alert_emails: [...budgetAlertRowsFromMetadata(keyData.metadata, KEY_MAX_BUDGET_ALERT_EMAILS_KEY)],
budget_duration: canonicalBudgetDuration(keyData.budget_duration),
tpm_limit: keyData.tpm_limit,
tpm_limit_type: (keyData as { tpm_limit_type?: string | null }).tpm_limit_type ?? null,
@ -114,7 +122,7 @@ export const toKeyEditFormValues = (keyData: KeyResponse): KeyEditFormValues =>
team_id: keyData.team_id,
project_id: keyData.project_id,
logging_settings: extractLoggingSettings(keyData.metadata),
metadata: formatMetadataForDisplay(stripTagsFromMetadata(keyData.metadata)),
metadata: formatMetadataForDisplay(stripFieldManagedKeysFromMetadata(keyData.metadata)),
duration: (keyData as { duration?: string }).duration ?? "",
token: keyData.token || keyData.token_id,
disabled_callbacks: Array.isArray(readMetadata(keyData, "litellm_disabled_callbacks"))
@ -130,6 +138,11 @@ export const keyEditFormSchema = z.object({
allowed_routes: z.custom<string | undefined>().optional(),
max_budget: z.custom<number | string | null | undefined>().optional(),
soft_budget: z.custom<number | string | null | undefined>().optional(),
max_budget_alert_emails: budgetAlertRowsSchema((issue) =>
issue === "thresholdRange"
? i18n.t("keyDetail.maxBudgetAlertEmails.thresholdRange")
: i18n.t("keyDetail.maxBudgetAlertEmails.duplicateThreshold"),
).optional(),
budget_duration: z.custom<string | null | undefined>().optional(),
tpm_limit: z.custom<number | string | null | undefined>().optional(),
tpm_limit_type: z.custom<string | null | undefined>().optional(),
@ -187,6 +200,7 @@ export const toSubmittedValues = (
allowed_routes: values.allowed_routes,
max_budget: values.max_budget,
soft_budget: values.soft_budget,
max_budget_alert_emails: values.max_budget_alert_emails,
budget_duration: values.budget_duration,
tpm_limit: values.tpm_limit,
tpm_limit_type: values.tpm_limit_type,

View file

@ -15,6 +15,7 @@ import {
modelAvailableCall,
vectorStoreListCall,
} from "../networking";
import { toast } from "@/lib/toast";
import { KeyEditView } from "./key_edit_view";
import { teamAdminMemberKeyPayload } from "./teamAdminMemberKeyPayload";
@ -2577,6 +2578,7 @@ describe("KeyEditView", () => {
models: [],
max_budget: 0,
soft_budget: null,
max_budget_alert_emails: [],
budget_duration: "30d",
tpm_limit: 10,
tpm_limit_type: null,
@ -3308,4 +3310,185 @@ describe("KeyEditView", () => {
});
});
});
describe("budget alert recipients", () => {
const KEY_WITH_ALERTS: KeyResponse = {
...MOCK_KEY_DATA,
max_budget: 100,
metadata: {
...MOCK_KEY_DATA.metadata,
cost_center: "ops",
max_budget_alert_emails: { "80": ["shared-mailbox@example.com", "finance-dl@example.com"], "50": [] },
},
};
const renderAlerts = (
onSubmit: (values: Record<string, unknown>) => Promise<void>,
editingMemberKeyAsTeamAdmin = false,
keyData: KeyResponse = KEY_WITH_ALERTS,
) =>
renderWithProviders(
<KeyEditView
keyData={keyData}
onCancel={() => {}}
onSubmit={onSubmit}
accessToken={"test-token"}
userID={"test-user"}
userRole={"Admin"}
premiumUser={true}
editingMemberKeyAsTeamAdmin={editingMemberKeyAsTeamAdmin}
/>,
);
const save = async (onSubmit: ReturnType<typeof vi.fn>) => {
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
return onSubmit.mock.calls[0][0];
};
it("shows the stored thresholds as rows and keeps them out of the metadata JSON box", async () => {
renderAlerts(vi.fn());
const thresholds = await screen.findAllByPlaceholderText("% of budget");
expect(thresholds.map((input) => (input as HTMLInputElement).value)).toEqual(["50", "80"]);
expect(screen.getAllByPlaceholderText("Additional recipients, e.g. finance@example.com")[1]).toHaveValue(
"shared-mailbox@example.com, finance-dl@example.com",
);
expect(JSON.parse((screen.getByLabelText("Metadata") as HTMLTextAreaElement).value)).toEqual({
cost_center: "ops",
});
});
it("shows only the stored entries the rows cannot show in the metadata JSON box", async () => {
renderAlerts(vi.fn(), false, {
...KEY_WITH_ALERTS,
metadata: {
cost_center: "ops",
max_budget_alert_emails: { "80": ["row@example.com"], "150": ["over@example.com"], "0": [] },
},
});
const thresholds = await screen.findAllByPlaceholderText("% of budget");
expect(thresholds.map((input) => (input as HTMLInputElement).value)).toEqual(["80"]);
expect(JSON.parse((screen.getByLabelText("Metadata") as HTMLTextAreaElement).value)).toEqual({
cost_center: "ops",
max_budget_alert_emails: { "150": ["over@example.com"], "0": [] },
});
});
it("submits an untouched key's rows unchanged", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
renderAlerts(onSubmit);
await screen.findAllByPlaceholderText("% of budget");
expect((await save(onSubmit)).max_budget_alert_emails).toEqual([
{ threshold: 50, emails: "" },
{ threshold: 80, emails: "shared-mailbox@example.com, finance-dl@example.com" },
]);
expect(onSubmit.mock.calls[0][1]).not.toContain("max_budget_alert_emails");
});
it("submits an added row and drops a removed one", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
renderAlerts(onSubmit);
await screen.findAllByPlaceholderText("% of budget");
await userEvent.click(screen.getAllByRole("button", { name: "Remove budget alert threshold" })[0]);
await userEvent.click(screen.getByRole("button", { name: "Add Budget Alert Threshold" }));
const thresholds = screen.getAllByPlaceholderText("% of budget");
fireEvent.change(thresholds[thresholds.length - 1], { target: { value: "100" } });
const recipients = screen.getAllByPlaceholderText("Additional recipients, e.g. finance@example.com");
fireEvent.change(recipients[recipients.length - 1], { target: { value: "ap-team@example.com" } });
expect((await save(onSubmit)).max_budget_alert_emails).toEqual([
{ threshold: 80, emails: "shared-mailbox@example.com, finance-dl@example.com" },
{ threshold: 100, emails: "ap-team@example.com" },
]);
expect(onSubmit.mock.calls[0][1]).toContain("max_budget_alert_emails");
});
it("reports the rows as edited when every row is removed, so the save clears them", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
renderAlerts(onSubmit);
await screen.findAllByPlaceholderText("% of budget");
for (const button of screen.getAllByRole("button", { name: "Remove budget alert threshold" }).reverse()) {
await userEvent.click(button);
}
expect((await save(onSubmit)).max_budget_alert_emails).toEqual([]);
expect(onSubmit.mock.calls[0][1]).toContain("max_budget_alert_emails");
});
it("moves a recipient map typed into the metadata JSON box into the rows on blur", async () => {
const info = vi.spyOn(toast, "info");
const onSubmit = vi.fn().mockResolvedValue(undefined);
renderAlerts(onSubmit);
await screen.findAllByPlaceholderText("% of budget");
const metadataBox = screen.getByLabelText("Metadata");
fireEvent.change(metadataBox, {
target: {
value: JSON.stringify({
cost_center: "ops",
max_budget_alert_emails: { "80": ["typed@example.com"], "95": [] },
}),
},
});
fireEvent.blur(metadataBox);
expect(JSON.parse((metadataBox as HTMLTextAreaElement).value)).toEqual({ cost_center: "ops" });
expect(info).toHaveBeenCalledWith(
"Moved alert thresholds 80%, 95% from metadata to the Budget Alert Thresholds rows",
);
expect((await save(onSubmit)).max_budget_alert_emails).toEqual([
{ threshold: 50, emails: "" },
{ threshold: 80, emails: "typed@example.com" },
{ threshold: 95, emails: "" },
]);
expect(onSubmit.mock.calls[0][1]).toContain("max_budget_alert_emails");
});
it("moves a recipient map typed into the metadata JSON box into the rows even without a blur", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
renderAlerts(onSubmit);
await screen.findAllByPlaceholderText("% of budget");
fireEvent.change(screen.getByLabelText("Metadata"), {
target: {
value: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: { "80": ["typed@example.com"] } }),
},
});
fireEvent.submit(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
expect(onSubmit.mock.calls[0][0].max_budget_alert_emails).toEqual([
{ threshold: 50, emails: "" },
{ threshold: 80, emails: "typed@example.com" },
]);
expect(onSubmit.mock.calls[0][1]).toContain("max_budget_alert_emails");
});
it("hides the rows from a team admin editing a member's key", async () => {
renderAlerts(vi.fn(), true);
expect(await screen.findByLabelText("Metadata")).toBeInTheDocument();
expect(screen.queryByPlaceholderText("% of budget")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Add Budget Alert Threshold" })).not.toBeInTheDocument();
});
it("blocks the save on a duplicate threshold", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
renderAlerts(onSubmit);
await screen.findAllByPlaceholderText("% of budget");
await userEvent.click(screen.getByRole("button", { name: "Add Budget Alert Threshold" }));
const thresholds = screen.getAllByPlaceholderText("% of budget");
fireEvent.change(thresholds[thresholds.length - 1], { target: { value: "80" } });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
expect(await screen.findAllByText("Duplicate threshold")).toHaveLength(2);
expect(onSubmit).not.toHaveBeenCalled();
});
});
});

View file

@ -38,6 +38,7 @@ import {
KeyRateLimitFields,
KeyTypeSelect,
labelWithHint,
moveMetadataBudgetAlertsToKeyRows,
moveMetadataTagsToTagsField,
} from "./KeyEditViewControls";
import {
@ -49,6 +50,7 @@ import {
clearMcpGrants,
writeMcpToolPermissions,
} from "./keyEditFormValues";
import { BudgetAlertEmailsEditor } from "../key_team_helpers/BudgetAlertEmailsEditor";
import { BudgetFallbacksEditor } from "../key_team_helpers/BudgetFallbacksEditor";
import { END_USER_BUDGET_HINT, EndUserBudgetSelect } from "../key_team_helpers/EndUserBudgetSelect";
import {
@ -90,6 +92,7 @@ interface KeyEditViewProps {
userID: string | null;
userRole: string | null;
premiumUser?: boolean;
editingMemberKeyAsTeamAdmin?: boolean;
}
const sessionKey = (keyData: KeyResponse) => keyData.token || keyData.token_id;
@ -105,6 +108,7 @@ function KeyEditSession({
userID,
userRole,
premiumUser = false,
editingMemberKeyAsTeamAdmin = false,
}: KeyEditViewProps) {
const { t } = useTranslation();
const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole));
@ -323,7 +327,7 @@ function KeyEditSession({
...values,
...(detachProject && enableProjectsUI && canDetachProject ? { project_id: null } : {}),
}),
[...Object.keys(dirtyFields), ...(budgetLimitsUnchanged ? [] : ["budget_limits"])],
[...Object.keys(form.formState.dirtyFields), ...(budgetLimitsUnchanged ? [] : ["budget_limits"])],
);
} finally {
setIsKeySaving(false);
@ -373,16 +377,17 @@ function KeyEditSession({
);
};
const submitForm = (event: React.FormEvent<HTMLFormElement>) => {
moveMetadataTagsToTagsField(form, t);
if (!editingMemberKeyAsTeamAdmin) moveMetadataBudgetAlertsToKeyRows(form, t);
return form.handleSubmit((values) => handleSubmit(toSubmittedValues(values, { canViewPolicies, canViewPrompts })))(
event,
);
};
return (
<TooltipProvider>
<form
onSubmit={(event) => {
moveMetadataTagsToTagsField(form, t);
return form.handleSubmit((values) =>
handleSubmit(toSubmittedValues(values, { canViewPolicies, canViewPrompts })),
)(event);
}}
>
<form onSubmit={submitForm}>
<FieldGroup>
<FormField control={form.control} name="key_alias" label={t("keyDetail.keyEditView.fields.keyAlias")}>
{(field) => <Input {...field} value={(field.value as string | undefined) ?? ""} />}
@ -467,6 +472,23 @@ function KeyEditSession({
placeholder={t("keyDetail.keyEditView.fields.softBudgetPlaceholder")}
/>
{!editingMemberKeyAsTeamAdmin && (
<BudgetAlertEmailsEditor
control={form.control}
name="max_budget_alert_emails"
label={labelWithHint(
t("keyDetail.maxBudgetAlertEmails.budgetAlertThresholds"),
t("keyDetail.maxBudgetAlertEmails.budgetAlertThresholdsHint"),
)}
text={{
thresholdPlaceholder: t("keyDetail.maxBudgetAlertEmails.thresholdPlaceholder"),
recipientsPlaceholder: t("keyDetail.maxBudgetAlertEmails.recipientsPlaceholder"),
removeThreshold: t("keyDetail.maxBudgetAlertEmails.removeThreshold"),
addThreshold: t("keyDetail.maxBudgetAlertEmails.addThreshold"),
}}
/>
)}
<FormField
control={form.control}
name="budget_duration"
@ -896,7 +918,7 @@ function KeyEditSession({
)}
</FormField>
<KeyMetadataField form={form} />
<KeyMetadataField form={form} budgetAlertRowsShown={!editingMemberKeyAsTeamAdmin} />
<div className="mb-4">
<FormField control={form.control} name="duration">

View file

@ -17,6 +17,7 @@ import { useModelAccessGroupNames } from "@/app/(dashboard)/hooks/models/useMode
const editViewMocks = vi.hoisted(() => ({
onSubmit: undefined as ((v: Record<string, any>) => Promise<void>) | undefined,
editingMemberKeyAsTeamAdmin: undefined as boolean | undefined,
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => "/ui/alerts" }));
@ -26,8 +27,15 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
}));
vi.mock("./key_edit_view", () => ({
KeyEditView: ({ onSubmit }: { onSubmit: (v: Record<string, any>) => Promise<void> }) => {
KeyEditView: ({
onSubmit,
editingMemberKeyAsTeamAdmin,
}: {
onSubmit: (v: Record<string, any>) => Promise<void>;
editingMemberKeyAsTeamAdmin?: boolean;
}) => {
editViewMocks.onSubmit = onSubmit;
editViewMocks.editingMemberKeyAsTeamAdmin = editingMemberKeyAsTeamAdmin;
return <div data-testid="key-edit-view-stub" />;
},
}));
@ -892,7 +900,9 @@ describe("KeyInfoView", () => {
await renderAndOpenSettingsTab({ ...MOCK_KEY_DATA, user_id: "someone-else-id" });
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /edit settings/i }));
expect(await screen.findByTestId("key-edit-view-stub")).toBeInTheDocument();
expect(editViewMocks.editingMemberKeyAsTeamAdmin).toBe(false);
});
it("should show the Edit Settings button when the user is the key owner", async () => {
@ -961,7 +971,9 @@ describe("KeyInfoView", () => {
await renderAndOpenSettingsTab({ ...MOCK_KEY_DATA, team_id: teamId, user_id: "other-user-id" });
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /edit settings/i }));
expect(await screen.findByTestId("key-edit-view-stub")).toBeInTheDocument();
expect(editViewMocks.editingMemberKeyAsTeamAdmin).toBe(true);
});
});

View file

@ -39,6 +39,12 @@ import DeleteResourceModal from "../common_components/DeleteResourceModal";
import RouterSettingsSummary from "../common_components/RouterSettingsSummary";
import { hasRouterSettings } from "../common_components/routerSettingsPayload";
import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils";
import {
budgetAlertEmailsFromRows,
budgetAlertEmailsMetadata,
budgetAlertEmailsRowsCanShow,
KEY_MAX_BUDGET_ALERT_EMAILS_KEY,
} from "../key_team_helpers/budgetAlertEmails";
import { KeyResponse } from "../key_team_helpers/key_list";
import LoggingSettingsView from "../logging_settings_view";
import { toast } from "@/lib/toast";
@ -219,6 +225,15 @@ export default function KeyInfoView({
);
}
const memberKeyEditContext = {
userRole: userRole || "",
userId: userID || "",
keyUserId: currentKeyData.user_id,
keyTeamId: currentKeyData.team_id,
teamMembers: teamsData?.find((team) => team.team_id === currentKeyData.team_id)?.members_with_roles,
};
const editingMemberKeyAsTeamAdmin = isTeamAdminEditingMemberKey(memberKeyEditContext);
const handleKeyUpdate = async (formValues: Record<string, any>, dirtyFields: readonly string[] = []) => {
try {
if (!accessToken) return;
@ -334,6 +349,12 @@ export default function KeyInfoView({
formValues.tpd_limit = mapEmptyStringToNull(formValues.tpd_limit);
formValues.max_parallel_requests = mapEmptyStringToNull(formValues.max_parallel_requests);
const budgetAlertRowEntries = dirtyFields.includes(KEY_MAX_BUDGET_ALERT_EMAILS_KEY)
? budgetAlertEmailsFromRows(formValues.max_budget_alert_emails ?? [])
: budgetAlertEmailsRowsCanShow(currentKeyData.metadata, KEY_MAX_BUDGET_ALERT_EMAILS_KEY);
const budgetAlertEmailsWithBoxEntries = (boxMetadata: unknown) =>
budgetAlertEmailsMetadata(budgetAlertRowEntries, KEY_MAX_BUDGET_ALERT_EMAILS_KEY, boxMetadata);
// Convert metadata back to an object if it exists and is a string
if (formValues.metadata && typeof formValues.metadata === "string") {
try {
@ -342,9 +363,12 @@ export default function KeyInfoView({
if ("tags" in parsedMetadata) {
delete parsedMetadata["tags"];
}
const budgetAlertEmails = budgetAlertEmailsWithBoxEntries(parsedMetadata);
delete parsedMetadata[KEY_MAX_BUDGET_ALERT_EMAILS_KEY];
formValues.metadata = {
...parsedMetadata,
...(Array.isArray(formValues.tags) && formValues.tags.length > 0 ? { tags: formValues.tags } : {}),
...budgetAlertEmails,
...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}),
...(Array.isArray(formValues.logging_settings) && formValues.logging_settings.length > 0
? { logging: formValues.logging_settings }
@ -362,10 +386,11 @@ export default function KeyInfoView({
}
} else {
const baseMetadata = formValues.metadata || {};
const { tags: _omitTags, ...rest } = baseMetadata;
const { tags: _omitTags, [KEY_MAX_BUDGET_ALERT_EMAILS_KEY]: _boxAlertEmails, ...rest } = baseMetadata;
formValues.metadata = {
...rest,
...(Array.isArray(formValues.tags) && formValues.tags.length > 0 ? { tags: formValues.tags } : {}),
...budgetAlertEmailsWithBoxEntries(baseMetadata),
...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}),
...(Array.isArray(formValues.logging_settings) && formValues.logging_settings.length > 0
? { logging: formValues.logging_settings }
@ -383,6 +408,7 @@ export default function KeyInfoView({
delete formValues.tags;
}
delete formValues.logging_settings;
delete formValues.max_budget_alert_emails;
// Normalize any legacy word-form budget_duration to the canonical API format
if (formValues.budget_duration) {
@ -395,14 +421,6 @@ export default function KeyInfoView({
formValues.budget_duration = wordToCanonical[formValues.budget_duration] ?? formValues.budget_duration;
}
const memberKeyEditContext = {
userRole: userRole || "",
userId: userID || "",
keyUserId: currentKeyData.user_id,
keyTeamId: currentKeyData.team_id,
teamMembers: teamsData?.find((team) => team.team_id === currentKeyData.team_id)?.members_with_roles,
};
const editingMemberKeyAsTeamAdmin = isTeamAdminEditingMemberKey(memberKeyEditContext);
if (editingMemberKeyAsTeamAdmin) {
const trimmed = teamAdminMemberKeyPayload(formValues, dirtyFields);
if (trimmed.kind === "blocked") {
@ -1010,6 +1028,7 @@ export default function KeyInfoView({
userID={userID}
userRole={userRole}
premiumUser={premiumUser}
editingMemberKeyAsTeamAdmin={editingMemberKeyAsTeamAdmin}
/>
) : (
<div className="space-y-4">

View file

@ -2391,6 +2391,17 @@
"description": "Tags are managed by the Tags field above. A tags array typed here is moved to that field."
}
},
"maxBudgetAlertEmails": {
"budgetAlertThresholds": "Budget Alert Thresholds",
"budgetAlertThresholdsHint": "Thresholds are a percentage of the key's max budget and replace this key's default 80% alert. Global alert thresholds configured on the proxy still apply. Thresholds outside 1 to 100 can be edited in the Metadata box. The key owner's email is always included. Add comma-separated addresses, such as a shared mailbox or distribution list, to notify others as well. Requires email alerting to be configured on the proxy.",
"thresholdPlaceholder": "% of budget",
"recipientsPlaceholder": "Additional recipients, e.g. finance@example.com",
"removeThreshold": "Remove budget alert threshold",
"addThreshold": "Add Budget Alert Threshold",
"thresholdRange": "Enter a whole number from 1 to 100",
"duplicateThreshold": "Duplicate threshold",
"movedThresholds": "Moved alert thresholds {{thresholds}} from metadata to the Budget Alert Thresholds rows"
},
"estimatedOutputTokens": {
"invalidPerModel": "Enter a JSON object of positive integers, e.g. {\"gpt-4\": 4096}",
"invalidPositive": "Enter a positive integer",
@ -20670,6 +20681,72 @@
"maxBudget": "Max budget (USD)",
"maxBudgetPlaceholder": "Leave empty for no budget",
"sharedBudgetWarning": "This customer is on budget {{budgetId}}. Changing the budget here changes it for every customer on that budget."
},
"analytics": {
"title": "Customer usage",
"customers": "Customers",
"breadcrumb": "Customer hierarchy",
"selectOrganization": "Open a customer organization to explore its usage.",
"backToCustomers": "Back to customers",
"organizationUsage": "Organization usage",
"teamUsage": "Team usage",
"memberUsage": "Member usage",
"subtitle": "Understand where usage goes, from your organization to each member.",
"trend": "Usage over time",
"trendHint": "Daily activity, broken down by {{dimension}}.",
"metric": "Usage metric",
"spend": "Spend",
"tokens": "Tokens",
"requests": "Requests",
"bucket": "Time interval",
"daily": "Daily",
"weekly": "Weekly",
"loading": "Loading customer usage",
"empty": "No usage in this range",
"error": "Could not load customer usage",
"breakdown": "Usage breakdown",
"models": "Models",
"keys": "API keys",
"useCases": "Use cases",
"teams": "Teams",
"members": "Members",
"sortedBy": "Ranked by {{metric}}",
"search": "Search breakdown",
"rank": "Rank",
"share": "Share",
"rankingHint": "Share of the selected metric across this customer scope. Click a team or member to explore its usage.",
"useCaseHint": "Use cases recorded with requests. Unattributed activity appears as Unassigned.",
"noMatches": "No results match your search.",
"noBreakdown": "No usage recorded for this breakdown.",
"pagination": "{{from}}–{{to}} of {{total}}",
"previous": "Previous",
"next": "Next",
"allOrganizationUsage": "View all organization usage",
"freshness": "Usage appears after accounting completes. Date ranges use UTC. Customer budgets are managed separately.",
"scopeMismatch": "This team or member does not belong to the selected organization.",
"invalidIdentifier": "Select a valid customer identifier.",
"incompletePages": "Could not load the complete usage range. Please choose fewer days.",
"unassignedTeam": "Unassigned or removed team",
"unassignedMember": "Unassigned or removed member",
"unnamedKey": "Unnamed key",
"currentSpend": "Budget spend",
"noBudget": "No budget set",
"breakDownBy": "Break down by",
"topModels": "Top models",
"topKeys": "Top API keys",
"topTeams": "Top teams",
"topMembers": "Top members",
"memberSpend": "Member spend",
"memberSpendHint": "Usage in the selected date range. Cap status uses each member’s recorded budget spend.",
"searchMembers": "Search members",
"member": "Member",
"currentBudget": "Spend / cap",
"status": "Status",
"noCap": "No cap",
"atBudget": "At budget",
"withinBudget": "Within budget",
"noMembers": "No members in this customer scope.",
"overBudget": "Over budget"
}
}
}

View file

@ -9608,6 +9608,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/management/v1/customer_members/daily/activity": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Customer Member Daily Activity
* @description Get paginated daily customer member activity with model, provider, key and member breakdowns. Filters accept comma-separated member IDs. Proxy admins only.
*/
get: operations["customer_member_daily_activity_management_v1_customer_members_daily_activity_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/management/v1/customer_members/{customer_member_id}": {
parameters: {
query?: never;
@ -9660,6 +9680,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/management/v1/customer_organizations/daily/activity": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Customer Organization Daily Activity
* @description Get paginated daily customer organization activity with model, provider, key and organization breakdowns. Filters accept comma-separated organization IDs. Proxy admins only.
*/
get: operations["customer_organization_daily_activity_management_v1_customer_organizations_daily_activity_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/management/v1/customer_organizations/{customer_org_id}": {
parameters: {
query?: never;
@ -9756,6 +9796,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/management/v1/customer_teams/daily/activity": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Customer Team Daily Activity
* @description Get paginated daily customer team activity with model, provider, key and team breakdowns. Filters accept comma-separated team IDs. Proxy admins only.
*/
get: operations["customer_team_daily_activity_management_v1_customer_teams_daily_activity_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/management/v1/customer_teams/{customer_team_id}": {
parameters: {
query?: never;
@ -68150,6 +68210,44 @@ export interface operations {
};
};
};
customer_member_daily_activity_management_v1_customer_members_daily_activity_get: {
parameters: {
query?: {
customer_member_ids?: string | null;
start_date?: string | null;
end_date?: string | null;
model?: string | null;
api_key?: string | null;
page?: number;
page_size?: number;
exclude_customer_member_ids?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_customer_member_management_v1_customer_members__customer_member_id__get: {
parameters: {
query?: never;
@ -68310,6 +68408,44 @@ export interface operations {
};
};
};
customer_organization_daily_activity_management_v1_customer_organizations_daily_activity_get: {
parameters: {
query?: {
customer_org_ids?: string | null;
start_date?: string | null;
end_date?: string | null;
model?: string | null;
api_key?: string | null;
page?: number;
page_size?: number;
exclude_customer_org_ids?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_customer_organization_management_v1_customer_organizations__customer_org_id__get: {
parameters: {
query?: never;
@ -68572,6 +68708,44 @@ export interface operations {
};
};
};
customer_team_daily_activity_management_v1_customer_teams_daily_activity_get: {
parameters: {
query?: {
customer_team_ids?: string | null;
start_date?: string | null;
end_date?: string | null;
model?: string | null;
api_key?: string | null;
page?: number;
page_size?: number;
exclude_customer_team_ids?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_customer_team_management_v1_customer_teams__customer_team_id__get: {
parameters: {
query?: never;

View file

@ -5024,9 +5024,7 @@ def get_optional_params_embeddings(
optional_params["dimensions"] = non_default_params.pop("dimensions")
if len(non_default_params.keys()) > 0:
if litellm.drop_params is True or drop_params is True: # drop the unsupported non-default values
keys = list(non_default_params.keys())
for k in keys:
non_default_params.pop(k, None)
non_default_params.clear()
else:
raise UnsupportedParamsError(
status_code=500,
@ -5039,9 +5037,7 @@ def get_optional_params_embeddings(
):
if len(non_default_params.keys()) > 0:
if litellm.drop_params is True or drop_params is True: # drop the unsupported non-default values
keys = list(non_default_params.keys())
for k in keys:
non_default_params.pop(k, None)
non_default_params.clear()
else:
raise UnsupportedParamsError(
status_code=500,
@ -5060,7 +5056,7 @@ def get_optional_params_embeddings(
additional_drop_params=kwargs.get("additional_drop_params", None),
)
if "extra_body" in final_params and len(final_params["extra_body"]) == 0:
if "extra_body" in final_params and not final_params["extra_body"]:
final_params.pop("extra_body", None)
return final_params

View file

@ -28650,7 +28650,7 @@
"mode": "chat",
"output_cost_per_token": 2.8e-07,
"source": "https://docs.fireworks.ai/serverless/pricing",
"successor_model": "fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731",
"successor_model": "fireworks_ai/accounts/fireworks/models/deepseek-v4p1-flash",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -29124,7 +29124,7 @@
"mode": "chat",
"output_cost_per_token": 2.8e-07,
"source": "https://docs.fireworks.ai/serverless/pricing",
"successor_model": "fireworks_ai/deepseek-v4-flash-0731",
"successor_model": "fireworks_ai/deepseek-v4p1-flash",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -83697,7 +83697,7 @@
"mode": "chat",
"output_cost_per_token": 2.1e-07,
"source": "https://api.fireworks.ai/v1/serverless/models",
"successor_model": "fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731",
"successor_model": "fireworks_ai/accounts/fireworks/models/deepseek-v4p1-flash",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,

View file

@ -1,6 +1,6 @@
[project]
name = "litellm"
version = "1.110.0"
version = "1.111.0"
description = "Library to easily interface with LLM API providers"
readme = "README.md"
requires-python = ">=3.10, <3.16"
@ -88,7 +88,7 @@ proxy = [
"httpx2>=2.10.0,<3",
"pydantic>=2.12.0,<3",
"litellm-proxy-extras==0.4.115",
"litellm-enterprise==0.1.83",
"litellm-enterprise==0.1.84",
"RestrictedPython>=8.5,<9.0",
"rich>=13.9.4,<14.0",
"InquirerPy>=0.3.4,<1.0",
@ -441,7 +441,7 @@ litellm-enterprise = { workspace = true }
members = ["litellm/enterprise", "litellm/proxy-extras", "litellm/packaging/litellm-proxy"]
[tool.commitizen]
version = "1.110.0"
version = "1.111.0"
version_files = [
"pyproject.toml:^version",
]

View file

@ -0,0 +1,164 @@
import { test, expect, type Page as PlaywrightPage } from "@playwright/test";
import {
ADMIN_STORAGE_PATH,
E2E_TEAM_CRUD_ALIAS,
E2E_TEAM_CRUD_ID,
} from "../../constants";
import { Page } from "../../fixtures/pages";
import {
dismissFeedbackPopup,
navigateToPage,
openKeyDetail,
} from "../../helpers/navigation";
import { readBack } from "../../helpers/roundTrip";
import {
createVirtualKey,
deleteVirtualKey,
uniqueSuffix,
} from "../../helpers/traffic";
const SHARED_MAILBOX = "shared-mailbox@example.com";
const DISTRIBUTION_LIST = "finance-dl@example.com";
const RECIPIENTS_PLACEHOLDER =
"Additional recipients, e.g. finance@example.com";
async function keyMetadata(
page: PlaywrightPage,
token: string,
): Promise<Record<string, unknown>> {
const body = await readBack<{ info: { metadata: Record<string, unknown> } }>(
page,
`/key/info?key=${encodeURIComponent(token)}`,
);
return body.info.metadata;
}
async function tokenForAlias(
page: PlaywrightPage,
alias: string,
): Promise<string | undefined> {
const body = await readBack<{ keys: { key_alias: string; token: string }[] }>(
page,
`/key/list?key_alias=${encodeURIComponent(alias)}&return_full_object=true&size=100`,
);
return body.keys.find((row) => row.key_alias === alias)?.token;
}
async function addAlertRow(
page: PlaywrightPage,
threshold: string,
emails: string,
): Promise<void> {
await page
.getByRole("button", { name: "Add Budget Alert Threshold" })
.click();
await page.getByPlaceholder("% of budget").last().fill(threshold);
await page.getByPlaceholder(RECIPIENTS_PLACEHOLDER).last().fill(emails);
}
test.describe("Proxy Admin - Key budget alert recipients", () => {
test.use({ storageState: ADMIN_STORAGE_PATH });
test("Create Key stores the alert recipients next to the metadata JSON", async ({
page,
}) => {
const alias = `e2e-alert-create-${uniqueSuffix()}`;
await navigateToPage(page, Page.ApiKeys);
await dismissFeedbackPopup(page);
await page.getByRole("button", { name: /Create New Key/i }).click();
await page.getByLabel(/Key Name/).fill(alias);
await page.getByTestId("team-dropdown").getByRole("combobox").click();
await page.keyboard.type(E2E_TEAM_CRUD_ALIAS);
await page
.getByRole("option", { name: E2E_TEAM_CRUD_ALIAS })
.first()
.click();
await page.getByRole("combobox", { name: "Select models" }).click();
await page
.getByRole("option", { name: "All Team Models", exact: true })
.click();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: /Optional Settings/i }).click();
await page.getByLabel(/Max Budget \(USD\)/).fill("10");
await addAlertRow(page, "80", `${SHARED_MAILBOX}, ${DISTRIBUTION_LIST}`);
await page.getByLabel("Metadata").fill('{"cost_center": "ops"}');
await page.getByRole("button", { name: "Create Key", exact: true }).click();
await expect(page.getByText("Save your Key")).toBeVisible({
timeout: 10_000,
});
const token = await tokenForAlias(page, alias);
expect(token, `key ${alias} readable from /key/list`).toBeTruthy();
try {
expect(await keyMetadata(page, token!)).toMatchObject({
cost_center: "ops",
max_budget_alert_emails: { "80": [SHARED_MAILBOX, DISTRIBUTION_LIST] },
});
} finally {
await deleteVirtualKey(page.request, token!);
}
});
test("Edit Key round-trips the rows, keeps the rest of the metadata, and clears them when every row is removed", async ({
page,
}) => {
const alias = `e2e-alert-edit-${uniqueSuffix()}`;
const created = await createVirtualKey(page.request, {
key_alias: alias,
team_id: E2E_TEAM_CRUD_ID,
max_budget: 10,
metadata: {
cost_center: "ops",
max_budget_alert_emails: { "80": [SHARED_MAILBOX] },
},
});
try {
await navigateToPage(page, Page.ApiKeys);
await dismissFeedbackPopup(page);
await openKeyDetail(page, alias);
await page.getByRole("tab", { name: "Settings" }).click();
await page.getByRole("button", { name: "Edit Settings" }).click();
await expect(page.getByPlaceholder("% of budget")).toHaveValue("80");
await expect(page.getByPlaceholder(RECIPIENTS_PLACEHOLDER)).toHaveValue(
SHARED_MAILBOX,
);
expect(
JSON.parse(await page.getByLabel("Metadata").inputValue()),
).toEqual({ cost_center: "ops" });
await page
.getByRole("button", { name: "Remove budget alert threshold" })
.click();
await addAlertRow(page, "50", DISTRIBUTION_LIST);
await page.getByRole("button", { name: "Save Changes" }).click();
await expect
.poll(() => keyMetadata(page, created.token), { timeout: 20_000 })
.toMatchObject({
cost_center: "ops",
max_budget_alert_emails: { "50": [DISTRIBUTION_LIST] },
});
await page.getByRole("button", { name: "Edit Settings" }).click();
await expect(page.getByPlaceholder("% of budget")).toHaveValue("50");
await page
.getByRole("button", { name: "Remove budget alert threshold" })
.click();
await page.getByRole("button", { name: "Save Changes" }).click();
await expect
.poll(
async () =>
"max_budget_alert_emails" in
(await keyMetadata(page, created.token)),
{ timeout: 20_000 },
)
.toBe(false);
expect(await keyMetadata(page, created.token)).toMatchObject({
cost_center: "ops",
});
} finally {
await deleteVirtualKey(page.request, created.token);
}
});
});

View file

@ -186,7 +186,7 @@ def test_image_admin_mcp_personal_admin_manages_team(offline_postgres: tuple[str
"--env", "HOME=/opt/prisma", "--env", "XDG_CACHE_HOME=/opt/prisma/.cache",
"--env", "PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries",
"--entrypoint", "prisma", SCHEMA_IMAGE,
"db", "push", "--schema", "/app/schema.prisma", "--skip-generate", "--accept-data-loss",
"db", "push", "--schema", "/app/litellm/schema.prisma", "--skip-generate", "--accept-data-loss",
],
capture_output=True, text=True, timeout=180, check=False,
)

View file

@ -0,0 +1,74 @@
import uuid
from pathlib import Path
from typing import Final
import pytest
import yaml
from integration._support.client import Gateway, eventually
from integration._support.database import read_rows
from integration._support.mail import smtp_sink
from integration._support.process import owned_proxy
pytestmark: Final = pytest.mark.shares_worker_state
KEY_BUDGET: Final = 0.10
CALL_COST: Final = 20 * 0.001 + 20 * 0.002
def _key_spend(key: str) -> float:
rows: Final = read_rows('SELECT spend FROM "LiteLLM_VerificationToken" WHERE key_alias = %s', (key,))
return float(str(rows[0]["spend"])) if rows else 0.0
def test_key_threshold_emails_owner_and_shared_mailbox_from_admin_ui_metadata(gateway: Gateway, tmp_path: Path) -> None:
owner_email: Final = f"owner-{uuid.uuid4().hex}@integration.test"
shared_mailbox: Final = f"shared-mailbox-{uuid.uuid4().hex}@integration.test"
alias: Final = f"alert-key-{uuid.uuid4().hex}"
configuration: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
configuration["general_settings"]["alerting"] = ["email"]
configuration["general_settings"]["user_api_key_cache_ttl"] = 1
path: Final = tmp_path / "email-alerting.yaml"
path.write_text(yaml.safe_dump(configuration))
with smtp_sink() as mailbox:
overrides: Final = {
"SMTP_HOST": mailbox.host,
"SMTP_PORT": str(mailbox.port),
"SMTP_TLS": "False",
"SMTP_SENDER_EMAIL": "alerts@integration.test",
}
with owned_proxy(gateway, tmp_path, overrides, config=path) as candidate, candidate.scenario() as scenario:
model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002)
user_id: Final = scenario.user(user_email=owner_email)
key: Final = scenario.key(
key_alias=alias,
user_id=user_id,
models=[model],
max_budget=KEY_BUDGET,
metadata={"cost_center": "ops", "max_budget_alert_emails": {"50": [shared_mailbox]}},
)
first: Final = candidate.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": "first call"}]},
key=key,
)
assert first.status_code == 200, first.text
assert float(first.headers["x-litellm-response-cost"]) == pytest.approx(CALL_COST)
eventually(lambda: _key_spend(alias), lambda spend: spend == pytest.approx(CALL_COST), seconds=70)
assert mailbox.deliveries() == (), "no threshold is reached before the first call is recorded"
second: Final = candidate.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": "second call"}]},
key=key,
)
assert second.status_code == 200, second.text
halfway: Final = eventually(mailbox.deliveries, lambda found: len(found) >= 2, seconds=30)
assert {recipient for delivery in halfway for recipient in delivery.recipients} == {
owner_email,
shared_mailbox,
}, halfway
assert all("50%" in delivery.subject for delivery in halfway), halfway
assert len(halfway) == 2, halfway

View file

@ -0,0 +1,103 @@
from collections.abc import Mapping
from dataclasses import dataclass, replace
from datetime import datetime, timezone
from typing import Final, cast
import pytest
from pydantic import TypeAdapter
from litellm.accounting.application import _apply_customer_usage
from litellm.accounting.delivery_projection import project_deliveries
from litellm.accounting.projection import Contribution
from litellm.proxy.common_utils.customer_usage import CUSTOMER_USAGE_METADATA_KEY, CustomerUsageAttribution
from litellm.repositories.accounting_delivery_repository import DeliveryProjection, LedgerTransaction
_FIELDS: Final = TypeAdapter(Mapping[str, object])
_TEXT: Final = TypeAdapter(str)
_UPDATES: Final = TypeAdapter(Mapping[str, Mapping[str, float]])
_IDENTIFIER: Final = "\U0001f680" * 256
_ATTRIBUTION: Final = CustomerUsageAttribution(
customer_org_id=_IDENTIFIER, customer_team_id=_IDENTIFIER, customer_member_id=_IDENTIFIER
)
@dataclass(frozen=True, slots=True)
class Bucket:
values: Mapping[str, object]
counters: Mapping[str, float]
class Buckets:
def __init__(self) -> None:
self.rows: Mapping[str, Bucket] = {}
async def upsert(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> None:
row_id: Final = _TEXT.validate_python(where["id"])
create: Final = _FIELDS.validate_python(data["create"])
previous: Final = self.rows.get(row_id)
increments: Final = (
{name: fields["increment"] for name, fields in _UPDATES.validate_python(data["update"]).items()}
if previous is not None
else {name: float(value) for name, value in create.items() if isinstance(value, (int, float))}
)
self.rows = {
**self.rows,
row_id: Bucket(
values=previous.values if previous is not None else create,
counters={
name: (previous.counters.get(name, 0) if previous is not None else 0) + value
for name, value in increments.items()
},
),
}
@dataclass(frozen=True, slots=True)
class Transaction:
litellm_dailycustomermemberspend: Buckets
def contribution(alias: str, provider: str, spend: float) -> Contribution:
delivery: Final = DeliveryProjection(
{
"request_id": "long-customer-id-request",
"startTime": datetime(2026, 1, 1, tzinfo=timezone.utc).isoformat(),
"api_key": "hashed-key",
"model": "shared-model",
"model_group": alias,
"custom_llm_provider": provider,
"spend": spend,
"prompt_tokens": 10,
"completion_tokens": 5,
"metadata": {CUSTOMER_USAGE_METADATA_KEY: _ATTRIBUTION.model_dump_json()},
}
)
return next(item for item in project_deliveries((delivery,)) if item.table == "LiteLLM_DailyCustomerMemberSpend")
@pytest.mark.asyncio
async def test_long_customer_ids_roll_up_without_truncation_and_keep_alias_provider_buckets_distinct() -> None:
buckets: Final = Buckets()
transaction: Final = cast(LedgerTransaction, Transaction(buckets)) # cast-ok: in-memory Prisma upsert boundary
first: Final = contribution("alias-a", "provider-a", 1)
second: Final = replace(contribution("alias-a", "provider-a", 2), key=tuple(reversed(first.key)))
await _apply_customer_usage(transaction, (first,))
await _apply_customer_usage(transaction, (second, contribution("alias-b", "provider-a", 4)))
await _apply_customer_usage(transaction, (contribution("alias-a", "provider-b", 8),))
assert len(buckets.rows) == 3
assert all(len(row_id.encode("utf-8")) == 64 for row_id in buckets.rows)
assert all(
row.values["customer_org_id"]
== row.values["customer_team_id"]
== row.values["customer_member_id"]
== _IDENTIFIER
for row in buckets.rows.values()
)
assert {
(row.values["model_group"], row.values["custom_llm_provider"]): row.counters["spend"]
for row in buckets.rows.values()
} == {("alias-a", "provider-a"): 3, ("alias-b", "provider-a"): 4, ("alias-a", "provider-b"): 8}
assert sum(row.counters["api_requests"] for row in buckets.rows.values()) == 4
assert sum(row.counters["prompt_tokens"] + row.counters["completion_tokens"] for row in buckets.rows.values()) == 60

View file

@ -6,20 +6,120 @@ from typing import Final
import pytest
from pydantic import TypeAdapter
from litellm.accounting.delivery_projection import project_deliveries
from litellm.accounting.projection import project
from litellm.accounting.snapshot import build_accounting_payload
from litellm.constants import BILLED_RETRY_ATTEMPT_METADATA_KEY, INTERNAL_CALL_ORIGIN_METADATA_KEY
from litellm.proxy._types import SpendLogsPayload
from litellm.proxy.common_utils.customer_usage import CUSTOMER_USAGE_METADATA_KEY, CustomerUsageAttribution
from litellm.proxy.db.db_spend_update_writer import DBSpendUpdateWriter
from litellm.proxy.proxy_server import proxy_logging_obj
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
from litellm.proxy.utils import PrismaClient
from litellm.repositories.accounting_delivery_repository import DeliveryProjection
from litellm.types.utils import ModelResponse, Usage
_NOW: Final = datetime(2026, 11, 2, tzinfo=timezone.utc)
_FIELDS: Final = TypeAdapter(dict[str, object])
def test_customer_model_aliases_and_providers_keep_separate_daily_totals() -> None:
stamp: Final = CustomerUsageAttribution(customer_org_id="org-1", customer_member_id="member-1").model_dump_json()
base: Final = {
"startTime": _NOW,
"api_key": "hashed-key",
"model": "shared-model",
"metadata": {CUSTOMER_USAGE_METADATA_KEY: stamp},
}
deliveries: Final = tuple(
DeliveryProjection({**base, "model_group": alias, "custom_llm_provider": provider, "spend": spend})
for alias, provider, spend in (
("alias-a", "provider-a", 1),
("alias-b", "provider-a", 2),
("alias-a", "provider-b", 3),
)
)
totals: Final = {
(dict(item.key)["model_group"], dict(item.key)["custom_llm_provider"]): dict(item.increments)["spend"]
for item in project_deliveries(deliveries)
if item.table == "LiteLLM_DailyCustomerMemberSpend"
}
assert totals == {("alias-a", "provider-a"): 1, ("alias-b", "provider-a"): 2, ("alias-a", "provider-b"): 3}
@pytest.mark.parametrize("retry", [False, True])
def test_customer_usage_survives_logging_and_accounting_with_correct_request_counts(retry: bool) -> None:
attribution: Final = CustomerUsageAttribution(
customer_org_id="org-1", customer_team_id="team-1", customer_member_id="member-1"
)
payload: Final = get_logging_payload(
kwargs={
"model": "usage-model",
"custom_llm_provider": "openai",
"call_type": "acompletion",
"litellm_call_id": "usage-request",
"litellm_params": {
"metadata": {
"user_api_key": "hashed-key",
"status": "success",
CUSTOMER_USAGE_METADATA_KEY: attribution.model_dump_json(),
**({BILLED_RETRY_ATTEMPT_METADATA_KEY: True} if retry else {}),
}
},
},
response_obj=ModelResponse(
id="usage-request", model="usage-model", usage=Usage(prompt_tokens=10, completion_tokens=5)
),
start_time=_NOW,
end_time=_NOW,
)
accounting: Final = build_accounting_payload(
{**payload, "spend": 0.75}, entities={}, model_access_groups=(), windows=(), router=lambda: None
)
contributions: Final = project(accounting)
usage: Final = next(item for item in contributions if item.table == "LiteLLM_DailyCustomerMemberSpend")
assert dict(usage.key) == {
"date": _NOW.date().isoformat(),
"customer_org_id": attribution.customer_org_id,
"customer_team_id": attribution.customer_team_id,
"customer_member_id": attribution.customer_member_id,
"model": payload["model"],
"model_group": payload["model_group"],
"custom_llm_provider": payload["custom_llm_provider"],
"api_key": payload["api_key"],
"mcp_namespaced_tool_name": "",
"endpoint": "/chat/completions",
}
assert dict(usage.increments) == {
"spend": 0.75,
"prompt_tokens": 10,
"completion_tokens": 5,
"api_requests": 0 if retry else 1,
"successful_requests": 0 if retry else 1,
"failed_requests": 0,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
"compression_saved_tokens": 0,
"compression_savings_spend": 0,
"prompt_caching_savings_spend": 0,
"gateway_injected_caching_savings_spend": 0,
"autorouter_savings_spend": 0,
"guardrail_spend": 0,
"total_response_time_ms": 0,
"timed_requests": 1,
}
assert {
item.table: dict(item.increments)["spend"]
for item in contributions
if item.table
in ("LiteLLM_CustomerOrganizationTable", "LiteLLM_CustomerTeamTable", "LiteLLM_CustomerMemberTable")
} == {
"LiteLLM_CustomerOrganizationTable": 0.75,
"LiteLLM_CustomerTeamTable": 0.75,
"LiteLLM_CustomerMemberTable": 0.75,
}
@pytest.mark.parametrize("status", ["success", "failure"])
@pytest.mark.parametrize("origin", [None, "auto_router", "billed_retry"])
@pytest.mark.parametrize("spend", [0.0, 0.25])

View file

@ -1,5 +1,9 @@
import json
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import partial
from typing import Final, Literal
from unittest.mock import AsyncMock, MagicMock
@ -10,11 +14,18 @@ from fakeredis.aioredis import FakeRedis
from pydantic import TypeAdapter
from litellm import in_memory_llm_clients_cache
from litellm.accounting.application import apply_deliveries
from litellm.accounting.worker import flush_accounting_deliveries
from litellm.caching.redis_cache import RedisCache
from litellm.proxy.common_utils.customer_usage import CUSTOMER_USAGE_METADATA_KEY, CustomerUsageAttribution
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.db.db_spend_update_writer import DBSpendUpdateWriter
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.repositories.accounting_delivery_repository import (
AccountingDeliveryRepository,
DeliveryProjection,
LedgerTransaction,
)
_FIELDS: Final = TypeAdapter(dict[str, object])
_INCREMENTS: Final = TypeAdapter(dict[str, dict[str, float]])
@ -30,7 +41,7 @@ class _Ledger:
spend: float = 0.0
attempts: int = 0
async def query(self, query: str, *args: object) -> tuple[dict[str, object], ...]:
async def query(self, query: str, *args: object) -> tuple[Mapping[str, object], ...]:
if query.startswith("SELECT delivery_id, attempts"):
if not self.pending or self.attempts or args[1] is True:
return ()
@ -96,3 +107,119 @@ async def test_delivery_charges_the_creating_key_despite_an_orphaned_maintenance
in_memory_llm_clients_cache.delete_cache(key=cache_key)
await redis.aclose()
sync_redis.close()
class _CustomerLedger:
def __init__(self, fail_acknowledgement: bool) -> None:
self.pending = True
self.attempts = 0
self.fail_acknowledgement = fail_acknowledgement
self.daily: Mapping[str, int | float] = {}
self.entities: Mapping[str, float] = {}
async def query(self, query: str, *args: object) -> tuple[Mapping[str, object], ...]:
if query.startswith("SELECT delivery_id, attempts"):
if not self.pending or (self.attempts > 0) is not args[1]:
return ()
return (
{
"delivery_id": "customer-delivery",
"attempts": self.attempts,
"accounting_payload": _CUSTOMER_PAYLOAD,
},
)
if "SET attempts = attempts + 1" in query:
self.attempts += 1
return ({"delivery_id": "customer-delivery", "attempts": self.attempts},)
return ()
async def execute(self, query: str, *args: object) -> int:
if "SET state = 'applied'" not in query:
return 0
if self.fail_acknowledgement:
self.fail_acknowledgement = False
raise OSError("database connection lost before delivery acknowledgement")
self.pending = False
return 1
def upsert_daily(self, *, where: object, data: object) -> None:
values: Final = _FIELDS.validate_python(_FIELDS.validate_python(data)["create"])
counters: Final = {name: value for name, value in values.items() if isinstance(value, (int, float))}
self.daily = {name: self.daily.get(name, 0) + value for name, value in counters.items()}
def update_entity(self, entity: str, *, where: object, data: object) -> int:
spend: Final = _INCREMENTS.validate_python(data)["spend"]["increment"]
self.entities = {**self.entities, entity: self.entities.get(entity, 0.0) + spend}
return 1
_CUSTOMER_PAYLOAD: Final = json.dumps(
{
"request_id": "customer-completed-request",
"startTime": datetime(2026, 1, 1, tzinfo=timezone.utc).isoformat(),
"api_key": "hashed-key",
"spend": 0.75,
"prompt_tokens": 10,
"completion_tokens": 5,
"metadata": {
CUSTOMER_USAGE_METADATA_KEY: CustomerUsageAttribution(
customer_org_id="customer-org", customer_team_id="customer-team", customer_member_id="customer-member"
).model_dump_json()
},
}
)
@pytest.mark.asyncio
@pytest.mark.parametrize("fail_acknowledgement", (False, True))
async def test_customer_daily_and_lifetime_totals_commit_once_after_delivery_replay(
fail_acknowledgement: bool,
) -> None:
ledger: Final = _CustomerLedger(fail_acknowledgement)
transaction: Final = MagicMock()
transaction.query_raw = AsyncMock(side_effect=ledger.query)
transaction.execute_raw = AsyncMock(side_effect=ledger.execute)
transaction.litellm_dailycustomermemberspend.upsert = AsyncMock(side_effect=ledger.upsert_daily)
transaction.litellm_customerorganizationtable.update_many = AsyncMock(
side_effect=partial(ledger.update_entity, "organization")
)
transaction.litellm_customerteamtable.update_many = AsyncMock(side_effect=partial(ledger.update_entity, "team"))
transaction.litellm_customermembertable.update_many = AsyncMock(side_effect=partial(ledger.update_entity, "member"))
transaction.batch_.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
transaction.batch_.return_value.__aexit__ = AsyncMock(return_value=False)
@asynccontextmanager
async def database_transaction(*, timeout: object) -> AsyncIterator[LedgerTransaction]:
before: Final = (ledger.pending, ledger.daily, ledger.entities)
try:
yield transaction
except Exception:
ledger.pending, ledger.daily, ledger.entities = before
raise
database: Final = MagicMock(spec=PrismaClient)
database.tx.side_effect = database_transaction
repository: Final = AccountingDeliveryRepository(database)
writer: Final = DBSpendUpdateWriter(accounting_clock=lambda: 0.0)
logger: Final = ProxyLogging(user_api_key_cache=UserApiKeyCache())
async def apply(tx: LedgerTransaction, deliveries: tuple[DeliveryProjection, ...]) -> None:
await apply_deliveries(tx, deliveries, prisma_client=database, writer=writer, proxy_logging_obj=logger)
first: Final = await repository.apply_pending(apply)
if fail_acknowledgement:
assert first.failed == 1
assert ledger.daily == {}
assert ledger.entities == {}
retried: Final = await repository.apply_pending(apply)
assert retried.applied == 1
else:
assert first.applied == 1
await repository.apply_pending(apply)
await repository.apply_pending(apply)
assert ledger.daily["spend"] == 0.75
assert ledger.daily["prompt_tokens"] == 10
assert ledger.daily["completion_tokens"] == 5
assert ledger.daily["api_requests"] == 1
assert ledger.entities == {"organization": 0.75, "team": 0.75, "member": 0.75}
assert ledger.pending is False

View file

@ -11,7 +11,7 @@
"user": "",
"team_id": "",
"organization_id": "",
"metadata": "{\"actor_agent_id\": null, \"target_agent_id\": null, \"billing_agent_id\": null, \"agent_execution_mode\": null, \"verified_human_user_id\": null, \"used_client_oauth_token\": null, \"applied_guardrails\": [], \"attempted_fallbacks\": null, \"original_model_group\": null, \"batch_models\": null, \"batch_successful_requests\": null, \"batch_failed_requests\": null, \"mcp_tool_call_metadata\": null, \"vector_store_request_metadata\": null, \"routing_decision\": null, \"internal_call_origin\": null, \"litellm_roi_estimator\": false, \"billed_retry_attempt\": false, \"router_metadata\": null, \"autorouter_savings_estimate\": null, \"autorouter_baseline_observation\": null, \"azure_spillover\": null, \"guardrail_information\": null, \"compression_savings\": null, \"litellm_gateway_injected_cache\": null, \"usage_object\": {\"completion_tokens\": 20, \"prompt_tokens\": 10, \"total_tokens\": 30, \"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"model_map_information\": {\"model_map_key\": \"gpt-4o\", \"model_map_value\": {\"key\": \"gpt-4o\", \"max_tokens\": 16384, \"max_input_tokens\": 128000, \"max_output_tokens\": 16384, \"input_cost_per_token\": 2.5e-06, \"cache_creation_input_token_cost\": null, \"cache_read_input_token_cost\": 1.25e-06, \"input_cost_per_character\": null, \"input_cost_per_token_above_128k_tokens\": null, \"input_cost_per_token_above_200k_tokens\": null, \"input_cost_per_query\": null, \"input_cost_per_second\": null, \"input_cost_per_audio_token\": null, \"input_cost_per_token_batches\": 1.25e-06, \"output_cost_per_token_batches\": 5e-06, \"output_cost_per_token\": 1e-05, \"output_cost_per_audio_token\": null, \"output_cost_per_character\": null, \"output_cost_per_token_above_128k_tokens\": null, \"output_cost_per_character_above_128k_tokens\": null, \"output_cost_per_token_above_200k_tokens\": null, \"output_cost_per_second\": null, \"output_cost_per_image\": null, \"output_vector_size\": null, \"litellm_provider\": \"openai\", \"mode\": \"chat\", \"supports_system_messages\": true, \"supports_response_schema\": true, \"supports_vision\": true, \"supports_function_calling\": true, \"supports_tool_choice\": true, \"supports_assistant_prefill\": false, \"supports_prompt_caching\": true, \"supports_audio_input\": false, \"supports_audio_output\": false, \"supports_pdf_input\": false, \"supports_embedding_image_input\": false, \"supports_native_streaming\": null, \"supports_web_search\": true, \"supports_reasoning\": false, \"search_context_cost_per_query\": {\"search_context_size_low\": 0.03, \"search_context_size_medium\": 0.035, \"search_context_size_high\": 0.05}, \"tpm\": null, \"rpm\": null, \"supported_openai_params\": [\"frequency_penalty\", \"logit_bias\", \"logprobs\", \"top_logprobs\", \"max_tokens\", \"max_completion_tokens\", \"modalities\", \"prediction\", \"n\", \"presence_penalty\", \"seed\", \"stop\", \"stream\", \"stream_options\", \"temperature\", \"top_p\", \"tools\", \"tool_choice\", \"function_call\", \"functions\", \"max_retries\", \"extra_headers\", \"parallel_tool_calls\", \"audio\", \"response_format\", \"user\"]}}, \"additional_usage_values\": {\"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"user_api_key\": null, \"user_api_key_alias\": null, \"user_api_key_team_id\": null, \"user_api_key_project_id\": null, \"user_api_key_project_alias\": null, \"user_api_key_org_id\": null, \"user_api_key_user_id\": null, \"user_api_key_user_email\": null, \"user_api_key_team_alias\": null, \"spend_logs_metadata\": null, \"requester_ip_address\": null, \"user_agent\": null, \"status\": null, \"proxy_server_request\": null, \"error_information\": null, \"attempted_retries\": null, \"max_retries\": null, \"kubernetes_pod_routing\": null}",
"metadata": "{\"actor_agent_id\": null, \"target_agent_id\": null, \"billing_agent_id\": null, \"agent_execution_mode\": null, \"verified_human_user_id\": null, \"used_client_oauth_token\": null, \"applied_guardrails\": [], \"attempted_fallbacks\": null, \"original_model_group\": null, \"batch_models\": null, \"batch_successful_requests\": null, \"batch_failed_requests\": null, \"mcp_tool_call_metadata\": null, \"vector_store_request_metadata\": null, \"routing_decision\": null, \"internal_call_origin\": null, \"litellm_roi_estimator\": false, \"billed_retry_attempt\": false, \"router_metadata\": null, \"autorouter_savings_estimate\": null, \"autorouter_baseline_observation\": null, \"azure_spillover\": null, \"guardrail_information\": null, \"compression_savings\": null, \"litellm_gateway_injected_cache\": null, \"usage_object\": {\"completion_tokens\": 20, \"prompt_tokens\": 10, \"total_tokens\": 30, \"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"model_map_information\": {\"model_map_key\": \"gpt-4o\", \"model_map_value\": {\"key\": \"gpt-4o\", \"max_tokens\": 16384, \"max_input_tokens\": 128000, \"max_output_tokens\": 16384, \"input_cost_per_token\": 2.5e-06, \"cache_creation_input_token_cost\": null, \"cache_read_input_token_cost\": 1.25e-06, \"input_cost_per_character\": null, \"input_cost_per_token_above_128k_tokens\": null, \"input_cost_per_token_above_200k_tokens\": null, \"input_cost_per_query\": null, \"input_cost_per_second\": null, \"input_cost_per_audio_token\": null, \"input_cost_per_token_batches\": 1.25e-06, \"output_cost_per_token_batches\": 5e-06, \"output_cost_per_token\": 1e-05, \"output_cost_per_audio_token\": null, \"output_cost_per_character\": null, \"output_cost_per_token_above_128k_tokens\": null, \"output_cost_per_character_above_128k_tokens\": null, \"output_cost_per_token_above_200k_tokens\": null, \"output_cost_per_second\": null, \"output_cost_per_image\": null, \"output_vector_size\": null, \"litellm_provider\": \"openai\", \"mode\": \"chat\", \"supports_system_messages\": true, \"supports_response_schema\": true, \"supports_vision\": true, \"supports_function_calling\": true, \"supports_tool_choice\": true, \"supports_assistant_prefill\": false, \"supports_prompt_caching\": true, \"supports_audio_input\": false, \"supports_audio_output\": false, \"supports_pdf_input\": false, \"supports_embedding_image_input\": false, \"supports_native_streaming\": null, \"supports_web_search\": true, \"supports_reasoning\": false, \"search_context_cost_per_query\": {\"search_context_size_low\": 0.03, \"search_context_size_medium\": 0.035, \"search_context_size_high\": 0.05}, \"tpm\": null, \"rpm\": null, \"supported_openai_params\": [\"frequency_penalty\", \"logit_bias\", \"logprobs\", \"top_logprobs\", \"max_tokens\", \"max_completion_tokens\", \"modalities\", \"prediction\", \"n\", \"presence_penalty\", \"seed\", \"stop\", \"stream\", \"stream_options\", \"temperature\", \"top_p\", \"tools\", \"tool_choice\", \"function_call\", \"functions\", \"max_retries\", \"extra_headers\", \"parallel_tool_calls\", \"audio\", \"response_format\", \"user\"]}}, \"additional_usage_values\": {\"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"user_api_key\": null, \"user_api_key_alias\": null, \"user_api_key_team_id\": null, \"user_api_key_project_id\": null, \"user_api_key_project_alias\": null, \"user_api_key_org_id\": null, \"user_api_key_customer_usage\": null, \"user_api_key_user_id\": null, \"user_api_key_user_email\": null, \"user_api_key_team_alias\": null, \"spend_logs_metadata\": null, \"requester_ip_address\": null, \"user_agent\": null, \"status\": null, \"proxy_server_request\": null, \"error_information\": null, \"attempted_retries\": null, \"max_retries\": null, \"kubernetes_pod_routing\": null}",
"cache_key": "Cache OFF",
"spend": 0.00022500000000000002,
"total_tokens": 30,

View file

@ -2,9 +2,6 @@
"name": "litellm-acompletion",
"parent_span_id": null,
"attributes": {
"langfuse.observation.cost_details": {
"total": 6e-05
},
"langfuse.observation.input": {
"messages": [
{

View file

@ -97,7 +97,9 @@ def _comparable(span: Mapping[str, object]) -> dict[str, object]:
}
def _expected_generation(route: respx.Route, fixture_name: str, trace_id: str) -> dict[str, object]:
def _expected_generation(
route: respx.Route, fixture_name: str, trace_id: str, cost_total: float | None = None
) -> dict[str, object]:
otel_trace_id: Final = resolve_trace_id(trace_id)
generations: Final = tuple(
span
@ -108,7 +110,14 @@ def _expected_generation(route: respx.Route, fixture_name: str, trace_id: str) -
)
assert len(generations) == 1
expected_path: Final = Path(__file__).with_name("langfuse_expected_request_body") / fixture_name
expected: Final = TypeAdapter(dict[str, object]).validate_python(json.loads(expected_path.read_text()))
fixture: Final = TypeAdapter(dict[str, object]).validate_python(json.loads(expected_path.read_text()))
fixture_attributes: Final = TypeAdapter(dict[str, object]).validate_python(fixture["attributes"])
priced_attributes: Final = (
fixture_attributes
if cost_total is None
else {**fixture_attributes, "langfuse.observation.cost_details": {"total": cost_total}}
)
expected: Final = {**fixture, "attributes": priced_attributes}
actual: Final = _comparable(generations[0])
expected_comparable: Final = _comparable(expected)
assert actual == expected_comparable, json.dumps(
@ -355,6 +364,12 @@ async def test_langfuse_logging_completion_with_bedrock_llm_response(
langfuse_export: tuple[respx.Route, str],
) -> None:
route, trace_id = langfuse_export
profile: Final = litellm.get_model_info(
model="us.anthropic.claude-haiku-4-5-20251001-v1:0", custom_llm_provider="bedrock"
)
input_rate: Final = profile["input_cost_per_token"]
output_rate: Final = profile["output_cost_per_token"]
assert isinstance(input_rate, float) and isinstance(output_rate, float)
response: Final = litellm.ModelResponse(
choices=[],
usage=litellm.Usage(prompt_tokens=10, completion_tokens=10, total_tokens=20),
@ -373,7 +388,9 @@ async def test_langfuse_logging_completion_with_bedrock_llm_response(
)
await _flush_langfuse()
assert route.call_count == 1
_expected_generation(route, "completion_with_bedrock_call.json", trace_id)
_expected_generation(
route, "completion_with_bedrock_call.json", trace_id, cost_total=10 * input_rate + 10 * output_rate
)
@pytest.mark.asyncio

View file

@ -106,3 +106,4 @@ def test_image_embedding(case: _Case, respx_mock: MockRouter) -> None:
else:
assert body["inputImage"] == png_b64
assert response.data[0]["embedding"] == [0.11, 0.22, 0.33]

View file

@ -190,6 +190,34 @@ def test_bedrock_cohere_embedding_returns_the_scripted_vector(respx_mock: respx.
assert response.data == [{"object": "embedding", "index": 0, "embedding": [0.3, 0.2, 0.1]}]
def test_bedrock_cohere_embedding_sends_max_tokens_and_priority(respx_mock: respx.MockRouter) -> None:
route = respx_mock.post("https://bedrock-runtime.us-east-1.amazonaws.com/model/cohere.embed-v4:0/invoke").mock(
return_value=httpx.Response(
200,
json={"embeddings": {"float": [[0.3, 0.2, 0.1]]}, "response_type": "embeddings_by_type"},
)
)
litellm.embedding(
model="bedrock/cohere.embed-v4:0",
input=["hello"],
api_key="test-bearer-token",
aws_region_name="us-east-1",
aws_bedrock_runtime_endpoint="https://bedrock-runtime.us-east-1.amazonaws.com",
max_tokens=16,
priority=5,
truncate="END",
)
assert json.loads(route.calls.last.request.read()) == {
"input_type": "search_document",
"texts": ["hello"],
"max_tokens": 16,
"priority": 5,
"truncate": "END",
}
GOVCLOUD_WEST_RUNTIME: Final = "https://bedrock-runtime.us-gov-west-1.amazonaws.com"
@ -2130,3 +2158,122 @@ def test_bedrock_embedding_region_bug_reproduction():
img_base_64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkBAMAAACCzIhnAAAAG1BMVEURAAD///+ln5/h39/Dv79qX18uHx+If39MPz9oMSdmAAAACXBIWXMAAA7EAAAOxAGVKw4bAAABB0lEQVRYhe2SzWrEIBCAh2A0jxEs4j6GLDS9hqWmV5Flt0cJS+lRwv742DXpEjY1kOZW6HwHFZnPmVEBEARBEARB/jd0KYA/bcUYbPrRLh6amXHJ/K+ypMoyUaGthILzw0l+xI0jsO7ZcmCcm4ILd+QuVYgpHOmDmz6jBeJImdcUCmeBqQpuqRIbVmQsLCrAalrGpfoEqEogqbLTWuXCPCo+Ki1XGqgQ+jVVuhB8bOaHkvmYuzm/b0KYLWwoK58oFqi6XfxQ4Uz7d6WeKpna6ytUs5e8betMcqAv5YPC5EZB2Lm9FIn0/VP6R58+/GEY1X1egVoZ/3bt/EqF6malgSAIgiDIH+QL41409QMY0LMAAAAASUVORK5CYII="
@pytest.mark.parametrize(
("model_id", "embed_response", "extra_body", "expected_body"),
[
(
"amazon.titan-embed-text-v2:0",
titan_embedding_response,
{"normalize": False, "dimensions": 256},
{"inputText": test_input, "normalize": False, "dimensions": 256},
),
(
"cohere.embed-english-v3",
cohere_embedding_response,
{"truncate": "END", "embedding_types": ["float"]},
{"texts": [test_input], "truncate": "END", "embedding_types": ["float"]},
),
],
)
def test_bedrock_embedding_sends_extra_body_fields_in_the_request_body(
respx_mock: respx.MockRouter,
model_id: str,
embed_response: dict[str, object],
extra_body: dict[str, object],
expected_body: dict[str, object],
) -> None:
route = respx_mock.post(f"https://bedrock-runtime.us-east-1.amazonaws.com/model/{model_id}/invoke").mock(
return_value=httpx.Response(200, json=embed_response)
)
litellm.embedding(
model=f"bedrock/{model_id}",
input=[test_input],
api_key="test-bearer-token",
aws_region_name="us-east-1",
extra_body=extra_body,
)
body = json.loads(route.calls.last.request.read())
assert "extra_body" not in body
assert expected_body.items() <= body.items()
def test_bedrock_nova_embedding_sends_extra_body_fields_in_the_request_body(respx_mock: respx.MockRouter) -> None:
model_id: Final = "amazon.nova-2-multimodal-embeddings-v1:0"
route = respx_mock.post(f"https://bedrock-runtime.us-east-1.amazonaws.com/model/{model_id}/invoke").mock(
return_value=httpx.Response(200, json={"embeddings": [{"embeddingType": "TEXT", "embedding": [0.1, 0.2]}]})
)
litellm.embedding(
model=f"bedrock/{model_id}",
input=[test_input],
api_key="test-bearer-token",
aws_region_name="us-east-1",
extra_body={"segmentationConfig": {"durationSeconds": 10}},
)
body = json.loads(route.calls.last.request.read())
assert "extra_body" not in body
assert body["segmentationConfig"] == {"durationSeconds": 10}
@pytest.mark.parametrize(
("extra_body", "field", "expected"),
[
({"dimensions": 512}, "dimensions", 256),
({"inputText": "text the guardrails never saw"}, "inputText", test_input),
],
ids=["mapped_param", "input"],
)
def test_bedrock_embedding_extra_body_cannot_replace_the_input_or_a_mapped_param(
respx_mock: respx.MockRouter, extra_body: dict[str, object], field: str, expected: object
) -> None:
route = respx_mock.post(
"https://bedrock-runtime.us-east-1.amazonaws.com/model/amazon.titan-embed-text-v2:0/invoke"
).mock(return_value=httpx.Response(200, json=titan_embedding_response))
litellm.embedding(
model="bedrock/amazon.titan-embed-text-v2:0",
input=[test_input],
api_key="test-bearer-token",
aws_region_name="us-east-1",
dimensions=256,
extra_body=extra_body,
)
assert json.loads(route.calls.last.request.read())[field] == expected
def test_bedrock_embedding_signs_the_body_it_sends(respx_mock: respx.MockRouter) -> None:
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.credentials import Credentials
route = respx_mock.post(
"https://bedrock-runtime.us-east-1.amazonaws.com/model/amazon.titan-embed-text-v2:0/invoke"
).mock(return_value=httpx.Response(200, json=titan_embedding_response))
litellm.embedding(
model="bedrock/amazon.titan-embed-text-v2:0",
input=[test_input],
aws_access_key_id="AKIAIOSFODNN7EXAMPLE",
aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
aws_region_name="us-east-1",
dimensions=256,
extra_body={"dimensions": 512},
)
sent: Final = route.calls.last.request
replay: Final = AWSRequest(
method="POST", url=str(sent.url), data=sent.content, headers={"Content-Type": "application/json"}
)
replay.context["timestamp"] = sent.headers["x-amz-date"]
SigV4Auth(
Credentials("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"), "bedrock", "us-east-1"
).add_auth(replay)
assert json.loads(sent.content)["dimensions"] == 256
assert replay.headers["Authorization"] == sent.headers["authorization"]

View file

@ -237,3 +237,28 @@ def test_completion_nvidia_nim():
assert request_body["model"] == "databricks/dbrx-instruct"
assert request_body["frequency_penalty"] == 0.1
assert request_body["presence_penalty"] == 0.5
def test_embedding_nvidia_nim_sends_extra_body_fields_without_an_extra_body_key():
from openai import OpenAI
transport: Final = CapturingTransport(
CreateEmbeddingResponse(
object="list",
data=(Embedding(object="embedding", index=0, embedding=(0.1, 0.2, 0.3)),),
model="nvidia/nv-embedqa-e5-v5",
usage=EmbeddingUsage(prompt_tokens=6, total_tokens=6),
)
)
client: Final = OpenAI(api_key="fake-api-key", http_client=httpx.Client(transport=transport))
litellm.embedding(
model="nvidia_nim/nvidia/nv-embedqa-e5-v5",
input="What is the meaning of life?",
extra_body={"input_type": "query", "truncate": "END"},
client=client,
)
request_body: Final = transport.request_bodies[0]
assert "extra_body" not in request_body
assert (request_body["input_type"], request_body["truncate"]) == ("query", "END")

View file

@ -348,3 +348,21 @@ async def test_sdk_embedding_rejects_non_string_batch(embedding_route: respx.Rou
litellm.embedding(model="ollama/nomic-embed-text", input=[123], api_base="http://ollama.test")
assert not embedding_route.called
def test_ollama_embeddings_sends_extra_body_fields_at_the_documented_places(embedding_route: respx.Route) -> None:
response: Final = litellm.embedding(
model="ollama/nomic-embed-text",
input=["hello", "world"],
api_base="http://ollama.test",
extra_body={"truncate": False, "keep_alive": "5m", "options": {"num_ctx": 2048}},
)
assert json.loads(embedding_route.calls.last.request.content) == {
"model": "nomic-embed-text",
"input": ["hello", "world"],
"truncate": False,
"keep_alive": "5m",
"options": {"num_ctx": 2048},
}
assert response.data[0]["embedding"] == [0.1, 0.2]

View file

@ -7,6 +7,7 @@ transformation, and model type detection.
"""
import json
from typing import Final
from unittest.mock import MagicMock, patch
import httpx
@ -105,6 +106,21 @@ class TestSagemakerCohereEmbeddingConfig:
)
assert result["texts"] == ["hello"]
def test_transform_request_keeps_max_tokens_and_priority(self):
result: Final = self.config.transform_embedding_request(
model="cohere-embed-v4",
input=["hello"],
optional_params={"max_tokens": 16, "priority": 5, "truncate": "END"},
headers={},
)
assert result == {
"texts": ["hello"],
"input_type": "search_document",
"max_tokens": 16,
"priority": 5,
"truncate": "END",
}
def test_map_openai_params_dimensions_to_output_dimension(self):
params = self.config.map_openai_params(
non_default_params={"dimensions": 512, "encoding_format": "float"},
@ -626,3 +642,40 @@ class TestTEIEmbeddingResponse:
if __name__ == "__main__":
pytest.main([__file__])
def test_voyage_endpoint_sends_extra_body_fields_in_the_invoke_body():
client = MagicMock()
client.invoke_endpoint.return_value = {
"Body": MagicMock(
read=MagicMock(
return_value=json.dumps(
{
"data": [{"object": "embedding", "embedding": [0.1, 0.2], "index": 0}],
"usage": {"total_tokens": 3},
}
).encode()
)
)
}
session = MagicMock()
session.client.return_value = client
with patch("boto3.Session", return_value=session):
response = embedding(
model="sagemaker/voyage-3-5-endpoint",
input=["hello"],
aws_region_name="us-east-1",
aws_access_key_id="test-access-key",
aws_secret_access_key="test-secret-key",
extra_body={"input_type": "query", "truncation": False, "output_dimension": 512},
)
assert json.loads(client.invoke_endpoint.call_args.kwargs["Body"]) == {
"input": ["hello"],
"model": "voyage-3-5-endpoint",
"input_type": "query",
"truncation": False,
"output_dimension": 512,
}
assert response.data[0]["embedding"] == [0.1, 0.2]

View file

@ -652,3 +652,32 @@ def test_gemini_embedding(respx_mock: respx.MockRouter) -> None:
expected_prompt_tokens: Final = litellm.token_counter(model="gemini/gemini-embedding-001", text="Hello, world!")
assert response.usage.prompt_tokens == expected_prompt_tokens
assert response.usage.total_tokens == expected_prompt_tokens
def test_gemini_embedding_sends_extra_body_fields_on_each_request(respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.post(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:batchEmbedContents"
).mock(return_value=httpx.Response(200, json={"embeddings": [{"values": [0.1, 0.2]}]}))
litellm.embedding(
model="gemini/gemini-embedding-001",
input="Hello, world!",
api_key="gemini-test-key",
extra_body={
"title": "Greeting",
"taskType": "RETRIEVAL_DOCUMENT",
"outputDimensionality": 256,
"documentOcr": True,
},
)
assert json.loads(route.calls.last.request.content)["requests"] == [
{
"model": "models/gemini-embedding-001",
"content": {"parts": [{"text": "Hello, world!"}]},
"title": "Greeting",
"taskType": "RETRIEVAL_DOCUMENT",
"outputDimensionality": 256,
"documentOcr": True,
}
]

View file

@ -1,8 +1,13 @@
import json
from unittest.mock import AsyncMock, MagicMock, patch
import google.auth
import google.auth.credentials
import httpx
import pytest
import respx
import litellm
from litellm.llms.vertex_ai.multimodal_embeddings.transformation import (
@ -174,3 +179,30 @@ class TestVertexMultimodalEmbedding:
headers={},
)
assert "parameters" not in request
class _StaticGoogleCredentials(google.auth.credentials.Credentials):
def refresh(self, request: object) -> None:
self.token = "test-token"
def test_extra_body_fields_reach_the_multimodal_predict_body(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(google.auth, "default", lambda scopes: (_StaticGoogleCredentials(), "test-project"))
route = respx_mock.post(host="us-central1-aiplatform.googleapis.com").mock(
return_value=httpx.Response(200, json={"predictions": [{"textEmbedding": [0.1, 0.2]}]})
)
litellm.embedding(
model="vertex_ai/multimodalembedding@001",
input="Hello world",
vertex_project="test-project",
vertex_location="us-central1",
extra_body={"dimension": 128},
)
assert json.loads(route.calls.last.request.content) == {
"instances": [{"text": "Hello world"}],
"parameters": {"dimension": 128},
}

View file

@ -305,3 +305,20 @@ async def test_vertex_embedding_usage_carries_the_billable_character_count_googl
8,
character_count,
)
def test_vertex_embedding_call_sends_extra_body_fields_as_predict_parameters(predict_endpoint: respx.Route) -> None:
predict_endpoint.mock(return_value=_predict_response({"predictions": [_gecko_prediction([0.1, 2], 3)]}))
litellm.embedding(
model=f"vertex_ai/{GECKO}",
input=["hello"],
vertex_project=PUBLIC_CALL_PROJECT,
vertex_location="us-central1",
extra_body={"autoTruncate": False},
)
body: Final = json.loads(predict_endpoint.calls.last.request.content)
assert body["instances"] == [{"content": "hello"}]
assert "extra_body" not in body["parameters"]
assert body["parameters"]["autoTruncate"] is False

View file

@ -0,0 +1,225 @@
from collections.abc import Mapping
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Final, cast # noqa: TID251 # the fake exposes only the Prisma lookup methods exercised here
import pytest
from litellm.proxy.common_utils.customer_usage import (
CustomerUsageAttribution,
CustomerUsageDatabase,
CustomerUsageError,
CustomerUsageFailure,
resolve_customer_usage,
)
@dataclass(frozen=True, slots=True)
class Table:
identifier: str
rows: Mapping[str, Mapping[str, object]]
async def find_unique(self, where: Mapping[str, object]) -> Mapping[str, object] | None:
return self.rows.get(str(where[self.identifier]))
@dataclass(frozen=True, slots=True)
class Database:
litellm_customerorganizationtable: Table
litellm_customerteamtable: Table
litellm_customermembertable: Table
DATABASE: Final = cast( # cast-ok: the external Prisma fake implements the lookup subset used by attribution
CustomerUsageDatabase,
Database(
Table("customer_org_id", {"org-1": {}, "org-2": {}}),
Table("customer_team_id", {"team-1": {"parent_org_id": "org-1"}, "team-2": {"parent_org_id": "org-2"}}),
Table("customer_member_id", {"member-1": {"parent_team_id": "team-1"}}),
),
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("metadata", "expected"),
(
(
{"customer_member_id": "member-1"},
CustomerUsageAttribution(customer_org_id="org-1", customer_team_id="team-1", customer_member_id="member-1"),
),
({"customer_team_id": "team-1"}, CustomerUsageAttribution(customer_org_id="org-1", customer_team_id="team-1")),
({"customer_org_id": "org-1"}, CustomerUsageAttribution(customer_org_id="org-1")),
),
)
async def test_resolves_ancestors_without_requiring_redundant_request_ids(
metadata: Mapping[str, object], expected: CustomerUsageAttribution
) -> None:
actual: Final = await resolve_customer_usage(
metadata=metadata, key_metadata={}, administrator=True, database=DATABASE, ledger_enabled=True
)
assert actual == expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
("metadata", "key_metadata", "administrator", "expected"),
(
({"customer_member_id": "member-1", "customer_team_id": "team-2"}, {}, True, CustomerUsageError.MISMATCH),
({"customer_member_id": "member-1"}, {"customer_org_id": "org-2"}, False, CustomerUsageError.FORBIDDEN),
({"customer_team_id": "team-2", "customer_org_id": "org-1"}, {}, True, CustomerUsageError.MISMATCH),
({"customer_org_id": "org-2"}, {"customer_org_id": "org-1"}, False, CustomerUsageError.FORBIDDEN),
({"customer_org_id": "org-1"}, {}, False, CustomerUsageError.UNBOUND),
({"customer_member_id": "missing"}, {}, True, CustomerUsageError.NOT_FOUND),
({"customer_team_id": "missing"}, {}, True, CustomerUsageError.NOT_FOUND),
({"customer_org_id": "missing"}, {}, True, CustomerUsageError.NOT_FOUND),
({"customer_org_id": 123}, {}, True, CustomerUsageError.INVALID),
),
)
async def test_rejects_cross_organization_attribution_and_invalid_identifiers(
metadata: Mapping[str, object],
key_metadata: Mapping[str, object],
administrator: bool,
expected: CustomerUsageError,
) -> None:
actual: Final = await resolve_customer_usage(
metadata=metadata,
key_metadata=key_metadata,
administrator=administrator,
database=DATABASE,
ledger_enabled=True,
)
assert actual == CustomerUsageFailure(expected)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("metadata", "key_metadata"),
(
({"customer_member_id": "missing"}, {"customer_org_id": "org-1"}),
({"customer_member_id": "member-1"}, {"customer_org_id": "org-2"}),
(
{"customer_member_id": "member-1"},
{"customer_org_id": "org-2", "customer_team_id": "team-2"},
),
({"customer_team_id": "missing"}, {"customer_org_id": "org-1"}),
({"customer_team_id": "team-2"}, {"customer_org_id": "org-1"}),
({}, {"customer_org_id": "missing"}),
),
)
async def test_non_admin_cannot_distinguish_missing_and_other_organization_identifiers(
metadata: Mapping[str, object], key_metadata: Mapping[str, object]
) -> None:
actual: Final = await resolve_customer_usage(
metadata=metadata,
key_metadata=key_metadata,
administrator=False,
database=DATABASE,
ledger_enabled=True,
)
assert actual == CustomerUsageFailure(CustomerUsageError.FORBIDDEN)
@pytest.mark.asyncio
@pytest.mark.parametrize("administrator", (False, True))
async def test_invalid_identifier_types_keep_validation_errors(administrator: bool) -> None:
actual: Final = await resolve_customer_usage(
metadata={"customer_member_id": 123},
key_metadata={"customer_org_id": "org-1"},
administrator=administrator,
database=DATABASE,
ledger_enabled=True,
)
assert actual == CustomerUsageFailure(CustomerUsageError.INVALID)
@pytest.mark.asyncio
async def test_key_binding_tracks_requests_with_no_repeated_customer_metadata() -> None:
actual: Final = await resolve_customer_usage(
metadata={},
key_metadata={"customer_org_id": "org-1"},
administrator=False,
database=DATABASE,
ledger_enabled=True,
)
assert actual == CustomerUsageAttribution(customer_org_id="org-1")
@pytest.mark.asyncio
async def test_requires_accounting_before_accepting_a_tracked_request() -> None:
actual: Final = await resolve_customer_usage(
metadata={"customer_org_id": "org-1"},
key_metadata={},
administrator=True,
database=DATABASE,
ledger_enabled=False,
)
assert actual == CustomerUsageFailure(CustomerUsageError.UNAVAILABLE)
@pytest.mark.asyncio
async def test_untracked_requests_do_not_require_a_database_or_ledger() -> None:
actual: Final = await resolve_customer_usage(
metadata={}, key_metadata={}, administrator=False, database=None, ledger_enabled=False
)
assert actual is None
@pytest.mark.asyncio
async def test_untracked_request_enrichment_does_not_touch_customer_database_tables(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from fastapi import Request
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request
from litellm.proxy.proxy_server import ProxyConfig
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", SimpleNamespace(db=object()))
request: Final = Request(
{"type": "http", "path": "/v1/chat/completions", "method": "POST", "headers": (), "query_string": b""}
)
enriched: Final = await add_litellm_data_to_request(
data={"model": "usage-model", "metadata": {"application": "support"}},
request=request,
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
proxy_config=ProxyConfig(),
general_settings={},
)
assert enriched["metadata"]["application"] == "support"
@pytest.mark.asyncio
@pytest.mark.parametrize("path", ["/v1/chat/completions", "/v1/responses"])
async def test_request_enrichment_replaces_forged_stamps_and_preserves_valid_attribution(
monkeypatch: pytest.MonkeyPatch, path: str
) -> None:
from fastapi import Request
from pydantic import TypeAdapter
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.customer_usage import CUSTOMER_USAGE_METADATA_KEY
from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request, get_metadata_variable_name
from litellm.proxy.proxy_server import ProxyConfig
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", SimpleNamespace(db=DATABASE))
request: Final = Request({"type": "http", "path": path, "method": "POST", "headers": (), "query_string": b""})
metadata_name: Final = get_metadata_variable_name(request)
forged: Final = CustomerUsageAttribution(customer_org_id="org-2").model_dump_json()
metadata: Final = {"customer_member_id": "member-1", CUSTOMER_USAGE_METADATA_KEY: forged}
enriched: Final = await add_litellm_data_to_request(
data={
"model": "usage-model",
"metadata": {CUSTOMER_USAGE_METADATA_KEY: forged},
"litellm_metadata": {CUSTOMER_USAGE_METADATA_KEY: forged},
metadata_name: metadata,
},
request=request,
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", metadata={"customer_org_id": "org-1"}),
proxy_config=ProxyConfig(),
general_settings={},
)
stamp: Final = TypeAdapter(str).validate_python(enriched[metadata_name][CUSTOMER_USAGE_METADATA_KEY])
assert CustomerUsageAttribution.model_validate_json(stamp) == CustomerUsageAttribution(
customer_org_id="org-1", customer_team_id="team-1", customer_member_id="member-1"
)

View file

@ -3172,6 +3172,62 @@ def _held_to_borrow_cap(exc: ProxyRateLimitError, rate_limit_type: str, limit_se
), exc.detail
async def _cap_tokens_remaining(handler: _PROXY_DynamicRateLimitHandlerV3, auth: UserAPIKeyAuth) -> int:
share: Final = handler._borrowing_team_share("shared-model", auth)
assert share is not None and share.ceiling is not None
response: Final = await handler.v3_limiter.should_rate_limit(
[share.ceiling], read_only=True, skip_parallel_check=True
)
return next(s["limit_remaining"] for s in response["statuses"] if s["rate_limit_type"] == "tokens")
@pytest.mark.asyncio
async def test_a_capped_borrowers_idle_usage_counts_toward_its_cap_but_not_toward_its_share(
monkeypatch: pytest.MonkeyPatch,
):
handler = _borrowing_handler(monkeypatch, borrows=True)
await _check(handler, _capped_team_key(2), saturation=0.0)
await _settle_tokens(handler, 150)
assert await _cap_tokens_remaining(handler, _capped_team_key(2)) == 50
assert await _team_tokens_remaining(handler, _capped_team_key(2)) == 100
await _check(handler, _capped_team_key(2), saturation=0.5)
await _settle_tokens(handler, 30)
assert await _cap_tokens_remaining(handler, _capped_team_key(2)) == 20
assert await _team_tokens_remaining(handler, _capped_team_key(2)) == 70
@pytest.mark.asyncio
@pytest.mark.parametrize("saturation", [0.0, 0.5], ids=["idle", "saturated"])
async def test_a_capped_borrowers_reservation_is_sized_by_its_share_not_its_cap(
monkeypatch: pytest.MonkeyPatch, saturation: float
):
"""A prompt without max_tokens reserves a floor that scales with the limit it is sized against. Sizing it
against the cap would refuse a request its own share would let through, and would admit different
requests depending on whether the model happened to be saturated."""
handler: Final = _borrowing_handler(monkeypatch, borrows=True)
await _admit_at_the_hook(handler, _capped_team_key(2), tokens=160)
short_prompt_without_max_tokens: Final[dict[str, object]] = {"messages": [{"role": "user", "content": "hi"}]}
await _check(handler, _capped_team_key(2), saturation=saturation, data=short_prompt_without_max_tokens)
assert short_prompt_without_max_tokens["max_tokens"] == handler.v3_limiter.no_max_tokens_output_floor(100)
@pytest.mark.asyncio
async def test_a_capped_borrower_is_held_to_its_cap_while_idle_after_a_saturated_stretch(
monkeypatch: pytest.MonkeyPatch,
):
handler = _borrowing_handler(monkeypatch, borrows=True)
await _check(handler, _capped_team_key(2), saturation=0.0)
await _settle_tokens(handler, 150)
await _check(handler, _capped_team_key(2), saturation=0.5)
await _settle_tokens(handler, 60)
with pytest.raises(ProxyRateLimitError) as exc:
await _check(handler, _capped_team_key(2), saturation=0.49)
_held_to_borrow_cap(exc.value, "tokens", "model_tpm_limit")
with pytest.raises(ProxyRateLimitError) as saturated:
await _check(handler, _capped_team_key(2), saturation=0.5)
_held_to_borrow_cap(saturated.value, "tokens", "model_tpm_limit")
@pytest.mark.asyncio
async def test_team_borrows_past_its_share_while_it_stays_under_its_max_borrow_multiple(
monkeypatch: pytest.MonkeyPatch,
@ -3280,6 +3336,44 @@ async def test_a_websocket_turn_is_refused_at_the_teams_max_borrow_multiple_whil
_held_to_borrow_cap(exc.value, "tokens", "model_tpm_limit")
@pytest.mark.asyncio
async def test_a_websocket_admitted_while_saturated_gives_its_share_hold_back_when_the_model_goes_idle(
monkeypatch: pytest.MonkeyPatch,
):
handler: Final = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.5)
await _check(handler, _capped_team_key(2), saturation=0.5, data=_prompt(20))
assert await _team_tokens_remaining(handler, _capped_team_key(2)) < 100
await handler.enforce_token_limits_for_frame(user_api_key_dict=_capped_team_key(2), requested_model="shared-model")
await _settle_tokens(
handler,
90,
response=[],
combined_usage_object=litellm.Usage(prompt_tokens=60, completion_tokens=30, total_tokens=90),
)
assert await _team_tokens_remaining(handler, _capped_team_key(2)) == 10
assert await _cap_tokens_remaining(handler, _capped_team_key(2)) == 110
@pytest.mark.asyncio
async def test_a_capped_websocket_admitted_while_idle_settles_its_first_saturated_turn_on_a_fresh_share(
monkeypatch: pytest.MonkeyPatch,
):
"""The idle admission held tokens on the cap only, so the share the turn settles to once the model saturates
starts from zero rather than being refunded a hold it never carried."""
handler: Final = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.1)
await _check(handler, _capped_team_key(2), saturation=0.0, data=_prompt(20))
await asyncio.create_task(_saturate_model_from_another_request(handler, 200))
await handler.enforce_token_limits_for_frame(user_api_key_dict=_capped_team_key(2), requested_model="shared-model")
await _settle_tokens(
handler,
90,
response=[],
combined_usage_object=litellm.Usage(prompt_tokens=60, completion_tokens=30, total_tokens=90),
)
assert await _team_tokens_remaining(handler, _capped_team_key(2)) == 10
assert await _cap_tokens_remaining(handler, _capped_team_key(2)) == 110
@pytest.mark.asyncio
async def test_a_websocket_turn_under_the_teams_max_borrow_multiple_still_borrows(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.5)
@ -3476,6 +3570,22 @@ async def _admit_at_the_hook(handler: _PROXY_DynamicRateLimitHandlerV3, auth: Us
await _settle_tokens(handler, tokens)
async def _saturate_model_from_another_request(handler: _PROXY_DynamicRateLimitHandlerV3, tokens: int) -> None:
"""``_saturate_model`` on a request stash of its own, as a second request in the proxy would have, so the
stash of the request under test keeps its holds."""
from litellm.proxy.hooks import parallel_request_limiter_v3
parallel_request_limiter_v3._request_stash.set(None) # pyright: ignore[reportPrivateUsage] # fresh request context
await _saturate_model(handler, tokens)
async def _saturate_model(handler: _PROXY_DynamicRateLimitHandlerV3, tokens: int) -> None:
"""Idle usage that pushes the model past its saturation threshold without touching any team's share, from a
priority class of its own so the default pool the measured team sits in stays untouched."""
warm_up: Final = UserAPIKeyAuth(api_key="sk-warm-up", team_id="team-warm-up", team_metadata={"priority": "batch"})
await _admit_at_the_hook(handler, warm_up, tokens=tokens)
async def _refused_at_the_hook(
handler: _PROXY_DynamicRateLimitHandlerV3, auth: UserAPIKeyAuth, model: str = "shared-model"
) -> ProxyRateLimitError:
@ -3520,7 +3630,7 @@ async def test_a_failed_request_settles_the_team_share_gauges_too(monkeypatch: p
registry = _prometheus_sink(monkeypatch)
await _admit_then_settle(handler, _team_key(), tokens=90)
claim_request_stash_for_data({"litellm_call_id": "failed-call"})
await _check(handler, _team_key(), saturation=0.0, data=_prompt(words=40), priority=None)
await _check(handler, _team_key(), saturation=0.5)
await handler.async_log_failure_event(
kwargs={
"litellm_params": {"metadata": {"model_group": "shared-model"}},
@ -3536,10 +3646,10 @@ async def test_a_failed_request_settles_the_team_share_gauges_too(monkeypatch: p
@pytest.mark.asyncio
@pytest.mark.parametrize(
"auth, burns, saturation_threshold, reason",
"auth, burns, saturation_threshold, reason, enforced_share_tokens",
[
(_team_key(), (60, 70), 0.1, "model_per_team_saturated"),
(_capped_team_key(2), (150, 60), 0.5, "model_per_team_max_borrow_multiple"),
(_team_key(), (60, 70), 0.0, "model_per_team_saturated", 100),
(_capped_team_key(2), (150, 60), 0.5, "model_per_team_max_borrow_multiple", 100),
],
ids=["saturated", "borrow_cap"],
)
@ -3549,6 +3659,7 @@ async def test_a_refused_request_is_counted_under_the_reason_its_429_header_carr
burns: tuple[int, ...],
saturation_threshold: float,
reason: str,
enforced_share_tokens: int,
):
handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=saturation_threshold)
registry = _prometheus_sink(monkeypatch)
@ -3564,7 +3675,11 @@ async def test_a_refused_request_is_counted_under_the_reason_its_429_header_carr
)
== 1
)
assert _sample(registry, "litellm_dynamic_limiter_borrowed_tokens", **TEAM_LABELS) == sum(burns) - 100
assert _sample(registry, "litellm_dynamic_limiter_share_tokens", **TEAM_LABELS) == enforced_share_tokens
assert (
_sample(registry, "litellm_dynamic_limiter_borrowed_tokens", **TEAM_LABELS)
== sum(burns) - enforced_share_tokens
)
@pytest.mark.asyncio
@ -3628,13 +3743,14 @@ async def test_an_alternates_refusal_is_not_counted_while_the_preferred_model_ad
@pytest.mark.asyncio
async def test_share_gauges_and_throttles_carry_the_teams_priority_so_the_series_join(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.1)
handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.0)
registry = _prometheus_sink(monkeypatch)
team: Final = UserAPIKeyAuth(
api_key="sk-interactive",
team_id="team-a",
team_metadata={"model_tpm_limit": {"shared-model": 100}, "priority": "interactive"},
)
await _saturate_model(handler, 200)
await _admit_at_the_hook(handler, team, tokens=60)
await _admit_at_the_hook(handler, team, tokens=70)
labels: Final = {"model_group": "shared-model", "team": "team-a", "priority": "interactive"}
@ -3651,9 +3767,10 @@ async def test_share_gauges_and_throttles_carry_the_teams_priority_so_the_series
async def test_a_fallback_served_request_publishes_the_teams_share_on_the_fallback_model(
monkeypatch: pytest.MonkeyPatch,
):
handler = _borrowing_handler(monkeypatch, borrows=True)
handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.0)
registry = _prometheus_sink(monkeypatch)
team: Final = _team_key(team_limits={"model_tpm_limit": {"other-model": 40, "shared-model": 100}})
await _saturate_model(handler, 500)
assert await handler.async_pre_call_hook(team, DualCache(), {"model": "other-model"}, "completion") is None
assert await handler.async_fallback_target_allowed(team, "shared-model", {}) is True
await _settle_tokens(handler, 150, model_group="shared-model")
@ -3663,6 +3780,35 @@ async def test_a_fallback_served_request_publishes_the_teams_share_on_the_fallba
assert _sample(registry, "litellm_dynamic_limiter_share_tokens", model_group="other-model") is None
@pytest.mark.asyncio
async def test_a_capped_idle_teams_borrowing_is_read_against_its_share_not_the_wider_class_share(
monkeypatch: pytest.MonkeyPatch,
):
handler = _borrowing_handler(monkeypatch, borrows=True, splits=True)
registry = _prometheus_sink(monkeypatch)
await _admit_at_the_hook(handler, _capped_team_key(2), tokens=150)
assert _sample(registry, "litellm_dynamic_limiter_share_tokens", **TEAM_LABELS) == 100
assert _sample(registry, "litellm_dynamic_limiter_borrowed_tokens", **TEAM_LABELS) == 50
@pytest.mark.asyncio
async def test_an_idle_fallback_served_for_a_capped_team_still_publishes_its_borrowing(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True)
registry = _prometheus_sink(monkeypatch)
team: Final = _team_key(
team_limits={
"model_tpm_limit": {"other-model": 40, "shared-model": 100},
"team_model_limit_max_borrow_multiple": 2,
}
)
assert await handler.async_pre_call_hook(team, DualCache(), {"model": "other-model"}, "completion") is None
assert await handler.async_fallback_target_allowed(team, "shared-model", {}) is True
await _settle_tokens(handler, 150, model_group="shared-model")
shared: Final = {"model_group": "shared-model", "team": "team-a", "priority": "default"}
assert _sample(registry, "litellm_dynamic_limiter_share_tokens", **shared) == 100
assert _sample(registry, "litellm_dynamic_limiter_borrowed_tokens", **shared) == 50
@pytest.mark.asyncio
async def test_a_failing_metrics_sink_never_changes_admission(monkeypatch: pytest.MonkeyPatch):
from litellm.integrations.prometheus import PrometheusLogger
@ -3674,8 +3820,9 @@ async def test_a_failing_metrics_sink_never_changes_admission(monkeypatch: pytes
def increment_dynamic_limiter_throttled(self, *args: object, **kwargs: object) -> None:
raise RuntimeError("metrics backend down")
handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.1)
handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.0)
_prometheus_sink(monkeypatch, BrokenSink)
await _saturate_model(handler, 200)
await _admit_at_the_hook(handler, _team_key(), tokens=60)
await _admit_at_the_hook(handler, _team_key(), tokens=70)
assert await _team_tokens_remaining(handler) == -30

View file

@ -0,0 +1,278 @@
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from functools import reduce
from typing import Final, cast
import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.list_api.common import ManagementProblem, problem_response
from litellm.proxy.management_endpoints.management_v1.customer_usage import get_customer_usage_prisma, router
from litellm.proxy.utils import PrismaClient
from litellm.types.proxy.management_endpoints.common_daily_activity import SpendMetrics
class Record(BaseModel):
model_config = ConfigDict(frozen=True, extra="allow")
class DailyRecord(SpendMetrics):
model_config = ConfigDict(frozen=True)
id: str
date: str
customer_org_id: str
customer_team_id: str
customer_member_id: str
api_key: str
model: str | None = None
model_group: str | None = None
custom_llm_provider: str | None = None
mcp_namespaced_tool_name: str | None = None
endpoint: str | None = None
def matches(values: Mapping[str, object], where: Mapping[str, object]) -> bool:
for key, condition in where.items():
if key == "OR":
if not any(matches(values, clause) for clause in condition):
return False
elif key == "AND":
if not all(matches(values, clause) for clause in condition):
return False
elif isinstance(condition, Mapping):
if "in" in condition and values.get(key) not in condition["in"]:
return False
if "not" in condition and matches(values, {key: condition["not"]}):
return False
if "gte" in condition and str(values.get(key)) < str(condition["gte"]):
return False
if "lte" in condition and str(values.get(key)) > str(condition["lte"]):
return False
elif values.get(key) != condition:
return False
return True
@dataclass(frozen=True, slots=True)
class Table:
rows: tuple[Record | DailyRecord, ...] = ()
async def find_many(
self,
where: Mapping[str, object] | None = None,
take: int | None = None,
skip: int = 0,
order: Mapping[str, object] | Sequence[Mapping[str, object]] | None = None,
) -> tuple[Record | DailyRecord, ...]:
selected: Final = tuple(row for row in self.rows if matches(row.model_dump(), where or {}))
orders: Final = (order,) if isinstance(order, Mapping) else tuple(order or ())
ordered: Final = reduce(
lambda rows, item: tuple(
sorted(
rows,
key=lambda row: str(row.model_dump()[next(iter(item))]),
reverse=next(iter(item.values())) == "desc",
)
),
reversed(orders),
selected,
)
return ordered[skip:] if take is None else ordered[skip : skip + take]
async def count(self, where: Mapping[str, object] | None = None) -> int:
return len(await self.find_many(where=where))
@dataclass(frozen=True, slots=True)
class Database:
litellm_dailycustomermemberspend: Table
litellm_customerorganizationtable: Table = Table(
(
Record(customer_org_id="org-a", alias="Organization A"),
Record(customer_org_id="org-b", alias="Organization B"),
)
)
litellm_customerteamtable: Table = Table(
(Record(customer_team_id="team-a", alias="Product"), Record(customer_team_id="team-b", alias="Operations"))
)
litellm_customermembertable: Table = Table(
(
Record(customer_member_id="member-a", alias="Member A"),
Record(customer_member_id="member-b", alias="Member B"),
)
)
litellm_verificationtoken: Table = Table(
(
Record(token="hash-a", key_alias="Support", team_id=None, user_id=None, metadata=None),
Record(token="hash-b", key_alias="Search", team_id=None, user_id=None, metadata=None),
)
)
litellm_deletedverificationtoken: Table = Table()
litellm_usertable: Table = Table()
@dataclass(frozen=True, slots=True)
class Client:
db: Database
def usage_row(
*,
id: str = "usage-a",
date: str = "2026-01-01",
customer_org_id: str = "org-a",
customer_team_id: str = "team-a",
customer_member_id: str = "member-a",
api_key: str = "hash-a",
model: str = "model-a",
model_group: str = "alias-a",
provider: str = "provider-a",
spend: float = 2.0,
) -> DailyRecord:
return DailyRecord(
id=id,
date=date,
customer_org_id=customer_org_id,
customer_team_id=customer_team_id,
customer_member_id=customer_member_id,
api_key=api_key,
model=model,
model_group=model_group,
custom_llm_provider=provider,
spend=spend,
prompt_tokens=10,
completion_tokens=5,
total_tokens=15,
api_requests=3,
successful_requests=2,
failed_requests=1,
)
def client(rows: tuple[DailyRecord, ...] = (), role: LitellmUserRoles = LitellmUserRoles.PROXY_ADMIN) -> TestClient:
app: Final = FastAPI()
app.include_router(router)
@app.exception_handler(ManagementProblem)
async def problem(request: Request, exc: ManagementProblem):
return problem_response(exc.problem)
auth: Final = UserAPIKeyAuth(user_id="admin", user_role=role)
prisma: Final = cast(PrismaClient, Client(Database(Table(rows)))) # cast-ok: in-memory Prisma boundary
app.dependency_overrides[user_api_key_auth] = lambda: auth
app.dependency_overrides[get_customer_usage_prisma] = lambda: prisma
return TestClient(app)
DATES: Final = {"start_date": "2026-01-01", "end_date": "2026-01-03"}
SCOPES: Final = (
("customer_organizations", "customer_org_ids", "exclude_customer_org_ids", "org-a", "org-b", "Organization A"),
("customer_teams", "customer_team_ids", "exclude_customer_team_ids", "team-a", "team-b", "Product"),
("customer_members", "customer_member_ids", "exclude_customer_member_ids", "member-a", "member-b", "Member A"),
)
@pytest.mark.parametrize("resource,parameter,excluded,first,second,label", SCOPES)
def test_standard_daily_activity_filters_entities_and_preserves_aliases_and_dimensions(
resource: str, parameter: str, excluded: str, first: str, second: str, label: str
) -> None:
rows: Final = (
usage_row(),
usage_row(id="usage-c", date="2026-01-03", model_group="alias-b", provider="provider-b", spend=4),
usage_row(
id="usage-b", customer_org_id="org-b", customer_team_id="team-b", customer_member_id="member-b", spend=10
),
usage_row(id="outside-range", date="2025-12-31", spend=50),
)
response: Final = client(rows).get(f"/management/v1/{resource}/daily/activity", params={**DATES, parameter: first})
assert response.status_code == 200, response.text
body: Final = response.json()
assert set(body) == {"results", "metadata"}
assert body["metadata"]["total_spend"] == 6
assert body["metadata"]["total_tokens"] == 30
assert body["metadata"]["total_api_requests"] == 6
assert body["metadata"]["has_more"] is False
day: Final = body["results"][0]
assert day["breakdown"]["entities"][first]["metadata"]["alias"] == label
assert day["breakdown"]["models"]["model-a"]["metrics"]["spend"] == 4
assert day["breakdown"]["model_groups"]["alias-b"]["metrics"]["spend"] == 4
assert day["breakdown"]["providers"]["provider-b"]["metrics"]["spend"] == 4
assert day["breakdown"]["api_keys"]["hash-a"]["metadata"]["key_alias"] == "Support"
@pytest.mark.parametrize("resource,parameter,excluded,first,second,label", SCOPES)
def test_inclusion_and_exclusion_lists_are_comma_separated(
resource: str, parameter: str, excluded: str, first: str, second: str, label: str
) -> None:
rows: Final = (
usage_row(),
usage_row(
id="usage-b", customer_org_id="org-b", customer_team_id="team-b", customer_member_id="member-b", spend=10
),
)
response: Final = client(rows).get(
f"/management/v1/{resource}/daily/activity", params={**DATES, parameter: f"{first},{second}", excluded: second}
)
assert response.status_code == 200, response.text
assert response.json()["metadata"]["total_spend"] == 2
@pytest.mark.parametrize("resource,parameter,excluded,first,second,label", SCOPES)
def test_standard_model_key_and_page_filters_use_the_shared_response_contract(
resource: str, parameter: str, excluded: str, first: str, second: str, label: str
) -> None:
rows: Final = (
usage_row(id="page-a"),
usage_row(id="page-b", date="2026-01-02", spend=4),
usage_row(id="other-model", model="model-b", spend=20),
usage_row(id="other-key", api_key="hash-b", spend=30),
)
response: Final = client(rows).get(
f"/management/v1/{resource}/daily/activity",
params={**DATES, parameter: first, "model": "model-a", "api_key": "hash-a", "page": 1, "page_size": 1},
)
assert response.status_code == 200, response.text
body: Final = response.json()
assert body["metadata"]["page"] == 1
assert body["metadata"]["total_pages"] == 2
assert body["metadata"]["has_more"] is True
assert body["metadata"]["total_spend"] == 4
assert body["results"][0]["date"] == "2026-01-02"
next_page: Final = client(rows).get(
f"/management/v1/{resource}/daily/activity",
params={**DATES, parameter: first, "model": "model-a", "api_key": "hash-a", "page": 2, "page_size": 1},
)
assert next_page.status_code == 200, next_page.text
assert next_page.json()["metadata"]["total_spend"] == 2
assert next_page.json()["metadata"]["has_more"] is False
@pytest.mark.parametrize("resource,parameter,excluded,first,second,label", SCOPES)
@pytest.mark.parametrize("role", (LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY))
def test_all_hierarchy_daily_activity_routes_are_admin_only(
resource: str, parameter: str, excluded: str, first: str, second: str, label: str, role: LitellmUserRoles
) -> None:
response: Final = client(role=role).get(f"/management/v1/{resource}/daily/activity", params=DATES)
assert response.status_code == 403, response.text
@pytest.mark.parametrize("params", ({}, {"start_date": "invalid", "end_date": "2026-01-03"}, {**DATES, "page": 0}))
def test_shared_helper_rejects_missing_dates_invalid_dates_and_nonpositive_pagination(
params: Mapping[str, object],
) -> None:
response: Final = client().get("/management/v1/customer_members/daily/activity", params=params)
assert response.status_code == 400, response.text
def test_unmatched_entities_return_the_standard_empty_response() -> None:
response: Final = client((usage_row(),)).get(
"/management/v1/customer_members/daily/activity", params={**DATES, "customer_member_ids": "missing"}
)
assert response.status_code == 200, response.text
assert response.json()["results"] == []
assert response.json()["metadata"]["total_spend"] == 0

View file

@ -41,6 +41,7 @@ from litellm.proxy.management_endpoints.internal_user_endpoints import (
)
from litellm.proxy.proxy_server import app
from litellm.types.proxy.auth.auth_checks import UserNotFoundError
from litellm.types.proxy.common_proxy_errors import CommonProxyErrors
from litellm.types.proxy.management_endpoints.internal_user_endpoints import InsensitiveContains
from tests.unit.proxy.management_endpoints.jwt_key_mapping_doubles import (
CascadingJWTMappingTable,
@ -50,6 +51,30 @@ from tests.unit.proxy.management_endpoints.jwt_key_mapping_doubles import (
client = TestClient(app)
@pytest.mark.asyncio
@pytest.mark.parametrize("binding", ["customer_org_id", "customer_team_id", "customer_member_id"])
async def test_new_user_cannot_mint_customer_bound_keys_without_proxy_admin(binding: str) -> None:
with pytest.raises(HTTPException) as caught:
await new_user(
data=NewUserRequest(metadata={binding: "another-customer"}),
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER),
)
assert caught.value.status_code == 403
assert caught.value.detail == "Only proxy admins can change a key's customer organization, team, or member binding."
@pytest.mark.asyncio
async def test_new_user_reports_missing_database_after_customer_binding_validation(mocker: MockerFixture) -> None:
mocker.patch("litellm.proxy.proxy_server.prisma_client", None)
with pytest.raises(ProxyException) as caught:
await new_user(
data=NewUserRequest(metadata={"customer_org_id": "org-1"}),
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert str(caught.value.code) == "400"
assert caught.value.message == CommonProxyErrors.db_not_connected_error.value
@pytest.mark.parametrize("user_id", ["", " \t\n", "user\x00id", 123, True, [], {}])
def test_new_user_rejects_invalid_custom_id(user_id: object) -> None:
with pytest.raises(ValidationError) as exc:

View file

@ -5,7 +5,7 @@ from collections.abc import Mapping
from contextlib import ExitStack
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Final
from typing import Final, Literal
from unittest.mock import AsyncMock, MagicMock, call, patch
from uuid import UUID, uuid4
@ -24618,3 +24618,89 @@ def test_user_new_without_a_key_does_not_check_guardrail_names(spend_permission_
assert response.status_code == 200, response.text
find_many.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("field", ("customer_org_id", "customer_team_id", "customer_member_id"))
async def test_customer_binding_requires_admin_on_key_creation(field: str) -> None:
with pytest.raises(HTTPException) as rejected:
await _common_key_generation_helper(
data=GenerateKeyRequest(metadata={field: "customer-id"}),
user_api_key_dict=UserAPIKeyAuth(user_id="member", user_role=LitellmUserRoles.INTERNAL_USER),
litellm_changed_by=None,
team_table=None,
)
assert rejected.value.status_code == 403
@pytest.mark.asyncio
@pytest.mark.parametrize("request_type", ("key", "user"))
async def test_direct_key_mint_cannot_bypass_customer_binding_authorization(request_type: Literal["key", "user"]) -> None:
with pytest.raises(HTTPException) as rejected:
await generate_key_helper_fn(request_type=request_type, metadata={"customer_org_id": "another-customer"})
assert rejected.value.status_code == 403
@pytest.mark.asyncio
@pytest.mark.parametrize("operation", ("update", "regenerate"))
@pytest.mark.parametrize("field", ("customer_org_id", "customer_team_id", "customer_member_id"))
@pytest.mark.parametrize("requested", ("another-customer-id", None))
async def test_customer_binding_cannot_be_reassigned_or_cleared_by_key_holder(
operation: Literal["update", "regenerate"], field: str, requested: str | None
) -> None:
existing: Final = LiteLLM_VerificationToken(token="hashed-key", metadata={field: "customer-id"})
data: Final = (
UpdateKeyRequest(key="hashed-key", metadata={field: requested})
if operation == "update"
else RegenerateKeyRequest(metadata={field: requested})
)
with pytest.raises(HTTPException) as rejected:
await prepare_key_update_data(
data=data,
existing_key_row=existing,
user_api_key_dict=UserAPIKeyAuth(user_id="member", user_role=LitellmUserRoles.INTERNAL_USER),
)
assert rejected.value.status_code == 403
@pytest.mark.asyncio
@pytest.mark.parametrize("operation", ("update", "regenerate"))
@pytest.mark.parametrize("metadata", ({"application": "support"}, {}, None))
async def test_unrelated_key_metadata_changes_preserve_existing_customer_binding(
operation: Literal["update", "regenerate"], metadata: dict[str, object] | None
) -> None:
bindings: Final = {"customer_org_id": "org-id", "customer_team_id": "team-id", "customer_member_id": "member-id"}
existing: Final = LiteLLM_VerificationToken(token="hashed-key", metadata={**bindings, "old": "discard"})
data: Final = (
UpdateKeyRequest(key="hashed-key", metadata=metadata)
if operation == "update"
else RegenerateKeyRequest(metadata=metadata)
)
actual: Final = await prepare_key_update_data(
data=data,
existing_key_row=existing,
user_api_key_dict=UserAPIKeyAuth(user_id="member", user_role=LitellmUserRoles.INTERNAL_USER),
)
assert actual["metadata"] == {**(metadata or {}), **bindings}
assert existing.metadata == {**bindings, "old": "discard"}
assert data.metadata == metadata
@pytest.mark.asyncio
@pytest.mark.parametrize("operation", ("update", "regenerate"))
@pytest.mark.parametrize("metadata", ({"customer_org_id": "another-org"}, {}, None))
async def test_proxy_admin_can_replace_or_clear_customer_key_binding(
operation: Literal["update", "regenerate"], metadata: dict[str, object] | None
) -> None:
existing: Final = LiteLLM_VerificationToken(token="hashed-key", metadata={"customer_org_id": "org-id"})
data: Final = (
UpdateKeyRequest(key="hashed-key", metadata=metadata)
if operation == "update"
else RegenerateKeyRequest(metadata=metadata)
)
actual: Final = await prepare_key_update_data(
data=data,
existing_key_row=existing,
user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert actual["metadata"] == metadata

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