mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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:
parent
69a7bdb247
commit
2b826fddb7
4 changed files with 110 additions and 4 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}"},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
77
tests/proxy_unit_tests/test_vantage_endpoints.py
Normal file
77
tests/proxy_unit_tests/test_vantage_endpoints.py
Normal 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"]
|
||||
Loading…
Add table
Reference in a new issue