fix(proxy): keep the raw model string out of the unknown-model spend-log error message

This commit is contained in:
mateo-berri 2026-09-11 18:42:08 -07:00
parent f84f986b4e
commit 832da2950f
6 changed files with 80 additions and 5 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``.
@ -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"):

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

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