mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
feat(customers): reject unknown fields on /customer/new and /customer/update instead of dropping them (#50965)
* feat(customers): reject unknown fields on customer create and update instead of dropping them Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(customers): derive the budget hint from the budget request fields and fix the create example Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
c229851924
commit
c1abd490b2
6 changed files with 133 additions and 9 deletions
|
|
@ -1860,6 +1860,21 @@ class CustomerBase(LiteLLMPydanticObjectBase):
|
|||
blocked: bool = False
|
||||
|
||||
|
||||
def reject_unknown_customer_fields(request: type[BaseModel], values: object) -> object:
|
||||
if not isinstance(values, dict):
|
||||
return values
|
||||
unknown: Final = tuple(sorted(str(key) for key in values if key not in request.model_fields)) # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] # raw request body
|
||||
if not unknown:
|
||||
return values
|
||||
on_budget: Final = tuple(field for field in unknown if field in BudgetNewRequest.model_fields)
|
||||
hint: Final = (
|
||||
f" {', '.join(on_budget)}: these live on the customer's budget object, update it with /budget/update."
|
||||
if on_budget
|
||||
else ""
|
||||
)
|
||||
raise ValueError(f"Unknown fields: {', '.join(unknown)}.{hint}")
|
||||
|
||||
|
||||
class NewCustomerRequest(BudgetNewRequest):
|
||||
"""
|
||||
Create a new customer, allocate a budget to them
|
||||
|
|
@ -1877,6 +1892,11 @@ class NewCustomerRequest(BudgetNewRequest):
|
|||
models: list[str] | None = None
|
||||
object_permission: LiteLLM_ObjectPermissionBase | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def reject_unknown_fields(cls, values: object) -> object:
|
||||
return reject_unknown_customer_fields(cls, values)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def check_user_info(cls, values):
|
||||
|
|
@ -1904,6 +1924,11 @@ class UpdateCustomerRequest(LiteLLMPydanticObjectBase):
|
|||
models: list[str] | None = None
|
||||
object_permission: LiteLLM_ObjectPermissionBase | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def reject_unknown_fields(cls, values: object) -> object:
|
||||
return reject_unknown_customer_fields(cls, values)
|
||||
|
||||
|
||||
class DeleteCustomerRequest(LiteLLMPydanticObjectBase):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -353,7 +353,6 @@ async def new_end_user(
|
|||
- allowed_model_region: Optional[Union[Literal["eu"], Literal["us"]]] - Require all user requests to use models in this specific region.
|
||||
- default_model: Optional[str] - If no equivalent model in the allowed region, default all requests to this model.
|
||||
- models: Optional[list[str]] - Restrict this customer's access to the listed models.
|
||||
- metadata: Optional[dict] = Metadata for customer, store information for customer. Example metadata = {"data_training_opt_out": True}
|
||||
- budget_duration: Optional[str] - Budget is reset at the end of specified duration. If not set, budget is never reset. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
|
||||
- tpm_limit: Optional[int] - [Not Implemented Yet] Specify tpm limit for a given customer (Tokens per minute)
|
||||
- rpm_limit: Optional[int] - [Not Implemented Yet] Specify rpm limit for a given customer (Requests per minute)
|
||||
|
|
@ -387,7 +386,7 @@ async def new_end_user(
|
|||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"user_id" : "ishaan-jaff-3",
|
||||
"allowed_region": "eu",
|
||||
"allowed_model_region": "eu",
|
||||
"budget_id": "free_tier",
|
||||
"models": ["gpt-4o-mini"],
|
||||
"default_model": "azure/gpt-3.5-turbo-eu"
|
||||
|
|
|
|||
55
tests/integration/management/test_customer_unknown_fields.py
Normal file
55
tests/integration/management/test_customer_unknown_fields.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import uuid
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.integration._support.client import Gateway, Scenario
|
||||
|
||||
pytestmark: Final = pytest.mark.shares_worker_state
|
||||
|
||||
|
||||
def _customer(scenario: Scenario, **fields: object) -> str:
|
||||
customer: Final = f"integration-customer-{uuid.uuid4().hex}"
|
||||
scenario.gateway.post("/customer/new", {"user_id": customer, **fields}) # pyright: ignore[reportArgumentType] # JsonValue kwargs
|
||||
scenario.cleanups.callback(scenario.gateway.post, "/customer/delete", {"user_ids": [customer]})
|
||||
return customer
|
||||
|
||||
|
||||
def _post(gateway: Gateway, route: str, body: dict[str, object]) -> httpx.Response:
|
||||
return gateway.request("POST", route, body) # pyright: ignore[reportArgumentType] # JsonValue body
|
||||
|
||||
|
||||
def _info(gateway: Gateway, customer: str) -> dict[str, object]:
|
||||
return gateway.get("/customer/info", {"end_user_id": customer})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "unknown_field"),
|
||||
[
|
||||
pytest.param({"tpm_limit": 5}, "tpm_limit", id="budget_field_on_update"),
|
||||
pytest.param({"quota": {"opus": 1}}, "quota", id="made_up_field"),
|
||||
],
|
||||
)
|
||||
def test_customer_update_rejects_unknown_fields_and_leaves_the_customer_unchanged(
|
||||
gateway: Gateway, body: dict[str, object], unknown_field: str
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
customer: Final = _customer(scenario, alias="before")
|
||||
rejected: Final = _post(gateway, "/customer/update", {"user_id": customer, **body})
|
||||
assert rejected.status_code == 422, rejected.text
|
||||
assert unknown_field in rejected.text, rejected.text
|
||||
assert _info(gateway, customer)["alias"] == "before"
|
||||
|
||||
accepted: Final = _post(gateway, "/customer/update", {"user_id": customer, "alias": "after"})
|
||||
assert accepted.status_code == 200, accepted.text
|
||||
assert _info(gateway, customer)["alias"] == "after"
|
||||
|
||||
|
||||
def test_customer_new_rejects_a_misspelled_field_and_creates_nothing(gateway: Gateway) -> None:
|
||||
customer: Final = f"integration-customer-{uuid.uuid4().hex}"
|
||||
rejected: Final = _post(gateway, "/customer/new", {"user_id": customer, "tpm_limti": 5})
|
||||
assert rejected.status_code == 422, rejected.text
|
||||
assert "tpm_limti" in rejected.text, rejected.text
|
||||
missing: Final = gateway.request("GET", "/customer/info", params={"end_user_id": customer})
|
||||
assert missing.status_code == 404, missing.text
|
||||
|
|
@ -144,7 +144,6 @@ async def test_update_customer_creates_budget_with_proper_relations(
|
|||
update_request = UpdateCustomerRequest(
|
||||
user_id="test-user",
|
||||
max_budget=100.0, # This triggers budget creation
|
||||
rpm_limit=200, # Use valid budget field
|
||||
)
|
||||
|
||||
# Act
|
||||
|
|
@ -267,7 +266,6 @@ async def test_update_customer_budget_creation_with_fallback_admin(
|
|||
update_request = UpdateCustomerRequest(
|
||||
user_id="test-user",
|
||||
max_budget=150.0,
|
||||
tpm_limit=1000, # Add another budget field
|
||||
)
|
||||
|
||||
# Act
|
||||
|
|
@ -325,7 +323,6 @@ async def test_update_customer_with_budget_id_and_creation_fields(
|
|||
user_id="test-user",
|
||||
budget_id="existing-budget-link", # For linking to existing budget
|
||||
max_budget=300.0, # This should trigger new budget creation
|
||||
rpm_limit=500, # Use valid budget field
|
||||
)
|
||||
|
||||
# Act
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from litellm.proxy._types import (
|
|||
OrganizationMemberUpdateRequest,
|
||||
RegenerateKeyRequest,
|
||||
ResetSpendRequest,
|
||||
UpdateCustomerRequest,
|
||||
UpdateKeyRequest,
|
||||
UpdateMCPServerRequest,
|
||||
UpdateTeamRequest,
|
||||
|
|
@ -634,3 +635,52 @@ def test_batch_file_caps_accept_only_positive_limits(setting):
|
|||
with pytest.raises(ValidationError):
|
||||
ConfigGeneralSettings.model_validate({setting: invalid})
|
||||
assert getattr(ConfigGeneralSettings.model_validate({setting: 3}), setting) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("request_type", "body", "message"),
|
||||
[
|
||||
pytest.param(
|
||||
UpdateCustomerRequest,
|
||||
{"user_id": "acme", "tpm_limit": 5, "quota": {"opus": 1}},
|
||||
"Unknown fields: quota, tpm_limit. tpm_limit: these live on the customer's budget object, "
|
||||
"update it with /budget/update.",
|
||||
id="update_with_a_budget_field_and_a_made_up_one",
|
||||
),
|
||||
pytest.param(
|
||||
UpdateCustomerRequest,
|
||||
{"user_id": "acme", "alias": "Acme", "quota": 1},
|
||||
"Unknown fields: quota.",
|
||||
id="update_with_a_made_up_field_has_no_budget_hint",
|
||||
),
|
||||
pytest.param(
|
||||
NewCustomerRequest,
|
||||
{"user_id": "acme", "tpm_limti": 5},
|
||||
"Unknown fields: tpm_limti.",
|
||||
id="create_with_a_misspelled_budget_field",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_a_customer_request_with_unknown_fields_is_refused_naming_them(
|
||||
request_type: type[BaseModel], body: dict[str, object], message: str
|
||||
) -> None:
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
request_type.model_validate(body)
|
||||
assert [error["msg"] for error in exc.value.errors()] == [f"Value error, {message}"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("request_type", "body"),
|
||||
[
|
||||
pytest.param(UpdateCustomerRequest, {"user_id": "acme", "alias": "Acme", "blocked": True}, id="update"),
|
||||
pytest.param(
|
||||
NewCustomerRequest,
|
||||
{"user_id": "acme", "tpm_limit": 5, "model_tpm_limit": {"opus": 60}},
|
||||
id="create_with_inline_budget_fields",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_a_customer_request_with_only_known_fields_is_accepted(
|
||||
request_type: type[BaseModel], body: dict[str, object]
|
||||
) -> None:
|
||||
assert request_type.model_validate(body).model_dump(exclude_unset=True) == body
|
||||
|
|
|
|||
6
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
6
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -4549,7 +4549,6 @@ export interface paths {
|
|||
* - allowed_model_region: Optional[Union[Literal["eu"], Literal["us"]]] - Require all user requests to use models in this specific region.
|
||||
* - default_model: Optional[str] - If no equivalent model in the allowed region, default all requests to this model.
|
||||
* - models: Optional[list[str]] - Restrict this customer's access to the listed models.
|
||||
* - metadata: Optional[dict] = Metadata for customer, store information for customer. Example metadata = {"data_training_opt_out": True}
|
||||
* - budget_duration: Optional[str] - Budget is reset at the end of specified duration. If not set, budget is never reset. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
|
||||
* - tpm_limit: Optional[int] - [Not Implemented Yet] Specify tpm limit for a given customer (Tokens per minute)
|
||||
* - rpm_limit: Optional[int] - [Not Implemented Yet] Specify rpm limit for a given customer (Requests per minute)
|
||||
|
|
@ -4580,7 +4579,7 @@ export interface paths {
|
|||
* ```
|
||||
* curl --location 'http://0.0.0.0:4000/customer/new' --header "Authorization: Bearer $LITELLM_MASTER_KEY" --header 'Content-Type: application/json' --data '{
|
||||
* "user_id" : "ishaan-jaff-3",
|
||||
* "allowed_region": "eu",
|
||||
* "allowed_model_region": "eu",
|
||||
* "budget_id": "free_tier",
|
||||
* "models": ["gpt-4o-mini"],
|
||||
* "default_model": "azure/gpt-3.5-turbo-eu"
|
||||
|
|
@ -5229,7 +5228,6 @@ export interface paths {
|
|||
* - allowed_model_region: Optional[Union[Literal["eu"], Literal["us"]]] - Require all user requests to use models in this specific region.
|
||||
* - default_model: Optional[str] - If no equivalent model in the allowed region, default all requests to this model.
|
||||
* - models: Optional[list[str]] - Restrict this customer's access to the listed models.
|
||||
* - metadata: Optional[dict] = Metadata for customer, store information for customer. Example metadata = {"data_training_opt_out": True}
|
||||
* - budget_duration: Optional[str] - Budget is reset at the end of specified duration. If not set, budget is never reset. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
|
||||
* - tpm_limit: Optional[int] - [Not Implemented Yet] Specify tpm limit for a given customer (Tokens per minute)
|
||||
* - rpm_limit: Optional[int] - [Not Implemented Yet] Specify rpm limit for a given customer (Requests per minute)
|
||||
|
|
@ -5260,7 +5258,7 @@ export interface paths {
|
|||
* ```
|
||||
* curl --location 'http://0.0.0.0:4000/customer/new' --header "Authorization: Bearer $LITELLM_MASTER_KEY" --header 'Content-Type: application/json' --data '{
|
||||
* "user_id" : "ishaan-jaff-3",
|
||||
* "allowed_region": "eu",
|
||||
* "allowed_model_region": "eu",
|
||||
* "budget_id": "free_tier",
|
||||
* "models": ["gpt-4o-mini"],
|
||||
* "default_model": "azure/gpt-3.5-turbo-eu"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue