diff --git a/litellm/integrations/focus/transformer.py b/litellm/integrations/focus/transformer.py index 8496b7ec159..af6173759c4 100644 --- a/litellm/integrations/focus/transformer.py +++ b/litellm/integrations/focus/transformer.py @@ -125,7 +125,18 @@ class FocusTransformer: pl.col("model").cast(pl.String).alias("ResourceType"), pl.lit("AI and Machine Learning").alias("ServiceCategory"), pl.lit("Generative AI").alias("ServiceSubcategory"), - pl.col("model_group").cast(pl.String).alias("ServiceName"), + # Vantage requires ServiceName on every row. model_group can be + # null/empty (e.g. direct calls that bypass the router), so fall + # back to model, then to a literal placeholder. + pl.coalesce( + pl.when(pl.col("model_group").cast(pl.String).str.len_chars() > 0) + .then(pl.col("model_group").cast(pl.String)) + .otherwise(None), + pl.when(pl.col("model").cast(pl.String).str.len_chars() > 0) + .then(pl.col("model").cast(pl.String)) + .otherwise(None), + pl.lit("unknown"), + ).alias("ServiceName"), pl.col("team_id").cast(pl.String).alias("SubAccountId"), pl.col("team_alias").cast(pl.String).alias("SubAccountName"), none_str.alias("SubAccountType"), diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 01c94476431..2c9e3cc9b28 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -485,6 +485,10 @@ class MaskedHTTPStatusError(httpx.HTTPStatusError): if k.lower() not in ("content-encoding", "content-length") } + # Requests built with `files=` (multipart) have a streaming body, so + # `.content` raises httpx.RequestNotRead. Guard the access so the + # masked error can still be constructed when the original upload used + # multipart — otherwise the real HTTPStatusError gets swallowed. try: request_content = original_error.request.content except httpx.RequestNotRead: diff --git a/litellm/proxy/spend_tracking/vantage_endpoints.py b/litellm/proxy/spend_tracking/vantage_endpoints.py index 1dde31b54cb..77e9f501a62 100644 --- a/litellm/proxy/spend_tracking/vantage_endpoints.py +++ b/litellm/proxy/spend_tracking/vantage_endpoints.py @@ -287,6 +287,16 @@ def is_vantage_setup_in_config() -> bool: return False +def _get_upstream_status_code(exception: Exception) -> int: + """Return the upstream HTTP status code when the export client exposes one.""" + status_code = getattr(exception, "status_code", None) + if status_code is None: + response = getattr(exception, "response", None) + status_code = getattr(response, "status_code", None) + + return status_code if isinstance(status_code, int) else 500 + + async def is_vantage_setup() -> bool: """Check if Vantage is setup in either config or database.""" try: @@ -518,10 +528,14 @@ async def vantage_export( except HTTPException: raise except Exception as e: - verbose_proxy_logger.error(f"Error performing Vantage export: {str(e)}") + body = getattr(e, "text", None) or getattr( + getattr(e, "response", None), "text", None + ) + detail = f"{str(e)} | response body: {body}" if body else str(e) + verbose_proxy_logger.error(f"Error performing Vantage export: {detail}") raise HTTPException( - status_code=500, - detail={"error": f"Failed to perform Vantage export: {str(e)}"}, + status_code=_get_upstream_status_code(e), + detail={"error": f"Failed to perform Vantage export: {detail}"}, ) diff --git a/tests/proxy_unit_tests/test_vantage_endpoints.py b/tests/proxy_unit_tests/test_vantage_endpoints.py new file mode 100644 index 00000000000..3b00199221f --- /dev/null +++ b/tests/proxy_unit_tests/test_vantage_endpoints.py @@ -0,0 +1,77 @@ +from unittest.mock import AsyncMock + +import httpx +import pytest +from fastapi import HTTPException + +from litellm.llms.custom_httpx.http_handler import MaskedHTTPStatusError +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.spend_tracking import vantage_endpoints +from litellm.types.proxy.vantage_endpoints import VantageExportRequest + + +@pytest.mark.parametrize( + ("exception", "expected_status_code"), + [ + (type("StatusCodeError", (Exception,), {"status_code": 422})(), 422), + ( + type( + "ResponseStatusCodeError", + (Exception,), + {"response": httpx.Response(409)}, + )(), + 409, + ), + (Exception("unexpected failure"), 500), + ], +) +def test_get_upstream_status_code_uses_upstream_status_when_available( + exception, expected_status_code +): + assert ( + vantage_endpoints._get_upstream_status_code(exception) == expected_status_code + ) + + +@pytest.mark.asyncio +async def test_vantage_export_preserves_upstream_http_status(monkeypatch): + request = httpx.Request( + "POST", + "https://api.vantage.sh/costs", + content=b"usage-data", + ) + response = httpx.Response( + 422, + request=request, + content=b'{"error":"ServiceName is required"}', + ) + upstream_error = httpx.HTTPStatusError( + "Unprocessable Entity", + request=request, + response=response, + ) + masked_error = MaskedHTTPStatusError( + upstream_error, + message='{"error":"ServiceName is required"}', + text='{"error":"ServiceName is required"}', + ) + + fake_logger = AsyncMock() + fake_logger.export_usage_data.side_effect = masked_error + monkeypatch.setattr( + vantage_endpoints, + "_get_registered_vantage_logger", + lambda: fake_logger, + ) + + with pytest.raises(HTTPException) as exc_info: + await vantage_endpoints.vantage_export( + request=VantageExportRequest(), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-test", + ), + ) + + assert exc_info.value.status_code == 422 + assert "ServiceName is required" in exc_info.value.detail["error"]