Skip to content
9 changes: 5 additions & 4 deletions litellm/proxy/openai_files_endpoints/common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,8 @@ def get_credentials_for_model(
"""
from fastapi import HTTPException

from litellm.proxy.route_llm_request import ProxyModelNotFoundError

if llm_router is None:
raise HTTPException(
status_code=500,
Expand All @@ -372,9 +374,8 @@ def get_credentials_for_model(
credentials: Final = llm_router.get_deployment_credentials_with_provider(model_id=model_id)

if credentials is None:
raise HTTPException(
status_code=400,
detail={"error": f"Model '{model_id}' not found in model_list. Please check your config.yaml."},
raise ProxyModelNotFoundError(
route=operation_context, model_name=model_id, retryable_with_model_read_through=False
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
)

return credentials
Expand Down Expand Up @@ -610,7 +611,7 @@ def handle_model_based_routing(
credentials = get_credentials_for_model(
llm_router=llm_router,
model_id=model_from_id,
operation_context=f"file operation (file created with model '{model_from_id}')",
operation_context="file operation (file created with model)",
)
original_file_id: Final = get_original_file_id(file_id)
return True, model_from_id, original_file_id, credentials
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
LiteLLMProxyRequestSetup,
_get_dynamic_logging_metadata, # pyright: ignore[reportPrivateUsage] # shared proxy helper, same import style as _read_request_body above
)
from litellm.proxy.route_llm_request import ProxyModelNotFoundError
from litellm.proxy.utils import normalize_route_for_root_path
from litellm.repositories.team_repository import TeamRepository
from litellm.secret_managers.main import get_secret_str
Expand Down Expand Up @@ -281,9 +282,8 @@ async def chat_completion_pass_through_endpoint(
elif user_model is not None: # `litellm --model <your-model-name>`
llm_response = asyncio.create_task(litellm.aadapter_completion(**data))
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "completion: Invalid model name passed in model=" + data.get("model", "")},
raise ProxyModelNotFoundError(
route="completion", model_name=data.get("model", ""), retryable_with_model_read_through=False
)

# Await the llm_response task
Expand Down
163 changes: 156 additions & 7 deletions litellm/proxy/spend_tracking/spend_tracking_utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import json
import os
import re
import secrets
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from datetime import datetime as dt
from functools import reduce
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Literal, Protocol, cast, runtime_checkable

Expand Down Expand Up @@ -380,11 +382,81 @@ def _model_group_provider(model_group: str, llm_router: "Router | None") -> str
return next(iter(providers)) if len(providers) == 1 else None


def _is_configured_model_group(model_group: str, llm_router: "Router | None") -> bool:
if llm_router is None or not model_group:
return False
return llm_router.is_recognized_model(model_group) or model_group in llm_router.team_public_model_names


def _looks_like_model_name(model: str) -> bool:
candidate: Final = model.removeprefix(MCP_SPEND_LOG_MODEL_PREFIX)
return len(candidate) <= MAX_SPEND_LOG_MODEL_NAME_LENGTH and not any(char.isspace() for char in candidate)


_TRUNCATION_MARKER: Final = re.compile(
rf"\.\.\. \({re.escape(LITELLM_TRUNCATED_PAYLOAD_FIELD)} skipped \d+ chars\. "
rf"{re.escape(LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE)}\) \.\.\."
)
_SCRUBBED_ERROR_TEXT_FIELDS: Final = frozenset(("error_message", "traceback"))


def _raw_model_spellings(raw_model: str) -> tuple[str, ...]:
return tuple(dict.fromkeys((raw_model, repr(raw_model)[1:-1], json.dumps(raw_model)[1:-1])))


def _overlap_at_end(text: str, spelling: str) -> int:
lengths: Final = range(min(len(text), len(spelling) - 1), 0, -1)
return next((length for length in lengths if text.endswith(spelling[:length])), 0)


def _overlap_at_start(text: str, spelling: str) -> int:
lengths: Final = range(min(len(text), len(spelling) - 1), 0, -1)
return next((length for length in lengths if text.startswith(spelling[-length:])), 0)


def _scrub_raw_model_split_by_truncation(text: str, spellings: tuple[str, ...]) -> str:
marker: Final = _TRUNCATION_MARKER.search(text)
if marker is None:
return text
head: Final = text[: marker.start()]
tail: Final = text[marker.end() :]
head_cut: Final = max(_overlap_at_end(head, spelling) for spelling in spellings)
tail_cut: Final = max(_overlap_at_start(tail, spelling) for spelling in spellings)
return "".join(
(
head[: len(head) - head_cut],
UNKNOWN_MODEL_SPEND_LOG_MODEL if head_cut else "",
marker.group(0),
UNKNOWN_MODEL_SPEND_LOG_MODEL if tail_cut else "",
tail[tail_cut:],
)
)


def _scrub_raw_model_from_error_text(text: str, spellings: tuple[str, ...]) -> str:
whole_occurrences_scrubbed: Final = reduce(
lambda scrubbed, spelling: scrubbed.replace(spelling, UNKNOWN_MODEL_SPEND_LOG_MODEL), spellings, text
)
return _scrub_raw_model_split_by_truncation(whole_occurrences_scrubbed, spellings)


def _scrub_raw_model_from_error_information(
error_information: StandardLoggingPayloadErrorInformation | None, raw_model: str
) -> StandardLoggingPayloadErrorInformation | None:
if error_information is None or not raw_model:
return error_information
spellings: Final = _raw_model_spellings(raw_model)
return cast(
StandardLoggingPayloadErrorInformation,
{
key: _scrub_raw_model_from_error_text(value, spellings)
if key in _SCRUBBED_ERROR_TEXT_FIELDS and isinstance(value, str)
else value
for key, value in error_information.items()
},
)


def get_logging_payload(
kwargs: dict | None,
response_obj: object,
Expand Down Expand Up @@ -502,22 +574,37 @@ def get_logging_payload(
)
failed_with_prompt_shaped_model: Final = (
_get_status_for_spend_log(metadata=metadata) == "failure"
and not _model_group
and not _model_id
and not _looks_like_model_name(resolved_model)
and not _is_configured_model_group(_model_group, llm_router)
)
model_name: Final = (
UNKNOWN_MODEL_SPEND_LOG_MODEL
if rejected_as_unknown_model or failed_with_prompt_shaped_model or model_is_malformed
else resolved_model
)
model_is_placeholdered: Final = model_name == UNKNOWN_MODEL_SPEND_LOG_MODEL
persisted_model_group: Final = (
""
if model_is_placeholdered and _model_group == raw_model and not _looks_like_model_name(raw_model)
else _model_group
)
persisted_metadata: Final = (
{
**metadata,
"error_information": _scrub_raw_model_from_error_information(metadata.get("error_information"), raw_model),
}
if model_is_placeholdered
else metadata
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
litellm_call_id: Final = cast(
str | None,
kwargs.get("litellm_call_id") or litellm_params.get("litellm_call_id"),
)

# clean up litellm metadata
clean_metadata = _get_spend_logs_metadata(
metadata,
persisted_metadata,
applied_guardrails=(
standard_logging_payload["metadata"].get("applied_guardrails", None)
if standard_logging_payload is not None
Expand Down Expand Up @@ -576,7 +663,7 @@ def get_logging_payload(
litellm_call_id=litellm_call_id,
router_metadata=_get_router_metadata_for_spend_log(
metadata=metadata,
requested_model=_model_group,
requested_model=persisted_model_group,
selected_model=model_name,
selected_provider=custom_llm_provider,
router_correlation_id=litellm_call_id,
Expand Down Expand Up @@ -658,7 +745,7 @@ def get_logging_payload(
request_tags=request_tags,
end_user=end_user_id or "",
api_base=_api_base,
model_group=_model_group,
model_group=persisted_model_group,
model_id=_model_id,
mcp_namespaced_tool_name=mcp_namespaced_tool_name,
agent_id=agent_id,
Expand All @@ -669,7 +756,13 @@ def get_logging_payload(
),
response=_get_response_for_spend_logs_payload(payload=standard_logging_payload, kwargs=kwargs),
proxy_server_request=_get_proxy_server_request_for_spend_logs_payload(
metadata=metadata, litellm_params=litellm_params, kwargs=kwargs
metadata=metadata,
litellm_params=(
_placeholder_stored_request_body(litellm_params, persisted_model_group, raw_model)
if model_is_placeholdered
else litellm_params
),
kwargs=kwargs,
),
session_id=_get_session_id_for_spend_log(
kwargs=kwargs,
Expand Down Expand Up @@ -975,7 +1068,7 @@ def _sanitize_request_body_for_spend_logs_payload(
visited.add(obj_id)

def _sanitize_value(value: object) -> object:
if isinstance(value, dict):
if isinstance(value, Mapping):
return _sanitize_request_body_for_spend_logs_payload(value, visited, max_string_length_prompt_in_db)
elif isinstance(value, list):
return [_sanitize_value(item) for item in value]
Expand Down Expand Up @@ -1329,9 +1422,65 @@ def _convert_mapping_to_json_serializable(obj: Mapping[str, object]) -> dict[str
return dict(obj)


def _placeholder_stored_request_body_metadata(
request_body: Mapping[str, object], persisted_model_group: str, raw_model: str
) -> Mapping[str, object]:
body_metadata: Final = request_body.get("metadata")
if not isinstance(body_metadata, Mapping):
return request_body
error_information: Final = body_metadata.get("error_information")
placeholdered_fields: Final = MappingProxyType(
{
"model_group": persisted_model_group,
"error_information": _scrub_raw_model_from_error_information(
cast(StandardLoggingPayloadErrorInformation, error_information), raw_model
)
if isinstance(error_information, Mapping)
else error_information,
}
)
return MappingProxyType(
{
**request_body,
"metadata": MappingProxyType(
{key: placeholdered_fields.get(key, value) for key, value in body_metadata.items()}
),
}
)


def _placeholder_stored_request_body(
litellm_params: Mapping[str, object], persisted_model_group: str, raw_model: str
) -> Mapping[str, object]:
proxy_server_request: Final = litellm_params.get("proxy_server_request")
if not isinstance(proxy_server_request, Mapping):
return litellm_params
request_body: Final = proxy_server_request.get("body")
if not isinstance(request_body, Mapping):
return litellm_params
model_placeholdered: Final = (
MappingProxyType({**request_body, "model": UNKNOWN_MODEL_SPEND_LOG_MODEL})
if "model" in request_body
else request_body
)
return MappingProxyType(
{
**litellm_params,
"proxy_server_request": MappingProxyType(
{
**proxy_server_request,
"body": _placeholder_stored_request_body_metadata(
model_placeholdered, persisted_model_group, raw_model
),
}
),
}
)


def _get_proxy_server_request_for_spend_logs_payload(
metadata: dict,
litellm_params: dict,
litellm_params: Mapping[str, object],
kwargs: dict | None = None,
) -> str:
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,37 @@
from types import MappingProxyType
from typing import Final
from unittest.mock import AsyncMock, MagicMock

import pytest


from litellm.proxy.openai_files_endpoints.common_utils import (
apply_unified_file_ids,
get_credentials_for_model,
map_raw_file_ids_to_unified,
)
from litellm.proxy.route_llm_request import ProxyModelNotFoundError
from litellm.proxy.utils import handle_exception_on_proxy
from litellm.types.utils import LiteLLMBatch

_RAW_MODEL_WITH_PROMPT: Final = "opus-4.6 Please summarize my medical records\nPatient has diabetes"


def test_get_credentials_for_model_rejects_an_unknown_model_without_persisting_the_raw_model():
llm_router: Final = MagicMock()
llm_router.get_deployment_credentials_with_provider.return_value = None

with pytest.raises(ProxyModelNotFoundError) as raised:
get_credentials_for_model(
llm_router=llm_router, model_id=_RAW_MODEL_WITH_PROMPT, operation_context="file upload"
)

assert (raised.value.status_code, handle_exception_on_proxy(raised.value).code) == (400, "400")
assert _RAW_MODEL_WITH_PROMPT in raised.value.detail["error"]
assert raised.value.retryable_with_model_read_through is False
assert raised.value.spend_log_error_message.startswith("file upload: ")
assert "medical records" not in raised.value.spend_log_error_message


def _batch(input_file_id, output_file_id, error_file_id) -> LiteLLMBatch:
return LiteLLMBatch(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from contextlib import ExitStack, contextmanager
from io import BytesIO
from types import SimpleNamespace
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch

import httpx
Expand Down Expand Up @@ -38,6 +39,7 @@
from litellm.proxy.pass_through_endpoints.success_handler import (
PassThroughEndpointLogging,
)
from litellm.proxy.route_llm_request import ProxyModelNotFoundError
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
LITELLM_PASS_THROUGH_DEPLOYMENT_MODEL_INFO_STATE_KEY,
LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY,
Expand Down Expand Up @@ -6443,6 +6445,46 @@ async def fake_add_litellm_data_to_request(**kwargs: object) -> object:
assert (raised.value.type, raised.value.param, raised.value.code) == ("invalid_request_error", None, "400")


@pytest.mark.asyncio
async def test_chat_completion_pass_through_endpoint_keeps_the_raw_model_out_of_the_spend_log_error(
monkeypatch: pytest.MonkeyPatch,
):
raw_model: Final = "opus-4.6 Please summarize my medical records\nPatient has diabetes"
proxy_logging: Final = MagicMock()
proxy_logging.pre_call_hook = AsyncMock(side_effect=lambda **kwargs: kwargs["data"])
proxy_logging.post_call_failure_hook = AsyncMock()

async def fake_add_litellm_data_to_request(**kwargs: object) -> object:
return kwargs["data"]

monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging)
monkeypatch.setattr("litellm.proxy.proxy_server.add_litellm_data_to_request", fake_add_litellm_data_to_request)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
monkeypatch.setattr("litellm.proxy.proxy_server.user_model", None)
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})

request: Final = MagicMock(spec=Request)
request.body = AsyncMock(
return_value=json.dumps({"model": raw_model, "messages": [{"role": "user", "content": "hi"}]}).encode()
)

with pytest.raises(ProxyException) as raised:
await chat_completion_pass_through_endpoint(
fastapi_response=Response(),
request=request,
adapter_id="anthropic",
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"),
)

logged_exception: Final = proxy_logging.post_call_failure_hook.call_args.kwargs["original_exception"]
assert isinstance(logged_exception, ProxyModelNotFoundError)
assert logged_exception.retryable_with_model_read_through is False
assert logged_exception.spend_log_error_message.startswith("completion: ")
assert "medical records" not in logged_exception.spend_log_error_message
assert (raised.value.type, raised.value.param, raised.value.code) == ("invalid_request_error", None, "400")
assert raw_model in logged_exception.detail["error"]


@pytest.mark.asyncio
async def test_chat_completion_pass_through_endpoint_failure_carries_the_callers_litellm_call_id(
monkeypatch: pytest.MonkeyPatch,
Expand Down
Loading
Loading