From 27dd1a02aa786d1185aad6fecec24d8d4ca57617 Mon Sep 17 00:00:00 2001 From: kerry Date: Thu, 17 Sep 2026 19:37:14 +0000 Subject: [PATCH 1/2] fix(proxy): reject non-string model with 400 and log its spend as unknown-model Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/common_request_processing.py | 8 +++++ .../spend_tracking/spend_tracking_utils.py | 13 +++++---- .../test_spend_tracking_utils.py | 21 ++++++++++++++ .../proxy/test_common_request_processing.py | 29 +++++++++++++++++++ 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 2f39e6c71bc..f650b6d0b28 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -1937,6 +1937,14 @@ class ProxyBaseLLMRequestProcessing: ) -> tuple[dict, LiteLLMLoggingObj]: start_time: Final = datetime.now() # start before calling guardrail hooks + requested_model: Final = self.data.get("model") + if requested_model is not None and not isinstance(requested_model, str): + raise ProxyException( + message="'model' must be a string.", + type=ProxyErrorTypes.bad_request_error, + param="model", + code=status.HTTP_400_BAD_REQUEST, + ) self.data = await add_litellm_data_to_request( data=self.data, request=request, diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 52900c33745..09d719202ca 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -485,10 +485,13 @@ def get_logging_payload( or None ) custom_llm_provider: Final = logged_provider or _model_group_provider(_model_group, llm_router) - raw_model: Final = cast(str, kwargs.get("model") or "") - resolved_model: Final = ( - standard_logging_payload.get("model") if standard_logging_payload is not None else None - ) or reconstruct_model_name(raw_model, logged_provider, metadata or {}) + requested_model: Final = cast(object, kwargs.get("model")) + raw_model: Final = requested_model if isinstance(requested_model, str) else "" + model_is_malformed: Final = requested_model is not None and not isinstance(requested_model, str) + logged_model: Final = standard_logging_payload.get("model") if standard_logging_payload is not None else None + resolved_model: Final = (logged_model if isinstance(logged_model, str) else None) or reconstruct_model_name( + raw_model, logged_provider, metadata or {} + ) failed_with_prompt_shaped_model: Final = ( _get_status_for_spend_log(metadata=metadata) == "failure" and not _model_group @@ -496,7 +499,7 @@ def get_logging_payload( ) model_name: Final = ( UNKNOWN_MODEL_SPEND_LOG_MODEL - if rejected_as_unknown_model or failed_with_prompt_shaped_model + if rejected_as_unknown_model or failed_with_prompt_shaped_model or model_is_malformed else resolved_model ) litellm_call_id: Final = cast( diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py index 1072e970094..7663bd83790 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py @@ -1049,6 +1049,27 @@ def test_get_logging_payload_replaces_rejected_or_prompt_shaped_models_with_the_ assert payload["model"] == expected_model +@pytest.mark.parametrize("requested_model", [{"bad": "value"}, ["gpt-5.2"], 1]) +def test_get_logging_payload_replaces_a_non_string_model_with_the_placeholder( + requested_model: dict[str, str] | list[str] | int, +): + kwargs: Final = { + "model": requested_model, + "messages": [{"role": "user", "content": "hi"}], + "call_type": "acompletion", + "litellm_params": {"metadata": {"user_api_key": "sk-test", "status": "failure"}}, + } + + payload: Final = get_logging_payload( + kwargs=kwargs, + response_obj=ValueError("model must be a string"), + start_time=datetime.datetime.now(timezone.utc), + end_time=datetime.datetime.now(timezone.utc), + ) + + assert payload["model"] == UNKNOWN_MODEL_SPEND_LOG_MODEL + + @pytest.mark.parametrize( ("metadata", "response_obj"), [ diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 4ac687625c2..d465deace15 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -327,6 +327,35 @@ class TestProxyBaseLLMRequestProcessing: pytest.fail("litellm_call_id is not a valid UUID") assert data_passed["litellm_call_id"] == returned_data["litellm_call_id"] + @pytest.mark.asyncio + @pytest.mark.parametrize("requested_model", [{"bad": "value"}, ["gpt-5.2"], 1]) + async def test_common_processing_pre_call_logic_rejects_a_non_string_model_with_400( + self, monkeypatch, requested_model: dict[str, str] | list[str] | int + ): + processing_obj = ProxyBaseLLMRequestProcessing( + data={"model": requested_model, "messages": [{"role": "user", "content": "hi"}]} + ) + mock_request = MagicMock(spec=Request) + mock_request.headers = {} + add_litellm_data_to_request = AsyncMock() + monkeypatch.setattr( + litellm.proxy.common_request_processing, "add_litellm_data_to_request", add_litellm_data_to_request + ) + + with pytest.raises(ProxyException) as exc_info: + await processing_obj.common_processing_pre_call_logic( + request=mock_request, + general_settings={}, + user_api_key_dict=MagicMock(spec=UserAPIKeyAuth), + proxy_logging_obj=MagicMock(spec=ProxyLogging), + proxy_config=MagicMock(spec=ProxyConfig), + route_type="acompletion", + ) + + assert exc_info.value.code == str(status.HTTP_400_BAD_REQUEST) + assert exc_info.value.param == "model" + add_litellm_data_to_request.assert_not_awaited() + @pytest.mark.asyncio async def test_common_processing_pre_call_logic_refreshes_proxy_server_request_body_after_guardrails( self, monkeypatch From 427d08470fe9c65ce1ee3fae9f1573c858e76e0a Mon Sep 17 00:00:00 2001 From: kerry Date: Thu, 17 Sep 2026 19:40:00 +0000 Subject: [PATCH 2/2] test(together_ai): stop pinning successor deprecation status Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm/test_together_ai_model_metadata.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_litellm/test_together_ai_model_metadata.py b/tests/test_litellm/test_together_ai_model_metadata.py index 88d6db0d8b0..7176ba4f219 100644 --- a/tests/test_litellm/test_together_ai_model_metadata.py +++ b/tests/test_litellm/test_together_ai_model_metadata.py @@ -95,7 +95,7 @@ def _successor(info: dict[str, object]) -> str | None: return successor if isinstance(successor, str) else None -def test_together_successor_metadata_points_at_live_models(cost_map: CostMap): +def test_together_successor_metadata_points_at_known_models(cost_map: CostMap): successors = { model: successor for model, info in cost_map.items() @@ -103,9 +103,7 @@ def test_together_successor_metadata_points_at_live_models(cost_map: CostMap): } assert len(successors) >= 10 for model, successor in successors.items(): - target = cost_map.get(successor) - assert target is not None, f"{model} names successor {successor} that is not in the map" - assert "deprecation_date" not in target, f"{model} names deprecated successor {successor}" + assert successor in cost_map, f"{model} names successor {successor} that is not in the map" def test_together_backup_cost_map_in_sync(cost_map: CostMap):