mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge branch 'litellm_window_spend_writer' into litellm_window_spend_reader
This commit is contained in:
commit
3cc2f615da
49 changed files with 4475 additions and 91 deletions
|
|
@ -3,7 +3,7 @@
|
|||
"limit": 17270
|
||||
},
|
||||
"reportArgumentType": {
|
||||
"limit": 2539
|
||||
"limit": 2538
|
||||
},
|
||||
"reportAssignmentType": {
|
||||
"limit": 319
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ flag_management:
|
|||
carryforward: false
|
||||
- name: proxy-db-schema-migration
|
||||
carryforward: false
|
||||
- name: circleci
|
||||
carryforward: false
|
||||
|
||||
component_management:
|
||||
individual_components:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "LiteLLM_ModelAccessGroupBudgetTable" (
|
||||
"access_group_name" TEXT NOT NULL,
|
||||
"spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||
"budget_id" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" TEXT,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" TEXT,
|
||||
|
||||
CONSTRAINT "LiteLLM_ModelAccessGroupBudgetTable_pkey" PRIMARY KEY ("access_group_name")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'LiteLLM_ModelAccessGroupBudgetTable_budget_id_fkey') THEN
|
||||
ALTER TABLE "LiteLLM_ModelAccessGroupBudgetTable" ADD CONSTRAINT "LiteLLM_ModelAccessGroupBudgetTable_budget_id_fkey" FOREIGN KEY ("budget_id") REFERENCES "LiteLLM_BudgetTable"("budget_id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
|
@ -29,6 +29,7 @@ model LiteLLM_BudgetTable {
|
|||
keys LiteLLM_VerificationToken[] // multiple keys can have the same budget
|
||||
end_users LiteLLM_EndUserTable[] // multiple end-users can have the same budget
|
||||
tags LiteLLM_TagTable[] // multiple tags can have the same budget
|
||||
model_access_groups LiteLLM_ModelAccessGroupBudgetTable[] // multiple model access groups can have the same budget
|
||||
team_membership LiteLLM_TeamMembership[] // budgets of Users within a Team
|
||||
organization_membership LiteLLM_OrganizationMembership[] // budgets of Users within a Organization
|
||||
}
|
||||
|
|
@ -585,6 +586,20 @@ model LiteLLM_EndUserTable {
|
|||
blocked Boolean @default(false)
|
||||
}
|
||||
|
||||
// Budget and shared spend for a model access group. The groups themselves are not rows anywhere:
|
||||
// they are free-text strings in LiteLLM_ProxyModelTable.model_info.access_groups, so a row here
|
||||
// exists only once someone gives that group a budget.
|
||||
model LiteLLM_ModelAccessGroupBudgetTable {
|
||||
access_group_name String @id
|
||||
spend Float @default(0.0)
|
||||
budget_id String?
|
||||
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
created_by String?
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String?
|
||||
}
|
||||
|
||||
// Track tags with budgets and spend
|
||||
model LiteLLM_TagTable {
|
||||
tag_name String @id
|
||||
|
|
|
|||
|
|
@ -1683,6 +1683,7 @@ DEFAULT_MCP_NAMESPACE_CSV_MAX_TOKENS: Final = 16
|
|||
# Ceilings on the cached auth registries; larger tables fall back to per-row lookups
|
||||
# instead of holding an unbounded id set in every worker.
|
||||
TAG_REGISTRY_MAX_SIZE: Final = 5000
|
||||
MODEL_ACCESS_GROUP_REGISTRY_MAX_SIZE: Final = 5000
|
||||
END_USER_RESTRICTED_REGISTRY_MAX_SIZE: Final = 5000
|
||||
# How long a failed registry load is remembered as "unusable", so a degraded Postgres
|
||||
# is not re-scanned on every request on top of the per-id lookups it falls back to.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ from litellm.types.utils import BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN, Inter
|
|||
|
||||
BUDGET_RESERVATION_METADATA_KEYS: Final = frozenset({"user_api_key_budget_reservation"})
|
||||
|
||||
MODEL_ACCESS_GROUP_METADATA_KEY: Final = "user_api_key_matched_model_access_groups"
|
||||
"""Where auth records the model access groups that authorized the request, for the spend writer.
|
||||
|
||||
The ``user_api_key`` prefix is load-bearing, not cosmetic: when a request carries both
|
||||
``metadata`` and ``litellm_metadata``, ``get_litellm_metadata_from_kwargs`` returns the latter and
|
||||
copies a key across only when ``user_api_key`` appears in its name."""
|
||||
|
||||
_USER_API_KEY_AUTH_KEY: Final = "user_api_key_auth"
|
||||
|
||||
FORWARDABLE_IDENTITY_METADATA_KEYS: Final = frozenset(
|
||||
|
|
|
|||
|
|
@ -64,7 +64,10 @@ from litellm.integrations.mlflow import MlflowLogger
|
|||
from litellm.integrations.sqs import SQSLogger
|
||||
from litellm.litellm_core_utils.core_helpers import is_expected_client_error, reconstruct_model_name
|
||||
from litellm.litellm_core_utils.get_litellm_params import get_litellm_params
|
||||
from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call
|
||||
from litellm.litellm_core_utils.internal_call_metadata import (
|
||||
MODEL_ACCESS_GROUP_METADATA_KEY,
|
||||
is_unbilled_non_inference_call,
|
||||
)
|
||||
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
|
||||
cost_breakdown_with_guardrail,
|
||||
guardrail_information_cost,
|
||||
|
|
@ -5051,6 +5054,42 @@ def is_valid_sha256_hash(value: str) -> bool:
|
|||
return bool(re.fullmatch(r"[a-fA-F0-9]{64}", value))
|
||||
|
||||
|
||||
def coerce_model_access_groups(value: object) -> tuple[str, ...]:
|
||||
"""Model access group names out of untrusted request metadata, deduped and order preserving."""
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return ()
|
||||
return tuple(dict.fromkeys(group for group in value if isinstance(group, str) and group))
|
||||
|
||||
|
||||
def _model_access_groups_on_auth_object(user_api_key_auth: object) -> object:
|
||||
if isinstance(user_api_key_auth, Mapping):
|
||||
return user_api_key_auth.get("matched_model_access_groups")
|
||||
return getattr(user_api_key_auth, "matched_model_access_groups", None)
|
||||
|
||||
|
||||
def _model_access_groups_from_metadata(metadata: Mapping[str, object]) -> tuple[str, ...]:
|
||||
stamped: Final = coerce_model_access_groups(metadata.get(MODEL_ACCESS_GROUP_METADATA_KEY))
|
||||
if stamped:
|
||||
return stamped
|
||||
return coerce_model_access_groups(_model_access_groups_on_auth_object(metadata.get("user_api_key_auth")))
|
||||
|
||||
|
||||
def request_model_access_groups_from_litellm_params(litellm_params: Mapping[str, object]) -> tuple[str, ...]:
|
||||
"""Access groups the auth layer stamped onto this request, from whichever metadata field carries them.
|
||||
|
||||
Detached internal sub-calls only inherit the identity keys, so the auth object is the
|
||||
fallback there, exactly as _get_budget_reservation_from_metadata does for reservations.
|
||||
"""
|
||||
for metadata_variable_name in ("metadata", "litellm_metadata"):
|
||||
metadata = litellm_params.get(metadata_variable_name)
|
||||
if not isinstance(metadata, Mapping):
|
||||
continue
|
||||
model_access_groups = _model_access_groups_from_metadata(metadata)
|
||||
if model_access_groups:
|
||||
return model_access_groups
|
||||
return ()
|
||||
|
||||
|
||||
class StandardLoggingPayloadSetup:
|
||||
@staticmethod
|
||||
def cleanup_timestamps(
|
||||
|
|
@ -5902,6 +5941,7 @@ def get_standard_logging_object_payload(
|
|||
request_tags: Final = StandardLoggingPayloadSetup._get_request_tags(
|
||||
litellm_params=litellm_params, proxy_server_request=proxy_server_request
|
||||
)
|
||||
request_model_access_groups: Final = request_model_access_groups_from_litellm_params(litellm_params)
|
||||
|
||||
# cleanup timestamps
|
||||
(
|
||||
|
|
@ -6064,6 +6104,7 @@ def get_standard_logging_object_payload(
|
|||
prompt_tokens=usage_dict.get("prompt_tokens", 0),
|
||||
completion_tokens=usage_dict.get("completion_tokens", 0),
|
||||
request_tags=request_tags,
|
||||
request_model_access_groups=request_model_access_groups,
|
||||
end_user=end_user_id,
|
||||
api_base=StandardLoggingPayloadSetup.strip_trailing_slash(litellm_params.get("api_base", "")) or "",
|
||||
model_group=_model_group,
|
||||
|
|
@ -6277,6 +6318,7 @@ def create_dummy_standard_logging_payload() -> StandardLoggingPayload:
|
|||
cache_key=None,
|
||||
saved_cache_cost=saved_cache_cost,
|
||||
request_tags=[],
|
||||
request_model_access_groups=(),
|
||||
end_user=None,
|
||||
requester_ip_address="127.0.0.1",
|
||||
messages=messages,
|
||||
|
|
|
|||
|
|
@ -461,6 +461,145 @@
|
|||
"access_groups": {
|
||||
"components": {
|
||||
"schemas": {
|
||||
"AccessGroupBudget": {
|
||||
"properties": {
|
||||
"budget_duration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Budget Duration"
|
||||
},
|
||||
"budget_id": {
|
||||
"title": "Budget Id",
|
||||
"type": "string"
|
||||
},
|
||||
"budget_reset_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Budget Reset At"
|
||||
},
|
||||
"max_budget": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Max Budget"
|
||||
},
|
||||
"soft_budget": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Soft Budget"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"budget_id"
|
||||
],
|
||||
"title": "AccessGroupBudget",
|
||||
"type": "object"
|
||||
},
|
||||
"AccessGroupBudgetRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"budget_duration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Budget Duration"
|
||||
},
|
||||
"budget_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Budget Id"
|
||||
},
|
||||
"max_budget": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minimum": 0.0,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Max Budget"
|
||||
},
|
||||
"soft_budget": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minimum": 0.0,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Soft Budget"
|
||||
}
|
||||
},
|
||||
"title": "AccessGroupBudgetRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"AccessGroupBudgetResponse": {
|
||||
"properties": {
|
||||
"access_group": {
|
||||
"title": "Access Group",
|
||||
"type": "string"
|
||||
},
|
||||
"budget": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/AccessGroupBudget"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spend": {
|
||||
"title": "Spend",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"access_group",
|
||||
"spend"
|
||||
],
|
||||
"title": "AccessGroupBudgetResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"AccessGroupCreateRequest": {
|
||||
"properties": {
|
||||
"access_agent_ids": {
|
||||
|
|
@ -561,6 +700,16 @@
|
|||
"title": "Access Group",
|
||||
"type": "string"
|
||||
},
|
||||
"budget": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/AccessGroupBudget"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"deployment_count": {
|
||||
"title": "Deployment Count",
|
||||
"type": "integer"
|
||||
|
|
@ -571,6 +720,17 @@
|
|||
},
|
||||
"title": "Model Names",
|
||||
"type": "array"
|
||||
},
|
||||
"spend": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Spend"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
|
@ -782,6 +942,29 @@
|
|||
"title": "AccessGroupUpdateRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"DeleteAccessGroupBudgetResponse": {
|
||||
"properties": {
|
||||
"access_group": {
|
||||
"title": "Access Group",
|
||||
"type": "string"
|
||||
},
|
||||
"budget_deleted": {
|
||||
"title": "Budget Deleted",
|
||||
"type": "boolean"
|
||||
},
|
||||
"message": {
|
||||
"title": "Message",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"access_group",
|
||||
"budget_deleted",
|
||||
"message"
|
||||
],
|
||||
"title": "DeleteAccessGroupBudgetResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"DeleteModelGroupResponse": {
|
||||
"properties": {
|
||||
"access_group": {
|
||||
|
|
@ -1072,6 +1255,156 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/access_group/{access_group}/budget": {
|
||||
"delete": {
|
||||
"description": "Clear the shared budget of an access group, leaving the group itself in place.\n\nExample:\n```bash\ncurl -X DELETE 'http://localhost:4000/access_group/production-models/budget' \\\n -H 'Authorization: Bearer sk-1234'\n```\n\nParameters:\n- access_group: str - The access group name (URL path parameter)\n\nReturns:\n- DeleteAccessGroupBudgetResponse; budget_deleted is false when there was nothing to clear\n\nRaises:\n- HTTPException 404: If access group not found",
|
||||
"operationId": "delete_access_group_budget_access_group__access_group__budget_delete",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "access_group",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Access Group",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeleteAccessGroupBudgetResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Delete Access Group Budget",
|
||||
"tags": [
|
||||
"access_groups"
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"description": "Get the shared budget of an access group, and the spend drawn against it.\n\nExample:\n```bash\ncurl -X GET 'http://localhost:4000/access_group/production-models/budget' \\\n -H 'Authorization: Bearer sk-1234'\n```\n\nParameters:\n- access_group: str - The access group name (URL path parameter)\n\nReturns:\n- AccessGroupBudgetResponse; budget is null when the group has no budget set\n\nRaises:\n- HTTPException 404: If access group not found",
|
||||
"operationId": "get_access_group_budget_access_group__access_group__budget_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "access_group",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Access Group",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AccessGroupBudgetResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Get Access Group Budget",
|
||||
"tags": [
|
||||
"access_groups"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"description": "Set or replace the shared budget of an access group. Idempotent.\n\nEvery key that can reach a model in the group draws from this one budget.\n\nExample:\n```bash\ncurl -X PUT 'http://localhost:4000/access_group/production-models/budget' \\\n -H 'Authorization: Bearer sk-1234' \\\n -H 'Content-Type: application/json' \\\n -d '{\n \"max_budget\": 100.0,\n \"budget_duration\": \"30d\"\n }'\n```\n\nParameters:\n- access_group: str - The access group name (URL path parameter)\n- max_budget: Optional[float] - Requests fail once the group's shared spend exceeds this\n- soft_budget: Optional[float] - Fires an alert when reached; requests still succeed\n- budget_duration: Optional[str] - Frequency of resetting the group's spend (e.g. '30d')\n- budget_id: Optional[str] - Link an existing budget instead of creating one\n\nReturns:\n- AccessGroupBudgetResponse with the stored budget and current spend\n\nRaises:\n- HTTPException 400: If no budget field is given, or budget_duration cannot be parsed\n- HTTPException 404: If access group not found",
|
||||
"operationId": "set_access_group_budget_access_group__access_group__budget_put",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "access_group",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Access Group",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AccessGroupBudgetRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AccessGroupBudgetResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Set Access Group Budget",
|
||||
"tags": [
|
||||
"access_groups"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/access_group/{access_group}/delete": {
|
||||
"delete": {
|
||||
"description": "Delete an access group.\n\nRemoves the access group from all deployments that have it.\n\nExample:\n```bash\ncurl -X DELETE 'http://localhost:4000/access_group/production-models/delete' \\\n -H 'Authorization: Bearer sk-1234'\n```\n\nParameters:\n- access_group: str - The access group name (URL path parameter)\n\nReturns:\n- DeleteModelGroupResponse with deletion details\n\nRaises:\n- HTTPException 404: If access group not found",
|
||||
|
|
@ -1122,7 +1455,7 @@
|
|||
},
|
||||
"/access_group/{access_group}/info": {
|
||||
"get": {
|
||||
"description": "Get information about a specific access group.\n\nExample:\n```bash\ncurl -X GET 'http://localhost:4000/access_group/production-models/info' \\\n -H 'Authorization: Bearer sk-1234'\n```\n\nParameters:\n- access_group: str - The access group name (URL path parameter)\n\nReturns:\n- AccessGroupInfo with the access group details\n\nRaises:\n- HTTPException 404: If access group not found",
|
||||
"description": "Get information about a specific access group.\n\nExample:\n```bash\ncurl -X GET 'http://localhost:4000/access_group/production-models/info' \\\n -H 'Authorization: Bearer sk-1234'\n```\n\nParameters:\n- access_group: str - The access group name (URL path parameter)\n\nReturns:\n- AccessGroupInfo with the access group details, its shared budget and its spend\n\nRaises:\n- HTTPException 404: If access group not found",
|
||||
"operationId": "get_access_group_info_access_group__access_group__info_get",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -241,6 +241,7 @@ class Litellm_EntityType(enum.Enum):
|
|||
PROJECT = "project"
|
||||
TAG = "tag"
|
||||
AGENT = "agent"
|
||||
MODEL_ACCESS_GROUP = "model_access_group"
|
||||
|
||||
# global proxy level entity
|
||||
PROXY = "proxy"
|
||||
|
|
@ -2887,6 +2888,7 @@ class UserAPIKeyAuth(LiteLLM_VerificationTokenView): # the expected response ob
|
|||
),
|
||||
)
|
||||
budget_reservation: dict[str, Any] | None = Field(default=None, exclude=True)
|
||||
matched_model_access_groups: list[str] | None = Field(default=None, exclude=True)
|
||||
budget_throttle_pct: float | None = Field(default=None, exclude=True)
|
||||
user: Any | None = None # Expanded user object when expand=user is used
|
||||
created_by_user: Any | None = None # Expanded created_by user when expand=user is used
|
||||
|
|
@ -4921,6 +4923,7 @@ class DBSpendUpdateTransactions(TypedDict):
|
|||
org_list_transactions: dict[str, float] | None
|
||||
tag_list_transactions: dict[str, float] | None
|
||||
agent_list_transactions: dict[str, float] | None
|
||||
model_access_group_list_transactions: ReadOnly[dict[str, float] | None]
|
||||
|
||||
|
||||
class SpendUpdateQueueItem(TypedDict, total=False):
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from litellm.constants import (
|
|||
DEFAULT_MAX_RECURSE_DEPTH,
|
||||
EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE,
|
||||
END_USER_RESTRICTED_REGISTRY_MAX_SIZE,
|
||||
MODEL_ACCESS_GROUP_REGISTRY_MAX_SIZE,
|
||||
REGISTRY_ERROR_NEGATIVE_CACHE_TTL,
|
||||
TAG_REGISTRY_MAX_SIZE,
|
||||
)
|
||||
|
|
@ -78,11 +79,15 @@ from litellm.proxy.common_utils.http_parsing_utils import (
|
|||
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
END_USER_RESTRICTED_REGISTRY_OVERFLOW_SENTINEL,
|
||||
MODEL_ACCESS_GROUP_REGISTRY_OVERFLOW_SENTINEL,
|
||||
TAG_REGISTRY_OVERFLOW_SENTINEL,
|
||||
UserApiKeyCache,
|
||||
end_user_cache_key,
|
||||
end_user_restricted_registry_cache_key,
|
||||
get_management_object_ttl,
|
||||
model_access_group_cache_key,
|
||||
model_access_group_registry_cache_key,
|
||||
model_access_group_spend_counter_key,
|
||||
object_permission_cache_key,
|
||||
tag_cache_key,
|
||||
tag_registry_cache_key,
|
||||
|
|
@ -107,12 +112,14 @@ from litellm.repositories.table_repositories import (
|
|||
EndUserRepository,
|
||||
JWTKeyMappingRepository,
|
||||
ManagedVectorStoresRepository,
|
||||
ModelAccessGroupBudgetRepository,
|
||||
TagRepository,
|
||||
TeamMembershipRepository,
|
||||
)
|
||||
from litellm.repositories.team_repository import TeamRepository
|
||||
from litellm.repositories.user_repository import UserRepository
|
||||
from litellm.router import Router
|
||||
from litellm.types.proxy.model_access_group_budget import ModelAccessGroupBudget
|
||||
from litellm.utils import get_utc_datetime
|
||||
|
||||
from .auth_checks_organization import (
|
||||
|
|
@ -251,6 +258,43 @@ def _end_user_table(repo: _PrismaTableHolder[_PrismaEndUserRow]) -> _PrismaAuthT
|
|||
return repo.table
|
||||
|
||||
|
||||
class _PrismaMaxBudgetRow(Protocol):
|
||||
@property
|
||||
def max_budget(self) -> float | None: ...
|
||||
|
||||
|
||||
class _PrismaModelAccessGroupBudgetRow(Protocol):
|
||||
access_group_name: str
|
||||
|
||||
@property
|
||||
def spend(self) -> float | None: ...
|
||||
|
||||
@property
|
||||
def litellm_budget_table(self) -> _PrismaMaxBudgetRow | None: ...
|
||||
|
||||
|
||||
def _model_access_group_budget_table(
|
||||
repo: _PrismaTableHolder[_PrismaModelAccessGroupBudgetRow],
|
||||
) -> _PrismaAuthTable[_PrismaModelAccessGroupBudgetRow]:
|
||||
return repo.table
|
||||
|
||||
|
||||
class _MemberModelScope(Protocol):
|
||||
@property
|
||||
def allowed_models(self) -> Sequence[str] | None: ...
|
||||
|
||||
|
||||
class _TeamMembershipModelScope(Protocol):
|
||||
@property
|
||||
def litellm_budget_table(self) -> _MemberModelScope | None: ...
|
||||
|
||||
|
||||
def _member_allowed_models(membership: _TeamMembershipModelScope) -> Sequence[str]:
|
||||
"""The member's own model scope, read through a narrowed view of the membership row."""
|
||||
budget_table: Final = membership.litellm_budget_table
|
||||
return () if budget_table is None else (budget_table.allowed_models or ())
|
||||
|
||||
|
||||
class _RawCacheRead(Protocol):
|
||||
async def async_get_cache(self, *, key: str) -> object: ...
|
||||
|
||||
|
|
@ -807,6 +851,7 @@ async def common_checks(
|
|||
1.1. If project is blocked
|
||||
2. If team can call model
|
||||
2.2 If project can call model
|
||||
2.3 Which model access groups authorized this request
|
||||
3. If team is in budget
|
||||
3.0.2. If project is in budget
|
||||
3.0.3. If project is over soft budget (alert only)
|
||||
|
|
@ -925,6 +970,18 @@ async def common_checks(
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
# 2.3 Which model access groups authorized this request
|
||||
matched_model_access_groups: Final = await stamp_matched_model_access_groups(
|
||||
model=_model,
|
||||
valid_token=valid_token,
|
||||
team_object=team_object,
|
||||
project_object=project_object,
|
||||
llm_router=llm_router,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
# Run before apply_key_tags_pre_auth injects key metadata.tags into request_body.
|
||||
_reject_clientside_metadata_tags_check(general_settings, request_body, route)
|
||||
|
||||
|
|
@ -1004,6 +1061,13 @@ async def common_checks(
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
valid_token=valid_token,
|
||||
),
|
||||
_model_access_group_max_budget_check(
|
||||
matched_model_access_groups=matched_model_access_groups,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
if matched_model_access_groups
|
||||
else None,
|
||||
_user_max_budget_check(),
|
||||
_check_team_member_budget(
|
||||
team_object=team_object,
|
||||
|
|
@ -1444,6 +1508,7 @@ _REGISTRY_NOT_CACHED: Final = _RegistryNotCached()
|
|||
#: One lock per registry; module-level because the stampede to collapse is worker-wide.
|
||||
_TAG_REGISTRY_LOAD_LOCK: Final = asyncio.Lock()
|
||||
_END_USER_REGISTRY_LOAD_LOCK: Final = asyncio.Lock()
|
||||
_MODEL_ACCESS_GROUP_REGISTRY_LOAD_LOCK: Final = asyncio.Lock()
|
||||
|
||||
|
||||
async def _cached_registry(
|
||||
|
|
@ -1836,6 +1901,105 @@ async def _load_tag_registry(
|
|||
)
|
||||
|
||||
|
||||
async def _load_model_access_group_registry(
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
) -> frozenset[str] | None:
|
||||
"""The set of model access group names that have a row in ``LiteLLM_ModelAccessGroupBudgetTable``."""
|
||||
|
||||
async def fetch_ids() -> tuple[str, ...]:
|
||||
registry_rows: Final = await _model_access_group_budget_table(
|
||||
ModelAccessGroupBudgetRepository(prisma_client)
|
||||
).find_many(take=MODEL_ACCESS_GROUP_REGISTRY_MAX_SIZE + 1)
|
||||
return tuple(row.access_group_name for row in registry_rows)
|
||||
|
||||
return await _load_bounded_registry(
|
||||
cache_key=model_access_group_registry_cache_key(),
|
||||
overflow_sentinel=MODEL_ACCESS_GROUP_REGISTRY_OVERFLOW_SENTINEL,
|
||||
max_size=MODEL_ACCESS_GROUP_REGISTRY_MAX_SIZE,
|
||||
load_lock=_MODEL_ACCESS_GROUP_REGISTRY_LOAD_LOCK,
|
||||
fetch_ids=fetch_ids,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_uncached_model_access_group_budgets(
|
||||
uncached_groups: Sequence[str],
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
) -> tuple[tuple[str, ModelAccessGroupBudget], ...]:
|
||||
"""Budget rows for the groups a cache probe missed.
|
||||
|
||||
No registry gate here, unlike the tag path: the names only ever come from
|
||||
``matched_model_access_groups``, which :func:`collect_matched_model_access_groups` already
|
||||
intersected with the registry, so a name that has no row cannot reach this.
|
||||
"""
|
||||
if not uncached_groups:
|
||||
return ()
|
||||
|
||||
try:
|
||||
db_rows: Final = await _model_access_group_budget_table(
|
||||
ModelAccessGroupBudgetRepository(prisma_client)
|
||||
).find_many(
|
||||
where={"access_group_name": {"in": list(uncached_groups)}},
|
||||
include={"litellm_budget_table": True},
|
||||
)
|
||||
fetched: Final = tuple((row.access_group_name, _model_access_group_budget(row)) for row in db_rows)
|
||||
for fetched_name, fetched_obj in fetched:
|
||||
await user_api_key_cache.async_set_cache(
|
||||
key=model_access_group_cache_key(fetched_name),
|
||||
value=fetched_obj,
|
||||
model_type=ModelAccessGroupBudget,
|
||||
ttl=get_management_object_ttl(user_api_key_cache),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # fail-safe: a budget fetch error must yield "no budget rows", never break auth
|
||||
verbose_proxy_logger.debug("Error batch fetching model access group budgets from database: %s", e)
|
||||
return ()
|
||||
else:
|
||||
return fetched
|
||||
|
||||
|
||||
def _model_access_group_budget(row: _PrismaModelAccessGroupBudgetRow) -> ModelAccessGroupBudget:
|
||||
budget_table: Final = row.litellm_budget_table
|
||||
return ModelAccessGroupBudget(
|
||||
access_group_name=row.access_group_name,
|
||||
spend=row.spend or 0.0,
|
||||
max_budget=None if budget_table is None else budget_table.max_budget,
|
||||
)
|
||||
|
||||
|
||||
@log_db_metrics
|
||||
async def get_model_access_group_budgets_batch(
|
||||
access_group_names: Sequence[str],
|
||||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
) -> dict[str, ModelAccessGroupBudget]:
|
||||
"""Budget rows for the given model access groups, served from cache where possible.
|
||||
|
||||
Shared by the two enforcement paths so they read one row per group per request: the
|
||||
reservation counters when reservations are on, and :func:`_model_access_group_max_budget_check`
|
||||
when ``disable_budget_reservation`` turns them off.
|
||||
"""
|
||||
if prisma_client is None or not access_group_names:
|
||||
return {}
|
||||
|
||||
probed: Final = [
|
||||
(
|
||||
group,
|
||||
await user_api_key_cache.async_get_cache(
|
||||
key=model_access_group_cache_key(group), model_type=ModelAccessGroupBudget
|
||||
),
|
||||
)
|
||||
for group in access_group_names
|
||||
]
|
||||
fetched: Final = await _fetch_uncached_model_access_group_budgets(
|
||||
uncached_groups=tuple(group for group, budget in probed if budget is None),
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
return {group: budget for group, budget in (*probed, *fetched) if budget is not None}
|
||||
|
||||
|
||||
async def _fetch_uncached_tags(
|
||||
uncached_tags: Sequence[str],
|
||||
prisma_client: PrismaClient,
|
||||
|
|
@ -3882,6 +4046,192 @@ def _resolve_key_models_for_auth_check(valid_token: UserAPIKeyAuth) -> list[str]
|
|||
return models
|
||||
|
||||
|
||||
def _model_access_groups_serving_model(
|
||||
model: str | Sequence[str],
|
||||
llm_router: Router,
|
||||
team_id: str | None,
|
||||
) -> frozenset[str]:
|
||||
"""Every model access group whose deployments serve the requested model(s)."""
|
||||
requested: Final = (model,) if isinstance(model, str) else tuple(model)
|
||||
return frozenset(
|
||||
group
|
||||
for requested_model in requested
|
||||
for group in llm_router.get_model_access_groups(model_name=requested_model, team_id=team_id)
|
||||
)
|
||||
|
||||
|
||||
async def _team_member_granted_models(
|
||||
valid_token: UserAPIKeyAuth,
|
||||
team_object: LiteLLM_TeamTable | None,
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> Sequence[str]:
|
||||
"""The member's own ``allowed_models`` scope; empty when the member is not narrowed below the team."""
|
||||
if team_object is None or valid_token.user_id is None:
|
||||
return ()
|
||||
|
||||
team_membership: Final = await get_team_membership(
|
||||
user_id=valid_token.user_id,
|
||||
team_id=team_object.team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
return () if team_membership is None else _member_allowed_models(team_membership)
|
||||
|
||||
|
||||
async def _org_granted_models(
|
||||
valid_token: UserAPIKeyAuth,
|
||||
team_object: LiteLLM_TeamTable | None,
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> Sequence[str]:
|
||||
"""The org allowlist reached through the key, or through its team when the key names no org."""
|
||||
org_id: Final = valid_token.org_id or (team_object.organization_id if team_object is not None else None)
|
||||
if org_id is None:
|
||||
return ()
|
||||
|
||||
try:
|
||||
org_object: Final = await get_org_object(
|
||||
org_id=org_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # fail-safe: attribution degrades to "no org grant", it must never break auth
|
||||
verbose_proxy_logger.debug("access group attribution: org lookup failed: %s", e)
|
||||
return ()
|
||||
return org_object.models if org_object is not None else ()
|
||||
|
||||
|
||||
async def _granted_model_lists(
|
||||
valid_token: UserAPIKeyAuth,
|
||||
team_object: LiteLLM_TeamTable | None,
|
||||
project_object: LiteLLM_ProjectTableCachedObj | None,
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> tuple[Sequence[str], ...]:
|
||||
"""One model allowlist per level that participates in authorizing the request."""
|
||||
return (
|
||||
_resolve_key_models_for_auth_check(valid_token=valid_token),
|
||||
team_object.models if team_object is not None else (),
|
||||
await _team_member_granted_models(
|
||||
valid_token=valid_token,
|
||||
team_object=team_object,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
),
|
||||
project_object.models if project_object is not None else (),
|
||||
await _org_granted_models(
|
||||
valid_token=valid_token,
|
||||
team_object=team_object,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def collect_matched_model_access_groups(
|
||||
model: str | Sequence[str] | None,
|
||||
valid_token: UserAPIKeyAuth | None,
|
||||
team_object: LiteLLM_TeamTable | None,
|
||||
project_object: LiteLLM_ProjectTableCachedObj | None,
|
||||
llm_router: Router | None,
|
||||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> tuple[str, ...]:
|
||||
"""
|
||||
The budgeted model access groups that authorized this request, sorted and deduplicated.
|
||||
|
||||
A group is charged only when its name appears on an allowlist the caller was granted -- key,
|
||||
team, team-member scope, project or org -- *and* that group serves the requested model. Asking
|
||||
for a model that merely belongs to a group attributes nothing, because nothing about the caller
|
||||
named the group.
|
||||
|
||||
Levels are unioned, never ranked: a team granted ``*`` whose member is scoped to one group is
|
||||
still a caller gated by that group. An unrestricted allowlist (empty, ``*``) names no group and
|
||||
so contributes nothing.
|
||||
|
||||
The whole walk is gated on the budget registry, because collecting every match costs a full scan
|
||||
of each allowlist where the plain access check stops at the first hit. An empty registry means no
|
||||
group carries a budget, so there is nothing to attribute and no work worth doing.
|
||||
"""
|
||||
if model is None or valid_token is None or llm_router is None or prisma_client is None:
|
||||
return ()
|
||||
|
||||
registry: Final = await _load_model_access_group_registry(
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
if registry is not None and not registry:
|
||||
return ()
|
||||
|
||||
covering_groups: Final = _model_access_groups_serving_model(
|
||||
model=model,
|
||||
llm_router=llm_router,
|
||||
team_id=valid_token.team_id,
|
||||
)
|
||||
budgeted_groups: Final = covering_groups if registry is None else covering_groups & registry
|
||||
if not budgeted_groups:
|
||||
return ()
|
||||
|
||||
granted: Final = frozenset(
|
||||
granted_model
|
||||
for granted_models in await _granted_model_lists(
|
||||
valid_token=valid_token,
|
||||
team_object=team_object,
|
||||
project_object=project_object,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
for granted_model in granted_models
|
||||
)
|
||||
return tuple(sorted(budgeted_groups & granted))
|
||||
|
||||
|
||||
async def stamp_matched_model_access_groups(
|
||||
model: str | Sequence[str] | None,
|
||||
valid_token: UserAPIKeyAuth | None,
|
||||
team_object: LiteLLM_TeamTable | None,
|
||||
project_object: LiteLLM_ProjectTableCachedObj | None,
|
||||
llm_router: Router | None,
|
||||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> tuple[str, ...]:
|
||||
"""Record the groups that authorized this request on its auth object, for the post-call spend
|
||||
writer and the reservation counters, and hand them back for the budget check."""
|
||||
if valid_token is None:
|
||||
return ()
|
||||
|
||||
try:
|
||||
matched: Final = await collect_matched_model_access_groups(
|
||||
model=model,
|
||||
valid_token=valid_token,
|
||||
team_object=team_object,
|
||||
project_object=project_object,
|
||||
llm_router=llm_router,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # fail-safe: attribution is spend telemetry, it must never break auth
|
||||
verbose_proxy_logger.debug("model access group attribution failed: %s", e)
|
||||
return ()
|
||||
if not matched:
|
||||
return ()
|
||||
matched_groups: Final = list(matched) # mutable-ok: the auth field is typed list[str] | None
|
||||
valid_token.matched_model_access_groups = matched_groups # rebind-ok: request-scoped carrier for the writer
|
||||
return matched
|
||||
|
||||
|
||||
async def can_key_call_model(
|
||||
model: str | list[str],
|
||||
llm_model_list: list | None,
|
||||
|
|
@ -5258,6 +5608,61 @@ async def _tag_max_budget_check(
|
|||
)
|
||||
|
||||
|
||||
async def _model_access_group_max_budget_check(
|
||||
matched_model_access_groups: Sequence[str],
|
||||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
) -> None:
|
||||
"""Block the request when a model access group that authorized it is over its max budget.
|
||||
|
||||
Only the groups auth already matched are charged and therefore only they are checked, so a
|
||||
request that no budgeted group authorized costs nothing here.
|
||||
|
||||
Like the tag check this is a plain read with no reservation, so concurrent requests can
|
||||
overshoot the ceiling slightly. The reservation counters are the precise path; this one covers
|
||||
the ``disable_budget_reservation`` case.
|
||||
|
||||
The ceiling is exclusive, unlike the tag check it otherwise mirrors: a pool whose recorded
|
||||
spend has reached ``max_budget`` has nothing left to give, so the next request is refused.
|
||||
Keys and organizations already draw the line there. A non-positive budget means no budget,
|
||||
matching what the reservation path treats as unbudgeted.
|
||||
|
||||
Raises:
|
||||
BudgetExceededError if a matched group is over its max budget.
|
||||
"""
|
||||
if prisma_client is None or not matched_model_access_groups:
|
||||
return
|
||||
|
||||
budgets: Final = await get_model_access_group_budgets_batch(
|
||||
access_group_names=matched_model_access_groups,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
|
||||
from litellm.proxy.proxy_server import get_current_spend
|
||||
|
||||
for group in matched_model_access_groups:
|
||||
budget = budgets.get(group)
|
||||
if budget is None or budget.max_budget is None or budget.max_budget <= 0:
|
||||
continue
|
||||
|
||||
group_spend = await get_current_spend(
|
||||
counter_key=model_access_group_spend_counter_key(group),
|
||||
fallback_spend=budget.spend,
|
||||
max_budget=budget.max_budget,
|
||||
fallback_authoritative=True,
|
||||
)
|
||||
if group_spend < budget.max_budget:
|
||||
continue
|
||||
raise litellm.BudgetExceededError(
|
||||
current_cost=group_spend,
|
||||
max_budget=budget.max_budget,
|
||||
message=f"Budget has been exceeded! Model access group={group} Current cost: {group_spend}, Max budget: {budget.max_budget}",
|
||||
entity_type=Litellm_EntityType.MODEL_ACCESS_GROUP.value,
|
||||
entity_id=group,
|
||||
)
|
||||
|
||||
|
||||
def is_model_allowed_by_pattern(model: str, allowed_model_pattern: str) -> bool:
|
||||
"""
|
||||
Check if a model matches an allowed pattern.
|
||||
|
|
|
|||
|
|
@ -35,7 +35,11 @@ from litellm.proxy.common_utils.timezone_utils import (
|
|||
compute_budget_reset_at,
|
||||
get_budget_reset_settings,
|
||||
)
|
||||
from litellm.proxy.common_utils.user_api_key_cache import tag_cache_key
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
model_access_group_cache_key,
|
||||
model_access_group_spend_counter_key,
|
||||
tag_cache_key,
|
||||
)
|
||||
from litellm.proxy.db.budget_window_spend_writer import roll_window_spend_row
|
||||
from litellm.proxy.db.db_transaction_queue.pod_lock_manager import PodLockManager
|
||||
from litellm.proxy.db.exception_handler import call_with_db_reconnect_retry
|
||||
|
|
@ -44,6 +48,7 @@ from litellm.repositories.organization_repository import OrganizationRepository
|
|||
from litellm.repositories.prisma_protocols import SpendLinkedTable
|
||||
from litellm.repositories.table_repositories import (
|
||||
EndUserRepository,
|
||||
ModelAccessGroupBudgetRepository,
|
||||
TagRepository,
|
||||
TeamMembershipRepository,
|
||||
)
|
||||
|
|
@ -95,6 +100,11 @@ class _TagRow(_BudgetLinkedRow, Protocol):
|
|||
def tag_name(self) -> str: ...
|
||||
|
||||
|
||||
class _ModelAccessGroupRow(_BudgetLinkedRow, Protocol):
|
||||
@property
|
||||
def access_group_name(self) -> str: ...
|
||||
|
||||
|
||||
class _EndUserRow(_BudgetLinkedRow, Protocol):
|
||||
@property
|
||||
def user_id(self) -> str: ...
|
||||
|
|
@ -157,6 +167,14 @@ def _tag_cache_keys(row: _TagRow) -> tuple[str, ...]:
|
|||
return (tag_cache_key(row.tag_name),)
|
||||
|
||||
|
||||
def _model_access_group_counter_key(row: _ModelAccessGroupRow) -> str:
|
||||
return model_access_group_spend_counter_key(row.access_group_name)
|
||||
|
||||
|
||||
def _model_access_group_cache_keys(row: _ModelAccessGroupRow) -> tuple[str, ...]:
|
||||
return (model_access_group_cache_key(row.access_group_name),)
|
||||
|
||||
|
||||
def _budget_link_where(
|
||||
budget_ids: Sequence[str],
|
||||
extra: Mapping[str, object] = MappingProxyType({}),
|
||||
|
|
@ -616,6 +634,11 @@ class ResetBudgetJob:
|
|||
where=_budget_link_where(budget_ids, _SPENT_ROWS_WHERE),
|
||||
log_subject="tags",
|
||||
)
|
||||
model_access_groups: Final[tuple[_ModelAccessGroupRow, ...]] = await self._fetch_linked_rows(
|
||||
table=ModelAccessGroupBudgetRepository(self.prisma_client).table,
|
||||
where=_budget_link_where(budget_ids, _SPENT_ROWS_WHERE),
|
||||
log_subject="model access groups",
|
||||
)
|
||||
rollover_caps: Final[Mapping[str, float]] = MappingProxyType(
|
||||
{ # mutable-ok: MappingProxyType wraps a one-shot dict comprehension
|
||||
b.budget_id: cap
|
||||
|
|
@ -645,6 +668,10 @@ class ResetBudgetJob:
|
|||
*((_key_counter_key(row), _row_carried_spend(row, rollover_caps)) for row in keys),
|
||||
*((_org_counter_key(row), _row_carried_spend(row, rollover_caps)) for row in orgs),
|
||||
*((_tag_counter_key(row), _row_carried_spend(row, rollover_caps)) for row in tags),
|
||||
*(
|
||||
(_model_access_group_counter_key(row), _row_carried_spend(row, rollover_caps))
|
||||
for row in model_access_groups
|
||||
),
|
||||
),
|
||||
rollover_caps=rollover_caps,
|
||||
cache_keys=(
|
||||
|
|
@ -652,6 +679,7 @@ class ResetBudgetJob:
|
|||
*(key for row in keys for key in _key_cache_keys(row)),
|
||||
*(key for row in orgs for key in _org_cache_keys(row)),
|
||||
*(key for row in tags for key in _tag_cache_keys(row)),
|
||||
*(key for row in model_access_groups for key in _model_access_group_cache_keys(row)),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -677,6 +705,7 @@ class ResetBudgetJob:
|
|||
_queue_budget_linked_resets(uow.keys, cascade, extra=_LINKED_KEYS_WHERE)
|
||||
_queue_budget_linked_resets(uow.organizations, cascade, extra=_SPENT_ROWS_WHERE)
|
||||
_queue_budget_linked_resets(uow.tags, cascade, extra=_SPENT_ROWS_WHERE)
|
||||
_queue_budget_linked_resets(uow.model_access_groups, cascade, extra=_SPENT_ROWS_WHERE)
|
||||
_queue_enduser_resets(uow.endusers, cascade)
|
||||
for budget_id, budget_reset_at in cascade.budget_resets:
|
||||
uow.budgets.queue_window_advance(budget_id=budget_id, budget_reset_at=budget_reset_at)
|
||||
|
|
@ -720,7 +749,8 @@ class ResetBudgetJob:
|
|||
async def reset_budget_for_litellm_budget_table(self) -> None:
|
||||
"""
|
||||
Resets the spend a budget tier gates (end users, team members, keys,
|
||||
orgs, tags) and advances the tier's budget_reset_at, atomically.
|
||||
orgs, tags, model access groups) and advances the tier's
|
||||
budget_reset_at, atomically.
|
||||
|
||||
Caches are invalidated only after the transaction commits, so a failed
|
||||
run cannot leave a zeroed counter in front of an un-reset DB row.
|
||||
|
|
@ -751,8 +781,9 @@ class ResetBudgetJob:
|
|||
return _ChunkOutcome(fetched=len(cascade.budgets), advanced=advanced)
|
||||
case _BudgetCascadeFailed(cascade=cascade, error=error):
|
||||
verbose_proxy_logger.exception(
|
||||
"Failed to reset the budget table cascade (team member, enduser, org and tag spend, plus "
|
||||
"budget_reset_at); nothing was committed and the budgets stay due for the next run: %s",
|
||||
"Failed to reset the budget table cascade (team member, enduser, org, tag and model access "
|
||||
"group spend, plus budget_reset_at); nothing was committed and the budgets stay due for the "
|
||||
"next run: %s",
|
||||
error,
|
||||
exc_info=error,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -185,6 +185,32 @@ def tag_registry_cache_key() -> str:
|
|||
return "tag_registry"
|
||||
|
||||
|
||||
#: Cached under ``model_access_group_registry_cache_key`` when the table exceeds
|
||||
#: ``MODEL_ACCESS_GROUP_REGISTRY_MAX_SIZE``: registry unusable, fall back to the per-group lookup.
|
||||
MODEL_ACCESS_GROUP_REGISTRY_OVERFLOW_SENTINEL: Final = "__model_access_group_registry_overflow__"
|
||||
|
||||
|
||||
def model_access_group_cache_key(access_group_name: str) -> str:
|
||||
"""Cache key one model access group budget row is stored under; shared so auth, spend tracking and the management endpoints cannot drift."""
|
||||
return f"model_access_group:{access_group_name}"
|
||||
|
||||
|
||||
def model_access_group_registry_cache_key() -> str:
|
||||
"""Cache key for the set of model access group names that have a budget row."""
|
||||
return "model_access_group_registry"
|
||||
|
||||
|
||||
def model_access_group_spend_counter_key(access_group_name: str) -> str:
|
||||
"""Spend counter key for one model access group; shared so its four owners cannot drift.
|
||||
|
||||
The reservation path writes it up front, the cost callback writes it after the call, auth
|
||||
reads it to enforce ``max_budget``, and the reset job clears it on rollover. A copy that
|
||||
drifts in any one of them silently resets or reads a counter nobody else touches, which shows
|
||||
up as a budget that never trips or never resets.
|
||||
"""
|
||||
return f"spend:model_access_group:{access_group_name}"
|
||||
|
||||
|
||||
#: Cached under ``end_user_restricted_registry_cache_key`` when the restricted set exceeds
|
||||
#: ``END_USER_RESTRICTED_REGISTRY_MAX_SIZE``: registry unusable, fall back to the per-id fetch.
|
||||
END_USER_RESTRICTED_REGISTRY_OVERFLOW_SENTINEL: Final = "__end_user_restricted_registry_overflow__"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import os
|
|||
import random
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, cast, overload
|
||||
|
||||
|
|
@ -24,6 +24,7 @@ from litellm.constants import (
|
|||
DB_SPEND_UPDATE_JOB_NAME,
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY,
|
||||
)
|
||||
from litellm.litellm_core_utils.litellm_logging import coerce_model_access_groups
|
||||
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
|
||||
from litellm.proxy._types import (
|
||||
DB_RETRY_SAFE_ERROR_TYPES,
|
||||
|
|
@ -91,6 +92,7 @@ class _SpendBatch(Protocol):
|
|||
litellm_organizationtable: BatchTable
|
||||
litellm_tagtable: BatchTable
|
||||
litellm_agentstable: BatchTable
|
||||
litellm_modelaccessgroupbudgettable: BatchTable
|
||||
|
||||
|
||||
class _SpendBatchManager(Protocol):
|
||||
|
|
@ -114,7 +116,7 @@ def _spend_update_tx(prisma_client: PrismaClient) -> _SpendTransactionManager:
|
|||
return tx
|
||||
|
||||
|
||||
def _get_llm_router():
|
||||
def get_llm_router():
|
||||
"""The proxy's router, or None outside a running proxy.
|
||||
|
||||
Injected rather than imported where it is used, so the savings computation stays
|
||||
|
|
@ -128,6 +130,52 @@ def _get_llm_router():
|
|||
return None
|
||||
|
||||
|
||||
class _DeploymentLookup(Protocol):
|
||||
def get_model_info(self, id: str) -> Mapping[str, object] | None: ...
|
||||
|
||||
|
||||
def _served_model_access_groups(
|
||||
router: _DeploymentLookup | None,
|
||||
served_model_id: str | None,
|
||||
) -> frozenset[str] | None:
|
||||
"""Access groups declared by the deployment that actually served the request.
|
||||
|
||||
None when the served deployment cannot be identified, in which case the set
|
||||
attributed at auth time stands unchanged.
|
||||
"""
|
||||
if router is None or not served_model_id:
|
||||
return None
|
||||
deployment: Final = router.get_model_info(id=served_model_id)
|
||||
if deployment is None:
|
||||
return None
|
||||
model_info: Final = deployment.get("model_info")
|
||||
if not isinstance(model_info, Mapping):
|
||||
return None
|
||||
declared: Final = model_info.get("access_groups")
|
||||
if not isinstance(declared, (list, tuple)):
|
||||
return frozenset()
|
||||
return frozenset(group for group in declared if isinstance(group, str))
|
||||
|
||||
|
||||
def debitable_model_access_groups(
|
||||
attributed: Sequence[str] | None,
|
||||
served_model_id: str | None,
|
||||
router: _DeploymentLookup | None,
|
||||
) -> tuple[str, ...]:
|
||||
"""Groups to debit: the set attributed at auth time, narrowed to those the served model belongs to.
|
||||
|
||||
The router may fall back to a model outside the pool auth reserved against, so the
|
||||
attributed set is the hard upper bound: a group absent from it is never debited.
|
||||
"""
|
||||
ordered: Final = coerce_model_access_groups(attributed)
|
||||
if not ordered:
|
||||
return ()
|
||||
served: Final = _served_model_access_groups(router=router, served_model_id=served_model_id)
|
||||
if served is None:
|
||||
return ordered
|
||||
return tuple(group for group in ordered if group in served)
|
||||
|
||||
|
||||
class DBSpendUpdateWriter:
|
||||
"""
|
||||
Module responsible for
|
||||
|
|
@ -197,6 +245,7 @@ class DBSpendUpdateWriter:
|
|||
## CREATE SPEND LOG PAYLOAD ##
|
||||
from litellm.proxy.spend_tracking.spend_tracking_utils import (
|
||||
get_logging_payload,
|
||||
get_request_model_access_groups,
|
||||
)
|
||||
|
||||
payload: Final = get_logging_payload(
|
||||
|
|
@ -249,6 +298,7 @@ class DBSpendUpdateWriter:
|
|||
prisma_client=prisma_client,
|
||||
litellm_proxy_budget_name=litellm_proxy_budget_name,
|
||||
payload=payload,
|
||||
request_model_access_groups=get_request_model_access_groups(kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -332,7 +382,7 @@ class DBSpendUpdateWriter:
|
|||
routing_decision=metadata.get("routing_decision"),
|
||||
usage_object=usage_object_raw if isinstance(usage_object_raw, dict) else None,
|
||||
model_id=payload.get("model_id"),
|
||||
llm_router=_get_llm_router,
|
||||
llm_router=get_llm_router,
|
||||
cost_breakdown=metadata.get("cost_breakdown"),
|
||||
recorded_autorouter_savings=metadata.get("autorouter_savings"),
|
||||
)
|
||||
|
|
@ -443,9 +493,10 @@ class DBSpendUpdateWriter:
|
|||
prisma_client: PrismaClient | None,
|
||||
litellm_proxy_budget_name: str | None,
|
||||
payload: SpendLogsPayload,
|
||||
request_model_access_groups: Sequence[str] = (),
|
||||
):
|
||||
"""
|
||||
Runs all 11 spend-update helpers sequentially inside a single asyncio task.
|
||||
Runs all 13 spend-update helpers sequentially inside a single asyncio task.
|
||||
|
||||
Each helper is wrapped in try/except so one failure doesn't prevent the others.
|
||||
|
||||
|
|
@ -517,6 +568,14 @@ class DBSpendUpdateWriter:
|
|||
traceback.format_exc(),
|
||||
)
|
||||
|
||||
await self._update_model_access_group_db(
|
||||
response_cost=response_cost,
|
||||
request_model_access_groups=request_model_access_groups,
|
||||
served_model_id=payload_copy.get("model_id"),
|
||||
prisma_client=prisma_client,
|
||||
router=get_llm_router(),
|
||||
)
|
||||
|
||||
_agent_id_for_spend: Final = payload_copy.get("agent_id")
|
||||
try:
|
||||
await self._update_agent_db(
|
||||
|
|
@ -826,6 +885,50 @@ class DBSpendUpdateWriter:
|
|||
)
|
||||
raise e
|
||||
|
||||
async def _update_model_access_group_db(
|
||||
self,
|
||||
response_cost: float | None,
|
||||
request_model_access_groups: Sequence[str] | None,
|
||||
served_model_id: str | None,
|
||||
prisma_client: PrismaClient | None,
|
||||
router: _DeploymentLookup | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Update spend for every model access group this request is billed against.
|
||||
|
||||
Args:
|
||||
response_cost: Cost of the request, charged in full to each group
|
||||
request_model_access_groups: Groups attributed at auth time, the upper bound on what may be debited
|
||||
served_model_id: Deployment id actually served, used to narrow the attributed set
|
||||
prisma_client: Prisma client instance
|
||||
router: Deployment lookup used to re-resolve groups after a fallback
|
||||
"""
|
||||
try:
|
||||
if prisma_client is None:
|
||||
return
|
||||
|
||||
for model_access_group in debitable_model_access_groups(
|
||||
attributed=request_model_access_groups,
|
||||
served_model_id=served_model_id,
|
||||
router=router,
|
||||
):
|
||||
await self.spend_update_queue.add_update(
|
||||
update=SpendUpdateQueueItem(
|
||||
entity_type=Litellm_EntityType.MODEL_ACCESS_GROUP,
|
||||
entity_id=model_access_group,
|
||||
response_cost=response_cost,
|
||||
)
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # isolation: a helper failure must not stop the batch
|
||||
spend_log_error(
|
||||
"Spend tracking - failed to enqueue model access group spend update. "
|
||||
"model_access_groups=%s, response_cost=%s - %s",
|
||||
request_model_access_groups,
|
||||
response_cost,
|
||||
str(e),
|
||||
exc=e,
|
||||
)
|
||||
|
||||
async def _insert_spend_log_to_db(
|
||||
self,
|
||||
payload: dict | SpendLogsPayload,
|
||||
|
|
@ -942,7 +1045,8 @@ class DBSpendUpdateWriter:
|
|||
if db_spend_update_transactions is not None:
|
||||
verbose_proxy_logger.info(
|
||||
"Spend tracking - committing spend updates from Redis to DB: "
|
||||
"keys=%d, users=%d, teams=%d, orgs=%d, end_users=%d, team_members=%d, tags=%d, agents=%d",
|
||||
"keys=%d, users=%d, teams=%d, orgs=%d, end_users=%d, team_members=%d, tags=%d, agents=%d, "
|
||||
"model_access_groups=%d",
|
||||
len(db_spend_update_transactions.get("key_list_transactions") or {}),
|
||||
len(db_spend_update_transactions.get("user_list_transactions") or {}),
|
||||
len(db_spend_update_transactions.get("team_list_transactions") or {}),
|
||||
|
|
@ -951,6 +1055,7 @@ class DBSpendUpdateWriter:
|
|||
len(db_spend_update_transactions.get("team_member_list_transactions") or {}),
|
||||
len(db_spend_update_transactions.get("tag_list_transactions") or {}),
|
||||
len(db_spend_update_transactions.get("agent_list_transactions") or {}),
|
||||
len(db_spend_update_transactions.get("model_access_group_list_transactions") or {}),
|
||||
)
|
||||
await self._commit_spend_updates_to_db(
|
||||
prisma_client=prisma_client,
|
||||
|
|
@ -1497,6 +1602,20 @@ class DBSpendUpdateWriter:
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
### UPDATE MODEL ACCESS GROUP TABLE ###
|
||||
model_access_group_list_transactions: Final = db_spend_update_transactions.get(
|
||||
"model_access_group_list_transactions"
|
||||
)
|
||||
await DBSpendUpdateWriter._update_entity_spend_in_db(
|
||||
entity_name="Model access group",
|
||||
transactions=model_access_group_list_transactions,
|
||||
table_accessor="litellm_modelaccessgroupbudgettable",
|
||||
where_field="access_group_name",
|
||||
n_retry_times=n_retry_times,
|
||||
prisma_client=prisma_client,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
### UPDATE AGENT TABLE ###
|
||||
agent_list_transactions: Final = db_spend_update_transactions["agent_list_transactions"]
|
||||
await DBSpendUpdateWriter._update_entity_spend_in_db(
|
||||
|
|
@ -1513,7 +1632,7 @@ class DBSpendUpdateWriter:
|
|||
async def _update_entity_spend_in_db(
|
||||
entity_name: str,
|
||||
transactions: dict[str, float] | None,
|
||||
table_accessor: Literal["litellm_tagtable", "litellm_agentstable"],
|
||||
table_accessor: Literal["litellm_tagtable", "litellm_agentstable", "litellm_modelaccessgroupbudgettable"],
|
||||
where_field: str,
|
||||
n_retry_times: int,
|
||||
prisma_client: PrismaClient,
|
||||
|
|
@ -1948,7 +2067,7 @@ class DBSpendUpdateWriter:
|
|||
gateway_injected_cache=marks_gateway_injection(_metadata, payload.get("model_id")),
|
||||
routing_decision=_metadata.get("routing_decision"),
|
||||
model_id=payload.get("model_id"),
|
||||
llm_router=_get_llm_router,
|
||||
llm_router=get_llm_router,
|
||||
usage_object=usage_obj,
|
||||
cost_breakdown=_metadata.get("cost_breakdown"),
|
||||
recorded_autorouter_savings=_metadata.get("autorouter_savings"),
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ _SpendTransactionField: TypeAlias = Literal[
|
|||
"org_list_transactions",
|
||||
"tag_list_transactions",
|
||||
"agent_list_transactions",
|
||||
"model_access_group_list_transactions",
|
||||
]
|
||||
|
||||
_SPEND_TRANSACTION_FIELDS: Final[tuple[_SpendTransactionField, ...]] = (
|
||||
|
|
@ -81,6 +82,7 @@ _SPEND_TRANSACTION_FIELDS: Final[tuple[_SpendTransactionField, ...]] = (
|
|||
"org_list_transactions",
|
||||
"tag_list_transactions",
|
||||
"agent_list_transactions",
|
||||
"model_access_group_list_transactions",
|
||||
)
|
||||
|
||||
_ValueT = TypeVar("_ValueT")
|
||||
|
|
@ -417,6 +419,10 @@ class RedisUpdateBuffer:
|
|||
Litellm_EntityType.AGENT,
|
||||
db_spend_update_transactions.get("agent_list_transactions"),
|
||||
),
|
||||
(
|
||||
Litellm_EntityType.MODEL_ACCESS_GROUP,
|
||||
db_spend_update_transactions.get("model_access_group_list_transactions"),
|
||||
),
|
||||
]
|
||||
for entity_type, entities in entity_entries:
|
||||
if not entities:
|
||||
|
|
@ -866,6 +872,9 @@ class RedisUpdateBuffer:
|
|||
org_list_transactions=_merged_entity_transactions(list_of_transactions, "org_list_transactions"),
|
||||
tag_list_transactions=_merged_entity_transactions(list_of_transactions, "tag_list_transactions"),
|
||||
agent_list_transactions=_merged_entity_transactions(list_of_transactions, "agent_list_transactions"),
|
||||
model_access_group_list_transactions=_merged_entity_transactions(
|
||||
list_of_transactions, "model_access_group_list_transactions"
|
||||
),
|
||||
)
|
||||
|
||||
async def _emit_new_item_added_to_redis_buffer_event(
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ class SpendUpdateQueue(BaseUpdateQueue):
|
|||
org_list_transactions={},
|
||||
tag_list_transactions={},
|
||||
agent_list_transactions={},
|
||||
model_access_group_list_transactions={},
|
||||
)
|
||||
|
||||
# Map entity types to their corresponding transaction dictionary keys
|
||||
|
|
@ -151,6 +152,7 @@ class SpendUpdateQueue(BaseUpdateQueue):
|
|||
Litellm_EntityType.ORGANIZATION: "org_list_transactions",
|
||||
Litellm_EntityType.TAG: "tag_list_transactions",
|
||||
Litellm_EntityType.AGENT: "agent_list_transactions",
|
||||
Litellm_EntityType.MODEL_ACCESS_GROUP: "model_access_group_list_transactions",
|
||||
}
|
||||
|
||||
for update in updates:
|
||||
|
|
@ -190,6 +192,8 @@ class SpendUpdateQueue(BaseUpdateQueue):
|
|||
transactions_dict = db_spend_update_transactions["tag_list_transactions"]
|
||||
elif dict_key == "agent_list_transactions":
|
||||
transactions_dict = db_spend_update_transactions["agent_list_transactions"]
|
||||
elif dict_key == "model_access_group_list_transactions":
|
||||
transactions_dict = db_spend_update_transactions["model_access_group_list_transactions"]
|
||||
else:
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import traceback
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
|
|
@ -20,6 +21,10 @@ from litellm.proxy.auth.auth_checks import (
|
|||
log_db_metrics,
|
||||
)
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.db.db_spend_update_writer import (
|
||||
debitable_model_access_groups,
|
||||
get_llm_router,
|
||||
)
|
||||
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
|
||||
from litellm.proxy.spend_tracking.spend_log_error_logger import (
|
||||
should_suppress_spend_log_tracebacks,
|
||||
|
|
@ -27,6 +32,7 @@ from litellm.proxy.spend_tracking.spend_log_error_logger import (
|
|||
)
|
||||
from litellm.proxy.spend_tracking.spend_tracking_utils import (
|
||||
_sanitize_error_information_for_spend_logs,
|
||||
get_request_model_access_groups,
|
||||
)
|
||||
from litellm.proxy.utils import ProxyUpdateSpend
|
||||
from litellm.types.utils import (
|
||||
|
|
@ -258,6 +264,11 @@ class _ProxyDBLogger(CustomLogger):
|
|||
sl_object=sl_object,
|
||||
metadata=metadata,
|
||||
)
|
||||
model_access_groups: Final = debitable_model_access_groups(
|
||||
attributed=get_request_model_access_groups(kwargs),
|
||||
served_model_id=sl_object.get("model_id") if sl_object is not None else None,
|
||||
router=get_llm_router(),
|
||||
)
|
||||
|
||||
if response_cost is not None:
|
||||
user_api_key: Final = metadata.get("user_api_key", None)
|
||||
|
|
@ -296,6 +307,7 @@ class _ProxyDBLogger(CustomLogger):
|
|||
response_cost=response_cost,
|
||||
budget_reservation=budget_reservation,
|
||||
request_tags=tags,
|
||||
model_access_groups=model_access_groups,
|
||||
)
|
||||
|
||||
# update cache (fire-and-forget for backward compat:
|
||||
|
|
@ -572,6 +584,7 @@ async def _update_database_and_spend_counters(
|
|||
response_cost: float,
|
||||
budget_reservation: dict | None,
|
||||
request_tags: list[str] | None = None,
|
||||
model_access_groups: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
spend_log_request_id = await proxy_logging_obj.db_spend_update_writer.update_database(
|
||||
|
|
@ -612,6 +625,7 @@ async def _update_database_and_spend_counters(
|
|||
tags=request_tags,
|
||||
request_id=spend_log_request_id,
|
||||
request_started_at=start_time,
|
||||
model_access_groups=model_access_groups,
|
||||
)
|
||||
except Exception:
|
||||
if budget_reservation is not None:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
|||
_request_blocked_callback_params,
|
||||
iter_client_callback_metadata_dicts,
|
||||
)
|
||||
from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY
|
||||
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
|
||||
from litellm.litellm_core_utils.url_utils import (
|
||||
is_url_destination_allowed_by_host,
|
||||
|
|
@ -1378,6 +1379,10 @@ class LiteLLMProxyRequestSetup:
|
|||
)
|
||||
if user_api_key_dict.budget_reservation is not None:
|
||||
data[_metadata_variable_name]["user_api_key_budget_reservation"] = user_api_key_dict.budget_reservation
|
||||
if user_api_key_dict.matched_model_access_groups:
|
||||
data[_metadata_variable_name][MODEL_ACCESS_GROUP_METADATA_KEY] = (
|
||||
user_api_key_dict.matched_model_access_groups
|
||||
)
|
||||
# UserAPIKeyAuth object for MCP server access control
|
||||
data[_metadata_variable_name]["user_api_key_auth"] = user_api_key_dict.model_copy(
|
||||
update={
|
||||
|
|
|
|||
|
|
@ -2,18 +2,33 @@
|
|||
Allow proxy admin to manage model access groups
|
||||
|
||||
Endpoints here:
|
||||
- POST /model_group/new - Create a new access group with multiple model names
|
||||
- POST /access_group/new - Create a new access group with multiple model names
|
||||
- GET /access_group/list - List every access group
|
||||
- GET /access_group/{access_group}/info - Read one access group, including its budget
|
||||
- PUT /access_group/{access_group}/update - Replace an access group's deployments
|
||||
- DELETE /access_group/{access_group}/delete - Delete an access group and its budget
|
||||
- GET /access_group/{access_group}/budget - Read an access group's shared budget and spend
|
||||
- PUT /access_group/{access_group}/budget - Set or replace an access group's shared budget
|
||||
- DELETE /access_group/{access_group}/budget - Clear an access group's shared budget
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final, Protocol
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Final, Protocol
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
UserApiKeyCache,
|
||||
model_access_group_cache_key,
|
||||
model_access_group_registry_cache_key,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.common_utils import validate_budget_duration
|
||||
|
||||
# Clear cache and reload models to pick up the access group changes
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
|
|
@ -22,10 +37,16 @@ from litellm.proxy.management_endpoints.model_management_endpoints import (
|
|||
model_info_as_mapping,
|
||||
reload_serving_verdict,
|
||||
)
|
||||
from litellm.proxy.management_helpers.utils import handle_budget_for_entity
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
from litellm.repositories.model_repository import ModelRepository
|
||||
from litellm.repositories.table_repositories import ModelAccessGroupBudgetRepository
|
||||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
AccessGroupBudget,
|
||||
AccessGroupBudgetRequest,
|
||||
AccessGroupBudgetResponse,
|
||||
AccessGroupInfo,
|
||||
DeleteAccessGroupBudgetResponse,
|
||||
DeleteModelGroupResponse,
|
||||
ListAccessGroupsResponse,
|
||||
NewModelGroupRequest,
|
||||
|
|
@ -36,7 +57,43 @@ from litellm.types.proxy.management_endpoints.model_management_endpoints import
|
|||
if TYPE_CHECKING:
|
||||
from litellm import Router
|
||||
|
||||
router: Final = APIRouter()
|
||||
router: Final = APIRouter(tags=["model management"])
|
||||
|
||||
_AUTH_DEPENDENCIES: Final = (Depends(user_api_key_auth),)
|
||||
|
||||
|
||||
class _ErrorDetail(TypedDict):
|
||||
error: ReadOnly[str]
|
||||
|
||||
|
||||
class _ModelAccessGroupWhere(TypedDict):
|
||||
access_group_name: ReadOnly[str]
|
||||
|
||||
|
||||
class _BudgetInclude(TypedDict):
|
||||
litellm_budget_table: ReadOnly[bool]
|
||||
|
||||
|
||||
class _ModelAccessGroupBudgetCreate(TypedDict):
|
||||
access_group_name: ReadOnly[str]
|
||||
budget_id: ReadOnly[str | None]
|
||||
created_by: ReadOnly[str]
|
||||
updated_by: ReadOnly[str]
|
||||
|
||||
|
||||
class _ModelAccessGroupBudgetUpdate(TypedDict):
|
||||
budget_id: ReadOnly[str | None]
|
||||
updated_by: ReadOnly[str]
|
||||
|
||||
|
||||
class _ModelAccessGroupBudgetUpsert(TypedDict):
|
||||
create: ReadOnly[_ModelAccessGroupBudgetCreate]
|
||||
update: ReadOnly[_ModelAccessGroupBudgetUpdate]
|
||||
|
||||
|
||||
def _http_error(status_code: int, message: str) -> HTTPException:
|
||||
detail: Final[_ErrorDetail] = {"error": message}
|
||||
return HTTPException(status_code=status_code, detail=detail)
|
||||
|
||||
|
||||
class _DeploymentRow(Protocol):
|
||||
|
|
@ -58,10 +115,140 @@ class _ModelTableClient(Protocol):
|
|||
async def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> object: ...
|
||||
|
||||
|
||||
class _BudgetRow(Protocol):
|
||||
@property
|
||||
def budget_id(self) -> str: ...
|
||||
|
||||
@property
|
||||
def max_budget(self) -> float | None: ...
|
||||
|
||||
@property
|
||||
def soft_budget(self) -> float | None: ...
|
||||
|
||||
@property
|
||||
def budget_duration(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def budget_reset_at(self) -> datetime | None: ...
|
||||
|
||||
|
||||
class _ModelAccessGroupBudgetRow(Protocol):
|
||||
@property
|
||||
def spend(self) -> float: ...
|
||||
|
||||
@property
|
||||
def budget_id(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def litellm_budget_table(self) -> _BudgetRow | None: ...
|
||||
|
||||
|
||||
class _ModelAccessGroupBudgetTableClient(Protocol):
|
||||
async def find_unique(
|
||||
self, *, where: Mapping[str, object], include: Mapping[str, object] | None = None
|
||||
) -> _ModelAccessGroupBudgetRow | None: ...
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
*,
|
||||
where: Mapping[str, object],
|
||||
data: Mapping[str, object],
|
||||
include: Mapping[str, object] | None = None,
|
||||
) -> _ModelAccessGroupBudgetRow: ...
|
||||
|
||||
async def delete(self, *, where: Mapping[str, object]) -> _ModelAccessGroupBudgetRow | None: ...
|
||||
|
||||
|
||||
def _model_table(prisma_client: PrismaClient) -> _ModelTableClient:
|
||||
return ModelRepository(prisma_client).table
|
||||
|
||||
|
||||
def _model_access_group_budget_table(prisma_client: PrismaClient) -> _ModelAccessGroupBudgetTableClient:
|
||||
return ModelAccessGroupBudgetRepository(prisma_client).table
|
||||
|
||||
|
||||
def _prisma_client_or_500() -> PrismaClient:
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise _http_error(500, "Database not connected.")
|
||||
return prisma_client
|
||||
|
||||
|
||||
def _auth_cache() -> UserApiKeyCache:
|
||||
from litellm.proxy.proxy_server import user_api_key_cache
|
||||
|
||||
return user_api_key_cache
|
||||
|
||||
|
||||
async def _evict_model_access_group_cache_keys(access_group: str, auth_cache: UserApiKeyCache) -> None:
|
||||
"""
|
||||
Every endpoint that writes an access group budget row must call this, or the budget stays
|
||||
unenforced until the TTL expires: auth gates the feature on a cached registry of the groups
|
||||
that have a budget row, read cache-first with no freshness check.
|
||||
"""
|
||||
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
|
||||
evict_and_broadcast,
|
||||
)
|
||||
|
||||
await evict_and_broadcast(
|
||||
cache_keys=(model_access_group_cache_key(access_group), model_access_group_registry_cache_key()),
|
||||
user_api_key_cache=auth_cache,
|
||||
)
|
||||
|
||||
|
||||
async def _model_access_group_budget_row(
|
||||
access_group: str, prisma_client: PrismaClient
|
||||
) -> _ModelAccessGroupBudgetRow | None:
|
||||
where: Final[_ModelAccessGroupWhere] = {"access_group_name": access_group}
|
||||
include: Final[_BudgetInclude] = {"litellm_budget_table": True}
|
||||
return await _model_access_group_budget_table(prisma_client).find_unique(where=where, include=include)
|
||||
|
||||
|
||||
def _budget_or_none(row: _ModelAccessGroupBudgetRow | None) -> AccessGroupBudget | None:
|
||||
budget: Final = row.litellm_budget_table if row is not None else None
|
||||
if budget is None:
|
||||
return None
|
||||
return AccessGroupBudget(
|
||||
budget_id=budget.budget_id,
|
||||
max_budget=budget.max_budget,
|
||||
soft_budget=budget.soft_budget,
|
||||
budget_duration=budget.budget_duration,
|
||||
budget_reset_at=budget.budget_reset_at,
|
||||
)
|
||||
|
||||
|
||||
def _budget_response(access_group: str, row: _ModelAccessGroupBudgetRow | None) -> AccessGroupBudgetResponse:
|
||||
return AccessGroupBudgetResponse(
|
||||
access_group=access_group,
|
||||
spend=row.spend if row is not None else 0.0,
|
||||
budget=_budget_or_none(row),
|
||||
)
|
||||
|
||||
|
||||
async def _delete_model_access_group_budget_row(
|
||||
access_group: str, prisma_client: PrismaClient, auth_cache: UserApiKeyCache
|
||||
) -> bool:
|
||||
"""
|
||||
Drop the group's budget row only, matching /tag/delete: the LiteLLM_BudgetTable row survives
|
||||
because the link is ON DELETE SET NULL and a budget_id an admin passed in may be shared with
|
||||
other entities.
|
||||
|
||||
Evicts unconditionally: a group with no row of its own can still be sitting in the cached
|
||||
registry, so skipping the eviction when nothing was deleted would leave that stale.
|
||||
"""
|
||||
where: Final[_ModelAccessGroupWhere] = {"access_group_name": access_group}
|
||||
row: Final = await _model_access_group_budget_table(prisma_client).delete(where=where)
|
||||
await _evict_model_access_group_cache_keys(access_group, auth_cache)
|
||||
return row is not None
|
||||
|
||||
|
||||
async def _raise_404_if_model_access_group_missing(access_group: str, prisma_client: PrismaClient) -> None:
|
||||
access_groups_map: Final = await get_all_access_groups_from_db(prisma_client=prisma_client)
|
||||
if access_group not in access_groups_map:
|
||||
raise _http_error(404, f"Access group '{access_group}' not found")
|
||||
|
||||
|
||||
def validate_models_exist(model_names: Sequence[str], llm_router: "Router | None") -> tuple[bool, Sequence[str]]:
|
||||
"""
|
||||
Validate that all requested model names exist in the router.
|
||||
|
|
@ -356,13 +543,12 @@ async def get_all_access_groups_from_db(
|
|||
|
||||
@router.post(
|
||||
"/access_group/new",
|
||||
tags=["model management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
dependencies=_AUTH_DEPENDENCIES,
|
||||
response_model=NewModelGroupResponse,
|
||||
)
|
||||
async def create_model_group(
|
||||
data: NewModelGroupRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
):
|
||||
"""
|
||||
Create a new access group containing multiple model names.
|
||||
|
|
@ -503,12 +689,11 @@ async def create_model_group(
|
|||
|
||||
@router.get(
|
||||
"/access_group/list",
|
||||
tags=["model management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
dependencies=_AUTH_DEPENDENCIES,
|
||||
response_model=ListAccessGroupsResponse,
|
||||
)
|
||||
async def list_access_groups(
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
):
|
||||
"""
|
||||
List all access groups.
|
||||
|
|
@ -553,13 +738,12 @@ async def list_access_groups(
|
|||
|
||||
@router.get(
|
||||
"/access_group/{access_group}/info",
|
||||
tags=["model management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
dependencies=_AUTH_DEPENDENCIES,
|
||||
response_model=AccessGroupInfo,
|
||||
)
|
||||
async def get_access_group_info(
|
||||
access_group: str,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
):
|
||||
"""
|
||||
Get information about a specific access group.
|
||||
|
|
@ -574,7 +758,7 @@ async def get_access_group_info(
|
|||
- access_group: str - The access group name (URL path parameter)
|
||||
|
||||
Returns:
|
||||
- AccessGroupInfo with the access group details
|
||||
- AccessGroupInfo with the access group details, its shared budget and its spend
|
||||
|
||||
Raises:
|
||||
- HTTPException 404: If access group not found
|
||||
|
|
@ -596,7 +780,15 @@ async def get_access_group_info(
|
|||
detail={"error": f"Access group '{access_group}' not found"},
|
||||
)
|
||||
|
||||
return access_groups_map[access_group]
|
||||
info: Final = access_groups_map[access_group]
|
||||
budget_row: Final = await _model_access_group_budget_row(access_group, prisma_client)
|
||||
return AccessGroupInfo(
|
||||
access_group=info.access_group,
|
||||
model_names=info.model_names,
|
||||
deployment_count=info.deployment_count,
|
||||
spend=budget_row.spend if budget_row is not None else 0.0,
|
||||
budget=_budget_or_none(budget_row),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -610,14 +802,13 @@ async def get_access_group_info(
|
|||
|
||||
@router.put(
|
||||
"/access_group/{access_group}/update",
|
||||
tags=["model management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
dependencies=_AUTH_DEPENDENCIES,
|
||||
response_model=NewModelGroupResponse,
|
||||
)
|
||||
async def update_access_group(
|
||||
access_group: str,
|
||||
data: UpdateModelGroupRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
):
|
||||
"""
|
||||
Update an access group's model names.
|
||||
|
|
@ -765,13 +956,13 @@ async def update_access_group(
|
|||
|
||||
@router.delete(
|
||||
"/access_group/{access_group}/delete",
|
||||
tags=["model management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
dependencies=_AUTH_DEPENDENCIES,
|
||||
response_model=DeleteModelGroupResponse,
|
||||
)
|
||||
async def delete_access_group(
|
||||
access_group: str,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
auth_cache: Annotated[UserApiKeyCache, Depends(_auth_cache)],
|
||||
):
|
||||
"""
|
||||
Delete an access group.
|
||||
|
|
@ -835,6 +1026,13 @@ async def delete_access_group(
|
|||
removed_pairs: Final = tuple(pair for pair in removed if pair is not None)
|
||||
models_updated: Final = len(removed_pairs)
|
||||
|
||||
# Budget last, deliberately: failing here strands a budget row for a group already on no
|
||||
# deployment (clutter), where the reverse order can leave a live group enforcing nothing.
|
||||
# The LiteLLM_BudgetTable row it linked is left alone, as /tag/delete leaves a tag's.
|
||||
await _delete_model_access_group_budget_row(
|
||||
access_group=access_group, prisma_client=prisma_client, auth_cache=auth_cache
|
||||
)
|
||||
|
||||
# Clear cache and reload models to pick up the access group changes
|
||||
live_before_reload: Final = live_model_ids_snapshot()
|
||||
reload_outcome: Final = await clear_cache()
|
||||
|
|
@ -864,3 +1062,162 @@ async def delete_access_group(
|
|||
status_code=500,
|
||||
detail={"error": f"Failed to delete access group: {e}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/access_group/{access_group}/budget",
|
||||
dependencies=_AUTH_DEPENDENCIES,
|
||||
response_model=AccessGroupBudgetResponse,
|
||||
)
|
||||
async def get_access_group_budget(
|
||||
access_group: str,
|
||||
) -> AccessGroupBudgetResponse:
|
||||
"""
|
||||
Get the shared budget of an access group, and the spend drawn against it.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
curl -X GET 'http://localhost:4000/access_group/production-models/budget' \\
|
||||
-H 'Authorization: Bearer sk-1234'
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- access_group: str - The access group name (URL path parameter)
|
||||
|
||||
Returns:
|
||||
- AccessGroupBudgetResponse; budget is null when the group has no budget set
|
||||
|
||||
Raises:
|
||||
- HTTPException 404: If access group not found
|
||||
"""
|
||||
prisma_client: Final = _prisma_client_or_500()
|
||||
await _raise_404_if_model_access_group_missing(access_group=access_group, prisma_client=prisma_client)
|
||||
|
||||
return _budget_response(
|
||||
access_group=access_group,
|
||||
row=await _model_access_group_budget_row(access_group, prisma_client),
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/access_group/{access_group}/budget",
|
||||
dependencies=_AUTH_DEPENDENCIES,
|
||||
response_model=AccessGroupBudgetResponse,
|
||||
)
|
||||
async def set_access_group_budget(
|
||||
access_group: str,
|
||||
data: AccessGroupBudgetRequest,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
auth_cache: Annotated[UserApiKeyCache, Depends(_auth_cache)],
|
||||
) -> AccessGroupBudgetResponse:
|
||||
"""
|
||||
Set or replace the shared budget of an access group. Idempotent.
|
||||
|
||||
Every key that can reach a model in the group draws from this one budget.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
curl -X PUT 'http://localhost:4000/access_group/production-models/budget' \\
|
||||
-H 'Authorization: Bearer sk-1234' \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-d '{
|
||||
"max_budget": 100.0,
|
||||
"budget_duration": "30d"
|
||||
}'
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- access_group: str - The access group name (URL path parameter)
|
||||
- max_budget: Optional[float] - Requests fail once the group's shared spend exceeds this
|
||||
- soft_budget: Optional[float] - Fires an alert when reached; requests still succeed
|
||||
- budget_duration: Optional[str] - Frequency of resetting the group's spend (e.g. '30d')
|
||||
- budget_id: Optional[str] - Link an existing budget instead of creating one
|
||||
|
||||
Returns:
|
||||
- AccessGroupBudgetResponse with the stored budget and current spend
|
||||
|
||||
Raises:
|
||||
- HTTPException 400: If no budget field is given, or budget_duration cannot be parsed
|
||||
- HTTPException 404: If access group not found
|
||||
"""
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
prisma_client: Final = _prisma_client_or_500()
|
||||
if not data.model_dump(exclude_none=True):
|
||||
raise _http_error(400, "One of max_budget, soft_budget, budget_duration or budget_id is required")
|
||||
validate_budget_duration(data.budget_duration)
|
||||
await _raise_404_if_model_access_group_missing(access_group=access_group, prisma_client=prisma_client)
|
||||
|
||||
existing_row: Final = await _model_access_group_budget_row(access_group, prisma_client)
|
||||
budget_id: Final = await handle_budget_for_entity(
|
||||
data=data,
|
||||
existing_budget_id=existing_row.budget_id if existing_row is not None else None,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
prisma_client=prisma_client,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
)
|
||||
actor: Final = user_api_key_dict.user_id or litellm_proxy_admin_name
|
||||
upsert_data: Final[_ModelAccessGroupBudgetUpsert] = {
|
||||
"create": {
|
||||
"access_group_name": access_group,
|
||||
"budget_id": budget_id,
|
||||
"created_by": actor,
|
||||
"updated_by": actor,
|
||||
},
|
||||
"update": {"budget_id": budget_id, "updated_by": actor},
|
||||
}
|
||||
where: Final[_ModelAccessGroupWhere] = {"access_group_name": access_group}
|
||||
include: Final[_BudgetInclude] = {"litellm_budget_table": True}
|
||||
row: Final = await _model_access_group_budget_table(prisma_client).upsert(
|
||||
where=where, data=upsert_data, include=include
|
||||
)
|
||||
await _evict_model_access_group_cache_keys(access_group, auth_cache)
|
||||
|
||||
verbose_proxy_logger.info("Set budget %s on access group '%s'", budget_id, access_group)
|
||||
return _budget_response(access_group=access_group, row=row)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/access_group/{access_group}/budget",
|
||||
dependencies=_AUTH_DEPENDENCIES,
|
||||
response_model=DeleteAccessGroupBudgetResponse,
|
||||
)
|
||||
async def delete_access_group_budget(
|
||||
access_group: str,
|
||||
auth_cache: Annotated[UserApiKeyCache, Depends(_auth_cache)],
|
||||
) -> DeleteAccessGroupBudgetResponse:
|
||||
"""
|
||||
Clear the shared budget of an access group, leaving the group itself in place.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
curl -X DELETE 'http://localhost:4000/access_group/production-models/budget' \\
|
||||
-H 'Authorization: Bearer sk-1234'
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- access_group: str - The access group name (URL path parameter)
|
||||
|
||||
Returns:
|
||||
- DeleteAccessGroupBudgetResponse; budget_deleted is false when there was nothing to clear
|
||||
|
||||
Raises:
|
||||
- HTTPException 404: If access group not found
|
||||
"""
|
||||
prisma_client: Final = _prisma_client_or_500()
|
||||
await _raise_404_if_model_access_group_missing(access_group=access_group, prisma_client=prisma_client)
|
||||
|
||||
budget_deleted: Final = await _delete_model_access_group_budget_row(
|
||||
access_group=access_group,
|
||||
prisma_client=prisma_client,
|
||||
auth_cache=auth_cache,
|
||||
)
|
||||
return DeleteAccessGroupBudgetResponse(
|
||||
access_group=access_group,
|
||||
budget_deleted=budget_deleted,
|
||||
message=(
|
||||
f"Budget for access group '{access_group}' deleted successfully"
|
||||
if budget_deleted
|
||||
else f"Access group '{access_group}' has no budget to delete"
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ from litellm.litellm_core_utils.core_helpers import (
|
|||
get_metadata_variable_name_from_kwargs,
|
||||
get_or_create_metadata_bucket,
|
||||
)
|
||||
from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
|
|
@ -577,6 +578,7 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
|
|||
_metadata["user_api_key"] = user_api_key_dict.api_key
|
||||
_metadata["litellm_parent_otel_span"] = user_api_key_dict.parent_otel_span
|
||||
_metadata["user_api_key_budget_reservation"] = user_api_key_dict.budget_reservation
|
||||
_metadata[MODEL_ACCESS_GROUP_METADATA_KEY] = user_api_key_dict.matched_model_access_groups
|
||||
# The per-model budget counters are keyed off these. get_sanitized_user_information_from_key
|
||||
# returns StandardLoggingUserAPIKeyMetadata, which carries no budget field, so without this
|
||||
# the post-call increment finds nothing and every passthrough request goes untracked and
|
||||
|
|
|
|||
|
|
@ -382,6 +382,8 @@ from litellm.proxy.common_utils.user_api_key_cache import (
|
|||
UserApiKeyCache,
|
||||
end_user_cache_key,
|
||||
get_management_object_ttl,
|
||||
model_access_group_cache_key,
|
||||
model_access_group_spend_counter_key,
|
||||
tag_cache_key,
|
||||
)
|
||||
from litellm.proxy.config_resolvers import resolve_fields
|
||||
|
|
@ -2658,6 +2660,7 @@ async def increment_spend_counters(
|
|||
tags: list[str] | None = None,
|
||||
request_id: str | None = None,
|
||||
request_started_at: datetime | None = None,
|
||||
model_access_groups: Sequence[str] | None = None,
|
||||
):
|
||||
"""
|
||||
Atomically increment spend counters for budget enforcement.
|
||||
|
|
@ -2813,6 +2816,13 @@ async def increment_spend_counters(
|
|||
)
|
||||
if end_user_id is not None or tags is not None
|
||||
else None,
|
||||
_increment_model_access_group_spend_counters(
|
||||
model_access_groups=model_access_groups,
|
||||
response_cost=cost,
|
||||
reserved_counter_keys=reserved_counter_keys,
|
||||
)
|
||||
if model_access_groups
|
||||
else None,
|
||||
_increment_org_spend_counter(
|
||||
org_id=org_id,
|
||||
response_cost=cost,
|
||||
|
|
@ -2901,6 +2911,33 @@ async def _increment_end_user_and_tag_spend_counters(
|
|||
)
|
||||
|
||||
|
||||
async def _increment_model_access_group_spend_counters(
|
||||
model_access_groups: Sequence[object],
|
||||
response_cost: float,
|
||||
reserved_counter_keys: set[str],
|
||||
) -> None:
|
||||
"""Charge the model access groups that authorized this request.
|
||||
|
||||
Without this the counter auth reads is written only by the reservation path, so
|
||||
``disable_budget_reservation`` would leave ``_model_access_group_max_budget_check`` enforcing
|
||||
against the DB row's spend, which lags by up to the cache TTL.
|
||||
|
||||
Typed ``object`` rather than ``str`` because the names reach the cost callback out of request
|
||||
metadata, which the coercion upstream filters to a list but not to strings. A non-string that
|
||||
slipped through would build a counter key nothing else ever reads.
|
||||
"""
|
||||
unique_groups: Final = tuple(
|
||||
dict.fromkeys(group for group in model_access_groups if group and isinstance(group, str))
|
||||
)
|
||||
for group in unique_groups:
|
||||
await _init_and_increment_unreserved_spend_counter(
|
||||
counter_key=model_access_group_spend_counter_key(group),
|
||||
source_cache_key=model_access_group_cache_key(group),
|
||||
increment=response_cost,
|
||||
reserved_counter_keys=reserved_counter_keys,
|
||||
)
|
||||
|
||||
|
||||
async def _increment_org_spend_counter(
|
||||
org_id: str | None,
|
||||
response_cost: float,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ model LiteLLM_BudgetTable {
|
|||
keys LiteLLM_VerificationToken[] // multiple keys can have the same budget
|
||||
end_users LiteLLM_EndUserTable[] // multiple end-users can have the same budget
|
||||
tags LiteLLM_TagTable[] // multiple tags can have the same budget
|
||||
model_access_groups LiteLLM_ModelAccessGroupBudgetTable[] // multiple model access groups can have the same budget
|
||||
team_membership LiteLLM_TeamMembership[] // budgets of Users within a Team
|
||||
organization_membership LiteLLM_OrganizationMembership[] // budgets of Users within a Organization
|
||||
}
|
||||
|
|
@ -585,6 +586,20 @@ model LiteLLM_EndUserTable {
|
|||
blocked Boolean @default(false)
|
||||
}
|
||||
|
||||
// Budget and shared spend for a model access group. The groups themselves are not rows anywhere:
|
||||
// they are free-text strings in LiteLLM_ProxyModelTable.model_info.access_groups, so a row here
|
||||
// exists only once someone gives that group a budget.
|
||||
model LiteLLM_ModelAccessGroupBudgetTable {
|
||||
access_group_name String @id
|
||||
spend Float @default(0.0)
|
||||
budget_id String?
|
||||
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
created_by String?
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String?
|
||||
}
|
||||
|
||||
// Track tags with budgets and spend
|
||||
model LiteLLM_TagTable {
|
||||
tag_name String @id
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from fastapi import HTTPException, status
|
|||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching import DualCache
|
||||
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
|
||||
from litellm.litellm_core_utils.llm_cost_calc.tiered_pricing import select_tier_for_input, tier_rate
|
||||
from litellm.proxy._types import (
|
||||
|
|
@ -26,12 +25,16 @@ from litellm.proxy.auth.auth_utils import get_model_from_request
|
|||
from litellm.proxy.auth.budget_throttle import should_throttle_budget_exceeded
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
UserApiKeyCache,
|
||||
end_user_cache_key,
|
||||
model_access_group_cache_key,
|
||||
model_access_group_spend_counter_key,
|
||||
tag_cache_key,
|
||||
team_membership_reservation_cache_key,
|
||||
)
|
||||
from litellm.proxy.utils import PrismaClient, ProxyLogging
|
||||
from litellm.router import Router
|
||||
from litellm.types.proxy.model_access_group_budget import ModelAccessGroupBudget
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -54,6 +57,7 @@ _COUNTER_ENTITY_TYPES: Final[Mapping[str, str]] = {
|
|||
"User": Litellm_EntityType.USER.value,
|
||||
"EndUser": Litellm_EntityType.END_USER.value,
|
||||
"Tag": Litellm_EntityType.TAG.value,
|
||||
"Model access group": Litellm_EntityType.MODEL_ACCESS_GROUP.value,
|
||||
"Organization": Litellm_EntityType.ORGANIZATION.value,
|
||||
}
|
||||
|
||||
|
|
@ -159,7 +163,7 @@ async def reserve_budget_for_request(
|
|||
team_object: LiteLLM_TeamTable | None,
|
||||
user_object: LiteLLM_UserTable | None,
|
||||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: DualCache,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
end_user_id: str | None = None,
|
||||
end_user_object: object = None,
|
||||
|
|
@ -349,7 +353,7 @@ async def _get_budget_counters(
|
|||
team_object: LiteLLM_TeamTable | None,
|
||||
user_object: LiteLLM_UserTable | None,
|
||||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: DualCache,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
end_user_id: str | None = None,
|
||||
end_user_object: object = None,
|
||||
|
|
@ -438,6 +442,14 @@ async def _get_budget_counters(
|
|||
)
|
||||
)
|
||||
|
||||
counters.extend(
|
||||
await _get_model_access_group_budget_counters(
|
||||
valid_token=valid_token,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
)
|
||||
|
||||
team_member_counter: Final = await _get_team_member_budget_counter(
|
||||
valid_token=valid_token,
|
||||
team_object=team_object,
|
||||
|
|
@ -492,7 +504,7 @@ async def _get_end_user_budget_counter(
|
|||
async def _get_tag_budget_counters(
|
||||
request_body: dict,
|
||||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: DualCache,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> list[_BudgetCounter]:
|
||||
from litellm.proxy.auth.auth_checks import get_tag_objects_batch
|
||||
|
|
@ -531,6 +543,46 @@ async def _get_tag_budget_counters(
|
|||
return counters
|
||||
|
||||
|
||||
async def _get_model_access_group_budget_counters(
|
||||
valid_token: UserAPIKeyAuth,
|
||||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
) -> list[_BudgetCounter]:
|
||||
"""Reservation counters for the model access groups that authorized this request.
|
||||
|
||||
The names come off the auth object rather than the request body: ``common_checks`` already
|
||||
resolved which granted groups serve the requested model, and re-deriving that here would both
|
||||
duplicate the walk and risk disagreeing with what the spend writer attributes.
|
||||
"""
|
||||
from litellm.proxy.auth.auth_checks import get_model_access_group_budgets_batch
|
||||
|
||||
group_names: Final = tuple(dict.fromkeys(valid_token.matched_model_access_groups or ()))
|
||||
if not group_names:
|
||||
return []
|
||||
|
||||
budgets: Final = await get_model_access_group_budgets_batch(
|
||||
access_group_names=group_names,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
candidates: Final = (_model_access_group_counter(group, budgets.get(group)) for group in group_names)
|
||||
return [counter for counter in candidates if counter is not None]
|
||||
|
||||
|
||||
def _model_access_group_counter(group: str, budget: ModelAccessGroupBudget | None) -> _BudgetCounter | None:
|
||||
"""A counter for one group, or nothing when the group carries no budget to reserve against."""
|
||||
if budget is None or budget.max_budget is None or budget.max_budget <= 0:
|
||||
return None
|
||||
return _BudgetCounter(
|
||||
counter_key=model_access_group_spend_counter_key(group),
|
||||
source_cache_key=model_access_group_cache_key(group),
|
||||
max_budget=budget.max_budget,
|
||||
fallback_spend=budget.spend,
|
||||
entity_type="Model access group",
|
||||
entity_id=group,
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_tags(tags: list[str]) -> list[str]:
|
||||
seen: Final = set()
|
||||
deduped_tags: Final = []
|
||||
|
|
@ -546,7 +598,7 @@ async def _get_team_member_budget_counter(
|
|||
valid_token: UserAPIKeyAuth,
|
||||
team_object: LiteLLM_TeamTable | None,
|
||||
user_object: LiteLLM_UserTable | None,
|
||||
user_api_key_cache: DualCache,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
) -> _BudgetCounter | None:
|
||||
if team_object is None or team_object.team_id is None or user_object is None or valid_token.user_id is None:
|
||||
return None
|
||||
|
|
@ -589,7 +641,7 @@ async def _get_team_member_budget_counter(
|
|||
async def _get_org_budget_counter(
|
||||
valid_token: UserAPIKeyAuth,
|
||||
team_object: LiteLLM_TeamTable | None,
|
||||
user_api_key_cache: DualCache,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
) -> _BudgetCounter | None:
|
||||
org_id: str | None = None
|
||||
if valid_token.org_id is not None:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import os
|
||||
import re
|
||||
import secrets
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime as dt
|
||||
from typing import Any, Final, Literal, cast
|
||||
|
|
@ -23,7 +24,11 @@ from litellm.litellm_core_utils.core_helpers import (
|
|||
reconstruct_model_name,
|
||||
)
|
||||
from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call
|
||||
from litellm.litellm_core_utils.litellm_logging import is_valid_sha256_hash
|
||||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
coerce_model_access_groups,
|
||||
is_valid_sha256_hash,
|
||||
request_model_access_groups_from_litellm_params,
|
||||
)
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps, strip_null_bytes
|
||||
from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload
|
||||
from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
|
||||
|
|
@ -249,6 +254,23 @@ def _extract_usage_for_ocr_call(response_obj: Any, response_obj_dict: dict) -> d
|
|||
return {}
|
||||
|
||||
|
||||
def get_request_model_access_groups(kwargs: Mapping[str, object] | None) -> tuple[str, ...]:
|
||||
"""Model access groups that authorized this request, as stamped onto request metadata at auth time."""
|
||||
if kwargs is None:
|
||||
return ()
|
||||
|
||||
standard_logging_payload: Final = kwargs.get("standard_logging_object")
|
||||
if isinstance(standard_logging_payload, Mapping):
|
||||
from_payload: Final = coerce_model_access_groups(standard_logging_payload.get("request_model_access_groups"))
|
||||
if from_payload:
|
||||
return from_payload
|
||||
|
||||
litellm_params: Final = kwargs.get("litellm_params")
|
||||
if not isinstance(litellm_params, Mapping):
|
||||
return ()
|
||||
return request_model_access_groups_from_litellm_params(litellm_params)
|
||||
|
||||
|
||||
def _sl_attribution_fallback(
|
||||
standard_logging_payload: StandardLoggingPayload | None,
|
||||
field: Literal["model_id", "model_group", "api_base", "custom_llm_provider"],
|
||||
|
|
|
|||
|
|
@ -6031,10 +6031,42 @@ def _should_use_smtp_ssl(smtp_port: int) -> bool:
|
|||
return os.getenv("SMTP_USE_SSL", "False") == "True" or smtp_port == 465
|
||||
|
||||
|
||||
def _create_smtp_connection(smtp_host: str, smtp_port: int) -> smtplib.SMTP:
|
||||
def _create_smtp_connection(smtp_host: str, smtp_port: int, timeout: float) -> smtplib.SMTP:
|
||||
if _should_use_smtp_ssl(smtp_port=smtp_port):
|
||||
return smtplib.SMTP_SSL(host=smtp_host, port=smtp_port, context=ssl.create_default_context())
|
||||
return smtplib.SMTP(host=smtp_host, port=smtp_port)
|
||||
return smtplib.SMTP_SSL(host=smtp_host, port=smtp_port, context=ssl.create_default_context(), timeout=timeout)
|
||||
return smtplib.SMTP(host=smtp_host, port=smtp_port, timeout=timeout)
|
||||
|
||||
|
||||
def _send_smtp_message(
|
||||
email_message: MIMEMultipart,
|
||||
smtp_host: str,
|
||||
smtp_port: int,
|
||||
smtp_username: str | None,
|
||||
smtp_password: str | None,
|
||||
sender_email: str,
|
||||
receiver_email: str,
|
||||
timeout: float,
|
||||
) -> None:
|
||||
using_ssl: Final = _should_use_smtp_ssl(smtp_port=smtp_port)
|
||||
with _create_smtp_connection(
|
||||
smtp_host=smtp_host,
|
||||
smtp_port=smtp_port,
|
||||
timeout=timeout,
|
||||
) as server:
|
||||
if not using_ssl and os.getenv("SMTP_TLS", "True") != "False":
|
||||
server.starttls(context=ssl.create_default_context())
|
||||
|
||||
if smtp_username and smtp_password:
|
||||
server.login(
|
||||
user=smtp_username,
|
||||
password=smtp_password,
|
||||
)
|
||||
|
||||
server.send_message(
|
||||
msg=email_message,
|
||||
from_addr=sender_email,
|
||||
to_addrs=receiver_email,
|
||||
)
|
||||
|
||||
|
||||
async def send_email(
|
||||
|
|
@ -6080,27 +6112,18 @@ async def send_email(
|
|||
email_message.attach(MIMEText(html, "html"))
|
||||
|
||||
try:
|
||||
using_ssl: Final = _should_use_smtp_ssl(smtp_port=smtp_port)
|
||||
with _create_smtp_connection(
|
||||
smtp_timeout: Final = float(os.getenv("SMTP_TIMEOUT", "30"))
|
||||
await asyncio.to_thread(
|
||||
_send_smtp_message,
|
||||
email_message=email_message,
|
||||
smtp_host=smtp_host,
|
||||
smtp_port=smtp_port,
|
||||
) as server:
|
||||
if not using_ssl and os.getenv("SMTP_TLS", "True") != "False":
|
||||
server.starttls(context=ssl.create_default_context())
|
||||
|
||||
# Login to your email account only if smtp_username and smtp_password are provided
|
||||
if smtp_username and smtp_password:
|
||||
server.login(
|
||||
user=smtp_username,
|
||||
password=smtp_password,
|
||||
)
|
||||
|
||||
# Send the email
|
||||
server.send_message(
|
||||
msg=email_message,
|
||||
from_addr=sender_email,
|
||||
to_addrs=receiver_email,
|
||||
)
|
||||
smtp_username=smtp_username,
|
||||
smtp_password=smtp_password,
|
||||
sender_email=sender_email,
|
||||
receiver_email=receiver_email,
|
||||
timeout=smtp_timeout,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("An error occurred while sending the email:" + str(e))
|
||||
|
|
|
|||
|
|
@ -144,4 +144,7 @@ class PrismaBatch(Protocol):
|
|||
@property
|
||||
def litellm_endusertable(self) -> BatchTable: ...
|
||||
|
||||
@property
|
||||
def litellm_modelaccessgroupbudgettable(self) -> BatchTable: ...
|
||||
|
||||
async def commit(self) -> None: ...
|
||||
|
|
|
|||
|
|
@ -104,6 +104,10 @@ class TagRepository(PrismaTableRepository["prisma_models.LiteLLM_TagTable"]):
|
|||
table_name = "litellm_tagtable"
|
||||
|
||||
|
||||
class ModelAccessGroupBudgetRepository(PrismaTableRepository["prisma_models.LiteLLM_ModelAccessGroupBudgetTable"]):
|
||||
table_name = "litellm_modelaccessgroupbudgettable"
|
||||
|
||||
|
||||
class InvitationLinkRepository(PrismaTableRepository["prisma_models.LiteLLM_InvitationLink"]):
|
||||
table_name = "litellm_invitationlink"
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ class BudgetCascadeUnitOfWork:
|
|||
keys: LinkedSpendResetWrites
|
||||
organizations: LinkedSpendResetWrites
|
||||
tags: LinkedSpendResetWrites
|
||||
model_access_groups: LinkedSpendResetWrites
|
||||
endusers: LinkedSpendResetWrites
|
||||
budgets: BudgetWindowWrites
|
||||
|
||||
|
|
@ -143,6 +144,7 @@ async def budget_cascade_unit_of_work(
|
|||
keys=LinkedSpendResetWrites(table=batch.litellm_verificationtoken),
|
||||
organizations=LinkedSpendResetWrites(table=batch.litellm_organizationtable),
|
||||
tags=LinkedSpendResetWrites(table=batch.litellm_tagtable),
|
||||
model_access_groups=LinkedSpendResetWrites(table=batch.litellm_modelaccessgroupbudgettable),
|
||||
endusers=LinkedSpendResetWrites(table=batch.litellm_endusertable),
|
||||
budgets=BudgetWindowWrites(table=batch.litellm_budgettable),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from ...router import ModelGroupInfo
|
||||
|
||||
|
|
@ -53,10 +54,42 @@ class DeleteModelGroupResponse(BaseModel):
|
|||
message: str
|
||||
|
||||
|
||||
class AccessGroupBudget(BaseModel):
|
||||
budget_id: str
|
||||
max_budget: float | None = None
|
||||
soft_budget: float | None = None
|
||||
budget_duration: str | None = None
|
||||
budget_reset_at: datetime | None = None
|
||||
|
||||
|
||||
class AccessGroupBudgetRequest(BaseModel):
|
||||
budget_id: str | None = None # Link an existing budget instead of creating one
|
||||
max_budget: float | None = Field(default=None, ge=0)
|
||||
soft_budget: float | None = Field(default=None, ge=0)
|
||||
budget_duration: str | None = None
|
||||
|
||||
# rejects tpm_limit/rpm_limit/max_parallel_requests: those are not enforced per access group
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AccessGroupBudgetResponse(BaseModel):
|
||||
access_group: str
|
||||
spend: float # Shared spend accrued by every key that can reach this access group
|
||||
budget: AccessGroupBudget | None = None
|
||||
|
||||
|
||||
class DeleteAccessGroupBudgetResponse(BaseModel):
|
||||
access_group: str
|
||||
budget_deleted: bool # False when the access group had no budget to begin with
|
||||
message: str
|
||||
|
||||
|
||||
class AccessGroupInfo(BaseModel):
|
||||
access_group: str
|
||||
model_names: list[str] # List of model names in this access group
|
||||
deployment_count: int # Total number of deployments with this access group
|
||||
spend: float | None = None # Only populated by /access_group/{access_group}/info
|
||||
budget: AccessGroupBudget | None = None
|
||||
|
||||
|
||||
class ListAccessGroupsResponse(BaseModel):
|
||||
|
|
|
|||
19
litellm/types/proxy/model_access_group_budget.py
Normal file
19
litellm/types/proxy/model_access_group_budget.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""The model access group budget state auth and the spend reservation path share."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ModelAccessGroupBudget(BaseModel):
|
||||
"""One model access group's budget, flattened out of its joined ``LiteLLM_ModelAccessGroupBudgetTable`` row.
|
||||
|
||||
Both readers want only the recorded spend and the ceiling, and this sits on the per-request hot
|
||||
path behind a cache, so the linked budget row is collapsed to ``max_budget`` rather than cached
|
||||
whole. ``spend`` is the DB-recorded value, which lags the live counter and is only ever a
|
||||
fallback for it.
|
||||
"""
|
||||
|
||||
access_group_name: str
|
||||
spend: float = 0.0
|
||||
max_budget: float | None = None
|
||||
|
|
@ -40,7 +40,7 @@ from pydantic import (
|
|||
field_serializer,
|
||||
field_validator,
|
||||
)
|
||||
from typing_extensions import ReadOnly, Required, TypedDict
|
||||
from typing_extensions import NotRequired, ReadOnly, Required, TypedDict
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm._uuid import uuid
|
||||
|
|
@ -3260,6 +3260,7 @@ class StandardLoggingPayload(TypedDict):
|
|||
cache_key: str | None
|
||||
saved_cache_cost: float
|
||||
request_tags: list
|
||||
request_model_access_groups: NotRequired[ReadOnly[Sequence[str]]]
|
||||
end_user: str | None
|
||||
requester_ip_address: str | None
|
||||
user_agent: str | None
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ model LiteLLM_BudgetTable {
|
|||
keys LiteLLM_VerificationToken[] // multiple keys can have the same budget
|
||||
end_users LiteLLM_EndUserTable[] // multiple end-users can have the same budget
|
||||
tags LiteLLM_TagTable[] // multiple tags can have the same budget
|
||||
model_access_groups LiteLLM_ModelAccessGroupBudgetTable[] // multiple model access groups can have the same budget
|
||||
team_membership LiteLLM_TeamMembership[] // budgets of Users within a Team
|
||||
organization_membership LiteLLM_OrganizationMembership[] // budgets of Users within a Organization
|
||||
}
|
||||
|
|
@ -585,6 +586,20 @@ model LiteLLM_EndUserTable {
|
|||
blocked Boolean @default(false)
|
||||
}
|
||||
|
||||
// Budget and shared spend for a model access group. The groups themselves are not rows anywhere:
|
||||
// they are free-text strings in LiteLLM_ProxyModelTable.model_info.access_groups, so a row here
|
||||
// exists only once someone gives that group a budget.
|
||||
model LiteLLM_ModelAccessGroupBudgetTable {
|
||||
access_group_name String @id
|
||||
spend Float @default(0.0)
|
||||
budget_id String?
|
||||
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
created_by String?
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String?
|
||||
}
|
||||
|
||||
// Track tags with budgets and spend
|
||||
model LiteLLM_TagTable {
|
||||
tag_name String @id
|
||||
|
|
|
|||
|
|
@ -126,3 +126,6 @@ POST /customer/delete # known gap: litellm_customer
|
|||
GET /team/{team_id}/callback # known gap: team callback resource
|
||||
POST /team/{team_id}/callback # known gap: team callback resource
|
||||
DELETE /team/{team_id}/callback/{callback_name} # known gap: team callback resource
|
||||
GET /access_group/{access_group}/budget # known gap: budget attributes on litellm_access_group
|
||||
PUT /access_group/{access_group}/budget # known gap: budget attributes on litellm_access_group
|
||||
DELETE /access_group/{access_group}/budget # known gap: budget attributes on litellm_access_group
|
||||
|
|
|
|||
|
|
@ -177,15 +177,15 @@ quota_management.<behavior>.<variant>.<assertion>
|
|||
behavior : ratelimit | budget | spend_tracking
|
||||
variant : <ratelimit> rpm | tpm | priority_generous | priority_strict
|
||||
<budget> key | internal_user | end_user | organization | team | team_member | tag
|
||||
| model_max | soft | key_multi_window | team_multi_window
|
||||
| fallback | spend_counter
|
||||
| model_access_group | model_max | soft | key_multi_window
|
||||
| team_multi_window | fallback | spend_counter
|
||||
<spend_tracking> chat_completions | stream | messages_bridge | embeddings
|
||||
| cache_hit | key_rollup | concurrent_burst | tags | end_user
|
||||
| per_model | failure | spend_calculate | pagination
|
||||
assertion : blocks_over_limit | resets_after_window | headers_report_remaining | picks_under_tpm
|
||||
| blocks_then_resets | resets_windows_independently | alerts_without_blocking
|
||||
| isolates_per_model | isolates_per_member | enforced_across_keys | routes_to_fallback
|
||||
| reseed_matches_db | logs_cost | zero_cost
|
||||
| isolates_per_model | isolates_per_member | isolates_per_group | enforced_across_keys
|
||||
| routes_to_fallback | reseed_matches_db | reports_spend | logs_cost | zero_cost
|
||||
| matches_sum_of_logs | loses_no_spend | attributes_spend | writes_own_rows
|
||||
| writes_failure_row | returns_cost | keeps_total
|
||||
e.g. quota_management.ratelimit.rpm.blocks_over_limit exercised_on=[chat_completions, messages]
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@
|
|||
- {id: quota_management.budget.organization.blocks_over_limit, module: quota_management, tier: P1, behavior: budget, variant: organization, assertions: [blocks_over_limit], exercised_on: [chat_completions], source: "proxy/auth/auth_checks.py", rationale: "An organization's max_budget blocks keys under its teams"}
|
||||
- {id: quota_management.budget.team_member.blocks_over_limit, module: quota_management, tier: P1, behavior: budget, variant: team_member, assertions: [blocks_over_limit], exercised_on: [chat_completions], source: "proxy/auth/auth_checks.py", rationale: "A member's per-team budget blocks independently of the team budget"}
|
||||
- {id: quota_management.budget.team_member.isolates_per_member, module: quota_management, tier: P1, behavior: budget, variant: team_member, assertions: [isolates_per_member], exercised_on: [chat_completions], source: "proxy/auth/auth_checks.py", rationale: "One team member's exhausted per-team budget does not block a different member on the same team"}
|
||||
- {id: quota_management.budget.model_access_group.blocks_over_limit, module: quota_management, tier: P1, behavior: budget, variant: model_access_group, assertions: [blocks_over_limit], exercised_on: [chat_completions], source: "proxy/auth/auth_checks.py", rationale: "A model access group's shared max_budget blocks further calls to deployments in the group once the pool is spent"}
|
||||
- {id: quota_management.budget.model_access_group.enforced_across_keys, module: quota_management, tier: P1, behavior: budget, variant: model_access_group, assertions: [enforced_across_keys], exercised_on: [chat_completions], source: "proxy/auth/auth_checks.py", rationale: "The pool is shared, so a key that spent nothing of its own is blocked once another key granted the same group drained it"}
|
||||
- {id: quota_management.budget.model_access_group.isolates_per_group, module: quota_management, tier: P1, behavior: budget, variant: model_access_group, assertions: [isolates_per_group], exercised_on: [chat_completions], source: "proxy/db/db_spend_update_writer.py", rationale: "A request is charged only to the granted groups that serve the model it called, so an exhausted group never blocks a sibling group"}
|
||||
- {id: quota_management.budget.model_access_group.reports_spend, module: quota_management, tier: P2, behavior: budget, variant: model_access_group, assertions: [reports_spend], exercised_on: [chat_completions], source: "proxy/management_endpoints/model_access_group_management_endpoints.py", rationale: "GET /access_group/{name}/budget reports the pool and the spend drawn against it, so an admin can see why calls are being refused"}
|
||||
- {id: quota_management.budget.tag.blocks_over_limit, module: quota_management, tier: P1, behavior: budget, variant: tag, assertions: [blocks_over_limit], exercised_on: [chat_completions], source: "router_strategy/budget_limiter.py", rationale: "Proxy-level tag budgets block tagged requests at the cap"}
|
||||
- {id: quota_management.budget.end_user_model_max.blocks_over_limit, module: quota_management, tier: P1, behavior: budget, variant: end_user_model_max, assertions: [blocks_over_limit], exercised_on: [chat_completions], source: "budget_management_endpoints.py", fail_before_fix: proven, rationale: "A per-model rpm_limit on an end-user budget is accepted and stored but never enforced; only key-attached budgets honour it"}
|
||||
- {id: quota_management.budget.model_max.isolates_per_model, module: quota_management, tier: P1, behavior: budget, variant: model_max, assertions: [isolates_per_model], exercised_on: [chat_completions], source: "proxy/hooks/model_max_budget_limiter.py", rationale: "model_max_budget caps one model without touching a sibling's budget"}
|
||||
|
|
|
|||
|
|
@ -151,6 +151,28 @@ class TagDeleteBody(BaseModel):
|
|||
name: str
|
||||
|
||||
|
||||
class AccessGroupBudgetBody(BaseModel):
|
||||
max_budget: float | None = None
|
||||
soft_budget: float | None = None
|
||||
budget_duration: str | None = None
|
||||
|
||||
|
||||
class AccessGroupBudgetView(BaseModel):
|
||||
budget_id: str
|
||||
max_budget: float | None = None
|
||||
soft_budget: float | None = None
|
||||
budget_duration: str | None = None
|
||||
|
||||
|
||||
class AccessGroupBudgetResponse(BaseModel):
|
||||
"""GET/PUT /access_group/{name}/budget: the group's shared pool and the spend
|
||||
every key that can reach the group has drawn against it."""
|
||||
|
||||
access_group: str
|
||||
spend: float
|
||||
budget: AccessGroupBudgetView | None = None
|
||||
|
||||
|
||||
class BudgetNewBody(BaseModel):
|
||||
max_budget: float | None = None
|
||||
soft_budget: float | None = None
|
||||
|
|
@ -514,6 +536,49 @@ class BudgetClient:
|
|||
response_type=NoBody,
|
||||
)
|
||||
|
||||
# ---- model access group ---------------------------------------------
|
||||
|
||||
def set_access_group_budget(
|
||||
self,
|
||||
access_group: str,
|
||||
*,
|
||||
max_budget: float | None = None,
|
||||
soft_budget: float | None = None,
|
||||
budget_duration: str | None = None,
|
||||
) -> AccessGroupBudgetResponse:
|
||||
"""Give a model access group one shared budget. Every key that can reach a
|
||||
deployment in the group draws from it."""
|
||||
return unwrap(
|
||||
self.proxy.transport.put(
|
||||
f"/access_group/{access_group}/budget",
|
||||
headers=self.proxy.transport.master,
|
||||
json=AccessGroupBudgetBody(
|
||||
max_budget=max_budget,
|
||||
soft_budget=soft_budget,
|
||||
budget_duration=budget_duration,
|
||||
),
|
||||
response_type=AccessGroupBudgetResponse,
|
||||
)
|
||||
)
|
||||
|
||||
def access_group_budget(self, access_group: str) -> AccessGroupBudgetResponse:
|
||||
return unwrap(
|
||||
self.proxy.transport.get(
|
||||
f"/access_group/{access_group}/budget",
|
||||
headers=self.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=AccessGroupBudgetResponse,
|
||||
)
|
||||
)
|
||||
|
||||
def delete_access_group_budget(self, access_group: str) -> None:
|
||||
_ = self.proxy.transport.delete(
|
||||
f"/access_group/{access_group}/budget",
|
||||
headers=self.proxy.transport.master,
|
||||
json=NoBody(),
|
||||
response_type=NoBody,
|
||||
)
|
||||
|
||||
# ---- budget table ---------------------------------------------------
|
||||
|
||||
def create_budget(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
"""Live e2e: one shared budget across every key that can reach a model access group.
|
||||
|
||||
A model access group is a free-text label on a deployment (`model_info.access_groups`),
|
||||
and a key is granted the group by name. The budget hangs off the group, not the key, so
|
||||
the interesting behaviors are the ones a per-key budget cannot produce: a key that has
|
||||
spent nothing of its own is refused once somebody else drained the pool, and draining one
|
||||
group leaves a second group untouched, because a request is only charged to the groups
|
||||
the caller was granted that also serve the model being called.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from budget_client import BudgetClient, is_budget_block
|
||||
from e2e_config import unique_marker
|
||||
from e2e_http import StreamingResponse, require_successful_call
|
||||
from lifecycle import ResourceManager
|
||||
from models import KeyGenerateBody, LiteLLMParamsBody, ModelInfoBody, ModelNewBody
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
BACKEND: Final = "openai/gpt-5.4-nano"
|
||||
TINY_BUDGET: Final = 5e-6
|
||||
MAX_TOKENS: Final = 16
|
||||
DRAIN_TIMEOUT_SECONDS: Final = 180
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DrainedPool:
|
||||
"""A model access group whose shared budget has been spent to exhaustion, the
|
||||
deployment inside it, the key that did the spending, and a second group holding
|
||||
its own deployment that was never given a budget at all."""
|
||||
|
||||
access_group: str
|
||||
model: str
|
||||
spender_key: str
|
||||
free_access_group: str
|
||||
free_model: str
|
||||
|
||||
|
||||
def _provider_key(env_var: str) -> str:
|
||||
return os.environ.get(env_var) or f"os.environ/{env_var}"
|
||||
|
||||
|
||||
def _grouped_model(model_name: str, access_group: str) -> ModelNewBody:
|
||||
return ModelNewBody(
|
||||
model_name=model_name,
|
||||
litellm_params=LiteLLMParamsBody(model=BACKEND, api_key=_provider_key("OPENAI_API_KEY")),
|
||||
model_info=ModelInfoBody(access_groups=[access_group]),
|
||||
)
|
||||
|
||||
|
||||
def _call(client: BudgetClient, key: str, model: str) -> StreamingResponse:
|
||||
return client.chat(key, model, f"hi {unique_marker()}", max_tokens=MAX_TOKENS)
|
||||
|
||||
|
||||
def _drain(client: BudgetClient, key: str, model: str, access_group: str) -> None:
|
||||
"""Spend the group's pool until the proxy refuses the next request. The first call
|
||||
lands under the cap and the block comes from the spend it recorded, so this needs at
|
||||
least one round trip through the spend writer, not just one request."""
|
||||
deadline: Final = time.monotonic() + DRAIN_TIMEOUT_SECONDS
|
||||
while time.monotonic() < deadline:
|
||||
result = _call(client, key, model)
|
||||
if is_budget_block(result):
|
||||
return
|
||||
require_successful_call(result)
|
||||
time.sleep(1)
|
||||
pytest.fail(f"budget on model access group {access_group!r} never blocked a request")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def drained(client: BudgetClient) -> Iterator[DrainedPool]:
|
||||
marker: Final = unique_marker()
|
||||
pool: Final = DrainedPool(
|
||||
access_group=f"e2e-mag-budget-{marker}",
|
||||
model=f"e2e-mag-budgeted-{marker}",
|
||||
spender_key=client.proxy.generate_key(KeyGenerateBody(models=[f"e2e-mag-budget-{marker}"])),
|
||||
free_access_group=f"e2e-mag-free-{marker}",
|
||||
free_model=f"e2e-mag-unbudgeted-{marker}",
|
||||
)
|
||||
created: Final = (
|
||||
client.proxy.register_model(_grouped_model(pool.model, pool.access_group)),
|
||||
client.proxy.register_model(_grouped_model(pool.free_model, pool.free_access_group)),
|
||||
)
|
||||
try:
|
||||
client.set_access_group_budget(pool.access_group, max_budget=TINY_BUDGET)
|
||||
_drain(client, pool.spender_key, pool.model, pool.access_group)
|
||||
yield pool
|
||||
finally:
|
||||
client.delete_access_group_budget(pool.access_group)
|
||||
client.proxy.delete_key(pool.spender_key)
|
||||
for model_id in created:
|
||||
client.proxy.delete_model(model_id)
|
||||
|
||||
|
||||
class TestModelAccessGroupBudget:
|
||||
@pytest.mark.covers("quota_management.budget.model_access_group.blocks_over_limit")
|
||||
def test_the_key_that_drained_the_pool_stays_blocked(
|
||||
self, client: BudgetClient, drained: DrainedPool
|
||||
) -> None:
|
||||
result = _call(client, drained.spender_key, drained.model)
|
||||
assert is_budget_block(result), (
|
||||
f"an exhausted pool served {drained.model!r} again: {result.status_code} {result.body[:300]}"
|
||||
)
|
||||
assert drained.access_group in result.body, (
|
||||
f"the block did not name the group that caused it: {result.body[:300]}"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("quota_management.budget.model_access_group.enforced_across_keys")
|
||||
def test_a_key_that_spent_nothing_is_blocked_by_the_shared_pool(
|
||||
self, client: BudgetClient, resources: ResourceManager, drained: DrainedPool
|
||||
) -> None:
|
||||
newcomer = resources.key(models=[drained.access_group])
|
||||
|
||||
result = _call(client, newcomer, drained.model)
|
||||
|
||||
assert is_budget_block(result), (
|
||||
"a freshly minted key with no spend of its own was served by an exhausted "
|
||||
f"shared pool: {result.status_code} {result.body[:300]}"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("quota_management.budget.model_access_group.isolates_per_group")
|
||||
def test_a_drained_group_does_not_block_a_different_group(
|
||||
self, client: BudgetClient, resources: ResourceManager, drained: DrainedPool
|
||||
) -> None:
|
||||
other = resources.key(models=[drained.free_access_group])
|
||||
|
||||
result = _call(client, other, drained.free_model)
|
||||
|
||||
assert not is_budget_block(result), (
|
||||
f"{drained.free_access_group!r} has no budget of its own but was blocked by "
|
||||
f"{drained.access_group!r}'s exhausted pool: {result.body[:300]}"
|
||||
)
|
||||
require_successful_call(result)
|
||||
|
||||
@pytest.mark.covers("quota_management.budget.model_access_group.reports_spend")
|
||||
def test_the_budget_read_reports_the_spend_drawn_against_the_pool(
|
||||
self, client: BudgetClient, drained: DrainedPool
|
||||
) -> None:
|
||||
"""Enforcement runs off a live counter while the group's row is written by the
|
||||
batched spend writer, so the recorded spend an admin reads lands a beat after the
|
||||
block. Poll for it: what matters is that it arrives and matches the pool."""
|
||||
deadline = time.monotonic() + client.proxy.poll_timeout
|
||||
reported = client.access_group_budget(drained.access_group)
|
||||
while reported.spend < TINY_BUDGET and time.monotonic() < deadline:
|
||||
time.sleep(client.proxy.poll_interval)
|
||||
reported = client.access_group_budget(drained.access_group)
|
||||
|
||||
assert reported.budget is not None, "the group lost the budget that just blocked it"
|
||||
assert reported.budget.max_budget == TINY_BUDGET
|
||||
assert reported.spend >= TINY_BUDGET, (
|
||||
f"the pool blocked at {TINY_BUDGET} but only {reported.spend} was ever recorded "
|
||||
f"against the group within {client.proxy.poll_timeout}s"
|
||||
)
|
||||
|
|
@ -3735,6 +3735,60 @@ def test_get_standard_logging_object_payload_includes_litellm_call_id(logging_ob
|
|||
assert payload["litellm_call_id"] == call_id
|
||||
|
||||
|
||||
def test_get_standard_logging_object_payload_carries_matched_access_groups(logging_obj):
|
||||
"""Access groups stamped at auth time reach the logging payload, so integrations see what a request billed."""
|
||||
from datetime import datetime
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
get_standard_logging_object_payload,
|
||||
)
|
||||
|
||||
now = datetime.now()
|
||||
payload = get_standard_logging_object_payload(
|
||||
kwargs={
|
||||
"model": "gpt-4o",
|
||||
"messages": [],
|
||||
"litellm_params": {
|
||||
"metadata": {
|
||||
"user_api_key_matched_model_access_groups": ["premium-pool", "shared-pool"]
|
||||
},
|
||||
"proxy_server_request": {"body": {}},
|
||||
},
|
||||
},
|
||||
init_response_obj={},
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
logging_obj=logging_obj,
|
||||
status="success",
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["request_model_access_groups"] == ("premium-pool", "shared-pool")
|
||||
|
||||
|
||||
def test_get_standard_logging_object_payload_has_no_access_groups_when_unstamped(
|
||||
logging_obj,
|
||||
):
|
||||
from datetime import datetime
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
get_standard_logging_object_payload,
|
||||
)
|
||||
|
||||
now = datetime.now()
|
||||
payload = get_standard_logging_object_payload(
|
||||
kwargs={"model": "gpt-4o", "messages": []},
|
||||
init_response_obj={},
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
logging_obj=logging_obj,
|
||||
status="success",
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["request_model_access_groups"] == ()
|
||||
|
||||
|
||||
def test_get_standard_logging_object_payload_preserves_absent_end_user_as_none(logging_obj):
|
||||
from datetime import datetime
|
||||
from typing import Final
|
||||
|
|
|
|||
543
tests/test_litellm/proxy/auth/test_model_access_group_budgets.py
Normal file
543
tests/test_litellm/proxy/auth/test_model_access_group_budgets.py
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
"""
|
||||
Which model access groups a request is charged to.
|
||||
|
||||
A group is attributed only when its name appears on an allowlist the caller was granted, so the
|
||||
group is what authorized the call. Asking for a model that merely belongs to a group attributes
|
||||
nothing, and every level that can name a group (key, team, team-member scope, project, org) is
|
||||
unioned rather than ranked.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm import Router
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_BudgetTable,
|
||||
Litellm_EntityType,
|
||||
LiteLLM_OrganizationTable,
|
||||
LiteLLM_ProjectTableCachedObj,
|
||||
LiteLLM_TeamMembership,
|
||||
LiteLLM_TeamTable,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
_model_access_group_max_budget_check,
|
||||
collect_matched_model_access_groups,
|
||||
common_checks,
|
||||
stamp_matched_model_access_groups,
|
||||
)
|
||||
from litellm.proxy.common_utils.reset_budget_job import _model_access_group_counter_key
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
UserApiKeyCache,
|
||||
model_access_group_registry_cache_key,
|
||||
model_access_group_spend_counter_key,
|
||||
team_membership_reservation_cache_key,
|
||||
)
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
TEAM_ID = "team-1"
|
||||
USER_ID = "user-1"
|
||||
ORG_ID = "org-1"
|
||||
BUDGETED_GROUPS = ("tier-a", "tier-b", "claude-tier")
|
||||
MODEL_ACCESS_GROUP_COUNTER_KEY = model_access_group_spend_counter_key("tier-a")
|
||||
|
||||
MODEL_LIST = [
|
||||
{
|
||||
"model_name": "gpt-4o",
|
||||
"litellm_params": {"model": "openai/gpt-4o", "api_key": "k"},
|
||||
"model_info": {"access_groups": ["tier-a", "tier-b"]},
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet",
|
||||
"litellm_params": {"model": "anthropic/claude-sonnet", "api_key": "k"},
|
||||
"model_info": {"access_groups": ["claude-tier"]},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class _ExplodingPrismaClient:
|
||||
"""Every lookup in these tests is served from the injected cache; a real DB read is a bug."""
|
||||
|
||||
def __getattr__(self, name: str) -> object:
|
||||
raise AssertionError(f"unexpected database access: {name}")
|
||||
|
||||
|
||||
class _CountingRouter(Router):
|
||||
"""Counts access-group lookups, so a test can prove the registry gate skipped them."""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.access_group_lookups = 0
|
||||
|
||||
def get_model_access_groups(self, *args, **kwargs):
|
||||
self.access_group_lookups += 1
|
||||
return super().get_model_access_groups(*args, **kwargs)
|
||||
|
||||
|
||||
async def _cache(
|
||||
budgeted_groups: tuple[str, ...] = BUDGETED_GROUPS,
|
||||
member_allowed_models: tuple[str, ...] = (),
|
||||
org_models: tuple[str, ...] = (),
|
||||
) -> UserApiKeyCache:
|
||||
cache = UserApiKeyCache()
|
||||
await cache.async_set_cache(key=model_access_group_registry_cache_key(), value=budgeted_groups)
|
||||
if member_allowed_models:
|
||||
await cache.async_set_cache(
|
||||
key=team_membership_reservation_cache_key(user_id=USER_ID, team_id=TEAM_ID),
|
||||
value=LiteLLM_TeamMembership(
|
||||
user_id=USER_ID,
|
||||
team_id=TEAM_ID,
|
||||
budget_id="member-budget",
|
||||
litellm_budget_table=LiteLLM_BudgetTable(allowed_models=list(member_allowed_models)),
|
||||
),
|
||||
model_type=LiteLLM_TeamMembership,
|
||||
)
|
||||
if org_models:
|
||||
await cache.async_set_cache(
|
||||
key=f"org_id:{ORG_ID}",
|
||||
value=LiteLLM_OrganizationTable(
|
||||
organization_id=ORG_ID,
|
||||
budget_id="org-budget",
|
||||
models=list(org_models),
|
||||
created_by=USER_ID,
|
||||
updated_by=USER_ID,
|
||||
),
|
||||
model_type=LiteLLM_OrganizationTable,
|
||||
)
|
||||
return cache
|
||||
|
||||
|
||||
async def _matched(
|
||||
*,
|
||||
model: str = "gpt-4o",
|
||||
key_models: list[str] | None = None,
|
||||
team_models: list[str] | None = None,
|
||||
team_org_id: str | None = None,
|
||||
project_models: list[str] | None = None,
|
||||
valid_token: UserAPIKeyAuth | None = None,
|
||||
cache: UserApiKeyCache | None = None,
|
||||
llm_router: Router | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
resolved_cache = cache if cache is not None else await _cache()
|
||||
return await collect_matched_model_access_groups(
|
||||
model=model,
|
||||
valid_token=valid_token
|
||||
if valid_token is not None
|
||||
else UserAPIKeyAuth(api_key="hashed", models=key_models or [], team_id=TEAM_ID, user_id=USER_ID),
|
||||
team_object=(
|
||||
LiteLLM_TeamTable(team_id=TEAM_ID, models=team_models, organization_id=team_org_id)
|
||||
if team_models is not None
|
||||
else None
|
||||
),
|
||||
project_object=(
|
||||
LiteLLM_ProjectTableCachedObj(project_id="project-1", models=project_models)
|
||||
if project_models is not None
|
||||
else None
|
||||
),
|
||||
llm_router=llm_router if llm_router is not None else Router(model_list=MODEL_LIST),
|
||||
prisma_client=_ExplodingPrismaClient(),
|
||||
user_api_key_cache=resolved_cache,
|
||||
proxy_logging_obj=ProxyLogging(user_api_key_cache=resolved_cache),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_named_on_the_key_is_attributed():
|
||||
assert await _matched(key_models=["tier-a"]) == ("tier-a",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_granted_directly_on_the_key_attributes_nothing():
|
||||
assert await _matched(key_models=["gpt-4o"]) == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("key_models", [["*"], [], ["all-proxy-models"]])
|
||||
async def test_unrestricted_key_attributes_nothing(key_models: list[str]):
|
||||
assert await _matched(key_models=key_models) == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_that_does_not_serve_the_requested_model_is_not_attributed():
|
||||
assert await _matched(model="gpt-4o", key_models=["claude-tier"]) == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_granted_groups_covering_the_model_are_attributed():
|
||||
assert await _matched(key_models=["tier-b", "tier-a"]) == ("tier-a", "tier-b")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_named_only_on_the_team_is_attributed():
|
||||
assert await _matched(key_models=[], team_models=["tier-a"]) == ("tier-a",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_named_only_in_a_team_members_scope_is_attributed():
|
||||
assert await _matched(
|
||||
model="claude-sonnet",
|
||||
key_models=["*"],
|
||||
team_models=["*"],
|
||||
cache=await _cache(member_allowed_models=("claude-tier",)),
|
||||
) == ("claude-tier",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_named_only_on_the_project_is_attributed():
|
||||
assert await _matched(key_models=["*"], project_models=["tier-b"]) == ("tier-b",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_named_only_on_the_org_is_attributed():
|
||||
assert await _matched(
|
||||
valid_token=UserAPIKeyAuth(api_key="hashed", models=["*"], user_id=USER_ID, org_id=ORG_ID),
|
||||
cache=await _cache(org_models=("tier-a",)),
|
||||
) == ("tier-a",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_named_on_the_teams_org_is_attributed_when_the_key_names_no_org():
|
||||
assert await _matched(
|
||||
key_models=["*"],
|
||||
team_models=["*"],
|
||||
team_org_id=ORG_ID,
|
||||
cache=await _cache(org_models=("tier-b",)),
|
||||
) == ("tier-b",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_team_models_sentinel_on_the_key_resolves_to_the_teams_groups():
|
||||
assert await _matched(
|
||||
valid_token=UserAPIKeyAuth(
|
||||
api_key="hashed",
|
||||
models=["all-team-models"],
|
||||
team_models=["tier-a"],
|
||||
team_id=TEAM_ID,
|
||||
user_id=USER_ID,
|
||||
),
|
||||
) == ("tier-a",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_without_a_budget_is_not_attributed():
|
||||
assert await _matched(key_models=["tier-a"], cache=await _cache(budgeted_groups=("tier-b",))) == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_registry_skips_the_access_group_matching_entirely():
|
||||
router = _CountingRouter(model_list=MODEL_LIST)
|
||||
|
||||
assert await _matched(key_models=["tier-a"], cache=await _cache(budgeted_groups=()), llm_router=router) == ()
|
||||
assert router.access_group_lookups == 0
|
||||
|
||||
assert await _matched(key_models=["tier-a"], llm_router=router) == ("tier-a",)
|
||||
assert router.access_group_lookups == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stamp_records_the_matched_groups_on_the_auth_object():
|
||||
cache = await _cache()
|
||||
valid_token = UserAPIKeyAuth(api_key="hashed", models=["tier-a", "tier-b"], team_id=TEAM_ID, user_id=USER_ID)
|
||||
|
||||
await stamp_matched_model_access_groups(
|
||||
model="gpt-4o",
|
||||
valid_token=valid_token,
|
||||
team_object=None,
|
||||
project_object=None,
|
||||
llm_router=Router(model_list=MODEL_LIST),
|
||||
prisma_client=_ExplodingPrismaClient(),
|
||||
user_api_key_cache=cache,
|
||||
proxy_logging_obj=ProxyLogging(user_api_key_cache=cache),
|
||||
)
|
||||
|
||||
assert valid_token.matched_model_access_groups == ["tier-a", "tier-b"]
|
||||
|
||||
|
||||
class _BrokenRouter(Router):
|
||||
def get_model_access_groups(self, *args, **kwargs):
|
||||
raise RuntimeError("access group store unavailable")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stamp_does_not_break_auth_when_the_access_group_lookup_fails():
|
||||
cache = await _cache()
|
||||
valid_token = UserAPIKeyAuth(api_key="hashed", models=["tier-a"], team_id=TEAM_ID, user_id=USER_ID)
|
||||
|
||||
await stamp_matched_model_access_groups(
|
||||
model="gpt-4o",
|
||||
valid_token=valid_token,
|
||||
team_object=None,
|
||||
project_object=None,
|
||||
llm_router=_BrokenRouter(model_list=MODEL_LIST),
|
||||
prisma_client=_ExplodingPrismaClient(),
|
||||
user_api_key_cache=cache,
|
||||
proxy_logging_obj=ProxyLogging(user_api_key_cache=cache),
|
||||
)
|
||||
|
||||
assert valid_token.matched_model_access_groups is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stamp_leaves_the_auth_object_untouched_when_nothing_matched():
|
||||
cache = await _cache()
|
||||
valid_token = UserAPIKeyAuth(api_key="hashed", models=["gpt-4o"], team_id=TEAM_ID, user_id=USER_ID)
|
||||
|
||||
await stamp_matched_model_access_groups(
|
||||
model="gpt-4o",
|
||||
valid_token=valid_token,
|
||||
team_object=None,
|
||||
project_object=None,
|
||||
llm_router=Router(model_list=MODEL_LIST),
|
||||
prisma_client=_ExplodingPrismaClient(),
|
||||
user_api_key_cache=cache,
|
||||
proxy_logging_obj=ProxyLogging(user_api_key_cache=cache),
|
||||
)
|
||||
|
||||
assert valid_token.matched_model_access_groups is None
|
||||
|
||||
|
||||
class _MagBudgetRow:
|
||||
"""One ``LiteLLM_ModelAccessGroupBudgetTable`` row as prisma hands it back."""
|
||||
|
||||
def __init__(self, access_group_name: str, spend: float = 0.0, max_budget: float | None = None) -> None:
|
||||
self.access_group_name = access_group_name
|
||||
self.spend = spend
|
||||
self.litellm_budget_table = None if max_budget is None else SimpleNamespace(max_budget=max_budget)
|
||||
|
||||
|
||||
class _RecordingPrismaClient:
|
||||
"""Serves budget rows and records which groups actually reached the database."""
|
||||
|
||||
def __init__(self, *rows: _MagBudgetRow) -> None:
|
||||
self.rows = {row.access_group_name: row for row in rows}
|
||||
self.batches: list[list[str]] = []
|
||||
self.db = SimpleNamespace(
|
||||
litellm_modelaccessgroupbudgettable=SimpleNamespace(find_many=self._find_many)
|
||||
)
|
||||
|
||||
async def _find_many(self, **kwargs):
|
||||
requested = list(kwargs["where"]["access_group_name"]["in"])
|
||||
self.batches.append(requested)
|
||||
return [self.rows[group] for group in requested if group in self.rows]
|
||||
|
||||
|
||||
def _spend_reader(spend_by_counter_key: dict[str, float]):
|
||||
"""Stand-in for proxy_server.get_current_spend, recording every counter key it is asked for."""
|
||||
seen: list[str] = []
|
||||
|
||||
async def read(counter_key, fallback_spend, max_budget=None, **kwargs):
|
||||
seen.append(counter_key)
|
||||
return spend_by_counter_key.get(counter_key, fallback_spend)
|
||||
|
||||
return read, seen
|
||||
|
||||
|
||||
async def _enforce(
|
||||
matched: tuple[str, ...],
|
||||
*rows: _MagBudgetRow,
|
||||
spend_by_counter_key: dict[str, float] | None = None,
|
||||
prisma_client: object | None = None,
|
||||
cache: UserApiKeyCache | None = None,
|
||||
) -> list[str]:
|
||||
read, seen = _spend_reader(spend_by_counter_key or {})
|
||||
# The check takes its client and cache as arguments, injected just below. get_current_spend is the
|
||||
# one collaborator it reaches by a lazy `from litellm.proxy.proxy_server import`, with no parameter.
|
||||
with patch("litellm.proxy.proxy_server.get_current_spend", read): # test-quality-ok: get_current_spend is lazily imported inside _model_access_group_max_budget_check and has no injection point
|
||||
await _model_access_group_max_budget_check(
|
||||
matched_model_access_groups=matched,
|
||||
prisma_client=prisma_client if prisma_client is not None else _RecordingPrismaClient(*rows),
|
||||
user_api_key_cache=cache if cache is not None else UserApiKeyCache(),
|
||||
)
|
||||
return seen
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_under_its_max_budget_passes():
|
||||
assert await _enforce(
|
||||
("tier-a",),
|
||||
_MagBudgetRow("tier-a", spend=4.0, max_budget=10.0),
|
||||
spend_by_counter_key={MODEL_ACCESS_GROUP_COUNTER_KEY: 4.0},
|
||||
) == [MODEL_ACCESS_GROUP_COUNTER_KEY]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_exactly_at_its_max_budget_blocks_the_request():
|
||||
"""A pool whose spend has reached the ceiling has nothing left, so the next request is refused.
|
||||
|
||||
This is where the check departs from the tag one it otherwise mirrors, and it matches where
|
||||
keys and organizations already draw the line.
|
||||
"""
|
||||
with pytest.raises(litellm.BudgetExceededError) as exc_info:
|
||||
await _enforce(
|
||||
("tier-a",),
|
||||
_MagBudgetRow("tier-a", max_budget=10.0),
|
||||
spend_by_counter_key={MODEL_ACCESS_GROUP_COUNTER_KEY: 10.0},
|
||||
)
|
||||
|
||||
assert exc_info.value.entity_id == "tier-a"
|
||||
assert exc_info.value.current_cost == 10.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_just_under_its_max_budget_passes():
|
||||
"""Asserting the counter was read is what keeps this honest: a group that got skipped entirely,
|
||||
because its row never arrived or carried no budget, would also not raise."""
|
||||
assert await _enforce(
|
||||
("tier-a",),
|
||||
_MagBudgetRow("tier-a", max_budget=10.0),
|
||||
spend_by_counter_key={MODEL_ACCESS_GROUP_COUNTER_KEY: 9.99},
|
||||
) == [MODEL_ACCESS_GROUP_COUNTER_KEY]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_non_positive_budget_means_no_budget():
|
||||
"""The reservation path treats max_budget <= 0 as unbudgeted, so the read-time check must agree.
|
||||
|
||||
Without this the exclusive ceiling would turn a zero into a total freeze on one path and a
|
||||
no-op on the other.
|
||||
"""
|
||||
assert (
|
||||
await _enforce(
|
||||
("tier-a",),
|
||||
_MagBudgetRow("tier-a", max_budget=0.0),
|
||||
spend_by_counter_key={MODEL_ACCESS_GROUP_COUNTER_KEY: 5.0},
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_over_its_max_budget_blocks_the_request_and_names_the_group():
|
||||
with pytest.raises(litellm.BudgetExceededError) as exc_info:
|
||||
await _enforce(
|
||||
("tier-a",),
|
||||
_MagBudgetRow("tier-a", max_budget=10.0),
|
||||
spend_by_counter_key={MODEL_ACCESS_GROUP_COUNTER_KEY: 10.5},
|
||||
)
|
||||
|
||||
assert exc_info.value.entity_id == "tier-a"
|
||||
assert exc_info.value.entity_type == Litellm_EntityType.MODEL_ACCESS_GROUP.value
|
||||
assert exc_info.value.current_cost == 10.5
|
||||
assert exc_info.value.max_budget == 10.0
|
||||
assert "tier-a" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_with_a_row_but_no_budget_never_blocks():
|
||||
"""An admin can register a group without a ceiling; that must not become an implicit zero budget."""
|
||||
assert (
|
||||
await _enforce(
|
||||
("tier-a",),
|
||||
_MagBudgetRow("tier-a", spend=9999.0),
|
||||
spend_by_counter_key={MODEL_ACCESS_GROUP_COUNTER_KEY: 9999.0},
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_cold_counter_falls_back_to_the_spend_recorded_on_the_row():
|
||||
"""After a counter expires the DB row is the only record of the spend, so it has to be read."""
|
||||
with pytest.raises(litellm.BudgetExceededError) as exc_info:
|
||||
await _enforce(("tier-a",), _MagBudgetRow("tier-a", spend=12.0, max_budget=10.0))
|
||||
|
||||
assert exc_info.value.current_cost == 12.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_over_budget_group_blocks_even_when_another_matched_group_is_fine():
|
||||
with pytest.raises(litellm.BudgetExceededError) as exc_info:
|
||||
await _enforce(
|
||||
("tier-a", "tier-b"),
|
||||
_MagBudgetRow("tier-a", max_budget=10.0),
|
||||
_MagBudgetRow("tier-b", max_budget=1.0),
|
||||
spend_by_counter_key={
|
||||
MODEL_ACCESS_GROUP_COUNTER_KEY: 1.0,
|
||||
model_access_group_spend_counter_key("tier-b"): 5.0,
|
||||
},
|
||||
)
|
||||
|
||||
assert exc_info.value.entity_id == "tier-b"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_that_matched_no_group_touches_neither_database_nor_counters():
|
||||
assert await _enforce((), prisma_client=_ExplodingPrismaClient()) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_budget_check_reads_the_counter_key_the_reset_job_clears():
|
||||
"""Reads and resets must agree, or a rollover clears a counter nobody reads."""
|
||||
reset_job_key = _model_access_group_counter_key(SimpleNamespace(access_group_name="tier-a"))
|
||||
|
||||
assert await _enforce(("tier-a",), _MagBudgetRow("tier-a", max_budget=10.0)) == [reset_job_key]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_second_request_serves_the_budget_row_from_cache():
|
||||
cache = UserApiKeyCache()
|
||||
prisma_client = _RecordingPrismaClient(_MagBudgetRow("tier-a", max_budget=10.0))
|
||||
|
||||
await _enforce(("tier-a",), prisma_client=prisma_client, cache=cache)
|
||||
await _enforce(("tier-a",), prisma_client=prisma_client, cache=cache)
|
||||
|
||||
assert prisma_client.batches == [["tier-a"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_database_error_does_not_block_the_request():
|
||||
class _FailingPrismaClient:
|
||||
def __init__(self) -> None:
|
||||
self.db = SimpleNamespace(
|
||||
litellm_modelaccessgroupbudgettable=SimpleNamespace(find_many=self._boom)
|
||||
)
|
||||
|
||||
async def _boom(self, **kwargs):
|
||||
raise RuntimeError("database unavailable")
|
||||
|
||||
assert await _enforce(("tier-a",), prisma_client=_FailingPrismaClient()) == []
|
||||
|
||||
|
||||
async def _common_checks_with_over_budget_group(*, skip_budget_checks: bool) -> bool:
|
||||
cache = await _cache()
|
||||
prisma_client = _RecordingPrismaClient(_MagBudgetRow("tier-a", max_budget=1.0))
|
||||
read, _ = _spend_reader({MODEL_ACCESS_GROUP_COUNTER_KEY: 99.0})
|
||||
|
||||
with (
|
||||
# common_checks resolves all three off the proxy_server module at call time; its signature
|
||||
# has no client, cache or spend-reader parameter to pass them through instead.
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma_client), # test-quality-ok: common_checks lazily imports prisma_client from proxy_server and takes no client parameter
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache", cache), # test-quality-ok: common_checks lazily imports user_api_key_cache from proxy_server and takes no cache parameter
|
||||
patch("litellm.proxy.proxy_server.get_current_spend", read), # test-quality-ok: get_current_spend is lazily imported inside the budget check and has no injection point
|
||||
):
|
||||
return await common_checks(
|
||||
request_body={"model": "gpt-4o", "messages": []},
|
||||
team_object=None,
|
||||
user_object=None,
|
||||
end_user_object=None,
|
||||
global_proxy_spend=None,
|
||||
general_settings={},
|
||||
route="/v1/chat/completions",
|
||||
llm_router=Router(model_list=MODEL_LIST),
|
||||
proxy_logging_obj=ProxyLogging(user_api_key_cache=cache),
|
||||
valid_token=UserAPIKeyAuth(api_key="hashed", models=["tier-a"], user_id=USER_ID),
|
||||
request=SimpleNamespace(method="POST", headers={}, query_params={}, url=SimpleNamespace(path="/v1/chat/completions")),
|
||||
skip_budget_checks=skip_budget_checks,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_common_checks_blocks_a_request_whose_group_is_over_budget():
|
||||
with pytest.raises(litellm.BudgetExceededError) as exc_info:
|
||||
await _common_checks_with_over_budget_group(skip_budget_checks=False)
|
||||
|
||||
assert exc_info.value.entity_id == "tier-a"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_free_model_routes_skip_the_model_access_group_budget_check():
|
||||
"""skip_budget_checks is how free models stay free; it has to cover this budget too."""
|
||||
assert await _common_checks_with_over_budget_group(skip_budget_checks=True) is True
|
||||
|
|
@ -77,6 +77,7 @@ class MockBatcher:
|
|||
self.litellm_teammembership = _Table("team_membership", self)
|
||||
self.litellm_organizationtable = _Table("org", self)
|
||||
self.litellm_tagtable = _Table("tag", self)
|
||||
self.litellm_modelaccessgroupbudgettable = _Table("model_access_group", self)
|
||||
self.litellm_endusertable = _Table("enduser", self)
|
||||
|
||||
async def commit(self):
|
||||
|
|
@ -91,6 +92,7 @@ class MockDB:
|
|||
self.litellm_endusertable = MockTable()
|
||||
self.litellm_organizationtable = MockTable()
|
||||
self.litellm_tagtable = MockTable()
|
||||
self.litellm_modelaccessgroupbudgettable = MockTable()
|
||||
self.batch_calls: List[Dict[str, Any]] = []
|
||||
self.batchers: List[MockBatcher] = []
|
||||
|
||||
|
|
@ -521,6 +523,7 @@ _LINKED_TABLE_CASES = [
|
|||
),
|
||||
("org", {"budget_id": {"in": ["7d-budget-tier"]}, "spend": {"gt": 0}}),
|
||||
("tag", {"budget_id": {"in": ["7d-budget-tier"]}, "spend": {"gt": 0}}),
|
||||
("model_access_group", {"budget_id": {"in": ["7d-budget-tier"]}, "spend": {"gt": 0}}),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -1439,13 +1442,19 @@ _INVALIDATION_CASES = [
|
|||
"spend:tag:tenant-42",
|
||||
{"tag:tenant-42"},
|
||||
),
|
||||
(
|
||||
"litellm_modelaccessgroupbudgettable",
|
||||
type("AccessGroup", (), {"access_group_name": "gpt-4-group"}),
|
||||
"spend:model_access_group:gpt-4-group",
|
||||
{"model_access_group:gpt-4-group"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"table_attr, linked_row, counter_key, cache_keys",
|
||||
_INVALIDATION_CASES,
|
||||
ids=["team_membership", "key", "org", "tag"],
|
||||
ids=["team_membership", "key", "org", "tag", "model_access_group"],
|
||||
)
|
||||
def test_budget_table_reset_invalidates_counters_and_management_cache(
|
||||
reset_budget_job, mock_prisma_client, monkeypatch, table_attr, linked_row, counter_key, cache_keys
|
||||
|
|
@ -1499,6 +1508,102 @@ def test_budget_table_reset_commits_even_when_cache_eviction_fails(reset_budget_
|
|||
assert mock_prisma_client.db.batchers[0].committed is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model access group budgets ride the same cascade
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _model_access_group_row(name: str = "gpt-4-group", spend: float = 12.0, budget_id: str = "budget-1"):
|
||||
"""A LiteLLM_ModelAccessGroupBudgetTable row, shaped like prisma hands it back."""
|
||||
return type("AccessGroup", (), {"access_group_name": name, "spend": spend, "budget_id": budget_id})
|
||||
|
||||
|
||||
def test_access_group_reset_only_matches_rows_that_have_spend(reset_budget_job, mock_prisma_client, monkeypatch):
|
||||
"""Both the read and the write are filtered to spend > 0 on the due tiers.
|
||||
|
||||
A group sitting at spend 0 has nothing to reset, and a group hanging off a
|
||||
tier that is not due yet must not be swept along: both are excluded by the
|
||||
filter, not by anything downstream.
|
||||
"""
|
||||
_make_counter_invalidation_job(monkeypatch)
|
||||
mock_prisma_client.data["budget"] = [_budget_row(budget_id="budget-due", budget_duration="7d")]
|
||||
mock_prisma_client.db.litellm_modelaccessgroupbudgettable.set_find_many_results(
|
||||
[_model_access_group_row(budget_id="budget-due")]
|
||||
)
|
||||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
|
||||
|
||||
expected_where = {"budget_id": {"in": ["budget-due"]}, "spend": {"gt": 0}}
|
||||
assert mock_prisma_client.db.litellm_modelaccessgroupbudgettable.find_many_calls == [{"where": expected_where}]
|
||||
writes = _batch_writes(mock_prisma_client, "model_access_group", op="update_many")
|
||||
assert len(writes) == 1
|
||||
assert writes[0]["where"] == expected_where
|
||||
assert writes[0]["data"] == {"spend": 0}
|
||||
|
||||
|
||||
def test_access_groups_are_untouched_when_no_budget_is_due(reset_budget_job, mock_prisma_client, monkeypatch):
|
||||
"""No due tier means the group table is never read, written or evicted."""
|
||||
counter_cache = _make_counter_invalidation_job(monkeypatch)
|
||||
mock_prisma_client.db.litellm_modelaccessgroupbudgettable.set_find_many_results([_model_access_group_row()])
|
||||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
|
||||
|
||||
assert mock_prisma_client.db.litellm_modelaccessgroupbudgettable.find_many_calls == []
|
||||
assert _batch_writes(mock_prisma_client, "model_access_group") == []
|
||||
counter_cache.in_memory_cache.set_cache.assert_not_called()
|
||||
counter_cache.user_api_key_cache.async_delete_cache.assert_not_awaited()
|
||||
|
||||
|
||||
def test_budget_table_reset_invalidates_every_access_group_not_just_the_first(
|
||||
reset_budget_job, mock_prisma_client, monkeypatch
|
||||
):
|
||||
"""When several groups share the expiring tier, all of them are evicted."""
|
||||
counter_cache = _make_counter_invalidation_job(monkeypatch)
|
||||
mock_prisma_client.data["budget"] = [_budget_row(budget_id="budget-1")]
|
||||
mock_prisma_client.db.litellm_modelaccessgroupbudgettable.set_find_many_results(
|
||||
[_model_access_group_row(name=name) for name in ("group-a", "group-b", "group-c")]
|
||||
)
|
||||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
|
||||
|
||||
deleted = {call.kwargs.get("key") for call in counter_cache.user_api_key_cache.async_delete_cache.await_args_list}
|
||||
assert deleted == {"model_access_group:group-a", "model_access_group:group-b", "model_access_group:group-c"}
|
||||
for name in ("group-a", "group-b", "group-c"):
|
||||
counter_cache.in_memory_cache.set_cache.assert_any_call(key=f"spend:model_access_group:{name}", value=0.0, ttl=60)
|
||||
|
||||
|
||||
def test_budget_cascade_carries_access_group_overage_when_rollover_enabled(
|
||||
rollover_enabled, reset_budget_job, mock_prisma_client, monkeypatch
|
||||
):
|
||||
"""A group 5 over the tier cap keeps a spend of 5 in the next window, the
|
||||
same way a tag or a team member does: over-cap rows are decremented by the
|
||||
cap, the rest are zeroed, and the counter is seeded with the carried spend."""
|
||||
counter_cache = _make_counter_invalidation_job(monkeypatch)
|
||||
mock_prisma_client.data["budget"] = [_budget_row(budget_id="budget-roll", budget_duration="7d", max_budget=10.0)]
|
||||
mock_prisma_client.db.litellm_modelaccessgroupbudgettable.set_find_many_results(
|
||||
[_model_access_group_row(spend=15.0, budget_id="budget-roll")]
|
||||
)
|
||||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
|
||||
|
||||
writes = _batch_writes(mock_prisma_client, "model_access_group")
|
||||
assert {
|
||||
"table": "model_access_group",
|
||||
"op": "update_many",
|
||||
"where": {"budget_id": "budget-roll", "spend": {"gt": 10.0}},
|
||||
"data": {"spend": {"decrement": 10.0}},
|
||||
} in writes
|
||||
assert {
|
||||
"table": "model_access_group",
|
||||
"op": "update_many",
|
||||
"where": {"budget_id": "budget-roll", "spend": {"gt": 0, "lte": 10.0}},
|
||||
"data": {"spend": 0},
|
||||
} in writes
|
||||
assert _replay_spend_writes(writes, 15.0) == 5.0
|
||||
assert _replay_spend_writes(writes, 8.0) == 0
|
||||
counter_cache.in_memory_cache.set_cache.assert_any_call(key="spend:model_access_group:gpt-4-group", value=5.0, ttl=60)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Atomicity of the budget-table cascade (LIT-5138)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1611,6 +1716,7 @@ def test_budget_cascade_writes_land_in_a_single_transaction(reset_budget_job, mo
|
|||
("key", "update_many"),
|
||||
("org", "update_many"),
|
||||
("tag", "update_many"),
|
||||
("model_access_group", "update_many"),
|
||||
("enduser", "update_many"),
|
||||
("budget", "update_many"),
|
||||
}
|
||||
|
|
@ -1646,7 +1752,7 @@ def test_failed_cascade_is_logged_as_a_cascade_failure(monkeypatch):
|
|||
assert mock_exception.call_count == 1
|
||||
message = mock_exception.call_args.args[0]
|
||||
assert "cascade" in message
|
||||
for mentioned in ("team member", "enduser", "org", "tag", "budget_reset_at"):
|
||||
for mentioned in ("team member", "enduser", "org", "tag", "model access group", "budget_reset_at"):
|
||||
assert mentioned in message, f"failure log should mention {mentioned}: {message}"
|
||||
|
||||
|
||||
|
|
|
|||
508
tests/test_litellm/proxy/db/test_model_access_group_spend.py
Normal file
508
tests/test_litellm/proxy/db/test_model_access_group_spend.py
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
"""Spend accumulation for model access group budgets."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY
|
||||
from litellm.proxy._types import DBSpendUpdateTransactions, Litellm_EntityType, SpendUpdateQueueItem
|
||||
from litellm.proxy.db.db_spend_update_writer import DBSpendUpdateWriter, debitable_model_access_groups
|
||||
from litellm.proxy.db.db_transaction_queue.daily_spend_update_queue import DailySpendUpdateQueue
|
||||
from litellm.proxy.db.db_transaction_queue.redis_update_buffer import RedisUpdateBuffer
|
||||
from litellm.proxy.db.db_transaction_queue.spend_update_queue import SpendUpdateQueue
|
||||
from litellm.proxy.spend_tracking.spend_tracking_utils import get_request_model_access_groups
|
||||
|
||||
|
||||
class _FakeRouter:
|
||||
"""Deployment lookup returning the access groups each deployment declares."""
|
||||
|
||||
def __init__(self, deployments: Mapping[str, Sequence[str] | None]) -> None:
|
||||
self._deployments = deployments
|
||||
|
||||
def get_model_info(self, id: str) -> dict | None:
|
||||
if id not in self._deployments:
|
||||
return None
|
||||
declared = self._deployments[id]
|
||||
model_info: dict = {"id": id}
|
||||
if declared is not None:
|
||||
model_info["access_groups"] = list(declared)
|
||||
return {"model_name": "some-model", "model_info": model_info}
|
||||
|
||||
|
||||
class _FakeBatchTable:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[dict, dict]] = []
|
||||
|
||||
def update_many(self, where: dict, data: dict) -> None:
|
||||
self.calls.append((where, data))
|
||||
|
||||
|
||||
class _FakeBatcher:
|
||||
def __init__(self) -> None:
|
||||
self.tables: dict[str, _FakeBatchTable] = {}
|
||||
|
||||
def __getattr__(self, name: str) -> _FakeBatchTable:
|
||||
return self.tables.setdefault(name, _FakeBatchTable())
|
||||
|
||||
|
||||
class _FakeBatchManager:
|
||||
def __init__(self, batcher: _FakeBatcher) -> None:
|
||||
self._batcher = batcher
|
||||
|
||||
async def __aenter__(self) -> _FakeBatcher:
|
||||
return self._batcher
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _FakeTransaction:
|
||||
def __init__(self, batcher: _FakeBatcher) -> None:
|
||||
self._batcher = batcher
|
||||
|
||||
def batch_(self) -> _FakeBatchManager:
|
||||
return _FakeBatchManager(self._batcher)
|
||||
|
||||
async def __aenter__(self) -> "_FakeTransaction":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _FakeDb:
|
||||
def __init__(self, batcher: _FakeBatcher) -> None:
|
||||
self._batcher = batcher
|
||||
|
||||
def tx(self, timeout: object = None) -> _FakeTransaction:
|
||||
return _FakeTransaction(self._batcher)
|
||||
|
||||
|
||||
class _FakePrismaClient:
|
||||
def __init__(self) -> None:
|
||||
self.batcher = _FakeBatcher()
|
||||
self.db = _FakeDb(self.batcher)
|
||||
|
||||
|
||||
def _empty_transactions(**overrides: dict[str, float]) -> DBSpendUpdateTransactions:
|
||||
return DBSpendUpdateTransactions(
|
||||
user_list_transactions=overrides.get("user_list_transactions", {}),
|
||||
end_user_list_transactions=overrides.get("end_user_list_transactions", {}),
|
||||
key_list_transactions=overrides.get("key_list_transactions", {}),
|
||||
team_list_transactions=overrides.get("team_list_transactions", {}),
|
||||
team_member_list_transactions=overrides.get("team_member_list_transactions", {}),
|
||||
org_list_transactions=overrides.get("org_list_transactions", {}),
|
||||
tag_list_transactions=overrides.get("tag_list_transactions", {}),
|
||||
agent_list_transactions=overrides.get("agent_list_transactions", {}),
|
||||
model_access_group_list_transactions=overrides.get("model_access_group_list_transactions", {}),
|
||||
)
|
||||
|
||||
|
||||
async def _drain(queue: SpendUpdateQueue) -> list[SpendUpdateQueueItem]:
|
||||
return await queue.flush_all_updates_from_in_memory_queue()
|
||||
|
||||
|
||||
# --- enqueue ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_matched_group_enqueues_one_item_with_full_cost():
|
||||
writer = DBSpendUpdateWriter()
|
||||
|
||||
await writer._update_model_access_group_db(
|
||||
response_cost=0.42,
|
||||
request_model_access_groups=["premium-pool"],
|
||||
served_model_id="deployment-1",
|
||||
prisma_client=object(),
|
||||
router=_FakeRouter({"deployment-1": ["premium-pool"]}),
|
||||
)
|
||||
|
||||
updates = await _drain(writer.spend_update_queue)
|
||||
assert updates == [
|
||||
SpendUpdateQueueItem(
|
||||
entity_type=Litellm_EntityType.MODEL_ACCESS_GROUP,
|
||||
entity_id="premium-pool",
|
||||
response_cost=0.42,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_matched_group_is_charged_the_full_cost_not_a_split():
|
||||
writer = DBSpendUpdateWriter()
|
||||
|
||||
await writer._update_model_access_group_db(
|
||||
response_cost=0.30,
|
||||
request_model_access_groups=["pool-a", "pool-b", "pool-c"],
|
||||
served_model_id="deployment-1",
|
||||
prisma_client=object(),
|
||||
router=_FakeRouter({"deployment-1": ["pool-a", "pool-b", "pool-c"]}),
|
||||
)
|
||||
|
||||
updates = await _drain(writer.spend_update_queue)
|
||||
assert [update["entity_id"] for update in updates] == ["pool-a", "pool-b", "pool-c"]
|
||||
assert [update["response_cost"] for update in updates] == [0.30, 0.30, 0.30]
|
||||
assert {update["entity_type"] for update in updates} == {Litellm_EntityType.MODEL_ACCESS_GROUP}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("attributed", [None, [], ()])
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_attributed_groups_enqueues_nothing(attributed):
|
||||
writer = DBSpendUpdateWriter()
|
||||
|
||||
await writer._update_model_access_group_db(
|
||||
response_cost=1.0,
|
||||
request_model_access_groups=attributed,
|
||||
served_model_id="deployment-1",
|
||||
prisma_client=object(),
|
||||
router=_FakeRouter({"deployment-1": ["premium-pool"]}),
|
||||
)
|
||||
|
||||
assert await _drain(writer.spend_update_queue) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_prisma_client_enqueues_nothing():
|
||||
writer = DBSpendUpdateWriter()
|
||||
|
||||
await writer._update_model_access_group_db(
|
||||
response_cost=1.0,
|
||||
request_model_access_groups=["premium-pool"],
|
||||
served_model_id="deployment-1",
|
||||
prisma_client=None,
|
||||
router=_FakeRouter({"deployment-1": ["premium-pool"]}),
|
||||
)
|
||||
|
||||
assert await _drain(writer.spend_update_queue) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_outside_the_attributed_set_is_never_debited():
|
||||
"""The served deployment also sits in a pool auth never attributed; that pool stays untouched."""
|
||||
writer = DBSpendUpdateWriter()
|
||||
|
||||
await writer._update_model_access_group_db(
|
||||
response_cost=0.10,
|
||||
request_model_access_groups=["premium-pool"],
|
||||
served_model_id="deployment-1",
|
||||
prisma_client=object(),
|
||||
router=_FakeRouter({"deployment-1": ["premium-pool", "unattributed-pool"]}),
|
||||
)
|
||||
|
||||
updates = await _drain(writer.spend_update_queue)
|
||||
assert [update["entity_id"] for update in updates] == ["premium-pool"]
|
||||
|
||||
|
||||
# --- fallback guard --------------------------------------------------------
|
||||
|
||||
|
||||
def test_fallback_to_a_model_in_another_pool_debits_nothing():
|
||||
assert (
|
||||
debitable_model_access_groups(
|
||||
attributed=["premium-pool"],
|
||||
served_model_id="fallback-deployment",
|
||||
router=_FakeRouter({"fallback-deployment": ["cheap-pool"]}),
|
||||
)
|
||||
== ()
|
||||
)
|
||||
|
||||
|
||||
def test_fallback_to_a_model_in_no_pool_debits_nothing():
|
||||
assert (
|
||||
debitable_model_access_groups(
|
||||
attributed=["premium-pool"],
|
||||
served_model_id="fallback-deployment",
|
||||
router=_FakeRouter({"fallback-deployment": None}),
|
||||
)
|
||||
== ()
|
||||
)
|
||||
|
||||
|
||||
def test_attributed_set_stands_when_the_served_deployment_is_unknown():
|
||||
assert debitable_model_access_groups(
|
||||
attributed=["premium-pool"],
|
||||
served_model_id="not-in-router",
|
||||
router=_FakeRouter({"deployment-1": ["premium-pool"]}),
|
||||
) == ("premium-pool",)
|
||||
|
||||
|
||||
def test_attributed_set_stands_without_a_router():
|
||||
assert debitable_model_access_groups(
|
||||
attributed=["premium-pool", "premium-pool"],
|
||||
served_model_id="deployment-1",
|
||||
router=None,
|
||||
) == ("premium-pool",)
|
||||
|
||||
|
||||
def test_partial_overlap_keeps_only_the_intersection():
|
||||
assert debitable_model_access_groups(
|
||||
attributed=["pool-a", "pool-b"],
|
||||
served_model_id="deployment-1",
|
||||
router=_FakeRouter({"deployment-1": ["pool-b", "pool-c"]}),
|
||||
) == ("pool-b",)
|
||||
|
||||
|
||||
def test_only_real_group_names_ever_become_entity_ids():
|
||||
"""Whatever shape the attributed set arrives in, an empty or non-string name never reaches the queue."""
|
||||
assert debitable_model_access_groups(
|
||||
attributed=["pool-a", "", "pool-a", None, 7],
|
||||
served_model_id=None,
|
||||
router=None,
|
||||
) == ("pool-a",)
|
||||
|
||||
|
||||
# --- metadata extraction ---------------------------------------------------
|
||||
|
||||
|
||||
def test_access_groups_read_from_request_metadata():
|
||||
kwargs = {"litellm_params": {"metadata": {MODEL_ACCESS_GROUP_METADATA_KEY: ["pool-a", "pool-b", "pool-a"]}}}
|
||||
assert get_request_model_access_groups(kwargs) == ("pool-a", "pool-b")
|
||||
|
||||
|
||||
def test_access_groups_read_from_litellm_metadata():
|
||||
kwargs = {"litellm_params": {"litellm_metadata": {MODEL_ACCESS_GROUP_METADATA_KEY: ["pool-a"]}}}
|
||||
assert get_request_model_access_groups(kwargs) == ("pool-a",)
|
||||
|
||||
|
||||
def test_standard_logging_payload_wins_over_metadata():
|
||||
kwargs = {
|
||||
"litellm_params": {"metadata": {MODEL_ACCESS_GROUP_METADATA_KEY: ["from-metadata"]}},
|
||||
"standard_logging_object": {"request_model_access_groups": ["from-payload"]},
|
||||
}
|
||||
assert get_request_model_access_groups(kwargs) == ("from-payload",)
|
||||
|
||||
|
||||
def test_metadata_is_used_when_the_logging_payload_carries_no_groups():
|
||||
kwargs = {
|
||||
"litellm_params": {"metadata": {MODEL_ACCESS_GROUP_METADATA_KEY: ["from-metadata"]}},
|
||||
"standard_logging_object": {"request_model_access_groups": []},
|
||||
}
|
||||
assert get_request_model_access_groups(kwargs) == ("from-metadata",)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stamped", ["pool-a", 7, {"pool-a": 1}])
|
||||
def test_non_list_access_group_metadata_is_ignored(stamped):
|
||||
kwargs = {"litellm_params": {"metadata": {MODEL_ACCESS_GROUP_METADATA_KEY: stamped}}}
|
||||
assert get_request_model_access_groups(kwargs) == ()
|
||||
|
||||
|
||||
def test_key_absent_from_metadata_yields_no_groups():
|
||||
"""The chat path only stamps the key when something matched, so absent must mean nothing to debit."""
|
||||
kwargs = {"litellm_params": {"metadata": {"user_api_key_user_id": "u-1"}}}
|
||||
assert get_request_model_access_groups(kwargs) == ()
|
||||
|
||||
|
||||
def test_explicit_none_yields_no_groups():
|
||||
"""The pass-through path stamps the key unconditionally, so it can be present and None."""
|
||||
kwargs = {"litellm_params": {"metadata": {MODEL_ACCESS_GROUP_METADATA_KEY: None}}}
|
||||
assert get_request_model_access_groups(kwargs) == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"metadata",
|
||||
[
|
||||
{"user_api_key_user_id": "u-1"},
|
||||
{MODEL_ACCESS_GROUP_METADATA_KEY: None},
|
||||
],
|
||||
ids=["key-absent", "key-present-but-none"],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_neither_absent_nor_none_metadata_debits_anything(metadata):
|
||||
writer = DBSpendUpdateWriter()
|
||||
|
||||
await writer._update_model_access_group_db(
|
||||
response_cost=0.5,
|
||||
request_model_access_groups=get_request_model_access_groups({"litellm_params": {"metadata": metadata}}),
|
||||
served_model_id="deployment-1",
|
||||
prisma_client=object(),
|
||||
router=_FakeRouter({"deployment-1": ["premium-pool"]}),
|
||||
)
|
||||
|
||||
assert await _drain(writer.spend_update_queue) == []
|
||||
|
||||
|
||||
def test_detached_sub_call_falls_back_to_the_auth_object():
|
||||
"""Sub-calls inherit only the identity keys, so the groups come off user_api_key_auth there."""
|
||||
|
||||
class _Auth:
|
||||
matched_model_access_groups = ["premium-pool"]
|
||||
|
||||
kwargs = {"litellm_params": {"metadata": {"user_api_key_auth": _Auth()}}}
|
||||
assert get_request_model_access_groups(kwargs) == ("premium-pool",)
|
||||
|
||||
|
||||
def test_stamped_metadata_wins_over_the_auth_object():
|
||||
class _Auth:
|
||||
matched_model_access_groups = ["stale-pool"]
|
||||
|
||||
kwargs = {
|
||||
"litellm_params": {
|
||||
"metadata": {
|
||||
MODEL_ACCESS_GROUP_METADATA_KEY: ["fresh-pool"],
|
||||
"user_api_key_auth": _Auth(),
|
||||
}
|
||||
}
|
||||
}
|
||||
assert get_request_model_access_groups(kwargs) == ("fresh-pool",)
|
||||
|
||||
|
||||
def test_auth_object_without_matched_groups_yields_no_groups():
|
||||
class _Auth:
|
||||
matched_model_access_groups = None
|
||||
|
||||
kwargs = {"litellm_params": {"metadata": {"user_api_key_auth": _Auth()}}}
|
||||
assert get_request_model_access_groups(kwargs) == ()
|
||||
|
||||
|
||||
def test_non_string_entries_are_dropped():
|
||||
kwargs = {"litellm_params": {"metadata": {MODEL_ACCESS_GROUP_METADATA_KEY: ["pool-a", None, "", 3]}}}
|
||||
assert get_request_model_access_groups(kwargs) == ("pool-a",)
|
||||
|
||||
|
||||
def test_missing_metadata_yields_no_groups():
|
||||
assert get_request_model_access_groups(None) == ()
|
||||
assert get_request_model_access_groups({}) == ()
|
||||
assert get_request_model_access_groups({"litellm_params": {}}) == ()
|
||||
|
||||
|
||||
# --- queue bucketing and redis round trip ----------------------------------
|
||||
|
||||
|
||||
def test_access_group_updates_aggregate_into_their_own_bucket():
|
||||
queue = SpendUpdateQueue()
|
||||
|
||||
transactions = queue.get_aggregated_db_spend_update_transactions(
|
||||
[
|
||||
SpendUpdateQueueItem(
|
||||
entity_type=Litellm_EntityType.MODEL_ACCESS_GROUP, entity_id="pool-a", response_cost=0.1
|
||||
),
|
||||
SpendUpdateQueueItem(
|
||||
entity_type=Litellm_EntityType.MODEL_ACCESS_GROUP, entity_id="pool-a", response_cost=0.2
|
||||
),
|
||||
SpendUpdateQueueItem(
|
||||
entity_type=Litellm_EntityType.MODEL_ACCESS_GROUP, entity_id="pool-b", response_cost=0.5
|
||||
),
|
||||
SpendUpdateQueueItem(entity_type=Litellm_EntityType.TAG, entity_id="pool-a", response_cost=9.0),
|
||||
]
|
||||
)
|
||||
|
||||
assert transactions["model_access_group_list_transactions"] == {"pool-a": pytest.approx(0.3), "pool-b": 0.5}
|
||||
assert transactions["tag_list_transactions"] == {"pool-a": 9.0}
|
||||
|
||||
|
||||
def test_access_group_transactions_survive_the_redis_buffer_merge():
|
||||
merged = RedisUpdateBuffer._combine_list_of_transactions(
|
||||
[
|
||||
_empty_transactions(model_access_group_list_transactions={"pool-a": 0.25}),
|
||||
_empty_transactions(model_access_group_list_transactions={"pool-a": 0.25, "pool-b": 1.0}),
|
||||
]
|
||||
)
|
||||
|
||||
assert merged["model_access_group_list_transactions"] == {"pool-a": 0.5, "pool-b": 1.0}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_buffer_requeues_access_group_transactions_as_queue_items():
|
||||
queue = SpendUpdateQueue()
|
||||
daily_queue = DailySpendUpdateQueue()
|
||||
|
||||
await RedisUpdateBuffer._restore_spend_updates_to_in_memory_queues(
|
||||
db_spend_update_transactions=_empty_transactions(model_access_group_list_transactions={"pool-a": 0.75}),
|
||||
daily_spend_update_transactions=None,
|
||||
daily_team_spend_update_transactions=None,
|
||||
daily_org_spend_update_transactions=None,
|
||||
daily_end_user_spend_update_transactions=None,
|
||||
daily_agent_spend_update_transactions=None,
|
||||
spend_update_queue=queue,
|
||||
daily_spend_update_queue=daily_queue,
|
||||
daily_team_spend_update_queue=daily_queue,
|
||||
daily_org_spend_update_queue=daily_queue,
|
||||
daily_end_user_spend_update_queue=daily_queue,
|
||||
daily_agent_spend_update_queue=daily_queue,
|
||||
)
|
||||
|
||||
updates = await _drain(queue)
|
||||
assert updates == [
|
||||
SpendUpdateQueueItem(
|
||||
entity_type=Litellm_EntityType.MODEL_ACCESS_GROUP,
|
||||
entity_id="pool-a",
|
||||
response_cost=0.75,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
# --- flush to postgres -----------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_increments_spend_on_the_model_access_group_budget_table():
|
||||
prisma_client = _FakePrismaClient()
|
||||
|
||||
await DBSpendUpdateWriter()._commit_spend_updates_to_db(
|
||||
prisma_client=prisma_client,
|
||||
n_retry_times=0,
|
||||
proxy_logging_obj=None,
|
||||
db_spend_update_transactions=_empty_transactions(
|
||||
model_access_group_list_transactions={"pool-b": 0.5, "pool-a": 0.25}
|
||||
),
|
||||
)
|
||||
|
||||
assert prisma_client.batcher.tables["litellm_modelaccessgroupbudgettable"].calls == [
|
||||
({"access_group_name": "pool-a"}, {"spend": {"increment": 0.25}}),
|
||||
({"access_group_name": "pool-b"}, {"spend": {"increment": 0.5}}),
|
||||
]
|
||||
assert "litellm_tagtable" not in prisma_client.batcher.tables
|
||||
|
||||
|
||||
# --- end-to-end through the batched fan-out --------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_database_updates_enqueues_access_group_spend():
|
||||
writer = DBSpendUpdateWriter()
|
||||
|
||||
await writer._batch_database_updates(
|
||||
response_cost=0.15,
|
||||
user_id=None,
|
||||
hashed_token=None,
|
||||
team_id=None,
|
||||
org_id=None,
|
||||
end_user_id=None,
|
||||
prisma_client=object(),
|
||||
litellm_proxy_budget_name=None,
|
||||
payload={"model_id": "deployment-1", "spend": 0.15},
|
||||
request_model_access_groups=("pool-a", "pool-b"),
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
access_group_updates = [
|
||||
update
|
||||
for update in await _drain(writer.spend_update_queue)
|
||||
if update["entity_type"] is Litellm_EntityType.MODEL_ACCESS_GROUP
|
||||
]
|
||||
assert [(update["entity_id"], update["response_cost"]) for update in access_group_updates] == [
|
||||
("pool-a", 0.15),
|
||||
("pool-b", 0.15),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_database_updates_enqueues_nothing_without_access_groups():
|
||||
writer = DBSpendUpdateWriter()
|
||||
|
||||
await writer._batch_database_updates(
|
||||
response_cost=0.15,
|
||||
user_id=None,
|
||||
hashed_token=None,
|
||||
team_id=None,
|
||||
org_id=None,
|
||||
end_user_id=None,
|
||||
prisma_client=object(),
|
||||
litellm_proxy_budget_name=None,
|
||||
payload={"model_id": "deployment-1", "spend": 0.15},
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
updates = await _drain(writer.spend_update_queue)
|
||||
assert [update for update in updates if update["entity_type"] is Litellm_EntityType.MODEL_ACCESS_GROUP] == []
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
|
||||
import pytest
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.hooks.proxy_track_cost_callback import (
|
||||
_ProxyDBLogger,
|
||||
_get_budget_reservation_from_metadata,
|
||||
_ProxyDBLogger,
|
||||
_should_track_cost_callback,
|
||||
_update_database_and_spend_counters,
|
||||
)
|
||||
|
|
@ -589,6 +589,7 @@ async def test_update_database_and_spend_counters_updates_counters_after_db_upda
|
|||
response_cost=0.2,
|
||||
budget_reservation=budget_reservation,
|
||||
request_tags=["tag-a"],
|
||||
model_access_groups=("premium",),
|
||||
)
|
||||
|
||||
proxy_logging_obj.db_spend_update_writer.update_database.assert_awaited_once()
|
||||
|
|
@ -603,6 +604,7 @@ async def test_update_database_and_spend_counters_updates_counters_after_db_upda
|
|||
tags=["tag-a"],
|
||||
request_id="chatcmpl-abc123",
|
||||
request_started_at=start_time,
|
||||
model_access_groups=("premium",),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1935,3 +1937,113 @@ async def test_update_database_and_spend_counters_forwards_a_missing_request_id_
|
|||
)
|
||||
|
||||
assert increment_spend_counters.await_args.kwargs["request_id"] is None
|
||||
|
||||
|
||||
class _FakeDeploymentLookup:
|
||||
"""Deployment lookup returning the access groups each deployment declares."""
|
||||
|
||||
def __init__(self, deployments):
|
||||
self._deployments = deployments
|
||||
|
||||
def get_model_info(self, id):
|
||||
if id not in self._deployments:
|
||||
return None
|
||||
return {"model_name": "premium-haiku", "model_info": {"id": id, "access_groups": list(self._deployments[id])}}
|
||||
|
||||
|
||||
def _model_access_group_kwargs(granted, served_model_id=None):
|
||||
metadata = {"user_api_key": "hashed-key", "user_api_key_user_id": "user-1"}
|
||||
if granted is not None:
|
||||
metadata[MODEL_ACCESS_GROUP_METADATA_KEY] = list(granted)
|
||||
return {
|
||||
"call_type": "acompletion",
|
||||
"model": "premium-haiku",
|
||||
"litellm_call_id": "test-call-id",
|
||||
"litellm_params": {"metadata": metadata},
|
||||
"stream": False,
|
||||
"standard_logging_object": {"response_cost": 0.25, "request_tags": None, "model_id": served_model_id},
|
||||
}
|
||||
|
||||
|
||||
async def _groups_charged_by_the_callback(kwargs, deployments=None):
|
||||
"""The groups the callback hands the spend counters for one request.
|
||||
|
||||
The callback resolves ``proxy_logging_obj`` and the router by importing them off
|
||||
``proxy_server`` inside its own body, so there is no seam to inject either through.
|
||||
"""
|
||||
logger = _ProxyDBLogger()
|
||||
with (
|
||||
patch( # test-quality-ok: callback imports proxy_logging_obj off proxy_server in its body, no seam
|
||||
"litellm.proxy.proxy_server.proxy_logging_obj"
|
||||
) as mock_proxy_logging,
|
||||
patch( # test-quality-ok: the arguments to this call are the boundary under test
|
||||
"litellm.proxy.hooks.proxy_track_cost_callback._update_database_and_spend_counters",
|
||||
new=AsyncMock(),
|
||||
) as mock_update,
|
||||
patch( # test-quality-ok: llm_router is a proxy_server global the callback reads lazily, no seam
|
||||
"litellm.proxy.proxy_server.llm_router", new=_FakeDeploymentLookup(deployments or {})
|
||||
),
|
||||
):
|
||||
mock_proxy_logging.failed_tracking_alert = AsyncMock()
|
||||
mock_proxy_logging.slack_alerting_instance.customer_spend_alert = AsyncMock()
|
||||
mock_proxy_logging.db_spend_update_writer = MagicMock()
|
||||
mock_proxy_logging.db_spend_update_writer.update_database = AsyncMock()
|
||||
|
||||
await logger._PROXY_track_cost_callback(
|
||||
kwargs=kwargs,
|
||||
completion_response=None,
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
|
||||
return mock_update.await_args.kwargs["model_access_groups"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_cost_callback_charges_the_model_access_groups_auth_stamped():
|
||||
"""Auth stamps the matched groups onto request metadata; the callback has to carry them through.
|
||||
|
||||
Without this hop nothing writes ``spend:model_access_group:*`` on the normal path, so with
|
||||
reservations disabled the budget check reads a counter no one maintains.
|
||||
"""
|
||||
charged = await _groups_charged_by_the_callback(
|
||||
kwargs=_model_access_group_kwargs(granted=["premium", "starter"]),
|
||||
)
|
||||
|
||||
assert charged == ("premium", "starter")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_cost_callback_charges_no_model_access_group_when_none_were_stamped():
|
||||
"""A request no budgeted group authorized must not debit anything."""
|
||||
charged = await _groups_charged_by_the_callback(
|
||||
kwargs=_model_access_group_kwargs(granted=None),
|
||||
)
|
||||
|
||||
assert charged == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spend_counters_only_debit_the_group_the_served_deployment_belongs_to():
|
||||
"""A caller granted two pools that both cover the model group only draws down the pool that served.
|
||||
|
||||
The database writer already narrows by served deployment, so passing the unnarrowed set to the
|
||||
live counters let one request block a pool the persisted spend never debited.
|
||||
"""
|
||||
charged = await _groups_charged_by_the_callback(
|
||||
kwargs=_model_access_group_kwargs(granted=["premium", "tier0"], served_model_id="deployment-premium"),
|
||||
deployments={"deployment-premium": ["premium"], "deployment-tier0": ["tier0"]},
|
||||
)
|
||||
|
||||
assert charged == ("premium",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spend_counters_keep_every_granted_group_when_the_deployment_is_unknown():
|
||||
"""An unidentifiable deployment leaves the auth-time set standing, so nothing silently stops billing."""
|
||||
charged = await _groups_charged_by_the_callback(
|
||||
kwargs=_model_access_group_kwargs(granted=["premium", "tier0"], served_model_id="deployment-gone"),
|
||||
deployments={"deployment-premium": ["premium"]},
|
||||
)
|
||||
|
||||
assert charged == ("premium", "tier0")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
Test access group management endpoints
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -449,6 +453,7 @@ async def test_delete_access_group_ignores_models_that_were_already_dead():
|
|||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[deploy_broken])
|
||||
mock_prisma.db.litellm_proxymodeltable.update = AsyncMock()
|
||||
mock_prisma.db.litellm_modelaccessgroupbudgettable.delete = AsyncMock(return_value=None)
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
|
|
@ -468,6 +473,7 @@ async def test_delete_access_group_ignores_models_that_were_already_dead():
|
|||
response = await delete_access_group(
|
||||
access_group="doomed-group",
|
||||
user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN),
|
||||
auth_cache=_FakeAuthCache(),
|
||||
)
|
||||
|
||||
assert response.models_updated == 1
|
||||
|
|
@ -568,3 +574,639 @@ async def test_create_access_group_model_missing_everywhere_still_400s():
|
|||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert model_name in str(exc_info.value.detail)
|
||||
|
||||
@dataclass
|
||||
class _FakeBudgetRow:
|
||||
budget_id: str
|
||||
max_budget: float | None = None
|
||||
soft_budget: float | None = None
|
||||
budget_duration: str | None = None
|
||||
budget_reset_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeAccessGroupBudgetRow:
|
||||
access_group_name: str
|
||||
budget_id: str | None = None
|
||||
spend: float = 0.0
|
||||
litellm_budget_table: _FakeBudgetRow | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeDeployment:
|
||||
model_id: str
|
||||
model_name: str
|
||||
model_info: dict
|
||||
|
||||
|
||||
class _FakeBudgetTable:
|
||||
"""Stands in for litellm_budgettable so a test can see whether a budget row was created,
|
||||
updated in place, or left orphaned."""
|
||||
|
||||
def __init__(self, journal: list[str]) -> None:
|
||||
self.journal = journal
|
||||
self.rows: dict[str, _FakeBudgetRow] = {}
|
||||
self.create_calls: list[dict] = []
|
||||
self.update_calls: list[tuple[str, dict]] = []
|
||||
self.deleted_ids: list[str] = []
|
||||
self._sequence = 0
|
||||
|
||||
async def create(self, data, include=None):
|
||||
self._sequence += 1
|
||||
budget_id = str(data.get("budget_id") or f"budget-{self._sequence}")
|
||||
row = _FakeBudgetRow(
|
||||
budget_id=budget_id,
|
||||
max_budget=data.get("max_budget"),
|
||||
soft_budget=data.get("soft_budget"),
|
||||
budget_duration=data.get("budget_duration"),
|
||||
)
|
||||
self.rows[budget_id] = row
|
||||
self.create_calls.append(dict(data))
|
||||
self.journal.append(f"budget_table.create:{budget_id}")
|
||||
return row
|
||||
|
||||
async def update(self, where, data, include=None):
|
||||
budget_id = where["budget_id"]
|
||||
self.update_calls.append((budget_id, dict(data)))
|
||||
self.journal.append(f"budget_table.update:{budget_id}")
|
||||
row = self.rows.get(budget_id)
|
||||
if row is None:
|
||||
return None
|
||||
for field_name in ("max_budget", "soft_budget", "budget_duration"):
|
||||
if data.get(field_name) is not None:
|
||||
setattr(row, field_name, data[field_name])
|
||||
return row
|
||||
|
||||
async def delete(self, where, include=None):
|
||||
budget_id = where["budget_id"]
|
||||
self.journal.append(f"budget_table.delete:{budget_id}")
|
||||
self.deleted_ids.append(budget_id)
|
||||
return self.rows.pop(budget_id, None)
|
||||
|
||||
|
||||
class _FakeAccessGroupBudgetTable:
|
||||
"""Stands in for litellm_modelaccessgroupbudgettable, resolving `include` against the fake
|
||||
budget table the way prisma resolves the relation."""
|
||||
|
||||
def __init__(self, journal: list[str], budget_table: _FakeBudgetTable) -> None:
|
||||
self.journal = journal
|
||||
self.budget_table = budget_table
|
||||
self.rows: dict[str, _FakeAccessGroupBudgetRow] = {}
|
||||
self.upsert_calls: list[dict] = []
|
||||
|
||||
def _resolve(self, row, include):
|
||||
if row is None:
|
||||
return None
|
||||
row.litellm_budget_table = (
|
||||
self.budget_table.rows.get(row.budget_id) if include and row.budget_id is not None else None
|
||||
)
|
||||
return row
|
||||
|
||||
async def find_unique(self, where, include=None):
|
||||
return self._resolve(self.rows.get(where["access_group_name"]), include)
|
||||
|
||||
async def upsert(self, where, data, include=None):
|
||||
access_group_name = where["access_group_name"]
|
||||
self.upsert_calls.append(dict(data))
|
||||
self.journal.append(f"access_group_budget.upsert:{access_group_name}")
|
||||
existing = self.rows.get(access_group_name)
|
||||
payload = data["update"] if existing is not None else data["create"]
|
||||
row = existing or _FakeAccessGroupBudgetRow(access_group_name=access_group_name)
|
||||
row.budget_id = payload.get("budget_id")
|
||||
self.rows[access_group_name] = row
|
||||
return self._resolve(row, include)
|
||||
|
||||
async def delete(self, where, include=None):
|
||||
access_group_name = where["access_group_name"]
|
||||
self.journal.append(f"access_group_budget.delete:{access_group_name}")
|
||||
return self.rows.pop(access_group_name, None)
|
||||
|
||||
|
||||
class _FakeModelTable:
|
||||
def __init__(self, journal: list[str], deployments) -> None:
|
||||
self.journal = journal
|
||||
self.deployments = list(deployments)
|
||||
self.updates: list[tuple[dict, dict]] = []
|
||||
|
||||
async def find_many(self, where=None, **kwargs):
|
||||
return list(self.deployments)
|
||||
|
||||
async def find_unique(self, where, include=None):
|
||||
return next((d for d in self.deployments if d.model_id == where["model_id"]), None)
|
||||
|
||||
async def update(self, where, data, include=None):
|
||||
self.journal.append(f"model_table.update:{where['model_id']}")
|
||||
self.updates.append((dict(where), dict(data)))
|
||||
return None
|
||||
|
||||
|
||||
class _FakePrismaClient:
|
||||
def __init__(self, journal: list[str], deployments=()) -> None:
|
||||
self.budget_table = _FakeBudgetTable(journal)
|
||||
self.access_group_budget_table = _FakeAccessGroupBudgetTable(journal, self.budget_table)
|
||||
self.model_table = _FakeModelTable(journal, deployments)
|
||||
self.db = SimpleNamespace(
|
||||
litellm_budgettable=self.budget_table,
|
||||
litellm_modelaccessgroupbudgettable=self.access_group_budget_table,
|
||||
litellm_proxymodeltable=self.model_table,
|
||||
)
|
||||
|
||||
def jsonify_object(self, data):
|
||||
return dict(data)
|
||||
|
||||
|
||||
class _FakeAuthCache:
|
||||
"""Spy for the auth cache the endpoints evict through. Injected into the endpoint rather than
|
||||
patched over the proxy_server global, so dropping the eviction call fails a test."""
|
||||
|
||||
def __init__(self, journal: list[str] | None = None) -> None:
|
||||
self.journal = journal if journal is not None else []
|
||||
self.deleted_keys: list[str] = []
|
||||
|
||||
async def async_delete_cache(self, key):
|
||||
self.deleted_keys.append(key)
|
||||
self.journal.append(f"auth_cache.delete:{key}")
|
||||
|
||||
|
||||
def _admin():
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
|
||||
return UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
|
||||
def _deployment(model_id="deploy-1", model_name="gpt-4o", access_groups=("prod-models",)):
|
||||
return _FakeDeployment(
|
||||
model_id=model_id,
|
||||
model_name=model_name,
|
||||
model_info={"access_groups": list(access_groups)},
|
||||
)
|
||||
|
||||
|
||||
def _seed_budget(prisma, access_group, spend=0.0, budget_id="budget-seed", **budget_fields):
|
||||
prisma.budget_table.rows[budget_id] = _FakeBudgetRow(budget_id=budget_id, **budget_fields)
|
||||
prisma.access_group_budget_table.rows[access_group] = _FakeAccessGroupBudgetRow(
|
||||
access_group_name=access_group,
|
||||
budget_id=budget_id,
|
||||
spend=spend,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _proxy(prisma):
|
||||
with patch( # test-quality-ok: the endpoints import proxy_server.prisma_client themselves; no parameter to inject
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _proxy_with_stubbed_reload(prisma):
|
||||
"""delete_access_group finishes by reloading the router and judging what it serves afterwards.
|
||||
Both collaborators it reaches for there are module globals it imports itself, so a fake can only
|
||||
get in by patching them; auth_cache and prisma are the ones with a real seam."""
|
||||
never_served_router = MagicMock()
|
||||
never_served_router.get_model_ids.return_value = []
|
||||
with (
|
||||
_proxy(prisma),
|
||||
patch( # test-quality-ok: live_model_ids_snapshot() reads the llm_router global; the endpoint takes no router
|
||||
"litellm.proxy.proxy_server.llm_router", never_served_router
|
||||
),
|
||||
patch( # test-quality-ok: the endpoint calls its module-level clear_cache import; there is no parameter for it
|
||||
"litellm.proxy.management_endpoints.model_access_group_management_endpoints.clear_cache",
|
||||
new=AsyncMock(return_value=ReconcileOutcome(still_desired=None, live_after=None)),
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _eviction_journal(access_group):
|
||||
"""Both auth cache keys, in the order a write path has to evict them."""
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
model_access_group_cache_key,
|
||||
model_access_group_registry_cache_key,
|
||||
)
|
||||
|
||||
return [
|
||||
f"auth_cache.delete:{model_access_group_cache_key(access_group)}",
|
||||
f"auth_cache.delete:{model_access_group_registry_cache_key()}",
|
||||
]
|
||||
|
||||
|
||||
def _assert_evicted_after_write(journal, access_group, write_entry):
|
||||
"""Exactly the two keys, in order, after the DB write. Deliberately not a tail slice: what
|
||||
has to hold is that the eviction follows the write, not that nothing follows the eviction."""
|
||||
evictions = [entry for entry in journal if entry.startswith("auth_cache.delete:")]
|
||||
assert evictions == _eviction_journal(access_group)
|
||||
assert journal.index(write_entry) < journal.index(evictions[0])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_access_group_budget_creates_the_row_and_its_budget():
|
||||
"""First PUT has to create both halves: the budget row it links, and the access group row
|
||||
that carries the link and the shared spend."""
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
set_access_group_budget,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
AccessGroupBudgetRequest,
|
||||
)
|
||||
|
||||
prisma = _FakePrismaClient([], deployments=[_deployment()])
|
||||
cache = _FakeAuthCache()
|
||||
|
||||
with _proxy(prisma):
|
||||
response = await set_access_group_budget(
|
||||
access_group="prod-models",
|
||||
data=AccessGroupBudgetRequest(max_budget=100.0, soft_budget=80.0, budget_duration="30d"),
|
||||
user_api_key_dict=_admin(),
|
||||
auth_cache=cache,
|
||||
)
|
||||
|
||||
assert response.access_group == "prod-models"
|
||||
assert response.spend == 0.0
|
||||
assert response.budget is not None
|
||||
assert response.budget.max_budget == 100.0
|
||||
assert response.budget.soft_budget == 80.0
|
||||
assert response.budget.budget_duration == "30d"
|
||||
assert len(prisma.budget_table.create_calls) == 1
|
||||
assert prisma.access_group_budget_table.rows["prod-models"].budget_id == response.budget.budget_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_put_replaces_the_budget_instead_of_creating_another():
|
||||
"""PUT is idempotent: a second call must update the budget already linked to the group,
|
||||
not leave a second budget row (and a second group row) behind."""
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
set_access_group_budget,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
AccessGroupBudgetRequest,
|
||||
)
|
||||
|
||||
prisma = _FakePrismaClient([], deployments=[_deployment()])
|
||||
cache = _FakeAuthCache()
|
||||
|
||||
with _proxy(prisma):
|
||||
first = await set_access_group_budget(
|
||||
access_group="prod-models",
|
||||
data=AccessGroupBudgetRequest(max_budget=100.0),
|
||||
user_api_key_dict=_admin(),
|
||||
auth_cache=cache,
|
||||
)
|
||||
second = await set_access_group_budget(
|
||||
access_group="prod-models",
|
||||
data=AccessGroupBudgetRequest(max_budget=250.0),
|
||||
user_api_key_dict=_admin(),
|
||||
auth_cache=cache,
|
||||
)
|
||||
|
||||
assert first.budget is not None and second.budget is not None
|
||||
assert second.budget.budget_id == first.budget.budget_id
|
||||
assert second.budget.max_budget == 250.0
|
||||
assert len(prisma.budget_table.create_calls) == 1
|
||||
assert len(prisma.budget_table.rows) == 1
|
||||
assert len(prisma.access_group_budget_table.rows) == 1
|
||||
assert prisma.budget_table.update_calls[-1][0] == first.budget.budget_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_access_group_budget_links_an_existing_budget_without_creating_one():
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
set_access_group_budget,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
AccessGroupBudgetRequest,
|
||||
)
|
||||
|
||||
prisma = _FakePrismaClient([], deployments=[_deployment()])
|
||||
prisma.budget_table.rows["shared-budget"] = _FakeBudgetRow(budget_id="shared-budget", max_budget=7.0)
|
||||
cache = _FakeAuthCache()
|
||||
|
||||
with _proxy(prisma):
|
||||
response = await set_access_group_budget(
|
||||
access_group="prod-models",
|
||||
data=AccessGroupBudgetRequest(budget_id="shared-budget"),
|
||||
user_api_key_dict=_admin(),
|
||||
auth_cache=cache,
|
||||
)
|
||||
|
||||
assert prisma.budget_table.create_calls == []
|
||||
assert response.budget is not None
|
||||
assert response.budget.budget_id == "shared-budget"
|
||||
assert response.budget.max_budget == 7.0
|
||||
assert prisma.access_group_budget_table.rows["prod-models"].budget_id == "shared-budget"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_access_group_budget_rejects_an_empty_body():
|
||||
"""An empty PUT would register the group as budgeted while enforcing nothing."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
set_access_group_budget,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
AccessGroupBudgetRequest,
|
||||
)
|
||||
|
||||
prisma = _FakePrismaClient([], deployments=[_deployment()])
|
||||
cache = _FakeAuthCache()
|
||||
|
||||
with _proxy(prisma), pytest.raises(HTTPException) as exc_info:
|
||||
await set_access_group_budget(
|
||||
access_group="prod-models",
|
||||
data=AccessGroupBudgetRequest(),
|
||||
user_api_key_dict=_admin(),
|
||||
auth_cache=cache,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert prisma.access_group_budget_table.rows == {}
|
||||
assert cache.deleted_keys == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_access_group_budget_rejects_an_unparseable_duration():
|
||||
"""An unparseable duration can only be discovered by the reset job, long after the write."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
set_access_group_budget,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
AccessGroupBudgetRequest,
|
||||
)
|
||||
|
||||
prisma = _FakePrismaClient([], deployments=[_deployment()])
|
||||
cache = _FakeAuthCache()
|
||||
|
||||
with _proxy(prisma), pytest.raises(HTTPException) as exc_info:
|
||||
await set_access_group_budget(
|
||||
access_group="prod-models",
|
||||
data=AccessGroupBudgetRequest(max_budget=10.0, budget_duration="every other tuesday"),
|
||||
user_api_key_dict=_admin(),
|
||||
auth_cache=cache,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert prisma.budget_table.create_calls == []
|
||||
assert prisma.access_group_budget_table.rows == {}
|
||||
|
||||
|
||||
def test_access_group_budget_request_rejects_rate_limit_fields():
|
||||
"""tpm/rpm/max_parallel_requests are not enforced per access group, so accepting them would
|
||||
promise rate limiting that never happens."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
AccessGroupBudgetRequest,
|
||||
)
|
||||
|
||||
for unsupported in ({"tpm_limit": 10}, {"rpm_limit": 10}, {"max_parallel_requests": 10}):
|
||||
with pytest.raises(ValidationError):
|
||||
AccessGroupBudgetRequest(max_budget=1.0, **unsupported)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_access_group_budget_returns_the_budget_and_the_shared_spend():
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
get_access_group_budget,
|
||||
)
|
||||
|
||||
prisma = _FakePrismaClient([], deployments=[_deployment()])
|
||||
_seed_budget(prisma, "prod-models", spend=42.5, max_budget=100.0, budget_duration="30d")
|
||||
|
||||
with _proxy(prisma):
|
||||
response = await get_access_group_budget(access_group="prod-models")
|
||||
|
||||
assert response.access_group == "prod-models"
|
||||
assert response.spend == 42.5
|
||||
assert response.budget is not None
|
||||
assert response.budget.max_budget == 100.0
|
||||
assert response.budget.budget_duration == "30d"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_access_group_budget_on_a_budgetless_group_is_200_not_404():
|
||||
"""A real group that simply has no budget is not an error; only an unknown group is."""
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
get_access_group_budget,
|
||||
)
|
||||
|
||||
prisma = _FakePrismaClient([], deployments=[_deployment()])
|
||||
|
||||
with _proxy(prisma):
|
||||
response = await get_access_group_budget(access_group="prod-models")
|
||||
|
||||
assert response.spend == 0.0
|
||||
assert response.budget is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_access_group_budget_routes_404_on_an_unknown_group():
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
delete_access_group_budget,
|
||||
get_access_group_budget,
|
||||
set_access_group_budget,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
AccessGroupBudgetRequest,
|
||||
)
|
||||
|
||||
prisma = _FakePrismaClient([], deployments=[_deployment()])
|
||||
cache = _FakeAuthCache()
|
||||
admin = _admin()
|
||||
|
||||
calls = (
|
||||
lambda: get_access_group_budget(access_group="ghost-group"),
|
||||
lambda: set_access_group_budget(
|
||||
access_group="ghost-group",
|
||||
data=AccessGroupBudgetRequest(max_budget=1.0),
|
||||
user_api_key_dict=admin,
|
||||
auth_cache=cache,
|
||||
),
|
||||
lambda: delete_access_group_budget(access_group="ghost-group", auth_cache=cache),
|
||||
)
|
||||
|
||||
with _proxy(prisma):
|
||||
for make_call in calls:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await make_call()
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
assert prisma.budget_table.create_calls == []
|
||||
assert prisma.access_group_budget_table.rows == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_access_group_budget_drops_the_row_and_spares_the_shared_budget():
|
||||
"""The group row goes; the LiteLLM_BudgetTable row it linked survives, as /tag/delete leaves a
|
||||
tag's. That row can be shared, so deleting it would be data loss for whatever else points at it."""
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
delete_access_group_budget,
|
||||
)
|
||||
|
||||
prisma = _FakePrismaClient([], deployments=[_deployment()])
|
||||
_seed_budget(prisma, "prod-models", spend=12.0, max_budget=100.0)
|
||||
cache = _FakeAuthCache()
|
||||
|
||||
with _proxy(prisma):
|
||||
response = await delete_access_group_budget(access_group="prod-models", auth_cache=cache)
|
||||
|
||||
assert response.budget_deleted is True
|
||||
assert prisma.access_group_budget_table.rows == {}
|
||||
assert prisma.budget_table.deleted_ids == []
|
||||
assert prisma.budget_table.rows["budget-seed"].max_budget == 100.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_access_group_budget_on_a_budgetless_group_still_evicts():
|
||||
"""budget_deleted is False, but the group can still be sitting in the cached registry of
|
||||
budgeted groups, so the eviction has to run whether or not a row was there to drop."""
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
delete_access_group_budget,
|
||||
)
|
||||
|
||||
journal: list[str] = []
|
||||
prisma = _FakePrismaClient(journal, deployments=[_deployment()])
|
||||
cache = _FakeAuthCache(journal)
|
||||
|
||||
with _proxy(prisma):
|
||||
response = await delete_access_group_budget(access_group="prod-models", auth_cache=cache)
|
||||
|
||||
assert response.budget_deleted is False
|
||||
assert prisma.budget_table.deleted_ids == []
|
||||
_assert_evicted_after_write(journal, "prod-models", "access_group_budget.delete:prod-models")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleting_the_access_group_strips_deployments_before_dropping_the_budget():
|
||||
"""Ordering is the point: stripping first means a failure leaves an unreachable budget row,
|
||||
while the reverse leaves a live group whose enforcement silently vanished. The shared
|
||||
LiteLLM_BudgetTable row survives here too."""
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
delete_access_group,
|
||||
)
|
||||
|
||||
journal: list[str] = []
|
||||
prisma = _FakePrismaClient(journal, deployments=[_deployment()])
|
||||
_seed_budget(prisma, "prod-models", spend=3.0, max_budget=100.0)
|
||||
cache = _FakeAuthCache()
|
||||
|
||||
with _proxy_with_stubbed_reload(prisma):
|
||||
response = await delete_access_group(
|
||||
access_group="prod-models", user_api_key_dict=_admin(), auth_cache=cache
|
||||
)
|
||||
|
||||
assert response.models_updated == 1
|
||||
assert prisma.access_group_budget_table.rows == {}
|
||||
assert prisma.budget_table.deleted_ids == []
|
||||
assert prisma.budget_table.rows["budget-seed"].max_budget == 100.0
|
||||
assert journal.index("model_table.update:deploy-1") < journal.index("access_group_budget.delete:prod-models")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_access_group_info_surfaces_the_budget_and_spend():
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
get_access_group_info,
|
||||
)
|
||||
|
||||
prisma = _FakePrismaClient([], deployments=[_deployment()])
|
||||
_seed_budget(prisma, "prod-models", spend=9.5, max_budget=100.0, soft_budget=50.0)
|
||||
|
||||
with _proxy(prisma):
|
||||
info = await get_access_group_info(access_group="prod-models", user_api_key_dict=_admin())
|
||||
|
||||
assert info.model_names == ["gpt-4o"]
|
||||
assert info.spend == 9.5
|
||||
assert info.budget is not None
|
||||
assert info.budget.max_budget == 100.0
|
||||
assert info.budget.soft_budget == 50.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_access_group_budget_evicts_both_auth_cache_keys():
|
||||
"""Auth reads the per-group row and the registry of budgeted groups cache-first with no
|
||||
freshness check, so a PUT that skips either eviction returns 200 and enforces nothing until
|
||||
the TTL expires. Both keys, after the write."""
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
set_access_group_budget,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
AccessGroupBudgetRequest,
|
||||
)
|
||||
|
||||
journal: list[str] = []
|
||||
prisma = _FakePrismaClient(journal, deployments=[_deployment()])
|
||||
cache = _FakeAuthCache(journal)
|
||||
|
||||
with _proxy(prisma):
|
||||
await set_access_group_budget(
|
||||
access_group="prod-models",
|
||||
data=AccessGroupBudgetRequest(max_budget=100.0),
|
||||
user_api_key_dict=_admin(),
|
||||
auth_cache=cache,
|
||||
)
|
||||
|
||||
_assert_evicted_after_write(journal, "prod-models", "access_group_budget.upsert:prod-models")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_access_group_budget_evicts_both_auth_cache_keys():
|
||||
"""Clearing a budget has the same window as setting one: until both keys are dropped, auth
|
||||
keeps enforcing the budget that is already gone."""
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
delete_access_group_budget,
|
||||
)
|
||||
|
||||
journal: list[str] = []
|
||||
prisma = _FakePrismaClient(journal, deployments=[_deployment()])
|
||||
_seed_budget(prisma, "prod-models", spend=12.0, max_budget=100.0)
|
||||
cache = _FakeAuthCache(journal)
|
||||
|
||||
with _proxy(prisma):
|
||||
await delete_access_group_budget(access_group="prod-models", auth_cache=cache)
|
||||
|
||||
_assert_evicted_after_write(journal, "prod-models", "access_group_budget.delete:prod-models")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleting_the_access_group_evicts_both_auth_cache_keys():
|
||||
"""The group-delete cascade drops the budget row too, so it owes the same two evictions."""
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
delete_access_group,
|
||||
)
|
||||
|
||||
journal: list[str] = []
|
||||
prisma = _FakePrismaClient(journal, deployments=[_deployment()])
|
||||
_seed_budget(prisma, "prod-models", spend=3.0, max_budget=100.0)
|
||||
cache = _FakeAuthCache(journal)
|
||||
|
||||
with _proxy_with_stubbed_reload(prisma):
|
||||
await delete_access_group(access_group="prod-models", user_api_key_dict=_admin(), auth_cache=cache)
|
||||
|
||||
_assert_evicted_after_write(journal, "prod-models", "access_group_budget.delete:prod-models")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleting_an_access_group_that_never_had_a_budget_still_evicts():
|
||||
"""The cascade's delete finds no row and reports nothing dropped, but the group can still be
|
||||
sitting in the cached registry of budgeted groups, so both keys have to go regardless."""
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
delete_access_group,
|
||||
)
|
||||
|
||||
journal: list[str] = []
|
||||
prisma = _FakePrismaClient(journal, deployments=[_deployment()])
|
||||
cache = _FakeAuthCache(journal)
|
||||
|
||||
with _proxy_with_stubbed_reload(prisma):
|
||||
response = await delete_access_group(
|
||||
access_group="prod-models", user_api_key_dict=_admin(), auth_cache=cache
|
||||
)
|
||||
|
||||
assert response.models_updated == 1
|
||||
assert prisma.access_group_budget_table.rows == {}
|
||||
_assert_evicted_after_write(journal, "prod-models", "access_group_budget.delete:prod-models")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
import threading
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -10,15 +11,16 @@ from fastapi import HTTPException
|
|||
import litellm
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.constants import STREAM_SSE_KEEPALIVE_PING_BYTES
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import (
|
||||
AnthropicMessagesStreamingResponse,
|
||||
)
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.agentic_streaming_iterator import (
|
||||
AgenticAnthropicStreamingIterator,
|
||||
)
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import (
|
||||
AnthropicMessagesStreamingResponse,
|
||||
)
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_BudgetTable,
|
||||
LiteLLM_EndUserTable,
|
||||
Litellm_EntityType,
|
||||
LiteLLM_OrganizationTable,
|
||||
LiteLLM_TagTable,
|
||||
LiteLLM_TeamMembership,
|
||||
|
|
@ -27,9 +29,16 @@ from litellm.proxy._types import (
|
|||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
|
||||
from litellm.proxy.common_utils.reset_budget_job import _model_access_group_counter_key
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
UserApiKeyCache,
|
||||
model_access_group_cache_key,
|
||||
model_access_group_spend_counter_key,
|
||||
)
|
||||
from litellm.proxy.spend_tracking.budget_reservation import (
|
||||
TOKENIZE_OFF_EVENT_LOOP_MIN_CHARS,
|
||||
_approximate_input_size,
|
||||
_get_model_access_group_budget_counters,
|
||||
estimate_request_max_cost,
|
||||
get_budget_window_start,
|
||||
invalidate_budget_reservation_counters,
|
||||
|
|
@ -39,6 +48,7 @@ from litellm.proxy.spend_tracking.budget_reservation import (
|
|||
)
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
from litellm.router import Router
|
||||
from litellm.types.proxy.model_access_group_budget import ModelAccessGroupBudget
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
|
@ -2962,3 +2972,214 @@ async def test_small_prompt_is_tokenized_inline(spend_counter_state):
|
|||
|
||||
assert reservation is not None
|
||||
assert threads == [threading.main_thread()]
|
||||
|
||||
|
||||
class _ModelAccessGroupBudgetPrisma:
|
||||
"""Serves ``LiteLLM_ModelAccessGroupBudgetTable`` rows, recording what reached the database."""
|
||||
|
||||
def __init__(self, **max_budget_by_group) -> None:
|
||||
self.rows = {
|
||||
group: SimpleNamespace(
|
||||
access_group_name=group,
|
||||
spend=7.0,
|
||||
litellm_budget_table=None if max_budget is None else SimpleNamespace(max_budget=max_budget),
|
||||
)
|
||||
for group, max_budget in max_budget_by_group.items()
|
||||
}
|
||||
self.batches = []
|
||||
self.db = SimpleNamespace(
|
||||
litellm_modelaccessgroupbudgettable=SimpleNamespace(find_many=self._find_many)
|
||||
)
|
||||
|
||||
async def _find_many(self, **kwargs):
|
||||
requested = list(kwargs["where"]["access_group_name"]["in"])
|
||||
self.batches.append(requested)
|
||||
return [self.rows[group] for group in requested if group in self.rows]
|
||||
|
||||
|
||||
async def _model_access_group_counters(matched, **max_budget_by_group):
|
||||
return await _get_model_access_group_budget_counters(
|
||||
valid_token=UserAPIKeyAuth(api_key="hashed", matched_model_access_groups=matched),
|
||||
prisma_client=_ModelAccessGroupBudgetPrisma(**max_budget_by_group),
|
||||
user_api_key_cache=UserApiKeyCache(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_access_group_with_a_budget_reserves_against_the_reset_jobs_counter_key():
|
||||
counters = await _model_access_group_counters(["premium"], premium=25.0)
|
||||
|
||||
assert len(counters) == 1
|
||||
counter = counters[0]
|
||||
assert counter.counter_key == _model_access_group_counter_key(SimpleNamespace(access_group_name="premium"))
|
||||
assert counter.source_cache_key == model_access_group_cache_key("premium")
|
||||
assert counter.max_budget == 25.0
|
||||
assert counter.fallback_spend == 7.0
|
||||
assert counter.entity_type == "Model access group"
|
||||
assert counter.entity_id == "premium"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_access_group_without_a_budget_reserves_nothing():
|
||||
assert await _model_access_group_counters(["premium"], premium=None) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_access_group_with_a_zero_budget_reserves_nothing():
|
||||
"""Zero is how a budget is cleared, not a ceiling that blocks every request."""
|
||||
assert await _model_access_group_counters(["premium"], premium=0.0) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_access_group_counters_come_from_the_auth_object():
|
||||
"""Auth already resolved which granted groups serve the model; re-deriving it here would drift."""
|
||||
assert await _model_access_group_counters(None, premium=25.0) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repeated_model_access_group_reserves_once():
|
||||
counters = await _model_access_group_counters(["premium", "premium"], premium=25.0)
|
||||
|
||||
assert [counter.entity_id for counter in counters] == ["premium"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_access_group_counter_blocks_a_request_over_the_group_budget(spend_counter_state):
|
||||
"""End to end through the reservation path, which is what runs when reservations are enabled."""
|
||||
counter_cache, key_cache = spend_counter_state
|
||||
prisma_client = _ModelAccessGroupBudgetPrisma(premium=1.0)
|
||||
valid_token = UserAPIKeyAuth(api_key="hashed", token="tok", matched_model_access_groups=["premium"])
|
||||
|
||||
with patch( # test-quality-ok: reserve_budget_for_request takes no estimator, so pinning the estimate needs this attribute
|
||||
"litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost",
|
||||
return_value=0.5,
|
||||
):
|
||||
with pytest.raises(litellm.BudgetExceededError) as exc_info:
|
||||
await reserve_budget_for_request(
|
||||
request_body=_request_body(),
|
||||
route="/chat/completions",
|
||||
llm_router=None,
|
||||
valid_token=valid_token,
|
||||
team_object=None,
|
||||
user_object=None,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=key_cache,
|
||||
proxy_logging_obj=ProxyLogging(user_api_key_cache=key_cache),
|
||||
)
|
||||
|
||||
assert exc_info.value.entity_id == "premium"
|
||||
assert exc_info.value.entity_type == Litellm_EntityType.MODEL_ACCESS_GROUP.value
|
||||
|
||||
|
||||
async def _cache_model_access_group_budget(key_cache, group, spend, max_budget=None):
|
||||
await key_cache.async_set_cache(
|
||||
key=model_access_group_cache_key(group),
|
||||
value=ModelAccessGroupBudget(access_group_name=group, spend=spend, max_budget=max_budget),
|
||||
model_type=ModelAccessGroupBudget,
|
||||
)
|
||||
|
||||
|
||||
async def _reserve_for_model_access_groups(key_cache, groups, estimate):
|
||||
"""Reserve against the given groups, whose rows are already cached, so nothing hits the DB."""
|
||||
with patch( # test-quality-ok: reserve_budget_for_request takes no estimator, so pinning the estimate needs this attribute
|
||||
"litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost",
|
||||
return_value=estimate,
|
||||
):
|
||||
return await reserve_budget_for_request(
|
||||
request_body=_request_body(),
|
||||
route="/chat/completions",
|
||||
llm_router=None,
|
||||
valid_token=UserAPIKeyAuth(
|
||||
api_key="hashed", token="tok-mag-counter", matched_model_access_groups=list(groups)
|
||||
),
|
||||
team_object=None,
|
||||
user_object=None,
|
||||
prisma_client=_ModelAccessGroupBudgetPrisma(),
|
||||
user_api_key_cache=key_cache,
|
||||
proxy_logging_obj=ProxyLogging(user_api_key_cache=key_cache),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_access_group_counter_accumulates_across_calls_without_a_reservation(spend_counter_state):
|
||||
"""With reservations disabled nothing writes the counter up front, so the cost callback must.
|
||||
|
||||
Otherwise the read-time budget check enforces against the DB row's spend, which the cache
|
||||
holds for the full TTL, and a caller runs past the ceiling for that whole window.
|
||||
"""
|
||||
counter_cache, key_cache = spend_counter_state
|
||||
await _cache_model_access_group_budget(key_cache, "premium", spend=1.0, max_budget=25.0)
|
||||
|
||||
from litellm.proxy.proxy_server import increment_spend_counters
|
||||
|
||||
counter_key = model_access_group_spend_counter_key("premium")
|
||||
|
||||
await increment_spend_counters(
|
||||
token=None, team_id=None, user_id=None, response_cost=0.25, model_access_groups=["premium"]
|
||||
)
|
||||
assert counter_cache.in_memory_cache.get_cache(key=counter_key) == pytest.approx(1.25)
|
||||
|
||||
await increment_spend_counters(
|
||||
token=None, team_id=None, user_id=None, response_cost=0.75, model_access_groups=["premium", "premium", ""]
|
||||
)
|
||||
assert counter_cache.in_memory_cache.get_cache(key=counter_key) == pytest.approx(2.0)
|
||||
assert counter_cache.in_memory_cache.get_cache(key=model_access_group_spend_counter_key("")) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reserved_model_access_group_is_not_charged_twice(spend_counter_state):
|
||||
"""The reservation already wrote this counter, so the post-call pass has to skip it."""
|
||||
counter_cache, key_cache = spend_counter_state
|
||||
await _cache_model_access_group_budget(key_cache, "premium", spend=1.0, max_budget=25.0)
|
||||
|
||||
reservation = await _reserve_for_model_access_groups(key_cache, ["premium"], estimate=0.6)
|
||||
counter_key = model_access_group_spend_counter_key("premium")
|
||||
assert counter_cache.in_memory_cache.get_cache(key=counter_key) == pytest.approx(1.6)
|
||||
|
||||
from litellm.proxy.proxy_server import increment_spend_counters
|
||||
|
||||
await increment_spend_counters(
|
||||
token=None,
|
||||
team_id=None,
|
||||
user_id=None,
|
||||
response_cost=0.2,
|
||||
budget_reservation=reservation,
|
||||
model_access_groups=["premium"],
|
||||
)
|
||||
|
||||
# 1.0 recorded + the reservation reconciled down to the 0.2 actually spent. A second
|
||||
# increment would land at 1.4.
|
||||
assert counter_cache.in_memory_cache.get_cache(key=counter_key) == pytest.approx(1.2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreserved_model_access_group_is_charged_alongside_a_reserved_one(spend_counter_state):
|
||||
"""A budgetless group reserves nothing, so only the post-call pass can charge it.
|
||||
|
||||
Both groups authorized the request and both get debited, each exactly once, whether or not
|
||||
the reservation path happened to hold a counter for them.
|
||||
"""
|
||||
counter_cache, key_cache = spend_counter_state
|
||||
await _cache_model_access_group_budget(key_cache, "premium", spend=1.0, max_budget=25.0)
|
||||
await _cache_model_access_group_budget(key_cache, "starter", spend=4.0)
|
||||
|
||||
reservation = await _reserve_for_model_access_groups(key_cache, ["premium", "starter"], estimate=0.6)
|
||||
assert [entry["entity_id"] for entry in reservation["entries"]] == ["premium"]
|
||||
|
||||
from litellm.proxy.proxy_server import increment_spend_counters
|
||||
|
||||
await increment_spend_counters(
|
||||
token=None,
|
||||
team_id=None,
|
||||
user_id=None,
|
||||
response_cost=0.2,
|
||||
budget_reservation=reservation,
|
||||
model_access_groups=["premium", "starter", "starter", "premium"],
|
||||
)
|
||||
|
||||
assert counter_cache.in_memory_cache.get_cache(
|
||||
key=model_access_group_spend_counter_key("premium")
|
||||
) == pytest.approx(1.2)
|
||||
assert counter_cache.in_memory_cache.get_cache(
|
||||
key=model_access_group_spend_counter_key("starter")
|
||||
) == pytest.approx(4.2)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ from litellm.proxy.litellm_pre_call_utils import (
|
|||
check_if_token_is_service_account,
|
||||
clean_headers,
|
||||
)
|
||||
from litellm.litellm_core_utils.core_helpers import get_litellm_metadata_from_kwargs
|
||||
from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY
|
||||
from litellm.litellm_core_utils.get_provider_specific_headers import (
|
||||
ProviderSpecificHeaderUtils,
|
||||
)
|
||||
|
|
@ -7683,3 +7685,37 @@ async def test_add_litellm_data_to_request_keeps_litellm_metadata_on_litellm_met
|
|||
)
|
||||
|
||||
assert updated["litellm_metadata"]["trace_id"] == "abc"
|
||||
|
||||
|
||||
def _stamp_model_access_groups(matched_model_access_groups, metadata_variable_name="metadata"):
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key="hashed-key")
|
||||
user_api_key_dict.matched_model_access_groups = matched_model_access_groups
|
||||
return LiteLLMProxyRequestSetup.add_user_api_key_auth_to_request_metadata(
|
||||
data={metadata_variable_name: {}},
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
_metadata_variable_name=metadata_variable_name,
|
||||
)[metadata_variable_name]
|
||||
|
||||
|
||||
def test_matched_model_access_groups_are_stamped_into_request_metadata():
|
||||
"""The post-call spend writer reads the groups off request metadata, not off UserAPIKeyAuth."""
|
||||
stamped = _stamp_model_access_groups(["tier-a", "tier-b"])
|
||||
|
||||
assert stamped[MODEL_ACCESS_GROUP_METADATA_KEY] == ["tier-a", "tier-b"]
|
||||
assert MODEL_ACCESS_GROUP_METADATA_KEY not in _stamp_model_access_groups(None)
|
||||
|
||||
|
||||
def test_stamped_model_access_groups_survive_the_litellm_metadata_merge():
|
||||
"""
|
||||
The key must keep its ``user_api_key`` prefix: when a request carries both metadata dicts,
|
||||
get_litellm_metadata_from_kwargs returns litellm_metadata and copies a key over from metadata
|
||||
only when that substring is in its name, so an unprefixed key is silently dropped.
|
||||
"""
|
||||
kwargs = {
|
||||
"litellm_params": {
|
||||
"metadata": _stamp_model_access_groups(["tier-a"]),
|
||||
"litellm_metadata": {"trace_id": "abc"},
|
||||
}
|
||||
}
|
||||
|
||||
assert get_litellm_metadata_from_kwargs(kwargs)[MODEL_ACCESS_GROUP_METADATA_KEY] == ["tier-a"]
|
||||
|
|
|
|||
|
|
@ -1266,13 +1266,14 @@ class TestCreateSmtpConnection:
|
|||
patch("smtplib.SMTP_SSL") as mock_smtp_ssl,
|
||||
patch("smtplib.SMTP") as mock_smtp,
|
||||
):
|
||||
result = _create_smtp_connection(smtp_host="mail.example.com", smtp_port=465)
|
||||
result = _create_smtp_connection(smtp_host="mail.example.com", smtp_port=465, timeout=30.0)
|
||||
|
||||
mock_smtp.assert_not_called()
|
||||
assert result is mock_smtp_ssl.return_value
|
||||
_, kwargs = mock_smtp_ssl.call_args
|
||||
assert kwargs["host"] == "mail.example.com"
|
||||
assert kwargs["port"] == 465
|
||||
assert kwargs["timeout"] == 30.0
|
||||
context = kwargs["context"]
|
||||
assert isinstance(context, ssl.SSLContext)
|
||||
assert context.verify_mode == ssl.CERT_REQUIRED
|
||||
|
|
@ -1286,11 +1287,11 @@ class TestCreateSmtpConnection:
|
|||
patch("smtplib.SMTP_SSL") as mock_smtp_ssl,
|
||||
patch("smtplib.SMTP") as mock_smtp,
|
||||
):
|
||||
result = _create_smtp_connection(smtp_host="mail.example.com", smtp_port=587)
|
||||
result = _create_smtp_connection(smtp_host="mail.example.com", smtp_port=587, timeout=30.0)
|
||||
|
||||
mock_smtp_ssl.assert_not_called()
|
||||
assert result is mock_smtp.return_value
|
||||
mock_smtp.assert_called_once_with(host="mail.example.com", port=587)
|
||||
mock_smtp.assert_called_once_with(host="mail.example.com", port=587, timeout=30.0)
|
||||
|
||||
|
||||
class TestSendEmailStartTls:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import sys
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
|
|
@ -320,6 +321,7 @@ class _SentMessage:
|
|||
body: Optional[str]
|
||||
starttls_called: bool
|
||||
login_args: Optional[tuple]
|
||||
thread_ident: int
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -328,6 +330,7 @@ class InMemorySMTP:
|
|||
|
||||
sent: List[_SentMessage] = field(default_factory=list)
|
||||
raise_on_send: Optional[Exception] = None
|
||||
connection_kwargs: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def server_factory(self) -> Callable[..., Any]:
|
||||
outer = self
|
||||
|
|
@ -370,10 +373,12 @@ class InMemorySMTP:
|
|||
body=body,
|
||||
starttls_called=self._starttls_called,
|
||||
login_args=self._login_args,
|
||||
thread_ident=threading.get_ident(),
|
||||
)
|
||||
)
|
||||
|
||||
def _factory(*args: Any, **kwargs: Any) -> _Conn:
|
||||
outer.connection_kwargs.append(dict(kwargs))
|
||||
return _Conn()
|
||||
|
||||
return _factory
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ Symbols pinned here:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
|
@ -51,9 +52,7 @@ async def test_send_email_dispatches_via_smtp(in_memory_smtp: Any) -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_email_starttls_uses_ssl(
|
||||
in_memory_smtp: Any, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def test_send_email_starttls_uses_ssl(in_memory_smtp: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("SMTP_USE_SSL", "True")
|
||||
await send_email(
|
||||
receiver_email="to@invalid",
|
||||
|
|
@ -82,9 +81,7 @@ async def test_send_email_error_missing_sender_email(
|
|||
) -> None:
|
||||
monkeypatch.delenv("SMTP_SENDER_EMAIL", raising=False)
|
||||
with pytest.raises(ValueError, match="SMTP_SENDER_EMAIL"):
|
||||
await send_email(
|
||||
receiver_email="x@y", subject="s", html="<p>h</p>"
|
||||
)
|
||||
await send_email(receiver_email="x@y", subject="s", html="<p>h</p>")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -105,6 +102,49 @@ async def test_send_email_error_missing_html() -> None:
|
|||
await send_email(receiver_email="x@y", subject="s", html=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_email_sets_connection_timeout(in_memory_smtp: Any) -> None:
|
||||
await send_email(
|
||||
receiver_email="to@invalid",
|
||||
subject="Hi",
|
||||
html="<p>x</p>",
|
||||
)
|
||||
assert in_memory_smtp.connection_kwargs[0].get("timeout") == 30.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_email_timeout_env_override(in_memory_smtp: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("SMTP_TIMEOUT", "5")
|
||||
monkeypatch.setenv("SMTP_USE_SSL", "True")
|
||||
await send_email(
|
||||
receiver_email="to@invalid",
|
||||
subject="Hi",
|
||||
html="<p>x</p>",
|
||||
)
|
||||
assert in_memory_smtp.connection_kwargs[0].get("timeout") == 5.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_email_malformed_timeout_is_swallowed(in_memory_smtp: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("SMTP_TIMEOUT", "30s")
|
||||
await send_email(
|
||||
receiver_email="to@invalid",
|
||||
subject="Hi",
|
||||
html="<p>x</p>",
|
||||
)
|
||||
assert in_memory_smtp.sent == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_email_runs_off_event_loop_thread(in_memory_smtp: Any) -> None:
|
||||
await send_email(
|
||||
receiver_email="to@invalid",
|
||||
subject="Hi",
|
||||
html="<p>x</p>",
|
||||
)
|
||||
assert in_memory_smtp.sent[0].thread_ident != threading.get_ident()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_email_smtp_failure_is_swallowed(
|
||||
in_memory_smtp: Any,
|
||||
|
|
@ -113,7 +153,5 @@ async def test_send_email_smtp_failure_is_swallowed(
|
|||
does not raise so a failing email never blocks the proxy.
|
||||
"""
|
||||
in_memory_smtp.raise_on_send = RuntimeError("smtp boom")
|
||||
await send_email(
|
||||
receiver_email="to@invalid", subject="Hi", html="<p>x</p>"
|
||||
)
|
||||
await send_email(receiver_email="to@invalid", subject="Hi", html="<p>x</p>")
|
||||
assert in_memory_smtp.sent == []
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from dataclasses import fields
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Mapping, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.repositories.unit_of_work import (
|
||||
LinkedSpendResetWrites,
|
||||
budget_cascade_unit_of_work,
|
||||
spend_reset_unit_of_work,
|
||||
)
|
||||
|
|
@ -32,6 +34,7 @@ class FakeBatch:
|
|||
self.litellm_teammembership = FakeBatchTable("litellm_teammembership", self.calls)
|
||||
self.litellm_organizationtable = FakeBatchTable("litellm_organizationtable", self.calls)
|
||||
self.litellm_tagtable = FakeBatchTable("litellm_tagtable", self.calls)
|
||||
self.litellm_modelaccessgroupbudgettable = FakeBatchTable("litellm_modelaccessgroupbudgettable", self.calls)
|
||||
self.litellm_endusertable = FakeBatchTable("litellm_endusertable", self.calls)
|
||||
|
||||
async def commit(self) -> None:
|
||||
|
|
@ -90,6 +93,7 @@ async def test_budget_cascade_dependents_and_window_advance_share_one_batch():
|
|||
uow.keys.queue_spend_zero(where=linked)
|
||||
uow.organizations.queue_spend_zero(where=linked)
|
||||
uow.tags.queue_spend_zero(where=linked)
|
||||
uow.model_access_groups.queue_spend_zero(where=linked)
|
||||
uow.endusers.queue_spend_zero(where={"user_id": {"in": ["enduser-1"]}})
|
||||
uow.budgets.queue_window_advance(budget_id="budget-1", budget_reset_at=reset_at)
|
||||
assert batch.commit_count == 0
|
||||
|
|
@ -100,6 +104,7 @@ async def test_budget_cascade_dependents_and_window_advance_share_one_batch():
|
|||
("litellm_verificationtoken.update_many", linked, {"spend": 0}),
|
||||
("litellm_organizationtable.update_many", linked, {"spend": 0}),
|
||||
("litellm_tagtable.update_many", linked, {"spend": 0}),
|
||||
("litellm_modelaccessgroupbudgettable.update_many", linked, {"spend": 0}),
|
||||
("litellm_endusertable.update_many", {"user_id": {"in": ["enduser-1"]}}, {"spend": 0}),
|
||||
("litellm_budgettable.update_many", {"budget_id": "budget-1"}, {"budget_reset_at": reset_at}),
|
||||
]
|
||||
|
|
@ -117,6 +122,39 @@ async def test_budget_window_advance_tolerates_a_tier_deleted_mid_chunk():
|
|||
assert [call[0] for call in batch.calls] == ["litellm_budgettable.update_many"]
|
||||
|
||||
|
||||
async def test_every_cascade_dependent_writes_to_its_own_table_on_the_one_batch():
|
||||
"""Walks the dataclass instead of naming tables, so a dependent added to
|
||||
BudgetCascadeUnitOfWork later cannot go uncovered.
|
||||
|
||||
The named test above only proves the tables it lists, and an unbound
|
||||
dependent surfaces as an AttributeError from whichever tests happen to
|
||||
open a cascade. This pins the real contract: every field writes, each to a
|
||||
distinct table, all on the same batch.
|
||||
"""
|
||||
reset_at = datetime(2026, 8, 3, 12, 0, tzinfo=timezone.utc)
|
||||
batches: List[FakeBatch] = []
|
||||
|
||||
def _new_batch() -> FakeBatch:
|
||||
# Fresh per call like db.batch_(), unlike the `lambda: batch` above: a
|
||||
# second transaction would otherwise alias onto the first and hide.
|
||||
batches.append(FakeBatch())
|
||||
return batches[-1]
|
||||
|
||||
async with budget_cascade_unit_of_work(_new_batch) as uow:
|
||||
writes = [getattr(uow, field.name) for field in fields(uow)]
|
||||
for write in writes:
|
||||
if isinstance(write, LinkedSpendResetWrites):
|
||||
write.queue_spend_zero(where={"budget_id": "budget-1"})
|
||||
else:
|
||||
write.queue_window_advance(budget_id="budget-1", budget_reset_at=reset_at)
|
||||
|
||||
assert len(batches) == 1, "the cascade must open exactly one transaction"
|
||||
batch = batches[0]
|
||||
assert len(batch.calls) == len(writes), "a dependent bound to a batch of its own would not land here"
|
||||
assert len({call[0] for call in batch.calls}) == len(writes), "two dependents share one table"
|
||||
assert batch.commit_count == 1
|
||||
|
||||
|
||||
async def test_budget_cascade_raising_inside_block_skips_commit():
|
||||
"""A failure part-way through must leave budget_reset_at where it was, so
|
||||
the tier is still due on the next tick."""
|
||||
|
|
|
|||
228
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
228
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -497,6 +497,91 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/access_group/{access_group}/budget": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Access Group Budget
|
||||
* @description Get the shared budget of an access group, and the spend drawn against it.
|
||||
*
|
||||
* Example:
|
||||
* ```bash
|
||||
* curl -X GET 'http://localhost:4000/access_group/production-models/budget' \
|
||||
* -H 'Authorization: Bearer sk-1234'
|
||||
* ```
|
||||
*
|
||||
* Parameters:
|
||||
* - access_group: str - The access group name (URL path parameter)
|
||||
*
|
||||
* Returns:
|
||||
* - AccessGroupBudgetResponse; budget is null when the group has no budget set
|
||||
*
|
||||
* Raises:
|
||||
* - HTTPException 404: If access group not found
|
||||
*/
|
||||
get: operations["get_access_group_budget_access_group__access_group__budget_get"];
|
||||
/**
|
||||
* Set Access Group Budget
|
||||
* @description Set or replace the shared budget of an access group. Idempotent.
|
||||
*
|
||||
* Every key that can reach a model in the group draws from this one budget.
|
||||
*
|
||||
* Example:
|
||||
* ```bash
|
||||
* curl -X PUT 'http://localhost:4000/access_group/production-models/budget' \
|
||||
* -H 'Authorization: Bearer sk-1234' \
|
||||
* -H 'Content-Type: application/json' \
|
||||
* -d '{
|
||||
* "max_budget": 100.0,
|
||||
* "budget_duration": "30d"
|
||||
* }'
|
||||
* ```
|
||||
*
|
||||
* Parameters:
|
||||
* - access_group: str - The access group name (URL path parameter)
|
||||
* - max_budget: Optional[float] - Requests fail once the group's shared spend exceeds this
|
||||
* - soft_budget: Optional[float] - Fires an alert when reached; requests still succeed
|
||||
* - budget_duration: Optional[str] - Frequency of resetting the group's spend (e.g. '30d')
|
||||
* - budget_id: Optional[str] - Link an existing budget instead of creating one
|
||||
*
|
||||
* Returns:
|
||||
* - AccessGroupBudgetResponse with the stored budget and current spend
|
||||
*
|
||||
* Raises:
|
||||
* - HTTPException 400: If no budget field is given, or budget_duration cannot be parsed
|
||||
* - HTTPException 404: If access group not found
|
||||
*/
|
||||
put: operations["set_access_group_budget_access_group__access_group__budget_put"];
|
||||
post?: never;
|
||||
/**
|
||||
* Delete Access Group Budget
|
||||
* @description Clear the shared budget of an access group, leaving the group itself in place.
|
||||
*
|
||||
* Example:
|
||||
* ```bash
|
||||
* curl -X DELETE 'http://localhost:4000/access_group/production-models/budget' \
|
||||
* -H 'Authorization: Bearer sk-1234'
|
||||
* ```
|
||||
*
|
||||
* Parameters:
|
||||
* - access_group: str - The access group name (URL path parameter)
|
||||
*
|
||||
* Returns:
|
||||
* - DeleteAccessGroupBudgetResponse; budget_deleted is false when there was nothing to clear
|
||||
*
|
||||
* Raises:
|
||||
* - HTTPException 404: If access group not found
|
||||
*/
|
||||
delete: operations["delete_access_group_budget_access_group__access_group__budget_delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/access_group/{access_group}/delete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -555,7 +640,7 @@ export interface paths {
|
|||
* - access_group: str - The access group name (URL path parameter)
|
||||
*
|
||||
* Returns:
|
||||
* - AccessGroupInfo with the access group details
|
||||
* - AccessGroupInfo with the access group details, its shared budget and its spend
|
||||
*
|
||||
* Raises:
|
||||
* - HTTPException 404: If access group not found
|
||||
|
|
@ -22259,6 +22344,38 @@ export interface components {
|
|||
*/
|
||||
type: "restricted_sso_group";
|
||||
};
|
||||
/** AccessGroupBudget */
|
||||
AccessGroupBudget: {
|
||||
/** Budget Duration */
|
||||
budget_duration?: string | null;
|
||||
/** Budget Id */
|
||||
budget_id: string;
|
||||
/** Budget Reset At */
|
||||
budget_reset_at?: string | null;
|
||||
/** Max Budget */
|
||||
max_budget?: number | null;
|
||||
/** Soft Budget */
|
||||
soft_budget?: number | null;
|
||||
};
|
||||
/** AccessGroupBudgetRequest */
|
||||
AccessGroupBudgetRequest: {
|
||||
/** Budget Duration */
|
||||
budget_duration?: string | null;
|
||||
/** Budget Id */
|
||||
budget_id?: string | null;
|
||||
/** Max Budget */
|
||||
max_budget?: number | null;
|
||||
/** Soft Budget */
|
||||
soft_budget?: number | null;
|
||||
};
|
||||
/** AccessGroupBudgetResponse */
|
||||
AccessGroupBudgetResponse: {
|
||||
/** Access Group */
|
||||
access_group: string;
|
||||
budget?: components["schemas"]["AccessGroupBudget"] | null;
|
||||
/** Spend */
|
||||
spend: number;
|
||||
};
|
||||
/** AccessGroupCreateRequest */
|
||||
AccessGroupCreateRequest: {
|
||||
/** Access Agent Ids */
|
||||
|
|
@ -22280,10 +22397,13 @@ export interface components {
|
|||
AccessGroupInfo: {
|
||||
/** Access Group */
|
||||
access_group: string;
|
||||
budget?: components["schemas"]["AccessGroupBudget"] | null;
|
||||
/** Deployment Count */
|
||||
deployment_count: number;
|
||||
/** Model Names */
|
||||
model_names: string[];
|
||||
/** Spend */
|
||||
spend?: number | null;
|
||||
};
|
||||
/** AccessGroupResponse */
|
||||
AccessGroupResponse: {
|
||||
|
|
@ -26132,6 +26252,15 @@ export interface components {
|
|||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
/** DeleteAccessGroupBudgetResponse */
|
||||
DeleteAccessGroupBudgetResponse: {
|
||||
/** Access Group */
|
||||
access_group: string;
|
||||
/** Budget Deleted */
|
||||
budget_deleted: boolean;
|
||||
/** Message */
|
||||
message: string;
|
||||
};
|
||||
/**
|
||||
* DeleteCustomerRequest
|
||||
* @description Delete multiple Customers
|
||||
|
|
@ -39152,6 +39281,103 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
get_access_group_budget_access_group__access_group__budget_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
access_group: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AccessGroupBudgetResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
set_access_group_budget_access_group__access_group__budget_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
access_group: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AccessGroupBudgetRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AccessGroupBudgetResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_access_group_budget_access_group__access_group__budget_delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
access_group: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DeleteAccessGroupBudgetResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_access_group_access_group__access_group__delete_delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue