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:
Kerry Lu 2026-11-10 10:08:21 -08:00 • committed by GitHub
parent c229851924
commit c1abd490b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 133 additions and 9 deletions

View file

@ -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):
"""

View file

@ -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"

View 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

View file

@ -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

View file

@ -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

View file

@ -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"