fix(router): keep unresolved drop_params strings so DB rows and env refs survive

The drop_params validator collapsed every string it did not recognize to None. A pre-fix DB row holds the flag as ciphertext, so a partial PATCH rebuilt the deployment without it and dropped the key from the stored row, and /model/new turned an os.environ/ reference into nothing before the loader could resolve it. The validator now returns the raw value when it is not a boolean flag, the field admits strings the way timeout already does, and the flag set follows pydantic's lax bool parsing instead of a hand-rolled true/false pair
This commit is contained in:
mateo-berri 2026-09-07 17:06:34 -07:00
parent b827375e60
commit 4cc0180eab
7 changed files with 101 additions and 16 deletions

View file

@ -5,6 +5,7 @@ from collections.abc import Iterable, Mapping
from typing import TYPE_CHECKING, Any, Final, Literal
import httpx
from pydantic import TypeAdapter, ValidationError
from litellm._logging import verbose_logger
from litellm.types.llms.openai import AllMessageValues, OpenAIChatCompletionFinishReason
@ -37,16 +38,16 @@ def safe_divide_seconds(seconds: float, denominator: float, default: float | Non
return float(seconds / denominator)
_DROP_PARAMS_BOOL: Final = TypeAdapter(bool)
def normalize_drop_params(value: object) -> bool | None:
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered: Final = value.strip().lower()
if lowered == "true":
return True
if lowered == "false":
return False
return None
try:
return _DROP_PARAMS_BOOL.validate_python(value.strip() if isinstance(value, str) else value)
except ValidationError:
return None
def safe_divide(

View file

@ -315,7 +315,7 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams):
timeout: float | str | httpx.Timeout | None = None # if str, pass in as os.environ/
stream_timeout: float | str | None = None # timeout when making stream=True calls, if str, pass in as os.environ/
max_retries: int | None = None
drop_params: bool | None = None
drop_params: bool | str | None = None
organization: str | None = None # for openai orgs
configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = None
litellm_credential_name: str | None = None
@ -408,8 +408,9 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams):
@field_validator("drop_params", mode="before")
@classmethod
def coerce_drop_params(cls, value: object) -> bool | None:
return normalize_drop_params(value)
def coerce_drop_params(cls, value: object) -> object:
normalized: Final = normalize_drop_params(value)
return value if normalized is None else normalized
def __contains__(self, key) -> bool:
# Define custom behavior for the 'in' operator

View file

@ -268,11 +268,16 @@ class TestRedactNestedMatchAndRegexKeys:
(" TRUE ", True),
("false", False),
("False", False),
("yes", True),
("off", False),
("1", True),
(1, True),
(0, False),
(None, None),
("yes", None),
("", None),
(1, None),
(0, None),
("os.environ/DROP_PARAMS", None),
("v2:gcm:not-a-flag", None),
(2, None),
],
)
def test_normalize_drop_params(value, expected):

View file

@ -30,7 +30,7 @@ from litellm.proxy.management_endpoints.model_management_endpoints import (
)
from litellm.proxy.utils import PrismaClient
from litellm.router import Router
from litellm.types.router import Deployment, LiteLLM_Params, updateDeployment
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo, updateDeployment, updateLiteLLMParams
async def _passthrough_row(update_data):
@ -3070,6 +3070,31 @@ class TestUpdateDBModelBlocked:
assert "blocked" not in result
class TestUpdateDBModelKeepsLegacyDropParams:
def test_partial_patch_keeps_encrypted_string_drop_params(self, monkeypatch):
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.proxy.management_endpoints.model_management_endpoints import update_db_model
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-1234")
legacy_row = Deployment(
model_name="gpt-5-nano",
litellm_params=LiteLLM_Params(
model="openai/gpt-5-nano",
api_key=encrypt_value_helper(value="sk-old"),
drop_params=encrypt_value_helper(value="true"),
),
model_info=ModelInfo(id="legacy-row"),
)
result = update_db_model(
db_model=legacy_row,
updated_patch=updateDeployment(litellm_params=updateLiteLLMParams(api_key="sk-new")),
)
stored = json.loads(result["litellm_params"])
assert decrypt_value_helper(value=stored["drop_params"], key="drop_params") == "true"
def _build_db_model_with_pricing():
"""Wildcard deployment with custom pricing in litellm_params; Deployment.__init__
mirrors SPECIAL_MODEL_INFO_PARAMS into model_info, so both blobs hold the rate."""

View file

@ -19,6 +19,7 @@ import pytest
import litellm
from litellm.proxy._types import CommonProxyErrors
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper
from litellm.proxy.proxy_server import (
ProxyConfig,
_is_remote_module_url,
@ -2401,6 +2402,35 @@ def test_ProxyConfig__add_deployment_resolves_env_refs_on_arbitrary_field(monkey
assert deployment.litellm_params.some_future_field == "resolved-custom-value"
@pytest.mark.parametrize(
"stored_drop_params",
["true", "os.environ/DROP_PARAMS_FLAG"],
)
def test_ProxyConfig__add_deployment_turns_stored_drop_params_string_into_bool(monkeypatch, stored_drop_params):
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-1234")
monkeypatch.setenv("DROP_PARAMS_FLAG", "true")
fake_router = MagicMock()
fake_router.upsert_deployment = MagicMock(return_value=True)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", fake_router)
pc = ProxyConfig()
db_model = SimpleNamespace(
model_id="model-1",
model_name="gpt-5-nano",
model_info={"id": "model-1"},
litellm_params={
"model": encrypt_value_helper(value="openai/gpt-5-nano"),
"drop_params": encrypt_value_helper(value=stored_drop_params),
},
blocked=False,
)
added = pc._add_deployment(db_models=[db_model])
deployment = fake_router.upsert_deployment.call_args.kwargs["deployment"]
assert added == 1
assert deployment.litellm_params.drop_params is True
# ---------------------------------------------------------------------------
# ProxyConfig.decrypt_model_list_from_db
# ---------------------------------------------------------------------------

View file

@ -1,8 +1,10 @@
import pytest
from pydantic import ValidationError
from litellm.types.router import (
SPECIAL_MODEL_INFO_PARAMS,
Deployment,
GenericLiteLLMParams,
LiteLLM_Params,
ModelInfo,
)
@ -89,3 +91,24 @@ def test_pricing_strings_are_coerced_to_float():
def test_invalid_pricing_is_rejected():
with pytest.raises(ValueError, match='validation error for ModelInfo'):
ModelInfo(id="x", input_cost_per_token="free")
@pytest.mark.parametrize(
"value, expected",
[
(True, True),
("true", True),
(" False ", False),
("yes", True),
(None, None),
("os.environ/DROP_PARAMS", "os.environ/DROP_PARAMS"),
("v2:gcm:ciphertext-from-a-pre-fix-row", "v2:gcm:ciphertext-from-a-pre-fix-row"),
],
)
def test_drop_params_coerces_flags_and_keeps_unresolved_strings(value, expected):
assert GenericLiteLLMParams(drop_params=value).drop_params == expected
def test_drop_params_rejects_non_flag_non_string_values():
with pytest.raises(ValidationError):
GenericLiteLLMParams(drop_params=2)

View file

@ -29397,7 +29397,7 @@ export interface components {
/** Default Api Key Tpm Limit */
default_api_key_tpm_limit?: number | null;
/** Drop Params */
drop_params?: boolean | null;
drop_params?: boolean | string | null;
/** Gcs Bucket Name */
gcs_bucket_name?: string | null;
/** Google Maps Grounding Cost Per Query */
@ -39570,7 +39570,7 @@ export interface components {
/** Default Api Key Tpm Limit */
default_api_key_tpm_limit?: number | null;
/** Drop Params */
drop_params?: boolean | null;
drop_params?: boolean | string | null;
/** Gcs Bucket Name */
gcs_bucket_name?: string | null;
/** Google Maps Grounding Cost Per Query */