From 4efa4817e9c88eb805be7eed0473b04638abd5cc Mon Sep 17 00:00:00 2001 From: Andy Cheung Date: Wed, 13 May 2026 19:04:22 -0400 Subject: [PATCH] fix(vantage-export): surface upstream errors and prevent blank ServiceName rejection Three related fixes uncovered tracing a generic 500 from POST /vantage/export: 1. MaskedHTTPStatusError tried to read original_error.request.content when rebuilding the masked request, which raises httpx.RequestNotRead for any request built with files= (multipart). That swallowed the real upstream HTTPStatusError. Guard the access the same way response.content is already guarded so the real error propagates. Fix applies to all multipart uploads, not just Vantage. 2. The /vantage/export error handler called str(e) only, which on a MaskedHTTPStatusError yields just the status line. Surface the response body too so the actual upstream rejection (e.g. Vantage's "Row 3: ServiceName can't be blank") shows up in both logs and the HTTP detail. 3. FocusTransformer mapped ServiceName directly from model_group, which is null/empty for direct (non-router) calls. Vantage rejects rows with blank ServiceName. Fall back to model, then to a literal "unknown". Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/integrations/focus/transformer.py | 13 ++++- litellm/llms/custom_httpx/http_handler.py | 4 ++ .../proxy/spend_tracking/vantage_endpoints.py | 20 +++++-- .../test_vantage_endpoints.py | 54 +++++++++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 tests/proxy_unit_tests/test_vantage_endpoints.py diff --git a/litellm/integrations/focus/transformer.py b/litellm/integrations/focus/transformer.py index b7d28e3dbb9..8db91b1694f 100644 --- a/litellm/integrations/focus/transformer.py +++ b/litellm/integrations/focus/transformer.py @@ -122,7 +122,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 e11d8532dbf..512a03fa586 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 60e54d005b3..5d15031b5ea 100644 --- a/litellm/proxy/spend_tracking/vantage_endpoints.py +++ b/litellm/proxy/spend_tracking/vantage_endpoints.py @@ -286,6 +286,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: @@ -517,10 +527,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..705bc82c838 --- /dev/null +++ b/tests/proxy_unit_tests/test_vantage_endpoints.py @@ -0,0 +1,54 @@ +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.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"]