- Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "}
+ Alerts are sent to any Slack-compatible incoming webhook URL (Slack, Rocket.Chat, Mattermost, etc.). Get
+ Slack webhook urls from{" "}
here
@@ -532,7 +533,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID,
- Slack Webhook URL
+ Webhook URL (Slack-compatible)
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 137c67e837c..8a564a07489 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -25182,7 +25182,7 @@ export interface components {
alert_types?: components["schemas"]["AlertType"][] | null;
/**
* Alerting
- * @description List of alerting integrations. Today, just slack - `alerting: ['slack']`
+ * @description List of alerting integrations - e.g. `alerting: ['slack', 'webhook', 'email']`. 'slack' posts Slack-format messages to any Slack-compatible webhook (Slack, Rocket.Chat, Mattermost); 'webhook' posts structured JSON budget alerts to WEBHOOK_URL
*/
alerting?: unknown[] | null;
/**
From 30f32285103cea768c55f2f380a6422792363c91 Mon Sep 17 00:00:00 2001
From: yucheng-berri
Date: Mon, 31 Aug 2026 09:58:35 -0700
Subject: [PATCH 27/33] test(newrelic): cover static default_team_settings
per-team routing (#38857)
* test(newrelic): cover static default_team_settings per-team routing
The dynamic POST /team/{team_id}/callback path for New Relic is tested, but
the static default_team_settings twin had no regression coverage. Add a test
that drives default_team_settings -> add_team_based_callbacks_from_config and
asserts the resolved trusted vars dispatch to BOTH the per-team metrics logger
(cost/usage) and the trace logger (LLM/agent spans), so a config-file customer
gets the same per-team routing as the API customer.
Also correct the /team/callback docstring: callback_name is a str validated
against the credential-capable callbacks, not a fixed langfuse/langsmith/gcs
Literal, and document the newrelic_api_key / newrelic_region vars.
* chore(ui): sync schema.d.ts with the /team/callback docstring
Regenerate the dashboard OpenAPI types for the add_team_callbacks description
change: callback_name is a validated str (not a langfuse/langsmith/gcs
Literal) and the newrelic_api_key / newrelic_region vars are documented.
* docs(newrelic): note LITELLM_OTEL_V2 prerequisite, trim test comments
Address review: team-scoped New Relic config is rejected with a 400 unless the
proxy runs with LITELLM_OTEL_V2=true, so document that in the /team/callback
endpoint and sync schema.d.ts. Drop the narrative setup comments in the new
test per the repo comment convention; the test name and docstring already say why.
---
.../team_callback_endpoints.py | 4 +-
tests/proxy_unit_tests/test_proxy_utils.py | 56 +++++++++++++++++++
ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 +-
3 files changed, 62 insertions(+), 2 deletions(-)
diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py
index 08346983f32..c2f5dbb4032 100644
--- a/litellm/proxy/management_endpoints/team_callback_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py
@@ -252,7 +252,7 @@ async def add_team_callbacks(
Use this if if you want different teams to have different success/failure callbacks
Parameters:
- - callback_name (Literal["langfuse", "langsmith", "gcs"], required): The name of the callback to add
+ - callback_name (str, required): The name of the callback to add, e.g. "langfuse", "langsmith", "gcs", "newrelic". The value is validated against the callbacks that support team-scoped credentials
- callback_type (Literal["success", "failure", "success_and_failure"], required): The type of callback to add. One of:
- "success": Callback for successful LLM calls
- "failure": Callback for failed LLM calls
@@ -268,6 +268,8 @@ async def add_team_callbacks(
- langsmith_api_key: The API key for the Langsmith callback
- langsmith_project: The project for the Langsmith callback
- langsmith_base_url: The base URL for the Langsmith callback
+ - newrelic_api_key: The ingest license key for the team's New Relic account; routes both LLM/agent traces and cost metrics to that account. Requires the proxy to run with LITELLM_OTEL_V2=true, otherwise this callback is rejected with a 400
+ - newrelic_region: The New Relic region for the team's account ("us" or "eu"), riding the team's own key
Example curl:
```
diff --git a/tests/proxy_unit_tests/test_proxy_utils.py b/tests/proxy_unit_tests/test_proxy_utils.py
index 3bde72ccd49..35de9961054 100644
--- a/tests/proxy_unit_tests/test_proxy_utils.py
+++ b/tests/proxy_unit_tests/test_proxy_utils.py
@@ -1317,6 +1317,62 @@ def test_proxy_config_state_post_init_callback_call(monkeypatch):
assert config["litellm_settings"]["default_team_settings"][0]["team_id"] == "test"
+@pytest.mark.asyncio
+async def test_default_team_settings_newrelic_resolves_traces_and_metrics():
+ """Static `default_team_settings` is the config-file twin of POST /team/callback.
+
+ A team pinned to New Relic through `default_team_settings` must reach the
+ same two loggers the dynamic path does: the per-team metrics logger (cost
+ and usage) and the trace logger (LLM/agent spans). This proves the static
+ path resolves both, not just one, so the config-file customer gets the
+ same per-team routing as the API customer.
+ """
+ from litellm.litellm_core_utils.litellm_logging import Logging
+ from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
+ from litellm.proxy.proxy_server import ProxyConfig
+
+ pc = ProxyConfig()
+ pc.config = {
+ "litellm_settings": {
+ "default_team_settings": [
+ {
+ "team_id": "team-a",
+ "success_callback": ["newrelic"],
+ "newrelic_api_key": "team-a-ingest-key",
+ "newrelic_region": "eu",
+ }
+ ]
+ }
+ }
+
+ callback_metadata = LiteLLMProxyRequestSetup.add_team_based_callbacks_from_config(
+ team_id="team-a",
+ proxy_config=pc,
+ )
+
+ assert callback_metadata is not None
+ assert callback_metadata.success_callback == ["newrelic"]
+ assert callback_metadata.callback_vars == {
+ "newrelic_api_key": "team-a-ingest-key",
+ "newrelic_region": "eu",
+ }
+
+ logging_obj = Logging(
+ model="gpt-3.5-turbo",
+ messages=[{"role": "user", "content": "hi"}],
+ stream=False,
+ call_type="completion",
+ start_time=None,
+ litellm_call_id="static-nr-1",
+ function_id="static-nr-1",
+ )
+ logging_obj._trusted_callback_vars = tuple(callback_metadata.callback_vars.items())
+
+ resolved = logging_obj._resolve_dynamic_callback_string("newrelic")
+ resolved_names = {type(logger).__name__ for logger in resolved}
+ assert resolved_names == {"NewRelicMetricsLogger", "NewRelicLogger"}
+
+
def test_proxy_config_state_get_config_state_error():
"""
Ensures that get_config_state does not raise an error when the config is not a valid dictionary
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 8a564a07489..81a2fefe32e 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -15505,7 +15505,7 @@ export interface paths {
* Use this if if you want different teams to have different success/failure callbacks
*
* Parameters:
- * - callback_name (Literal["langfuse", "langsmith", "gcs"], required): The name of the callback to add
+ * - callback_name (str, required): The name of the callback to add, e.g. "langfuse", "langsmith", "gcs", "newrelic". The value is validated against the callbacks that support team-scoped credentials
* - callback_type (Literal["success", "failure", "success_and_failure"], required): The type of callback to add. One of:
* - "success": Callback for successful LLM calls
* - "failure": Callback for failed LLM calls
@@ -15521,6 +15521,8 @@ export interface paths {
* - langsmith_api_key: The API key for the Langsmith callback
* - langsmith_project: The project for the Langsmith callback
* - langsmith_base_url: The base URL for the Langsmith callback
+ * - newrelic_api_key: The ingest license key for the team's New Relic account; routes both LLM/agent traces and cost metrics to that account. Requires the proxy to run with LITELLM_OTEL_V2=true, otherwise this callback is rejected with a 400
+ * - newrelic_region: The New Relic region for the team's account ("us" or "eu"), riding the team's own key
*
* Example curl:
* ```
From e938e89d138eec2ef20a998e09d200dfb44b71a1 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Mon, 31 Aug 2026 11:19:11 -0700
Subject: [PATCH 28/33] docs(proxy): account for budget rollover and daily
upserts in spend wording
---
.../internal_user_endpoints.py | 25 +++++++++++--------
ui/litellm-dashboard/src/lib/http/schema.d.ts | 25 +++++++++++--------
2 files changed, 28 insertions(+), 22 deletions(-)
diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py
index 73e993b37a1..6edb75eacb4 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -997,12 +997,13 @@ async def user_info_v2(
This is the v2 replacement for /user/info, designed to avoid the "god endpoint" problem
where the old endpoint loaded all keys and teams into memory.
- Note on `spend`: this is the user's running budget counter, which is zeroed by the
- budget reset job whenever `budget_reset_at` elapses (see `budget_duration`). It is NOT
+ Note on `spend`: this is the user's running budget counter, which the budget reset job
+ resets whenever `budget_reset_at` elapses (see `budget_duration`): to zero by default,
+ or to the overage above `max_budget` when `budget_rollover` is enabled. It is NOT
lifetime or per-period historical spend. For historical spend over a date range, use
- `/user/daily/activity` or `/user/daily/activity/aggregated`, which read immutable daily
- spend records that are never reset. The two values are expected to diverge once a
- budget reset has occurred within the queried period.
+ `/user/daily/activity` or `/user/daily/activity/aggregated`, which read daily spend
+ records that only ever accumulate and are never reset. The two values are expected to
+ diverge once a budget reset has occurred within the queried period.
Access control:
- Proxy admins can query any user
@@ -2694,9 +2695,10 @@ async def get_user_daily_activity(
Meant to optimize querying spend data for analytics for a user.
- Reads immutable daily spend records, which are never affected by budget resets.
- This can legitimately exceed the `spend` field returned by `/v2/user/info`, which
- is a running budget counter zeroed on every budget reset.
+ Reads daily spend records that only ever accumulate and are never affected by budget
+ resets. Their total can legitimately exceed the `spend` field returned by
+ `/v2/user/info`, which is a running budget counter that every budget reset sets back
+ to zero (or to the overage above `max_budget` when `budget_rollover` is enabled).
Returns:
(by date)
@@ -2812,9 +2814,10 @@ async def get_user_daily_activity_aggregated(
Aggregated analytics for a user's daily activity without pagination.
Returns the same response shape as the paginated endpoint with page metadata set to single-page.
- Reads immutable daily spend records, which are never affected by budget resets.
- This can legitimately exceed the `spend` field returned by `/v2/user/info`, which
- is a running budget counter zeroed on every budget reset.
+ Reads daily spend records that only ever accumulate and are never affected by budget
+ resets. Their total can legitimately exceed the `spend` field returned by
+ `/v2/user/info`, which is a running budget counter that every budget reset sets back
+ to zero (or to the overage above `max_budget` when `budget_rollover` is enabled).
"""
from litellm.proxy.proxy_server import prisma_client
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index b4526834b98..1599db10c7c 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -16210,9 +16210,10 @@ export interface paths {
*
* Meant to optimize querying spend data for analytics for a user.
*
- * Reads immutable daily spend records, which are never affected by budget resets.
- * This can legitimately exceed the `spend` field returned by `/v2/user/info`, which
- * is a running budget counter zeroed on every budget reset.
+ * Reads daily spend records that only ever accumulate and are never affected by budget
+ * resets. Their total can legitimately exceed the `spend` field returned by
+ * `/v2/user/info`, which is a running budget counter that every budget reset sets back
+ * to zero (or to the overage above `max_budget` when `budget_rollover` is enabled).
*
* Returns:
* (by date)
@@ -16246,9 +16247,10 @@ export interface paths {
* @description Aggregated analytics for a user's daily activity without pagination.
* Returns the same response shape as the paginated endpoint with page metadata set to single-page.
*
- * Reads immutable daily spend records, which are never affected by budget resets.
- * This can legitimately exceed the `spend` field returned by `/v2/user/info`, which
- * is a running budget counter zeroed on every budget reset.
+ * Reads daily spend records that only ever accumulate and are never affected by budget
+ * resets. Their total can legitimately exceed the `spend` field returned by
+ * `/v2/user/info`, which is a running budget counter that every budget reset sets back
+ * to zero (or to the overage above `max_budget` when `budget_rollover` is enabled).
*/
get: operations["get_user_daily_activity_aggregated_user_daily_activity_aggregated_get"];
put?: never;
@@ -21012,12 +21014,13 @@ export interface paths {
* This is the v2 replacement for /user/info, designed to avoid the "god endpoint" problem
* where the old endpoint loaded all keys and teams into memory.
*
- * Note on `spend`: this is the user's running budget counter, which is zeroed by the
- * budget reset job whenever `budget_reset_at` elapses (see `budget_duration`). It is NOT
+ * Note on `spend`: this is the user's running budget counter, which the budget reset job
+ * resets whenever `budget_reset_at` elapses (see `budget_duration`): to zero by default,
+ * or to the overage above `max_budget` when `budget_rollover` is enabled. It is NOT
* lifetime or per-period historical spend. For historical spend over a date range, use
- * `/user/daily/activity` or `/user/daily/activity/aggregated`, which read immutable daily
- * spend records that are never reset. The two values are expected to diverge once a
- * budget reset has occurred within the queried period.
+ * `/user/daily/activity` or `/user/daily/activity/aggregated`, which read daily spend
+ * records that only ever accumulate and are never reset. The two values are expected to
+ * diverge once a budget reset has occurred within the queried period.
*
* Access control:
* - Proxy admins can query any user
From cf1b431d58264399c0cf6f7a53e7bfd73b8560b3 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Mon, 31 Aug 2026 11:50:45 -0700
Subject: [PATCH 29/33] fix(bedrock): stop duplicating Converse config blocks
inside inferenceConfig
---
.../bedrock/chat/converse_transformation.py | 9 ++++--
.../chat/test_converse_transformation.py | 30 ++++++++++++++-----
2 files changed, 29 insertions(+), 10 deletions(-)
diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py
index db9c8a5cedd..395d99a4caa 100644
--- a/litellm/llms/bedrock/chat/converse_transformation.py
+++ b/litellm/llms/bedrock/chat/converse_transformation.py
@@ -1631,6 +1631,11 @@ class AmazonConverseConfig(BaseConfig):
bedrock_tool_config["toolChoice"] = tool_choice_values
self._drop_tool_choice_type_conflicting_with_tool_config(additional_request_params)
+ config_block_entries: Final = tuple(
+ (config_name, config_class, inference_params.pop(config_name, None))
+ for config_name, config_class in self.get_config_blocks().items()
+ )
+
data: Final[CommonRequestObject] = {
"inferenceConfig": self._transform_inference_params(inference_params=inference_params),
}
@@ -1641,9 +1646,7 @@ class AmazonConverseConfig(BaseConfig):
if system_content_blocks:
data["system"] = system_content_blocks
- # Handle all config blocks
- for config_name, config_class in self.get_config_blocks().items():
- config_value = inference_params.pop(config_name, None)
+ for config_name, config_class, config_value in config_block_entries:
if config_value is not None:
data[config_name] = config_class(**config_value)
diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py
index 226bba6826a..63f895e1819 100644
--- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py
+++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py
@@ -957,6 +957,28 @@ def test_transform_request_helper_includes_anthropic_beta_and_tools():
assert fields["tools"][0]["type"] == "computer_20250124"
+def test_config_blocks_do_not_leak_into_inference_config():
+ """Regression: inferenceConfig was built before the config blocks were popped, so a dead
+ nested copy of each block (guardrailConfig, performanceConfig, serviceTier) rode inside
+ inferenceConfig alongside the real top-level one."""
+ data = AmazonConverseConfig()._transform_request_helper(
+ model="anthropic.claude-haiku-4-5-20251001-v1:0",
+ system_content_blocks=[],
+ optional_params={
+ "maxTokens": 100,
+ "guardrailConfig": {"guardrailIdentifier": "gr-id", "guardrailVersion": "DRAFT"},
+ "performanceConfig": {"latency": "optimized"},
+ "serviceTier": {"type": "priority"},
+ },
+ messages=[{"role": "user", "content": "hi"}],
+ )
+
+ assert data["inferenceConfig"] == {"maxTokens": 100}
+ assert data["guardrailConfig"] == {"guardrailIdentifier": "gr-id", "guardrailVersion": "DRAFT"}
+ assert data["performanceConfig"] == {"latency": "optimized"}
+ assert data["serviceTier"] == {"type": "priority"}
+
+
def test_parallel_tool_calls_config_kept_for_sonnet_5(monkeypatch):
old_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP")
old_cost = litellm.model_cost
@@ -2853,17 +2875,11 @@ def test_guarded_text_guardrail_config_preserved():
headers={},
)
- # GuardrailConfig should be present at top level
assert "guardrailConfig" in result
assert result["guardrailConfig"]["guardrailIdentifier"] == "gr-abc123"
- # GuardrailConfig should also be in inferenceConfig
assert "inferenceConfig" in result
- assert "guardrailConfig" in result["inferenceConfig"]
- assert (
- result["inferenceConfig"]["guardrailConfig"]["guardrailIdentifier"]
- == "gr-abc123"
- )
+ assert "guardrailConfig" not in result["inferenceConfig"]
def test_auto_convert_last_user_message_to_guarded_text():
From 9f67a58198ec2b2d992a88812e2fae3c304bfd2e Mon Sep 17 00:00:00 2001
From: davida-ps
Date: Mon, 31 Aug 2026 22:05:57 +0300
Subject: [PATCH 30/33] fix(guardrails): configure Prompt Security file timeout
policy (#38083)
* fix(guardrails): fail open on Prompt Security file timeouts
* fix(guardrails): configure Prompt Security timeout policy
---
.../prompt_security/__init__.py | 1 +
.../prompt_security/prompt_security.py | 49 +++++++++
.../guardrail_hooks/prompt_security.py | 4 +
.../test_prompt_security_guardrails.py | 103 ++++++++++++++++--
4 files changed, 148 insertions(+), 9 deletions(-)
diff --git a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/__init__.py
index fa1f9f3d36d..0aaba4016cd 100644
--- a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/__init__.py
+++ b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/__init__.py
@@ -20,6 +20,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"
guardrail_name=guardrail.get("guardrail_name", ""),
event_hook=litellm_params.mode,
default_on=litellm_params.default_on,
+ file_sanitization_fail_open=getattr(litellm_params, "file_sanitization_fail_open", None),
)
litellm.logging_callback_manager.add_litellm_callback(_prompt_security_callback)
diff --git a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py
index 809d5e0fb31..84c4f118b00 100644
--- a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py
+++ b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py
@@ -4,10 +4,12 @@ import os
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Final, Literal, Optional
+import httpx
from fastapi import HTTPException
from typing_extensions import ReadOnly, TypedDict
from litellm._logging import verbose_proxy_logger
+from litellm.exceptions import Timeout as LiteLLMTimeout
from litellm.integrations.custom_guardrail import (
CustomGuardrail,
log_guardrail_information,
@@ -24,6 +26,9 @@ if TYPE_CHECKING:
from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
+_SANITIZE_FILE_FAIL_OPEN_TIMEOUT_SECONDS: Final = 30.0
+
+
class PromptSecurityGuardrailMissingSecrets(Exception):
pass
@@ -63,6 +68,13 @@ class _SanitizeStatusResponse(TypedDict, total=False):
metadata: ReadOnly[_SanitizeMetadata]
+class _SanitizeResult(TypedDict):
+ action: ReadOnly[str]
+ content: ReadOnly[str | None]
+ metadata: ReadOnly[_SanitizeMetadata]
+ violations: ReadOnly[Sequence[str]]
+
+
class PromptSecurityGuardrail(CustomGuardrail):
@classmethod
def get_supported_event_hooks(cls) -> list[GuardrailEventHooks]:
@@ -79,6 +91,8 @@ class PromptSecurityGuardrail(CustomGuardrail):
user: str | None = None,
system_prompt: str | None = None,
check_tool_results: bool | None = None,
+ file_sanitization_timeout: float = _SANITIZE_FILE_FAIL_OPEN_TIMEOUT_SECONDS,
+ file_sanitization_fail_open: bool | None = None,
**kwargs,
):
kwargs.setdefault("supported_event_hooks", list(self.get_supported_event_hooks()))
@@ -108,6 +122,8 @@ class PromptSecurityGuardrail(CustomGuardrail):
# Configuration for file sanitization
self.max_poll_attempts = 30 # Maximum number of polling attempts
self.poll_interval = 2 # Seconds between polling attempts
+ self.file_sanitization_timeout = file_sanitization_timeout
+ self.file_sanitization_fail_open = file_sanitization_fail_open is not False
super().__init__(**kwargs)
@@ -397,6 +413,39 @@ class PromptSecurityGuardrail(CustomGuardrail):
Sanitize file content using Prompt Security API.
Returns: dict with keys 'action', 'content', 'metadata'
"""
+ try:
+ return await asyncio.wait_for(
+ self._sanitize_file_content(file_data, filename, user_api_key_alias),
+ timeout=self.file_sanitization_timeout,
+ )
+ except (asyncio.TimeoutError, httpx.TimeoutException, LiteLLMTimeout) as exc:
+ if not self.file_sanitization_fail_open:
+ verbose_proxy_logger.error(
+ "Prompt Security Guardrail: file sanitization for %s timed out with %s; failing closed",
+ filename,
+ type(exc).__name__,
+ )
+ raise HTTPException(status_code=408, detail="File sanitization timeout") from exc
+
+ verbose_proxy_logger.error(
+ "Prompt Security Guardrail: file sanitization for %s timed out with %s; failing open",
+ filename,
+ type(exc).__name__,
+ )
+ fail_open_result: Final[_SanitizeResult] = {
+ "action": "allow",
+ "content": None,
+ "metadata": {},
+ "violations": (),
+ }
+ return fail_open_result
+
+ async def _sanitize_file_content(
+ self,
+ file_data: bytes,
+ filename: str,
+ user_api_key_alias: str | None,
+ ) -> _SanitizeResult:
headers: Final = {"APP-ID": self.api_key}
if user_api_key_alias:
headers["X-LiteLLM-Key-Alias"] = user_api_key_alias
diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/prompt_security.py b/litellm/types/proxy/guardrails/guardrail_hooks/prompt_security.py
index 6e64f0f47a5..94f8161f44e 100644
--- a/litellm/types/proxy/guardrails/guardrail_hooks/prompt_security.py
+++ b/litellm/types/proxy/guardrails/guardrail_hooks/prompt_security.py
@@ -12,6 +12,10 @@ class PromptSecurityGuardrailConfigModel(GuardrailConfigModel):
default=None,
description="The API base for the Prompt Security guardrail. If not provided, the `PROMPT_SECURITY_API_BASE` environment variable is used.",
)
+ file_sanitization_fail_open: bool = Field(
+ default=True,
+ description="Whether file sanitization timeouts allow the original file through instead of blocking the request.",
+ )
@staticmethod
def ui_friendly_name() -> str:
diff --git a/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py b/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py
index 26beaa78a46..ab4e15ff423 100644
--- a/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py
+++ b/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py
@@ -1,16 +1,16 @@
-from fastapi.exceptions import HTTPException
-from unittest.mock import patch, AsyncMock
-from httpx import Response, Request
+import asyncio
import base64
+from unittest.mock import AsyncMock, patch
import pytest
-
-from litellm.proxy.guardrails.guardrail_hooks.prompt_security.prompt_security import (
- PromptSecurityGuardrailMissingSecrets,
- PromptSecurityGuardrail,
-)
+from fastapi.exceptions import HTTPException
+from httpx import ReadTimeout, Request, Response
import litellm
+from litellm.proxy.guardrails.guardrail_hooks.prompt_security.prompt_security import (
+ PromptSecurityGuardrail,
+ PromptSecurityGuardrailMissingSecrets,
+)
from litellm.proxy.guardrails.init_guardrails import init_guardrails_v2
@@ -30,6 +30,7 @@ def test_prompt_security_guard_config(monkeypatch: pytest.MonkeyPatch):
"guardrail": "prompt_security",
"mode": "during_call",
"default_on": True,
+ "file_sanitization_fail_open": False,
},
}
],
@@ -41,6 +42,10 @@ def test_prompt_security_guard_config(monkeypatch: pytest.MonkeyPatch):
assert registered[0].guardrail_name == "prompt_security"
assert registered[0].default_on is True
assert registered[0].event_hook == "during_call"
+ assert registered[0].file_sanitization_fail_open is False
+ config_model = registered[0].get_config_model()
+ assert config_model is not None
+ assert config_model().file_sanitization_fail_open is True
def test_prompt_security_guard_config_no_api_key(monkeypatch: pytest.MonkeyPatch):
@@ -374,6 +379,86 @@ async def test_file_sanitization(monkeypatch: pytest.MonkeyPatch):
assert result is not None
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "timeout",
+ (
+ litellm.Timeout(
+ message="Prompt Security upload timed out",
+ model="default-model-name",
+ llm_provider="litellm-httpx-handler",
+ ),
+ ReadTimeout(
+ "Prompt Security poll timed out",
+ request=Request(method="GET", url="https://test.prompt.security/api/sanitizeFile"),
+ ),
+ ),
+ ids=("litellm", "httpx"),
+)
+@pytest.mark.parametrize("fail_open", (True, False), ids=("fail-open", "fail-closed"))
+async def test_file_sanitization_request_timeout_policy(
+ monkeypatch: pytest.MonkeyPatch, timeout: Exception, fail_open: bool
+):
+ monkeypatch.setenv("PROMPT_SECURITY_API_KEY", "test-key")
+ monkeypatch.setenv("PROMPT_SECURITY_API_BASE", "https://test.prompt.security")
+
+ guardrail = PromptSecurityGuardrail(
+ guardrail_name="test-guard",
+ event_hook="pre_call",
+ default_on=True,
+ file_sanitization_fail_open=fail_open,
+ )
+
+ with patch.object(guardrail.async_handler, "post", AsyncMock(side_effect=timeout)):
+ if not fail_open:
+ with pytest.raises(HTTPException) as exc_info:
+ await guardrail.sanitize_file_content(b"file-content", "document.pdf")
+ assert exc_info.value.status_code == 408
+ assert exc_info.value.detail == "File sanitization timeout"
+ return
+
+ result = await guardrail.sanitize_file_content(b"file-content", "document.pdf")
+
+ assert result == {
+ "action": "allow",
+ "content": None,
+ "metadata": {},
+ "violations": (),
+ }
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("fail_open", (True, False), ids=("fail-open", "fail-closed"))
+async def test_file_sanitization_overall_timeout_policy(monkeypatch: pytest.MonkeyPatch, fail_open: bool):
+ monkeypatch.setenv("PROMPT_SECURITY_API_KEY", "test-key")
+ monkeypatch.setenv("PROMPT_SECURITY_API_BASE", "https://test.prompt.security")
+
+ guardrail = PromptSecurityGuardrail(
+ guardrail_name="test-guard",
+ event_hook="pre_call",
+ default_on=True,
+ file_sanitization_timeout=0.01,
+ file_sanitization_fail_open=fail_open,
+ )
+
+ async def hanging_post(*_args: object, **_kwargs: object) -> None:
+ await asyncio.sleep(60)
+ raise AssertionError("sanitization request should have been cancelled")
+
+ with patch.object(guardrail.async_handler, "post", side_effect=hanging_post):
+ if not fail_open:
+ with pytest.raises(HTTPException) as exc_info:
+ await guardrail.sanitize_file_content(b"file-content", "document.pdf")
+ assert exc_info.value.status_code == 408
+ assert exc_info.value.detail == "File sanitization timeout"
+ return
+
+ result = await guardrail.sanitize_file_content(b"file-content", "document.pdf")
+
+ assert result["action"] == "allow"
+ assert result["content"] is None
+
+
@pytest.mark.asyncio
async def test_file_sanitization_block(monkeypatch: pytest.MonkeyPatch):
"""Test that file sanitization blocks malicious files"""
@@ -544,7 +629,7 @@ async def test_role_filtering(monkeypatch: pytest.MonkeyPatch):
return mock_response
with patch.object(guardrail.async_handler, "post", side_effect=mock_post):
- result = await guardrail.apply_guardrail(
+ await guardrail.apply_guardrail(
inputs=inputs,
request_data=request_data,
input_type="request",
From 9f9236e8d5f2485c6e4f1638f1cb1e3b471262e2 Mon Sep 17 00:00:00 2001
From: Ashton Sidhu
Date: Mon, 31 Aug 2026 15:50:42 -0400
Subject: [PATCH 31/33] fix(guardrails): exclude images from HiddenLayer v1
scans (#29210)
* Don't scan images
* Fix failing tests
* Fix lint: typed image-part filter, restore monkeypatch-based tests
---------
Co-authored-by: Yucheng Zhu
---
.../hiddenlayer/hiddenlayer.py | 27 ++++++++++++++++++-
.../guardrail_hooks/test_hiddenlayer.py | 7 ++---
2 files changed, 30 insertions(+), 4 deletions(-)
diff --git a/litellm/proxy/guardrails/guardrail_hooks/hiddenlayer/hiddenlayer.py b/litellm/proxy/guardrails/guardrail_hooks/hiddenlayer/hiddenlayer.py
index a6ea2e09583..68914a1989e 100644
--- a/litellm/proxy/guardrails/guardrail_hooks/hiddenlayer/hiddenlayer.py
+++ b/litellm/proxy/guardrails/guardrail_hooks/hiddenlayer/hiddenlayer.py
@@ -156,6 +156,31 @@ def _header_value(headers: Mapping[str, str], key: str, default: str) -> str:
return headers.get(key, default)
+def _is_image_part(item: object) -> bool:
+ """Whether a structured-message content part carries an image rather than text."""
+
+ if not isinstance(item, Mapping):
+ return False
+
+ part: Final[Mapping[object, object]] = item
+ return part.get("type") == "image_url"
+
+
+def _scannable_text(content: object) -> str:
+ """Flatten a structured message's content into the single string the v1 detection endpoint takes.
+
+ Image parts are dropped: the endpoint accepts one string, so an image would only reach it as
+ its stringified source (a base64 blob or a URL), which is not text the scanner can evaluate.
+ """
+
+ if not isinstance(content, list):
+ return str(content or "")
+
+ parts: Final[Sequence[object]] = content
+ text_parts: Final = [item for item in parts if not _is_image_part(item)] # mutable-ok: sent as a list repr
+ return str(text_parts or "")
+
+
def is_saas(host: str) -> bool:
"""Checks whether the connection is to the SaaS platform"""
@@ -270,7 +295,7 @@ class HiddenlayerGuardrail(CustomGuardrail):
"messages": [
{
"role": last_msg.get("role", "user"),
- "content": str(last_msg.get("content", "")),
+ "content": _scannable_text(last_msg.get("content")),
}
]
},
diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_hiddenlayer.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_hiddenlayer.py
index b140082a3bf..f5d51a601d7 100644
--- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_hiddenlayer.py
+++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_hiddenlayer.py
@@ -432,7 +432,7 @@ class TestHiddenlayerGuardrail:
@pytest.mark.asyncio
async def test_apply_guardrail_request_with_image(self, monkeypatch: pytest.MonkeyPatch):
- """Test apply_guardrail sends multimodal content (image) to HiddenLayer v1."""
+ """Test apply_guardrail strips images from multimodal content before sending to HiddenLayer v1."""
monkeypatch.setenv("HIDDENLAYER_API_BASE", "https://my.hiddenlayer")
guardrail = HiddenlayerGuardrail(
@@ -485,12 +485,13 @@ class TestHiddenlayerGuardrail:
logging_obj=logging_obj,
)
- # v1 API requires string content — multimodal list is stringified
+ # v1 API requires string content — image_url items are stripped and the
+ # remaining (text-only) content is stringified before being sent.
mock_post.assert_called_once()
call_kwargs = mock_post.call_args.kwargs
sent_content = call_kwargs["json"]["input"]["messages"][0]["content"]
assert isinstance(sent_content, str)
- assert sent_content == str(multimodal_content)
+ assert sent_content == str([{"type": "text", "text": "how much is on this receipt?"}])
# Result should be returned without error
assert result is not None
From 0c21b30cb72aab7f56ab88bda47c00243aab1e0c Mon Sep 17 00:00:00 2001
From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com>
Date: Mon, 31 Aug 2026 12:52:34 -0700
Subject: [PATCH 32/33] feat(spend_tracking): persist router metadata in spend
logs for internal router models (#39001)
* feat(spend_tracking): persist router metadata in spend logs for internal router models
* test(spend_tracking): expect router_metadata key in exact-payload tests, type the routed-kwargs helper
---
litellm/proxy/_types.py | 14 ++++
.../spend_tracking/spend_tracking_utils.py | 60 ++++++++++++----
litellm/types/router.py | 5 ++
.../test_spend_management_endpoints.py | 6 +-
.../test_spend_tracking_utils.py | 69 +++++++++++++++++++
ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +
6 files changed, 139 insertions(+), 17 deletions(-)
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 0f2d97b8b1c..a84fae7fd23 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -3549,6 +3549,19 @@ class AllCallbacks(LiteLLMPydanticObjectBase):
)
+class SpendLogsRouterMetadata(TypedDict):
+ """
+ Router provenance stamped on spend logs for deployments flagged with
+ model_info.internal_router_model, correlating the requested model group
+ with the provider deployment that served the call
+ """
+
+ requested_model: ReadOnly[str | None]
+ selected_model: ReadOnly[str | None]
+ selected_provider: ReadOnly[str | None]
+ router_correlation_id: ReadOnly[str | None]
+
+
class SpendLogsMetadata(TypedDict):
"""
Specific metadata k,v pairs logged to spendlogs for easier cost tracking
@@ -3591,6 +3604,7 @@ class SpendLogsMetadata(TypedDict):
compression_savings: CompressionSavingsMetadata | None
autorouter_savings: ReadOnly[float | None] # stamped by the logging payload; None = not auto-routed
litellm_gateway_injected_cache: ReadOnly[str | None]
+ router_metadata: ReadOnly[SpendLogsRouterMetadata | None] # None = deployment not flagged internal_router_model
class SpendLogsPayload(TypedDict):
diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py
index 43709e4e6ff..9f718b7d20d 100644
--- a/litellm/proxy/spend_tracking/spend_tracking_utils.py
+++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py
@@ -30,7 +30,7 @@ from litellm.litellm_core_utils.litellm_logging import (
request_model_access_groups_from_litellm_params,
)
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps, strip_null_bytes
-from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload
+from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload, SpendLogsRouterMetadata
from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
from litellm.proxy.utils import PrismaClient, hash_token
from litellm.types.utils import (
@@ -93,6 +93,24 @@ def _redact_logged_api_key(value: str | None, *, already_redacted: bool = False)
return hash_token(stripped)
+def _get_router_metadata_for_spend_log(
+ metadata: Mapping[str, object] | None,
+ requested_model: str | None,
+ selected_model: str | None,
+ selected_provider: str | None,
+ router_correlation_id: str | None,
+) -> SpendLogsRouterMetadata | None:
+ model_info: Final = metadata.get("model_info") if metadata is not None else None
+ if not isinstance(model_info, Mapping) or model_info.get("internal_router_model") is not True:
+ return None
+ return SpendLogsRouterMetadata(
+ requested_model=requested_model or None,
+ selected_model=selected_model or None,
+ selected_provider=selected_provider or None,
+ router_correlation_id=router_correlation_id,
+ )
+
+
def _get_spend_logs_metadata(
metadata: dict | None,
applied_guardrails: list[str] | None = None,
@@ -109,6 +127,7 @@ def _get_spend_logs_metadata(
cost_breakdown: CostBreakdown | None = None,
litellm_call_id: str | None = None,
autorouter_savings: float | None = None,
+ router_metadata: SpendLogsRouterMetadata | None = None,
) -> SpendLogsMetadata:
if metadata is None:
return SpendLogsMetadata(
@@ -148,13 +167,17 @@ def _get_spend_logs_metadata(
autorouter_savings=autorouter_savings,
litellm_gateway_injected_cache=None,
litellm_call_id=litellm_call_id,
+ router_metadata=router_metadata,
)
verbose_proxy_logger.debug(
"getting payload for SpendLogs, available keys in metadata: " + str(list(metadata.keys()))
)
# Filter the metadata dictionary to include only the specified keys
- clean_metadata: Final = SpendLogsMetadata(**{key: metadata.get(key) for key in SpendLogsMetadata.__annotations__})
+ clean_metadata: Final = SpendLogsMetadata(
+ **{key: metadata.get(key) for key in SpendLogsMetadata.__annotations__ if key != "router_metadata"},
+ router_metadata=router_metadata,
+ )
_raw_key: Final = clean_metadata.get("user_api_key")
_trusted_hash: Final = metadata.get("user_api_key_hash")
_already_redacted: Final = (
@@ -375,6 +398,20 @@ def get_logging_payload(kwargs, response_obj, start_time, end_time) -> SpendLogs
hidden_params: Final = standard_logging_payload.get("hidden_params", {})
litellm_overhead_time_ms = hidden_params.get("litellm_overhead_time_ms")
+ custom_llm_provider: Final = (
+ kwargs.get("custom_llm_provider")
+ or _sl_attribution_fallback(standard_logging_payload, "custom_llm_provider")
+ or None
+ )
+ raw_model: Final = cast(str, kwargs.get("model") or "")
+ model_name: Final = (
+ standard_logging_payload.get("model") if standard_logging_payload is not None else None
+ ) or reconstruct_model_name(raw_model, custom_llm_provider, metadata or {})
+ litellm_call_id: Final = cast(
+ str | None,
+ kwargs.get("litellm_call_id") or litellm_params.get("litellm_call_id"),
+ )
+
# clean up litellm metadata
clean_metadata = _get_spend_logs_metadata(
metadata,
@@ -433,9 +470,13 @@ def get_logging_payload(kwargs, response_obj, start_time, end_time) -> SpendLogs
autorouter_savings=(
standard_logging_payload.get("autorouter_savings", None) if standard_logging_payload is not None else None
),
- litellm_call_id=cast(
- str | None,
- kwargs.get("litellm_call_id") or litellm_params.get("litellm_call_id"),
+ litellm_call_id=litellm_call_id,
+ router_metadata=_get_router_metadata_for_spend_log(
+ metadata=metadata,
+ requested_model=_model_group,
+ selected_model=model_name,
+ selected_provider=custom_llm_provider,
+ router_correlation_id=litellm_call_id,
),
)
@@ -480,15 +521,6 @@ def get_logging_payload(kwargs, response_obj, start_time, end_time) -> SpendLogs
# Extract agent_id for A2A requests (set directly on model_call_details)
agent_id: Final[str | None] = kwargs.get("agent_id") or metadata.get("agent_id")
- custom_llm_provider: Final = (
- kwargs.get("custom_llm_provider")
- or _sl_attribution_fallback(standard_logging_payload, "custom_llm_provider")
- or None
- )
- raw_model: Final = cast(str, kwargs.get("model") or "")
- model_name: Final = (
- standard_logging_payload.get("model") if standard_logging_payload is not None else None
- ) or reconstruct_model_name(raw_model, custom_llm_provider, metadata or {})
try:
payload: Final[SpendLogsPayload] = SpendLogsPayload(
diff --git a/litellm/types/router.py b/litellm/types/router.py
index 97bd93f3f47..ab6c807ba20 100644
--- a/litellm/types/router.py
+++ b/litellm/types/router.py
@@ -189,6 +189,11 @@ class ModelInfo(MirroredPricingParams):
# router-wide default.
enable_tag_filtering: bool | None = None
+ # when True, calls routed to this deployment persist a router_metadata block
+ # (requested model group, selected model + provider, router correlation id)
+ # in the spend log row's metadata. Set it on every deployment of the group.
+ internal_router_model: bool | None = None
+
def __init__(self, id: str | int | None = None, **params) -> None:
if id is None:
id = str(uuid.uuid4()) # Generate a UUID if id is None or not provided
diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
index 10c3e5fecf8..a0dcbf802ef 100644
--- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
+++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
@@ -2865,7 +2865,7 @@ class TestSpendLogsPayload:
"model": "gpt-4o",
"user": "",
"team_id": "",
- "metadata": '{"applied_guardrails": [], "attempted_fallbacks": null, "original_model_group": null, "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "usage_object": {"completion_tokens": 20, "prompt_tokens": 10, "total_tokens": 30, "completion_tokens_details": null, "prompt_tokens_details": null}, "model_map_information": {"model_map_key": "gpt-4o", "model_map_value": {"key": "gpt-4o", "max_tokens": 16384, "max_input_tokens": 128000, "max_output_tokens": 16384, "input_cost_per_token": 2.5e-06, "cache_creation_input_token_cost": null, "cache_read_input_token_cost": 1.25e-06, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": 1.25e-06, "output_cost_per_token_batches": 5e-06, "output_cost_per_token": 1e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_reasoning_token": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "openai", "mode": "chat", "supports_system_messages": true, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": false, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": false, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": true, "supports_reasoning": false, "search_context_cost_per_query": {"search_context_size_low": 0.03, "search_context_size_medium": 0.035, "search_context_size_high": 0.05}, "tpm": null, "rpm": null, "supported_openai_params": ["frequency_penalty", "logit_bias", "logprobs", "top_logprobs", "max_tokens", "max_completion_tokens", "modalities", "prediction", "n", "presence_penalty", "seed", "stop", "stream", "stream_options", "temperature", "top_p", "tools", "tool_choice", "function_call", "functions", "max_retries", "extra_headers", "parallel_tool_calls", "audio", "response_format", "user"]}}, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": null}}',
+ "metadata": '{"applied_guardrails": [], "attempted_fallbacks": null, "original_model_group": null, "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "router_metadata": null, "usage_object": {"completion_tokens": 20, "prompt_tokens": 10, "total_tokens": 30, "completion_tokens_details": null, "prompt_tokens_details": null}, "model_map_information": {"model_map_key": "gpt-4o", "model_map_value": {"key": "gpt-4o", "max_tokens": 16384, "max_input_tokens": 128000, "max_output_tokens": 16384, "input_cost_per_token": 2.5e-06, "cache_creation_input_token_cost": null, "cache_read_input_token_cost": 1.25e-06, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": 1.25e-06, "output_cost_per_token_batches": 5e-06, "output_cost_per_token": 1e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_reasoning_token": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "openai", "mode": "chat", "supports_system_messages": true, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": false, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": false, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": true, "supports_reasoning": false, "search_context_cost_per_query": {"search_context_size_low": 0.03, "search_context_size_medium": 0.035, "search_context_size_high": 0.05}, "tpm": null, "rpm": null, "supported_openai_params": ["frequency_penalty", "logit_bias", "logprobs", "top_logprobs", "max_tokens", "max_completion_tokens", "modalities", "prediction", "n", "presence_penalty", "seed", "stop", "stream", "stream_options", "temperature", "top_p", "tools", "tool_choice", "function_call", "functions", "max_retries", "extra_headers", "parallel_tool_calls", "audio", "response_format", "user"]}}, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": null}}',
"cache_key": "Cache OFF",
"spend": 0.00022500000000000002,
"total_tokens": 30,
@@ -2961,7 +2961,7 @@ class TestSpendLogsPayload:
"model": "claude-4-sonnet-20250514",
"user": "",
"team_id": "",
- "metadata": '{"applied_guardrails": [], "attempted_fallbacks": null, "original_model_group": null, "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "usage_object": {"completion_tokens": 503, "prompt_tokens": 2095, "total_tokens": 2598, "completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}, "model_map_information": {"model_map_key": "claude-4-sonnet-20250514", "model_map_value": {"key": "claude-4-sonnet-20250514", "max_tokens": 128000, "max_input_tokens": 200000, "max_output_tokens": 128000, "input_cost_per_token": 3e-06, "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": null, "output_cost_per_token_batches": null, "output_cost_per_token": 1.5e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "anthropic", "mode": "chat", "supports_system_messages": null, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": true, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": true, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": false, "supports_reasoning": true, "search_context_cost_per_query": null, "tpm": null, "rpm": null, "supported_openai_params": ["stream", "stop", "temperature", "top_p", "max_tokens", "max_completion_tokens", "tools", "tool_choice", "extra_headers", "parallel_tool_calls", "response_format", "user", "reasoning_effort", "thinking"]}}, "additional_usage_values": {"completion_tokens_details": {"accepted_prediction_tokens": null, "audio_tokens": null, "reasoning_tokens": null, "rejected_prediction_tokens": null, "text_tokens": 503, "image_tokens": null}, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0, "text_tokens": null, "image_tokens": null}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}',
+ "metadata": '{"applied_guardrails": [], "attempted_fallbacks": null, "original_model_group": null, "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "router_metadata": null, "usage_object": {"completion_tokens": 503, "prompt_tokens": 2095, "total_tokens": 2598, "completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}, "model_map_information": {"model_map_key": "claude-4-sonnet-20250514", "model_map_value": {"key": "claude-4-sonnet-20250514", "max_tokens": 128000, "max_input_tokens": 200000, "max_output_tokens": 128000, "input_cost_per_token": 3e-06, "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": null, "output_cost_per_token_batches": null, "output_cost_per_token": 1.5e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "anthropic", "mode": "chat", "supports_system_messages": null, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": true, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": true, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": false, "supports_reasoning": true, "search_context_cost_per_query": null, "tpm": null, "rpm": null, "supported_openai_params": ["stream", "stop", "temperature", "top_p", "max_tokens", "max_completion_tokens", "tools", "tool_choice", "extra_headers", "parallel_tool_calls", "response_format", "user", "reasoning_effort", "thinking"]}}, "additional_usage_values": {"completion_tokens_details": {"accepted_prediction_tokens": null, "audio_tokens": null, "reasoning_tokens": null, "rejected_prediction_tokens": null, "text_tokens": 503, "image_tokens": null}, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0, "text_tokens": null, "image_tokens": null}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}',
"cache_key": "Cache OFF",
"spend": 0.01383,
"total_tokens": 2598,
@@ -3055,7 +3055,7 @@ class TestSpendLogsPayload:
"model": "claude-4-sonnet-20250514",
"user": "",
"team_id": "",
- "metadata": '{"applied_guardrails": [], "attempted_fallbacks": 0, "original_model_group": "my-anthropic-model-group", "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "usage_object": {"completion_tokens": 503, "prompt_tokens": 2095, "total_tokens": 2598, "completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}, "model_map_information": {"model_map_key": "claude-4-sonnet-20250514", "model_map_value": {"key": "claude-4-sonnet-20250514", "max_tokens": 128000, "max_input_tokens": 200000, "max_output_tokens": 128000, "input_cost_per_token": 3e-06, "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": null, "output_cost_per_token_batches": null, "output_cost_per_token": 1.5e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "anthropic", "mode": "chat", "supports_system_messages": null, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": true, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": true, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": false, "supports_reasoning": true, "search_context_cost_per_query": null, "tpm": null, "rpm": null, "supported_openai_params": ["stream", "stop", "temperature", "top_p", "max_tokens", "max_completion_tokens", "tools", "tool_choice", "extra_headers", "parallel_tool_calls", "response_format", "user", "reasoning_effort", "thinking"]}}, "additional_usage_values": {"completion_tokens_details": {"accepted_prediction_tokens": null, "audio_tokens": null, "reasoning_tokens": null, "rejected_prediction_tokens": null, "text_tokens": 503, "image_tokens": null}, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0, "text_tokens": null, "image_tokens": null}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}',
+ "metadata": '{"applied_guardrails": [], "attempted_fallbacks": 0, "original_model_group": "my-anthropic-model-group", "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "router_metadata": null, "usage_object": {"completion_tokens": 503, "prompt_tokens": 2095, "total_tokens": 2598, "completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}, "model_map_information": {"model_map_key": "claude-4-sonnet-20250514", "model_map_value": {"key": "claude-4-sonnet-20250514", "max_tokens": 128000, "max_input_tokens": 200000, "max_output_tokens": 128000, "input_cost_per_token": 3e-06, "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": null, "output_cost_per_token_batches": null, "output_cost_per_token": 1.5e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "anthropic", "mode": "chat", "supports_system_messages": null, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": true, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": true, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": false, "supports_reasoning": true, "search_context_cost_per_query": null, "tpm": null, "rpm": null, "supported_openai_params": ["stream", "stop", "temperature", "top_p", "max_tokens", "max_completion_tokens", "tools", "tool_choice", "extra_headers", "parallel_tool_calls", "response_format", "user", "reasoning_effort", "thinking"]}}, "additional_usage_values": {"completion_tokens_details": {"accepted_prediction_tokens": null, "audio_tokens": null, "reasoning_tokens": null, "rejected_prediction_tokens": null, "text_tokens": 503, "image_tokens": null}, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0, "text_tokens": null, "image_tokens": null}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}',
"cache_key": "Cache OFF",
"spend": 0.01383,
"total_tokens": 2598,
diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py
index 5022dab32be..9e5917637a8 100644
--- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py
+++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py
@@ -2,6 +2,7 @@ import asyncio
import datetime
import json
from datetime import timezone
+from collections.abc import Mapping
from typing import Any, Final, cast
from unittest.mock import AsyncMock, MagicMock, patch
@@ -3956,3 +3957,71 @@ def test_passthrough_caching_carries_no_injection_marker():
)
metadata = json.loads(payload["metadata"])
assert metadata["litellm_gateway_injected_cache"] is None
+
+
+def _routed_call_kwargs(model_info: Mapping[str, object]) -> dict[str, object]:
+ return {
+ "model": "claude-haiku-4-5",
+ "custom_llm_provider": "azure_ai",
+ "litellm_call_id": "router-corr-123",
+ "litellm_params": {
+ "metadata": {
+ "user_api_key": "test-key",
+ "model_group": "internal-router/gpt-5.4",
+ "deployment": "azure_ai/claude-haiku-4-5",
+ "model_info": model_info,
+ }
+ },
+ }
+
+
+def test_router_metadata_stamped_for_internal_router_model_deployment():
+ """A deployment flagged model_info.internal_router_model gets a router_metadata
+ block correlating the requested model group with the selected deployment."""
+ payload = get_logging_payload(
+ kwargs=_routed_call_kwargs({"id": "mi-1", "internal_router_model": True}),
+ response_obj=litellm.ModelResponse(id="chatcmpl-router-meta", choices=[], usage=litellm.Usage()),
+ start_time=datetime.datetime.now(timezone.utc),
+ end_time=datetime.datetime.now(timezone.utc),
+ )
+ metadata = json.loads(payload["metadata"])
+ assert metadata["router_metadata"] == {
+ "requested_model": "internal-router/gpt-5.4",
+ "selected_model": "azure_ai/claude-haiku-4-5",
+ "selected_provider": "azure_ai",
+ "router_correlation_id": "router-corr-123",
+ }
+
+
+def test_router_metadata_absent_without_internal_router_model_flag():
+ payload = get_logging_payload(
+ kwargs=_routed_call_kwargs({"id": "mi-1"}),
+ response_obj=litellm.ModelResponse(id="chatcmpl-unflagged", choices=[], usage=litellm.Usage()),
+ start_time=datetime.datetime.now(timezone.utc),
+ end_time=datetime.datetime.now(timezone.utc),
+ )
+ metadata = json.loads(payload["metadata"])
+ assert metadata["router_metadata"] is None
+
+
+@pytest.mark.parametrize("bucket", ["metadata", "litellm_metadata"])
+def test_caller_forged_router_metadata_is_discarded(bucket):
+ """The raw request bucket is client-writable and _get_spend_logs_metadata projects
+ every SpendLogsMetadata key from it, so the server-derived value must overwrite
+ unconditionally or a caller could plant router provenance the router never produced."""
+ payload = get_logging_payload(
+ kwargs={
+ "model": "gpt-4o-mini",
+ "litellm_params": {
+ bucket: {
+ "user_api_key": "test-key",
+ "router_metadata": {"requested_model": "forged", "router_correlation_id": "forged-id"},
+ }
+ },
+ },
+ response_obj=litellm.ModelResponse(id="chatcmpl-forged-router-meta", choices=[], usage=litellm.Usage()),
+ start_time=datetime.datetime.now(timezone.utc),
+ end_time=datetime.datetime.now(timezone.utc),
+ )
+ metadata = json.loads(payload["metadata"])
+ assert metadata["router_metadata"] is None
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 0ddb26ba035..1ed5366dac1 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -38438,6 +38438,8 @@ export interface components {
input_cost_per_character?: number | null;
/** Input Cost Per Token */
input_cost_per_token?: number | null;
+ /** Internal Router Model */
+ internal_router_model?: boolean | null;
/** Output Cost Per Character */
output_cost_per_character?: number | null;
/** Output Cost Per Token */
From 1249f84b10e39f1ad7ddfffb0fe11069abe0d2f1 Mon Sep 17 00:00:00 2001
From: "devin-ai-integration[bot]"
<158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Mon, 31 Aug 2026 12:56:34 -0700
Subject: [PATCH 33/33] fix(vertex_ai): graft default vertex path when api_base
has a version-only path (#38986)
* fix(vertex_ai): graft default vertex path when api_base has a version-only path
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(vertex_ai): keep query and fragment placement when grafting vertex path
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(vertex_ai): merge alt=sse into existing query when streaming
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: yassin
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/llms/vertex_ai/vertex_llm_base.py | 20 +++-
.../llms/vertex_ai/test_vertex_llm_base.py | 110 ++++++++++++++++++
2 files changed, 127 insertions(+), 3 deletions(-)
diff --git a/litellm/llms/vertex_ai/vertex_llm_base.py b/litellm/llms/vertex_ai/vertex_llm_base.py
index 75098515deb..aca257dc095 100644
--- a/litellm/llms/vertex_ai/vertex_llm_base.py
+++ b/litellm/llms/vertex_ai/vertex_llm_base.py
@@ -27,6 +27,15 @@ from .common_utils import (
get_vertex_base_url,
)
+
+def _graft_default_vertex_path(api_base: str, default_url: str) -> str:
+ parsed_api_base: Final = urlparse(api_base)
+ default_segments: Final = urlparse(default_url).path.lstrip("/").split("/")
+ graft_segments: Final = default_segments[1:] if default_segments[0] in ("v1", "v1beta1") else default_segments
+ grafted_path: Final = parsed_api_base.path.rstrip("/") + "/" + "/".join(graft_segments)
+ return parsed_api_base._replace(path=grafted_path).geturl()
+
+
GOOGLE_IMPORT_ERROR_MESSAGE: Final = (
"Google Cloud SDK not found. Install it with: pip install 'litellm[google]' or pip install google-cloud-aiplatform"
)
@@ -621,8 +630,9 @@ class VertexBase:
Handles custom api_base for:
1. Gemini (Google AI Studio) - constructs /models/{model}:{endpoint}
- 2. Vertex AI with standard proxies - constructs {api_base}:{endpoint};
- if api_base has no path (bare host), grafts the default vertex URL path onto it
+ 2. Vertex AI with standard proxies - grafts the default vertex URL path onto the
+ api_base when its path is empty or only an API version (/v1, /v1beta1);
+ otherwise constructs {api_base}:{endpoint}
3. Vertex AI with PSC endpoints - constructs full path structure
{api_base}/v1/projects/{project}/locations/{location}/endpoints/{model}:{endpoint}
(only when use_psc_endpoint_format=True)
@@ -669,10 +679,14 @@ class VertexBase:
)
elif urlparse(api_base).path in ("", "/"):
url = api_base.rstrip("/") + urlparse(url).path
+ elif urlparse(api_base).path.rstrip("/") in ("/v1", "/v1beta1") and "/projects/" in urlparse(url).path:
+ url = _graft_default_vertex_path(api_base=api_base, default_url=url)
else:
url = f"{api_base}:{endpoint}"
if stream is True:
- url = url + "?alt=sse"
+ parsed_stream_url: Final = urlparse(url)
+ stream_query: Final = f"{parsed_stream_url.query}&alt=sse" if parsed_stream_url.query else "alt=sse"
+ url = parsed_stream_url._replace(query=stream_query).geturl()
return auth_header, url
def _get_token_and_url(
diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py
index 29d22e844a5..a4d67606698 100644
--- a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py
+++ b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py
@@ -982,6 +982,116 @@ class TestVertexBase:
assert result_url == f"{gateway_api_base}:embedContent"
+ def test_check_custom_proxy_vertex_api_base_with_version_path_grafts_default_path(self):
+ vertex_base = VertexBase()
+
+ _, result_url = vertex_base._check_custom_proxy(
+ api_base="https://aiplatform.googleapis.com/v1beta1",
+ custom_llm_provider="vertex_ai",
+ gemini_api_key=None,
+ endpoint="generateContent",
+ stream=None,
+ auth_header="Bearer token123",
+ url="https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-3.5-flash-lite:generateContent",
+ model="gemini-3.5-flash-lite",
+ )
+
+ assert (
+ result_url
+ == "https://aiplatform.googleapis.com/v1beta1/projects/test-project/locations/us-central1/publishers/google/models/gemini-3.5-flash-lite:generateContent"
+ )
+
+ def test_check_custom_proxy_vertex_api_base_with_version_path_trailing_slash_grafts_default_path(self):
+ vertex_base = VertexBase()
+
+ _, result_url = vertex_base._check_custom_proxy(
+ api_base="https://internal-gateway.example.com/v1/",
+ custom_llm_provider="vertex_ai",
+ gemini_api_key=None,
+ endpoint="generateContent",
+ stream=None,
+ auth_header="Bearer token123",
+ url="https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-3.5-flash-lite:generateContent",
+ model="gemini-3.5-flash-lite",
+ )
+
+ assert (
+ result_url
+ == "https://internal-gateway.example.com/v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-3.5-flash-lite:generateContent"
+ )
+
+ def test_check_custom_proxy_vertex_api_base_with_version_path_and_query_grafts_before_query(self):
+ vertex_base = VertexBase()
+
+ _, result_url = vertex_base._check_custom_proxy(
+ api_base="https://internal-gateway.example.com/v1beta1?key=abc",
+ custom_llm_provider="vertex_ai",
+ gemini_api_key=None,
+ endpoint="generateContent",
+ stream=None,
+ auth_header="Bearer token123",
+ url="https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-3.5-flash-lite:generateContent",
+ model="gemini-3.5-flash-lite",
+ )
+
+ assert (
+ result_url
+ == "https://internal-gateway.example.com/v1beta1/projects/test-project/locations/us-central1/publishers/google/models/gemini-3.5-flash-lite:generateContent?key=abc"
+ )
+
+ def test_check_custom_proxy_vertex_api_base_with_version_path_and_query_streaming_appends_alt_sse(self):
+ vertex_base = VertexBase()
+
+ _, result_url = vertex_base._check_custom_proxy(
+ api_base="https://internal-gateway.example.com/v1beta1?key=abc",
+ custom_llm_provider="vertex_ai",
+ gemini_api_key=None,
+ endpoint="streamGenerateContent",
+ stream=True,
+ auth_header="Bearer token123",
+ url="https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-3.5-flash-lite:streamGenerateContent",
+ model="gemini-3.5-flash-lite",
+ )
+
+ assert (
+ result_url
+ == "https://internal-gateway.example.com/v1beta1/projects/test-project/locations/us-central1/publishers/google/models/gemini-3.5-flash-lite:streamGenerateContent?key=abc&alt=sse"
+ )
+
+ def test_check_custom_proxy_vertex_api_base_with_non_version_path_keeps_endpoint_append(self):
+ vertex_base = VertexBase()
+ gateway_api_base = "https://gateway.example.com/vertex-proxy"
+
+ _, result_url = vertex_base._check_custom_proxy(
+ api_base=gateway_api_base,
+ custom_llm_provider="vertex_ai",
+ gemini_api_key=None,
+ endpoint="generateContent",
+ stream=None,
+ auth_header="Bearer token123",
+ url="https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-3.5-flash-lite:generateContent",
+ model="gemini-3.5-flash-lite",
+ )
+
+ assert result_url == f"{gateway_api_base}:generateContent"
+
+ def test_check_custom_proxy_vertex_api_base_without_projects_in_default_url_keeps_endpoint_append(self):
+ vertex_base = VertexBase()
+ gemma_api_base = "https://example.com/custom/gemma-deployment"
+
+ _, result_url = vertex_base._check_custom_proxy(
+ api_base=gemma_api_base,
+ custom_llm_provider="vertex_ai",
+ gemini_api_key=None,
+ endpoint="predict",
+ stream=False,
+ auth_header=None,
+ url=gemma_api_base,
+ model="gemma-3-27b-it",
+ )
+
+ assert result_url == f"{gemma_api_base}:predict"
+
def test_check_custom_proxy_vertex_bare_host_streaming_keeps_single_alt_sse(self):
vertex_base = VertexBase()