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) <noreply@anthropic.com>

* test(vantage-export): cover upstream status resolution

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: shin-berri <shin-laptop@berri.ai>
Co-authored-by: yuneng-jiang <yuneng@berri.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andy Cheung 2026-06-08 17:46:22 -04:00 committed by GitHub
parent 69a7bdb247
commit 2b826fddb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 110 additions and 4 deletions

View file

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

View file

@ -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:

View file

@ -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}"},
)

View file

@ -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"]