Merge pull request #40820 from BerriAI/litellm_sanitize_unknown_model_error_message

fix(proxy): keep the raw model string out of the unknown-model spend-log error message
This commit is contained in:
Mateo Wang 2026-09-11 19:54:34 -07:00 committed by GitHub
commit 19c8553052
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 80 additions and 6 deletions

View file

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

View file

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

View file

@ -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``.
@ -1188,7 +1189,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"):

View file

@ -210,7 +210,8 @@ async def test_chat_completion_bad_model_with_spend_logs():
assert "traceback" in error_info
assert error_info["error_code"] == "400"
assert error_info["error_class"] in ("ProxyModelNotFoundError", "BadRequestError")
assert "non-existent-model" in error_info["error_message"]
assert "non-existent-model" not in error_info["error_message"]
assert "/chat/completions: Invalid model name passed in" in error_info["error_message"]
# Verify request details
assert log_entry["cache_hit"] == "False"

View file

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

View file

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

View file

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