diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index abf61a909b2..228e80da4bd 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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): """ diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index aec5e6840a5..bd202b85f73 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -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" diff --git a/tests/integration/management/test_customer_unknown_fields.py b/tests/integration/management/test_customer_unknown_fields.py new file mode 100644 index 00000000000..235d5745791 --- /dev/null +++ b/tests/integration/management/test_customer_unknown_fields.py @@ -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 diff --git a/tests/unit/proxy/management_endpoints/test_customer_budget.py b/tests/unit/proxy/management_endpoints/test_customer_budget.py index 0beca0c15e8..ac172ac2ffb 100644 --- a/tests/unit/proxy/management_endpoints/test_customer_budget.py +++ b/tests/unit/proxy/management_endpoints/test_customer_budget.py @@ -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 diff --git a/tests/unit/proxy/test__types.py b/tests/unit/proxy/test__types.py index 40e66f1da65..1c97e9054ae 100644 --- a/tests/unit/proxy/test__types.py +++ b/tests/unit/proxy/test__types.py @@ -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 diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index b229461e981..e1b0104e089 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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"