diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index fc0868a5fc9..609f367e36d 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -1069,6 +1069,12 @@ def _loaded_catalog_entry( return loaded_catalog().get(catalog_key, MappingProxyType({})) +def _json_temporal_default(value: object) -> str: + if isinstance(value, (datetime.datetime, datetime.date, datetime.time)): + return value.isoformat() + raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") + + def update_db_model( db_model: Deployment, updated_patch: updateDeployment, @@ -1168,7 +1174,8 @@ def update_db_model( prisma_compatible_model_dict: Final = PrismaCompatibleUpdateDBModel( model_name=merged_model_name, litellm_params=json.dumps(merged_litellm_params), - model_info=json.dumps(merged_model_info), + # date/time values nest inside model_info (e.g. access_windows start/end) + model_info=json.dumps(merged_model_info, default=_json_temporal_default), ) if updated_patch.blocked is not None: diff --git a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py index 303b8e060ed..0dab4f88fa7 100644 --- a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py @@ -3647,6 +3647,64 @@ class TestUpdateDBModelBlocked: assert "blocked" not in result +class TestJsonTemporalDefault: + def test_raises_type_error_for_unsupported_values(self): + from litellm.proxy.management_endpoints.model_management_endpoints import ( + _json_temporal_default, + ) + + with pytest.raises(TypeError, match="not JSON serializable"): + _json_temporal_default(object()) + + def test_isoformats_date_and_time(self): + import datetime + + from litellm.proxy.management_endpoints.model_management_endpoints import ( + _json_temporal_default, + ) + + assert _json_temporal_default(datetime.time(9, 30)) == "09:30:00" + assert _json_temporal_default(datetime.date(2026, 1, 2)) == "2026-01-02" + + +class TestUpdateDBModelAccessWindows: + """model_info.access_windows holds datetime.time values; editing a model with a + saved window must not 500 on json.dumps (issue #46872).""" + + def test_update_db_model_serializes_access_window_times(self): + import datetime + + from litellm.proxy.management_endpoints.model_management_endpoints import update_db_model + from litellm.router_utils.access_windows import parse_access_windows + from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo, updateDeployment + + db_model = Deployment( + model_name="gpt-4o", + litellm_params=LiteLLM_Params(model="openai/gpt-4o"), + model_info=ModelInfo( + id="dep-window", + access_windows=( + { + "start": datetime.time(9, 0), + "end": datetime.time(17, 0), + "timezone": "America/New_York", + "team_ids": ("team-1",), + }, + ), + ), + ) + + result = update_db_model(db_model=db_model, updated_patch=updateDeployment()) + + stored = json.loads(result["model_info"]) + assert stored["access_windows"][0]["start"] == "09:00:00" + assert stored["access_windows"][0]["end"] == "17:00:00" + # the stored JSON still parses back into valid windows - no data lost + windows = parse_access_windows(stored) + assert len(windows) == 1 + assert windows[0].start == datetime.time(9, 0) + + 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