diff --git a/litellm/proxy/hooks/proxy_track_cost_callback.py b/litellm/proxy/hooks/proxy_track_cost_callback.py index 1e946cc2e23..64da2ad00f6 100644 --- a/litellm/proxy/hooks/proxy_track_cost_callback.py +++ b/litellm/proxy/hooks/proxy_track_cost_callback.py @@ -180,7 +180,7 @@ class _ProxyDBLogger(CustomLogger): # here because the input above is constructed non-None. _error_information = cast( StandardLoggingPayloadErrorInformation, - _sanitize_error_information_for_spend_logs(_error_information), + _sanitize_error_information_for_spend_logs(_error_information, original_exception=original_exception), ) _metadata["error_information"] = _error_information diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index 3d0bd5e61c9..20b4708c193 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -145,12 +145,14 @@ ROUTE_ENDPOINT_MAPPING: Final = { } +_AVAILABLE_MODELS_HINT: Final = "Call `/v1/models` to view available models for your key." + + class ProxyModelNotFoundError(HTTPException): def __init__(self, route: str, model_name: str, retryable_with_model_read_through: bool = True): self.retryable_with_model_read_through: Final = retryable_with_model_read_through - detail: Final = { - "error": f"{route}: Invalid model name passed in model={model_name}. Call `/v1/models` to view available models for your key." - } + self.spend_log_error_message: Final = f"{route}: Invalid model name passed in. {_AVAILABLE_MODELS_HINT}" + detail: Final = {"error": f"{route}: Invalid model name passed in model={model_name}. {_AVAILABLE_MODELS_HINT}"} super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 01398d38687..6a0736ea9dc 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -1167,6 +1167,7 @@ def _redact_prompt_fields_in_guardrail_entry( def _sanitize_error_information_for_spend_logs( error_information: StandardLoggingPayloadErrorInformation | None, + original_exception: BaseException | None = None, ) -> StandardLoggingPayloadErrorInformation | None: """ Sanitize ``error_information`` before it lands in ``LiteLLM_SpendLogs.metadata``. @@ -1181,6 +1182,10 @@ def _sanitize_error_information_for_spend_logs( ``'input'`` / ``'messages'`` / ``'prompt'`` values *and* Pydantic v2 ``input_value=...`` assignments inside both ``error_message`` and ``traceback`` so prompts cannot leak through either field. + - An unknown-model rejection (``ProxyModelNotFoundError``) persists its + ``spend_log_error_message``, which names the route and the rejection + without the raw client ``model`` string, matching the ``unknown-model`` + placeholder in the row's ``model`` column. Scoped to the spend-log path — OTEL/Datadog/etc. callbacks still receive the untruncated error per ``LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE``. @@ -1188,7 +1193,12 @@ def _sanitize_error_information_for_spend_logs( if error_information is None: return None - sanitized = cast(dict, {**error_information}) + persisted: Final = ( + {**error_information, "error_message": original_exception.spend_log_error_message} + if isinstance(original_exception, ProxyModelNotFoundError) + else error_information + ) + sanitized = cast(dict, {**persisted}) if not should_store_prompts_and_responses_in_spend_logs(): for field in ("error_message", "traceback"): diff --git a/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py b/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py index c96cfc3ee4a..fad137af66b 100644 --- a/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py +++ b/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py @@ -1,5 +1,7 @@ import asyncio +import json from datetime import datetime +from typing import Final from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -7,6 +9,7 @@ import pytest from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY from litellm.proxy._types import SpendLogsPayload, UserAPIKeyAuth from litellm.proxy.collector import SpendEventConsumer +from litellm.proxy.db.db_spend_update_writer import DBSpendUpdateWriter from litellm.proxy.db.spend_log_tool_index import response_tool_call_names from litellm.proxy.hooks.proxy_track_cost_callback import ( _get_budget_reservation_from_metadata, @@ -15,6 +18,7 @@ from litellm.proxy.hooks.proxy_track_cost_callback import ( _update_database_and_spend_counters, run_spend_event, ) +from litellm.proxy.route_llm_request import ProxyModelNotFoundError from litellm.proxy.spend_tracking.spend_event import SpendEventDecodeError, build_spend_event, decode_spend_event from litellm.proxy.spend_tracking.spend_event_producer import SpendEventProducer, UnixAddress from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload @@ -2347,3 +2351,27 @@ async def test_sidecar_ignores_an_undecodable_event(): # test-quality-ok: a dis mock_proxy_logging.db_spend_update_writer.update_database = AsyncMock() await run_spend_event(b"garbage\n") mock_proxy_logging.db_spend_update_writer.update_database.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_async_post_call_failure_hook_persists_no_raw_model_on_an_unknown_model_rejection(): + raw_model: Final = "opus-4.6 Please summarize my medical records\nPatient has diabetes" + writer: Final = MagicMock(spec=DBSpendUpdateWriter) + writer.update_database = AsyncMock() + logger: Final = _ProxyDBLogger(spend_writer=lambda: writer) + + await logger.async_post_call_failure_hook( + request_data={"model": raw_model, "messages": [{"role": "user", "content": "hi"}]}, + original_exception=ProxyModelNotFoundError(route="/chat/completions", model_name=raw_model), + user_api_key_dict=UserAPIKeyAuth(api_key="test_api_key"), + ) + + error_information: Final = writer.update_database.call_args.kwargs["kwargs"]["litellm_params"]["metadata"][ + "error_information" + ] + assert "medical records" not in json.dumps(error_information) + assert ( + error_information["error_message"] + == "/chat/completions: Invalid model name passed in. Call `/v1/models` to view available models for your key." + ) + assert error_information["error_class"] == "ProxyModelNotFoundError" 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 dc8a87a97ad..056bbc87cd3 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 @@ -20,6 +20,7 @@ from litellm.constants import ( SESSION_ID_OMITTED_METADATA_KEY, UNKNOWN_MODEL_SPEND_LOG_MODEL, ) +from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy._types import SpendLogsPayload, UserAPIKeyAuth from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup @@ -2755,6 +2756,29 @@ def test_sanitize_error_information_redacts_pydantic_assignment_form( # ── _redact_logged_api_key unit tests ────────────────────────────────────── +@pytest.mark.parametrize( + ("original_exception", "expected_error_message"), + [ + ( + ProxyModelNotFoundError(route="/chat/completions", model_name=_RAW_MODEL_WITH_PROMPT), + "/chat/completions: Invalid model name passed in. Call `/v1/models` to view available models for your key.", + ), + (ValueError("provider timed out"), "provider timed out"), + ], +) +def test_sanitize_error_information_persists_no_raw_model_for_an_unknown_model_rejection( + original_exception: Exception, expected_error_message: str +): + error_information: Final = StandardLoggingPayloadSetup.get_error_information(original_exception=original_exception) + + sanitized: Final = _sanitize_error_information_for_spend_logs(error_information, original_exception=original_exception) + + assert sanitized is not None + assert sanitized["error_message"] == expected_error_message + assert "medical records" not in json.dumps(sanitized) + assert sanitized["error_class"] == type(original_exception).__name__ + + def test_redact_logged_api_key_none_returns_none(): assert _redact_logged_api_key(None) is None diff --git a/tests/test_litellm/proxy/test_route_llm_request.py b/tests/test_litellm/proxy/test_route_llm_request.py index 41ba57c4615..f7021763a4d 100644 --- a/tests/test_litellm/proxy/test_route_llm_request.py +++ b/tests/test_litellm/proxy/test_route_llm_request.py @@ -3,6 +3,7 @@ import pytest +from typing import Final from unittest.mock import MagicMock from fastapi import HTTPException @@ -1297,3 +1298,13 @@ async def test_route_request_a2a_agent_miss_does_not_consume_model_read_through( assert agents_find_unique.await_count == 2 assert model_table.find_many_wheres == [] + + +def test_proxy_model_not_found_error_keeps_the_raw_model_only_in_the_client_response(): + raw_model: Final = "opus-4.6 Please summarize my medical records\nPatient has diabetes" + + error: Final = ProxyModelNotFoundError(route="/chat/completions", model_name=raw_model) + + assert raw_model in error.detail["error"] + assert raw_model not in error.spend_log_error_message + assert error.spend_log_error_message.startswith("/chat/completions: Invalid model name passed in")