From 2b826fddb7dfbb1ffb69f6daf5a08f0a3b0ec298 Mon Sep 17 00:00:00 2001 From: Andy Cheung Date: Mon, 8 Jun 2026 17:46:22 -0400 Subject: [PATCH] fix(vantage-export): surface upstream errors and prevent blank ServiceName rejection (#27935) * 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) * test(vantage-export): cover upstream status resolution Co-authored-by: Cursor --------- Co-authored-by: shin-berri Co-authored-by: yuneng-jiang Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Cursor --- 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 | 77 +++++++++++++++++++ 4 files changed, 110 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 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"]