fix(passthrough): bound the Prometheus provider label and name Vertex failures like the success handler

Declared providers only reach the api_provider label when they name a
registered provider, otherwise the label falls back to inference.
Vertex failure rows now use the success handler's model naming
(vertex_ai/ prefix on rawPredict and streamRawPredict, vertex_ai/search_api
on :search) and its host-based provider detection, and the detected-stream
failure sites are covered by tests.
This commit is contained in:
mateo-berri 2026-09-05 02:03:46 -07:00
parent 006c166e5e
commit 60ae16db8c
6 changed files with 166 additions and 34 deletions

View file

@ -35,6 +35,7 @@ from litellm.litellm_core_utils.core_helpers import (
from litellm.litellm_core_utils.service_tier_utils import (
get_service_tier_from_standard_logging_payload,
)
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
from litellm.proxy._types import (
LiteLLM_DeletedVerificationToken,
LiteLLM_TeamTable,
@ -2485,6 +2486,12 @@ class PrometheusLogger(CustomLogger):
return False
@staticmethod
def _is_known_provider(provider: object) -> bool:
return isinstance(provider, str) and (
provider in litellm.provider_list or JSONProviderRegistry.exists(provider)
)
@staticmethod
def _extract_api_provider_from_request_data(request_data: dict) -> str | None:
"""
@ -2497,17 +2504,21 @@ class PrometheusLogger(CustomLogger):
the provider the request itself declares (e.g. a Gemini pass-through
route), and finally infer it from the requested model name (e.g. ``gpt-4o-mini`` ->
``openai``) since the proxy's failure ``request_data`` usually carries
only the client-supplied model. Return ``None`` when it cannot be
only the client-supplied model. Declared values outside the known
provider set are ignored, since the request body can carry any string
and this label must stay bounded. Return ``None`` when it cannot be
determined so the label emits empty rather than a guess.
"""
litellm_params: Final = request_data.get("litellm_params") or {}
provider = litellm_params.get("custom_llm_provider")
if provider:
return provider
standard_logging_object: Final = request_data.get("standard_logging_object") or {}
provider = standard_logging_object.get("custom_llm_provider") or request_data.get("custom_llm_provider")
if provider:
return provider
declared_providers: Final = (
litellm_params.get("custom_llm_provider"),
standard_logging_object.get("custom_llm_provider"),
request_data.get("custom_llm_provider"),
)
known_provider: Final = next((p for p in declared_providers if PrometheusLogger._is_known_provider(p)), None)
if known_provider:
return known_provider
model: Final = litellm_params.get("model") or request_data.get("model")
if not model:
return None

View file

@ -1,4 +1,3 @@
import re
from datetime import datetime
from typing import TYPE_CHECKING, Any, Final
@ -8,6 +7,7 @@ import litellm
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.gemini.videos.transformation import GeminiVideoConfig
from litellm.llms.vertex_ai.common_utils import get_vertex_model_id_from_url
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
ModelResponseIterator as GeminiModelResponseIterator,
)
@ -200,11 +200,7 @@ class GeminiPassthroughLoggingHandler:
@staticmethod
def extract_model_from_url(url: str) -> str:
pattern: Final = r"/models/([^:]+)"
match: Final = re.search(pattern, url)
if match:
return match.group(1)
return "unknown"
return get_vertex_model_id_from_url(url) or "unknown"
@staticmethod
def _create_gemini_response_logging_payload_for_generate_content(

View file

@ -126,7 +126,7 @@ class VertexPassthroughLoggingHandler:
start_time=start_time,
end_time=end_time,
logging_obj=logging_obj,
custom_llm_provider=VertexPassthroughLoggingHandler._get_custom_llm_provider_from_url(url_route),
custom_llm_provider=VertexPassthroughLoggingHandler.custom_llm_provider_from_url(url_route),
vertex_location=vertex_location,
)
@ -377,7 +377,7 @@ class VertexPassthroughLoggingHandler:
response_json=response_json,
)
custom_llm_provider: Final = VertexPassthroughLoggingHandler._get_custom_llm_provider_from_url(url_route)
custom_llm_provider: Final = VertexPassthroughLoggingHandler.custom_llm_provider_from_url(url_route)
litellm_embedding_response.model = model
logging_obj.model = model
@ -449,7 +449,7 @@ class VertexPassthroughLoggingHandler:
start_time=start_time,
end_time=end_time,
logging_obj=litellm_logging_obj,
custom_llm_provider=VertexPassthroughLoggingHandler._get_custom_llm_provider_from_url(url_route),
custom_llm_provider=VertexPassthroughLoggingHandler.custom_llm_provider_from_url(url_route),
vertex_location=vertex_location,
)
@ -508,6 +508,15 @@ class VertexPassthroughLoggingHandler:
def extract_model_from_url(url: str) -> str:
return get_vertex_model_id_from_url(url) or "unknown"
@staticmethod
def model_from_url_route(url_route: str) -> str | None:
model: Final = get_vertex_model_id_from_url(url_route)
if model is None:
return "vertex_ai/search_api" if ":search" in url_route else None
if "rawPredict" in url_route or "streamRawPredict" in url_route:
return f"vertex_ai/{model}"
return model
@staticmethod
def extract_model_name_from_vertex_path(vertex_model_path: str) -> str:
"""
@ -552,7 +561,7 @@ class VertexPassthroughLoggingHandler:
return None
@staticmethod
def _get_custom_llm_provider_from_url(url: str) -> str:
def custom_llm_provider_from_url(url: str) -> str:
parsed_url: Final = urlparse(url)
if parsed_url.hostname and parsed_url.hostname.endswith("generativelanguage.googleapis.com"):
return litellm.LlmProviders.GEMINI.value

View file

@ -99,6 +99,7 @@ from litellm.types.passthrough_endpoints.pass_through_endpoints import (
)
from litellm.types.utils import TRUSTED_CALLBACK_VARS_FIELD, Usage
from .llm_provider_handlers.vertex_passthrough_logging_handler import VertexPassthroughLoggingHandler
from .streaming_handler import PassThroughStreamingHandler
from .success_handler import PassThroughEndpointLogging
from .upstream_usage_headers import (
@ -735,10 +736,20 @@ def _passthrough_google_provider(url_route: str, custom_llm_provider: str | None
if pass_through_endpoint_logging.is_gemini_route(url_route, custom_llm_provider):
return "gemini"
if pass_through_endpoint_logging.is_vertex_route(url_route):
return "vertex_ai"
return VertexPassthroughLoggingHandler.custom_llm_provider_from_url(url_route)
return None
def _passthrough_google_model(url_route: str, google_provider: str | None) -> str | None:
match google_provider:
case "gemini":
return get_vertex_model_id_from_url(url_route)
case "vertex_ai":
return VertexPassthroughLoggingHandler.model_from_url_route(url_route)
case _:
return None
def _build_passthrough_failure_request_payload(
parsed_body: dict | None,
kwargs: dict | None,
@ -765,7 +776,7 @@ def _build_passthrough_failure_request_payload(
url_route: Final = str(url) if url is not None else ""
google_provider: Final = _passthrough_google_provider(url_route, custom_llm_provider)
if "model" not in request_payload:
request_payload["model"] = (get_vertex_model_id_from_url(url_route) if google_provider else None) or ""
request_payload["model"] = _passthrough_google_model(url_route, google_provider) or ""
resolved_provider: Final = custom_llm_provider or google_provider
if "custom_llm_provider" not in request_payload and resolved_provider:
request_payload["custom_llm_provider"] = resolved_provider

View file

@ -242,6 +242,7 @@ def test_extract_api_provider_prefers_declared_provider_over_model_name_inferenc
assert extract({"model": "gemini-3.8-flash"}) == "vertex_ai"
assert extract({"model": "gemini-3.8-flash", "custom_llm_provider": "gemini"}) == "gemini"
assert extract({"model": "gemini-3.8-flash", "custom_llm_provider": "vertex_ai"}) == "vertex_ai"
assert (
extract(
{
@ -253,6 +254,41 @@ def test_extract_api_provider_prefers_declared_provider_over_model_name_inferenc
)
@pytest.mark.parametrize(
"declared_provider",
["client-supplied-value", "", {"nested": "object"}, 42],
)
def test_extract_api_provider_ignores_declared_values_outside_the_known_provider_set(declared_provider: object):
from litellm.integrations.prometheus import PrometheusLogger
extract = PrometheusLogger._extract_api_provider_from_request_data
assert extract({"model": "gpt-4o-mini", "custom_llm_provider": declared_provider}) == "openai"
assert extract({"model": "gpt-4o-mini", "litellm_params": {"custom_llm_provider": declared_provider}}) == "openai"
assert extract({"model": "no-such-model", "custom_llm_provider": declared_provider}) is None
assert (
extract(
{
"litellm_params": {"custom_llm_provider": declared_provider},
"standard_logging_object": {"custom_llm_provider": "azure"},
}
)
== "azure"
)
def test_extract_api_provider_keeps_registered_custom_providers(monkeypatch: pytest.MonkeyPatch):
import litellm
from litellm.integrations.prometheus import PrometheusLogger
extract = PrometheusLogger._extract_api_provider_from_request_data
assert extract({"custom_llm_provider": "my-custom-llm"}) is None
monkeypatch.setattr(litellm, "provider_list", [*litellm.provider_list, "my-custom-llm"])
assert extract({"custom_llm_provider": "my-custom-llm"}) == "my-custom-llm"
assert extract({"litellm_params": {"custom_llm_provider": "my-custom-llm"}}) == "my-custom-llm"
def test_extract_api_provider_swallows_unknown_model_but_logs_unexpected_errors():
"""
get_llm_provider raises BadRequestError for a model that maps to no

View file

@ -17,32 +17,33 @@ from fastapi.responses import StreamingResponse
from starlette.datastructures import FormData, Headers, QueryParams
from starlette.datastructures import UploadFile as StarletteUploadFile
import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.vertex_passthrough_logging_handler import (
VertexPassthroughLoggingHandler,
)
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
DEFAULT_PASS_THROUGH_REQUEST_TIMEOUT_SECONDS,
LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY,
HttpPassThroughEndpointHelpers,
InitPassThroughEndpointHelpers,
LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY,
_build_passthrough_failure_request_payload,
_registered_pass_through_routes,
create_pass_through_route,
initialize_pass_through_endpoints,
pass_through_request,
resolve_pass_through_request_timeout,
resolve_llm_passthrough_timeout,
resolve_pass_through_request_timeout,
websocket_passthrough_request,
)
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY,
)
from litellm.proxy.pass_through_endpoints.success_handler import (
PassThroughEndpointLogging,
)
import litellm
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY,
)
MESSAGE_START_SSE_FRAME = b'event: message_start\ndata: {"type": "message_start"}\n\n'
@ -5812,6 +5813,14 @@ _VERTEX_FLASH_TARGET = (
"https://us-east5-aiplatform.googleapis.com/v1/projects/p/locations/us-east5"
"/publishers/google/models/gemini-3.8-flash:generateContent"
)
_VERTEX_CLAUDE_RAW_PREDICT_TARGET = (
"https://us-east5-aiplatform.googleapis.com/v1/projects/p/locations/us-east5"
"/publishers/anthropic/models/claude-opus-5:rawPredict"
)
_VERTEX_SEARCH_TARGET = (
"https://discoveryengine.googleapis.com/v1/projects/p/locations/global"
"/dataStores/default/servingConfigs/default:search"
)
async def _run_passthrough_failure(
@ -5871,6 +5880,15 @@ def _upstream_400(target: str) -> httpx.Response:
)
def _upstream_400_as_event_stream(target: str) -> httpx.Response:
return httpx.Response(
status_code=400,
headers={"content-type": "text/event-stream"},
content=b'data: {"error": {"code": 400, "status": "INVALID_ARGUMENT"}}\n\n',
request=httpx.Request("POST", target),
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"target, custom_llm_provider, stream, expected_provider",
@ -5890,11 +5908,27 @@ async def test_passthrough_upstream_error_logs_model_from_url(
@pytest.mark.asyncio
async def test_passthrough_internal_failure_logs_model_from_url():
@pytest.mark.parametrize(
"target, custom_llm_provider, expected_provider",
[
(_GEMINI_FLASH_TARGET, "gemini", "gemini"),
(_VERTEX_FLASH_TARGET, None, "vertex_ai"),
],
)
async def test_passthrough_detected_stream_upstream_error_logs_model_from_url(
target: str, custom_llm_provider: str | None, expected_provider: str
):
failure = await _run_passthrough_failure(
_GEMINI_FLASH_TARGET, "gemini", httpx.ReadTimeout("upstream timed out")
target, custom_llm_provider, _upstream_400_as_event_stream(target), stream=False
)
assert failure["request_data"]["model"] == "gemini-3.8-flash"
assert failure["request_data"]["custom_llm_provider"] == expected_provider
@pytest.mark.asyncio
async def test_passthrough_internal_failure_logs_model_from_url():
failure = await _run_passthrough_failure(_GEMINI_FLASH_TARGET, "gemini", httpx.ReadTimeout("upstream timed out"))
assert failure["request_data"]["model"] == "gemini-3.8-flash"
assert failure["request_data"]["custom_llm_provider"] == "gemini"
@ -5908,6 +5942,7 @@ def _upstream_dropping_stream(target: str) -> httpx.Response:
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [True, False])
@pytest.mark.parametrize(
"target, custom_llm_provider, expected_provider",
[
@ -5916,10 +5951,10 @@ def _upstream_dropping_stream(target: str) -> httpx.Response:
],
)
async def test_passthrough_mid_stream_drop_logs_model_from_url(
target: str, custom_llm_provider: str | None, expected_provider: str
target: str, custom_llm_provider: str | None, expected_provider: str, stream: bool
):
failure = await _run_passthrough_failure(
target, custom_llm_provider, _upstream_dropping_stream(target), stream=True
target, custom_llm_provider, _upstream_dropping_stream(target), stream=stream
)
assert isinstance(failure["original_exception"], httpx.ReadError)
assert failure["request_data"]["model"] == "gemini-3.8-flash"
@ -5934,6 +5969,23 @@ async def test_passthrough_mid_stream_drop_logs_model_from_url(
({"contents": []}, _VERTEX_FLASH_TARGET, None, "gemini-3.8-flash", "vertex_ai"),
({"contents": []}, _VERTEX_FLASH_TARGET, "anthropic", "gemini-3.8-flash", "anthropic"),
({"custom_llm_provider": "from-body"}, _VERTEX_FLASH_TARGET, None, "gemini-3.8-flash", "from-body"),
({"contents": []}, _GEMINI_FLASH_TARGET, None, "gemini-3.8-flash", "gemini"),
({}, _VERTEX_CLAUDE_RAW_PREDICT_TARGET, None, "vertex_ai/claude-opus-5", "vertex_ai"),
(
{},
_VERTEX_CLAUDE_RAW_PREDICT_TARGET.replace(":rawPredict", ":streamRawPredict"),
None,
"vertex_ai/claude-opus-5",
"vertex_ai",
),
({}, _VERTEX_SEARCH_TARGET, None, "vertex_ai/search_api", "vertex_ai"),
(
{},
"https://us-east5-aiplatform.googleapis.com/v1/projects/p/locations/us-east5/batchPredictionJobs",
None,
"",
"vertex_ai",
),
({"contents": []}, "https://api.example.com/v1/models/gpt-x:thing", None, "", None),
(None, None, None, "", None),
],
@ -5954,3 +6006,20 @@ def test_build_passthrough_failure_request_payload_model_and_provider_precedence
)
assert request_payload["model"] == expected_model
assert request_payload.get("custom_llm_provider") == expected_provider
@pytest.mark.parametrize(
"url_route, expected_model",
[
(_VERTEX_FLASH_TARGET, "gemini-3.8-flash"),
(_VERTEX_FLASH_TARGET.replace(":generateContent", ":streamGenerateContent?alt=sse"), "gemini-3.8-flash"),
(_VERTEX_FLASH_TARGET.replace(":generateContent", ":predict"), "gemini-3.8-flash"),
(_VERTEX_FLASH_TARGET.replace(":generateContent", ":embedContent"), "gemini-3.8-flash"),
(_VERTEX_CLAUDE_RAW_PREDICT_TARGET, "vertex_ai/claude-opus-5"),
(_VERTEX_CLAUDE_RAW_PREDICT_TARGET.replace(":rawPredict", ":streamRawPredict"), "vertex_ai/claude-opus-5"),
(_VERTEX_SEARCH_TARGET, "vertex_ai/search_api"),
("https://us-east5-aiplatform.googleapis.com/v1/projects/p/locations/us-east5/batchPredictionJobs", None),
],
)
def test_vertex_failure_model_name_matches_success_handler_naming(url_route: str, expected_model: str | None):
assert VertexPassthroughLoggingHandler.model_from_url_route(url_route) == expected_model